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.