The addressing forms ModRM gives you are [reg], [reg+disp] and [rip+disp]. Three forms. But the language you are compiling has arrays, and an array element is base + index*scale. Three forms is not enough to express arr[i].
So x86 spends a second byte on the problem, and that byte is the SIB — Scale, Index, Base. Eight bits:
And it earns its existence in one instruction, which is the most-used addressing form in compiled code on this platform:
$ ./build_samples.sh 2>&1 | sed -n '/I4/,/I5/p'
8 bits: ss(2) index(3) base(3). Two field values are special:
8b 04 24 index=100 base=rsp 8b 04 24 mov eax,DWORD PTR [rsp]
8b 04 2c index=101 = rbp 8b 04 2c mov eax,DWORD PTR [rsp+rbp*1]
8b 04 25 11223344 base=101 mod=00 8b 04 25 11 22 33 44 mov eax,DWORD PTR ds:0x44332211
8b 44 25 11 base=101 mod=01 8b 44 25 11 mov eax,DWORD PTR [rbp+riz*1+0x11]
8B 04 24 is mov eax, [rsp]. Read it: opcode 8B, ModRM 04 — rm=100, so a SIB follows — and SIB 24 = 00 100 100, which is scale 1, index “none”, base rsp. Three bytes to say “the stack pointer, with nothing added”.
And that last part is the whole reason the byte exists in that form. Base = rsp is the single most important addressing mode in x86-64, because rsp is where every local variable, every spilled register and every return address lives. A form that could say “rsp plus a scaled index” in one byte is worth a byte of encoding.
Two of the field values are special, and both of my recollections about them were wrong until they were measured. The corrections are the most useful part of this concept.
So here is the first correction, and it is the one people get wrong most often: index=100 means “no index”, and index=101 is rbp — the other way round from what most people remember. The consequence is a famous encoding gap:
Which is a real cost, paid by every compiler that generates a frame pointer. Any function with a frame pointer must emit a zero displacement to use rbp as a base at mod=00 — one wasted byte, in exchange for the rm=100 escape that buys scaled indexing everywhere else. The PIC-violation concept measured a related squeeze in the same register file; this is the encoding-level reason rbp is awkward.
The second correction is about something simpler and it cost a rebuild cycle to find, because it is not a special value at all — it is an ordering:
and the BYTE ORDER, which is not the order you would guess:
8b 84 24 11 22 33 44 SIB then disp 8b 84 24 11 22 33 44 mov eax,DWORD PTR [rsp+0x44332211]
8b 84 11 22 33 44 24 disp then SIB 8b 84 11 22 33 44 24 mov eax,DWORD PTR [rcx+rdx*1+0x24443322]
both are valid instructions, and they are DIFFERENT. The SIB comes
immediately after the ModRM, BEFORE the displacement.
ModRM, then SIB, then displacement, then immediate. Both byte strings above are valid and they are different instructions: the first is [rsp + 0x44332211] and the second is [rcx + rdx*1 + 0x24443322]. The second looks like the obvious order — address first, then the extra byte — and it is wrong.
The reason is structural rather than aesthetic. The ModRM must be able to say “there is more to this address” without knowing what the more is, so it signals the SIB with rm=100 before either appears. The SIB in turn may reveal that the displacement is four bytes rather than one, and by then both bytes are already placed. The order is forced by the dependency, and the dependency is the reason the byte exists.
Every SIB byte, decomposed, with the scale field measured separately:
$ ./build_samples.sh 2>&1 | sed -n '/scale field/,/I5/p' ss=0 8b 44 05 11 mov eax,DWORD PTR [rbp+rax*1+0x11] ss=1 8b 44 45 11 mov eax,DWORD PTR [rbp+rax*2+0x11] ss=2 8b 44 85 11 mov eax,DWORD PTR [rbp+rax*4+0x11] ss=3 8b 44 c5 11 mov eax,DWORD PTR [rbp+rax*8+0x11]
Scale is a two-bit shift, not a two-bit number — 00 maps to 1, not 0, because an address scaled by zero is not a thing anyone wants to write. ss=0,1,2,3 gives ×1, ×2, ×4, ×8, all four measured.
And the “no index” case shows up in the oracle’s output as a register literally named riz — the “zero index register”, a pseudo-register with the value zero. It is not a real register; it is how a disassembler prints an absent field:
$ ./build_samples.sh 2>&1 | sed -n '/index=100/p' 8b 04 24 index=100 base=rsp 8b 04 24 mov eax,DWORD PTR [rsp] 8b 44 25 11 base=101 mod=01 8b 44 25 11 mov eax,DWORD PTR [rbp+riz*1+0x11]
Read those two together. 8B 04 24 prints a bare [rsp] with no index at all, while 8B 44 25 11 prints [rbp+riz*1+0x11] with a visible zero index. Same field value, different presentation — because in the first the index field is 100 and in the second the ModRM is 44, so rm=100 and the SIB is 25 = 00 100 101, index none, base 101 which at mod=01 is rbp with a disp8. The riz appears because the SIB byte exists at all, and the printer does not special-case the “none” value when it is already committed to printing a SIB-shaped operand.
Now the part with a real consequence for a linker. When the base is 101 and mod=00, the displacement is four bytes and it is an absolute address:
$ python3 x86dec.py --why 8b 04 25 11 22 33 44
offset 0, 7 bytes: 8b 04 25 11 22 33 44
mov eax,DWORD PTR [0x44332211]
. byte 8b at 0 is a one-byte opcode
. byte 04 at 1 is the ModRM byte: mod=0 reg=0 rm=4
. mod=00: no displacement, except rm=101 which is
. RIP-relative and carries a 4-byte displacement
. rm=100 means a SIB byte follows: 25 at 2
. ss=0 -> scale 1, index=4, base=5
. index=100 with REX.X=0 means NO INDEX
. base=101 with mod=00 means NO BASE: the
. displacement is a 4-byte absolute address
. 4 bytes displacement at 3, value 0x44332211 (1144219153)
. read little-endian, so the LOW byte is at the LOW
. address -- 11 22 33 44 means 0x44332211
That is a two-level dependency, and it is the subtlest thing in the addressing encoding. The ModRM said “mod 00, rm 100, so there is more.” The SIB said “base 101.” And only then could the decoder conclude that a four-byte displacement follows, when the ModRM’s mod=00 had implied none. A decoder that computed the length from the ModRM alone gets this case wrong by four bytes — and a decoder that gets it wrong does not report an error, it reports everything after this instruction as nonsense.
Which is why the crosscheck audits every entry of the opcode table rather than testing examples. The table audit found this class of bug in a different place — the length concept has the full accounting — and it is the argument for checking a rule across its whole input space instead of at the cases you thought of.
Step back and ask whether the SIB is a good design, because the answer explains why x86 looks the way it does everywhere else.
Compare the two encodings of the same operation. Without a SIB, an array access arr[i] with a scaled index needs a scratch register or a self-modifying sequence. With it, one byte:
That is the trade, and it is the trade the whole ISA is built on: one byte of encoding to avoid a sequence of instructions. It is also why the register-save cost of the feature is zero — the scratch-register version has to preserve ecx, the SIB version preserves everything because it reads no register it writes.
Now the interesting counter-argument, because a good course should give the design its weakness as well as its strength. Scale is limited to 1, 2, 4 and 8, and the index is limited to eight registers before REX. So:
So the SIB solves one problem completely and adjacent problems partially, and the partial solutions are exactly where compilers still emit arithmetic. “base + index*scale” is not a general array-addressing unit; it is the innermost loop of one. Knowing the boundary is more useful than having the feature, and it is the same shape as the limits the encoding-limits course found for position independence: enough expressiveness to compile most code directly, and a long tail that needs instructions around it.
One more measured detail, because it is a trap in reading a disassembly. The decoder prints [rsp+32] where the oracle prints [rsp+0x20] — same address, different notation — because the decoder prints displacements in decimal and the oracle in hex with a 0x prefix. The image course hit the same class of problem with %018lx printing a zero with no prefix, and the answer was the same: a number that is missing its prefix is a number you will misread in a log. Notation is not cosmetic when you are comparing two tools’ output by eye.
$ cd courses/isa/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/I4/,/I5/p' $ python3 x86dec.py --why 8b 04 25 11 22 33 44 $ python3 crosscheck.py 2>&1 | sed -n '/^I4/,/^I5/p'
Then write the field decoder and check it across the whole space:
1. Write a SIB decomposer and run it over all
256 values, printing scale, index and base:
for sib in range(256):
ss, ix, bs = sib >> 6, (sib >> 3) & 7, sib & 7
print(sib, 1 << ss, ix, bs)
Now annotate each of the 256 with what it
actually means, treating index=100 and
base=101 as special. How many distinct
address FORMS are there in total?
(Fewer than 256, because ss is a scale not an
address component: 4 scales x 8 indices x
8 bases, minus the two special values, plus
the two "no base" cases. Count them -- the
answer is around 240, which is the point:
the byte is nearly injective and was designed
to be.)
2. Find the rbp gap empirically. Try to encode
[rbp+rsi*1] with no displacement and show
that you cannot:
# mod=00 rm=101 is RIP-relative, not rbp
# mod=00 rm=100 + SIB base=101 is absolute
# so rbp at mod=00 with no disp is unreachable
Then write the smallest thing that IS
reachable and check it:
8b 44 25 00 mov eax,[rbp+riz*1+0x0]
(One wasted byte. Then compile a function
with a frame pointer and count the zero
displacements clang emits -- that is the
price, measured on real code rather than
argued from the spec.)
3. Now the dependency. Write a length function
that uses ONLY the ModRM, and run it over
every SIB byte at mod=00. Count the failures:
for sib in range(256):
bs = bytes([0x8b, 0x04, sib]) + disp4
...
(32 failures: every SIB with base=101. The
ModRM said mod=00 so no displacement, and the
SIB said otherwise. A decoder must read both
before it can size the instruction, and there
is no shortcut.)
4. Test the scale field against reality. Compile
the same loop four ways and find the SIB byte
in each:
for s in 1 2 4 8; do
printf 'int f(char*a,long i){return a[i*%d];}\n' $s
done
(Expect *1, *2 and *4 to use the SIB scale
field directly, and *8 to need a shift or an
lea. The scale field tops out at 8, and finding
where the hardware stops helping is more
informative than confirming where it starts.)
5. Finally, the experiment that makes the SIB
legible: count SIB bytes in real code and
correlate with what the source was doing.
objdump -d /bin/ls | grep -c 'rsp\*'
objdump -d /bin/ls | grep -oE '\((%r[a-z0-9]+),%r[a-z0-9]+(,[124])?\)' \
| sort | uniq -c | sort -rn | head -20
The top patterns will be dominated by [rsp]
and [rsp+N] with scale 1 -- which is stack
traffic, and stack traffic is [rsp] because
rsp IS the stack. Then look for the rare
scaled forms and read the surrounding code:
they are array indexing, and they are rarer
than you would expect from reading C, which
is itself the finding from step 4 -- most
array access in compiled code is a pointer
that was already advanced, not an index that
is being scaled.
Exercise 5 is the one with the most surprising answer, and it is worth doing slowly. Scaled-index addressing is rare in real compiled code — the dominant SIB patterns are [rsp] and [rsp+N], which is stack traffic, and riz-free base-only forms. The reason is that C array decay means a compiler advances a pointer rather than scaling an index: p++ is an add, and the SIB is reserved for the cases where a multiply genuinely happens. The hardware feature was designed for array indexing and is mostly used for stack frames, which is a good reminder that what an encoding supports and what a compiler uses it for are different questions.
Exercise 2 is the one that pays off in the hardening and relocations courses. The rbp gap is a measured encoding cost, visible in every frame-pointer function as a wasted displacement byte — and it exists because rm=101 was taken by RIP-relative addressing before anyone needed rbp as a displacement-free base. That is a historical accident frozen into the encoding, and it is the same category of finding as the mode collisions: a slot that got used, and could not be given back.
The connection to the relocations course is where this concept’s arithmetic turns into a number, and it is the most direct technical debt in the course. That course measured that position independence costs twice the fixups and explained it as “a PIC access needs a PC32 against a symbol, and a data access needs an absolute 32-bit, so two relocations where non-PIC needs one.” That is the mod=00 rm=101 form — the one this concept measured as “RIP-relative, always a 4-byte displacement” — and the reason a PIE has more relocations than a non-PIE is one bit in one ModRM byte. The chain had that number for two courses and no mechanism; this is the mechanism.
The connection to the stack concept in the image course is that the ModRM concept already flagged the dominant use. That course measured a real frame: the stack grows down, rsp is the low end, and every local is at [rsp+N] with a compile-time-constant N. So “scale 1, index none, base rsp” — a three-byte instruction — is the machine code for a local variable, and the N is the offset the frame concept measured. The image course showed you the frame; this course shows you its encoding, and the two are the same bytes.
Two connections outward, both about the dependency chain rather than the field layout. The PIC concept in the object course needed the GOT and could not say how a compiler reaches it; RIP-relative addressing is how, and it is this concept’s mod=00 rm=101 case plus a REX byte. And The RELRO concept in the hardening course measured that .got.plt has a tail that crosses the end of the sealed range; the GOT slots are addressed with base-relative forms whose displacement width is exactly what the ModRM concept’s table says, so a linker that needs to know whether a GOT reference is 3 or 6 bytes is asking the question this concept answers.
One limit, and it is stated because it is the kind of thing that gets quietly assumed. The index=100 rule is conditional on REX.X, and this course’s own oracle gets that conditional wrong — it ignores REX.X on the SIB index entirely, printing [rsp] where the field layout says [rsp+r12*1]. The decoder applies it and the oracle does not, and the crosscheck compares only instruction boundaries so the two are never asked the question they disagree about. A reader who takes “index 100 means no index” as unconditional will get a confident wrong answer on exactly one input, which is the worst possible shape for a rule: almost always right, silently wrong once, and the once is the case that matters.