Back to course

The Other Unwind Section

21 minadvancedModule 5: The Format on Other InputsContinues from dwarf-call-sites
21 min · Module 5: The Format on Other Inputs · Algorithms

Why This Matters

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.

A Simple Model

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_frame

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

One more difference worth knowing before you read the bytes. An FDE's 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.

The Actual Detail

The whole section, 144 bytes

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.

The CIE at offset 0

BytesFieldValue
14 00 00 00length20, so the record occupies 24 bytes
ff ff ff ffCIE_id-1 — this is what marks a CIE
01version1
00augmentationempty, a NUL-terminated string
01code_alignment_factorULEB 1
78data_alignment_factorSLEB −8
10return_address_register16, which is rip
0cinitial instruction lengthULEB 12
07 08 90 01 ...12 bytes of instructionstwo 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.

The CIE marker, which is the difference that matters

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.

The FDE at offset 0x18, and the field the whole concept is about

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..2a

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

The general rule, and it is worth more than this example..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.

A Real Example

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.

locate the FDE covering the program counter (pc_begin and address_range are section-relative in BOTH sections, so this step is identical) read its CIE reference .eh_frame : cie = address_of_this_field + *this_field then check the target's CIE_id is the CIE marker .debug_frame: cie = *this_field used as a section offset, unmodified read the CIE augmentation string, code_alignment_factor, data_alignment_factor, return_address_register, and the initial instructions -- all identical in both sections apply the initial instructions, then the FDE's instructions, as a program counter advances: DW_CFA_def_cfa reg, off -> CFA = regs[reg] + off DW_CFA_offset reg, factored-> saved at CFA + factored * data_align ... fetch the return address from where the CIE said it was saved fetch the caller's CFA identical from here on

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:

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.

Try It Yourself

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

Check Your Understanding

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?

Apply It

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.

Connect

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.