Part 1 · The register machine
Reading your first function
Everything from Part 1 applied to one real function, in both instruction sets — then a mixed set that refuses to tell you which idea it is testing.
What this lesson is trying to break (2)
- Understanding each instruction means understanding the function.
- A shorter listing is a better listing.
Part 1 gave you registers, widths, addressing and flags. This lesson checks whether they work together, then deliberately mixes them up.
The function
Read it once the way you would in review.
int count_positive(const int *a, int n) {
int c = 0;
for (int i = 0; i < n; i++) {
if (a[i] > 0) c++;
}
return c;
}
Compile it for x86-64 and arm64 and keep both visible.
- Which registers hold
aandnon entry? - Will there be a branch inside the loop for the
if, or will it be branchless? - Will the loop counter
iappear as a register that counts 0, 1, 2, …?
rdiholdsa,esiholdsnon x86-64 — first and second integer arguments. On ARM64,x0andw1. Note the widths: the pointer is 64-bit, theintis 32-bit, and the register name shows you which.- Branchless. The
if (a[i] > 0) c++becomes a compare plus a conditional add or acmov-style accumulate, because the condition is data-dependent and both sides are trivial. This is 1.4 in the wild. At higher optimisation levels it may vectorise instead. - Usually not. The compiler rewrites the induction variable: rather than
counting
iand computinga + i*4each time, it walks a pointer and compares it against a precomputed end address. Your counter disappears. This is standard, it is called induction-variable strength reduction, and it is why loop bodies often contain no visible index at all.
Question 3 is the one that surprises people, and it is the first real hint that
-O2 output does not preserve the shape of your source.
Line by line
Open one step at a time, and try to answer each before expanding.
Step 1 — find the skeleton before reading any instruction
Do not start at the top. Do this instead:
- Find the entry label — the function name.
- Find the backward jump. A conditional jump to a label that appears earlier is the loop. That single line locates the loop body for free.
- Find the exits — every
ret, plus any jump to a label near the bottom. - Note which register ends up in
eax(x86) orw0(ARM64) before theret. That is your return value, and it tells you which register was the accumulator.
You now know the shape without having decoded a single instruction. That order — structure first, instructions last — is the whole reading method, and it is the same top-down approach that works on unfamiliar source code.
Step 2 — the guard before the loop
Almost every compiled loop begins with a check that it should run at all:
test esi, esi
jle .Lemptytest esi, esi then a “less or equal” jump: if n <= 0, skip everything and
return zero. The source for (int i = 0; i < n; i++) implies this — with n = 0
the body never runs — and the compiler must emit it explicitly.
Note jle rather than jbe: signed, because n is int. Lesson
1.2 is already paying off.
Step 3 — the loop body, and where the counter went
Inside, expect roughly:
- a load from the pointer — real brackets this time, with a size annotation
- a compare against zero
- a conditional accumulate into the counter register
- a pointer increment by 4
- a compare against an end address and a backward jump
The two things worth dwelling on: i does not exist, replaced by the pointer
walk; and the loop-exit test compares addresses, not counts. The end address was
computed before the loop — often with a lea, which you can now read at a glance.
Step 4 — the ARM64 version, and what actually differs
Compare the two listings side by side and sort the differences into two piles.
Incidental:
ldr w2, [x0], 4— ARM64 has post-increment addressing, so the load and the pointer bump are one instruction. x86 needs two.- Three-operand arithmetic means fewer
movs. - Register names, and
cmp+b.leinstead ofcmp+jle.
Essential — identical in both:
- Arguments arrive in registers, in a fixed order.
- There is a guard before the loop.
- The counter became a pointer walk.
- The condition is branchless.
- One load per element, and no way around it.
That second list is what you actually learned. The first list is trivia about one instruction set — and the only way to know which pile something belongs in is to have seen both.
Interleaved practice
Not in lesson order, not labelled by topic. Work out which idea applies first.
lea eax, [rdi+rdi*4]eax = rdi * 5. rdi + rdi*4. No memory touched.
1.3. If you hesitated because of the brackets, that is the habit this drill exists to break.
cmp edi, esi
jae .L2Unsigned. jae is “jump if above or equal” — the above/below family reads
the carry flag and is the unsigned one. The signed equivalent would be jge.
1.2. The arithmetic told you nothing; the jump told you everything.
mov rax, -1
mov al, 50xFFFFFFFFFFFFFF05 — not 5.
Writing al touches only the low 8 bits and leaves the other 56 untouched. Had
the second line been mov eax, 5, rax would be exactly 5, because 32-bit
writes zero the upper half.
1.1, and this is the trap that produces real bugs in hand-written assembly.
lea rax, [rdi+rsi*8]
mov rdx, [rax+16]
lea rcx, [rdx+rdx*2]Part 1 checkpoint
Out loud, no notes:
- How many general-purpose registers, and why that number? (1.1)
- What does writing
aldo to the rest ofrax? (1.1) - Where does signedness live in compiled code? (1.2)
- What does
leado? (1.3) - What are the four addressing-mode components and the legal scales? (1.3)
- How does a comparison communicate its result? (1.4)
- What does
cmovtell you the compiler decided? (1.4)
You now have fiction 1 — the register machine — solidly enough to read a function. Part 2 is about how functions compose, which is entirely convention and not hardware at all.