Back to course

The Special Opcodes

20 minadvancedModule 2: The Line Number ProgramContinues from dwarf-standard-opcodes
20 min · Module 2: The Line Number Program · Algorithms

Why This Matters

Twenty-one of the statements in shape_v5's line program are single bytes. Each one carries a line delta and an address delta and emits a row, with no operands at all. That is the entire reason 115 bytes can describe every source line in a program.

The trick is a mixed-radix division. It is also the single most commonly implemented-wrong piece of DWARF, because the spec's five steps are ordered in a way that trips people up, and because the result looks self-consistent even when it is wrong.

A Simple Model

There is a whole family of plausible questions you could ask of one byte: how far forward, which line, which column, is this a statement, and end of function. Instead of five opcodes, DWARF picks the common case — "advance the address and the line, emit a row" — and packs three answers into one byte. Anything unusual falls back to the standard opcodes.

The packing works like a clock face that also counts steps. The byte, minus opcode_base, is one number. Division by line_range peels off the coarse part (how many instructions to skip); the remainder peels off the fine part (how many lines to move). One number, two answers, no operands.

The signed field is the trap.line_base is -5 in our file, and a special opcode with a small adjusted value computes a negative line delta. That is not a bug: it is how the compiler attributes a loop's closing brace to the line after the loop. Read the byte as unsigned and every line in the program goes wildly wrong — and, as we computed in the header concept, wrong by a suspiciously tidy 256.

The Actual Detail

The specification defines exactly five steps, in this order. The order is load-bearing:

1. operation_advance = adjusted_opcode / line_range
2. address_advance = min_inst_len × ((op_index + operation_advance) / max_ops_per_inst)
3. op_index = (op_index + operation_advance) % max_ops_per_inst
4. address += address_advance
5. line += line_base + (adjusted_opcode % line_range)

where adjusted_opcode = opcode_byte - opcode_base, division is integer division, and step 5 also emits a row with the current address, op_index, file, line, column and is_stmt.

Two details in that listing cause most of the bugs:

Our file's three constants, from the header: opcode_base = 13, line_base = -5, line_range = 14, min_inst_len = 1, max_ops_per_inst = 1.

A Real Example

Every special opcode in shape_v5, decoded from the raw bytes. Note that readelf prints the adjusted opcode, not the raw byte — its "Special opcode 12" is the raw byte 0x19 = 25 minus 13. Knowing that saves an hour of confusion the first time.

OffsetRawadjustedop_adv = adj/14line_inc = -5 + (adj%14)address →line →
0x510x19 = 25120-5 + 12 = 70x11291 → 8
0x540xd7 = 21520214-5 + 6 = 1→ 0x11378 → 9
0x570x83 = 1311188-5 + 6 = 1→ 0x113f9 → 10
0x5a0x30 = 48352-5 + 7 = 2→ 0x114110 → 12
0x5d0xbc = 18817512-5 + 7 = 2→ 0x114d12 → 14
0x600x75 = 1171047-5 + 6 = 1→ 0x115414 → 15
0x670xe4 = 22821515-5 + 5 = 0→ 0x116315 → 15
0x6a0x3d = 61483-5 + 6 = 1→ 0x116615 → 16
0x6d0x3d = 61483-5 + 6 = 1→ 0x116916 → 17

Read the table's address column down and it is the disassembly, exactly. And read the line column down and it is the source file, exactly:

0x1129  line 8    int32_t add(int32_t a, int32_t b) {     <-- add() prologue
0x1137  line 9        return a + b;                   <-- the add instruction
0x113f  line 10   }                                  <-- pop, ret
0x1141  line 12   int main(void) {
0x114d  line 14       p.x = 3;
0x1154  line 15       p.y = add(p.x, 4);
0x1163  line 15       p.y = add(p.x, 4);                <-- same line, new address
0x1166  line 16       return p.y;
0x1169  line 17   }

Three things in that table are worth pausing on.

Row 1 produces a line delta of +7 with a byte that looks tiny.0x19 = 25, adjusted 12, and 12 % 14 = 12, giving -5 + 12 = 7. This is the "skip forward to the next statement" case: the compiler is jumping from the top of the function to the first real statement, and it chose to do that with a delta of 7 lines and an address delta of 0.

Row 6 produces a line delta of exactly 0.0xe4 = 228, adjusted 215, and 215 % 14 = 5, giving -5 + 5 = 0. The line does not change but the address advances 15 bytes, so a new row is still emitted on the same source line. This is the row at 0x1163, and it exists because a single C statement compiled to two separate instruction runs. Without it, a breakpoint set on line 15 would skip half the statement.

Negative deltas are possible and expected. Nothing in this table needs one, because shape.c has no loop. A for loop in a -O0 build produces a special opcode whose adjusted value is less than 5, producing a negative line increment — the machine is stepping backwards in your source as it walks forward in the binary. If your reader treats the line delta as unsigned, the first loop it meets produces an enormous positive jump and the rest of the file is nonsense. Try it:

$ printf 'int s=0;\nfor(int i=0;i<3;i++) s+=i;\nreturn s;\n' > loop.c
$ gcc -gdwarf-5 -O0 -o loop loop.c
$ readelf --debug-dump=rawline loop | sed -n '/Line Number Statements/,$p'

Try It Yourself

Do the arithmetic by hand first, then check against readelf. Pick the first special opcode:

$ objcopy --dump-section .debug_line=dl.bin shape_v5
$ xxd -s 0x51 -l 1 dl.bin          # the byte is 0x19
$ readelf --debug-dump=rawline shape_v5 | grep '0x00000051'

Your working: raw byte 0x19 = 25. adjusted = 25 - 13 = 12. operation_advance = 12 / 14 = 0. address_advance = 1 × ((0 + 0) / 1) = 0. line_increment = -5 + (12 % 14) = -5 + 12 = 7. So the address holds at 0x1129 and the line goes 1 → 8. Readelf prints "advance Address by 0 to 0x1129 and Line by 7 to 8".

Now the whole program, and check each row against the table above:

$ readelf --debug-dump=rawline shape_v5 | sed -n '/Line Number Statements/,$p'

What to look for: that readelf's "Special opcode N" is always the adjusted value, never the raw byte. If you expect the raw byte you will think readelf is wrong. It is not; you are reading the wrong column.

Then the negative-line-delta experiment from above, which is the fastest way to make sure your own reader handles a signed line register.

Check Your Understanding

Without looking back: line_base = -5, line_range = 14. A special opcode's adjusted value is 3. What address delta and what line delta does it produce, and is the line delta legal?

Apply It

Your reader is correct on x86-64, where max_ops_per_instruction = 1, so step 3 always resets op_index to 0 and step 2 simplifies. It is now given a file from a VLIW target with max_ops_per_instruction = 4, min_inst_len = 1, and an op_index of 3 carried in from the previous opcode. A new special opcode has an adjusted value of 12. What does your reader compute, and what is correct?

The general habit: when a formula contains a term that is provably 1 on your development machine, that term is untested. Deliberately run your parser against a target where it is not 1, or at minimum write a unit test that forces it away from 1.

Connect

Mixed-radix packing for compactness is not new to you: it is the same trick as variable-length integers and as the block_align math in PE section alignment. One number, decomposed by division, encoding several independent fields within the range each can actually take.

The op_index idea is a genuine lesson about machine code, and it connects to the ELF section flags you studied earlier: it exists because instructions are not all the same length, so "how far have we moved within this instruction" is real state a decoder must carry. The same problem is why VLIW and out-of-order execution are hard.

You can now decode every statement in the program. One concept remains: what a debugger actually does with the resulting table, and the two rules that make a row mean anything at all.

Next: From Rows to Source Lines — sequences, the lookup a debugger performs, and the high_pc trap.