Two facts make stack unwinding possible at all: the stack grows downward, so a return address pushed by a call is at a known place relative to the frame; and every function that might be unwound through saves the registers it clobbered before it clobbered them. Call frame information records the second fact as a set of rules, and the CIE records the rules that are true everywhere.
It is also the answer to a question the previous concept left open. A location expression said DW_OP_fbreg -36 — relative to the frame base — and did not say where the frame base is. That is here, in DW_CFA_def_cfa. The two sections are one mechanism split across two files, and a location expression with no CFI beside it is unusable.
The section is a sequence of records, and they come in exactly two shapes which are not the same shape:
| Record | Contains | Says |
|---|---|---|
| CIE (Common Information Entry) | length, CIE_id = 0, version, augmentation string, the alignment factors and return-address register, any augmentation data, then initial instructions | the rules that hold for every function that uses this CIE |
| FDE (Frame Description Entry) | length, CIE_pointer, initial_location, address_range, then instructions | which CIE's rules apply, over which address range, and what changes inside it |
An FDE has no version and no augmentation string. After the CIE pointer come exactly two encoded values and then the instructions. Reading a version byte for an FDE eats the first byte of the address and turns everything after it into noise — silently, because the instruction decoder will happily chew through whatever bytes it is given.
The instructions themselves are a second little stack machine, but a much simpler one. It has no stack: each opcode modifies a rule table, and the table is read at whatever program counter you are unwinding to.
DW_CFA_def_cfa reg, offset — the important one. The Canonical Frame Address is reg + offset. This single rule is what a location expression’s frame base resolves to.DW_CFA_offset reg, factored — register reg was saved at CFA + factored × data_alignment_factor.DW_CFA_def_cfa_offset n — change the CFA offset, keeping the register. This is what a push looks like: rsp moves, so the distance from rsp to the CFA shrinks by 8.DW_CFA_advance_loc n — the following rules apply from n code-alignment units later. This is how a table stays compact: a function of forty instructions needs two rules, not forty.DW_CFA_def_cfa_expression — the CFA is given by a DWARF expression instead of register-plus-offset, for frames the compiler cannot describe simply.DW_CFA_restore reg — revert that register to the CIE’s initial rule. This is what makes the advance-and-restore pattern work without listing every register twice.data_alignment_factor is -8 in the reference binary, and that is not a quirk. Register-save locations are described as negative displacements from the CFA, because that is the direction the stack grows. A positive factor would mean offsets below the frame, which is where the CFA is measured from in the first place. A reader that takes the absolute value of the factor produces save locations in the caller’s frame.The reference binary has exactly one CIE, at offset 0, twenty bytes long. Here it is in full, decoded byte by byte and matched against readelf:
0000: 14 00 00 00 00 00 00 00 01 7a 52 00 01 78 10 01
|——length 20——| |—CIE_id 0-—| |v|—aug—| | | | | |
"zR" | | | | |
| | | +— 1b: fde_encoding
| | +— 10: return_address_register = 16
| +— 78: data_alignment_factor = -8 (SLEB)
+— 01: code_alignment_factor = 1
0010: 1b 0c 07 08
|—| |———————— initial instructions
| +— 0c = DW_CFA_def_cfa reg=7 (rsp) offset=8
+— 1b = augmentation data length 1, then the R encoding 0x1b
07 = DW_CFA_undefined reg=16
08 = DW_CFA_same_value reg=0
(then padding nops to fill length 20)In words, which is what the concept is actually about:
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 at CFA - 8
DW_CFA_undefined reg=16
DW_CFA_same_value reg=0rsp + 8 on entry is the whole point. The return address sits at CFA-8 because call pushed it and then the push rbp (or the ABI’s equivalent) moved rsp down by another 8. So the CFA is a fixed landmark that does not move as the function pushes and pops, and every saved register and every local is described relative to it.
Now an FDE that does more than the default. The FDE covering 0x1020..0x1040:
DW_CFA_def_cfa_offset 16 -> CFA is now rsp + 16 DW_CFA_advance_loc 6 -> from here on, 6 bytes later DW_CFA_def_cfa_offset 24 -> CFA is rsp + 24 DW_CFA_advance_loc 10 DW_CFA_def_cfa_expression ... -> CFA is computed by a DWARF expression (then nops)
That is a real function's unwind table: three pushes, each one def_cfa_offset rather than a full redefinition, and then a frame the compiler cannot describe as register-plus-offset so it hands over a twelve-byte expression. The compactness is the design — a forty-instruction function needs three rules, not forty.
The reference binary has eight FDEs:
One function's information is split across three adjacent FDEs (0x1149..0x117e, 0x117e..0x11a9, 0x11a9..0x11c9). "One FDE per function" is a reasonable first guess and it is wrong often enough to matter: any tool that assumes it will mis-handle a function whose unwind rules change mid-body, and there is no marker saying so.
.eh_frame is relative to its own field position. Both FDEs in this binary store a value equal to the offset of the pointer field itself: the FDE at 0x18 stores 0x1c, and the FDE at 0x30 stores 0x34. The CIE is at 0. Read the field as an absolute offset and you look for a CIE at 0x1c and 0x34 and find nothing. In .debug_frame the same field is absolute — the two sections disagree, and a reader written for one will silently fail on the other.0x0b is sdata4, four bytes, not eight. The CIE sets fde_encoding = 0x1b = DW_EH_PE_pcrel | DW_EH_PE_sdata4. Reading the low nibble as an eight-byte value swallows the following four bytes, and the FDE’s end address comes out as 0x10076478 instead of 0x1086. Every instruction after that point is then decoded from the wrong position.pcrel applies to the location, not to the range, and needs the section’s virtual address.initial_location is a displacement from the address of its own field, where “address” means the runtime address — .eh_frame is loaded at 0x2050, not at its file offset. address_range is a length: applying pcrel to it as well gave 0x20ac where the real end of the range is 0x1040.All three were found because an independently written decoder disagreed with readelf. All eight FDE ranges now agree with it exactly.
Unwinding one frame, by hand, using only the tables above. Say the program counter is at 0x1180, inside the function whose FDEs are 0x117e..0x11a9 and 0x11a9..0x11c9.
0x117e..0x11a9 — and note that it is not the first of the three, so a reader assuming one FDE per function has already gone wrong.y in the equivalent -O0 build has DW_AT_location : 91 58 = DW_OP_fbreg -40. Its DW_AT_frame_base is 9c = DW_OP_call_frame_cfa, so the address is CFA - 40 = rsp + 8 - 40 = rsp - 32.rsp. Unwinding means loading that value, restoring the caller’s register state from the caller’s CFI, and doing it again — which is how a debugger walks a backtrace without executing anything.$ readelf --debug-dump=frames shape
$ readelf -wf shape # -w wide, -f frames
$ objdump --dwarf=frames shapeExperiments that turn the tables into understanding:
readelf -wf lists the ranges; compare them with readelf --debug-dump=info’s low_pc/high_pc pairs. In the reference build, five functions produce eight FDEs. Find which function owns three of them and read the extra rules — that is where the def_cfa_expression lives.make_point, note that its three parameters are at CFA-36, CFA-40 and CFA-44 and the return address at CFA-8, and confirm the frame is the shape a three-argument x86-64 call should produce. If your numbers do not line up, one of the two decoders is wrong.def_cfa_expression bytes. The twelve bytes 0c 07 08 80 00 3f 1a 39 2a 33 24 22 are a DWARF expression using the location machine. readelf prints its reading; decode it yourself with the opcodes from the previous concept and check you agree.And the check worth building into any tool: after decoding, verify that the FDE ranges are contiguous and non-overlapping across the section. Two FDEs claiming the same address, or a gap between them, means a pointer or a length is wrong — and it is much easier to catch that than to debug a wrong backtrace.
Without looking back: the CIE says DW_CFA_def_cfa reg=7 offset=8 and then an FDE says DW_CFA_def_cfa_offset 16. What has happened to the CFA, and what instruction in the machine code caused it?
Your unwinder produces a correct backtrace for every function in the binary except one, where it crashes or reports a frame pointer that looks random. The binary has one CIE and eight FDEs. You have read the CIE correctly. What is the most likely cause, and what is the cheapest way to confirm it?
The general habit: a failure that is local to one input is almost never a bug in the shared table. When a format gives you one common record and many per-input records, suspect the per-input ones first — and check the assumption you made about how many there should be.
Frame information is the other half of the PE exception handling story: .pdata entries, the IMAGE_SCN_CNT_CODE sections, and the .xdata unwind table you decoded byte-exactly in the COFF relocations concept are the Windows mechanism for the same job. The COFF course's ADDR32NB relocations pointed at .pdata and .xdata for exactly this reason: data that references code has to survive the image being placed.
Register-save rules and the NX and DEP story in the PE course are two answers to the same question — what may execute and what may be written. The COFF course's section characteristics decide it at the section level; the CFI decides it per instruction range, which is finer and is why a function that builds its own stack frame needs more rules.
The CFI table is also the cleanest example in either course of describe the delta, not the state — the same principle as a REL32 relocation storing a displacement, or a line program storing advances. One more mechanism, then: what happens when the tree gets too big to ship in one file.
Next: Split DWARF.