DEV Community

O que tem em um .class do Java?

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... ...()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. ()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 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. ()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

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.