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.
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:
| Tag | Means | Children / attributes that matter |
|---|---|---|
DW_TAG_base_type | a leaf: int, char, double | DW_AT_byte_size, DW_AT_encoding, DW_AT_name |
DW_TAG_typedef | an alias, not a new type | DW_AT_type → the real type |
DW_TAG_pointer_type | a pointer | DW_AT_byte_size, DW_AT_type → the pointee (optional — void* has none) |
DW_TAG_structure_type | a struct or union | DW_AT_byte_size, one DW_TAG_member child per field |
DW_TAG_array_type | an array | DW_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:
DW_TAG_member with DW_AT_data_member_location — the byte offset from the start of the struct.DW_TAG_subrange_type with DW_AT_upper_bound — and here is the trap this concept exists for.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 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:
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.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.<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.label is declared char label[8]. The array DIE's child says:
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.
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?
g_nest is a DW_TAG_variable whose DW_AT_type is <0xb4> — Nest.next among Nest's members. Found at <0xe3> with DW_AT_data_member_location : 24. So the field is 24 bytes into the struct.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.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.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.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]:
label is at offset 8, and its type is <0xf0>, an array.DW_AT_type is <0x77> — char, one byte.upper_bound = 7, so 3 is in range. Index 3 is at 8 + 3 = offset 11.g_nest.next->x on paper, you can read any type graph.$ 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:
double scale; — to struct Nest, recompile, and diff the member list. The new member's data_member_location tells you where the compiler chose to put it, and the answer is not always the end.double comes first. The byte_size of the struct usually does not change, but every data_member_location does. The layout is the compiler's decision; DWARF only records it.flags from uint32_t to plain unsigned and watch the two typedef DIEs disappear from the tree entirely. The struct's size and the field's offset do not change — the graph gets shorter, and the bytes are identical.label[8] to label[9] and confirm DW_AT_upper_bound becomes 8. It tracks the source exactly, and it is always one less than the count.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.
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?
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.
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.