The frames concept decoded unwind information out of .eh_frame, and every claim in it was checked against that section. So a reader built from that concept has a working CFI decoder that silently assumes a .eh_frame layout — and .debug_frame is not that layout.
Both sections describe the same thing. Both hold CIEs and FDEs, both use the same DW_CFA_* opcodes, and both answer the question the frames concept set up: at this program counter, where is the canonical frame address and where did each register get saved. Almost everything you learned transfers. And that is precisely the problem, because the differences are in the fields that come first — the ones a decoder reads before it has read anything it could sanity-check against.
So this concept decodes .debug_frame from a real object, field by field, and puts the two headers side by side. The payoff is not that the section is complicated. It is that one field differs in a way that produces a number, and a plausible number, and the wrong number.
Ask gcc for one section or the other, and you get exactly one of the pair:
$ gcc -g -O0 -c t.c -o plain.o
$ readelf -S -W plain.o | grep -oE '\.eh_frame|\.debug_frame'
.eh_frame
$ gcc -g -O0 -fno-asynchronous-unwind-tables -c t.c -o frame.o
$ readelf -S -W frame.o | grep -oE '\.eh_frame|\.debug_frame'
.debug_frameOne flag, and the section is replaced rather than supplemented. That is the design: they are alternative homes for the same records, chosen by the producer, and a consumer must handle both because a binary may have been produced either way.
Why a producer would choose one over the other is not a compiler preference so much as a historical accident that hardened. .eh_frame is designed to be loadable at runtime without any debug information present — it is how an exception propagator walks a stack on a machine that has never heard of DWARF, and it is why its records are position-relative so the section can be mapped anywhere. .debug_frame is the older, simpler arrangement kept for the debug-info case, where absolute addressing is fine because the section is read by a tool that also has the symbol table. Both are specified; they are not interchangeable.
pc_begin and address_range are relative to the start of the text section in both sections. So neither of them contains a real address in a relocatable object, and both are zero-patched by relocations. That is a fact about how the FDE is anchored, not about the CIE pointer, and it is worth separating the two so you do not attribute the wrong cause to the right symptom.0000: 14 00 00 00 ff ff ff ff 01 00 01 78 10 0c 07 08 0010: 90 01 00 00 00 00 00 00 24 00 00 00 00 00 00 00 0020: 00 00 00 00 00 00 00 00 2a 00 00 00 00 00 00 00 0030: 45 0e 10 86 02 43 0d 06 61 0c 07 08 00 00 00 00 0040: 24 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0050: 50 00 00 00 00 00 00 00 45 0e 10 86 02 43 0d 06
Two records. Read the first one completely, because it is a CIE and every field in it is either identical to .eh_frame or worth pausing on.
| Bytes | Field | Value |
|---|---|---|
14 00 00 00 | length | 20, so the record occupies 24 bytes |
ff ff ff ff | CIE_id | -1 — this is what marks a CIE |
01 | version | 1 |
00 | augmentation | empty, a NUL-terminated string |
01 | code_alignment_factor | ULEB 1 |
78 | data_alignment_factor | SLEB −8 |
10 | return_address_register | 16, which is rip |
0c | initial instruction length | ULEB 12 |
07 08 90 01 ... | 12 bytes of instructions | two rules and six nops |
Check the arithmetic: 4 for the length field, plus 20 counted by it — 4 of CIE_id, 1 version, 1 augmentation, 1 code alignment, 1 data alignment, 1 return-address register, 1 instruction length, 12 instructions — gives 24 bytes, so the next record starts at 0x18. It does.
The twelve instruction bytes decode exactly as the frames concept taught, and it is worth confirming rather than assuming:
07 DW_CFA_def_cfa
08 ULEB 8
"the CFA is register 7 (rsp) plus 8"
90 DW_CFA_offset
01 ULEB 1
"the return address register 16 was saved at
CFA + (1 * -8), i.e. CFA - 8"
00 00 00 00 00 00
six DW_CFA_nops -- padding to a 4-byte boundary
Those six nops are not decoration. They are how the record is aligned to four bytes without the reader needing to know the record's length in advance, and they are why a naive "keep consuming opcodes until something looks wrong" parser is wrong: a 0x00 in an instruction stream is DW_CFA_nop, not an end marker. The length field is the end marker.
Compare the first eight bytes of a CIE in each section:
.debug_frame CIE: 14 00 00 00 ff ff ff ff CIE_id = -1 .eh_frame CIE: 10 00 00 00 00 00 00 00 CIE_id = 0
Both are four bytes that mean "this record is a CIE", and they are different numbers because the two sections came from different specifications with different conventions. A reader that checks CIE_id == 0 will classify every .debug_frame CIE as an FDE, and then try to read a CIE's instruction bytes as an FDE's initial location and address range. Those are the same bytes read with a different structure in mind, and the result is a plausible address range of eight bytes of instructions.
0018: 24 00 00 00 length = 36 001c: 00 00 00 00 CIE_pointer = 0 <-- ABSOLUTE 0020: 00 00 00 00 00 00 00 00 pc_begin = 0 0028: 2a 00 00 00 00 00 00 00 range = 0x2a = 42 0030: 45 0e 10 86 02 43 0d 06 61 10 bytes of instructions 003a: 0c 07 08 ...
Three fields, and the third is the one to notice. pc_begin is 0 and range is 42 — both relative to the text section's start, and both zero-patched by relocations in this object file, which is why the range is a plausible-looking 42 rather than a real address. readelf agrees on all of it:
00000018 0000000000000024 00000000 FDE cie=00000000 pc=0..2aBut look at that middle field again: CIE_pointer = 0, and the CIE is at offset 0, so it is correct — and it is correct by accident of this example, because the CIE happens to be the first record. In .eh_frame that same field is a pc-relative displacement: you add the field's own address to its value to find the CIE. In .debug_frame it is an absolute section offset, used as-is.
So here is the trap, and it is a good one because this file is the one case where the two readings agree. Take an FDE at offset 0x30 instead. Its CIE_pointer field would read 0 again, meaning CIE at section offset 0. A .eh_frame reader computes 0x30 + 4 + 0 = 0x34 and walks off into the middle of the FDE's own instructions, then interprets whatever it finds there as a CIE. It will not report an error, because the bytes it lands on are valid instruction bytes.
.eh_frame is designed to be mapped at an unknown address, so every internal reference in it is relative to something. .debug_frame is read by a tool that also has the symbol table, so its references are plain offsets into the section. Both fields occupy the same four bytes and mean different things. A reader that does not branch on which section it is reading will be right on one of them and silently wrong on the other, and the "wrong" is a number that passes every range check you are likely to write.One consequence worth stating, because it changes how you validate: in .eh_frame, a correct reader has a self-check available. The CIE_pointer must resolve to a record whose CIE_id is the CIE marker, and a pcrel computation that has gone wrong will almost never land on a record that passes. In .debug_frame the same check exists but is weaker, because the value is used unmodified and a stale or wrong offset is just as likely to land on a plausible record. If you write a reader for .debug_frame, check that the target record is a CIE and that its version byte is sane, and do not treat "it pointed somewhere" as success.
Unwinding one frame, in both sections, to show exactly where the two paths diverge. The state is the same: program counter inside the function, and a caller frame to reconstruct.
So the divergence is one line, and it is the third from the top. Everything after it is shared code, which is why a single decoder handles both sections so easily and why getting that one line wrong is so easy to miss — the rest of the pipeline works, and produces a number.
It is also worth being concrete about what goes wrong when that line is wrong, because "a wrong address" is too vague to recognise. Suppose the reader takes a .debug_frame FDE and applies the pcrel rule. The computed CIE address is field_address + 0, which is the FDE's own length field — the first four bytes of the FDE. It then reads a CIE there:
CIE_id it gets is the FDE's CIE_pointer, which is 0 for a first-record-adjacent CIE — and the pcrel reader's CIE check is "does this equal the CIE marker", which 0 passes in the .eh_frame convention. So the check that should have caught it actively ratifies it.0x00000000 that is version 0, which does not exist; a reader that validates the version catches it here, and a reader that does not sails on.pc_begin bytes. So code_alignment_factor becomes a text-section-relative address, and every subsequent advance_loc delta is multiplied by a number in the billions.The result of all that is a CFA computed from garbage, a return address read from a location derived from garbage, and a backtrace whose frames are plausible addresses in the executable. Every one of them will be a valid address, which is what makes this failure expensive to diagnose — the frame addresses look like real code until you check them against the disassembly and find they are all off by a constant that depends on how far the FDE was from the section start.
And that last detail is the diagnostic. A wrong CIE reference produces an error that is constant within a function and varies between functions, because the pcrel error depends on the FDE's offset. A tool that reports the same wrong return address for every call site in one function, and a different wrong one in the next, is looking at a reference-resolution bug and not at a data problem. Recognising that pattern is faster than re-reading the opcode table.
$ gcc -g -O0 -fno-asynchronous-unwind-tables -c t.c -o frame.o
$ readelf --debug-dump=frames frame.o
$ readelf -S -W frame.o | grep -oE '\.eh_frame|\.debug_frame'readelf reports. A record walker that uses the length field is the only kind that works on both sections, and building one is the exercise..debug_frame where the CIE is not at offset 0 — a second CIE, or an FDE whose CIE is the second one. Then apply the pcrel rule to it and watch the computed address miss. This file is the one case where the bug hides, and building the case that exposes it is worth more than the observation.0x00 padding to matter. Add a function whose initial instructions come to an odd length, so the padding count is not six. Confirm the DW_CFA_nop run still pads to a four-byte boundary and that your parser consumes exactly the declared number of bytes. A parser that stops at the first 0x00 looks correct on this file and wrong on the next one..debug_frame is something you have never encountered, that is worth knowing before you meet it in a customer's core dump rather than after..eh_frame CIEs in practice usually carry zR or zPLR, which adds a pointer-encoding byte and personality-routine fields. Decode one of those from a .eh_frame section and note that the .debug_frame CIE above has no room for any of it — which is a real constraint, not a simplification.Without looking back: your decoder reads an FDE from a .debug_frame section. The four bytes after the length field are 00 00 00 00, and the section's only CIE is at offset 0. The .eh_frame version of your decoder is being reused, which computes the CIE address as the field's own address plus its value. What does it compute, and what happens next?
Your production unwinder works on your Linux binaries. A customer sends you a core dump from a tool built on a platform that uses .debug_frame, and every frame in the backtrace is wrong — but the addresses are all valid code addresses, the source lines look plausible, and the innermost frames are consistently off by the same amount within each function while differing between functions. Given this concept, what have you learned, and what is the one-line change that fixes it?
The general habit: a wrong answer that is constant within one function and different in the next is a coordinate-system error, not a data error. Whenever a value you computed is derived from another address, ask what would have to be true for the error to have that shape.
This is the other half of the frames concept, and the two together are the complete answer to "how does a debugger walk a stack". The connection is not thematic but mechanical: the same twelve CIE bytes in this file and in that one encode the same two rules, and the same DW_CFA_* opcodes drive the same state machine. The lesson to carry away is that a format with two encodings of one idea is a format where the section name is part of the data, and a reader that keys on the section name rather than on the content's shape will handle both.
That is the third instance of the same structure in this module. DWARF 4 and 5 differ by a header byte; type units differ by a section name in one version and a field in the other; and here two sections differ by a reference convention. In every case the discriminating information is a single small value — a version, a unit_type, a section name — and in every case skipping it produces a number rather than an error.
That is the shape of the last concept in this module too. Portability found a whole section that exists only in newer versions, and the packages concept found an index whose purpose is to skip work rather than to describe code. Both are cases where the format stores a pointer to knowledge that lives somewhere else, and both fail the same way: not loudly, but by resolving to something plausible.
That closes the DWARF course's format coverage. What remains in this format is not unwritten for want of effort — it is enumerated, with reasons, in courses/dwarf/research.md: the indexed list opcodes no configuration on this machine emits, DWARF64, which needs four gigabytes of debug information, and the trailing bytes of .debug_cu_index, which I could not account for and declined to guess at.