7. A VM Hack (especificação)¶
Este capítulo é a especificação de referência da VM Hack — a representação intermediária baseada em pilha para a qual o compilador Jack (capítulos 9 e 10) vai gerar código, e que o VM Translator (capítulos 12 e 13) vai traduzir para Assembly Hack. Não há código de compilador aqui; é a "planta" que orienta o restante da Parte 2 e o VM Translator.
Modelo de memória¶
Cada célula de memória da máquina Hack armazena 16 bits, usados para representar de forma uniforme três coisas: um inteiro em complemento de dois (-32768 a 32767), um booleano (0 para falso, -1 — todos os bits em 1 — para verdadeiro), ou um endereço de memória.
A memória é organizada em oito segmentos, cada um endereçado por índice a partir de 0:
| Segmento | Propósito | Usado pelo compilador Jack para |
|---|---|---|
local |
Variáveis locais da subroutine em execução | variáveis declaradas com var |
argument |
Argumentos recebidos pela subroutine | parâmetros da subroutine |
static |
Variáveis de classe (compartilhadas por todas as instâncias) | variáveis declaradas com static |
this |
Campos do objeto atual | variáveis declaradas com field |
that |
Elemento de array corrente | acesso arr[i] |
constant |
Pseudo-segmento somente leitura para constantes literais | literais inteiros |
pointer |
Duas células: pointer 0 guarda o endereço-base de this, pointer 1 o de that |
ancorar this no objeto e that no elemento de array |
temp |
Oito células de uso livre, compartilhadas por toda a VM | valores descartados e auxiliares |
this e that são segmentos "móveis": não têm endereço fixo, e sim o endereço que estiver gravado em pointer 0 e pointer 1. Gravar um endereço a em pointer 0 faz this i passar a significar RAM[a + i]; o mesmo vale para pointer 1 e that i:
push constant 8000
pop pointer 0 // this passa a começar em RAM[8000]
push constant 17
pop this 1 // RAM[8001] = 17
É esse mecanismo de endereçamento indireto que o compilador vai usar para acessar campos de objetos (via this) e elementos de arrays (via that).
Além dos segmentos, duas áreas adicionais completam o modelo: a pilha (stack), usada para armazenar valores temporários durante a avaliação de expressões e para implementar o protocolo de chamada de função (capítulo 13); e o heap, a região de RAM dedicada ao armazenamento de objetos e arrays, gerenciada dinamicamente pelas funções Memory.alloc/Memory.deAlloc do sistema operacional Jack.
Comandos de acesso à memória¶
Apenas dois comandos movem dados entre a pilha e os segmentos:
push segmento índice // empilha o valor de segmento[índice]
pop segmento índice // desempilha o topo e armazena em segmento[índice]
segmento é um dos oito acima (exceto que não existe pop constant, já que constantes não são graváveis); índice é um inteiro não negativo.
push local 2 // empilha local[2]
push constant 10 // empilha o literal 10
pop argument 0 // desempilha o topo para argument[0]
Comandos aritméticos e lógicos¶
Nove comandos operam sobre a pilha — sete binários (consomem dois valores do topo, empilham um resultado) e dois unários (consomem um, empilham um):
| Comando | Aridade | Semântica |
|---|---|---|
add |
binário | soma |
sub |
binário | subtração |
neg |
unário | negação aritmética |
eq |
binário | -1 se iguais, 0 caso contrário |
gt |
binário | -1 se o primeiro é maior |
lt |
binário | -1 se o primeiro é menor |
and |
binário | E bit a bit |
or |
binário | OU bit a bit |
not |
unário | complemento bit a bit |
Não existem comandos de multiplicação ou divisão na VM — como veremos no capítulo 9, o compilador Jack traduz os operadores * e / em chamadas às funções Math.multiply/Math.divide da biblioteca padrão, não em comandos VM nativos.
Controle de fluxo¶
Linguagens de alto nível têm if, while, for — a VM reduz tudo isso a três primitivas:
label rótulo // declara uma posição nomeada no código
goto rótulo // salto incondicional
if-goto rótulo // desempilha o topo; se for verdadeiro (≠ 0), salta
Rótulos têm escopo de função — dois label LOOP em funções diferentes não colidem (o capítulo 13 detalha a qualificação funcao$rótulo usada pelo VM Translator para garantir isso na tradução final para Assembly). É com apenas essas três primitivas que o capítulo 9 vai mapear if/else e while de Jack.
Funções¶
Três comandos governam todo o protocolo de chamada:
function nome nLocais // declara uma função, com N variáveis locais (inicializadas em 0)
call nome nArgs // chama uma função, passando os N argumentos já empilhados
return // desempilha um valor e devolve o controle ao chamador
Uma peça importante do design: mesmo subroutines que não retornam valor útil precisam empilhar algo antes do return — por convenção, push constant 0. É simetria pura: todo call espera encontrar um valor no topo da pilha ao retornar, então toda função — void ou não — precisa deixar exatamente um valor lá.
function Main.dobro 0
push constant 2
push argument 0
call Math.multiply 2
return
function Main.main 1
push constant 5
call Main.dobro 1
pop local 0
push constant 0
return
O nome de uma função é sempre Classe.nomeDaSubroutine — é assim que construtores, métodos e funções (na terminologia Jack) se tornam, todos igualmente, "funções" no nível da VM. O mecanismo completo de call/return (salvar e restaurar o estado do chamador, o stack frame) é o assunto do capítulo 13; por ora, basta saber a interface: argumentos entram empilhados, o resultado sai empilhado.
Um programa completo¶
Todo programa Jack começa executando Main.main — mais precisamente, a máquina começa por Sys.init, uma função do sistema operacional Jack que inicializa as demais classes do SO e então chama Main.main (o capítulo 13 mostra esse bootstrap). Um exemplo simples — somar de 1 a n e imprimir o resultado — ilustra push/pop, aritmética, controle de fluxo e chamada de função juntos:
function Soma.soma 2 // variáveis locais: sum (0), i (1)
push constant 0
pop local 0 // sum = 0
push constant 1
pop local 1 // i = 1
label LOOP
push local 1
push argument 0
gt // i > n ?
if-goto FIM
push local 0
push local 1
add
pop local 0 // sum = sum + i
push local 1
push constant 1
add
pop local 1 // i = i + 1
goto LOOP
label FIM
push local 0
return
function Main.main 1
push constant 10
pop local 0 // n = 10
push local 0
call Soma.soma 1
call Output.printInt 1
pop temp 0 // descarta o valor devolvido por Output.printInt
push constant 0
return
O pop temp 0 logo depois de call Output.printInt 1 é o outro lado da convenção vista acima. Como toda função devolve um valor — inclusive as void, que devolvem 0 —, quem chama uma função apenas pelo efeito colateral precisa descartar esse valor, para que a pilha não acumule lixo.
O mapeamento padrão de Jack sobre a VM¶
A VM não sabe nada de classes, objetos ou métodos. Para que programas Jack compilados separadamente (e o sistema operacional Jack, que é ele próprio um conjunto de arquivos .vm) funcionem juntos, todo compilador Jack → VM segue as mesmas convenções — o mapeamento padrão. Ele é parte da especificação, não da implementação: os capítulos 8 a 10 apenas o colocam em prática.
Arquivos e subroutines. Cada Xxx.jack é compilado para um Xxx.vm separado. Cada subroutine yyy da classe Xxx — seja function, method ou constructor — vira uma função VM chamada Xxx.yyy. Uma function ou um constructor com k parâmetros vira uma função VM de k argumentos; um method com k parâmetros vira uma função VM de k + 1 argumentos, porque argument 0 é sempre uma referência ao objeto sobre o qual o método opera.
Variáveis.
| Variável Jack | Segmento VM |
|---|---|
var (local da subroutine) |
local |
| parâmetro da subroutine | argument |
static |
static do .vm da classe |
field do objeto corrente |
this, depois de apontar pointer 0 para o objeto |
elemento arr[i] |
that 0, depois de apontar pointer 1 para arr + i |
Subroutines.
- Um método começa ancorando
thisno objeto recebido:push argument 0,pop pointer 0. - Um construtor começa alocando o objeto no heap com
Memory.alloc(n), em que n é o número de campos da classe, ancorathisno endereço devolvido (pop pointer 0) e termina devolvendo esse endereço (push pointer 0,return). - Funções e métodos
voiddevolvemconstant 0.
Chamadas. Quem chama empilha os argumentos e executa call. Se o alvo é um método, o primeiro valor empilhado é a referência ao objeto. Depois de um do, o valor devolvido é descartado com pop temp 0.
Constantes. null e false são 0; true é -1, obtido com push constant 1 seguido de neg (ou, equivalentemente, push constant 0 seguido de not).
Serviços do sistema operacional. O sistema operacional Jack é formado por oito classes já compiladas — Math.vm, Memory.vm, Screen.vm, Output.vm, Keyboard.vm, String.vm, Array.vm e Sys.vm —, que precisam estar no mesmo diretório dos .vm gerados pelo compilador (o VM Emulator do Nand2Tetris já traz uma implementação embutida delas, então nesse caso não é preciso copiá-las). O compilador recorre a elas implicitamente:
| Construção Jack | Serviço usado |
|---|---|
x * y, x / y |
Math.multiply, Math.divide |
constante "abc" |
String.new(3) seguido de String.appendChar para cada caractere |
Classe.new(...) (dentro do construtor) |
Memory.alloc(n) |
O que vem a seguir¶
Com a especificação da VM Hack e o mapeamento padrão em mãos, sabemos exatamente onde cada variável Jack precisa morar. O próximo capítulo constrói a estrutura que guarda essa informação durante a compilação — a tabela de símbolos —, e os capítulos 9 e 10 evoluem o CompilationEngine da Parte 1 para efetivamente gerar este código, expressão por expressão, comando por comando — fechando o compilador Jack → VM (Project 11) por completo. Os capítulos 12 e 13, na Parte 3, tratam do outro lado: como traduzir esse código VM para Assembly Hack.
Uma versão tabular desta especificação, para consulta rápida durante esses capítulos, está no Apêndice B.