Um pequeno emulador da CPU 8086
Emulador 8086 escrito em C
A primeira coisa que é preciso deixar claro é que esse não é um emulador completo, o objetivo é apenas interpretar um hello world. Claro, é possível pegar esse esqueleto e expandir até se tornar um emulador completo.
Eu estava entediado esperando meus amigos aparecerem na Campus Party de 2016, encontrei a mesa que estava separada para a galera de hackerspace e fiquei esperando mas como não tinha nenhuma palestra aquele horário nem nada que eu quisesse participar eu precisava de algo para matar o tempo.
Daí resolvi inventar um desafio para mim mesmo, criar um interpretador que conseguisse interpretar hello world escrito para assembly 16 bits do tempo do MS-DOS, nada demais, apenas um executável flat
, um antigo .com
.
A primeira coisa foi escrever o próprio hello world.
org 100h
section .text
mov ah, 40h
mov bx, 1
mov cx, 11
mov dx, msg
int 21h
mov al, 1
mov ah, 4Ch
int 21h
section .data
msg db "hello world"
Salvei no arquivo test.asm
e compilei com o NASM, meu compilador assembly favorito.
nasm -f bin test.asm -o test.com
Agora que já tinha o binário que ia interpretar criei um pequeno disassembler apenas pela diversão.
Como eu sabia exatamente as instruções do binário foquei apenas no que precisava, não me importei em adicionar todas as instruções, registradores e tudo mais porque afinal era apenas uma brincadeira para passar o tempo.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void decompile(const char *s, unsigned int i) {
unsigned char v;
unsigned char par_count;
for (unsigned int c = 0; c < i; c++) {
v = (unsigned char)(*(s + c));
printf("%04xh %02x ", c, v);
if (par_count > 0) {
par_count--;
printf("%02xh ", v);
} else {
switch (v) {
case 0xB0:
printf("mov al");
par_count = 1;
break;
case 0xB4:
printf("mov ah");
par_count = 1;
break;
case 0xB9:
printf("mov cx");
par_count = 2;
break;
case 0xBA:
printf("mov dx");
par_count = 2;
break;
case 0xBB:
printf("mov bx");
par_count = 2;
break;
case 0xCC:
printf("int 3");
par_count = 0;
break;
case 0xCD:
printf("int");
par_count = 1;
break;
default:
printf("%02xh", v);
if (v >= 32) {
printf(" \"%c\"", v);
}
break;
}
}
printf("\r\n");
}
}
unsigned int fsize(FILE *fd) {
fseek(fd, 0, SEEK_END);
unsigned int size = ftell(fd);
fseek(fd, 0, SEEK_SET);
return size;
}
int main(int argc, char *argv[]) {
char memory[1024];
FILE *fp;
if (argc == 1) {
printf("%s filename.com\r\n", argv[0]);
exit(1);
}
memset(memory, 0, sizeof(memory));
fp = fopen(argv[1], "rb");
if (fp == NULL) {
printf("Error opening file");
return (-1);
}
unsigned int size = fsize(fp);
fread(memory, size, 1, fp);
decompile(memory, size);
fclose(fp);
return 0;
}
Para compilar usei o gcc mas qualquer compilador ANSI C moderno vai funcionar:
gcc d86.c -o d86
Para executar e ver o disassembler em ação é só comandar:
./d86 test.com
O resultado vai ser o seguinte:
0000h b4 mov ah
0001h 40 40h
0002h bb mov bx
0003h 01 01h
0004h 00 00h
0005h b9 mov cx
0006h 0b 0bh
0007h 00 00h
0008h ba mov dx
0009h 14 14h
000ah 01 01h
000bh cd int
000ch 21 21h
000dh b0 mov al
000eh 01 01h
000fh b4 mov ah
0010h 4c 4ch
0011h cd int
0012h 21 21h
0013h 00 00h
0014h 68 68h "h"
0015h 65 65h "e"
0016h 6c 6ch "l"
0017h 6c 6ch "l"
0018h 6f 6fh "o"
0019h 20 20h " "
001ah 77 77h "w"
001bh 6f 6fh "o"
001ch 72 72h "r"
001dh 6c 6ch "l"
001eh 64 64h "d"
Pronto, com isso temos todas as posições de memória do test.com
, no momento essa brincadeira não tem muita utilidade pratica alem de confirmar que estamos lendo o arquivo corretamente e entendendo cada uma das suas instruções. Mas é mais fácil criar um disassembler e depois transformar ele em um emulador.
Vamos ver como o código do emulador se parece:
#include <curses.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int print_asm;
int use_curses;
int cursor_x;
int cursor_y;
WINDOW *mainwin;
void setCursorPos(int x, int y) {
cursor_x = x;
cursor_y = y;
wmove(mainwin, y, x);
refresh();
}
void setChar(const unsigned char c) {
mvwaddch(mainwin, cursor_y, cursor_x, c);
refresh();
}
void init_ncurses(void) {
if (!use_curses)
return;
if ((mainwin = initscr()) == NULL) {
printf("Error initialising ncurses.\n");
exit(1);
}
}
void end_ncurses(void) {
if (!use_curses)
return;
delwin(mainwin);
endwin();
refresh();
}
void run(const char *s) {
unsigned char v;
unsigned char par_count = 0;
unsigned char ah;
unsigned char al;
int16_t cx;
int16_t bx;
int16_t dx;
unsigned char lbyte;
unsigned char hbyte;
for (unsigned int c = 0; c < 1024; c++) {
v = (unsigned char)(*(s + c));
if (print_asm == 1)
printf("%04xh %02x ", c, v);
if (par_count > 0) {
par_count--;
printf("%02xh ", v);
} else {
switch (v) {
case 0xB0:
c++;
v = (unsigned char)(*(s + c));
al = v;
if (print_asm == 1)
printf("mov al, %4Xh", al);
break;
case 0xB4:
c++;
v = (unsigned char)(*(s + c));
ah = v;
if (print_asm == 1)
printf("mov ah, %4Xh", ah);
break;
case 0xB9:
c++;
lbyte = (unsigned char)(*(s + c));
c++;
hbyte = (unsigned char)(*(s + c));
cx = (int16_t)(hbyte << 8 | lbyte);
if (print_asm == 1)
printf("mov cx, %4Xh", cx);
break;
case 0xBA:
c++;
lbyte = (unsigned char)(*(s + c));
c++;
hbyte = (unsigned char)(*(s + c));
dx = (int16_t)(hbyte << 8 | lbyte);
if (print_asm == 1)
printf("mov dx, %4Xh", dx);
break;
case 0xBB:
c++;
lbyte = (unsigned char)(*(s + c));
c++;
hbyte = (unsigned char)(*(s + c));
bx = (int16_t)(hbyte << 8 | lbyte);
if (print_asm == 1)
printf("mov bx, %4Xh", bx);
break;
case 0xCC:
printf("int 3 debug...");
par_count = 2;
break;
case 0xCD:
c++;
v = (unsigned char)(*(s + c));
if (print_asm == 1)
printf("int %2Xh", v);
if (v == 0x20) { // return to "DOS" :D
exit(0);
}
if (v == 0x21) { // DOS
if (ah == 0x4C) { // exit program
printf("\r\n");
exit(al);
}
if (ah == 0x40) { // write to a file or device
/*
BX = file handle
CX = number of bytes to write
DS:DX -> data to write
*/
for (int i = 0; i < cx; i++) {
v = (unsigned char)(*(s + (dx - 0x100) + i));
if (use_curses) {
setChar(v);
cursor_x++;
} else {
printf("%c", v);
}
}
}
}
break;
default:
printf("%02xh", v);
if (v >= 32) {
printf(" \"%c\"", v);
}
break;
}
}
if (print_asm == 1)
printf("\r\n");
}
}
size_t fsize(FILE *fd) {
fseek(fd, 0, SEEK_END);
size_t size = (size_t)ftell(fd); // warning ftell return signed long
fseek(fd, 0, SEEK_SET);
return size;
}
int main(int argc, char *argv[]) {
char memory[1024];
FILE *fp;
int opt_flag;
char *filename = 0;
char *file = 0;
extern char *optarg;
extern int optind, optopt, opterr;
print_asm = 0;
use_curses = 0;
cursor_y = 0;
cursor_y = 0;
while ((opt_flag = getopt(argc, argv, "acf")) != -1) {
switch (opt_flag) {
case 'a':
print_asm = 1;
break;
case 'c':
use_curses = 1;
break;
case 'f':
filename = optarg;
printf("filename is %s\n", filename);
break;
case ':':
printf("-%c without filename\n", optopt);
break;
case '?':
printf("unknown option %c\n", optopt);
break;
default:
filename = optarg;
printf("filename is %s\n", filename);
break;
}
}
// printf("optind = %i\n",optind);
if (optind < argc) {
file = argv[optind];
// printf("filename is %s\n", file);
}
if (argc == 1) {
printf("%s filename.com\r\n", argv[0]);
exit(1);
}
memset(memory, 0, sizeof(memory));
fp = fopen(file, "rb");
if (fp == NULL) {
printf("Error opening file %s", file);
return (-1);
}
size_t size = fsize(fp);
fread(memory, size, 1, fp);
fclose(fp);
init_ncurses();
run(memory);
end_ncurses();
return 0;
}
Você vai notar que a estrutura geral do disassembler e do emulador são bem parecidas, o emulador tem umas coisinhas a mais e eu não lembro por que resolvi usar Ncurses, eu devia estar incrivelmente entediado.
Salvei o código fonte em um arquivo chamado r86.c
.
Para compilar usando o gcc
este é o comando:
gcc r86.c -o r86 -lncurses
Note que é necessário passar a lib ncurses
para o compilador.
Agora finalmente podemos executar o emulador e ver se ele funciona com o pequeno test.com
, para fazer isso basta executar o seguinte comando.
./r86 test.com
E o resultado como esperado vai ser
hello world
Como esperado o emulador funciona. Mas como ele funciona?
Quando o emulador inicia ele aloca 1kb de RAM, mais que suficiente para carregar o programa de teste. Obviamente se fosse algo mais serio precisaríamos de mais memória.
Em seguida ele le o arquivo que for passado pela linha de comando e carrega nessa memória e depois de fazer todas as inicializações e deixar o ambiente pronto ele executa a função run
passando como parâmetro nossa memória.
A função run por sua vez interpreta instrução por instrução da mesma forma que o processador faria ou seja lendo cada instrução da memória e executando. Claro um processador faz coisas mais complicadas que isso executando microcode e coisas muito mais avançadas.
Nós ao contrario disso estamos fazendo apenas o clássico dos emuladores que é percorrer um switch case
que interpreta as instruções que queremos.
É preciso notar também que esse emulador não é nada formal, por exemplo o correto seria simular a RAM do PC da era 16 bits, deixar as instruções do nosso switch case
gravar no seguimento de RAM destinado a memória de vídeo e dai outra rotina exibiria essa memória de vídeo.
Da forma como esta implementado nos simplesmente interpretamos as interrupções mas se o programa escrever direto na memória de vídeo, pratica que era muito comum para os programas da época simplesmente não vamos exibir nada.
Também não estamos interpretando nenhuma das portas nem nada, ou seja não existem periféricos de nenhum tipo para esse emulador.
Mas de forma geral como exercício e brincadeira para passar o tempo funcionou bem.
Algum tempo depois comecei a reescrever o emulador usando Golang mas projetos mais importantes acabaram tomando meu tempo livre e acabei deixando o projeto de lado, a única coisa que esta feita é o loader.
package main
import (
"bufio"
"fmt"
"io"
"os"
"github.com/crgimenes/goconfig"
)
type config struct {
FileName string `cfg:"name"`
}
var memory [640000]byte
func main() {
cfg := config{}
goconfig.PrefixEnv = "R86"
err := goconfig.Parse(&cfg)
if err != nil {
println(err.Error())
os.Exit(1)
}
if cfg.FileName == "" {
if len(os.Args) > 1 {
cfg.FileName = os.Args[len(os.Args)-1]
}
}
f, err := os.Open(cfg.FileName)
if err != nil {
println(err.Error())
os.Exit(1)
}
defer func() {
err = f.Close()
if err != nil {
println(err.Error())
os.Exit(1)
}
}()
buff := bufio.NewReader(f)
var c byte
var count int
var col int
count = 0x100
for {
c, err = buff.ReadByte()
if err != nil {
if err == io.EOF {
break
}
println(err.Error())
os.Exit(1)
}
memory[count] = c
if col >= 16 || col == 0 {
col = 0
fmt.Printf("\n%06X ", count)
}
fmt.Printf("%02X ", c)
col++
count++
}
fmt.Printf("\n\n%v bytes loaded\n", count)
}
A ideia era escrever um emulador mais sério já com uma RAM de 640k e carregando o executável no endereço 100h. Mas por enquanto só esta carregando o binário e então exibindo o que foi carregado na RAM.
Uma coisa que seria interessante fazer é um emulador que não dependa de nenhum ambiente gráfico, no lugar simplesmente usa a saída padrão e posiciona a saída na tela usando apenas códigos ANSI. Isso seria útil para rodar velos algum BBS door clássico como Trade Wars ou LORD.
Recentemente no Grupo de Estudos de Golang estávamos combinando de fazer a nossa própria versão de Core Wars. Ainda estamos discutindo se emulamos alguma CPU académica como LC-3 que seria bem interessante do ponto de vista académico, se criamos algo novo ou se vamos para o 80x86.