C Compilador Hardware
C

Compilação Nativa

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.

As Quatro Fases da Compilação

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)

Fase 1: Preprocessamento

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
Include Guards — evitar inclusão dupla Se dois ficheiros incluem o mesmo header, o preprocessador insere o conteúdo duas vezes — causando erros de redefinição. Include guards resolvem isto: #ifndef HEADER_H / #define HEADER_H / ... / #endif. A directiva moderna equivalente é #pragma once — suportada por todos os compiladores modernos mas não é standard ISO.

Fase 2: Compilação

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

Fase 3: Assembling

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.

Fase 4: Linking

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

O Formato do Executável: ELF

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

Compilação vs JVM: a Diferença Fundamental

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ãoC (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

Flags de Compilação Essenciais

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
AddressSanitizer e UBSanitizer -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.

Checklist Compilação Nativa