Part 1 · The register machine
There are no types, only widths
Types do not survive compilation. `add` cannot tell signed from unsigned — the signedness lives somewhere else entirely, and finding it is a reading skill.
What this lesson is trying to break (3)
- Types survive into the generated code.
- Signed and unsigned arithmetic use different instructions.
- A pointer is a distinct kind of thing from an integer.
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.
Types are fundamental. Rust taught you that types decide layout, dispatch and when destructors run — that they are real, not annotation. So surely the type information is in there somewhere.
It is not. The generated code has no types at all. It has
widths — 8, 16, 32, 64 bits — and nothing else. There is no instruction that
knows it is adding u32 rather than i32, and no way to
ask a register what it contains.
Types were consumed by the compiler, not translated. They decided which instructions to emit and then evaporated. So reading types out of a listing means reading them backwards from instruction choice — and that is a real, learnable skill, because the choices are systematic.
Rust was telling the truth: types are real at compile time. This is what happens after that.
The same instruction for both
Here is the fact that makes everything else follow. Two’s complement means addition and subtraction are bit-identical for signed and unsigned values.
0xFFFFFFFF plus one is 0x00000000. Whether that was -1 + 1 = 0 or
4294967295 + 1 = 0 (wrapped) is a question about interpretation, not about
what the adder did. So there is one add, and it serves both.
Which raises the question the rest of this lesson answers: if add does not
know, who does?
Three places signedness survives
Varies: only the signedness of the operands — the operation is the same in each row
| Operation | Unsigned | Signed | What it tells you |
|---|---|---|---|
| add, sub, and, or, xor | add | add | Nothing. Identical instruction. |
| multiply (low half) | imul | imul | Nothing — the low half is the same bits either way |
| compare-and-branch | jb / jabelow / above | jl / jgless / greater | Everything. The clearest signal in the listing. |
| widen 32→64 | mov eax, edior movzx | movsxd | Sign extension means it was signed |
| divide by 2 | shr | sar | sar preserves the sign bit |
| divide (general) | div | idiv | The i prefix means signed |
Read the table bottom-up when reverse-engineering: the jump condition, the widening instruction and the shift kind are your three witnesses. Everything above them is silent.
The vocabulary is worth noticing because it is deliberate and easy to miss. Above and below are unsigned. Greater and less are signed. That is not stylistic variation; it is the entire type system, compressed into a mnemonic suffix.
int su(unsigned a, unsigned b) { return a < b; }
int ss(int a, int b) { return a < b; }Identical source apart from the types. Compile both and look at the comparison.
Both produce a cmp, then differ in the instruction that reads the flags:
- the unsigned version uses a below condition (
setb, orjbin a branching context) - the signed version uses a less condition (
setl/jl)
Same cmp. Different reader of the flags. The cmp itself set all the flags —
carry, sign, overflow, zero — and the two conditions consult different
combinations of them.
That is the mechanism in full: comparison is signedness-agnostic; the interpretation happens when the flags are read. So when you are trying to work out whether a variable in some unfamiliar listing was signed, you do not look at the arithmetic. You look at the jumps.
Pointers are integers
There is no pointer type either. A pointer is a 64-bit integer that the code happens to use as an address.
This has a pleasant consequence for reading Rust output. A &[T] is a fat
pointer — (pointer, length) — and in a compiled function you see it as
two ordinary registers: rdi holding an address, rsi holding a count.
There is no slice object anywhere. Everything Rust told you about slices is
visible directly, and lesson 3.6 leans on
this.
movsxd rax, edi
A signed 32-bit value being widened for 64-bit address arithmetic.
You see this whenever C or Rust code indexes an array with an int/i32. The
index is 32-bit and signed; addresses are 64-bit; so the value must be
sign-extended before it can participate in address computation.
It is also a small performance tell. Indexing with a size_t/usize avoids the
extension entirely, which is one concrete reason idiomatic Rust prefers usize
for indices — and now you can see the difference rather than take it on trust.
Floating point lives somewhere else
One structural fact to file away now: floating-point values do not use the
general-purpose registers at all. They live in the vector registers — xmm0,
xmm1, … on x86-64; v0–v31 (or d0, s0 for scalar) on ARM64 — and they
use a separate instruction family (addsd, mulss, fadd).
So a function’s arguments may arrive in two entirely separate register banks:
integers in rdi, rsi, … and doubles in xmm0, xmm1, …, each counted
independently. A function taking (int, double, int, double) puts the ints in
edi, esi and the doubles in xmm0, xmm1 — the positions do not interleave.
The suffix tells you the width and count: sd is scalar double, ss is scalar
single, pd is packed double, ps is packed single. Packed means SIMD, which is
Part 5.
You are handed this fragment with no source and no symbols:
cmp esi, edi
jbe .L4
sar edi, 3
movsxd rax, ediSay what you can infer about the types of the original variables, and name the specific instruction that told you each thing.
There are at least two inferences available, and they appear to conflict. Say which and why that is possible.
What you should now be able to say
- Compiled code has widths, not types. Types were consumed, not translated.
- Two’s complement makes
add/subidentical for signed and unsigned. - Signedness survives in exactly three places: the jump condition
(above/below vs greater/less), the widening instruction
(
movzx/movvsmovsx), and the right shift (shrvssar). - Pointers are just 64-bit integers. A Rust slice is visibly two registers.
- Floating point uses a separate register bank and its own instructions, and argument positions are counted separately.
Next: 1.3 — lea is arithmetic.