Part 1 · The register machine
Registers are a scarce physical resource
Sixteen boxes, and the compiler spends most of its effort deciding who gets one. The first fiction, and the one you must accept before anything else makes sense.
What this lesson is trying to break (3)
- A variable is a name for a value.
- Registers are just fast variables.
- Writing to a 32-bit register leaves the upper bits alone.
This is a threshold concept: troublesome, transformative, and irreversible. You should expect to be genuinely stuck here, and being stuck is the mechanism rather than a sign that you are failing. Budget several sessions. Do not move on because you are bored — move on when you can explain it out loud without notes.
A variable is a name for a value. You can have as many as you like; naming one is free; the machine keeps them all somewhere and fetches them when needed. Rust made you think about where values live — stack or heap — but not about how many you were allowed.
At this level there are exactly 16 places to hold an integer on x86-64, and 31 on ARM64. Not sixteen thousand. Sixteen. Every value your function is working with competes for one, and when they run out the compiler starts writing values to memory and reading them back.
Read a register as a scarce physical resource, and read
register allocation as the compiler’s central problem. Most of what looks
like pointless shuffling in a listing — mov between registers,
stores to stack slots — is the compiler solving that problem. Once you see
it that way, the noise becomes signal.
The sixteen
Varies: only the width being addressed — every row names part of the same physical register
| 64-bit | 32-bit | 16-bit | 8-bit | Conventional use (SysV) |
|---|---|---|---|---|
rax | eax | ax | al | return value |
rdi | edi | di | dil | 1st argument |
rsi | esi | si | sil | 2nd argument |
rdx | edx | dx | dl | 3rd argument |
rcx | ecx | cx | cl | 4th argument |
r8–r15 | r8d… | r8w… | r8b… | 5th, 6th args; then scratch |
rsp | — | — | — | stack pointer — never general-purpose |
rbp | ebp | bp | bpl | frame pointer, or general if omitted |
`rax` and `eax` are not two registers. `eax` is the low half of `rax`. The four columns are four ways of naming parts of one 64-bit box, and which name appears in the listing tells you the width of the operation.
The names are historical wreckage. a was the accumulator, c the counter,
d data, si/di source and destination index — from the 8086, in 1978.
e meant extended to 32 bits in 1985, r meant 64 bits in 2003. r8–r15
were added at the same time and got no letters, which is why they are the only
sanely-named ones.
Why sixteen? Instruction encoding. A register number has to fit in the
instruction bytes, and x86-64 extends the original 8 to 16 using an extra prefix
byte. It is a compatibility artifact, not a design target. ARM64, designed in
2011 with no such baggage, has 31 — and you can see the difference in the naming:
x0–x30 for 64-bit, w0–w30 for their 32-bit halves. That is the whole
scheme.
Register pressure
Because registers are scarce, “how many values are live at once” becomes a real constraint with a name: register pressure. When live values outnumber registers, the compiler spills — stores a value to a stack slot and reloads it later.
int f(int a, int b, int c, int d, int e, int g) {
return a*b + c*d + e*g;
}Six arguments, three multiplies. Does the compiler need to touch the stack?
No. Compile it and you will see six arguments arrive in
edi, esi, edx, ecx, r8d, r9d, three multiplies, two adds, and a return. No
stack access at all — six live values fit comfortably in sixteen registers.
Now the useful follow-up: add three more parameters and compile again. The
seventh argument onward arrives on the stack rather than in registers,
because the convention only defines six integer registers for arguments — so you
will see mov eax, [rsp+8]-style loads appear. That is not spilling; that is the
calling convention running out of registers, which is
2.2.
Real spilling needs more simultaneously live values than registers, which usually means a big loop body or heavy inlining. When you see a hot loop with stores and loads to the same stack addresses every iteration, you are looking at spills, and it is a real signal — often caused by the compiler unrolling too aggressively.
The partial-register trap
This one produces genuine bugs and genuine slowdowns, and it is not guessable.
mov eax, 5 ; rax is now exactly 5 — upper 32 bits ZEROED
mov al, 5 ; rax's upper 56 bits are UNCHANGED
Writing a 32-bit register zeroes the upper half of the 64-bit register. Writing an 8-bit or 16-bit register does not — it leaves the rest alone.
Two consequences:
For reading code: mov eax, edi is the idiomatic way to zero-extend a 32-bit
value into 64 bits. It looks like a plain copy and it is also a conversion. When
you see a 32-bit mov where you expected 64, that is usually why.
For performance: writing al creates a dependency on the previous contents
of rax, because the CPU must merge your 8 bits with the existing 56. That is a
false dependency — you did not want the old value, but the hardware has to
wait for it. This is the flip side of the xor eax, eax beacon from
0.2: one idiom exists to break dependencies, and
this trap accidentally creates them.
mov eax, edi (32-bit mov where 64 was expected)
A zero-extending copy. The value is being widened from 32 to 64 bits, for free, as a side effect of the width rule above.
You will see this constantly in code that indexes arrays with int — the index
is 32-bit, the address arithmetic is 64-bit, so a widening step is required and
this is the cheapest form of it.
What ARM64 does differently
Compile anything for both targets and the register differences are the first thing you notice.
- 31 registers, uniformly named.
x0–x30, withw0–w30as the 32-bit halves. No accumulator, no counter, no history. - Writing
w0zeroes the top half ofx0, exactly likeeax/rax. ARM64 has no 8- or 16-bit register names at all, so the partial-register trap simply does not exist. - A dedicated zero register.
xzr/wzrreads as zero and discards writes. So ARM64 does not need thexoridiom —mov w0, wzris unambiguous and has no dependency by construction. Compare that with x86 needing a recognised pattern to achieve the same thing, and you can see a genuine design improvement rather than a cosmetic difference. - Three-operand arithmetic.
add w0, w1, w2meansw0 = w1 + w2, leaving both inputs intact. x86’sadd eax, edimeanseax += edi, destroyingeax. This is why x86 listings contain so many moremovinstructions: two-operand arithmetic forces you to copy anything you still need.
That last point explains most of the length difference between the two listings you saw in 0.1, and it is a good example of the distinction this course keeps making: more instructions was incidental, caused by operand count. The work was identical.
Explain to a colleague why the following two lines are not the same thing, and why the difference matters for performance and not just for correctness:
mov eax, 0
xor eax, eaxTwo or three sentences. You should use the words dependency and renaming.
What you should now be able to say
- There are 16 general-purpose registers on x86-64, 31 on ARM64, and the limit is encoding, not need.
rax/eax/ax/alare one register, four widths. The width in the listing tells you the width of the operation.- Register pressure and spilling are the compiler’s central problem, and most apparent shuffling in a listing is it solving that problem.
- Writing a 32-bit register zeroes the upper half; writing 8/16-bit does not, which creates false dependencies.
- ARM64’s three-operand arithmetic and zero register eliminate whole classes of x86 idiom — a real design difference, not cosmetics.