Ir para o conteúdo

15. Construindo o Assembler

Chegamos à última peça da cadeia de tradução: o Assembler (Project 6), que converte um arquivo .asm escrito na linguagem do capítulo 14 em um arquivo .hack — a sequência de instruções binárias de 16 bits que a CPU Hack executa diretamente. Como o VM Translator (capítulos 11-12), este capítulo é teoria + especificação + arquitetura — a implementação completa é sua atividade prática, comparando a saída com os testes oficiais do Nand2Tetris.

O problema central: referência futura

Se o Assembler traduzisse o arquivo .asm em uma única leitura, de cima para baixo, ele bateria de frente com um problema irresolúvel:

@END        // Qual é o endereço de END? Ainda não sabemos!
0;JMP
... (dezenas de instruções) ...
(END)       // END só é definido AQUI

Ao processar @END, o montador ainda não leu a declaração (END) — ela vem depois, no arquivo. Não é possível gerar o binário de @END sem saber a que endereço ROM esse símbolo se refere, e não saberemos isso até ler o programa inteiro. Esse é o problema da referência futura (forward reference), e ele não tem solução em uma única passagem.

A solução: duas passagens

A saída elegante é dividir a montagem em duas fases, cada uma com um objetivo só:

Primeira passagem — construir a tabela de símbolos (só rótulos). Percorra o arquivo do início ao fim, mantendo um contador de endereço ROM. Toda vez que encontrar um rótulo ((Xxx)), registre Xxx → contador_atual na tabela — e não incremente o contador (rótulos são pseudo-comandos, não ocupam espaço em ROM). Toda vez que encontrar uma instrução real (A ou C), apenas incremente o contador. Ao final desta passagem, todo rótulo do programa tem um endereço ROM conhecido — não importa se ele foi declarado antes ou depois de ser usado.

romAddr = 0
para cada linha do arquivo:
    se é rótulo (Xxx):
        tabela[Xxx] = romAddr        // registra; NÃO incrementa
    senão (é A-instruction ou C-instruction):
        romAddr = romAddr + 1

Segunda passagem — gerar o binário. Percorra o arquivo de novo, agora traduzindo cada instrução real. Rótulos são ignorados (já cumpriram seu papel na primeira passagem — não geram código). Uma A-instruction com um número literal (@10) converte-se diretamente; uma A-instruction com um símbolo consulta a tabela — se já estiver lá (um rótulo, ou uma variável já vista antes), usa o endereço registrado; caso contrário, é a primeira aparição de uma variável, e o montador aloca para ela o próximo endereço de RAM disponível, começando em 16. Uma C-instruction é decomposta em comp/dest/jump e cada parte é traduzida pelas tabelas do capítulo 14.

ramAddr = 16     // variáveis começam em RAM[16]
para cada linha do arquivo:
    se é rótulo: ignora (não gera código)
    se é A-instruction (@símbolo):
        se símbolo é numérico: endereco = número
        senão se símbolo está na tabela: endereco = tabela[símbolo]
        senão:                                     // nova variável
            tabela[símbolo] = ramAddr
            endereco = ramAddr
            ramAddr = ramAddr + 1
        emite 16 bits: 0 + endereco em binário (15 bits)
    se é C-instruction (dest=comp;jump):
        emite 16 bits: 111 + comp(7 bits) + dest(3 bits) + jump(3 bits)

Um exemplo completo, passo a passo

Considere de novo o programa de somatória do capítulo 14:

@R0
D=M
@i
M=D
@sum
M=0
(LOOP)
@i
D=M
@END
D;JEQ
@sum
D=M
@i
D=D+M
@sum
M=D
@i
M=M-1
@LOOP
0;JMP
(END)
@sum
D=M
@R1
M=D

Primeira passagem — só rótulos importam:

Linha Comando Tipo romAddr antes Ação
@R0 A 0 → 1
D=M C 1 → 2
@i A 2 → 3
M=D C 3 → 4
@sum A 4 → 5
M=0 C 5 → 6
(LOOP) L 6 registra LOOP → 6, não incrementa
... ... ... ... segue incrementando
(END) L 20 registra END → 20, não incrementa
... ... ... ...

Tabela ao final da primeira passagem: { LOOP: 6, END: 20 } (mais os pré-definidos: SP: 0, LCL: 1, ..., R0: 0, ..., R15: 15, SCREEN: 16384, KBD: 24576).

Segunda passagem — agora sim, traduzindo:

Instrução Resolução Binário
@R0 pré-definido, R0 = 0 0000000000000000
D=M comp=M, dest=D, jump=— 1111110000010000
@i símbolo novo → aloca RAM[16]; tabela agora tem i: 16 0000000000010000
M=D comp=D, dest=M 1110001100001000
@sum símbolo novo → aloca RAM[17] 0000000000010001
(LOOP) rótulo → ignorado, não gera linha
@i já na tabela (16) 0000000000010000
@END já na tabela, da 1ª passagem (20) 0000000000010100
... ... ...

Repare no ponto crucial: @END, quando alcançado na segunda passagem, já resolve imediatamente para 20 — porque a primeira passagem já tinha visitado (END) antes de a segunda passagem sequer começar. É essa separação de responsabilidades — uma passagem só para mapear rótulos, sem gerar nada; outra só para gerar código, sem se preocupar com posições futuras — que resolve o problema da referência futura sem qualquer backtracking ou reprocessamento.

Arquitetura modular

A implementação se organiza naturalmente em quatro módulos com responsabilidades bem separadas — a mesma disciplina de "uma classe, uma responsabilidade" que seguimos desde o Scanner/Parser da Unidade 1:

Módulo Responsabilidade
Parser Lê o .asm linha a linha, ignora espaços/comentários, classifica cada comando (A_COMMAND, C_COMMAND, L_COMMAND) e extrai suas partes (symbol(), dest(), comp(), jump())
SymbolTable Um mapa símbolo → endereço, pré-carregado com os símbolos pré-definidos do capítulo 14 (addEntry, contains, getAddress)
Code Traduz cada mnemônico (dest, comp, jump) para sua codificação binária, segundo as tabelas do capítulo 14
Main Orquestra as duas passagens, usando os três módulos acima

O Parser é deliberadamente burro — ele não sabe nada sobre símbolos ou endereços, só sintaxe. O SymbolTable é só um dicionário. O Code é uma coleção de tabelas de tradução (praticamente uma cópia executável das tabelas do capítulo 14). Toda a "inteligência" das duas passagens — quando registrar um rótulo, quando alocar uma variável — vive no Main, que é o único módulo que orquestra os outros três.

// esboço do módulo Main — primeira passagem
int romAddr = 0;
while (parser.hasMoreCommands()) {
    parser.advance();
    if (parser.commandType() == L_COMMAND) {
        symbolTable.addEntry(parser.symbol(), romAddr);
    } else {
        romAddr++;
    }
}

// segunda passagem
int nextRam = 16;
while (parser.hasMoreCommands()) {
    parser.advance();
    switch (parser.commandType()) {
        case L_COMMAND -> { /* ignora */ }
        case A_COMMAND -> {
            String sym = parser.symbol();
            int address;
            if (isNumeric(sym)) {
                address = Integer.parseInt(sym);
            } else if (symbolTable.contains(sym)) {
                address = symbolTable.getAddress(sym);
            } else {
                address = nextRam++;
                symbolTable.addEntry(sym, address);
            }
            output.write(toBinary16(address));
        }
        case C_COMMAND -> {
            String bits = "111"
                + code.comp(parser.comp())
                + code.dest(parser.dest())
                + code.jump(parser.jump());
            output.write(bits);
        }
    }
}

Validação

O Nand2Tetris fornece, para cada programa .asm de teste (Add.asm, Max.asm, Rect.asm, Pong.asm), o .hack esperado — comparação exata de texto, de novo, é o critério de correção. Vale testar em ordem crescente de complexidade: primeiro programas sem símbolo nenhum, depois com rótulos, depois com variáveis, depois combinando os dois — exatamente a mesma disciplina incremental que usamos desde o capítulo 2.

Fechando o livro

Com o Assembler completo, a cadeia de tradução está fechada de ponta a ponta:

Jack (Unidade 1: léxico + sintático)
    ↓ Compilador Jack → VM (Unidade 2)
Código VM
    ↓ VM Translator (capítulos 11-12)
Assembly Hack
    ↓ Assembler (este capítulo)
Código de máquina Hack
    ↓ CPU Hack
Execução

Cada elo dessa corrente — tokenizador, parser, tabela de símbolos, gerador de código VM, tradutor de VM para Assembly, montador — foi construído (ou especificado, nos capítulos de teoria + atividade) neste livro. É o ciclo completo que abrimos no capítulo 0: do texto-fonte ao hardware, sem nenhuma "caixa-preta" no meio do caminho.