Como criar uma máquina virtual (2022)
(jmeiners.com)- Para entender o funcionamento interno dos computadores e como linguagens de programação são executadas, foi implementada do zero uma VM em C com cerca de 250 linhas que executa programas em assembly sobre a arquitetura educacional LC-3
- O alvo da implementação é um pequeno modelo de computador com 65.536 posições de memória de 16 bits, 10 registradores, 16 opcodes, flags de condição, trap routines e registradores mapeados em memória
- O loop de execução funciona em uma estrutura fetch-decode-execute: lê a instrução apontada pelo
PC, incrementa-o, interpreta o opcode e executa instruções comoADD,LDI,BR,JMPeTRAP - O carregamento de programas lê o primeiro origin de 16 bits do arquivo objeto, coloca o programa na memória e faz byte swap do formato big-endian do LC-3 para adequá-lo ao formato little-endian usado pela maioria dos computadores modernos
- Entrada de teclado e saída no console são tratadas por trap routines e pelos registradores mapeados em memória
KBSR/KBDR, exigindo código diferente de bufferização de entrada do terminal para Unix/macOS e Windows
Objetivos e pressupostos do tutorial
- Acompanhar o processo de implementar diretamente uma máquina virtual LC-3 para executar programas em linguagem assembly
- O código final tem cerca de 250 linhas em C, com
lc3.cpara Unix elc3-win.cpara Windows - Os conhecimentos prévios necessários são leitura básica de C ou C++ e aritmética binária
- O código completo está no repositório do GitHub, e o tutorial em si está no formato de literate program, em que blocos de código são combinados para formar o código-fonte final
O que uma máquina virtual faz
- Uma VM é um programa que se comporta como uma CPU e alguns componentes de hardware
- Executa operações aritméticas
- Lê e escreve na memória
- Interage com dispositivos de I/O
- Entende sua própria linguagem de máquina e executa programas
- Dependendo do objetivo da VM, ela pode reproduzir fielmente o hardware real ou oferecer uma nova arquitetura virtual para facilitar o desenvolvimento de software
- A JVM é um exemplo representativo de VM que oferece uma plataforma padrão de execução; em dispositivos com uma JVM implementada, programas em Java, Kotlin e Clojure podem rodar sem modificações
- Execução isolada também é um uso importante de VMs
- Em garbage collection, a VM consegue observar a pilha e as referências de memória fora do programa em execução
- Smart contracts do Ethereum são executados dentro de uma VM que não permite acesso ao sistema de arquivos, à rede, ao disco etc.
Componentes da arquitetura LC-3
- O alvo da implementação é o LC-3, usado no ensino universitário de arquitetura de computadores e assembly
- A memória do LC-3 tem 65.536 posições, e cada posição armazena um valor de 16 bits
- A capacidade total de armazenamento é de 128 KB
- Na implementação em C, ela é representada por um array
uint16_t memory[MEMORY_MAX]
- Há um total de 10 registradores
R0~R7: 8 registradores de uso geralPC: endereço de memória da próxima instrução a executarCOND: flag de condição do resultado do cálculo anterior
- Todas as instruções do LC-3 têm 16 bits, e os 4 bits à esquerda são o opcode
- São definidos 16 opcodes
- Incluem
OP_BR,OP_ADD,OP_LD,OP_ST,OP_JSR,OP_AND,OP_LDR,OP_STR,OP_RTI,OP_NOT,OP_LDI,OP_STI,OP_JMP,OP_RES,OP_LEAeOP_TRAP
- As flags de condição indicam o sinal do resultado do cálculo anterior
FL_POS: positivoFL_ZRO: 0FL_NEG: negativo
Assembly e linguagem de máquina
- O que a VM LC-3 realmente executa não é o assembly legível por humanos, mas sim um array de instruções de máquina de 16 bits
- O assembler converte o assembly LC-3 escrito em texto em instruções binárias de 16 bits
- O exemplo
Hello Worldsegue este fluxo.ORIG x3000: especifica o endereço de memória em que o programa será carregadoLEA R0, HELLO_STR: carrega o endereço da string emR0PUTS: imprime a string apontada porR0HALT: interrompe o programa.STRINGZ "Hello World!": armazena os dados da string dentro do programa
.ORIGe.STRINGZnão são instruções da CPU, mas diretivas do assembler- Condições e repetições são implementadas com instruções de desvio próximas de
goto, comoBRn LOOP
Procedimento central do loop de execução
- A execução da VM repete o mesmo procedimento
- Lê a instrução no endereço do registrador
PC - Incrementa o
PC - Obtém o opcode a partir dos 4 bits superiores da instrução
- Executa o código de implementação correspondente ao opcode
- Lê novamente a próxima instrução
- Lê a instrução no endereço do registrador
- O endereço inicial padrão é
0x3000 - Algumas instruções alteram diretamente o
PC, fazendo o fluxo de execução saltar- Graças às instruções de desvio e salto, loops e execução condicional são possíveis mesmo em uma estrutura que simplesmente incrementa o
PC
- Graças às instruções de desvio e salto, loops e execução condicional são possíveis mesmo em uma estrutura que simplesmente incrementa o
- O loop de
mainchama o código de tratamento por opcode comswitch (op)- Trata
OP_ADD,OP_AND,OP_NOT,OP_BR,OP_JMP,OP_JSR,OP_LD,OP_LDI,OP_LDR,OP_LEA,OP_ST,OP_STI,OP_STReOP_TRAP OP_RESeOP_RTIsão opcodes não usados e podem ser tratados comabort()
- Trata
Como as instruções são implementadas
ADDsoma dois valores, armazena o resultado no registrador de destino e atualiza as flags de condiçãoADDtem dois modos- Modo registrador: lê o segundo operando de outro registrador
- Modo imediato: lê o segundo operando dos 5 bits inferiores da instrução,
imm5
- Valores mais curtos que 16 bits, como
imm5, precisam ser expandidos para 16 bits por sign extension- Números positivos são preenchidos com 0
- Números negativos são preenchidos com 1 para preservar o valor original
- Instruções que escrevem valores em registradores atualizam
R_CONDcomupdate_flags- Se o valor for 0,
FL_ZRO - Se o bit mais significativo for 1,
FL_NEG - Caso contrário,
FL_POS
- Se o valor for 0,
LDIé uma instrução de “load indirect”- Faz sign extension do
PCoffset9da instrução - Soma-o ao
PCatual para obter um endereço de memória - Usa o valor armazenado nessa posição novamente como endereço para ler o dado final
- Armazena o valor lido no registrador de destino e atualiza as flags de condição
- Faz sign extension do
Principais conjuntos de instruções
- Operações aritméticas e de bits
ADD: adiçãoAND: AND bit a bitNOT: NOT bit a bit
- Fluxo de controle
BR: move oPCcomparando as flags de condição com os bits de condição da instruçãoJMP: define oPCcomo o valor do registrador especificadoRET: embora seja uma palavra-chave separada na especificação, é um caso especial deJMPJSR,JSRR: salvam oPCatual emR7e saltam para a posição da sub-rotina
- Leitura de memória
LD: lê a partir de um endereço com offset relativo aoPCLDI: segue mais um nível de endereço indireto e lêLDR: lê a partir de um endereço calculado com base register e offsetLEA: armazena no registrador o próprio endereço efetivo
- Escrita de memória
ST: armazena em um endereço com offset relativo aoPCSTI: segue o endereço indireto e armazenaSTR: armazena em um endereço calculado com base register e offset
Trap routines e I/O
- O LC-3 oferece trap routines para tarefas comuns e acesso a dispositivos de I/O
- Uma trap routine pode ser vista como o sistema operacional ou a API do LC-3
- Os trap codes são definidos assim
TRAP_GETC = 0x20: entrada de caractere pelo teclado, sem eco no terminalTRAP_OUT = 0x21: saída de caractereTRAP_PUTS = 0x22: saída de word stringTRAP_IN = 0x23: entrada de caractere e eco no terminalTRAP_PUTSP = 0x24: saída de byte stringTRAP_HALT = 0x25: interrupção do programa
- No simulador oficial do LC-3, as trap routines são escritas em assembly, mas nesta VM elas são implementadas como funções C
PUTSimprime caracteres a partir do endereço armazenado emR0até encontrarx0000- Strings do LC-3 não armazenam um caractere por byte como strings em C, mas um caractere por posição de memória
- Como cada posição de memória tem 16 bits, a saída em C converte para
charantes de imprimir
- O trap
HALTimprime"HALT", altera a flag de execução para 0 e encerra o loop da VM
Carregamento da imagem do programa
- Ao converter um programa assembly LC-3 para linguagem de máquina, é gerado um arquivo contendo um array de instruções e dados
- Os primeiros 16 bits do arquivo objeto são o origin, que indica onde colocar o programa na memória
- O loader lê primeiro o origin e copia o restante dos dados para a memória a partir desse endereço
- Programas LC-3 usam o formato big-endian
- Como a maioria dos computadores modernos é little-endian, aplica-se
swap16a cadauint16_tcarregado - Em computadores big-endian, como Macs PPC antigos, o swap não deve ser feito
- Como a maioria dos computadores modernos é little-endian, aplica-se
read_imageabre o arquivo em modo binário, chamaread_image_filee depois fecha o arquivo
Registradores mapeados em memória
- Registradores especiais que não são acessados pela tabela comum de registradores são mapeados para endereços específicos de memória
- Há dois registradores mapeados em memória que precisam ser implementados no LC-3
MR_KBSR = 0xFE00: keyboard status registerMR_KBDR = 0xFE02: keyboard data register
KBSRindica se uma tecla foi pressionada, eKBDRarmazena qual tecla foi pressionadaGETCbloqueia a execução até que entre uma entrada, masKBSReKBDRfazem polling do estado do dispositivo, permitindo que o programa continue respondendo enquanto aguarda entrada- A leitura de memória não acessa diretamente o array; ela passa por
mem_read- Se o endereço for
MR_KBSR, verifica o estado do teclado comcheck_key() - Se houver uma tecla, seta o bit mais significativo de
KBSRe armazena o valor degetchar()emKBDR - Se não houver tecla, define
KBSRcomo 0
- Se o endereço for
Tratamento de terminal por plataforma
- Para tratar corretamente entrada de teclado e comportamento do terminal, é necessário configurar a bufferização de entrada de forma específica para cada plataforma
- A implementação para Linux/macOS/UNIX usa
termios,selectetc.- Desativa o modo canônico e o eco
- Usa
selectpara verificar se há entrada disponível
- A implementação para Windows usa
GetStdHandle,GetConsoleMode,SetConsoleMode,_kbhitetc.- Ajusta o eco e a entrada por linha
- Verifica entrada de teclado com
WaitForSingleObjecte_kbhit
- No início do programa, chama-se
disable_input_buffering(); ao encerrar, chama-serestore_input_buffering() - Ao receber
SIGINT, o programa restaura as configurações do terminal, imprime uma quebra de linha e termina
Execução e depuração da VM
- Exemplo de build da VM:
gcc lc3.c -o lc3-vm
- Para executar, passe como argumento um arquivo objeto LC-3 já montado
lc3-vm path/to/2048.obj
- Os arquivos objeto fornecidos como exemplo são
2048.objerogue.obj - O exemplo 2048 é controlado pelas teclas WASD
- Se o programa não funcionar corretamente, é provável que haja erro na implementação de alguma instrução
- Recomenda-se executar instrução por instrução no depurador da VM enquanto lê o código-fonte assembly do LC-3
- Se houver um ponto que não desvia para a instrução esperada, confira novamente a especificação e a implementação dessa instrução
Opcional: implementação baseada em genéricos de C++
- Também é abordada, como opção, uma técnica de implementação mais curta em C++
- Como várias instruções compartilham tarefas repetidas, como sign extension, offset relativo ao
PCe cálculo de endereço indireto, a execução de instruções pode ser vista como um pipeline de pequenas etapas de processamento - Usando templates de C++ e flags de bits, apenas as etapas necessárias para cada opcode são incluídas em tempo de compilação
- Essa abordagem reduz duplicação de código e se aproxima mais da forma de fiação de hardware real, em que cada etapa de processamento ocupa espaço físico no chip
- Bisqwit’s NES emulator é mencionado como origem da ideia
Recursos e contribuições
- atul-g contribuiu com um reference card que resume o funcionamento de todo o sistema
- Implementações em várias linguagens são organizadas pelo GitHub topic
lc3- Incluem C, C++, Go, Haskell, Java, JavaScript, Kotlin, Lua, OCaml, Python, Ruby, Rust, Swift, TypeScript, Zig etc.
- Para que sua implementação apareça na lista, basta adicionar o GitHub topic
lc3 - O suporte à plataforma Windows foi contribuído por inkydragon
- O projeto tem uma good first issue relacionada a testes de integração
1 comentários
Opiniões do Hacker News
Quando eu era adolescente, em uma aula introdutória de ciência da computação no community college, projetei um conjunto simples de instruções de CPU e criei por conta própria uma máquina virtual e um assembler para escrever e executar programas em assembly
Foi surpreendentemente fácil, e o computador passou a parecer muito menos misterioso
Acho que daria para aprender todas as camadas da computação desse jeito, desde o projeto de uma CPU real para FPGA até a criação de um sistema operacional simples e de programas rodando sobre ele
Se você tirar o desempenho e a segurança exigidos pela computação moderna e tiver como meta apenas “funcionar”, essa área é inesperadamente simples
Se não me falha a memória, no mínimo entram segmentação de memória, modo protegido e MMU
Era um computador/assembler simples escrito em BASIC em um PDP, e uma das tarefas era implementar multiplicação simples fazendo somas em um loop
Um amigo, em vez disso, alterou o programa para criar uma nova instrução MUL, e o professor não gostou nada
Quem tem curiosidade e vontade de aprender consegue assimilar facilmente essas camadas básicas, mas isso não vale para quem quer “ganhar dinheiro rápido e ficar empregável o mais depressa possível”
Livros recomendados:
Seria útil para todos se alguém que leu esses livros acrescentasse comentários
Um emulador de Nintendo, um hipervisor usando VT-x, um sistema operacional multitarefa tradicional, o interpretador de uma nova linguagem de scripting, um otimizador de consultas SQL, um matcher de regex, um monitor de segurança que executa código não confiável de jogadores em um servidor de jogo etc. parecem ter pouquíssimas considerações em comum, mas todos são máquinas virtuais
Até dentro do formato terminfo, que especifica sequências de escape de terminais de células de texto, há uma máquina virtual baseada em pilha
Indo mais fundo, o que faz um computador ser computador no sentido atual é a máquina virtual, e o artigo de Turing de 1936 sobre o Entscheidungsproblem também dependia do fato de máquinas virtuais poderem imitar umas às outras
Depois de assistir à série de CPU em protoboard do Ben Eater, tudo o que quero é projetar e emular uma CPU por conta própria
Gostaria de conseguir encontrar tempo para sentar e projetar isso
Acho que arquiteturas educacionais como a Brookshear Machine ou o Little Computer não se parecem em nada com arquiteturas reais e são mais do que inúteis: são prejudiciais
Já vi alunos que fizeram aulas usando esse tipo de coisa entenderem computadores de forma mais distorcida do que pessoas que não fizeram aula nenhuma
Para a maioria das pessoas que quer aprender um pouco sobre como o próprio computador funciona, uma aula de sistemas operacionais é melhor; e, se houver tempo para apenas um tutorial curto, recomendo “Writing my own bootloader”
https://dev.to/frosnerd/writing-my-own-boot-loader-3mld
Isso não quer dizer que tutoriais de “Write your own VM” sejam ruins, mas sim que, pela minha experiência, outros temas seriam mais úteis para a maioria das pessoas que fariam esse tutorial
Você poderia explicar melhor por que a LC-3 é ruim para estudar arquitetura de computadores?
Entendo que ela é completamente diferente de hardware real e simples demais, mas fico curioso se ela também é ruim do ponto de vista de escrever um emulador de CPU
Era uma máquina decimal do tipo que poderia ter sido criada nos anos 1960, mas que ninguém mais criaria depois dos anos 1970
Sistemas assim podem ensinar muitos fundamentos, mas as técnicas de https://en.wikipedia.org/wiki/Hacker%27s_Delight dependem principalmente de formas comuns de representação numérica, então ficam difíceis de aprender
Como não a conheço bem, dei uma olhada rápida na Wikipedia e, depois de ver a tirinha, esperava algo estranho, mas à primeira vista não achei tão chocante
Parece uma mistura de s/360, um pouco de x86 e um pouquinho de ARM ou de outra arquitetura da família RISC; há muitas partes omitidas e partes estranhas, mas o objetivo parece ser chegar rapidamente a uma implementação que funcione
Gostaria de saber o que faz você considerá-la “mais do que inútil, prejudicial” para fins educacionais
Em muitas aulas de ciência da computação na Índia, parece que ainda usam 8086/8088
Em especial, é possível fazer uma carga duplamente indireta por meio de uma palavra relativa ao PC no meio
Mesmo assim, a subtração precisa ser construída a partir da negação, e a negação precisa ser construída a partir de NOT e ADD ,,#-1
Considerando o espaço limitado de codificação de instruções, acho que NOT d,s = XOR d,s,#-1 teria sido um uso melhor
A rigor, isso não é uma máquina virtual, mas sim um emulador
Em sentido descritivo, o termo até pode se aplicar e, antes da era da virtualização de hardware, havia certa ambiguidade, mas hoje o uso esmagadoramente mais comum de “Virtual Machine” se refere a ambientes que usam recursos de virtualização de hardware, como VT-x
A JVM é amplamente distribuída, a Ethereum VM é chamada de EVM, https://www.linuxfoundation.org/hubfs/LF%20Research/The_Stat... também descreve repetidamente BPF e eBPF como “virtual machines”, e https://webassembly.org/ começa dizendo que “WebAssembly (abreviado como Wasm) é um formato de instruções binárias para uma máquina virtual baseada em pilha”
“Máquina virtual” continua sendo a forma mais comum de se referir a uma máquina virtual
Pessoalmente, gosto mais de expressões como “fictive machine”, “fictious machine”, “imaginary computer” e “fantastic automaton”, mas não acho que serão adotadas
Nem sempre dá para usar “emulador” no lugar de “máquina virtual”
O wasmtime talvez possa ser chamado de emulador, mas chamar o próprio WebAssembly de emulador não é preciso; WebAssembly é a máquina virtual que o wasmtime emula
Também é comum chamar emuladores de máquinas virtuais, e uma instância de emulador em execução também é uma máquina virtual em outro sentido
Chamar ambientes de virtualização de hardware de “máquinas virtuais” também é válido, e isso se sobrepõe em certa medida a esse último sentido
No seu ambiente atual, esse uso pode ser esmagadoramente comum, mas isso não vale necessariamente para outros contextos
No sentido mais puro, uma máquina virtual é apenas um computador inventado, sem implicar para que será usado nem como funciona
O texto também usa emulação de consoles clássicos como exemplo, mas, pela definição apresentada, fica claro que há muito mais máquinas virtuais possíveis
O ponto central é que uma máquina virtual é um conceito abstrato e há muitos tipos delas
Simuladores, emuladores, hipervisores etc. são todos máquinas virtuais, e também existem formas estranhas de máquina virtual que ainda nem receberam nome
Não quero ser rude; pelo contrário, quero ser respeitoso e deixar esse termo claro para quem está aprendendo
“Máquina virtual” é comumente usado para qualquer software que execute código de máquina ou bytecode, independentemente do motivo
Pode incluir virtualização, mas também é usado com frequência para runtimes de linguagem, como a JVM do Java ou a YARV (Yet Another Ruby VM) do Ruby
Na verdade, a área em que esse termo é menos ouvido é a emulação, em parte porque a maioria dos emuladores modernos tende a usar recompilação dinâmica do software-alvo em vez de emular o sistema inteiro
Dá para dizer que Java se enquadra em “uso esmagadoramente comum”