Compiladores têm uma reputação injusta. Pergunte para qualquer dev o que acha de escrever um, e as respostas variam entre “impossível” e “coisa de pós-doutorado”. Essa percepção vem, em grande parte, de como o tema é ensinado nas faculdades: draões, parênteses, CFGs, gramáticas LALR(1), tabelas de parsing que parecem hieróglifos. Ninguém sai do semestre querendo escrever um compilador. Sai querendo nunca mais ouvir falar nisso.
Mas dois papers — um de 2006 e outro de 2013, ambos frequentemente referenciados como “os de 2008” no HN — vieram para desfazer esse mito. E o que eles ensinam é simples o suficiente para ser feito num final de semana: você não precisa construir o compilador inteiro antes de ter algo funcionando.
Essa ideia muda tudo.
O Paper que Desmitificou Compiladores
Em 2006, Abdulaziz Ghuloum publicou “An Incremental Approach to Compiler Construction” no Scheme Workshop. O argumento central é direto: a maioria das pessoas não consegue escrever um compilador porque tenta escrever o compilador inteiro de uma vez. Ghuloum propõe o oposto.
A abordagem incremental funciona assim: você começa com o compilador mais simples possível, que compila exatamente um tipo de expressão. Um número inteiro. Só isso. Esse compilador tem lexer, parser, geração de código — tudo funcionando, do começo ao fim. Então você adiciona mais um passo. Operações aritméticas. Depois variáveis. Depois condicionais. Em cada passo, você tem um compilador completo e funcional para um subconjunto da linguagem.
A diferença psicológica é enorme. Em vez de passar semanas sem ver nada funcionando, você tem código compilando e rodando no hardware depois das primeiras horas.
Passo 1: Um Compilador de 30 Linhas
Seguindo o método de Ghuloum, o primeiro compilador compila apenas inteiros para assembly x86-64:
# compiler_v1.py — compila apenas inteiros
import sys
def compile_program(expr):
# Passo 1: parse (trivial para inteiros)
value = int(expr.strip())
# Passo 2: geração de código assembly x86-64
return f"""
.globl _start
_start:
mov ${value}, %rax # coloca o valor no registrador de retorno
mov $60, %rdi # syscall exit
syscall
"""
source = sys.argv[1]
print(compile_program(source))
Ridiculamente simples. Mas funciona — compila um inteiro para assembly que pode ser executado diretamente. A partir daqui, você estende. Não reescreve. Estende.
O Segundo Paper: Nanopass
O segundo paper é “A Nanopass Framework for Commercial Compiler Development” de Andy Keep e R. Kent Dybvig (ICFP 2013 — a versão educacional é de 2004). O insight central aqui é diferente do de Ghuloum, mas complementar.
Compiladores industriais tendem a ter passes enormes — transformações que fazem muita coisa ao mesmo tempo e são difíceis de debugar, testar e manter. O framework nanopass propõe o extremo oposto: cada passo (pass) faz uma única coisa pequena. Um compilador nanopass pode ter 50 passes, mas cada um é trivial de entender isoladamente.
Pense assim:
| Abordagem tradicional | Nanopass |
|---|---|
| Pass enorme: otimização, inlining, alocação de registradores juntos | Pass 1: inline chamadas triviais Pass 2: remover código morto Pass 3: alocar registradores |
| Difícil de testar em isolamento | Cada pass tem input/output bem definido e testável |
| Bug difícil de localizar | Bug localizado no pass específico |
| Mudança em um lugar afeta tudo | Passes são independentes |
Juntos, os dois papers ensinam a mesma coisa por ângulos diferentes: decomponha o problema. Verticalmente (incremental: compile menos primeiro) e horizontalmente (nanopass: faça menos por passo).
Como Funciona um Compilador de Verdade
Antes de ir para o código, vale solidificar o mapa mental. Um compilador moderno percorre estas etapas:
- Lexing (Análise Léxica): transforma texto bruto em tokens.
2 + 3vira[NUM(2), PLUS, NUM(3)]. - Parsing (Análise Sintática): transforma tokens em AST (Abstract Syntax Tree). A árvore representa a estrutura lógica do programa.
- Análise Semântica: verifica tipos, escopos de variáveis, uso correto de funções. Onde a maioria dos erros de compilação vive.
- Geração de IR: produz uma representação intermediária, agnóstica de arquitetura (como LLVM IR ou bytecode).
- Otimização: passes de transformação no IR que melhoram performance.
- Geração de Código: produz assembly ou bytecode específico para o target.
A abordagem de Ghuloum pula algumas dessas etapas inicialmente e vai direto de parse para assembly. Funciona muito bem para aprendizado porque o feedback é imediato.
Implementando o Incremental na Prática
Vamos expandir o compilador do passo 1 para suportar adição e subtração:
# compiler_v2.py — agora suporta +, -, números
import sys
import re
# Lexer: transforma string em tokens
def lex(source):
token_patterns = [
('NUM', r'\d+'),
('PLUS', r'\+'),
('MINUS', r'-'),
('LPAREN', r'\('),
('RPAREN', r'\)'),
('WS', r'\s+'), # whitespace, ignorar
]
pattern = '|'.join(f'(?P{regex})' for name, regex in token_patterns)
tokens = []
for m in re.finditer(pattern, source):
if m.lastgroup != 'WS':
tokens.append((m.lastgroup, m.group()))
return tokens
# Parser: tokens → AST (recursivo descendente)
class Parser:
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
def peek(self):
return self.tokens[self.pos] if self.pos < len(self.tokens) else None
def consume(self, expected=None):
tok = self.tokens[self.pos]
if expected and tok[0] != expected:
raise SyntaxError(f"Esperava {expected}, encontrou {tok}")
self.pos += 1
return tok
def parse_expr(self):
left = self.parse_atom()
while self.peek() and self.peek()[0] in ('PLUS', 'MINUS'):
op = self.consume()[0]
right = self.parse_atom()
left = (op, left, right)
return left
def parse_atom(self):
tok = self.peek()
if tok[0] == 'NUM':
self.consume()
return ('NUM', int(tok[1]))
raise SyntaxError(f"Token inesperado: {tok}")
# Code gen: AST → assembly x86-64
def codegen(node):
if node[0] == 'NUM':
return f" mov ${node[1]}, %rax\n"
left_code = codegen(node[1])
right_code = codegen(node[2])
if node[0] == 'PLUS':
return (left_code +
" push %rax\n" +
right_code +
" pop %rcx\n" +
" add %rcx, %rax\n")
elif node[0] == 'MINUS':
return (left_code +
" push %rax\n" +
right_code +
" mov %rax, %rcx\n" +
" pop %rax\n" +
" sub %rcx, %rax\n")
source = sys.argv[1]
tokens = lex(source)
ast = Parser(tokens).parse_expr()
body = codegen(ast)
print(f""" .globl _start
_start:
{body} mov %rax, %rdi
mov $60, %rax
syscall
""")
Ainda cabe num arquivo. Ainda é completamente legível. E agora compila expressões como 2 + 3, 10 - 4 + 1. O próximo passo seria adicionar multiplicação, depois variáveis, depois funções. Cada um isolado e testável.
Por Que Isso Importa Para Devs “Normais”
Escrever um compilador completo para uma linguagem de produção é trabalho de anos. Mas entender como compiladores funcionam transforma a forma como você escreve código — em qualquer linguagem.
Quando você sabe que o compilador vai fazer constant folding, você escreve código mais limpo sem tentar “ajudar” o compilador de formas que só atrapalham. Quando entende como inlining funciona, você toma decisões melhores sobre funções pequenas vs. grandes. Quando sabe o que é um registrador e por que o compilador tenta mantê-lo ali, você entende por que certas abstrações custam mais do que parecem.
Além disso, há casos práticos onde você vai querer escrever algo parecido com um compilador:
- DSLs (Domain Specific Languages) para configuração, queries, ou regras de negócio
- Template engines que compilam templates para funções eficientes
- Transpilers que convertem um subconjunto de código legado para código moderno
- Linters e analisadores estáticos que constroem ASTs para analisar padrões
- Formatadores de código como Prettier, que parseia e reprinta
Todas essas ferramentas usam as mesmas peças: lexer, parser, AST, transformações. A diferença entre um compilador “real” e um transpiler de DSL é apenas a complexidade — as técnicas são idênticas.
Por Onde Começar
Se você quiser realmente colocar a mão na massa, aqui está uma progressão honesta:
- Leia o paper do Ghuloum (18 páginas, gratuito): An Incremental Approach to Compiler Construction. Usa Scheme como linguagem de implementação, mas os conceitos se traduzem para qualquer linguagem.
- Implemente na linguagem que você conhece: Python, JavaScript, Go — todos funcionam. Não se prenda à linguagem do paper.
- Siga a progressão incremental: inteiros → aritmética → comparações → if/else → variáveis → funções. Um passo de cada vez.
- Escreva testes desde o início: cada passo novo tem casos de teste simples. Isso é o que o nanopass framework ensina: passes testáveis isoladamente.
- Não tente otimizar cedo: assembly ingênuo e correto é melhor do que assembly “otimizado” e bugado.
Recursos adicionais que valem o tempo:
- Crafting Interpreters — Robert Nystrom (gratuito online). Interprete antes de compilar — os conceitos se sobrepõem muito.
- My First Fifteen Compilers — SIGPLAN Blog. Uma retrospectiva honesta de alguém que escreveu muitos compiladores e errou bastante.
- scheme-to-c — implementação de referência nanopass por Andy Keep.
O Que Muda Depois que Você Escreve Um
Tem uma frase que ouço de todo dev que escreveu seu primeiro compilador, mesmo um pequeno: “agora eu entendo o que o compilador está fazendo”. Não é exagero. A caixa preta vira vidro.
Error messages que antes pareciam aleatórias fazem sentido. A ordem das operações numa expression faz sentido. Por que certos padrões de código são mais rápidos que outros faz sentido. Por que o type checker do TypeScript às vezes parece fazer mágica — e às vezes parece quebrado — faz sentido.
E talvez mais importante: você percebe que as pessoas que escreveram GCC, LLVM, rustc ou javac são humanas. Tomaram decisões, às vezes erradas, evoluíram o código ao longo de anos, tiveram deadlines. O compilador que você usa todo dia não é a obra de gênios inacessíveis. É código escrito por pessoas que um dia também começaram com um parser de inteiros.
Referências: An Incremental Approach to Compiler Construction — Abdulaziz Ghuloum (2006) | A Nanopass Framework for Commercial Compiler Development — Andy Keep & R. Kent Dybvig (2013) | Crafting Interpreters — Robert Nystrom















