Back to course

Directory and File Tables

17 minintermediateModule 2: The Line Number ProgramContinues from dwarf-line-header
17 min · Module 2: The Line Number Program · Structures

Why This Matters

The line program tells you that address 0x1154 is on line 15. That is not useful on its own — line 15 of what? A single binary is built from many source files, and the line program only ever refers to them by number.

The tables that turn those numbers into paths sit in the 56 bytes of header you skipped, and they are the single worst-designed corner of DWARF: two completely different encodings, one per modern version, both still in the wild, neither self-describing enough to save you.

A Simple Model

Two lists, and one indirection. The line program's rows carry a file number. The file table maps numbers to names. Each name carries a directory number, and the directory table maps numbers to paths. The full path is the directory plus the name.

That two-step structure is not decoration. It is why shape.c appears twice in our file's table: once for the compile unit itself and once for the function bodies, or in DWARF 5's case because the compiler emitted both a DWARF 4-style entry and a v5-style one. Repeating a 4-byte number is far cheaper than repeating a path.

Watch the indexing. In DWARF 2-4, file number 1 is the first file in the table, so valid numbers run 1..N. In DWARF 5, file number 0 is the first entry, so valid numbers run 0..N-1. A v5 table read with v4 indexing is off by one on every single file — and because the resulting paths still look plausible, this is the kind of bug that ships.

The Actual Detail

DWARF 4 and earlier. Two NUL-terminated string lists, run together, each ended by an empty string. No counts, no length prefixes. A file entry is the name, then three ULEB128 values: directory index, modification time, and file length — the last two being a build-system feature for telling two same-named files apart. Modern toolchains write zeros for both.

Here are the real bytes of shape_v4, where the directory table begins at 0x1c and the file table at 0x40:

00000010: 0001 0101 0100 0000 0100 0001 2f75 7372  ............/usr
00000020: 2f69 6e63 6c75 6465 2f78 3836 5f36 342d  /include/x86_64-
00000030: 6c69 6e75 782d 676e 752f 6269 7473 0000  linux-gnu/bits..
00000040: 7368 6170 652e 6300 0000 0074 7970 6573  shape.c....types
00000050: 2e68 0001 0000 7374 6469 6e74 2d69 6e74  .h....stdint-int

Read it: byte 2f at 0x1c is a literal /, and the string /usr/include/x86_64-linux-gnu/bits runs to the NUL at 0x3e. The second NUL at 0x3f is the empty string that terminates the directory list. Then shape.c\0 at 0x40, and three zero bytes at 0x48-0x4a: directory 0, mtime 0, length 0. Then types.h\0 at 0x4b and 01 00 00 at 0x53-0x55: directory 1, mtime 0, length 0. Then stdint-intn.h\0 at 0x56 and 01 00 00 again. Then a single NUL at 0x67 ends the file list.

Stop there and check: 0x68 is the line program start this file's header claims (0x0a + header_length 0x5e). The table walk landed on it exactly. That is the same self-check you used for the v5 table, and it is the cheapest bug-catcher in this whole format — a v4 and a v5 table are walked by completely different code, but both must finish on the same number.

Readelf's rendering, for comparison — note it displays these 1-based:

 The Directory Table (offset 0x1c):
  1	/usr/include/x86_64-linux-gnu/bits

 The File Name Table (offset 0x40):
  Entry	Dir	Time	Size	Name
  1	0	0	0	shape.c
  2	1	0	0	types.h
  3	1	0	0	stdint-intn.h

DWARF 5. No string bytes in the line header at all. Instead: a format descriptor saying what each entry contains and how each field is encoded, a count, and then that many entries. The descriptor is the clever part — it means a future version can add a field (a checksum, a size) without changing the layout.

Here is the real v5 table, decoded from the bytes at 0x1e onwards:

OffsetBytesMeaningValue
0x1e01directory_entry_format_count1 pair follows
0x1f-0x2001 1fdirectory_entry_formatcontent type 1 = DW_LNCT_path, form 0x1f = DW_FORM_line_strp
0x2102directories_count2 (ULEB128)
0x22-0x2500 00 00 00dir[0] path offset0x0
0x26-0x2925 00 00 00dir[1] path offset0x25
0x2a02file_name_entry_format_count2 pairs follow
0x2b-0x2e01 1f 02 0ffile_name_entry_format(path, line_strp), (directory_index, udata)
0x2f04file_names_count4 (ULEB128)
0x30-0x331d 00 00 00file[0] path offset0x1d
0x3400file[0] directory index0
0x35-0x381d 00 00 00file[1] path offset0x1d
0x3900file[1] directory index0
0x3a-0x3d48 00 00 00file[2] path offset0x48
0x3e01file[2] directory index1
0x3f-0x4250 00 00 00file[3] path offset0x50
0x4301file[3] directory index1

Every offset and count in that table was decoded independently and then required to match readelf's directory and file listings. The last entry ends at 0x44 — which is exactly the program start computed in the previous concept. The tables are self-checking: if your file-table walk does not land on the program start, you mis-parsed something.

A Real Example

None of those v5 offsets is a string. They are indices into .debug_line_str, and here is that section in full — 0x5e bytes:

00000000: 2f74 6d70 2f6f 7065 6e63 6f64 652f 6477  /tmp/opencode/dw
00000010: 6172 662d 7265 7365 6172 6368 0073 6861  arf-research.sha
00000020: 7065 2e63 002f 7573 722f 696e 636c 7564  pe.c./usr/includ
00000030: 652f 7838 365f 3634 2d6c 696e 7578 2d67  e/x86_64-linux-g
00000040: 6e75 2f62 6974 7300 7479 7065 732e 6800  nu/bits.types.h.
00000050: 7374 6469 6e74 2d69 6e74 6e2e 6800       stdint-intn.h.

Now resolve the table. DW_FORM_line_strp means "4-byte offset into .debug_line_str, read a NUL-terminated string from there":

That is byte-for-byte what readelf prints, and it accounts for the whole table. Note the payoff of the line_strp indirection: shape.c is stored once at 0x1d and referenced twice. A v4 file would have had to carry the string twice inline, because v4 has no offset form for names in this table at all.

Why is shape.c listed twice? Because GCC emits a DWARF 4-style entry alongside the v5 entry for compatibility with older consumers that only understand the first slot. File 0 is the legacy slot; file 1 is the v5 one. A reader that respects the version uses the right one and ignores the other.

Try It Yourself

$ gcc -gdwarf-5 -O0 -o shape_v5 shape.c
$ gcc -gdwarf-4 -O0 -o shape_v4 shape.c

$ readelf --debug-dump=rawline shape_v5 | sed -n '/Directory Table/,/^$/p'
$ readelf --debug-dump=rawline shape_v4 | sed -n '/Directory Table/,/^$/p'

$ readelf -p .debug_line_str shape_v5
$ objcopy --dump-section .debug_line_str=ls.bin shape_v5 && xxd ls.bin

What to look for: the v5 tables report offsets and format descriptors; the v4 tables report literal strings. Then confirm that every v5 offset, when added to the start of .debug_line_str, lands on a string start — 0x0, 0x1d, 0x25, 0x48 and 0x50 are all visible as NUL boundaries in the hex dump above.

Finally, the self-check worth building into any parser: after walking the file table, your cursor should sit exactly at the line program start. Here that is 0x44, which is the sum the header already told you to expect.

Check Your Understanding

Without looking back: a line-program row refers to file number 1. In shape_v5, which file is that, and what goes wrong if you answer with file number 1 read using DWARF 4 indexing rules?

Apply It

Your tool walks a v5 file table, reads file_names_count = 4, and loops for(i = 1; i <= count; i++) to build its lookup array. What is wrong, what will a learner see, and what is the one-line fix?

The general habit: when a table's indexing convention differs between versions, carry the convention as part of the version, not as a constant. A v5-only and a v4-only file both "have four files" here; only one of them has a file numbered 4, and neither tells you which.

Connect

Both encodings here are the offset-not-pointer idea from ELF relocations applied to data instead of code: name the thing once, reference it by number. DW_FORM_line_strp is the same shape as DW_FORM_strp, differing only in which string section it indexes — the split you learned in The .debug_* Sections.

Your reader can now get from the header to a filename. The remaining 0x2f bytes of the section are the line program itself, and the next three concepts decode it: first the standard opcodes, then the arithmetic in the special opcodes, then how a debugger actually uses the result.

Next: The Standard Opcodes — what each of the twelve byte codes does to the line program's registers.