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.
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.
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.
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.
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.
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
0x0a
= 10 = CONSTANT_Methodref0x0002
= 20x0003
= 3Isso é uma referência ao método Object.<init>()V
- o construtor da classe pai.
Entry 2:
07 00 04
0x07
= 7 = CONSTANT_Class0x0004
= 4 (aponta pra um Utf8 com o nome da classe)Entry 4:
01 0010 6a617661 2f6c616e 672f4f62 6a656374
0x01
= 1 = CONSTANT_Utf80x0010
= 16 bytesjava/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.
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.
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.
Agora que a gente entende a estrutura, vamos construir um gerador em Node.js. A ideia é montar o .class
byte por byte num buffer.
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.
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.
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.
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 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
.
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
.
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.
Nesse post a gente viu:
([Ljava/lang/String;)V
).class
do zero em Node.jsNa 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)1231ffc
(beat u jvm)Por hoje é só. Abraços.