Quando escreves gcc programa.c -o programa e carregas Enter,
o GCC executa silenciosamente quatro fases distintas que transformam texto
legível por humanos em código máquina que o processador executa directamente —
sem intermediário, sem runtime, sem intérprete. Perceber estas fases é perceber
porque programas C são rápidos, porque certos bugs só aparecem em release,
e o que a JVM acrescenta por cima deste modelo.
O processo de transformar código C num executável tem quatro fases distintas, cada uma com uma responsabilidade clara. O GCC e o Clang executam todas as quatro por omissão — mas é possível parar em qualquer fase para inspecção.
// As quatro fases — com os flags para parar em cada uma:
// Fase 1: Preprocessamento → gcc -E programa.c -o programa.i
// Fase 2: Compilação → gcc -S programa.i -o programa.s
// Fase 3: Assembling → gcc -c programa.s -o programa.o
// Fase 4: Linking → gcc programa.o -o programa
// Normalmente executa tudo de uma vez:
// gcc programa.c -o programa
// Ficheiro de entrada e saída de cada fase:
// .c → (preprocessamento) → .i (C expandido, sem directivas)
// .i → (compilação) → .s (Assembly)
// .s → (assembling) → .o (código máquina, objecto)
// .o → (linking) → (executável sem extensão no Linux, .exe no Windows)
O preprocessador é essencialmente um motor de substituição de texto —
expande macros, processa directivas #include e
#define, e elimina comentários. O resultado é C puro,
sem nenhuma directiva de preprocessamento.
// programa.c — antes do preprocessamento:
#include <stdio.h> // directiva #include
#define MAX_USERS 1000 // macro de constante
#define SQUARE(x) ((x)*(x)) // macro de função
int main() {
// Este comentário desaparece
int limit = MAX_USERS;
int area = SQUARE(5);
printf("limit=%d, area=%d\n", limit, area);
return 0;
}
// programa.i — depois do preprocessamento (gcc -E):
// (o conteúdo completo de stdio.h é colado aqui — tipicamente ~800 linhas)
// ...
// extern int printf(const char* restrict format, ...);
// ...
int main() {
int limit = 1000; // MAX_USERS substituído pelo valor
int area = ((5)*(5)); // SQUARE(5) expandido — parênteses são importantes
printf("limit=%d, area=%d\n", limit, area);
return 0;
}
// Porquê os parênteses em #define SQUARE(x) ((x)*(x)) importam:
// #define SQUARE(x) x*x ← ERRADO
// SQUARE(2+3) → 2+3*2+3 = 11 ← não é (2+3)² = 25
// #define SQUARE(x) ((x)*(x)) ← CORRECTO
// SQUARE(2+3) → ((2+3)*(2+3)) = 25
#ifndef HEADER_H / #define HEADER_H / ... / #endif.
A directiva moderna equivalente é #pragma once — suportada
por todos os compiladores modernos mas não é standard ISO.
O compilador transforma o C preprocessado em Assembly para a arquitectura alvo. Esta é a fase mais complexa — análise léxica, parsing, geração de código intermédio, optimizações, e finalmente geração de Assembly. O nível de optimização escolhido aqui tem impacto directo na performance e no comportamento do programa.
// Níveis de optimização — o flag mais importante para performance e debugging:
// -O0 — sem optimização (default)
// - Compilação rápida
// - Código previsível — cada linha C corresponde a instruções Assembly claras
// - Debugging fácil — variáveis não são eliminadas, código não é reordenado
// - Usado em desenvolvimento e debugging
// -O1 — optimizações básicas
// - Elimina código morto, simplifica expressões
// - Raramente usado directamente
// -O2 — optimizações standard (produção)
// - Inlining de funções pequenas, loop unrolling, vectorização
// - Não aumenta o tamanho do código significativamente
// - Recomendado para a maioria dos projectos
// -O3 — optimizações agressivas
// - Tudo de -O2 + optimizações mais agressivas que podem aumentar o tamanho do código
// - Pode expor bugs UB que -O0 esconde (ver artigo Undefined Behaviour)
// -Os — optimizar para tamanho (embebidos, firmware)
// -Og — optimizar para debugging (melhor que -O0 para uso com gdb)
// Exemplo de compilação para produção:
gcc -O2 -Wall -Wextra -o programa programa.c
// ^^^ optimização standard
// ^^^^ todos os warnings
// ^^^^^^ warnings extra — importante para detectar bugs
// O que o compilador faz com optimização — exemplo concreto:
// C original:
int soma_array(int* arr, int n) {
int total = 0;
for (int i = 0; i < n; i++) {
total += arr[i];
}
return total;
}
// Assembly gerado com -O0 (sem optimização) — literal, instrução por instrução:
soma_array:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi // guardar arr na stack
mov DWORD PTR [rbp-28], esi // guardar n na stack
mov DWORD PTR [rbp-4], 0 // total = 0
mov DWORD PTR [rbp-8], 0 // i = 0
.loop:
mov eax, DWORD PTR [rbp-8] // eax = i
cmp eax, DWORD PTR [rbp-28] // comparar i com n
jge .end // se i >= n, sair
// ... carregar arr[i], somar a total, incrementar i
jmp .loop
.end:
mov eax, DWORD PTR [rbp-4] // retornar total
pop rbp
ret
// Assembly gerado com -O2 — vectorizado com SIMD (SSE/AVX):
// O compilador detecta que é uma soma de array e usa instruções SIMD
// que somam 4 ou 8 inteiros em paralelo numa única instrução
// Resultado: 4-8× mais rápido que a versão -O0
O assembler transforma o Assembly textual em código máquina binário —
o ficheiro objecto .o. É uma tradução quase directa:
cada instrução Assembly corresponde a um ou mais bytes de código máquina.
O ficheiro objecto ainda não é executável — contém referências a símbolos
externos (funções de outras bibliotecas) que o linker ainda precisa de resolver.
// Inspecionar um ficheiro objecto:
gcc -c programa.c -o programa.o
objdump -d programa.o // desassemblar — mostrar código máquina + Assembly
nm programa.o // listar símbolos (funções, variáveis globais)
readelf -h programa.o // cabeçalho ELF
// Exemplo de saída do nm — símbolos no ficheiro objecto:
// U printf ← U = Undefined — referência externa, ainda não resolvida
// T main ← T = Text section — função definida neste ficheiro
// T soma_array ← definida neste ficheiro
// O símbolo printf está marcado como Undefined porque está na libc —
// o linker vai resolver esta referência na fase seguinte.
O linker combina um ou mais ficheiros objecto com bibliotecas e resolve
todas as referências externas — substitui cada referência U printf
pelo endereço real da função printf na libc.
O resultado é o executável final.
// Linking — combinar múltiplos objectos:
gcc main.o utils.o database.o -o programa
// O linker:
// 1. Junta as secções de código (.text) de todos os .o
// 2. Junta as secções de dados (.data, .bss)
// 3. Resolve referências cruzadas entre ficheiros
// 4. Liga bibliotecas (libc por omissão, outras com -l)
// 5. Escreve o executável com endereços reais
// Linking com bibliotecas:
gcc programa.o -lm -lpthread -o programa
// ^^ biblioteca matemática (libm.so / libm.a)
// ^^^^^^^^ POSIX threads (libpthread.so)
// Dois tipos de linking (ver artigo Bibliotecas: Estáticas vs Dinâmicas):
// Estático: o código da biblioteca é copiado para o executável
// gcc programa.o -static -lm -o programa
// resultado: executável grande, sem dependências externas
//
// Dinâmico: o executável apenas referencia a biblioteca
// gcc programa.o -lm -o programa (default)
// resultado: executável pequeno, requer libm.so em runtime
// Inspecionar as dependências dinâmicas de um executável:
ldd programa
// linux-vdso.so.1 (0x00007ffd...)
// libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6
// libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
No Linux, os executáveis seguem o formato ELF (Executable and Linkable Format). No Windows é PE (Portable Executable). Ambos organizam o programa em secções com propósitos distintos — o sistema operativo lê estas secções para carregar o programa em memória correctamente.
// Estrutura de um executável ELF:
// ┌─────────────────────────────────┐
// │ ELF Header │ magic number, arquitectura, entry point
// ├─────────────────────────────────┤
// │ .text │ código máquina (read-only, executable)
// │ main(), soma_array(), ... │
// ├─────────────────────────────────┤
// │ .rodata │ dados read-only (strings literais, constantes)
// │ "Hello, world\n", MAX=1000, ... │
// ├─────────────────────────────────┤
// │ .data │ variáveis globais inicializadas (read-write)
// │ int counter = 0; │
// ├─────────────────────────────────┤
// │ .bss │ variáveis globais não inicializadas
// │ int buffer[1024]; │ (não ocupa espaço no ficheiro — apenas tamanho)
// ├─────────────────────────────────┤
// │ .dynamic / .plt / .got │ tabelas para linking dinâmico em runtime
// ├─────────────────────────────────┤
// │ Symbol table, debug info │ presente em -g, removido com strip
// └─────────────────────────────────┘
// Inspecionar as secções:
readelf -S programa
objdump -h programa
// A separação .text (executável) / .data (escrita) não é acidental:
// O SO mapeia .text como read-only — escrever em .text causa segfault
// DEP/NX bit — Data Execution Prevention: páginas de dados não são executáveis
// Defesa contra shellcode — código injectado em .data não pode executar
C compila directamente para código máquina da arquitectura alvo — o resultado executa sem qualquer intermediário. Java compila para bytecode da JVM — um formato portável que a JVM interpreta e compila just-in-time para código máquina em runtime. Esta diferença tem consequências profundas em performance, portabilidade, e segurança.
| Dimensão | C (compilação nativa) | Java (JVM) |
|---|---|---|
| Compilação | Uma vez, para arquitectura específica (x86-64, ARM) | Uma vez para bytecode; JIT em runtime para código máquina |
| Portabilidade | Recompilação necessária para cada plataforma/arquitectura | "Write once, run anywhere" — mesmo .jar em qualquer plataforma com JVM |
| Startup | Imediato — o código já é máquina | Lento — JVM inicializa, carrega classes, JIT aquece |
| Performance peak | Máxima — zero overhead de runtime | Muito alta após JIT aquecer — o JIT pode optimizar com informação de runtime |
| Gestão de memória | Manual (malloc/free) — controlo total, risco de leaks e UB | Garbage Collector — automática, pausas GC, overhead de memória |
| Segurança de memória | Sem verificações — buffer overflow, use-after-free, UB possíveis | Verificações em runtime — ArrayIndexOutOfBoundsException, NullPointerException |
| Runtime necessário | Nenhum — o executável é autónomo | JVM obrigatória — Java Runtime Environment (JRE) |
// O percurso de compilação completo lado a lado:
// ── C ────────────────────────────────────────────────────────────────────────
programa.c
→ (preprocessamento) → programa.i (C expandido)
→ (compilação) → programa.s (Assembly x86-64)
→ (assembling) → programa.o (código máquina, objecto)
→ (linking) → programa (executável ELF)
→ executa directamente no CPU — zero overhead de runtime
// ── Java ─────────────────────────────────────────────────────────────────────
Programa.java
→ (javac) → Programa.class (bytecode JVM)
→ (java / JVM) → interpreta bytecode
→ (JIT compiler) → código máquina nativo (após aquecimento)
→ executa no CPU — com overhead de JVM, GC, verificações de runtime
// ── GraalVM Native Image — o melhor dos dois mundos ─────────────────────────
Programa.java
→ (javac) → Programa.class (bytecode JVM)
→ (native-image) → programa (executável nativo, sem JVM)
→ executa directamente no CPU — startup imediato, sem GC overhead
// usado em Spring Boot Native, Quarkus, Micronaut para cloud/serverless
A linha de compilação em produção deve incluir muito mais do que apenas o nome do ficheiro. Warnings são erros por revelar — o compilador detecta gratuitamente classes inteiras de bugs.
// Linha de compilação recomendada para desenvolvimento seguro em C:
gcc -O2 -Wall -Wextra -Wshadow -Wformat=2 -Wconversion \
-fstack-protector-strong \
-D_FORTIFY_SOURCE=2 \
-pie -fPIE \
-o programa programa.c
// Explicação de cada flag:
// -O2 optimização standard de produção
// -Wall todos os warnings standard (não é "all" — é um conjunto curado)
// -Wextra warnings adicionais não incluídos em -Wall
// -Wshadow avisa quando uma variável local esconde uma variável exterior
// -Wformat=2 verifica format strings em printf/scanf (previne format string attacks)
// -Wconversion avisa em conversões implícitas que podem perder dados
// -fstack-protector-strong canary na stack — detecta buffer overflows em runtime
// -D_FORTIFY_SOURCE=2 verifica limites em funções de string/memória em runtime
// -pie -fPIE Position Independent Executable — necessário para ASLR
// ASLR (Address Space Layout Randomisation) randomiza endereços
// tornando exploits de buffer overflow muito mais difíceis
// Para debugging — não usar em produção:
gcc -O0 -g3 -fsanitize=address,undefined -o programa programa.c
// ^^ informação de debugging máxima (nomes de variáveis, números de linha)
// ^^^^^^^^^^^^^^^^^^^^^^^^^^ AddressSanitizer + UBSanitizer
// detectam em runtime: buffer overflows, use-after-free, integer overflow UB
-fsanitize=address instrumenta o programa para detectar
em runtime: buffer overflows na stack e heap, use-after-free,
leituras de memória não inicializada. -fsanitize=undefined
detecta undefined behaviour: integer overflow signed, null dereference,
desalinhamento. Overhead de ~2× em tempo e memória — usar apenas em
testes e CI, nunca em produção.
-O0 para desenvolvimento, -O2 para produção — optimizações podem expor UB escondido em -O0.-Wall -Wextra são obrigatórios — warnings gratuitos que detectam bugs antes de executar.-fsanitize=address,undefined em testes e CI — detecta em runtime o que os warnings não apanham em compile time.-fstack-protector-strong -D_FORTIFY_SOURCE=2 -pie -fPIE em produção — mitigações de segurança com custo mínimo.