Compilando Brainfuck pra JVM, parte 2: dissecando o formato .class
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: 00034- 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
.classdo 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.