Back to course

The Standard Opcodes

16 minintermediateModule 2: The Line Number ProgramContinues from dwarf-file-tables
16 min · Module 2: The Line Number Program · Behaviour

Why This Matters

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.

A Simple Model

Six registers. This is the entire state a line-program reader carries between opcodes:

  1. address — where in the binary we are.
  2. op_index — which operation within the current instruction.
  3. file — which file number.
  4. line — which line number.
  5. column — which column.
  6. is_stmt — is this the start of a statement?

Opcodes fall into three groups, and the grouping is what makes them learnable:

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.

The Actual Detail

Twelve standard opcodes, with the argument counts the header published at 0x12 so you can skip any of them:

#NameArgsEffectIn shape_v5?
1DW_LNS_copy0Emit a row with the current registers, then reset the transient onesno
2DW_LNS_advance_pc1 ULEBaddress += arg × min_inst_lenyes — once, 02
3DW_LNS_advance_line1 SLEBline += arg (signed: may go backwards)no
4DW_LNS_set_file1 ULEBSet the file numberno
5DW_LNS_set_column1 ULEBSet the columnyes — 6 times
6DW_LNS_negate_stmt0Flip is_stmtno
7DW_LNS_set_basic_block0Mark the start of a basic blockno
8DW_LNS_const_add_pc0Advance address by the same rule as a special opcode with adjusted_opcode = 255no
9DW_LNS_fixed_advance_pc1 u16address += argbytes, ignoring min_inst_lenno
10DW_LNS_set_prologue_end0Mark the end of the function prologueno
11DW_LNS_set_epilogue_begin0Mark the start of the epilogueno
12DW_LNS_set_isa1 ULEBSet the instruction-set identifierno

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-opcodeNamePayloadIn shape_v5?
1DW_LNE_end_sequencenoneyes — closes the program at 0x70
2DW_LNE_set_addressone address, address_size bytesyes — sets 0x1129 at 0x46
3DW_LNE_define_filesame shape as a v4 file entryno
4DW_LNE_set_discriminator1 ULEByes — sets 1 at 0x63

A Real Example

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:

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:

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:

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.

Try It Yourself

$ 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.bin

What 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.

Check Your Understanding

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?

Apply It

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.

Connect

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.