Back to course

Type Units

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

Why This Matters

One compilation unit, one source file, one .debug_info unit — that is the mental model from Module 3, and for a C program it is simply true. C++ breaks it, and it breaks it for a specific reason worth stating: the same type is described over and over. A header defining std::string is included by four hundred translation units, and if each one carries its own copy of the full std::string DIE tree, the debug info is four hundred times larger than it needs to be, and a linker merging them has four hundred structurally identical things to compare.

The fix is to factor the type out of the compile unit. A type unit is a compilation unit whose job is to describe one type, given a name that identifies it independently of where it was found. The compile units then refer to that name rather than repeating the tree, and identical types from different files land in the same unit.

Which makes type units the first place in DWARF where identity is separated from location. Everything so far in this course has said "the type is at offset X in this unit". A type unit says "the type is whatever has this signature" — and as this concept shows, that signature is not what its name suggests it is.

A Simple Model

Two header layouts, because type units predate version 5 by a long way and version 5 moved them.

FieldDWARF 4, in .debug_typesDWARF 5, in .debug_info
unit_lengthyesyes
versionyesyes
unit_type— implied by the sectionDW_UT_type = 2
address_sizeyesyes
debug_abbrev_offsetyesyes
type_signatureyes, 16 bytesyes, 16 bytes
type_offsetyesyes

Read the version 4 column as what a reader learns from the section name instead of a field: anything in .debug_types is a type unit, and anything in .debug_info is a compile unit, and the two can never be confused. That is a genuinely nice property of the older design and version 5 gave it up — because it bought something else, which is the next section.

Why version 5 moved them into .debug_info

Compile the same file both ways and the difference is not subtle:

$ gcc -gdwarf-4 -fdebug-types-section -c t.c -o v4t.o
$ readelf -S -W v4t.o | grep -c '\.debug_types '        ->  1

$ gcc -gdwarf-5 -fdebug-types-section -c t.c -o types.o
$ readelf -S -W types.o | grep -c '\.debug_types '      ->  0
$ readelf --debug-dump=info types.o | grep 'Unit Type'
  Unit Type:     DW_UT_type (2)

The version 5 build has no .debug_types section at all, and the type unit is sitting in .debug_info where readelf reports it by its unit_type. This is a real, checkable relocation of a structure, and it is the kind of change that breaks every tool that walked .debug_info assuming every unit in it was a compile unit.

The reason is worth knowing because it is the same reason as a great many format decisions: a section has overhead, and units do not divide evenly into sections. A file with one type unit and one compile unit used to need two sections, each with a section header, an alignment requirement, and an entry in the relocatable object. In version 5 they share one. The cost is that the unit header grew by the unit_type byte you decoded in the previous concept; the benefit is that the boundary between "compile units" and "type units" stops being a section boundary and becomes a value, which is far cheaper to extend — version 5 added split units and partial units in exactly this space, and neither needed a new section.

That is the pattern to carry forward. Whenever a format has two ways to say the same thing — a section boundary or a field — the field wins over time, because fields compose. Version 4 said "which kind of unit am I" with the section name; version 5 says it with a byte in the header; and having said it with a byte, adding a fifth kind of unit became a matter of defining a new value rather than inventing a container.

The Actual Detail

Decoding the version 5 header by hand

The type unit is the first thing in .debug_info of the version 5 build. The whole header, 32 bytes:

0000: 67 00 00 00 05 00 02 08 00 00 00 00 89 5c 02 be
0010: e0 73 7e 69 23 00 00 00 01 1d 03 47 23 00 00 00 01 1d
BytesFieldValue
67 00 00 00unit_length103
05 00version5
02unit_typeDW_UT_type = 2
08address_size8
00 00 00 00debug_abbrev_offset0
89 5c 02 be e0 73 7e 69 23 00 00 00 01 1d 03 47type_signature16 bytes
23 00 00 00type_offset0x23 = 35
01first abbrev codethe type DIE begins at 0x23

One thing to check before trusting the table: type_offset is 0x23, and the unit's unit_length is 103, so the type DIE starts 35 bytes into a 107-byte unit. That is a plausible position — there is a root DIE and some type DIEs before the named type. And readelf independently reports Type Offset: 0x23, which is the cross-check that matters.

The version 4 header, for contrast

Same source, version 4, in .debug_types:

0000: 61 00 00 00 04 00 00 00 00 00 08 89 5c 02 be e0
0010: 73 7e 69 1d 00 00 00 01 0c 00 00 01 0c 00 00 00 00 02 53

Eleven bytes of the standard version 4 header — no unit_type — then the signature at offset 0x0b, then type_offset. Compare the two signature fields directly:

v4, in .debug_types:  89 5c 02 be  e0 73 7e 69  1d 00 00 00  01 0c 00 00
v5, in .debug_info :  89 5c 02 be  e0 73 7e 69  23 00 00 00  01 1d 03 47
                                ^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^
                                IDENTICAL          type_offset      differs

What the 16 bytes labelled a signature actually contain

Both readers agree on the version 4 unit and report the same signature:

$ readelf --debug-dump=info types.o   | grep Signature
  Signature:     0x697e73e0be025c89
$ readelf --debug-dump=info v4t.o   | grep Signature
  Signature:     0x697e73e0be025c89

Identical. The same type, compiled by the same compiler at two different DWARF versions, has the same signature as far as readelf is concerned. And yet the two sixteen-byte fields in the files are not identical. Decompose both into four 32-bit words:

WordVersion 4Version 5Same?
u32[0]0xbe025c890xbe025c89yes
u32[1]0x697e73e00x697e73e0yes
u32[2]0x0000001d0x00000023no
u32[3]0x00000c010x47031d01no

And here is the part that makes this worth a concept. u32[2] is the type_offset field, sitting inside the signature:

v4  u32[2] = 0x0000001d = 29   and readelf's Type Offset for v4 is 0x1d
v5  u32[2] = 0x00000023 = 35   and readelf's Type Offset for v5 is 0x23

Both match, exactly, in both files. So the sixteen bytes called a type signature are not sixteen opaque hash bytes: the third word is a copy of the unit's own type_offset, and the fourth word is the only part that looks like a hash.

What is established, and what is not.

Established, from the bytes: the first eight bytes are identical across the two versions; the third word equals type_offset in both; readelf prints only the first eight bytes as a 64-bit value; and u32[3] is the only word that differs in a way not already explained by the offset.

Not established: why the offset is in there. A plausible story is that the field is a hash over a canonicalised form of the type that happens to include the DIE's own position, which would make it version-dependent — and that would be a defect, because a signature whose purpose is to be comparable across compilations should not depend on encoding details. But a plausible story is not a finding, and no second implementation exists on this machine to ask. So the concept states the structure, states that readelf shows you half of it, and leaves the reason open. If you work it out, that is a real result and it belongs in a reader.

And the practical consequence of readelf showing you only eight bytes: two type units with the same displayed signature are not thereby known to be the same type. They could differ in the half you were not shown. Any tool that treats the displayed value as the whole signature is making an assumption it has not checked, and the assumption is wrong in at least one direction here — the same type in two versions does produce different full signatures.

A Real Example

How a reader uses this, and where the two versions genuinely differ rather than merely look different. The reference form is DW_FORM_ref_sig8: an eight-byte value that is not an offset into .debug_info but a type signature, to be resolved by searching for the type unit that claims it.

walk every unit in .debug_info (or .debug_types, in v4) if unit_type != DW_UT_type: skip it -- it is a compile or split unit record unit.type_signature -> unit when a DW_FORM_ref_sig8 attribute is read: signature = the 8 bytes from the attribute if signature in the recorded map: the referenced DIE is in that unit, at unit.type_offset else: the reference is dangling: the type unit is missing (this happens after -gdwarf-4 -fdebug-types-section is compiled without it, and in any stripped or partial file)

Two properties of that algorithm are worth naming, and both come from the design rather than from the code.

It is a two-pass algorithm, and the first pass is global. A DW_FORM_ref_sig8 in the first unit of a file can refer to a type unit at the end of the file, or in another file entirely, or in a .dwp package. There is no "the target is nearby" assumption available, so the index must be built before any reference can be resolved. A reader that resolves references as it walks will fail on exactly the units that use them.

The failure mode is a dangling reference, not a wrong one. If the signature is not in the index, there is no fallback that produces a plausible answer — there is simply no type. That is unusual and valuable. Compare it with a DW_FORM_ref_addr, where a stale offset silently resolves to whatever DIE now lives there: same size, plausible structure, wrong type. A signature-based reference fails loudly; an offset-based reference fails silently. That is a real argument for the whole indirection, and it is the mirror image of what the portability concept found about indexed addresses.

Which also explains why the signature is worth having even though the mechanism above does not verify it. The signature's job is to let a linker merge two type units that describe the same type, and to let a debugger share one copy across four hundred files. Both jobs are optimisation jobs — the debug info is correct without them. And that is exactly why the unverified half matters: a signature that collided or drifted would not make the debug info wrong, it would make a linker merge two different types, and the result would be a program whose types are subtly wrong with no diagnostic anywhere. The field is load-bearing precisely where nobody is looking.

Try It Yourself

$ gcc -gdwarf-4 -fdebug-types-section -O0 -c t.c -o v4t.o
$ gcc -gdwarf-5 -fdebug-types-section -O0 -c t.c -o types.o
$ readelf -S -W v4t.o | grep -oE '\.debug_[a-z_]+'
$ readelf --debug-dump=info types.o | grep -E 'Unit Type|Signature|Type Offset'

Check Your Understanding

Without looking back: the same type is compiled at DWARF 4 and at DWARF 5. readelf reports Signature: 0x697e73e0be025c89 for both units. The two sixteen-byte fields in the files are 89 5c 02 be e0 73 7e 69 1d 00 00 00 01 0c 00 00 and 89 5c 02 be e0 73 7e 69 23 00 00 00 01 1d 03 47, and the two Type Offset values are 0x1d and 0x23. What can you conclude, and what must you not conclude?

Apply It

Your build links correctly and your tests pass, but a colleague notices that one translation unit's view of a shared type shows a member that the other translation unit's view does not. Both were compiled with the same compiler, the same flags and the same DWARF version, from the same header, on the same day. The two objects' debug info is enormous. Given this concept, what is the most likely mechanism, and what single change would convert the failure from silent into loud?

The general habit: a field whose only job is to let something be merged or skipped is a field nobody checks. When a silent wrong answer is possible, prefer the slower comparison that can fail, at least once, on purpose.

Connect

This is the payoff for the two sentences in the split-debugging concept that said a skeleton carries an 8-byte dwo_id where a type unit would carry a 16-byte signature and offset. That was a comparison of two headers nobody had looked at. It has now been looked at, and the 16 bytes have a structure — which means the two identifier schemes are more alike than the sentence implied, since both carry a position alongside the identity.

It is also the answer to a question the types concept had to leave open: following a DW_AT_type chain is straightforward inside one unit and genuinely awkward across them, because a reference can leave the unit you are reading. This concept supplies the join. The general rule across all of DWARF is that a reference is either an offset — cheap, local, fails silently when stale — or a signature — expensive, global, fails loudly. Version 5 moved a lot of the format from the first kind to the second, and the reason is exactly that second property.

unit_type is a value and values compose, so version 5 could add kinds without adding sections. It has since added three more: DW_UT_partial for a unit whose abbrev table is shared with a unit elsewhere, and DW_UT_split_compile and DW_UT_split_type for the skeleton and split halves of split debugging. Only the type unit carries a signature, and only the split compile unit carries a dwo_id — which is the whole reason those two header fields are optional, and a reader that reads them unconditionally reads sixteen bytes of the next unit.

The remaining unit kind that appears in real files and is not yet decoded here is DW_UT_partial. It is the rarest of the four and it exists for a narrow reason: a producer that emits many units sharing one abbrev table can say "read my abbreviations from over there" instead of duplicating them. It is the unit-kind version of the indexed-string idea, and the same trade — smaller output, a global lookup, a reference that fails loudly if the other unit is missing.

What comes next is a different kind of question entirely. So far every concept has asked where is this thing. The call-site information asks what happened here: not what a variable is, or what a type is, but what the program was doing at a return address. That is a different question about the same DIE tree, and it is the last of the four unit-shaped features this module covers.

Next: Call Sites.