All the assembly below was generated with `gcc -S -masm=att` on a real machine — it's not reconstructed from memory. Calling convention: System V AMD64 (Linux, 64-bit). No `-fasynchronous-unwind-tables`, so the CFI directives don't clutter the read.
---
## 1. The full pipeline (preprocessor → assembly → object file → binary)
Base code:
```c
#include <stdio.h>
int soma(int a, int b) {
return a + b;
}
int main(void) {
printf("%d\n", soma(3, 4));
return 0;
}
```
**Step 1 — Preprocessor** (`gcc -E main.c`): resolves `#include`, macros, and directives. The output is plain C, minus the original `#include <stdio.h>` — it's already been expanded above (hundreds of lines from stdio.h, omitted here):
```c
int soma(int a, int b) {
return a + b;
}
int main(void) {
printf("%d\n", soma(3, 4));
return 0;
}
```
**Step 2 — Assembly** (`gcc -S main.c` → `main.s`):
```asm
soma:
endbr64
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
```
**Step 3 — Object file** (`gcc -c main.c` → `main.o`) + symbol table (`nm main.o`):
```
0000000000000018 T main
U printf
0000000000000000 T soma
```
`T` = symbol defined in the `.text` section. `U` = undefined symbol — `printf` only exists as a reference here; it gets resolved by the linker against libc.
**Step 4 — Disassembling the `.o`** (`objdump -d main.o`) — note that `soma` starts at address `0x0`, because the `.o` hasn't been placed in its final memory layout yet:
```
0000000000000000 <soma>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 89 75 f8 mov %esi,-0x8(%rbp)
e: 8b 55 fc mov -0x4(%rbp),%edx
11: 8b 45 f8 mov -0x8(%rbp),%eax
14: 01 d0 add %edx,%eax
16: 5d pop %rbp
17: c3 ret
```
**Step 5 — Final linked binary** (`gcc -o main main.c` → `objdump -d main`) — now `soma` has a "real" address inside the binary (`0x1149`), since the linker has laid out every section:
```
0000000000001149 <soma>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 89 7d fc mov %edi,-0x4(%rbp)
1154: 89 75 f8 mov %esi,-0x8(%rbp)
1157: 8b 55 fc mov -0x4(%rbp),%edx
115a: 8b 45 f8 mov -0x8(%rbp),%eax
115d: 01 d0 add %edx,%eax
115f: 5d pop %rbp
1160: c3 ret
```
The machine code is identical to the `.o` version (only the address changed) — good moment in the text to explain that the linker does *relocation*, it doesn't recompile anything.
---
## 2. `soma` — the simplest possible function
```c
int soma(int a, int b) {
return a + b;
}
```
**`-O0`:**
```asm
soma:
endbr64
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp) # a arrives in edi, saved to the stack
movl %esi, -8(%rbp) # b arrives in esi, saved to the stack
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
```
**`-O2`:**
```asm
soma:
endbr64
leal (%rdi,%rsi), %eax # add directly using the lea trick, no stack frame at all
ret
```
This pair is a great opener for the optimization section: at `-O0` the compiler doesn't even try to be clever — it stores everything on the stack "just in case" (makes it easier for a debugger to map a variable to an address). At `-O2` it realizes it doesn't even need an `add`: it reuses `lea` (load effective address) — literally an address-arithmetic instruction repurposed to add two integers — without touching the stack.
---
## 3. `for` loop — summing an array
```c
int soma_array(int *arr, int n) {
int total = 0;
for (int i = 0; i < n; i++) {
total += arr[i];
}
return total;
}
```
**`-O0`:**
```asm
soma_array:
endbr64
pushq %rbp
movq %rsp, %rbp
movq %rdi, -24(%rbp)
movl %esi, -28(%rbp)
movl $0, -8(%rbp) # total = 0
movl $0, -4(%rbp) # i = 0
jmp .L2
.L3:
movl -4(%rbp), %eax
cltq
leaq 0(,%rax,4), %rdx # rdx = i * 4
movq -24(%rbp), %rax
addq %rdx, %rax # rax = arr + i*4
movl (%rax), %eax # eax = arr[i]
addl %eax, -8(%rbp) # total += arr[i]
addl $1, -4(%rbp) # i++
.L2:
movl -4(%rbp), %eax
cmpl -28(%rbp), %eax
jl .L3 # if (i < n) go back to the loop
movl -8(%rbp), %eax
popq %rbp
ret
```
**`-O2`:**
```asm
soma_array:
endbr64
testl %esi, %esi
jle .L4 # if n <= 0, skip straight to the end
movslq %esi, %rsi
xorl %eax, %eax
leaq (%rdi,%rsi,4), %rdx # rdx = address of arr[n] (end sentinel)
.L3:
addl (%rdi), %eax
addq $4, %rdi # advance the pointer instead of recomputing the index
cmpq %rdx, %rdi
jne .L3
ret
.L4:
xorl %eax, %eax
ret
```
Worth highlighting: at `-O0` the compiler recomputes `arr + i*4` from scratch on every iteration. At `-O2` it realizes it can turn `for (i=0; i<n; i++)` into a pointer that walks byte-by-byte until it hits a sentinel address — eliminating the integer multiplication from the loop entirely. That's the compiler doing real "strength reduction" — not textbook theory, this is actual generated code.
---
## 4. `if/else`
```c
int maior(int a, int b) {
if (a > b) {
return a;
} else {
return b;
}
}
```
**`-O0`:** (a real branch, with a jump)
```asm
maior:
endbr64
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %eax
cmpl -8(%rbp), %eax
jle .L2
movl -4(%rbp), %eax
jmp .L3
.L2:
movl -8(%rbp), %eax
.L3:
popq %rbp
ret
```
**`-O2`:** (no branch at all — uses `cmov`)
```asm
maior:
endbr64
cmpl %esi, %edi
movl %esi, %eax
cmovge %edi, %eax # conditional move: eax = edi if edi >= esi
ret
```
One of my favorites for an article, because it's counterintuitive: people who learned "if turns into a jump" from a textbook will be surprised that `-O2` has no jump whatsoever. `cmov` (conditional move) exists specifically to avoid branch misprediction in small code paths — worth mentioning that this is a microarchitecture decision, not just "optimizing for code size."
---
## 5. Recursion — factorial
```c
int fatorial(int n) {
if (n <= 1) return 1;
return n * fatorial(n - 1);
}
```
**`-O0`:** (real recursion — `call` pushing a fresh stack frame every time)
```asm
fatorial:
endbr64
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl %edi, -4(%rbp)
cmpl $1, -4(%rbp)
jg .L2
movl $1, %eax
jmp .L3
.L2:
movl -4(%rbp), %eax
subl $1, %eax
movl %eax, %edi
call fatorial # actual recursive call
imull -4(%rbp), %eax
.L3:
leave
ret
```
**`-O2`:** (the compiler turned the recursion into a loop — there's no `call` at all here)
```asm
fatorial:
endbr64
movl $1, %eax
cmpl $1, %edi
jle .L1
.L2:
movl %edi, %edx
subl $1, %edi
imull %edx, %eax
cmpl $1, %edi
jne .L2
.L1:
ret
```
This is the single most instructive example in the set, because it takes down a common belief: "recursion always blows the stack, always runs slower than a loop." Here GCC recognized that the recursion was tail-recursion in disguise (the `imull` after the `call` makes it look otherwise, but it managed to reorder things) and turned it into a plain `while` — zero call overhead, zero stack-overflow risk. Worth being clear that this isn't guaranteed by the language — it's the compiler being generous in this specific case.
---
## 6. Array indexing vs. pointer arithmetic — the "pointers are faster" myth
```c
int acessa_indice(int *arr, int i) {
return arr[i];
}
int acessa_ponteiro(int *arr, int i) {
return *(arr + i);
}
```
**`-O2` (both functions, side by side):**
```asm
acessa_indice:
endbr64
movslq %esi, %rsi
movl (%rdi,%rsi,4), %eax
ret
acessa_ponteiro:
endbr64
movslq %esi, %rsi
movl (%rdi,%rsi,4), %eax
ret
```
Byte for byte, identical. Short pair, but it's one of the most cited (and most verifiable) proofs that `arr[i]` and `*(arr+i)` carry no performance difference in modern C — the bracket syntax is pure syntactic sugar the compiler already dissolves while building the AST, before it even starts thinking about optimization.
---
## 7. A struct passed by value — the compiler packs the fields into a register
```c
struct Ponto {
int x;
int y;
};
int soma_ponto(struct Ponto p) {
return p.x + p.y;
}
```
**`-O0`:**
```asm
soma_ponto:
endbr64
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp) # the whole struct p (x and y) fits in rdi's 8 bytes
movl -8(%rbp), %edx # the low 32 bits = x
movl -4(%rbp), %eax # the high 32 bits = y
addl %edx, %eax
popq %rbp
ret
```
**`-O2`:**
```asm
soma_ponto:
endbr64
movq %rdi, %rax
shrq $32, %rax # shift right by 32 to extract y
addl %edi, %eax # add with rdi's low 32 bits (x)
ret
```
A great one for explaining the ABI: since `struct Ponto` is only 8 bytes (two `int`s), it isn't passed by pointer or pushed onto the stack — it travels whole inside `rdi`, with `x` in the low 32 bits and `y` in the high 32 bits. That's a System V AMD64 ABI rule for small structs ("eightbyte classification"), and it's exactly the kind of detail that separates someone who reads C from someone who understands it.
---
## 8. Signed overflow is UB — and the compiler exploits it even without aggressive optimization
```c
int checa_overflow(int a, int b) {
if (a + b < a) {
return -1;
}
return a + b;
}
```
Mathematically, assuming no overflow, `a + b < a` is equivalent to `b < 0`. But signed integer overflow is *undefined behavior* in C — so the compiler is allowed to assume it never happens, and simplifies the expression accordingly. What's interesting is that this already shows up at `-O0`:
```asm
checa_overflow:
endbr64
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
cmpl $0, -8(%rbp) # only compares b against 0 — "a" isn't even used here!
jns .L2
movl $-1, %eax
jmp .L3
.L2:
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
.L3:
popq %rbp
ret
```
At `-O2` it's even more explicit:
```asm
checa_overflow:
endbr64
testl %esi, %esi
js .L3
leal (%rsi,%rdi), %eax
ret
.L3:
movl $-1, %eax
ret
```
This one's gold for any serious C article: it shows, with proof in the actual assembly, why "code that looks like it checks for overflow" might not check anything at all — `a` disappears from the comparison entirely. It's about as concrete an illustration as you'll find of why UB isn't a theoretical spec detail — it's something that changes the binary running on a user's machine.
---
## 9. `switch` — a real jump table
When the `case`s form a simple arithmetic progression, GCC sometimes prefers doing math over building a table:
```c
int dia_semana(int n) {
switch (n) {
case 0: return 100;
case 1: return 200;
case 2: return 300;
case 3: return 400;
case 4: return 500;
default: return -1;
}
}
```
```asm
dia_semana:
endbr64
movl $-1, %eax
cmpl $4, %edi
ja .L1
addl $1, %edi
imull $100, %edi, %eax # (n+1)*100 — no table needed
.L1:
ret
```
But when each `case` does something different, the classic jump table shows up:
```c
int operacao(int n, int a, int b) {
switch (n) {
case 0: return a + b;
case 1: return a - b;
case 2: return a * b;
case 3: return a / b;
case 4: return a % b;
default: return 0;
}
}
```
```asm
operacao:
endbr64
movl %esi, %eax
movl %edx, %ecx
cmpl $4, %edi
ja .L9
leaq .L4(%rip), %rsi # address of the table
movl %edi, %edi
movslq (%rsi,%rdi,4), %rdx # read the offset for entry n
addq %rsi, %rdx
notrack jmp *%rdx # indirect jump — lands straight on the right case
.section .rodata
.L4:
.long .L8-.L4
.long .L7-.L4
.long .L6-.L4
.long .L5-.L4
.long .L3-.L4
```
Worth showing both side by side in the article: proof that "switch always compiles to a jump table" is an intro-course myth — the compiler picks whichever strategy is cheapest for the specific case in front of it.
---
## 10. AT&T vs. Intel syntax — same function, two notations
Worth including so readers who followed an English-language tutorial (mostly Intel syntax) don't get lost reading `objdump` output (which defaults to AT&T syntax on Linux).
```c
int soma(int a, int b) { return a + b; }
```
**AT&T** (`gcc -S -masm=att`, the Linux default):
```asm
soma:
endbr64
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
ret
```
**Intel** (`gcc -S -masm=intel`):
```asm
soma:
endbr64
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], edi
mov DWORD PTR -8[rbp], esi
mov edx, DWORD PTR -4[rbp]
mov eax, DWORD PTR -8[rbp]
add eax, edx
pop rbp
ret
```
Three differences worth flagging in the text: (1) operand order is reversed (`mov dst, src` in Intel vs `mov src, dst` in AT&T); (2) registers don't carry a `%` prefix in Intel syntax; (3) constants drop the `$` and memory access uses `DWORD PTR base[offset]` instead of `offset(base)`.
---
### Commands used (to reproduce all of this)
```bash
gcc -S -masm=att -fno-asynchronous-unwind-tables -O0 -o out.s file.c # -O0
gcc -S -masm=att -fno-asynchronous-unwind-tables -O2 -o out.s file.c # -O2
gcc -S -masm=intel -fno-asynchronous-unwind-tables -o out.s file.c # Intel syntax
gcc -c file.c -o file.o && nm file.o # symbols
objdump -d file.o # disassemble the .o
objdump -d final_binary # disassemble the linked binary
```
Tested with `gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0` on `x86_64-linux-gnu`. Results on other GCC versions, or on clang, may differ — worth flagging that in the article so readers don't walk away thinking there's one "correct" assembly output for a given piece of C code.
A social news and discussion community