11. VM Translator — Parte 1: aritmética e memória¶
Com o compilador Jack → VM completo (capítulos 9 e 10), fechamos a metade "de cima" da nossa cadeia de tradução. Este capítulo começa a outra metade: o VM Translator, que traduz código VM (a IR especificada no capítulo 8) para Assembly Hack — a linguagem que o capítulo 14 vai detalhar e que o Assembler (capítulo 15) finalmente converte em código de máquina.
A partir deste capítulo, o livro muda de estilo: em vez de código construído junto com você, capítulo a capítulo (como fizemos com o JackTokenizer e o CompilationEngine), o VM Translator e o Assembler são apresentados como teoria + especificação + exercício guiado — você implementa como atividade prática, comparando sua saída com os testes oficiais do Nand2Tetris. A razão é de escopo: VM Translator e Assembler não são "o compilador Jack" propriamente — são a infraestrutura de back-end que o sustenta.
Por que o VM Translator é um tradutor, não um compilador¶
Note a diferença de natureza: o CompilationEngine compila uma linguagem de alto nível (Jack) para uma representação de mais baixo nível (VM) — uma tradução "para baixo" na hierarquia de abstração. O VM Translator, por sua vez, traduz uma linguagem-fonte (código VM) para outra linguagem de baixo nível (Assembly Hack) — ambas já próximas da máquina. É uma arquitetura em três estágios:
Código Jack (alto nível)
↓ CompilationEngine (capítulos 9-10)
Código VM (intermediário)
↓ VM Translator (capítulos 11-12)
Assembly Hack (baixo nível)
↓ Assembler (capítulo 15)
Código de máquina Hack
Modelo de memória: Hack¶
A máquina Hack tem uma ROM de instruções e uma RAM de 32K palavras de 16 bits. Os primeiros endereços da RAM têm papéis pré-definidos, que sustentam todo o mapeamento de segmentos VM → memória real:
| Endereço RAM | Registrador | Papel |
|---|---|---|
RAM[0] |
SP |
Ponteiro de topo da pilha |
RAM[1] |
LCL |
Endereço-base do segmento local |
RAM[2] |
ARG |
Endereço-base do segmento argument |
RAM[3] |
THIS |
Endereço-base do segmento this |
RAM[4] |
THAT |
Endereço-base do segmento that |
RAM[5..12] |
— | Segmento temp (8 células) |
RAM[13..15] |
— | Uso interno do VM Translator (registradores de trabalho) |
RAM[16..255] |
— | Variáveis static |
RAM[256..2047] |
— | Pilha |
RAM[2048..16383] |
— | Heap (objetos e arrays) |
RAM[16384..24575] |
— | Mapa de memória da tela |
O princípio geral: os quatro segmentos "dinâmicos" (local, argument, this, that) têm seu endereço-base guardado em um registrador (LCL, ARG, THIS, THAT) que muda a cada chamada de função (capítulo 12) — acessar local i significa sempre RAM[LCL + i]. Já temp e static têm endereço fixo, calculável em tempo de tradução, sem indireção nenhuma.
Traduzindo push/pop¶
A tradução de push/pop se divide em dois casos, com custos bem diferentes.
Segmentos dinâmicos (local, argument, this, that) exigem calcular um endereço antes de acessar — carregar o registrador-base, somar o índice, então desreferenciar:
// push local i
@LCL
D=M // D = valor de LCL (o endereço-base)
@i
A=D+A // A = LCL + i
D=M // D = RAM[LCL + i]
@SP
A=M
M=D // RAM[SP] = D
@SP
M=M+1 // SP++
pop para um segmento dinâmico é mais delicado: é preciso calcular o endereço-destino e desempilhar um valor, mas só temos os registradores A/D/M para trabalhar — nenhum dos dois cálculos pode "esperar" sem ser guardado em algum lugar. A solução clássica é um registrador de uso geral (R13, uma das células reservadas da tabela acima) como área de rascunho:
// pop local i
@LCL
D=M
@i
D=D+A // D = LCL + i (o endereço-destino)
@R13
M=D // R13 = endereço-destino (guardado)
@SP
AM=M-1 // SP--; A = SP (nova posição do topo)
D=M // D = valor desempilhado
@R13
A=M // A = endereço-destino (recuperado)
M=D // RAM[endereço-destino] = valor
Segmentos com endereço fixo (constant, temp, static) dispensam esse cálculo intermediário. constant nem acessa memória — o valor já é o índice:
// push constant i
@i
D=A // D = i (não M — não há memória a ler; o valor é o próprio literal)
@SP
A=M
M=D
@SP
M=M+1
temp i é sempre RAM[5+i], e static i é mapeado para um rótulo simbólico único por arquivo .vm (tipicamente NomeDoArquivo.i) — o Assembler (capítulo 15) se encarrega de atribuir um endereço real a esse rótulo, exatamente como faz com qualquer variável.
Traduzindo comandos aritméticos e lógicos¶
Um comando binário (add, sub, and, or) desempilha dois valores, calcula, e empilha o resultado — mas a implementação evita uma escrita extra fazendo a operação "in-place", sobrescrevendo diretamente a posição onde o resultado deve ficar:
// add
@SP
AM=M-1 // SP--; A = posição do segundo operando
D=M // D = segundo operando
A=A-1 // A = posição do primeiro operando (SP já foi decrementado uma vez)
M=D+M // RAM[primeiro] = primeiro + segundo (resultado fica exatamente onde deve)
Repare que nenhum novo M=M+1 é necessário: a pilha já encolheu de dois elementos para um só (um AM=M-1 implícito), e o resultado ocupa a posição que sobrou — sem incrementar SP de volta.
Comandos unários (neg, not) são ainda mais diretos, porque não há segundo operando a desempilhar:
Comparações (eq, lt, gt) são o caso mais elaborado, porque a VM não tem um comando de "comparar e produzir booleano" nativo em Assembly — é preciso simular com um salto condicional:
// eq
@SP
AM=M-1
D=M
A=A-1
D=M-D // D = primeiro - segundo (zero se iguais)
@TRUE_EQ_0
D;JEQ // se D == 0, salta para o ramo "verdadeiro"
@SP
A=M-1
M=0 // falso: 0
@END_EQ_0
0;JMP
(TRUE_EQ_0)
@SP
A=M-1
M=-1 // verdadeiro: -1 (todos os bits em 1)
(END_EQ_0)
lt e gt seguem exatamente o mesmo esqueleto, trocando apenas JEQ por JLT/JGT. E, como no capítulo 9, os rótulos (TRUE_EQ_0, END_EQ_0) precisam de um contador único por comando de comparação traduzido, para não colidir entre múltiplas comparações no mesmo arquivo.
Uma tabela de custos¶
Vale a pena registrar, em número de instruções Assembly geradas, o custo relativo de cada comando VM — é uma boa intuição para entender por que um código VM aparentemente pequeno vira um Assembly bem maior:
| Comando VM | Instruções Assembly (aprox.) |
|---|---|
push constant |
6 |
push/pop segmento dinâmico |
10–12 |
push/pop temp/static |
5–6 |
add/sub/and/or |
5 |
neg/not |
3 |
eq/lt/gt |
~16 |
label |
0 (é só um símbolo) |
goto |
2 |
if-goto |
4 |
Exercício guiado¶
Sua atividade prática nesta parte do curso é implementar um VMTranslator que recebe um arquivo .vm (ou uma pasta com vários) e produz o .asm correspondente, cobrindo:
- Todos os comandos aritméticos/lógicos (
add,sub,neg,eq,gt,lt,and,or,not). push/poppara todos os oito segmentos.- Rótulos únicos para cada comparação traduzida (um contador incremental é suficiente).
Os testes oficiais do Nand2Tetris para esta parte (pasta projects/07) incluem SimpleAdd, StackTest (aritmética e lógica combinadas), BasicTest, PointerTest e StaticTest (os quatro segmentos e os dois casos especiais pointer/static) — rode cada .asm gerado no CPU Emulator e compare o estado final da RAM com o script .tst/.cmp de referência.
O que vem a seguir¶
Esta parte cobre apenas metade do VM Translator — falta o controle de fluxo em escopo de função (rótulos qualificados) e, principalmente, o protocolo completo de call/return com stack frames, que é o que torna possível compilar chamadas de função recursivas. Essa é exatamente a fronteira que separa esta unidade da próxima: o capítulo 12, que abre a Unidade 3, completa o VM Translator (Project 8) antes de seguirmos para geração de código alvo e o Assembler.