Back to course

Same Source, Different Target

21 minadvancedModule 4: Location Lists, Portability and PackagesContinues from dwarf-rnglists
21 min · Module 4: Location Lists, Portability and Packages · Algorithms

Why This Matters

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.

A Simple Model

Three things in this format are not fixed, and each is fixed in a different place:

What variesDeclared byWidens when
The versionversion in the section headerYou change -gdwarf-
The address sizeaddress_size in the section headerYou change the target
The offset sizeformat: DWARF32 or DWARF64The 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.

The Actual Detail

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:

FileHeader bytesVersionAddress sizeFirst DIEUnit length
types_v2.o11 01 00 00 02 00 00 00 00 00 08 01280xb273
types_v3.o07 01 00 00 03 00 00 00 00 00 08 01380xb263
types_i386.oa5 00 00 00 05 00 01 04 00 00 00 00 01 00540xc165
types_aarch64.oa5 00 00 00 05 00 01 08 00 00 00 00 01 00580xc165

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): 0

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

What this buys, and what it costs. Indexing addresses is why 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.

Version, and the header that grows by one byte

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.

Version 2 versus version 3, and the thing that did not change

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 Real Example

A reader that handles all four files above correctly, and what each of the three varying quantities costs it:

header = read 4 bytes at the section start if header == 0xffffffff: offset_size = 8 header = read 8 more bytes else: offset_size = 4 unit_length = read unsigned of offset_size bytes 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 first_die = position after the header else: abbrev_offset = read unsigned of offset_size bytes address_size = read 1 byte first_die = position after the header

Three consequences worth stating plainly:

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.

Try It Yourself

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

Check Your Understanding

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?

Apply It

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.

Connect

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.