.text:00447380; =============== S U B R O U T I N E ===============
.text:00447380
.text:00447380

From C to x86-64: reading a compiled loop

.text:00447380; author: Can Ozdemir date: 2026-10-15 size: 0x1260 tags: x86-64, assembly, tutorial
.text:00447380
.text:00447380sub_447380procnear
.text:00447381

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:

.text:0044742C
int sum(const int *a, int n)
{
    int s = 0;
    for (int i = 0; i < n; i++)
        s += a[i];
    return s;
}
.text:0044749C

All the listings below are real objdump -d -M intel output from GCC 13 on Linux.

.text:004474EE

The calling convention

.text:00447507

On Linux and macOS, x86-64 uses the System V ABI:

.text:0044753C
  • 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 an int)
  • rbx, rbp and r12 to r15 must be preserved by the function; the others may be overwritten freely
.text:00447651

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.

.text:00447737

-O0: everything on the stack

.text:00447756
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:  ret
.text:00447B56

Things to notice:

.text:00447B67
  • push rbp / mov rbp, rsp is the classic prologue. It sets up rbp as a fixed reference point, so every local variable has a fixed offset from it.
  • Each C variable has a home on the stack: a at rbp-0x18, n at rbp-0x1c, s at rbp-0x8, i at rbp-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.
  • jl is a signed comparison ("jump if less"), which tells you i and n are signed ints. Unsigned code would use jb.
  • endbr64 is a marker for Intel CET (control-flow protection), not part of the logic.
.text:00447E4F

-O1: registers and a pointer walk

.text:00447E73
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    0022
.text:004480E6

The 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.

.text:004481FA

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.

.text:004482D8

Habits that help

.text:004482EB
  • Find the arguments first (rdi, rsi, …), then the return value (eax just before ret).
  • Name every stack slot and register as soon as you know what it holds. IDA and Ghidra both let you rename (n in IDA).
  • Backwards jumps are loops. Look at the compare just before them to find the condition.
  • Watch for signed vs unsigned jumps: jl/jg vs jb/ja.
.text:00448458

Do it yourself

.text:00448469
gcc -O0 -c sum.c && objdump -d -M intel sum.o
gcc -O2 -c sum.c && objdump -d -M intel sum.o
.text:004484C4

At -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.

.text:0044856E
.text:0044856Eretn
.text:0044856Esub_447380endp
.text:0044856E; © 2026 Can Ozdemir. All rights reserved. terms
.text:0044856E
.text:0044856F; -------- cross references --------
Output
Loading ozdc.us… 34 functions, 3 data segments.
Type help in the command line below and press Enter.