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.
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.
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.hDWARF 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:
| Offset | Bytes | Meaning | Value |
|---|---|---|---|
| 0x1e | 01 | directory_entry_format_count | 1 pair follows |
| 0x1f-0x20 | 01 1f | directory_entry_format | content type 1 = DW_LNCT_path, form 0x1f = DW_FORM_line_strp |
| 0x21 | 02 | directories_count | 2 (ULEB128) |
| 0x22-0x25 | 00 00 00 00 | dir[0] path offset | 0x0 |
| 0x26-0x29 | 25 00 00 00 | dir[1] path offset | 0x25 |
| 0x2a | 02 | file_name_entry_format_count | 2 pairs follow |
| 0x2b-0x2e | 01 1f 02 0f | file_name_entry_format | (path, line_strp), (directory_index, udata) |
| 0x2f | 04 | file_names_count | 4 (ULEB128) |
| 0x30-0x33 | 1d 00 00 00 | file[0] path offset | 0x1d |
| 0x34 | 00 | file[0] directory index | 0 |
| 0x35-0x38 | 1d 00 00 00 | file[1] path offset | 0x1d |
| 0x39 | 00 | file[1] directory index | 0 |
| 0x3a-0x3d | 48 00 00 00 | file[2] path offset | 0x48 |
| 0x3e | 01 | file[2] directory index | 1 |
| 0x3f-0x42 | 50 00 00 00 | file[3] path offset | 0x50 |
| 0x43 | 01 | file[3] directory index | 1 |
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.
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":
/tmp/opencode/dwarf-research/usr/include/x86_64-linux-gnu/bits/tmp/opencode/dwarf-research/shape.c/usr/include/x86_64-linux-gnu/bits/types.h/usr/include/x86_64-linux-gnu/bits/stdint-intn.hThat 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.
$ 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.binWhat 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.
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?
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.
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.