From C to x86-64: reading a compiled loop
The fastest way to get comfortable reading disassembly is to compile code you already understand and compare the two. Here is a function that adds up an array of integers:
int sum(const int *a, int n)
{
int s = 0;
for (int i = 0; i < n; i++)
s += a[i];
return s;
}All the listings below are real objdump -d -M intel output from GCC 13 on Linux.
The calling convention
On Linux and macOS, x86-64 uses the System V ABI:
- integer and pointer arguments arrive in
rdi,rsi,rdx,rcx,r8,r9, in that order - the return value goes in
rax(or its low 32 bits,eax, for anint) rbx,rbpandr12tor15must be preserved by the function; the others may be overwritten freely
So for sum: a is in rdi, n is in esi (the low half of rsi, because int is 32 bits), and the answer ends up in eax. Windows uses a different convention (rcx, rdx, r8, r9), so always check the platform first.
-O0: everything on the stack
0000: endbr64
0004: push rbp
0005: mov rbp, rsp
0008: mov QWORD PTR [rbp-0x18], rdi ; save a
000c: mov DWORD PTR [rbp-0x1c], esi ; save n
000f: mov DWORD PTR [rbp-0x8], 0x0 ; s = 0
0016: mov DWORD PTR [rbp-0x4], 0x0 ; i = 0
001d: jmp 003c ; jump to the loop test
001f: mov eax, DWORD PTR [rbp-0x4] ; eax = i
0022: cdqe ; sign-extend i to 64 bits
0024: lea rdx, [rax*4+0x0] ; rdx = i * sizeof(int)
002c: mov rax, QWORD PTR [rbp-0x18] ; rax = a
0030: add rax, rdx ; rax = &a[i]
0033: mov eax, DWORD PTR [rax] ; eax = a[i]
0035: add DWORD PTR [rbp-0x8], eax ; s += a[i]
0038: add DWORD PTR [rbp-0x4], 0x1 ; i++
003c: mov eax, DWORD PTR [rbp-0x4]
003f: cmp eax, DWORD PTR [rbp-0x1c] ; i < n ?
0042: jl 001f ; yes: loop again
0044: mov eax, DWORD PTR [rbp-0x8] ; return s
0047: pop rbp
0048: retThings to notice:
push rbp/mov rbp, rspis the classic prologue. It sets uprbpas a fixed reference point, so every local variable has a fixed offset from it.- Each C variable has a home on the stack:
aatrbp-0x18,natrbp-0x1c,satrbp-0x8,iatrbp-0x4. Naming these slots is the first thing to do in any -O0 function. - The loop is compiled "upside down": a jump straight to the test at the bottom, the body above it, and a backwards conditional jump (
jl). A backwards jump is almost always a loop. jlis a signed comparison ("jump if less"), which tells youiandnare signed ints. Unsigned code would usejb.endbr64is a marker for Intel CET (control-flow protection), not part of the logic.
-O1: registers and a pointer walk
0000: endbr64
0004: test esi, esi ; n <= 0 ?
0006: jle 0025 ; then return 0
0008: mov rax, rdi ; p = a
000b: movsxd rsi, esi ; n as 64-bit
000e: lea rcx, [rdi+rsi*4] ; end = a + n
0012: mov edx, 0x0 ; s = 0
0017: add edx, DWORD PTR [rax] ; s += *p
0019: add rax, 0x4 ; p++
001d: cmp rax, rcx ; p != end ?
0020: jne 0017
0022: mov eax, edx ; return s
0024: ret
0025: mov edx, 0x0
002a: jmp 0022The optimiser rewrote the loop. There is no i any more: it walks a pointer from a to a + n and stops when they meet. It also moved the n <= 0 check out front, so the loop body is just four instructions. The stack frame is gone, because nothing needs to live in memory.
This is what real-world binaries look like. The trick is to recognise the shape: one register that steps by 4 each time and is compared with an end pointer is "iterate over an int array", whatever the source code said.
Habits that help
- Find the arguments first (
rdi,rsi, …), then the return value (eaxjust beforeret). - Name every stack slot and register as soon as you know what it holds. IDA and Ghidra both let you rename (
nin IDA). - Backwards jumps are loops. Look at the compare just before them to find the condition.
- Watch for signed vs unsigned jumps:
jl/jgvsjb/ja.
Do it yourself
gcc -O0 -c sum.c && objdump -d -M intel sum.o
gcc -O2 -c sum.c && objdump -d -M intel sum.oAt -O2 GCC may vectorise the loop with SSE instructions (paddd, movdqu). Comparing the three levels side by side is one of the best ways to learn what compilers do.