Back to course

Scopes and Inlining

21 minadvancedModule 3: DIEs, Types, Scopes, Locations and FramesContinues from dwarf-types
21 min · Module 3: DIEs, Types and Scopes · Structures

Why This Matters

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.

A Simple Model

Three tags carry scope, and each answers a different question:

TagAnswersTypical attributes
DW_TAG_subprogramwhich functionname, low_pc, high_pc, type (the return type), frame_base
DW_TAG_formal_parameterwhich argumentname, type, location
DW_TAG_variablewhich local or globalname, type, location
DW_TAG_lexical_blockwhich inner scopeusually just low_pc/high_pc or a range
DW_TAG_inlined_subroutinewhich inlined copyabstract_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.

The Actual Detail

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.

What inlining does to the tree

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      12

The 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.

A Real Example

The question a debugger actually asks: the program counter is at 0x1180 inside work. Which variables are live, and what are they?

  1. Find the subprogram containing 0x1180. Walk the compile unit's children, and for each 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.
  2. Now walk the children of that subprogram. Its parameters, then its lexical blocks, then any DW_TAG_inlined_subroutine children. Everything at depth 2 and below is in scope.
  3. For each inlined instance, test its address range too. If 0x1180 falls inside one, then the parameters and locals of the inlined function are also live — and they are reached through the instance's DW_AT_abstract_origin, not by looking for a DW_TAG_subprogram named dot.
  4. Read each variable's 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.

Why the split exists at all. The description DIE and the instance DIE answer different questions and have genuinely different contents. The description knows the parameter names and types and the declaration line; it cannot know an address. The instance knows an address range and a call site; it knows nothing about the parameters, which is why it must point at the description to get them. One DIE could not carry both without either duplicating the parameter list three times or inventing an address that does not exist.

Try It Yourself

$ 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_subroutine

Four experiments, in increasing order of how much they teach:

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.

Check Your Understanding

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?

Apply It

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.

Connect

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.