The architecture-table concept asked a linker two questions and refused to answer either:
The answer is not a lookup table. It is two three-bit fields, and together they decide the length of the instruction — every other field in the encoding is a value, but these two are a grammar. That is a categorically different kind of thing, and it is why an x86 decoder has to read them before it can say anything.
$ ./build_samples.sh 2>&1 | sed -n '/I3/,/I4/p'
all 256 ModRM bytes for opcode 8b /r (MOV r32, r/m32):
bytes that did NOT decode as exactly one instruction: 0 []
mod=11 (register) forms: 64 of 256
mod!=11 (memory) forms: 192 of 256
the four mod classes, one representative each:
mod=00 rm=000 [reg] 8b 00 mov eax,DWORD PTR [rax]
mod=00 rm=100 SIB 8b 04 24 mov eax,DWORD PTR [rsp]
mod=00 rm=101 RIP+disp32 8b 05 44 33 22 11 mov eax,DWORD PTR [rip+0x11223344]
mod=01 rm=000 [reg+disp8] 8b 40 11 mov eax,DWORD PTR [rax+0x11]
mod=10 rm=000 [reg+disp32] 8b 80 11 22 33 44 mov eax,DWORD PTR [rax+0x44332211]
mod=11 rm=000 register 8b c0 mov eax,eax
All 256 values, each completed with the trailing bytes its own form requires, and every one decodes as exactly one instruction. That is not a triviality — it is the reason the encoding is a success. A 256-way space where all 256 values are usable, in an instruction set that has been extended for thirty years by collision, is a genuinely well-designed table.
And the split is memorable: mod=11 is exactly 64 of the 256 — a clean quarter — and those are the register forms. The other 192 are memory forms, and within those the mod field is choosing how to spend the next few bytes.
Eight bits, three fields, and the two that do the structural work:
Read the mod=00 exception carefully, because it is the one that breaks the tidy pattern. Normally mod=00 means no displacement, so a two-byte instruction. But rm=101 means no base register either — and an address with no base register must be relative to something, so the encoding switches to RIP-relative with a mandatory disp32.
And that gap is the answer to Q1a, stated as a rule a linker can implement:
That is the load-bearing result of the concept, and it is what makes the whole encoding tractable. The prefix bytes change what the instruction means; mod and rm change how long it is. A linker that needs the length has to decode exactly two fields and nothing else, which is a much smaller job than decoding the instruction.
So: how many bytes is 8B 05 11 22 33 44? The ModRM is 05 = 00 000 101. mod=00, rm=101 — the RIP-relative exception — so a disp32 follows, and the instruction is six bytes. And the disp32 starts at offset 2.
$ python3 x86dec.py --why 8b 05 44 33 22 11
offset 0, 6 bytes: 8b 05 44 33 22 11
mov eax,DWORD PTR [rip+0x11223344]
. byte 8b at 0 is a one-byte opcode
. byte 05 at 1 is the ModRM byte: mod=0 reg=0 rm=5
. mod=00: no displacement, except rm=101 which is
. RIP-relative and carries a 4-byte displacement
. 4 bytes displacement at 2, value 0x11223344 (114421920)
. read little-endian, so the LOW byte is at the LOW
. address -- 11 22 33 44 means 0x44332211
So Q1b has an answer too: the field starts at offset 2, and it is read little-endian so the byte at the lowest address is the least significant. That is the last piece obj-arch-table needed, and it is a complete answer to a question that course correctly refused to guess at.
Now where the tidy rule breaks, which is where the interesting part is. There is one family of opcodes where rm does not mean an address at all — it selects the operation. One byte covers eight different instructions:
$ python3 x86dec.py 83 c0 01 83 c8 01 83 f0 01 f6 c0 01 f6 d0 83 c0 01 add eax,0x1 83 c8 01 or eax,0x1 83 f0 01 xor eax,0x1 f6 c0 01 test eax,0x1 f6 d0 not eax
Same opcode byte, same length for the first three, and the operation comes from the reg field — the field that elsewhere names a register. And the last two are the sharpest case in the encoding: F6 is one byte, and whether an immediate follows depends on the reg field too.
Which breaks the rule I stated one paragraph ago, and the correction is instructive rather than embarrassing. The claim “only mod and rm decide the length” is true of the operand encoding and false of the opcode groups. For 80, 81, 83, C0, C1, D0–D3, F6, F7, FE and FF, the reg field participates in the length. And that is a real design tension worth naming: the same field means “a register” in most opcodes and “which operation” in these ten, and a decoder has to know which before it can proceed.
The encoding-limits course measured a related squeeze: position independence doubled the relocation count because the disp32 needed a R_X86_64_PC32 against a symbol that also needed one. This is the mechanism underneath that number — the mod=00 rm=101 form is the one the compiler emits for every position-independent access, because it is the only encoding that needs no base register.
Since mod and rm decide the trailing byte count, a decoder does not need a table of instruction lengths. It needs four lines of arithmetic, and that is the difference between a table that can be wrong and a rule that cannot:
That last clause is worth dwelling on, because it is a genuine two-level dependency. You cannot compute the length from the ModRM alone when rm=100 — you have to read the SIB byte first, and the SIB byte’s base field can then change the displacement width. So the decoder’s read order is forced: ModRM, then SIB, then displacement. There is no way to parallelise it and no way to guess it.
Which gives the correct shape for a length oracle a linker could use, and it is worth writing down because it is the artifact of a concept that otherwise stays theoretical:
And the honest caveat: that sketch is not the whole decoder, and the reason is instructive. Opcode groups, the 0F escape, 0F 38 and 0F 3A, mandatory prefixes like F3 0F 1E FA, and the Rex.W-does-not-widen-the-immediate rule all sit outside the four lines. The arithmetic gets the 192 memory forms right on its own; everything else needs a table. The decoder concept is about which parts need which.
$ cd courses/isa/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/I3/,/I4/p' $ python3 x86dec.py --why 8b 05 44 33 22 11 $ python3 crosscheck.py 2>&1 | sed -n '/^I3/,/^I4/p'
Then build the length function yourself, which is the exercise that turns the concept into code:
1. Write `trailing(modrm)` from the model above
-- four lines -- then check it against the
oracle for all 256 values:
for m in range(256):
bs = full_instruction_for(m) # supply the bytes
n, _ = ora(bs)
assert trailing(m) == n - 2 - opcode_bytes(m)
(Expect agreement on the 192 memory forms and
confusion on the 64 register forms if you forget
that mod=11 means ZERO trailing bytes and the
base count already includes the ModRM itself.
Off-by-one here is the single most common bug
in a disassembler and it desynchronises
everything after it.)
2. Now the case that made the decoder wrong the
first time: rm=100 with a SIB whose base is
101 at mod=00. Enumerate it:
for sib in range(256):
bs = bytes([0x8b, 0x04, sib]) + \
(b'\x11\x22\x33\x44' if (sib & 7) == 5 else b'')
...
How many of the 256 SIB bytes need a 4-byte
displacement, and how many need none? (Expect:
base=101 at mod=00 is the only one, so 32 of
256 -- one for each ss/index combination.)
3. Write a length-only decoder and run it over a
real .text, requiring the chain to consume the
section exactly. Do NOT resolve operands, do
NOT name instructions. How few lines is it?
def length(buf, i, end):
...
(Around thirty, once the opcode table is
included. The point is that a LENGTH decoder is
small -- and it is enough to answer Q1a, which
is all a linker ever needed. Everything else in
this course is for the human.)
4. Find the opcodes where the reg field affects
the LENGTH rather than naming a register. The
decoder's table has them:
grep -n "GROUP_IMM_ONLY" -A6 x86dec.py
For each, state the rule. Then check: is there
any opcode where the reg field affects the
length in a way that is NOT "reg 0 and 1 have
an immediate"? (The 0F BA group is one:
BT/BTS/BTR/BTC take an imm8, and the other four
members do not. Same shape, different members.)
5. Finally, the one that ties back to the course
that asked the question. Take a real relocation
against a real instruction and locate the field
by hand, then check with the decoder:
readelf -rW /bin/ls | head -5
objdump -d /bin/ls | head -40
Pick a PC32 relocation. Find the instruction
the relocation entry points into. Compute where
the disp32 is using mod and rm. Read the 4
bytes and check they are the addend.
(This is exactly the workflow obj-arch-table said
a linker performs and declined to do. Doing it
once by hand is worth more than the whole
concept, and doing it twice is worth more than
that -- because the second time you will not
need the decoder and the first time you will
get it wrong.)
Exercise 3 is the one with the best answer in the concept, and the answer is a deflation that is also the point. A length-only decoder is about thirty lines and it answers the linker’s question completely. Everything else — the mnemonics, the operand text, the SIB arithmetic, the condition codes — exists for a human reading the result. The mission of this collection is to parse a format into a working executable, and the honest shape of that here is: the part a machine needs is small, and the part a person needs is the course.
Exercise 5 is the one that closes the loop. Locating a disp32 inside a real instruction by hand, using only mod and rm, is the workflow obj-arch-table described and would not perform. The chain built a linker course that needed this and an architecture table that named the need, and left a gap between them. This is the gap, and the first time through you will get it wrong — which is exactly the argument for the concept existing rather than a table of answers.
The connection to obj-arch-table is the whole concept and it deserves to be stated without hedging. That concept asked “how many bytes is the instruction?” and said a linker must decode it; this concept says the linker must decode exactly two three-bit fields, and here is the four-line function that does it. The chain had a real gap — a format whose consumers were told they needed a capability no course taught — and the collection’s own method found it: measure, notice what is missing, build the thing.
The connection to the linker-script concept is about where these instructions end up. That course found the default script’s rule for .plt and noted the section is sometimes empty; a .plt entry is jmp [rip+disp32] for a lazily-bound function, which is FF 25 followed by a ModRM of mod=00 rm=101 and a disp32 — six bytes, and under full RELRO the form changes to jmp [rip+disp32] through a different register so the table can be sealed. Two courses and a hardening flag, all visible in one ModRM byte.
Two connections outward, both about fields doing double duty. The addend concept measured why a disassembler prints a symbol as “minus four”, and the answer is in this unit: the four is the disp32 field, and the printed value is the target, so the field’s contents are the target less four. That is a link-time convention living inside an instruction encoding, and it is the clearest example in the chain of a value meaning one thing in the file and another on screen. And The SIB concept is where rm=100 stops the story: this concept establishes that a SIB byte may follow, and the next one is about what it contains and why one of its field values also changes the displacement width.
One limit, and it is the same limit this course keeps hitting honestly. Everything here is x86-64, measured on one machine, against one reader. Where that reader is known to be wrong — it ignores REX.X on the SIB index — the crosscheck compares only what both tools are reliable about, and the disagreement is written down rather than resolved by assertion. The four-line length function is portable in its shape to any variable-length ISA and useless without the table, and the DWARF standard-opcodes concept covers the other side of that trade for a fixed-width architecture.