A name means different things in different places. x inside a loop, x as a function parameter, and a global x are three unrelated variables that happen to share four characters. A debugger has to pick the right one from a program counter and nothing else — no source file, no line number, no variable name in hand.
The mechanism is the simplest thing in DWARF: nesting in the DIE tree is scope. A variable DIE that is a child of a subprogram belongs to that function. One nested deeper, inside a lexical block, belongs to that block. And when the compiler inlines a function, the caller's tree grows a child that has no source-level counterpart at all — which is the second half of this concept.
Three tags carry scope, and each answers a different question:
| Tag | Answers | Typical attributes |
|---|---|---|
DW_TAG_subprogram | which function | name, low_pc, high_pc, type (the return type), frame_base |
DW_TAG_formal_parameter | which argument | name, type, location |
DW_TAG_variable | which local or global | name, type, location |
DW_TAG_lexical_block | which inner scope | usually just low_pc/high_pc or a range |
DW_TAG_inlined_subroutine | which inlined copy | abstract_origin, call_file, call_line, low_pc/high_pcorranges |
Three further points do most of the work in practice.
A name is not always stored. When a compiler can prove a name is obvious from context it may leave it out entirely. The parameter DIEs for make_point in the reference binary carry the names x and y inline, with no string-table lookup at all — DW_FORM_string rather than strp. A reader must handle both, and must not assume a name is present.
Globals and locals are the same tag. A DW_TAG_variable that is a child of the compile unit is a global; a child of a subprogram is a local. The tree position is the distinction, and it means static file-scope variables appear as compile-unit children even though they are not visible outside the file.
abstract_origin is the whole trick behind inlining. When the compiler inlines a function it needs to describe two different things: the function as written (its parameters, its locals, its line numbers — no addresses at all) and each place it was inlined into (an address range and a call site). DWARF splits them into two DIEs. The instance points back at the description with DW_AT_abstract_origin. Without that indirection the parameter list would be duplicated once per call site, and a debugger stepping through would see the same three parameters listed nine times.First, ordinary scope at -O0. The function make_point in the reference binary, at .debug_info offset 0x15c:
<1><15c> DW_TAG_subprogram
DW_AT_external 1 (flag_present: no bytes in .debug_info)
DW_AT_name make_point
DW_AT_type <0x96> (returns struct Point)
DW_AT_low_pc 0x11a9
DW_AT_high_pc 0x20
DW_AT_frame_base 1 byte block: 9c (DW_OP_call_frame_cfa)
DW_AT_call_all_calls 1
DW_AT_sibling <0x1a5>
<2><17d> DW_TAG_formal_parameter
DW_AT_name x
DW_AT_type <0x5d> (int)
DW_AT_location 2 byte block: 91 5c (DW_OP_fbreg -36)
<2><189> DW_TAG_formal_parameter
DW_AT_name y
DW_AT_type <0x5d>
DW_AT_location 2 byte block: 91 58 (DW_OP_fbreg -40)
<2><195> DW_TAG_variable
DW_AT_name out
DW_AT_type <0x96> (struct Point)
DW_AT_location 2 byte block: 91 68 (DW_OP_fbreg -24)
<2><1a4> 00 (closes the function)Three things to notice. The parameters and the local are all at depth 2, children of the subprogram — there is no DW_TAG_lexical_block here because the function body is one scope. The names x, y and out are stored inline in two or three bytes each, not as string-table offsets. And every location is a two-byte expression, which is about as compact as a location can be.
The offsets are negative and they are not in declaration order. x is at frame-base minus 36 and y at minus 40, so y is stored lower in the frame than x. That is not a mistake — it is the x86-64 calling convention, where arguments are pushed right to left, and it is why a location expression is a displacement from a frame base rather than a fixed address.
Now -O2, with a static function called from three places. inline.c:
struct Vec { double x, y, z; };
static double dot(struct Vec a, struct Vec b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
double magnitude2(struct Vec v) { return dot(v, v); }
double work(struct Vec a, struct Vec b) {
return dot(a, b) + dot(b, a) + dot(a, a);
}Compiled at -O2, that produces threeDW_TAG_inlined_subroutine DIEs. Two of them:
<2><ea> DW_TAG_inlined_subroutine
DW_AT_abstract_origin <0x127> (the subprogram DIE for dot)
DW_AT_entry_pc 0x16
DW_AT_low_pc 0x16
DW_AT_high_pc 0x14
DW_AT_call_file 1
DW_AT_call_line 6
DW_AT_call_column 12
<2><172> DW_TAG_inlined_subroutine
DW_AT_abstract_origin <0x127> (the SAME subprogram DIE)
DW_AT_entry_pc 0x52
DW_AT_ranges 0xc (not low_pc/high_pc!)
DW_AT_call_file 1
DW_AT_call_line 9
DW_AT_call_column 12The pair of attributes that answers "where was this called from" is DW_AT_call_file plus DW_AT_call_line plus DW_AT_call_column — the call site, which for the second instance is line 9. And that instance uses DW_AT_ranges instead of a low_pc/high_pc pair, because the compiler did not emit the inlined body as one contiguous run of instructions. A consumer that assumes a single range will miss part of the function.
The DW_AT_abstract_origin is <0x127> in both cases: one description of dot, shared by all three instances. The description DIE is a DW_TAG_subprogram with parameters, locals and line numbers but nolow_pc and nohigh_pc — because the function as written has no single address range. It exists three times in the machine code and nowhere in particular.
The question a debugger actually asks: the program counter is at 0x1180 inside work. Which variables are live, and what are they?
DW_TAG_subprogram test whether low_pc ≤ pc < low_pc + high_pc. Remember that high_pc is a length, so the test is an addition, not a comparison. The match is work.DW_TAG_inlined_subroutine children. Everything at depth 2 and below is in scope.DW_AT_abstract_origin, not by looking for a DW_TAG_subprogram named dot.DW_AT_location to get its address. This is where the previous concept's machinery and the next one's meet: a location is an expression, not a number.Step 3 is the part that is easy to skip and expensive to skip. Without it, stepping into a heavily inlined function shows you two parameters where there should be five, and the locals of the inlined body are simply absent. With it, the debugger reconstructs the source-level stack frame that the optimiser actually removed.
$ readelf --debug-dump=info shape | grep -A12 'DW_TAG_subprogram'
$ gcc -gdwarf-5 -O2 -c inline.c -o inline_O2.o
$ readelf --debug-dump=info inline_O2.o | grep -B2 -A9 DW_TAG_inlined_subroutineFour experiments, in increasing order of how much they teach:
sum_table in braces and declare a variable inside. Recompile at -O0: a DW_TAG_lexical_block appears at depth 2 with the variable at depth 3, and the variable DIE now has a DW_AT_ranges instead of a plain location, because its lifetime is narrower than the function's.-O2 loses the local variable DIE entirely, and the sum_table subprogram may vanish too if it was inlined. The machine code is still correct; the description is simply no longer emitted because nothing needs it.inline.c calls dot four times but inlines it three times — the one inside magnitude2 is folded away by the optimiser. Compare the DW_AT_call_line values against the source and check they land on the right lines.DW_AT_abstract_origin pointing at the same offset. Open <0x127> in the dump and verify it has parameters and a decl_line but no low_pc. That asymmetry is the entire design.And the check that catches scope bugs in your own reader: for the -O0 build, the number of DIEs at depth 2 or greater should be stable across small edits to the function bodies. A sudden drop usually means you are treating a 0 terminator as a DIE.
Without looking back: a DW_TAG_inlined_subroutine DIE has DW_AT_abstract_origin, DW_AT_call_line and DW_AT_ranges, and no low_pc/high_pc pair. What does each of those three attributes give a debugger, and what happens if a reader assumes the instance is one contiguous address range?
A colleague's debugger shows the correct parameter list at the start of an -O2 build of work, but two of the three parameters disappear partway through, and stepping into dot shows no locals at all. Nothing crashes; the values are simply absent from the frame view. What is the single most likely cause, and what should you check to confirm it?
The general habit: when a debugger's output is correct at first and degrades later, suspect the address arithmetic, not the data. A missing record drops everything uniformly; a range that stops covering the code drops things progressively.
Scope nesting in DWARF is the same idea as block structure in the ELF symbol table, where a local symbol's binding and visibility say who may see it — and DWARF's tree makes that hierarchy explicit rather than leaving it to be inferred. The COFF course's storage class covers the other half of the same question: which symbols exist outside the compilation unit at all.
DW_AT_abstract_origin is a reference used for deduplication, and the COFF course covers the other great deduplication mechanism in the toolchain: COMDAT. Both exist because the same code gets emitted more than once, and both solve it by pointing at one authoritative copy rather than repeating it. Split DWARF, the next-but-one concept, is a third instance of the same instinct at the level of whole files.
Scopes tell you which variables are live. The next concept is how a live variable is turned into an address, because for a local that address changes on every call.
Next: Location Expressions.