Back to course

Types and Type Chains

22 minadvancedModule 3: DIEs, Types, Scopes, Locations and FramesContinues from dwarf-dies
22 min · Module 3: DIEs, Types and Scopes · Algorithms

Why This Matters

Ask a debugger to print g_nest.next->x and it has to answer four questions in order: what type is g_nest, where in it does next live, what type is that, and where in that does x live. DWARF stores none of that as a single answer. It stores a graph of type DIEs joined by one attribute, and the reader walks it.

This is the last genuinely hard idea in the format. Once you can follow a DW_AT_type chain, everything a debugger prints is just this walk repeated with different starting points.

A Simple Model

Each type is one DIE. Composition is a reference: DW_AT_type holds the .debug_info offset of another DIE, and following it is a pointer chase in a file with no addresses.

Five shapes cover almost everything:

TagMeansChildren / attributes that matter
DW_TAG_base_typea leaf: int, char, doubleDW_AT_byte_size, DW_AT_encoding, DW_AT_name
DW_TAG_typedefan alias, not a new typeDW_AT_type → the real type
DW_TAG_pointer_typea pointerDW_AT_byte_size, DW_AT_type → the pointee (optional — void* has none)
DW_TAG_structure_typea struct or unionDW_AT_byte_size, one DW_TAG_member child per field
DW_TAG_array_typean arrayDW_AT_type → element type, one DW_TAG_subrange_type child for the bounds

And the members carry the offsets that make the whole thing work:

A typedef is a hop, not a shortcut.uint32_t in this file is a DW_TAG_typedef whose DW_AT_type points at another typedef, which points at the real unsigned int. Following DW_AT_type once does not give you the answer; you keep going until you reach a DIE with no DW_AT_type. Any tool that shows you uint32_t as the type of a variable is stopping early, which is a legitimate display choice but not a complete one.

The Actual Detail

The source being described, so every number below has a source you can point at:

struct Point { int x; int y; };

struct Nest {
    struct Point origin;
    char        label[8];
    uint32_t    flags;
    struct Point *next;
};

Here is the complete type graph, exactly as the reference binary records it. Offsets are .debug_info-relative and were confirmed against readelf and llvm-dwarfdump.

<0x5d> base_type  int                 byte_size 4
<0x41> base_type  unsigned int         byte_size 4
<0x48> base_type  long unsigned int    byte_size 8
<0x77> base_type  char                 byte_size 1

<0x64> typedef __uint32_t  -> <0x41>
<0x7e> typedef uint32_t     -> <0x64>
<0x8a> typedef ulong_t      -> <0x48>

<0x96> structure_type Point   byte_size 8
  <0xa1> member x  -> <0x5d> int    data_member_location 0
  <0xaa> member y  -> <0x5d> int    data_member_location 4

<0xb4> structure_type Nest    byte_size 32
  <0xbf> member origin -> <0x96> Point   offset  0
  <0xcb> member label  -> <0xf0> array   offset  8
  <0xd7> member flags  -> <0x7e> uint32_t offset 16
  <0xe3> member next   -> <0x100> pointer offset 24

<0xf0> array_type  -> <0x77> char
  <0xf9> subrange_type -> <0x48> upper_bound 7

<0x100> pointer_type  byte_size 8  -> <0x96> Point

Three verifications that this is a real type graph and not a plausible-looking invention:

  1. The struct size is the sum of its members, rounded up.Point is 8: two 4-byte ints at 0 and 4. Nest is 32: a Point at 0 (8 bytes), char[8] at 8 (8 bytes), uint32_t at 16 (4 bytes), then a pointer at 24 — not 20, because a 4-byte field followed by an 8-byte field must be padded to keep the pointer 8-byte aligned. The padding is inferred from the offsets, and it is not recorded anywhere. The gap between 20 and 24 exists in the struct and has no DIE.
  2. The chain terminates in a base type.flags is uint32_t, which is a typedef, which points at __uint32_t, which is another typedef, which points at unsigned int — a DW_TAG_base_type with no DW_AT_type. Three hops from a field to its four bytes.
  3. Every reference is an offset into the same section.<0x100> is not an address; it is a byte offset in .debug_info. Reading it as a virtual address finds nothing, and on a PIE binary it finds nothing that looks plausible either.

The inclusive bound

label is declared char label[8]. The array DIE's child says:

<2><f9> DW_TAG_subrange_type DW_AT_type <0x48> (long unsigned int — the index type) DW_AT_upper_bound : 7

Seven, not eight.DW_AT_upper_bound is an inclusive bound: valid indices run 0 to 7, so the element count is upper_bound + 1. This is a genuinely expensive mistake, because a pretty-printer that uses the value directly prints seven characters of an eight-character field and there is nothing anywhere that says the loop is wrong. The value is in range, the type is valid, and the output looks fine.

Two related things sit next to it. The index type is a separate type — DW_AT_type on the DW_TAG_subrange_type points at long unsigned int, not at int — because array indexing in C promotes to a large type, and a debugger that has to compute a byte offset needs the promoted width. And the subrange_type is a child of the array, not an attribute, which is what allows multidimensional arrays: each dimension is one child.

A Real Example

Take one expression and walk it by hand, using only the table above. The question is: what does g_nest.next->x mean, byte by byte?

  1. g_nest is a DW_TAG_variable whose DW_AT_type is <0xb4> — Nest.
  2. Look up next among Nest's members. Found at <0xe3> with DW_AT_data_member_location : 24. So the field is 24 bytes into the struct.
  3. That member's DW_AT_type is <0x100> — a DW_TAG_pointer_type. So the field holds an address, not a struct. Adding 24 to the address of g_nest gives the address of the pointer.
  4. The pointer's DW_AT_type is <0x96> — Point. Dereferencing follows this reference. The value read from those 8 bytes is the address of a Point, or zero.
  5. Now inside Point: member x at <0xa1> with DW_AT_data_member_location : 0, and its DW_AT_type is <0x5d> — int, a base type with no further DW_AT_type.
  6. The walk stops. Read 4 bytes at offset 0 of the Point and interpret them as a signed 32-bit integer.

Six steps, four DW_AT_type references and two data_member_location values. That is the whole of g_nest.next->x. Now the same walk for g_nest.label[3]:

  1. label is at offset 8, and its type is <0xf0>, an array.
  2. The array's DW_AT_type is <0x77> — char, one byte.
  3. The bound is upper_bound = 7, so 3 is in range. Index 3 is at 8 + 3 = offset 11.
  4. Read one byte at offset 11 and print it as a character.
The rule the whole format rests on. A variable is an address plus a type. A type is either a value (a base type) or a recipe for reaching a value (pointer, array, struct, union). Following the recipe is what a debugger does, and it is the same handful of steps every time. If you can walk g_nest.next->x on paper, you can read any type graph.

Try It Yourself

$ readelf --debug-dump=info shape | grep -E 'DW_TAG_(structure|member|array|subrange|pointer|typedef|base_type)|DW_AT_(type|data_member_location|upper_bound|byte_size)'

Then the exercise that turns the table above into understanding:

The array-bound check is the one to make a habit of. If you ever see a DW_AT_upper_bound equal to the element count in a DW_TAG_subrange_type child, something is off by one — in your reader, or in the tool that produced the file.

Check Your Understanding

Without looking back: a DW_TAG_subrange_type child of an array DIE carries DW_AT_upper_bound : 7, and the source said char label[8]. How many elements does the array have, and what would a reader that used the raw value print?

Apply It

You are writing a pretty-printer and it renders g_nest as {origin = {x = 1, y = 2}, label = "hi", flags = 48, next = (Nest *) 0x0} — which is exactly right. Then someone reports that a struct with three consecutive uint8_t fields prints with a gap between them. What has your reader most likely got wrong?

The general habit: when a format records derived data as a list of positions, read the positions. Never recompute a layout from the sizes — the whole purpose of recording the offsets is that the compiler, not you, knows where the padding went.

Connect

A DW_TAG_typedef that points at another typedef is the same two-step indirection as a line number resolving through a directory table, and it exists for the same reason: the string is written once and referenced by number. DWARF's PE course covers the import directory, where a similar chain of name-thunk-table-IAT resolves a function, and the resemblance is not accidental — both formats were designed to be read by a linker or loader that must do this walk cheaply.

The COFF course's relocation records solve the related problem for code: where the answer goes depends on a layout decided later, so the compiler records the question. A DW_AT_type reference is the same shape of indirection applied to types instead of addresses.

Types tell you what things are. The next concept is about where they live — which, for a local variable, is a stack offset that changes with every call.

Next: Scopes and Inlining.