The line program is a tiny state machine. It holds a handful of registers — where we are, which line, which file — and a stream of opcodes mutates them. Nothing in it says "this instruction is on line 9"; it says "move forward 6 bytes and add 1 to the line", and the reader is expected to notice that a new row has begun.
That design is why the format is so compact: 115 bytes describe 21 distinct address-to-line rows. It is also why you cannot skim it. Every opcode you misread shifts the cursor, and every shift produces a plausible-looking wrong address.
Six registers. This is the entire state a line-program reader carries between opcodes:
address — where in the binary we are.op_index — which operation within the current instruction.file — which file number.line — which line number.column — which column.is_stmt — is this the start of a statement?Opcodes fall into three groups, and the grouping is what makes them learnable:
set_file, set_column, set_isa).advance_pc, advance_line).copy, negate_stmt, set_basic_block, const_add_pc, fixed_advance_pc, set_prologue_end, set_epilogue_begin).Plus a separate extended family, signalled by a leading 0x00 byte: end_sequence, set_address, define_file, set_discriminator. These carry their own length so a reader can skip them, and they are what actually start and stop a sequence.
Twelve standard opcodes, with the argument counts the header published at 0x12 so you can skip any of them:
| # | Name | Args | Effect | In shape_v5? |
|---|---|---|---|---|
| 1 | DW_LNS_copy | 0 | Emit a row with the current registers, then reset the transient ones | no |
| 2 | DW_LNS_advance_pc | 1 ULEB | address += arg × min_inst_len | yes — once, 02 |
| 3 | DW_LNS_advance_line | 1 SLEB | line += arg (signed: may go backwards) | no |
| 4 | DW_LNS_set_file | 1 ULEB | Set the file number | no |
| 5 | DW_LNS_set_column | 1 ULEB | Set the column | yes — 6 times |
| 6 | DW_LNS_negate_stmt | 0 | Flip is_stmt | no |
| 7 | DW_LNS_set_basic_block | 0 | Mark the start of a basic block | no |
| 8 | DW_LNS_const_add_pc | 0 | Advance address by the same rule as a special opcode with adjusted_opcode = 255 | no |
| 9 | DW_LNS_fixed_advance_pc | 1 u16 | address += argbytes, ignoring min_inst_len | no |
| 10 | DW_LNS_set_prologue_end | 0 | Mark the end of the function prologue | no |
| 11 | DW_LNS_set_epilogue_begin | 0 | Mark the start of the epilogue | no |
| 12 | DW_LNS_set_isa | 1 ULEB | Set the instruction-set identifier | no |
Two rows deserve a warning, because both are places where implementations and the spec's letter have historically parted company.
DW_LNS_copy does not appear once in our program, and that is not a mistake. In practice GCC emits rows via special opcodes, which implicitly emit a row as part of advancing. copy is the explicit "a row ends here" opcode, and a compiler that wants to break a row without changing the address uses it. A reader must still implement it: a program that used it would break entirely otherwise.
DW_LNS_advance_pc and DW_LNS_fixed_advance_pc are not the same opcode. The first multiplies its argument by minimum_instruction_length; the second adds its argument as raw bytes. On x86-64, where min_inst_len = 1, they coincide — which is exactly why the bug survives on the platform most people test on. On a machine where the minimum instruction length is 4, the same byte means two different distances.
And the extended opcodes, whose leading 0x00 marks them, followed by a ULEB block length:
| Sub-opcode | Name | Payload | In shape_v5? |
|---|---|---|---|
| 1 | DW_LNE_end_sequence | none | yes — closes the program at 0x70 |
| 2 | DW_LNE_set_address | one address, address_size bytes | yes — sets 0x1129 at 0x46 |
| 3 | DW_LNE_define_file | same shape as a v4 file entry | no |
| 4 | DW_LNE_set_discriminator | 1 ULEB | yes — sets 1 at 0x63 |
Here are the two opcodes that actually occur, read straight out of the program bytes. The program begins at 0x44.
DW_LNS_set_column at 0x44 — the bytes are 05 23:
05 → opcode 5, which the table says is set_column with 1 argument.23 → ULEB128. A single byte with the high bit clear, so the value is 0x23 = 35. column = 35.Readelf agrees: [0x00000044] Set column to 35. Six of these appear, with arguments 35, 14, 1, 16, 9, 11, 9, 13, 1 — and every one of those single-byte arguments decodes the same way. A ULEB128 below 0x80 is just its own value; the multi-byte encoding only kicks in at 128.
DW_LNE_set_address at 0x46 — the bytes are 00 09 02 29 11 00 00 00 00 00:
00 → not a standard opcode, so this is an extended opcode.09 → the block length, 9 bytes, counting everything after this field. So the opcode spans 0x46-0x4f inclusive, which is exactly ten bytes — the length byte plus nine.02 → sub-opcode 2 = set_address.29 11 00 00 00 00 00 00 → eight bytes, because address_size = 8, little-endian: 0x1129.Readelf: [0x00000046] Extended opcode 2: set Address to 0x1129. Note that the length byte is the reason extended opcodes are skippable — a reader that meets an extended opcode it does not understand can jump length bytes and resynchronise. Standard opcodes have no such escape hatch, which is exactly what standard_opcode_lengths is for.
One more, because it looks like a mistake and is not. At 0x63 the bytes are 00 04 04 01:
00 → extended.04 → block length 4, spanning 0x63-0x67.04 → sub-opcode 4 = set_discriminator.01 → ULEB value 1.Readelf: [0x00000063] Extended opcode 4: set Discriminator to 1. The discriminator distinguishes multiple line rows for the same address — which happens when the compiler maps several source positions onto one machine instruction, as it does with short-circuit operators. One instruction, several source lines, disambiguated by an integer.
$ gcc -gdwarf-5 -O0 -o shape_v5 shape.c
$ readelf --debug-dump=rawline shape_v5 | sed -n '/Line Number Statements/,$p'
$ objcopy --dump-section .debug_line=dl.bin shape_v5
$ xxd -s 0x44 -l 32 dl.binWhat to look for: the set_column opcodes are all 05 xx and the extended opcodes are all 00 LL. Confirm that every 00 you see is followed by a length that lands exactly on the next statement offset readelf printed — that is the skip rule working.
Then make the compiler use the opcodes that are currently absent. Turn on optimisation and short-circuit operators, and watch set_discriminator multiply:
$ cat d.c
int f(int a, int b) { return a && b + (a ? 1 : 2); }
$ gcc -gdwarf-5 -O0 -o d d.c
$ readelf --debug-dump=rawline d | sed -n '/Line Number Statements/,$p'You will see several rows sharing one address, each with a different discriminator. That is the feature the previous concept's table row was pointing at.
Without looking back: the program contains DW_LNS_advance_pc with the single argument byte 02, and minimum_instruction_length is 1. What address change does that opcode produce, and what would the same byte produce on a target where minimum_instruction_length is 4?
You are writing a line-program reader and hit an opcode byte of 0x0d — thirteen, which is exactly opcode_base. Your code treats anything at or above opcode_base as a special opcode and runs the division and modulo. What is wrong, and what should it do instead?
The general habit: read the bounds from the header, do not hard-code them. opcode_base = 13 is a property of this file, not of DWARF, and the same two-branch dispatch works for any file once those three numbers come from the header.
This is a bytecode interpreter, and the tradeoffs are the same ones you would make writing any: a table of opcodes, a small register file, and a loop. The op_index register is the same idea as the operation index in the ELF entry point concept — state that lets a variable-length encoding resume mid-instruction.
The block length on extended opcodes is the same self-describing-length trick as unit_length, for the same reason: a reader must be able to skip what it does not understand. You saw it in the .debug_line header and in DWARF 2, 3, 4 and 5.
Only two of the twelve standard opcodes appeared in shape_v5. The other ten, and the arithmetic that makes the common case a single byte, are the subject of the next concept.
Next: The Special Opcodes — one byte, two divisions, and the arithmetic that decides the address and the line.