Na parte 1 a gente construiu um interpretador de Brainfuck com tokenizer, parser e uma Representação Intermediária. Agora a gente precisa entender o formato que a JVM espera receber pra poder gerar nosso próprio bytecode.

Quando você roda javac Hello.java, o compilador gera um Hello.class. Esse arquivo é binário - não é texto, não é JSON, não é XML. São bytes crus numa estrutura muito específica definida na especificação da JVM.

Nesse post, a gente vai abrir um .class com xxd, entender cada byte, e construir um gerador que monta essa estrutura do zero em Node.js.

Antes de tudo: big endian

A JVM usa big endian pra representar números de múltiplos bytes. Isso é importante porque o processador do seu computador (x86/ARM) provavelmente usa little endian, que é a ordem inversa.

Qual a diferença? Imagina o número 30.000 (em hex: 0x7530). Ele ocupa 2 bytes:

Formato Byte 1 Byte 2
Big endian 0x75 0x30
Little endian 0x30 0x75

Big endian coloca o byte mais significativo primeiro. Little endian coloca o menos significativo primeiro.

No nosso gerador, toda vez que a gente escrever um número de 2 ou 4 bytes, precisa respeitar essa ordem. A função pra converter um número de 16 bits (2 bytes) pra big endian:

function intTo2Bytes(num) {
  return [(num >> 8) & 0xFF, num & 0xFF];
}

O >> 8 desloca 8 bits pra direita, pegando o byte alto. O & 0xFF mascara o byte baixo. Pra 4 bytes, a lógica é a mesma mas com mais shifts.

Se você errar a ordem dos bytes, a JVM vai ler valores completamente errados. Um 0x7530 (30.000) vira 0x3075 (12.405) se os bytes ficarem invertidos. Então se o seu .class gerado dá erros estranhos no constant pool, confere se você tá escrevendo big endian.

Dissecando um .class

Vamos criar o programa Java mais simples possível:

public class Hello {
  public static void main(String[] args) {
    return;
  }
}

Compila com javac Hello.java e abre o binário com xxd Hello.class:

00000000: cafe babe 0000 0034 000d 0a00 0200 0307  .......4........
00000010: 0004 0c00 0500 0601 0010 6a61 7661 2f6c  ..........java/l
00000020: 616e 672f 4f62 6a65 6374 0100 063c 696e  ang/Object...<in
00000030: 6974 3e01 0003 2829 5607 0008 0100 0548  it>...()V......H
00000040: 656c 6c6f 0100 0443 6f64 6501 0004 6d61  ello...Code...ma
00000050: 696e 0100 1628 5b4c 6a61 7661 2f6c 616e  in...([Ljava/lan
00000060: 672f 5374 7269 6e67 3b29 5600 2100 0700  g/String;)V.!...

Parece caótico, mas tem uma estrutura definida. Vamos ler byte por byte.

O magic number

Os primeiros 4 bytes de todo .class são sempre CA FE BA BE. É o magic number que identifica o arquivo como um ClassFile da JVM. Se esses bytes não estiverem lá, a JVM recusa o arquivo imediatamente.

cafe babe

A história diz que os criadores do Java escolheram CAFEBABE porque lembravam de um café que frequentavam. Verdade ou não, é memorável.

Versão

Os 4 bytes seguintes indicam a versão do formato:

0000 0034
  • 0000 - minor version: 0
  • 0034 - major version: 52 (decimal)

Major version 52 é Java 8. A versão é importante porque determina quais features o .class pode usar. A partir da versão 50 (Java 6), por exemplo, a StackMapTable é obrigatória - mas esse é assunto da parte 3.

O constant pool

Aqui é onde mora a complexidade. O constant pool é uma tabela que armazena todas as constantes do programa: nomes de classes, nomes de métodos, strings, descritores de tipo. Tudo que o bytecode referencia é guardado aqui.

Os próximos 2 bytes indicam o tamanho:

000d

0x000d = 13. Mas atenção: o constant pool usa indexação começando em 1, e o count é sempre n + 1. Então temos 12 entries (indices 1 a 12).

Cada entry começa com um byte de tag que indica o tipo:

Tag Tipo O que armazena
1 CONSTANT_Utf8 String UTF-8 (nomes, descritores)
7 CONSTANT_Class Referência a uma classe (aponta pra um Utf8)
9 CONSTANT_Fieldref Referência a um campo (classe + nome/tipo)
10 CONSTANT_Methodref Referência a um método (classe + nome/tipo)
12 CONSTANT_NameAndType Par nome + descritor de tipo

Vou destrinchar as primeiras entries do nosso Hello.class:

Entry 1 (começa no byte 0x0a):

0a 00 02 00 03
  • Tag 0x0a = 10 = CONSTANT_Methodref
  • Class index: 0x0002 = 2
  • NameAndType index: 0x0003 = 3

Isso é uma referência ao método Object.<init>()V - o construtor da classe pai.

Entry 2:

07 00 04
  • Tag 0x07 = 7 = CONSTANT_Class
  • Name index: 0x0004 = 4 (aponta pra um Utf8 com o nome da classe)

Entry 4:

01 0010 6a617661 2f6c616e 672f4f62 6a656374
  • Tag 0x01 = 1 = CONSTANT_Utf8
  • Length: 0x0010 = 16 bytes
  • Conteúdo: java/lang/Object

Percebe o padrão? O Methodref aponta pro Class, que aponta pro Utf8. É uma estrutura de referências indiretas. O bytecode nunca guarda strings diretamente - tudo passa pelo constant pool.

Os descritores de tipo

Uma coisa que confunde no começo: a JVM usa uma notação própria pra tipos. Não é void main(String[] args), é ([Ljava/lang/String;)V.

As regras:

Tipo Java Descritor JVM
int I
byte B
char C
void V
String Ljava/lang/String;
int[] [I
byte[] [B

Pra métodos, o formato é (parâmetros)retorno. Então:

Java Descritor JVM
void main(String[] args) ([Ljava/lang/String;)V
void print(char c) (C)V
int read() ()I

Esses descritores aparecem no constant pool e o bytecode referencia eles pelo índice.

Depois do constant pool

O resto do ClassFile segue:

0021              - Access flags (ACC_PUBLIC | ACC_SUPER)
0007              - This class (índice no constant pool)
0002              - Super class (java/lang/Object)
0000              - Interfaces count: 0
0000              - Fields count: 0
0002              - Methods count: 2

Depois vêm os métodos (cada um com seus atributos de código) e por fim os atributos da classe.

Uma coisa que eu não esperava: mesmo o programa mais simples tem 2 métodos. O main que a gente escreveu e o construtor <init> que o Java gera automaticamente. No nosso gerador, a gente também precisa criar esse construtor.

Construindo o gerador

Agora que a gente entende a estrutura, vamos construir um gerador em Node.js. A ideia é montar o .class byte por byte num buffer.

Escrevendo bytes

Primeiro, as primitivas de escrita:

class ClassFileGenerator {
  constructor() {
    this.buffer = [];
    this.constantPool = [];
    this.constantPoolMap = {};
  }

  writeU1(value) {
    this.buffer.push(value & 0xFF);
  }

  writeU2(value) {
    this.buffer.push((value >> 8) & 0xFF);
    this.buffer.push(value & 0xFF);
  }

  writeU4(value) {
    this.buffer.push((value >> 24) & 0xFF);
    this.buffer.push((value >> 16) & 0xFF);
    this.buffer.push((value >> 8) & 0xFF);
    this.buffer.push(value & 0xFF);
  }

  writeBytes(bytes) {
    for (const b of bytes) {
      this.buffer.push(b);
    }
  }
}

U1, U2, U4 - 1, 2 e 4 bytes sem sinal. Tudo em big endian, que é o que a JVM espera. Repara que writeU2 e writeU4 usam shifts pra separar os bytes na ordem correta - byte mais significativo primeiro.

Gerenciando o constant pool

O constant pool precisa de deduplicação. Se dois métodos referenciam a mesma string "java/lang/Object", ela deve aparecer uma vez só. A gente usa um mapa pra controlar isso:

addUtf8Constant(str) {
  const key = `utf8:${str}`;
  if (this.constantPoolMap[key]) {
    return this.constantPoolMap[key];
  }

  this.constantPool.push({ tag: 1, value: str });
  const index = this.constantPool.length;
  this.constantPoolMap[key] = index;
  return index;
}

addClassConstant(nameIndex) {
  const key = `class:${nameIndex}`;
  if (this.constantPoolMap[key]) {
    return this.constantPoolMap[key];
  }

  this.constantPool.push({ tag: 7, nameIndex });
  const index = this.constantPool.length;
  this.constantPoolMap[key] = index;
  return index;
}

O mesmo padrão se repete pra addMethodrefConstant, addFieldrefConstant, addNameAndTypeConstant. Cada tipo tem seu tag e seus campos, mas a lógica de deduplicação é a mesma.

O índice retornado é a posição no array + 1, porque o constant pool da JVM é 1-indexed.

Montando o ClassFile

Pra gerar o .class do nosso compilador Brainfuck, a gente precisa de várias entries no constant pool. Especificamente, pra fazer System.out.print(char) e System.in.read(), que são as operações de I/O do Brainfuck:

// System.out (pra output do brainfuck)
const systemClassName = this.addUtf8Constant('java/lang/System');
const systemClass = this.addClassConstant(systemClassName);
const outFieldName = this.addUtf8Constant('out');
const printStreamDesc = this.addUtf8Constant('Ljava/io/PrintStream;');
const outNaT = this.addNameAndTypeConstant(outFieldName, printStreamDesc);
const outFieldRef = this.addFieldrefConstant(systemClass, outNaT);

// PrintStream.print(char)
const printStreamClassName = this.addUtf8Constant('java/io/PrintStream');
const printStreamClass = this.addClassConstant(printStreamClassName);
const printName = this.addUtf8Constant('print');
const printDesc = this.addUtf8Constant('(C)V');
const printNaT = this.addNameAndTypeConstant(printName, printDesc);
const printMethodRef = this.addMethodrefConstant(printStreamClass, printNaT);

Parece bastante coisa, e é. Cada referência de método ou campo na JVM exige essa cadeia: Utf8, depois Class, depois NameAndType, depois Methodref ou Fieldref. Mas depois que você monta uma vez, o padrão fica automático.

O construtor

Todo .class precisa de um construtor, mesmo que ele não faça nada. O construtor default em bytecode é:

aload_0             // carrega 'this' (local_0)
invokespecial #X    // chama Object.<init>()V
return              // retorna

Onde #X é o índice do Methodref pro construtor de Object no constant pool.

Em bytes:

// bytecode do construtor
const constructorCode = [
  0x2a,       // aload_0
  0xb7,       // invokespecial
  ...intTo2Bytes(objectInitMethodRef),
  0xb1        // return
];

O método main

O main é onde o bytecode do Brainfuck vai ficar. A declaração dele no ClassFile:

// access flags: ACC_PUBLIC | ACC_STATIC
this.writeU2(0x0009);
// name index: "main"
this.writeU2(mainNameIndex);
// descriptor index: "([Ljava/lang/String;)V"
this.writeU2(mainDescIndex);
// attributes count: 1 (o atributo Code)
this.writeU2(1);

O atributo Code contém o bytecode real:

// atributo Code
this.writeU2(codeAttrNameIndex);   // nome "Code" no constant pool
this.writeU4(codeAttrLength);      // tamanho total do atributo
this.writeU2(4);                   // max_stack
this.writeU2(3);                   // max_locals (args, cells, pointer)
this.writeU4(codeLength);          // tamanho do bytecode
this.writeBytes(jvmInstructions);  // o bytecode em si
this.writeU2(0);                   // exception table length
this.writeU2(stackMapEntries);     // attributes count (0 ou 1)
// se tiver StackMapTable, escreve aqui

O max_locals é 3 porque temos: slot 0 pro String[] args, slot 1 pro byte[] (memória do Brainfuck), e slot 2 pro int (ponteiro). Eu descobri isso na marra - quando coloquei 1, a JVM deu VerifyError: Local variable table overflow.

Gerando o arquivo

No final, a gente junta tudo e escreve:

const classBytes = generator.generateHelloWorldClass(
  className,
  ({ symbolicConstantPool }) => {
    return brainfuckIRToJVM(ir, {
      input: { /* refs do System.in */ },
      output: { /* refs do System.out */ }
    });
  }
);

fs.writeFileSync(`${className}.class`, new Uint8Array(classBytes));

O callback makeInstructions recebe o constant pool já montado e retorna o bytecode gerado. Essa separação permite que o gerador de ClassFile não saiba nada sobre Brainfuck - ele só sabe montar a estrutura do .class.

Inspecionando com javap

Depois de gerar o .class, você pode inspecionar ele com javap -v pra confirmar que a estrutura tá correta:

javap -v CompiledBrainfuck.class

Isso mostra o constant pool, os métodos, o bytecode decodificado, e os atributos. É a melhor ferramenta de debug que você vai ter nesse projeto. Quando algo der errado (e vai dar), roda o javap e compara com um .class gerado pelo javac.

Outra ferramenta que eu usei muito: xxd com offsets específicos. Quando o javap reclamava de algo, eu ia direto no byte:

xxd -s 270 -l 4 CompiledBrainfuck.class

Isso mostra 4 bytes a partir da posição 270. Útil pra conferir se um valor específico tá sendo escrito certo.

Recapitulando

Nesse post a gente viu:

  • O formato binário do ClassFile da JVM, byte por byte
  • Por que big endian importa e como escrever números de múltiplos bytes na ordem certa
  • Como o constant pool funciona (Utf8, Class, Methodref, NameAndType)
  • Os descritores de tipo da JVM (([Ljava/lang/String;)V)
  • Como montar um gerador de .class do zero em Node.js

Na parte 3, a gente vai gerar bytecode JVM a partir da IR do Brainfuck. E vai ter que lidar com a StackMapTable, que quase me fez desistir.

Commits relevantes: 234b285 (class generator creates base structure), 31dd14d (massive refactoring in the JVM bytecode reader), e 1231ffc (beat u jvm) - que foi quando eu finalmente entendi o formato.

Por hoje é só. Abraços.