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.
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.oTwo 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:
| Bytes | Field | Value |
|---|---|---|
fe 00 00 00 | unit_length | 254 |
04 00 | version | 4 |
00 00 00 00 | debug_abbrev_offset | 0 — at offset 6 |
08 | address_size | 8 — at offset 10 |
01 | first abbrev code | the 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.
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 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 table | Version 4 | Version 5 |
|---|---|---|
DW_FORM_sec_offset | yes | yes |
DW_FORM_string | yes | — |
DW_FORM_strp | yes | — |
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 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 : 0x89Three 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.
Pulling the last four modules together, the things a version 4 reader must never encounter and a version 5 reader meets constantly:
unit_type. There is exactly one kind of unit, and it is a compile unit. Type units and split units did not exist yet — the next two concepts cover both.DW_FORM_strx, DW_FORM_addrx, or DW_FORM_rnglistx. The whole indexed-address machinery, which is what makes .debug_addr and .debug_str_offsets necessary, arrived in version 5..debug_line_str, and no DW_FORM_line_strp. So a file-name string lives in .debug_str like any other string..debug_loclists or .debug_rnglists. A variable with per-address-range locations used .debug_loc and .debug_ranges, which are different sections with different opcodes. The version 5 tables and the DW_LLE / DW_RLE opcode sets simply do not exist here.dwo_id, no DW_AT_GNU_dwo_name.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.
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.
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:
| Bug | Symptom | Why it is silent |
|---|---|---|
| Hardcode a 12-byte header | Every version 4 unit's first DIE is read one byte late | The abbrev code is a small integer, so a shifted read still yields a plausible abbrev number |
Read address_size before branching | Version 5 units report address size 0 or 8 by accident | The wrong byte is often a valid size |
Always read unit_type | Version 4 units decode as a unit type of 0 or a random value | DW_UT_compile is 1, and a shifted read can land on it |
Assume .debug_str_offsets exists | Every DW_FORM_strx resolves to garbage | You 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.
$ 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 -u0xb. Compile it, look, and check. Then do it for version 3 as well, which shares the eleven-byte header — so the boundary is not between 3 and 4, it is between 4 and 5, and finding that out yourself is worth more than being told.high_pc trap in a file where it bites. In the version 4 object, low_pc is 0 and high_pc is 0x89. Write two lines of code that report a function's extent — one that adds, one that subtracts — and see which produces a set of functions that do not overlap. Then link the object and check again, because after linking low_pc is real and the answer changes shape.-gdwarf-4 and try to make clang emit .debug_addr. You cannot, because the form it exists to support does not exist. Now try -gdwarf-5 and count how many DW_FORM_addrx attributes appear. The section count follows directly from the form count, which is the actual design relationship.types_v4.o, and find the exact attribute where its output stops matching readelf. Then move the version test to the top of the function and confirm it matches. That is the whole exercise, and it is the most direct demonstration of the gate you will get.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?
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.
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.