Everything so far in this course has been one machine: 64-bit x86, GCC, DWARF 5. Every layout, every offset width, every assumption about what a pointer costs — verified against files on this disk, and therefore only ever evidence for one configuration.
A format is not portable because its specification allows for the variation. It is portable when a reader written against one configuration survives contact with another. Those are different claims, and the only way to have the second one is to actually produce the other configurations and diff them.
So this concept does the experiment: the same C file compiled four ways, with the debug sections read out of the resulting object files. The result is a much sharper answer to "what varies in DWARF" than any amount of reading the specification, because most of what people expect to vary does not vary at all, and one thing that does vary is easy to miss entirely.
Three things in this format are not fixed, and each is fixed in a different place:
| What varies | Declared by | Widens when |
|---|---|---|
| The version | version in the section header | You change -gdwarf- |
| The address size | address_size in the section header | You change the target |
| The offset size | format: DWARF32 or DWARF64 | The file exceeds 4 GB of debug info — not a target property at all |
Note the third row. Address size, version and offset size are usually conflated because they are all "how wide are the numbers". But they are not the same kind of decision. Address size is a property of the machine. Version is a property of the producer's flag. Offset size is a property of the file's size, and a 32-bit binary can have DWARF64 debug info if it carries more than four gigabytes of it.
That is the first portability lesson and it is purely conceptual: a reader that picks its field widths from the target architecture is wrong even though it looks right, because the format does not tie the two together.
One file, four builds. The first three are the same source with different clang targets, so the debug information is the compiler's answer to the same program on different hardware:
$ clang --target=i386-linux-gnu -gdwarf-5 -c t.c -o types_i386.o $ clang --target=aarch64-linux-gnu -gdwarf-5 -c t.c -o types_aarch64.o $ gcc -gdwarf-2 -O0 -c t.c -o types_v2.o $ gcc -gdwarf-3 -O0 -c t.c -o types_v3.o
Read the first twelve bytes of .debug_info in each and decode the header by hand:
| File | Header bytes | Version | Address size | First DIE | Unit length |
|---|---|---|---|---|---|
types_v2.o | 11 01 00 00 02 00 00 00 00 00 08 01 | 2 | 8 | 0xb | 273 |
types_v3.o | 07 01 00 00 03 00 00 00 00 00 08 01 | 3 | 8 | 0xb | 263 |
types_i386.o | a5 00 00 00 05 00 01 04 00 00 00 00 01 00 | 5 | 4 | 0xc | 165 |
types_aarch64.o | a5 00 00 00 05 00 01 08 00 00 00 00 01 00 | 5 | 8 | 0xc | 165 |
Decode the 32-bit one byte by byte, because it is the interesting case:
a5 00 00 00 unit_length = 165 05 00 version = 5 01 unit_type = DW_UT_compile 04 address_size = 4 <-- 32-bit target: half the pointer 00 00 00 00 debug_abbrev_offset = 0 01 00 the first DIE: abbrev code 1, then 00 = end of its children
Now the finding that makes this concept worth reading. types_i386.o and types_aarch64.o both have unit_length = 165 — identical. One is a 32-bit target and one is 64-bit. Every address in both is half the width of the other's. And yet the compilation unit occupies exactly the same number of bytes.
The reason is the form. Look at what the reader reports for a low address attribute in the 32-bit build:
$ readelf --debug-dump=info types_i386.o | grep low_pc
<1a> DW_AT_low_pc : (index: 0): 0
<24> DW_AT_low_pc : (index: 0): 0The value is a small integer index, not an address. It names a slot in the .debug_addr section, and the address itself lives over there where its width is decided. Since the index is a UDATA and both files have small indices, the slots in the DIEs are the same size in both — so the whole unit is the same size.
DW_FORM_addrx exists, and it is not a micro-optimisation. It is the reason the same debug info can describe a 32-bit build and a 64-bit build at all, and the reason a producer does not have to rewrite every address when the target width changes. The cost is indirection: a reader cannot answer "what address is this DIE at" from the DIE alone, and a producer that emits DW_FORM_addr inline instead — as older producers do — will produce a unit whose length does change with the address size. If you compare the unit lengths of two files and they differ, that is a signal about forms, not about the target.DWARF 2 and 3 share a header layout; DWARF 5 inserts a field. Here is the whole difference, from the first two rows of the table:
DWARF 2 and 3, 11 bytes: DWARF 5, 12 bytes:
unit_length 4 bytes unit_length 4 bytes
version 2 bytes version 2 bytes
debug_abbrev_offset 4 bytes unit_type 1 byte <-- new
address_size 1 byte address_size 1 byte
debug_abbrev_offset 4 bytes
Consequence: DW_AT_low_pc's companion, the first DIE, sits at offset 0xb in a version 2 or 3 unit and at 0xc in a version 5 unit. One byte. And that one byte is not signalled by anything except the version — there is no length field for the header, and the unit length counts the whole unit including the header, so you cannot infer the header size from it. A reader that hardcodes 11 is wrong on every modern file; a reader that hardcodes 12 is wrong on every file older than 2017. The only correct thing is to branch on version.
And the version changes something structural as well. DW_FORM_line_strp — an offset into a .debug_line_str section — appears only in the version 5 build. Versions 2 and 3 have no .debug_line_str section at all:
$ readelf -S -W types_i386.o | grep -c debug_line_str -> 1 $ readelf -S -W types_v2.o | grep -c debug_line_str -> 0 $ readelf -S -W types_v3.o | grep -c debug_line_str -> 0
So a reader written for version 5 can encounter a form whose target section does not exist, purely because it was handed an older file. A reader that assumes the section is there, because it has always been there, gets a wrong answer rather than a failure.
Both unit lengths are reported, and they are not equal: 273 for version 2, 263 for version 3. Ten bytes of difference in the debug information, from a version bump that changed no header size and no field width. That difference is in the content — different attribute sets, different types, different DIE counts — and it is a useful reminder that "portable" is not the same as "identical".
What is worth noting is what did not change: the address size stayed 8, the first DIE stayed at 0xb, the header stayed 11 bytes, and the string forms stayed the same pair — DW_FORM_string and DW_FORM_strp in both, with DW_FORM_line_strp appearing only in version 5. The version number moved the compiler's decisions about what to record; it did not move the container's geometry.
A reader that handles all four files above correctly, and what each of the three varying quantities costs it:
Three consequences worth stating plainly:
DW_FORM_addr is the only form whose width it changes, and that form is becoming rare for exactly the reason shown above. A reader that hardcodes 8 for "address size" is not wrong on most files and is wrong on every 32-bit one.0xffffffff escape at the front of every section is how a format says "the width of the widths just changed". Nothing about the target machine influences it, and a reader that computes it from the pointer width is wrong in a way that only shows up on files over four gigabytes — the rarest possible place to discover a bug..debug_line_str, introduced DW_FORM_addrx and DW_FORM_rnglistx, and gave the list sections their own opcodes. Nothing else in the format changes its structure between versions.Now the honest limit of what was just demonstrated. Four configurations is evidence, not proof. There are producers and targets this experiment did not touch: DWARF 4, DWARF64 format, a non-zero offset_entry_count, the split-debugging sections, and a 64-bit target's address_size under DWARF 2 or 3. Each of those is a real branch in a reader, and none of them was tested here. The experiment established which dimensions vary and which do not, which is the thing that was missing; it did not establish that every combination works.
$ readelf --debug-dump=abbrev types_v2.o | grep -oE 'DW_FORM_(str|line_str)[a-z_]*' | sort -u
$ readelf --debug-dump=abbrev types_v3.o | grep -oE 'DW_FORM_(str|line_str)[a-z_]*' | sort -u
$ readelf --debug-dump=abbrev types_i386.o | grep -oE 'DW_FORM_(str|line_str)[a-z_]*' | sort -u
$ gcc -gdwarf-4 -O0 -c t.c -o types_v4.o # the one version not tried here0xb you have understood that version 4 is on the old side of the split.types_v2 against types_i386 and listing which forms appear in one and not the other is the fastest way to enumerate what a version actually changed.DW_FORM_addr to prove the indexing point. The version 2 and 3 builds predate the indexed forms, so some address in them must be stored inline. Find one, note its width in each file, and confirm the unit length changes when you remove it. That is the mechanism behind the identical 165-byte units, observed directly.0xffffffff escape is really an escape. You will not produce a DWARF64 file without gigabytes of debug info, so instead read the code path: find a section you can inspect, and reason about where the sentinel would have to be checked. Being explicit about a branch you have never executed is better than being silent about it.DWARF64 would look like. Write down what the header bytes would have to be, given a 4-byte address_size and an 8-byte offset size. If your answer is not obvious, that is the gap this concept was pointing at.Two compilation units come out of the same build system. One is from a 32-bit target with DWARF 5 and has unit_length = 165 with its first DIE at offset 0xc. The other is from a 64-bit target with DWARF 5 and also has unit_length = 165, first DIE at 0xc. Both use DW_FORM_addrx. A third unit is from a 64-bit target with DWARF 3, has unit_length = 263, first DIE at 0xb. What accounts for all three of those observations?
Your DWARF reader passes its entire test suite on 64-bit Linux binaries from GCC and Clang, all DWARF 5, all under a megabyte. It fails in the field on two classes of file: binaries from a 32-bit Android toolchain, and a single large binary from a Windows build. Both failures are "wrong values, no error". You now have a model of the three varying quantities. What is your diagnosis for each, and what is the one change that would distinguish them from a bug in your DIE parser?
The general habit: a reader that gets a whole class of files wrong usually has a constant where the format has a field. Before debugging the parse, list every number the file states about itself — version, address size, offset size, segment size, entry counts — and check that each one is actually read from the file rather than assumed.
The address_size finding is the same fact you met in the CFI concept, arriving from the other direction. DW_EH_PE encoding bytes carry a pointer width, and a 32-bit build's unwind data is correspondingly narrower. Debug info and unwind info are the two halves of a debugger's model of the machine, and they agree on this machine's width because the format records it in the same way — declared once, per section, and used to parse everything after.
The indexed-address result also explains something from the split-debugging concept. Split units emit DW_FORM_addrx with abandon, and a split unit is explicitly not address-bearing at all — so the producer that most needs the "addresses live elsewhere" indirection is the one that uses it most. The mechanism was not invented for split debugging; split debugging took advantage of a mechanism that already existed.
One topic remains, and it is the one place where a DWARF file is not a file: the packaged form that lets a debug session travel without shipping the objects it describes.
Next: Packages.