Compilando Brainfuck pra JVM, parte 3: gerando bytecode
Na parte 1 a gente construiu o interpretador e a IR. Na parte 2 a gente entendeu o formato .class e montou o gerador de ClassFile. Agora é hora de juntar tudo e gerar bytecode JVM de verdade.
Esse é o post mais denso da série. No final, você vai rodar java CompiledBrainfuck e ver um “Hello, World!” gerado pelo seu próprio compilador.
O modelo de memória no bytecode
Antes de traduzir cada instrução, a gente precisa decidir como mapear o modelo do Brainfuck (fita de memória + ponteiro) pros conceitos da JVM.
Na JVM, um método tem variáveis locais numeradas a partir de 0. No nosso main(String[] args):
| Slot | Tipo | Uso |
|---|---|---|
| 0 | String[] |
argumentos do método (args) |
| 1 | byte[] |
fita de memória (30.000 células) |
| 2 | int |
ponteiro (posição atual na fita) |
O slot 0 é reservado pros argumentos - eu tentei usar ele pra memória e a JVM deu VerifyError. Também tentei colocar o byte[] e o int no mesmo slot e descobri que a JVM não deixa misturar tipos num slot. Faz sentido quando você pensa na verificação de tipos, mas na hora foi confuso.
O preâmbulo do bytecode aloca essas variáveis:
// sipush 30000 - coloca 30000 na stack
// newarray byte - cria byte[30000]
// astore_1 - guarda no slot 1
// iconst_0 - coloca 0 na stack
// istore_2 - guarda no slot 2 (ponteiro = 0)
const preamble = [
0x11, 0x75, 0x30, // sipush 30000
0xbc, 0x08, // newarray byte (T_BYTE = 8)
0x4c, // astore_1
0x03, // iconst_0
0x3d // istore_2
];
São 8 bytes. Todo bytecode gerado começa com esse preâmbulo.
Traduzindo cada instrução da IR
Cada instrução da IR vira uma sequência de opcodes JVM.
increment(n)
A operação cells[head] += n em bytecode:
aload_1 0x2b // carrega o array (slot 1) na stack
iload_2 0x1c // carrega o ponteiro (slot 2) na stack
dup2 0x5c // duplica os dois valores no topo
baload 0x33 // pega cells[head] (consome array + index, empilha o byte)
sipush n 0x11 // coloca n na stack
iadd 0x60 // soma
i2b 0x91 // converte de int pra byte (trunca pra 8 bits)
bastore 0x54 // guarda o resultado em cells[head]
O dup2 é o truque aqui. O baload consome a referência do array e o índice da stack, mas a gente ainda precisa deles pro bastore depois. Então duplica antes de carregar o valor.
Em JavaScript:
function increment(n) {
return [
0x2b, // aload_1
0x1c, // iload_2
0x5c, // dup2
0x33, // baload
0x11, ...intTo2Bytes(n), // sipush n
0x60, // iadd
0x91, // i2b
0x54 // bastore
];
}
Cada increment gera 10 bytes de bytecode.
move_head(h)
Essa é simples - só carrega o valor absoluto e guarda no slot 2:
function move_head(h) {
return [
0x11, ...intTo2Bytes(h), // sipush h
0x3d // istore_2
];
}
4 bytes. Por isso a decisão de usar posição absoluta no parser (parte 1) simplifica as coisas aqui.
output
Pra imprimir um caractere, a gente precisa chamar System.out.print(char). Em bytecode:
function output({ fieldRefIndex, methodRefIndex }) {
return [
0xb2, ...intTo2Bytes(fieldRefIndex), // getstatic System.out
0x2b, // aload_1 (array)
0x1c, // iload_2 (ponteiro)
0x33, // baload (carrega cells[head])
0x92, // i2c (converte int pra char)
0xb6, ...intTo2Bytes(methodRefIndex) // invokevirtual print(char)
];
}
O fieldRefIndex e methodRefIndex são índices no constant pool que referenciam System.out e PrintStream.print(C)V. Esses índices vêm do gerador de ClassFile que a gente construiu na parte 2.
O i2c (int to char) é necessário porque baload retorna um int e o método print espera um char. Eu originalmente tentei com (I)V no descritor (print recebendo int), mas o output saía como número em vez de caractere. O descritor correto é (C)V.
input
Ler de System.in.read() e guardar na célula:
function input({ fieldRefIndex, methodRefIndex }) {
return [
0xb2, ...intTo2Bytes(fieldRefIndex), // getstatic System.in
0xb6, ...intTo2Bytes(methodRefIndex), // invokevirtual read()
0x2b, // aload_1 (array)
0x5f, // swap
0x1c, // iload_2 (ponteiro)
0x5f, // swap
0x54 // bastore
];
}
Os dois swap são necessários por causa da ordem da stack. O bastore espera arrayref, index, value nessa ordem, de baixo pra cima. Mas depois do invokevirtual read(), o valor lido tá no topo. A gente precisa reorganizar.
Segue o estado da stack passo a passo:
Depois do read(): [valor_lido]
Depois do aload_1: [valor_lido, array]
Depois do swap: [array, valor_lido]
Depois do iload_2: [array, valor_lido, ponteiro]
Depois do swap: [array, ponteiro, valor_lido]
bastore consome tudo: []
jump_eqz e jump_neqz
Os saltos condicionais. O Brainfuck [ vira jump_eqz (pula se zero) e ] vira jump_neqz (pula se não-zero):
function jump_eqz(offset) {
return [
0x2b, // aload_1
0x1c, // iload_2
0x33, // baload (carrega cells[head])
0x99, ...intTo2Bytes(offset) // ifeq offset
];
}
function jump_neqz(offset) {
return [
0x2b,
0x1c,
0x33,
0x9a, ...intTo2Bytes(offset) // ifne offset
];
}
Cada jump gera 6 bytes. Mas tem um problema: na hora de gerar o bytecode, a gente ainda não sabe o offset do salto. Os offsets dependem do tamanho total do bytecode entre o [ e o ], e a gente só vai saber isso depois de gerar todas as instruções entre eles.
O sistema de patches
Pra resolver o problema dos offsets desconhecidos, a gente usa um sistema de dois passos.
Passo 1: gera todo o bytecode com offsets zerados (placeholder) e registra onde cada salto tá:
const patches = [];
const labelPC = {};
for (let i = 0; i < irInstructions.length; i++) {
const instruction = irInstructions[i];
labelPC[i] = jvmPc; // mapeia índice da IR pro PC do bytecode
if (instruction.type === 'jump_eqz') {
const bytes = jump_eqz(0); // offset 0 = placeholder
patches.push({
at: jvmPc + 4, // posição dos 2 bytes de offset
targetIr: instruction.jmp,
branchPc: jvmPc + 3 // posição do opcode ifeq
});
code.push(...bytes);
jvmPc += bytes.length;
}
// ... mesmo pra jump_neqz
}
Passo 2: depois de gerar todo o bytecode, percorre os patches e calcula os offsets reais:
for (const patch of patches) {
const targetPc = labelPC[patch.targetIr];
const offset = targetPc - patch.branchPc;
const [hi, lo] = intTo2Bytes(offset);
code[patch.at] = hi;
code[patch.at + 1] = lo;
}
O offset é relativo à posição do opcode de salto, não ao início do bytecode. Se o ifeq tá na posição 35 e o destino tá na posição 70, o offset é 70 - 35 = 35. Se é um salto pra trás (como o ] voltando pro [), o offset é negativo.
Esse foi um dos bugs que mais demorou pra achar. No começo eu tava usando offsets absolutos e a JVM reclamava de “bytecode offset out of range”. Só depois de ler a spec com mais cuidado eu entendi que os offsets de ifeq e ifne são relativos ao próprio opcode.
A StackMapTable
Esse é o chefe de fase do projeto.
A JVM a partir da versão 50 (Java 6) exige uma StackMapTable em todo método que tem saltos. Essa tabela descreve o estado das variáveis locais e da stack em cada ponto de destino de um salto. O verificador da JVM usa isso pra garantir type safety sem precisar executar o código.
Se você não gerar a StackMapTable, a JVM se recusa a carregar o .class:
Error: Expecting a stackmap frame at branch target 107
Dá pra contornar com java -noverify, mas isso desabilita a verificação inteira. É tipo desligar o alarme de incêndio porque tá apitando.
Como a StackMapTable funciona
A tabela é uma sequência de “frames”, cada um descrevendo o estado num ponto específico do bytecode. Existem vários tipos de frame, mas pro Brainfuck a gente só precisa de dois:
append_frame (tipo 253): usado no primeiro frame. Indica que foram adicionadas variáveis locais em relação ao frame inicial do método. No nosso caso, adicionamos byte[] (slot 1) e int (slot 2).
same_frame (tipo 0-63): os locais não mudaram desde o frame anterior. O tipo do frame É o offset delta (se cabe em 0-63).
same_frame_extended (tipo 251): mesmo que same_frame, mas pra offsets maiores que 63. O offset delta vem nos 2 bytes seguintes.
Calculando os offset deltas
O offset delta entre frames não é simplesmente a posição do destino. A fórmula:
primeiro frame: offset_delta = targetPc
demais frames: offset_delta = targetPc - previousTargetPc - 1
O -1 existe porque o frame anterior já “consome” uma posição. Eu demorei pra entender isso e o resultado era que os frames ficavam sempre 1 byte deslocados.
Gerando a StackMapTable
Durante a geração de bytecode, a gente registra os destinos de saltos. Depois, ordena eles por posição e calcula os frames:
function computeStackMapTable(entries, stackMapTableConstantIndex) {
const buf = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (i === 0) {
// primeiro frame: append_frame com 2 locais
buf.push(253); // frame type
buf.push(...intTo2Bytes(entry.offsetDelta)); // offset delta
buf.push(7); // verification: Object
buf.push(...intTo2Bytes(byteArrayCPIndex)); // index de [B no constant pool
buf.push(1); // verification: Integer
} else if (entry.offsetDelta <= 63) {
// same_frame: o tipo É o offset
buf.push(entry.offsetDelta);
} else {
// same_frame_extended
buf.push(251);
buf.push(...intTo2Bytes(entry.offsetDelta));
}
}
// monta o atributo completo
const result = [];
result.push(...intTo2Bytes(stackMapTableConstantIndex)); // nome
result.push(...intTo4Bytes(2 + buf.length)); // tamanho
result.push(...intTo2Bytes(entries.length)); // num entries
result.push(...buf); // frames
return result;
}
O tamanho do atributo é 2 + buf.length - 2 bytes pro número de entries, mais o conteúdo dos frames. Eu errei esse cálculo e demorei horas pra achar o bug. O .class passava no javap mas a JVM dava erro de verificação. O problema era que eu tava somando entries.length ao invés de 2 como os bytes fixos do campo de contagem.
Quando funciona sem -noverify
Quando eu finalmente acertei a StackMapTable, o programa rodou sem a flag -noverify pela primeira vez. O commit 44ff6c2 marca esse momento. Eu atualizei o README pra remover a instrução de usar -noverify e foi uma das melhores sensações do projeto inteiro.
Juntando tudo
O fluxo completo de compilação:
1. Lê o arquivo .bf
2. Tokeniza (extrai os 8 comandos válidos)
3. Parse pra IR (com otimizações)
4. Gera bytecode JVM a partir da IR
- Emite preâmbulo (alocação de memória)
- Traduz cada instrução
- Registra patches pra saltos
5. Resolve patches (calcula offsets)
6. Computa StackMapTable
7. Monta o ClassFile (constant pool + métodos + atributos)
8. Escreve o .class
O CLI do BrainJuck faz tudo isso em poucas linhas:
#!/usr/bin/env node
import { readFileSync, writeFileSync } from 'node:fs';
import { parseBrainfuck, brainfuckIRToJVM } from './index.js';
import { ClassFileGenerator } from './class_generator.js';
const [,, sourceFile, className = 'CompiledBrainfuck'] = process.argv;
const source = readFileSync(sourceFile, 'utf-8');
const ir = parseBrainfuck(source);
const generator = new ClassFileGenerator();
const classBytes = generator.generateHelloWorldClass(
className,
({ symbolicConstantPool }) => {
return brainfuckIRToJVM(ir, {
input: {
fieldRefIndex: symbolicConstantPool.input.fieldRef,
methodRefIndex: symbolicConstantPool.input.readMethodrefIndex
},
output: {
fieldRefIndex: symbolicConstantPool.output.fieldRef,
methodRefIndex: symbolicConstantPool.output.printlnMethodrefIndex
}
});
}
);
writeFileSync(`${className}.class`, new Uint8Array(classBytes));
console.log(`${className}.class generated`);
Compila e roda:
./brainjuck samples/helloworld.bf HelloWorld
java HelloWorld
# Hello, World!
Testando
O projeto tem testes unitários pro tokenizer, parser e geração de bytecode, e um teste de integração que compila o Hello World e roda com java:
import { describe, it } from 'node:test';
import { execSync } from 'node:child_process';
describe('integration', () => {
it('compila e executa helloworld.bf', () => {
execSync('./brainjuck samples/helloworld.bf CompiledHelloWorld');
const output = execSync('java CompiledHelloWorld').toString();
assert(output.includes('Hello, World'));
});
});
Se esse teste passa, o compilador gera bytecode JVM válido que a JVM aceita, verifica, e executa corretamente.
Recapitulando a série
Em três posts, a gente construiu um compilador de ~700 linhas de JavaScript que transforma Brainfuck em bytecode JVM executável. Interpretador, parser com otimizações, gerador de ClassFile, tradução de IR pra bytecode, sistema de patches pra saltos, e StackMapTable. Sem dependências externas. Cada byte do .class escrito manualmente.
O código completo tá em geeksilva97/brainjuck. Clona, lê, modifica, quebra. Se você quiser ir mais fundo, a spec da JVM é a referência definitiva. E se quiser ver outra abordagem, o Tsoding fez um JIT compiler pra Brainfuck compilando direto pra x86-64.
Por hoje é só. Abraços.