Reading Assembly

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.

35 minThreshold conceptrefutation textcontrasting casesPOE
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.
Liminality warning

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.

Unlearn thiscarried over from TypeScript and Rust, where the type system is the main event
You think

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.

Wrong here

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.

Instead

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

OperationUnsignedSignedWhat it tells you
add, sub, and, or, xoraddaddNothing. Identical instruction.
multiply (low half)imulimulNothing — the low half is the same bits either way
compare-and-branchjb / ja
below / above
jl / jg
less / greater
Everything. The clearest signal in the listing.
widen 32→64mov eax, edi
or movzx
movsxdSign extension means it was signed
divide by 2shrsarsar preserves the sign bit
divide (general)dividivThe 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.

Predict firstcommit in writing, then look
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.

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.

Beaconsee this → think this, without decoding it

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; v0v31 (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.

Explain it to yourselfwrite it — thinking it does not count

You are handed this fragment with no source and no symbols:

        cmp     esi, edi
        jbe     .L4
        sar     edi, 3
        movsxd  rax, edi

Say 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/sub identical for signed and unsigned.
  • Signedness survives in exactly three places: the jump condition (above/below vs greater/less), the widening instruction (movzx/mov vs movsx), and the right shift (shr vs sar).
  • 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.

In the wildgo read the real thing now, while it is fresh
  • Two's complement, visuallyRead only the "Fundamentals" section. The property that matters for this lesson is that addition and subtraction are identical for signed and unsigned — everything else follows from that.
  • x86 jump conditions tableNote that the list has two families: above/below for unsigned, greater/less for signed. That split IS where type information ended up. Bookmark it.
Scheduled for recall4 items added to your review queue

These are not shown to you now on purpose. They will surface in Review tomorrow, mixed in among items from other parts of the course — not grouped by topic. Sorting out which idea applies is the part that transfers.