Developer Tools · Debuggers

Single-Stepping: One Flags Bit and Its Cost per Instruction

The trap flag makes the processor raise an exception after every instruction. How debuggers use it, why stepping over a call needs a breakpoint instead, and where it does not exist.

Instruction-level stepping is the smallest unit of control a debugger has. It is also the cheapest to implement and the most expensive to use: one bit in the processor’s flags register makes the machine trap after every instruction, and the tracer must handle one stop per instruction executed. The gap between stepi and next is the gap between using that bit directly and avoiding it, and the avoidance is where most of the interesting engineering sits.

One bit, checked after retirement

x86 has a trap flag at bit 8 of the flags register, value 0x100. While it is set, the processor raises a debug exception after each instruction retires, before the next instruction begins. The exception is vector 1, not the int3 vector, and the saved instruction pointer is the address of the next instruction, because a single-step is a trap rather than a fault and nothing needs rewinding.

The debug exception handler distinguishes causes by reading the debug status register. A single-step sets a dedicated bit there, separate from the four bits that report hardware breakpoints, and Linux turns the event into SIGTRAP with a signal code that says the trap came from a step rather than from a breakpoint byte. A tracer that ignores the distinction will attribute a step to whichever breakpoint happens to be nearby.

Setting the flag is a matter of writing the flags register of a stopped thread and resuming it. PTRACE_SINGLESTEP does exactly that, and restores the flag afterwards, which is why most tracers never touch the bit directly.

#include <sys/ptrace.h>
#include <sys/user.h>

struct user_regs_struct regs;

ptrace(PTRACE_GETREGS, pid, 0, &regs);
regs.eflags |= 0x100;                  /* TF: trap after the next instruction */
ptrace(PTRACE_SETREGS, pid, 0, &regs);
ptrace(PTRACE_CONT, pid, 0, 0);        /* runs one instruction, then stops */

The flag is cleared automatically when the processor enters an exception or interrupt handler through a gate that clears it, and restored by the corresponding return. That matters for the tracer: if the stepped instruction itself faults, the handler runs with stepping disabled, and the tracee stops at the fault rather than after one instruction of the handler.

The bit is part of the thread’s saved context, so it is per-thread and survives scheduling. Two threads cannot both be stepping under one tracer unless the tracer tracks which thread it resumed with the flag set; resuming a different thread with PTRACE_CONT does not clear the flag on the first.

Stepping over a call is not stepping

stepi executes one machine instruction. step, at the source level, executes one source line, which may be dozens of instructions, and if that line contains a call, the useful behaviour is usually to run the callee to completion and stop after the call returns. Stepping into the callee instruction by instruction is both slow and, for a call into a library, useless.

The standard technique is not a processor feature at all. The debugger reads the return address from the top of the stack, places a temporary breakpoint there, and continues. When the callee returns, the temporary breakpoint fires before the caller’s next instruction executes, and the step is complete. One stop instead of thousands.

This technique has a list of exceptions that a debugger must handle, and each one is a way the temporary breakpoint fails to fire:

  • The callee does not return normally. A longjmp, an exception unwind, or a call to exit leaves the return address on the stack but never executes it. The debugger waits at a breakpoint that will never be hit, and the program appears to hang. Real debuggers detect the unwind or the exit event and clear the temporary breakpoint.
  • The callee returns to a different address than the one on the stack, because the stack was modified, or because the call was a tail call that reused the frame.
  • The return address is not executable, or is inside a region the debugger cannot set a breakpoint in.
  • A signal arrives during the call and the handler never returns to the interrupted point.

The mirror operation, nexti, steps over a single instruction that happens to be a call. It uses the same trick with the same exceptions. A stepi on a call instruction instead sets the trap flag and lands at the callee’s first instruction, which is what makes stepi predictable and next convenient.

A related case is a call through a function pointer or a virtual dispatch. The return address is still on the stack, so the technique works; what the debugger cannot do is predict the callee before the call executes, which matters for displaying the next source line.

When there is no trap flag

The trap flag is an x86 convenience, not a universal. Architectures with a debug-state register expose stepping through it: a bit that causes a debug exception after the next instruction, enabled only for the current exception level, with the debugger responsible for saving and restoring the surrounding state. Some older architectures have no single-step facility at all in their user-visible state, and debuggers on those implement stepping in software.

Software single-step is an instruction decoder plus a breakpoint engine. The debugger reads the instruction at the program counter, determines the set of addresses that could execute next, sets a temporary breakpoint at each, and continues. A direct branch has one successor. A conditional branch has two. A computed branch, which is an indirect jump or a return, has a successor that depends on a register value, which the debugger can read from the stopped context; if the value is not available, the set is unbounded and the technique fails.

The decoder must be exact about instruction length and semantics, because a wrong successor address means either a missed stop or a stop at an address that is never executed. Architectures with fixed-width instructions make this tractable; variable-width architectures make it a per-instruction table, which is one reason software single-step is mostly found on RISC targets. The technique also interacts with breakpoints already present: the temporary breakpoints are inserted into the same text, and the original bytes must be saved and restored around each step.

There is a third option, used by some hypervisors and hardware debuggers: a step that is implemented by the platform rather than the architecture, where the debugger is notified after each instruction through a channel that is not a signal. The observable behaviour is the same; the cost model is different, because the notification may be a shared-memory write rather than a context switch.

The price per instruction

Each step is at minimum one exception, one kernel entry, one wakeup of the tracer, one resume, and one return to user mode. That is on the order of microseconds even when nothing else happens, and it is paid per instruction. A loop of a million iterations stepped instruction by instruction is a million of these, and the program under test makes no forward progress between them.

The practical consequence is that stepping is a tool for short distances. Debuggers reduce the count in three ways: they step by source line rather than by instruction, so a line with thirty instructions costs one stop if no call is involved; they use temporary breakpoints for calls and for loop exits; and they set a breakpoint at the target address when the destination of a branch is known in advance, rather than stepping to it. The third trick generalises into conditional breakpoints, which replace a long step with a test evaluated at every hit.

Stepping also perturbs the program’s timing, which changes the behaviour of anything concurrent. Every instruction boundary is an opportunity for another thread to run, and the set of interleavings observed under a stepped thread is not the set that occurs at full speed. A race that reproduces under a debugger and not otherwise, or the reverse, is a consequence of this: the stepping machinery has changed the schedule. That is a property of the observation, not a defect in the program, and it is why watchpoints and logging are preferred for concurrency bugs over instruction-level stepping.

The same machinery underlies the resumption step in a breakpoint hit. After restoring the original byte at a breakpoint address, the debugger must execute exactly that one instruction and then reinsert the breakpoint. PTRACE_SINGLESTEP is the natural way to do it, and the reinsertion must happen before any other thread reaches the address, which is the race described in software breakpoints. A single-step that faults, or that is interrupted by a signal, leaves the breakpoint removed until the tracer notices; a tracer that only reinserts on a clean step will eventually leave a hole in the program’s text.

References

  1. Intel 64 and IA-32 Architectures Software Developer Manuals, Volume 3: System Programming Guide — Intel Corporation
  2. AMD64 Architecture Programmer's Manual, Volume 2: System Programming — AMD
  3. ptrace(2) — Linux manual page — Linux man-pages project