Back to course

Location Expressions

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

Why This Matters

A global variable has an address. A local variable does not: it lives at an offset from the stack pointer, and the stack pointer moves every time anything is pushed. So the number rsp - 24 is not an address — it is an instruction for computing one, valid only for one function on one particular stack state.

DWARF stores that instruction. A location expression is a tiny stack machine program, and evaluating it is how a debugger answers "where is this variable right now". Every value you have ever seen a debugger print for a local went through one of these.

A Simple Model

An expression is a byte string with a length prefix. The bytes are opcodes, and they run on a stack:

Which stack machine? There are two, and they share opcode numbering in some ranges. The one used by DW_AT_location as an exprloc is the expression machine, whose DW_OP_fbreg is 0x91. The one used by DW_AT_frame_base is the location machine, where the same idea is DW_OP_call_frame_cfa = 0x9c. Reading a frame base with the expression machine gives you a frame-base-relative offset; reading a location with the location machine gives you something else entirely. Neither error raises anything.

The Actual Detail

The reference binary's make_point, every location it records, read out of .debug_info and confirmed against both reference readers:

DIEAttributeBytesMeaning
<0x15c> subprogramDW_AT_frame_base9cDW_OP_call_frame_cfa — the frame base is the CFA
<0x17d> parameter xDW_AT_location91 5cDW_OP_fbreg with SLEB128 operand 0x5c = -36
<0x189> parameter yDW_AT_location91 58DW_OP_fbreg + SLEB128 0x58 = -40
<0x195> variable outDW_AT_location91 68DW_OP_fbreg + SLEB128 0x68 = -24

Three details that are easy to get wrong and produce no error at all:

  1. The operand is SLEB128, signed.0x5c is not 92. As a signed LEB128 byte it is 0x5c - 0x80 = -36, because the high bit is the sign flag. Reading these as unsigned gives offsets of 92, 88 and 104 bytes, which are in the caller's frame and will look like plausible garbage.
  2. Negative offsets are the normal case for parameters. The frame base is the canonical frame address, which the CFI establishes as rsp + 8 on entry (see the next concept). Parameters were pushed before the call, so they sit at negative offsets from it.
  3. Two bytes is the floor. The length prefix is a ULEB128, so 2 byte block: 91 5c means one opcode plus a one-byte operand. There is no fixed-size location field anywhere in the format.

What the frame base is comes from the CIE, and the two halves of that belong to different concepts. From the reference binary's single CIE:

DW_CFA_def_cfa  reg=7, offset=8      "the CFA is rsp + 8"
DW_CFA_offset  reg=16, factored=-8 bytes   "the return address is saved at CFA-8"

So DW_OP_call_frame_cfa evaluates to rsp + 8 at function entry, and parameter x is at rsp + 8 - 36 = rsp - 28. That is where a debugger actually reads it, and the arithmetic is the join between this concept and the frames concept.

The one-opcode case

At -O0 almost every location is a fbreg, because nothing has been optimised. At -O2 the common case is a single byte. From the inline.c build:

<2><...>  DW_AT_location : 91 00      DW_OP_fbreg 0        (offset zero)
<2><...>  DW_AT_location : 91 18      DW_OP_fbreg +24

And from the same build, the first inlined instance's parameter:

DW_AT_location : 2 byte block: 91 0    (DW_OP_fbreg: 0)

A one-byte operand of 0x00 is zero, not 128. Signed LEB128 encodes zero as a single 0x00 byte, and this is the shortest possible fbreg expression. It is also the easiest to get wrong, because a reader that treats the operand as unsigned reads 128 and puts the variable in the caller's frame.

A Real Example

Evaluating 91 5c by hand, in full, with no tool involved:

  1. Read opcode 0x91. That is DW_OP_fbreg: pop a SLEB128 operand, then push frame base + operand. The DW_OP_bregN and DW_OP_regN opcodes read a register; fbreg reads the frame base, which is itself whatever the function's DW_AT_frame_base expression evaluates to.
  2. Read the operand byte 0x5c. Its high bit is set, so this SLEB128 value continues — and the next byte would be needed. There is no next byte, so the value is complete and the sign bit is bit 6 of 0x5c, which is set: 0x5c is a negative number. Decoding a single-byte SLEB128: bit 6 set means negative, and the magnitude is 0x5c & 0x3f = 28, so the value is 28 - 64 = -36.
  3. So the stack now holds one value: frame_base - 36.
  4. The expression has ended with exactly one value on the stack, and no DW_OP_stack_value appeared, so that value is the address of x.
  5. Read 4 bytes there and interpret them as a signed 32-bit integer, because x's DW_AT_type is <0x5d> = int.

Now the general evaluation loop, which is all a debugger needs:

stack = [] for each opcode in the expression: if it pushes a register: stack.push(read_register(n)) if it pushes a constant: stack.push(constant) if it is bregN / fbreg: stack.push(base + signed_leb128()) if it is plus/minus/etc: stack.push(combine(stack.pop(), stack.pop())) if it is deref: stack.push(read_memory(stack.pop())) if it is stack_value: the remaining value IS the variable address = stack.pop() (if no stack_value was seen)

And the two ways this goes wrong without an error. A reader that treats the SLEB128 operand as unsigned gets -36 as 92. A reader that forgets that fbreg is relative to the frame base rather than to rsp gets every location off by 8 on entry, and off by a different amount after every push. Both produce values that are the right size and entirely wrong, which is why the CFI in the next concept is not optional background — it is the other half of every location.

Try It Yourself

$ readelf --debug-dump=info shape | grep -E 'DW_AT_(location|frame_base)'
$ readelf --debug-dump=frames shape | head -12

Experiments that make the stack machine concrete:

And a check on your own arithmetic: the three parameters of make_point are at CFA-36, CFA-40 and CFA-44, and the CIE says the return address is at CFA-8. A 48-byte frame with the return address at the top and the arguments just below it is the shape you should expect for three four-byte arguments on x86-64. If your numbers do not produce that shape, one of the three decoders is wrong.

Check Your Understanding

Without looking back: a parameter's DW_AT_location is the two bytes 91 5c. Decode the operand and say what the expression computes.

Apply It

Your debugger is correct on the -O0 build and prints garbage for every local in the -O2 build. It still finds the right variables and the right types — only the values are wrong, and they are wrong in a way that looks like a plausible pointer. Your evaluator handles DW_OP_fbreg, DW_OP_regN and DW_OP_addr. What is it almost certainly missing, and what symptom would distinguish it from a plain -O2 layout surprise?

The general habit: a value that decodes cleanly but is consistently wrong across a whole category of variable usually means a missing semantic, not a missing instruction. Check what the expression means before you check whether you can parse it.

Connect

The frame base is the CFA, and the CFA is defined by DW_CFA_def_cfa in the unwind section. That is the next concept, and the two are genuinely one mechanism split across two files: DW_AT_frame_base says which rule to apply, and the CFI says what the rule currently is. A location expression that says fbreg -36 is unusable without consulting the CFI, because the same expression means different addresses at different points in the function.

DWARF's little stack machine is the same shape as the bytecodes you met in ELF's note sections and in the PE base relocation concept — a compact, position-independent instruction sequence evaluated by whoever needs the answer. The COFF course's REL32 relocation is the same instinct at the machine-code level: store the displacement, not the address, so the value survives being moved.

One concept remains before the module closes: the unwind information itself, and the other way DWARF solves the "no addresses yet" problem.

Next: Call Frame Information.