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.
An expression is a byte string with a length prefix. The bytes are opcodes, and they run on a stack:
DW_OP_reg0 through DW_OP_reg31 are single bytes, one per machine register. This is how an optimised build says "this variable lives in a register" — and it is the normal case at -O2.DW_OP_addr followed by a machine address, DW_OP_const1u and friends, DW_OP_litN for small values.DW_OP_bregN pushes register N's value plus a signed offset; DW_OP_fbreg pushes the frame base plus a signed offset.DW_OP_plus pops two and pushes the sum, DW_OP_deref pops an address and pushes what it points at.DW_OP_stack_value opcode changes the meaning: the value left on the stack is the variable, not its address.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 reference binary's make_point, every location it records, read out of .debug_info and confirmed against both reference readers:
| DIE | Attribute | Bytes | Meaning |
|---|---|---|---|
<0x15c> subprogram | DW_AT_frame_base | 9c | DW_OP_call_frame_cfa — the frame base is the CFA |
<0x17d> parameter x | DW_AT_location | 91 5c | DW_OP_fbreg with SLEB128 operand 0x5c = -36 |
<0x189> parameter y | DW_AT_location | 91 58 | DW_OP_fbreg + SLEB128 0x58 = -40 |
<0x195> variable out | DW_AT_location | 91 68 | DW_OP_fbreg + SLEB128 0x68 = -24 |
Three details that are easy to get wrong and produce no error at all:
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.rsp + 8 on entry (see the next concept). Parameters were pushed before the call, so they sit at negative offsets from it.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.
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.
Evaluating 91 5c by hand, in full, with no tool involved:
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.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.frame_base - 36.DW_OP_stack_value appeared, so that value is the address of x.x's DW_AT_type is <0x5d> = int.Now the general evaluation loop, which is all a debugger needs:
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.
$ readelf --debug-dump=info shape | grep -E 'DW_AT_(location|frame_base)'
$ readelf --debug-dump=frames shape | head -12Experiments that make the stack machine concrete:
-O0 build, where every expression is fbreg and every operand is negative. If your SLEB128 decoder is unsigned you will get offsets of 88, 92 and 104, and every one of them will be in the caller's frame.-O0 and -O2 and diff the locations. At -O0: three fbreg expressions. At -O2: the parameters are often DW_OP_regN — one byte, opcode only, no operand — because the value is in a register. The form of the answer changed, and the DIE still exists.DW_OP_stack_value. It appears in the optimised build for values the compiler has constant-folded into a register, where the register holds the value itself rather than a pointer to it. It is the one opcode that changes the meaning of the whole expression, and getting it wrong means printing an address instead of the value.DW_AT_frame_base holds 9c = DW_OP_call_frame_cfa, which is a location machine opcode. DW_AT_location holds 91 = DW_OP_fbreg, an expression machine opcode. Evaluated with the wrong table you get a wrong answer, not an error.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.
Without looking back: a parameter's DW_AT_location is the two bytes 91 5c. Decode the operand and say what the expression computes.
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.
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.