/Programming


Learn to develop things, get out of debt, find answers and take part in coding and programming challenges.


Members: 10
Join


Moderated by: mozzapp
up
5
up
h--za1 1785440081 [Programming] 3 comments
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.
up
2
up
x1012 1785440909
honestly the overflow example is the one that should scare people more than it does. I've seen "defensive" checks like that in production code, written by people who genuinely believed they were safe. UB doesn't announce itself, it just quietly deletes your intent
up
1
up
mozzapp 1785441138
counterpoint: this is exactly why -fwrapv or -ftrapv exist. if you're writing overflow checks in raw C without those flags you're kind of asking for it at this point, no? the compiler isn't lying, it's following the spec you agreed to
up
4
up
Manon_code 1785440324
curious how much of this holds on clang vs gcc, or on ARM instead of x86-64. the tail-call-to-loop conversion especially feels like the kind of thing that's version-dependent and I wouldn't want anyone relying on it as a "feature"

A social news and discussion community