Eu criei um compilador para JVM só para provar um ponto
Um dia eu vi um vídeo de um influencer famoso de Java tentando explicar as coisas internas da linguagem. Máquina virtual, JVM, class loader, o processo de compilação. Tentando, porque o cara não sabia nada do que estava falando. Nada. Estava ali, com milhares de seguidores, explicando conceitos que ele claramente não entendia.
Eu não acho que todo mundo precisa saber JVM no nível de bytecode. Sério, não acho. Mas quando você se propõe a explicar - quando você senta na frente de uma câmera e fala como se fosse referência - é sua obrigação entender o que tá falando. Não precisa ser especialista, mas pelo menos saber o mínimo sobre o processo que você tá descrevendo.
Eu já estava curioso sobre máquinas virtuais fazia um tempo. Não o Java - a máquina virtual em si. O bytecode, o constant pool, o formato .class. Aquele nível que a maioria dos devs nunca precisa tocar. E eu já tinha brincado com Brainfuck antes. Daí eu vi um vídeo do Tsoding Daily onde ele implementa um JIT compiler pra Brainfuck - traduzindo direto pra código de máquina x86-64, em tempo de execução. Ver alguém construindo algo assim na raça, sem framework, sem abstração - aquilo juntou tudo na minha cabeça.
E se eu pegasse a linguagem mais simples que existe e compilasse ela pra rodar na JVM? Entender a JVM por dentro e fazer algo com Brainfuck, tudo no mesmo projeto.
Brainfuck tem 8 comandos. Oito. +, -, >, <, [, ], ., ,. É isso. Uma linguagem que cabe num guardanapo. Mas pra compilar ela pra bytecode JVM? Aí a coisa fica interessante.
O resultado? Um compilador de Brainfuck pra JVM escrito em Node.js. Zero dependências externas. Código escrito na mão. E o projeto mais divertido que eu já fiz.
A jornada
O primeiro passo foi construir um interpretador. Isso foi rápido - em um dia eu tinha um interpretador funcional em JavaScript. Brainfuck é simples de interpretar. Você tem um array de memória, um ponteiro, e vai executando os comandos. Que nem um caixa de supermercado - você processa um item de cada vez, na ordem.
O problema começou quando eu quis gerar bytecode.
Eu precisava entender o formato .class da JVM. E quando eu digo entender, eu digo byte por byte. O magic number CAFE BABE, o constant pool, os descritores de método, os atributos de código. Tudo em binário.
Minha primeira abordagem foi pegar um .class compilado pelo javac e dissecar ele com xxd:
xxd -s 270 -l 4 BrainfuckProgram.class
Fiquei semanas lendo hex dump. Parece loucura, mas foi assim que eu comecei a entender como a JVM realmente funciona. Eu escrevia um programa Java simples, compilava, e ficava comparando o binário com a spec. Byte por byte.
NOTA: Eu escrevi dois artigos detalhados sobre essa parte técnica no blog da Codeminer42: The Road To JVM: How To Create A Brainfuck Interpreter e The Road To JVM: The JVM Specification. Se você quer o detalhe técnico, vale a leitura.
Os bugs mais legais da minha vida
Eu não tô exagerando quando digo que os bugs desse projeto foram divertidos. Em qualquer outro projeto, um VerifyError da JVM seria frustrante. Aqui, eu ficava animado quando algo quebrava porque significava que eu ia aprender mais uma coisa.
Um dos primeiros: eu tentei guardar um int e um array de bytes no mesmo slot de variável local. A JVM não deixa. Faz sentido - ela precisa saber o tipo de cada slot pra verificação. Mas eu só descobri isso porque a JVM me mandou um erro detalhado dizendo exatamente o que estava errado:
Caused by: java.lang.VerifyError: Bad type on operand stack
Reason: Type integer is not assignable to reference type
Descobri que o slot 0 é reservado pros argumentos do método. Meu array de memória tinha que ir pro slot 1 e o ponteiro pro slot 2. Parece óbvio agora, mas na hora eu passei um bom tempo compilando programas Java com javac -g:vars pra ver a LocalVariableTable e confirmar minhas suspeitas.
Outro bug legal: os offsets de jump. No Brainfuck, [ e ] são instruções de loop. No bytecode JVM, isso vira ifeq e ifne com offsets em bytes. Eu estava calculando errado e a JVM reclamava de “bytecode offset out of range”. A solução foi um sistema de dois passos - primeiro calcula as posições, depois gera o bytecode com os offsets corretos.
E o boss final: a StackMapTable.
O chefe de fase: StackMapTable
A JVM moderna (versão 50+) exige que todo .class tenha uma StackMapTable nos métodos que fazem jumps. É uma estrutura que descreve o estado da stack e das variáveis locais em cada ponto de salto. Se você não gerar isso corretamente, a JVM se recusa a rodar seu código.
Por um bom tempo eu contornei isso rodando com java -noverify. Funcionava, mas era trapaça. Que nem usar // @ts-ignore - resolve na hora, mas você sabe que tá errado.
O problema é que a spec da StackMapTable é confusa. Existem vários tipos de frame (same_frame, append_frame, same_frame_extended), cada um com regras diferentes pra calcular o offset_delta. O primeiro frame é um append_frame (tipo 253) porque adiciona duas variáveis locais (o array de memória e o ponteiro). Os frames seguintes são same_frame (tipo 0-63) porque os locais não mudam.
A fórmula do delta: target_pc - 1 - previous_target_pc.
Quando eu achei que estava tudo certo, funcionou pro caso trivial mas quebrava com mais de dois jumps. Passei horas debugando hex dumps até perceber que o cálculo do tamanho do atributo estava errado - eu somava o número de entries ao tamanho do buffer, mas o correto era 2 + buffer.length. Dois bytes pro número de entries, o resto pro conteúdo.
Quando finalmente funcionou sem -noverify, eu fiquei uns bons minutos olhando pro terminal sem acreditar. Meses de hex dump, de ler spec, de errar e tentar de novo. E agora o verificador da JVM aceitou meu .class como válido.
O que a IA não conseguiu fazer
Uma coisa interessante aconteceu durante o projeto. Quando eu já tinha a ideia de como o bytecode deveria ser gerado, eu pedi pro ChatGPT e pro Claude implementarem. Na época eu estava usando os dois no chat, sem nenhuma ferramenta de coding. Eu tinha o design mental, só queria ver se eles conseguiam traduzir isso em código.
Não funcionou.
O código que eles geraram não rodava. Mas - e isso é importante - serviu de referência. Especialmente o sistema de patches pra lidar com instruções de jump. Eu peguei a ideia, entendi, e reescrevi do zero.
Isso pra mim é o uso correto de IA. Você usa pra pesquisar, pra ter uma ideia de direção, mas o trabalho de verdade ainda é seu. Que nem o Pragmatic Programmer fala sobre protótipos - você constrói pra aprender, não pra usar diretamente.
Hoje em dia, com Claude Code ou OpenCode rodando Opus, eu acredito que a IA daria conta. Mas mesmo que desse - e esse é o ponto - eu não teria aprendido nada. Se a IA escreve o código por você, o código funciona mas a sua cabeça continua vazia.
Por que isso foi tão divertido
Eu trabalho com software há anos. Já fiz feature, já fiz bugfix, já fiz refactoring em código legado. Tudo isso é importante e eu gosto do que faço. Mas tem algo diferente em construir algo completamente do zero, sem framework, sem biblioteca, sem dependência externa.
O BrainJuck é Node.js puro. Usa node:fs pra ler arquivos, node:test pra testes, e mais nada. Cada byte do .class gerado é escrito manualmente. O constant pool, os descritores de método, as instruções - tudo.
A arquitetura é um pipeline de três estágios:
Brainfuck Source -> Tokenizer -> Parser -> IR -> JVM Bytecode -> ClassFile
Não tinha deadline, não tinha sprint, não tinha ticket no Jira. Era só eu, a spec da JVM, e um editor de texto. Quando um .class gerado rodava de primeira, eu comemorava sozinho. Quando quebrava, eu abria o xxd e ia caçar o byte errado. As duas coisas eram igualmente boas.
O que eu aprendi
Além de JVM bytecode (que, sinceramente, eu duvido que vá usar no dia a dia), eu aprendi coisas que vão além do técnico:
-
Ler specs é uma habilidade. A spec da JVM é densa, mas precisa. Cada byte tem um significado definido. Aprender a ler specs te torna um programador melhor - você para de depender de tutoriais e vai direto na fonte.
-
Projetos pessoais não precisam ser úteis. O BrainJuck não resolve nenhum problema real. Ninguém precisa compilar Brainfuck pra JVM. E tá tudo bem. O valor tá no aprendizado. Que nem a Barbara Oakley fala em A Mind for Numbers - aprender coisas aparentemente desconectadas fortalece sua capacidade de resolver problemas em geral.
-
Se a IA escreve por você, você não aprende. A IA me deu ideias que aceleraram meu entendimento. Mas se eu tivesse deixado ela escrever o compilador, eu não teria entendido nada do que foi feito. O valor estava no processo, não no resultado.
-
Debug de baixo nível é meditativo. Tem algo zen em olhar hex dump e entender o que cada byte significa. É o oposto do desenvolvimento web moderno onde tudo é abstração sobre abstração.
-
Raiva é um combustível válido. Às vezes ver alguém falar besteira sobre algo te motiva a ir mais fundo do que a curiosidade sozinha levaria. Não é o combustível mais nobre, mas funciona.
Recomendações
Se você ficou com vontade de explorar compiladores e máquinas virtuais:
- A spec da JVM é gratuita e online: The Java Virtual Machine Specification
- Crafting Interpreters do Robert Nystrom - excelente livro sobre como construir linguagens de programação, do zero
- O vídeo do Tsoding implementando um JIT pra Brainfuck - pura inspiração ver alguém construindo na raça
- O próprio Brainfuck como linguagem de estudo - é simples o suficiente pra você focar na mecânica do compilador sem se perder na complexidade da linguagem fonte
Se você quer aprender a construir o seu próprio, eu escrevi uma série de três posts que ensina passo a passo: parte 1 (interpretador), parte 2 (formato .class), parte 3 (gerando bytecode).
E o BrainJuck tá no GitHub. Zero dependências. Leia o código, brinque, quebre. É pra isso que ele existe.
Por hoje é só. Abraços.