Back to course

SIB: The Byte That Exists Because rsp Is Special

23 minadvancedModule 3: AddressingContinues from isa-modrm
23 min · Module 3: Addressing · Advanced

Why This Matters

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:

SIB = s s i i i b b b | | | | | +-----+ base (3 bits) | | | +-------+ index (3 bits) +---------+ scale (2 bits) scale 00=1 01=2 10=4 11=8 a SHIFT, not a value index a register, or "none" see below base a register, or "none" see below the address is base + index*scale + displacement

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.

A Simple Model, and Two Retractions

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.

index = 100 (0b100) NO INDEX, when REX.X = 0 index = 101 (0b101) rbp -- an ordinary register base = 101 (0b101) NO BASE, but ONLY at mod = 00, where the disp32 becomes an ABSOLUTE address. at mod = 01 or 10 it is simply rbp. the asymmetry is not a typo. it is what makes an absolute address expressible at all: an address with no base register can only be written as a displacement from nothing, and the only "nothing" available is zero -- which is what the compiler uses for a genuine absolute address, and what a linker refuses to produce in a PIE.

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:

you cannot encode [rbp + rsi*1] without also adding a displacement. [rsp + rsi*1] index=110 base=100 fine [rbp + rsi*1] index=110 base=101 fine [rbp + rsi*1] with NO disp IMPOSSIBLE why: rbp is index=101, and index=101 is rbp, so to use rbp as a BASE with no displacement you would need base=101 at mod=00, and that means "no base, absolute address". the encoding has no way to say "rbp" there. the 32-bit encoding did not have this problem, because rm=101 at mod=00 was not taken -- it became RIP-relative, and 64-bit mode inherited the restriction.

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.

All 256 Bytes, and What They Say

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.

Why One Byte Was Worth It

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:

arr[i] where arr is in rbx and i in rax: 8B 04 98 mov eax, [rax*4 + rbx] one opcode, one ModRM, one SIB. no scratch register, no second instruction. the same access WITHOUT a SIB, which is what a 32-bit-mode-only design gives you: 89 C1 mov ecx, eax ; copy the index C1 E1 02 shl ecx, 2 ; scale it 01 D9 add ecx, ebx ; add the base 8B 01 mov eax, [ecx] ; load four instructions and a destroyed register. the SIB costs 1 byte and saves about 12.

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:

arr[i*3] needs a scratch register. no SIB byte can say *3. arr[i*16] scale 8, then a shift. or two scratch registers. or a different loop shape. a[i][j][k] three levels of scaling. a SIB byte holds ONE index and ONE scale, so a 3-D array walk still needs arithmetic -- the hardware helps with the innermost index and no further.

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.

Try It Yourself

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

Connect

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.

render_lesson_js(&mut page)