Back to course

DWARF 4 in Practice

20 minintermediateModule 5: The Format on Other InputsContinues from dwarf-packages
20 min · Module 5: The Format on Other Inputs · Algorithms

Why This Matters

Everything in Modules 3 and 4 was read out of DWARF 5 files, because that is what the compilers on this machine produce by default. That makes the last four concepts a study of the newest version of the format — which is a real risk, because the newest version is not the most common one.

Debug information outlives the toolchain that made it. Binaries ship with the debug info their build produced, and those builds are frequently older than anything on your machine. A great deal of DWARF 4 in the world was emitted by a GCC whose default has since moved on, and it will still be there, still being read, long after nobody remembers which version produced it.

So this concept compiles the same source at version 4 and puts it beside the version 5 files from the portability concept. The result is sharper than a version table: DWARF 4 turns out to differ from DWARF 5 in only two structural ways, and everything else about it is a section that simply is not there.

A Simple Model

Build the file, and look at what came out:

$ gcc -gdwarf-4 -O0 -c t.c -o types_v4.o
$ clang -gdwarf-4 -O0 -c t.c -o types_clang_v4.o

Two compilers, same source, same version, and the header of the compilation unit is where the first difference shows. Read the first twelve bytes of .debug_info in each, plus the version 5 files for comparison:

gcc   v4:  fe 00 00 00 04 00 00 00 00 00 08 01
gcc   v5:  29 02 00 00 05 00 01 08 00 00 00 00 01 00
clang v4:  e5 00 00 00 04 00 00 00 00 00 08 01
clang v5:  a5 00 00 00 05 00 01 08 00 00 00 00 01 00

Now the whole difference is visible in one column. Decode the version 4 header from gcc, field by field, using the offsets you already know:

BytesFieldValue
fe 00 00 00unit_length254
04 00version4
00 00 00 00debug_abbrev_offset0 — at offset 6
08address_size8 — at offset 10
01first abbrev codethe first DIE is at 0xb

Compare the field positions with version 5. In DWARF 5, debug_abbrev_offset is at offset 8 and address_size at offset 7, because unit_type occupies offset 6. In DWARF 4 there is no unit_type at all, so debug_abbrev_offset moves to offset 6 and address_size to offset 10, and the header is eleven bytes instead of twelve.

So the first DIE is at 0xb in a version 4 unit and at 0xc in a version 5 unit, and that one byte is the entire geometric difference. The portability concept told you to predict which side of the split version 4 falls on, and to go compile it. This is the answer: it is on the old side, with an eleven-byte header, exactly as predicted.

And nothing in the file tells you the header is eleven bytes.unit_length counts the whole unit including the header, so you cannot derive the header size from it. There is no header-length field. The only thing in the file that says how to parse the header is the version field, which is why a reader that hardcodes 12 or hardcodes 11 is wrong half the time, and why the very first thing a DWARF reader must do is read that field before it computes anything else.

The Actual Detail

What the two versions leave out

The more consequential difference is not in the header at all. It is in which sections exist:

$ readelf -S -W types_v4.o | grep -oE '\.debug_[a-z_]+'
.debug_abbrev
.debug_aranges
.debug_info
.debug_line
.debug_str

$ readelf -S -W types_v5.o | grep -oE '\.debug_[a-z_]+'
.debug_abbrev  .debug_addr   .debug_aranges  .debug_info
.debug_line    .debug_line_str  .debug_str   .debug_str_offsets

Three sections exist in the version 5 build and not in the version 4 build: .debug_addr, .debug_str_offsets and .debug_line_str. And the absence is not an accident of these flags — those three sections exist to support forms that do not exist in version 4. The abbreviation tables show it directly:

Forms in the abbrev tableVersion 4Version 5
DW_FORM_sec_offsetyesyes
DW_FORM_stringyes—
DW_FORM_strpyes—
DW_FORM_strx—yes
DW_FORM_addrx—yes

That is the version 4 arrangement in one table. Strings are inline (DW_FORM_string) or a direct offset into .debug_str (DW_FORM_strp). Addresses are written down (DW_FORM_addr, or DW_FORM_sec_offset for the range-like ones). There is no indirection, so there is nothing for .debug_str_offsets or .debug_addr to hold.

And this is the payoff of the portability concept's finding rather than a contradiction of it. That concept measured a 32-bit and a 64-bit build of the same source and found identical unit lengths, because DW_FORM_addrx stores an index so the address width never enters the DIE tree. Version 4 has no such form, so the same trick is unavailable — and the header shows it. address_size is still 8, the field is still present, and the same source at address_size 4 would produce a version 4 unit of a different length, because the addresses really are in there.

The attributes, read by hand

The version 4 compile unit, from readelf:

  Length:        0xfe (32-bit)
  Version:       4
  Abbrev Offset: 0
  Pointer Size:  8
<0><b>: Abbrev Number: 1 (DW_TAG_compile_unit)
   <c>   DW_AT_producer : (indirect string, offset: 0x12): GNU C23 15.2.0 ... -gdwarf-4 -O0
   <10>   DW_AT_language : 12 (ANSI C99)
   <11>   DW_AT_name     : t.c
   <15>   DW_AT_comp_dir : (indirect string, offset: 0xd2)
   <19>   DW_AT_low_pc  : 0
   <21>   DW_AT_high_pc : 0x89

Three details in that listing are worth pausing on, and all three are things a version 5 reader gets for free.

First, the attribute offsets. They run 0xc, 0x10, 0x11, 0x15, 0x19, 0x21 — and the DIE itself started at 0xb. The gap from 0xb to 0xc is the abbrev code, one byte, and the gap from 0x21 to the end of the unit is where 0x00 sits, the end-of-children marker. That is the same structure as version 5 and it is worth being able to walk, because an attribute offset in a reader is not a line number — it is a byte position in the section.

Second, DW_AT_high_pc : 0x89 next to DW_AT_low_pc : 0. That is not an address range from 0 to 137. It is DW_FORM_data4, and a high_pc that is not in DW_FORM_addr is a length relative to low_pc. So the function is 137 bytes long and sits at address zero, because this is a relocatable object. This is the address-versus-length rule from the address-to-line concept, and it is the single most common way a reader misreports a function's extent — here it would report a function occupying addresses 0 to 137, which overlaps every other function in the file.

Third, DW_AT_language : 12 (ANSI C99), where the version 5 build of the same source says 29 (C11). That is a compiler-version difference rather than a format one, and it is a good reminder that producer is a first-class field: when a reader reports something surprising, the producer string is the first place to look, because the same source through a different compiler can differ in ways that have nothing to do with the format.

What version 4 does not have, stated as a list

Pulling the last four modules together, the things a version 4 reader must never encounter and a version 5 reader meets constantly:

And that list is the honest summary of what "version 5" was: not a redesign of the DIE tree, which is essentially unchanged, but a change to how values are referenced — from writing them down to indexing them — plus three new sections and a new unit kind to carry the indices.

A Real Example

What a reader has to branch on, and the order the branches have to come in. Getting this wrong does not produce an error; it produces a plausible tree, which is why the ordering matters as much as the conditions.

read unit_length if unit_length == 0xffffffff: offset_size = 8 read 8 more bytes into unit_length else: offset_size = 4 version = read 2 bytes if version >= 5: unit_type = read 1 byte address_size = read 1 byte abbrev_offset = read unsigned of offset_size bytes if unit_type == DW_UT_type: type_signature = read 16 bytes type_offset = read unsigned of offset_size bytes if unit_type == DW_UT_split_compile: dwo_id = read 8 bytes first_die = here else: abbrev_offset = read unsigned of offset_size bytes address_size = read 1 byte first_die = here

Three things about that order are load-bearing. The version test comes before the address-size read, because the two fields swap positions across the version boundary and a reader that reads address_size first gets the wrong byte in both versions. unit_type is only read in version 5 or later, so a version 4 reader must not consume it — consuming it would shift every subsequent field by one byte and produce a version 4 unit that decodes into a version 5 unit's worth of nonsense. And the version 5 extras are keyed on unit_type, not on the version: a version 5 compile unit has no signature and no dwo_id, and a reader that reads 16 bytes of signature for every version 5 unit will be wrong for most of them.

Now the failure modes, which are what a version gate actually buys you:

BugSymptomWhy it is silent
Hardcode a 12-byte headerEvery version 4 unit's first DIE is read one byte lateThe abbrev code is a small integer, so a shifted read still yields a plausible abbrev number
Read address_size before branchingVersion 5 units report address size 0 or 8 by accidentThe wrong byte is often a valid size
Always read unit_typeVersion 4 units decode as a unit type of 0 or a random valueDW_UT_compile is 1, and a shifted read can land on it
Assume .debug_str_offsets existsEvery DW_FORM_strx resolves to garbageYou read a plausible offset from the wrong section, or from nothing

Not one of those produces a diagnostic. Each produces a DIE tree that a debugger will happily walk, with wrong string offsets and wrong addresses, and the result is source lines attached to the wrong functions. This is the argument for the version gate in one table: the cost of the gate is one comparison, and the cost of skipping it is a reader that fails only on the files it was not tested on.

Try It Yourself

$ gcc -gdwarf-4 -O0 -c t.c -o v4.o
$ clang -gdwarf-4 -O0 -c t.c -o cv4.o
$ readelf -S -W v4.o | grep -oE '\.debug_[a-z_]+'
$ readelf --debug-dump=abbrev v4.o | grep -oE 'DW_FORM_[a-z0-9_]+' | sort -u

Check Your Understanding

Without looking back: you are handed a compilation unit and told only that its version field reads 4. Its debug_abbrev_offset is 0 and its address_size is 8. Where do those two fields sit, how long is the header, and what is the offset of the first DIE?

Apply It

Your DWARF reader is in production and is now failing on a customer's binaries — but only on some of them, and only for functions in files with more than one compilation unit. Everything else is fine. You have no control over the customer's toolchain and cannot ask them to rebuild. Given what this concept established, what is your leading hypothesis, and what is the cheapest thing you can check before you start changing code?

The general habit: a per-file property is the first suspect for a per-file failure. Version, address size and offset size are the three things a file states about itself, and they are stated once at the top of every section. Reading three integers costs less than any amount of speculation.

Connect

This is the payoff for the version concept, which listed what each version changed and flagged that the version 5 header breaks a reader. That is now a decoded file rather than a table, and the specific breakage is one byte.

The missing unit_type is the next concept's subject. A version 4 file has exactly one kind of unit; version 5 has four, and the header field that distinguishes them is the byte this one does not have. Type units are the one kind that existed before version 5 in a different section, so that concept starts in version 4 and moves forward.

And the version 4 arrangement of unwind data — .eh_frame rather than .debug_frame — is the subject of the concept after that. The CFI in the frames concept was read out of .eh_frame, and the section that shares the name with the rest of the debug sections has a different and simpler encoding. Two sections, two formats, one job, and knowing which is which is the difference between a working unwinder and one that reads plausible nonsense.

Next: Type Units.