Developer Tools · Debuggers
Debugging Optimised Code: Location Lists and Inlined Frames
A variable at -O2 is a rule that changes with the program counter, and an inlined call is a frame that exists only in the debug information. Where both come from.
In unoptimised code a local variable has an address: a slot in the frame, fixed for the whole function, computed as the frame base minus an offset. That model is so convenient that debuggers were built around it, and it is false for every function the compiler optimised. At -O2 a variable lives in a register, then in a stack slot after it is spilled, then nowhere at all after its last use. The debug information does not record one location; it records a rule that changes as the program counter advances, and a debugger that wants to print the variable has to find the rule that covers the current address.
A variable is a rule, not an address
The description lives in DW_AT_location on the variable’s debugging entry. For a simple case it is a short expression: push the frame base, add a constant offset, and the result is the address of the value. For a register it names the register directly. For a constant the compiler folded, it carries the value itself and no address at all.
The frame base is itself a rule, given by DW_AT_frame_base on the enclosing subprogram, and it is frequently expressed as the canonical frame address, which is the same value the unwinder computes as described in stack unwinding and call frame information. That dependency is why printing a local in optimised code can require the unwinder to have succeeded: the debugger needs the CFA to evaluate the frame-relative part of the location, and if the unwind information is missing or the frame is a signal frame, the variable cannot be located even though its rule is present.
A variable that is split across storage is described by a sequence of pieces. Part of a struct may be in two registers and the rest in memory, and the location expression says which bytes come from where. Debuggers handle this by assembling the value piece by piece; a debugger that reads only the first location description prints a truncated or nonsensical value.
Location lists change with the program counter
When a variable moves, DW_AT_location points into a list rather than holding a single expression. Each entry has an address range and a location description valid within it. DWARF 5 replaced the older section with a more compact encoding, but the model is the same: a sorted list of ranges, each with its own answer, and gaps between the ranges where the variable has no location.
DW_AT_location (list at 0x0000002a):
0x00401000 .. 0x00401012 DW_OP_reg3 # live in rbx
0x00401012 .. 0x00401028 DW_OP_fbreg -24 # spilled to the frame
0x00401028 .. 0x00401031 <no entry> # dead after last use
The gaps are the interesting part. A variable whose last use precedes the end of the function has no location afterwards, because the compiler is free to reuse the register or the stack slot. When a debugger reports that a variable has been optimised out, it usually means that the current address falls in a gap, not that the debug information is absent. The same variable may be perfectly readable a few instructions earlier.
Producing accurate lists is not automatic. Tracking a value through register allocation, spilling, copy propagation, and block reordering requires a dedicated analysis, and compilers expose it as an option because it costs compile time and can inhibit some optimisations. GCC’s variable tracking pass exists for this purpose and can be disabled, with the result that locations become coarser and more variables appear unavailable.
The other consequence is that the same source line can have different answers at different addresses. A conditional breakpoint on a variable that is in a register at the top of the loop and in memory at the bottom evaluates correctly in one place and fails in the other. This is a property of the location list, not of the condition, and it is why a condition that works when set on one line fails when the compiler moves the code.
Frames that exist only in the debug information
Inlining removes a call, so it removes a frame. The callee’s instructions are copied into the caller, its parameters become the caller’s values, and its locals share the caller’s registers and stack slots. Nothing in the machine state records that a call ever happened.
The debug information reconstructs it. Each inlined copy is described by a DW_TAG_inlined_subroutine entry with an address range, a reference to the abstract description of the function, and the call site recorded as three attributes naming the file, line, and column of the call that was inlined. The abstract description holds the parameters and locals without locations, because there is no single instance for them to describe. Each concrete instance supplies its own location rules, valid only within its range.
This is what lets a debugger print a backtrace containing frames that have no stack slots. The physical unwind produces the real callers; the inlined entries are inserted between them by looking up the address in the range tree and walking the nesting of inlined subroutines that contain it. A function inlined into a function that was itself inlined produces a chain of these entries, and the logical depth of the trace can be much greater than the physical depth.
Stepping follows the same structure. Entering an inlined function does not change the program counter to a new function; it narrows the current range to the inlined subroutine’s range, and the line table supplies the source positions inside it. Leaving it widens the range again. A debugger that steps by address alone walks straight past the inlined call, which is why stepping behaviour differs between a debug build and an optimised one even when the source is identical.
Breakpoints inherit the multiplicity. A breakpoint on a function that has been inlined into five call sites has five locations, and a breakpoint on a line inside it may resolve to several addresses or to none. The debugger sets all of them and reports a single breakpoint with multiple locations; hitting any one of them stops the program, and the frame shown is the inlined one.
static inline int clamp(int v, int lo, int hi)
{
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
int scale(int v) { return clamp(v * 2, 0, 100); }
Compiled with optimisation, clamp has no symbol of its own and no frame. Its three comparisons exist as branches inside scale, and the parameters lo and hi exist only as the constants 0 and 100 recorded in the debug information.
What the compiler cannot preserve
Some information is destroyed rather than moved, and no amount of debug information recovers it.
A value that is constant-folded has no storage. If the optimiser proves that a variable always equals 7 at a point, it may replace every use with the constant and delete the variable; the debug information can record the constant, and a debugger can print it, but there is nothing to watch and no address at which a change could occur.
A value that is recomputed rather than stored has no location either. Common subexpression elimination and rematerialisation can leave a value available only as an expression of other live values, and unless the compiler emits a location expression that recomputes it, the debugger sees nothing. Some compilers do emit such expressions; the debugger then evaluates them, which is why printing a variable in optimised code occasionally involves arithmetic rather than a memory read.
Tail calls remove frames outright. A call in tail position reuses the caller’s frame, so the return address on the stack belongs to the caller’s caller. The physical backtrace skips the tail-calling function entirely, and the debug information has no inlined-subroutine entry to insert, because the call was not inlined but replaced by a jump. Debuggers can annotate the trace when they detect the pattern, but the frame is genuinely gone.
Reordering breaks the correspondence between source order and address order. A loop body may be split, with the exit test duplicated at the bottom and the cold path moved out of line. A breakpoint on the loop’s closing brace may land in a block that executes before the body on the first iteration. The line table describes what the compiler emitted, not what the source suggests, and every conclusion drawn from a line number in optimised code carries that caveat.
The practical upshot is that debug information for optimised code is a best-effort reconstruction, not a record. It is accurate enough to make stepping and backtraces useful, and it is not accurate enough to support reasoning that assumes a variable has one address for the whole function. Debuggers that present optimised frames with the same confidence as unoptimised ones are hiding the reconstruction; the ones that mark unavailable values and show inlined frames distinctly are exposing it.
References
- DWARF Debugging Information Format, Version 5 — DWARF Standards Committee
- LLVM Source Level Debugging with LLVM — LLVM Project
- GCC Debugging Options — GNU Project