Emulador
# Introducción
En esta unidad didáctica vamos a introducir un concepto muy utilizado en algunos ambientes informáticos como es el de la emulación. Normalmente un sistema tiene una arquitectura para la que se diseñan los programas destinados a ejecutarse en él. Pero puede ocurrir que tengamos programas de sistemas con arquitectura no compatible, que queramos aprovechar. Esta situación es muy habitual con máquinas obsoletas o descatalogadas que ya no están a la venta, como por ejemplo los antiguos ordenadores de 8 bit que fueron la base de la informática de finales de los 70 y principios de los 80. Se da la circunstancia además de que en esas situaciones, la máquina obsoleta de la que queremos ejecutar programas (huesped), tiene unas capacidades de hardware muchísimo más reducidas que la máquina moderna sobre la que queremos hacerlos funcionar (hospedador). Para resolver esta situación existe la Emulación. Emular un sistema en otro supone construir una capa de abstracción software en el sistema hospedador, del sistema que queremos emular. Esto facilita la cuestión, ya que al emular sin duda vamos a perder rendimiento respecto de lo que sería ejecutar por hardware el software en su sistema original. Incluso un microcontrolador de características limitadas como Raspberry Pi Pico, tiene potencia suficiente para emular sobradamente procesadores de 8 bit por ejemplo.
Construir un emulador enseña mucho sobre lo que en realidad es un sistema informático, una máquina de estados más o menos compleja. Si logramos identificar todos esos estados y organizar la forma en la que las distintas instrucciones ejecutadas por el procesador realizan las transiciones entre todos los estados posibles, tendremos nuestro emulador.
Vamos a ilustrar la emulación construyendo un intérprete de la máquina virtual CHIP-8 (opens new window). En un sistema, normalmente podemos distinguir partes como procesador, memoria, entradas, salidas que son implementados con componentes de hardware dedicados. En CHIP-8, al no ser una máquina real, todas estas partes están aglutinadas. Por ejemplo existen instrucciones de código máquina dedicadas a leer el teclado o a dibujar sprites en pantalla. En realidad esto lejos de ser un problema, simplifica la cuestión, ya que únicamente nos tendremos que centrar en emular el comportamiento de cada una de las instrucciones de código máquina de CHIP-8 para tener listo nuestro emulador. Es decir, emulando lo que normalmente sería el procesador, tendremos resuelta la emulación del sistema completo. Y la máquina tiene tan sólo 35 instrucciones. Si tenemos éxito, en teoría deberíamos ser capaces de ejecutar una de las muchas ROMs que existen para este sistema, como por ejemplo las recopiladas en este repositorio (opens new window).
# Descripción arquitectura CHIP-8
CHIP-8 en realidad fue una máquina que nunca existió. Es lo que se llama un sistema de fantasía, es decir desde el principio fue sólo una arquitectura virtual. Una especificación diseñada a mediados de los 70 para ser implementada sobre máquinas reales. Esta situación hoy en día puede sonar extraña sobre todo teniendo en cuenta la escasa potencia de las máquinas de entonces, pero se comprende si tenemos en cuenta que en aquella época no existían los sistemas estándar como hoy podemos decir de arquitecturas como la del IBM PC o el estándar MSX (opens new window) de los 80. Lo habitual era en cambio que cada sistema fuera completamente incompatible con el resto, y existían docenas de ellos. Al no ser una máquina existente sino una especificación, no sería correcto denominar emulador a lo que vamos a hacer sino intérprete o máquina virtual, aunque la forma de abordar el problema es equivalente.
La definición del sistema puede leerse en Wikipedia (opens new window) o en este wiki (opens new window). Como se ve resulta sumamente simple, tanto como para pensar en implementarla en un lenguaje de alto nivel como Python sobre un procesador tan limitado en potencia como el de Raspberry Pi Pico y por tanto en PiConsole.
Lo siguiente podría ser una especificación del sistema:
- Procesador: 500Hz; 35 instrucciones; 16 registros 8 bit; 1 instrucción/ciclo
- Memoria: 4KB (en realidad los primeros 512 bytes están reservados, por tanto 3,5KB)
- Pantalla: Monocromo de 64x32 px
- Teclado: Teclado hexadecimal de 16 teclas
- Sonido: 1 canal, 1 tono
CHIP-8 como todos los procesadores funciona ejecutando una secuencia de instrucciones de código máquina que en conjunto definen un programa o ROM. Estas ROMs serán ficheros binarios de extensión ch8
que podemos encontrar en distintos repositorios en internet como éste (opens new window). La mayoría de las ROMs que encontraremos para CHIP-8 serán de pequeños juegos. Las instrucciones de código máquina de CHIP-8 que contienen las ROMs tienen siempre 2 bytes o 16 bits de longitud. Normalmente se representan en hexadecimal y de esta forma cada instrucción está identificada por 4 caracteres. En los 4 caracteres se codifican tanto el tipo de instrucción como los argumentos de entrada. Por ejemplo la instrucción 600C
carga el valor 0C
hexadecimal (12 en decimal) en el registro V0
de la CPU. Como vemos tanto el valor a cargar como el número de registro donde lo introduciremos están codificados en la instrucción.
Para visualizar las ROMs lo mejor es utilizar editores hexadecimales separando el contenido de 2 en 2 bytes. Por ejemplo lo siguiente es el contenido de la ROM IBM Logo.ch8 (opens new window):
Podemos ver por ejemplo la instrucción que mencionábamos antes como ejemplo en tercera posición. El sencillo programa anterior lo único que hace es dibujar en pantalla el logotipo de IBM.
La tabla siguiente muestra las 35 instrucciones de CHIP-8:
Instrucción | Operación |
---|---|
0NNN | Salta a un código de rutina en NNN. Se usaba en los viejos computadores que implementaban Chip-8. Los intérpretes actuales lo ignoran. |
00E0 | Limpia la pantalla. |
00EE | Retorna de una subrutina. Se decrementa en 1 el Stack Pointer (SP). El intérprete establece el Program Counter como la dirección donde apunta el SP en la Pila. |
1NNN | Salta a la dirección NNN. El intérprete establece el Program Counter a NNN. |
2NNN | Llama a la subrutina NNN. El intérprete incrementa el Stack Pointer, luego pone el actual PC en el tope de la Pila. El PC se establece a NNN. |
3XNN | Salta a la siguiente instrucción si VX = NN. El intérprete compara el registro VX con el NN, y si son iguales, incrementa el PC en 2. |
4XNN | Salta a la siguiente instrucción si VX != NN. El intérprete compara el registro VX con el NN, y si no son iguales, incrementa el PC en 2. |
5XY0 | Salta a la siguiente instrucción si VX = VY. El intérprete compara el registro VX con el VY, y si no son iguales, incrementa el PC en 2. |
6XNN | Hace VX = NN. El intérprete coloca el valor NN dentro del registro VX. |
7XNN | Hace VX = VX + NN. Suma el valor de NN al valor de VX y el resultado lo deja en VX. |
8XY0 | Hace VX = VY. Almacena el valor del registro VY en el registro VX. |
8XY1 | Hace VX = VX OR VY. Realiza un bitwise OR (opens new window) (OR Binario) sobre los valores de VX y VY, entonces almacena el resultado en VX. Un OR binario compara cada uno de los bit respectivos desde 2 valores, y si al menos uno es verdadero (1), entonces el mismo bit en el resultado es 1. De otra forma es 0. |
8XY2 | Hace VX = VX AND VY. |
8XY3 | Hace VX = VX XOR VY. |
8XY4 | Suma VY a VX. VF se pone a 1 cuando hay un acarreo (carry), y a 0 cuando no. |
8XY5 | VY se resta de VX. VF se pone a 0 cuando hay que restarle un dígito al número de la izquierda, más conocido como "pedir prestado" o borrow, y se pone a 1 cuando no es necesario. |
8XY6 | Establece VF = 1 o 0 según bit menos significativo de VX. Divide VX por 2. |
8XY7 | Si VY > VX => VF = 1, sino 0. VX = VY - VX. |
8XYE | Establece VF = 1 o 0 según bit más significativo de VX. Multiplica VX por 2. |
9XY0 | Salta a la siguiente instrucción si VX != VY. |
ANNN | Establece I = NNN. |
BNNN | Salta a la ubicación V[0] + NNN. |
CXNN | Establece VX = un Byte Aleatorio AND NN. |
DXYN | Pinta un sprite en la pantalla. El intérprete lee N bytes desde la memoria, comenzando desde el contenido del registro I. Y se muestra dicho byte en las posiciones VX, VY de la pantalla. A los sprites que se pintan se le aplica XOR con lo que está en pantalla. Si esto causa que algún pixel se borre, el registro VF se setea a 1, de otra forma se setea a 0. Si el sprite se posiciona fuera de las coordenadas de la pantalla, dicho sprite se le hace aparecer en el lado opuesto de la pantalla. |
EX9E | Salta a la siguiente instrucción si valor de VX coincide con tecla presionada. |
EXA1 | Salta a la siguiente instrucción si valor de VX no coincide con tecla presionada (soltar tecla). |
FX07 | Establece Vx = valor del delay timer. |
FX0A | Espera por una tecla presionada y la almacena en el registro. |
FX15 | Establece Delay Timer = VX. |
FX18 | Establece Sound Timer = VX. |
FX1E | Índice = Índice + VX. |
FX29 | Establece I = VX * largo Sprite Chip-8. |
FX33 | Guarda la representación de VX en formato humano. Poniendo las centenas en la posición de memoria I, las decenas en I + 1 y las unidades en I + 2. |
FX55 | Almacena el contenido de V0 a VX en la memoria empezando por la dirección I. |
FX65 | Almacena el contenido de la dirección de memoria I en los registros del V0 al VX. |
# Desarrollo del emulador
Como hemos comentado, vamos a desarrollar el emulador en lenguaje Python para Raspberry Pi Pico, es decir en MicroPython. La única librería externa que necesitaremos es la habitual para manejar la pantalla SSD1306 que podemos encontrar aquí (opens new window). El resto lo codificaremos desde cero.
El código completo del emulador junto con algunas ROMs puede descargarse de aquí (opens new window). En lo que resta de este apartado vamos a describirlo poco a poco. El emulador que vemos aquí está derivado de la versión para Python convencional y la librería pygame (opens new window) de Edwin Jones que puede encontrarse aquí (opens new window).
# Cpu
Empezamos definiendo una clase dentro del módulo cpu.py
que encapsulará el hardware virtual de CHIP-8, es decir su CPU, memoria y entradas/salidas (pantalla, teclado y sonido). Veamos la declaración de las propiedades de la clase y su método constructor (más adelante la completaremos con algunos métodos más):
class Cpu:
# La RAM empieza en la dirección 0x200 / 512
PROGRAM_START_ADDRESS = const(0x200)
# Dirección de memoria donde se carga el juego de caracteres
FONT_OFFSET = const(0x0)
# CHIP-8 trabaja con instrucciones de código máquina de 16 bit/2 byte
WORD_SIZE = const(2)
# V[15/0xF] se usa como flag de acarreo en ciertas instrucciones
ARITHMETIC_FLAG_REGISTER = const(0xF)
# Frecuencia del sonido
SOUND_FREQ = const(500)
# Parámetros de la pantalla de PiConsole
SCALE = const(2)
DISPLAY_W = const(128)
DISPLAY_H = const(64)
CONTRAST = const(0x7F)
def __init__(self):
self.ram = [0] * 4096 # 4KB RAM
self.reg_PC = self.PROGRAM_START_ADDRESS # Program Counter
self.reg_I = 0 # Registro I
self.reg_Vx = [0] * 16 # Registros Vx
self.delay_timer = 0 # Delay timer
self.sound_timer = 0 # Sound timer
self.stack = [] # Pila de llamadas
self.stack_pointer = 0 # Puntero de pila
# Teclas
self.key_map = {
0x0: Pin(7, Pin.IN, Pin.PULL_UP), # B
0x1: None,
0x2: Pin(15, Pin.IN, Pin.PULL_UP), # Arriba
0x3: None,
0x4: Pin(13, Pin.IN, Pin.PULL_UP), # Izquierda
0x5: Pin(11, Pin.IN, Pin.PULL_UP), # A
0x6: Pin(12, Pin.IN, Pin.PULL_UP), # Derecha
0x7: None,
0x8: Pin(14, Pin.IN, Pin.PULL_UP), # Abajo
0x9: None,
0xA: None,
0xB: None,
0xC: None,
0xD: None,
0xE: None,
0xF: None
}
self.keys = set() # Teclas pulsadas
self.last_keys = set() # Teclas pulsadas en ciclo anterior
# Carga juego caracteres en RAM
offset = FONT_OFFSET
for item in font.DATA:
self.ram[offset] = item
offset += 1
# Pantalla SSD1306
spi = SPI(0, 100000, mosi=Pin(19), sck=Pin(18))
self.display = ssd1306.SSD1306_SPI(DISPLAY_W, DISPLAY_H, spi, Pin(16), Pin(20), Pin(17), contrast=CONTRAST)
# Buzzer
self.buzzer = PWM(Pin(10))
self.buzzer.freq(SOUND_FREQ)
self.buzzer.deinit()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
En el código anterior podemos ver cómo hemos definido los registros y memoria de la especificación de CHIP-8 en forma de variables y colecciones. También vemos el mapeo de las teclas de PiConsole hacia el teclado hexadecimal de CHIP-8. Desafortunadamente CHIP-8 gestiona más teclas (16) que las que tiene PiConsole (6). Si algún programa o ROM de CHIP-8 que queramos ejecutar necesita de más teclas, podemos mapear algunos de los 8 pines que hay en el GPIO conectando un pulsador entre estos pines y GND. En ese caso tendremos que añadir su definición al diccionario key_map
de forma similar a como hemos hecho con las 6 teclas de PiConsole.
# Font
En la inicialización de la CPU (el método constructor anterior), hay un momento en el que se carga el juego de caracteres de CHIP-8 desde un módulo de nombre font
. A continuación listamos el código de dicho módulo, que como vemos consiste únicamente en una lista de bytes en la que cada 5 de ellos contiene la definición bit a bit (la pantalla de CHIP-8 es en blanco y negro) de cada uno de los 16 caracteres hexadecimales (entre 0
y F
):
CHAR_SIZE = 5
DATA = [0xF0, 0x90, 0x90, 0x90, 0xF0, # 0
0x20, 0x60, 0x20, 0x20, 0x70, # 1
0xF0, 0x10, 0xF0, 0x80, 0xF0, # 2
0xF0, 0x10, 0xF0, 0x10, 0xF0, # 3
0x90, 0x90, 0xF0, 0x10, 0x10, # 4
0xF0, 0x80, 0xF0, 0x10, 0xF0, # 5
0xF0, 0x80, 0xF0, 0x90, 0xF0, # 6
0xF0, 0x10, 0x20, 0x40, 0x40, # 7
0xF0, 0x90, 0xF0, 0x90, 0xF0, # 8
0xF0, 0x90, 0xF0, 0x10, 0xF0, # 9
0xF0, 0x90, 0xF0, 0x90, 0x90, # A
0xE0, 0x90, 0xE0, 0x90, 0xE0, # B
0xF0, 0x80, 0x80, 0x80, 0xF0, # C
0xE0, 0x90, 0x90, 0x90, 0xE0, # D
0xF0, 0x80, 0xF0, 0x80, 0xF0, # E
0xF0, 0x80, 0xF0, 0x80, 0x80] # F
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# Opcode
Como hemos visto en la descripción de la arquitectura de CHIP-8, la base del proceso de las ROMs será la correcta interpretación o parseo de las instrucciones de código máquina. Para asistir en este proceso, vamos a crear la clase Opcode
dentro del módulo operation_code.py
que nos aportará métodos para extraer los identificadores de los registros (X o Y) y los valores (NNN, NN o N) embebidos en las instrucciones.
class Opcode:
def __init__(self, word):
"""
Argumentos:
word: un valor de 2 byte/16 bit que representa un opcode.
"""
# Limitamos la instrucción a 16 bit
self.word = word & 0xFFFF
# El tipo de instrucción se encuentra en los 4 primeros bits
self.a = (word & 0xF000) >> 12
# Leemos los datos de la instrucción en los tres tamaños posibles
# de 12, 8 ó 4 bits
self.nnn = word & 0x0FFF
self.nn = word & 0x00FF
self.n = word & 0x000F
# Leemos el valor donde se suele encontrar la referencia al primer
# registro
self.x = (word & 0x0F00) >> 8
# Leemos el valor donde se suele encontrar la referencia al segundo
# registro
self.y = (word & 0x00F0) >> 4
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Por ejemplo al procesar una instrucción como la DXYN
, podremos acceder a los identificadores de los registros donde se encuentran las coordenadas X e Y donde se dibujará el sprite en las propiedades Opcode.x
y Opcode.y
. La altura del sprite la obtendremos con la propiedad Opcode.n
. Otro ejemplo, al procesar la instrucción ANNN
obtendremos la dirección de memoria a cargar en el registro I
con la propiedad Opcode.nnn
.
# Operaciones
Una vez que ya podemos leer las instrucciones, necesitamos código que se encargue de desempeñar la operación que supone cada una de ellas. Esto vamos a hacerlo en el módulo operations.py
. Este módulo es el más largo por lo que no vamos a listarlo en su totalidad. Sólo vamos a ver como ejemplo un par de las operaciones que contiene que corresponderán a la lógica necesaria para ejecutar un par de instrucciones de código máquina de CHIP-8. Para estudiar el resto de funciones analizar el código fuente del fichero operations.py
.
# Instrucción 7XNN
La instrucción 7XNN
suma el valor de 8 bits NN
al registro identificado con X
. Lo que sigue es la función que realiza esa operación manipulando las estructuras de datos que definimos en la clase Cpu
:
def add_to_x(opcode, cpu):
cpu.reg_Vx[opcode.x] += opcode.nn
cpu.reg_Vx[opcode.x] &= 0xFF # Restringimos a 8 bit
2
3
# Instrucción 8XY2
La instrucción 8XY2
hace un AND bit a bit entre los registros V[X]
y V[Y]
. La función encargada de este trabajo es la siguiente:
def bitwise_and(opcode, cpu):
cpu.reg_Vx[opcode.x] = cpu.reg_Vx[opcode.x] & cpu.reg_Vx[opcode.y]
2
# Mapeo de operaciones
Una vez que hemos codificado todas las operaciones que es capaz de desempeñar el procesador de CHIP-8, nos falta "cablear" entre las instrucciones código máquina y las operaciones, es decir diseñar el mecanismo por el que al leer y parsear una instrucción (un opcode), terminemos invocando la función que codifica su operación. Esto lo haremos dentro de un nuevo módulo Python llamado operation_mapping.py
.
import operations
def find_operation(opcode):
opcodes_0 = {
0x00E0: operations.clear_display,
0x00EE: operations.return_from_function
}
if opcode.word in opcodes_0:
return opcodes_0[opcode.word]
opcodes = {
0x1: operations.goto,
0x2: operations.call_function,
0x3: operations.skip_if_equal,
0x4: operations.skip_if_not_equal,
0x5: operations.skip_if_x_y_equal,
0x6: operations.set_x,
0x7: operations.add_to_x,
0x9: operations.skip_if_x_y_not_equal,
0xA: operations.set_i,
0xB: operations.goto_plus,
0xC: operations.generate_random,
0xD: operations.draw_sprite
}
if opcode.a in opcodes:
return opcodes[opcode.a]
if opcode.a == 0x8:
opcodes_8 = {
0x0: operations.set_x_to_y,
0x1: operations.bitwise_or,
0x2: operations.bitwise_and,
0x3: operations.bitwise_xor,
0x4: operations.add_y_to_x,
0x5: operations.take_y_from_x,
0x6: operations.shift_x_right,
0x7: operations.take_x_from_y,
0xE: operations.shift_x_left
}
if opcode.n in opcodes_8:
return opcodes_8[opcode.n]
if opcode.a == 0xE:
opcodes_e = {
0x9E: operations.skip_if_key_pressed,
0xA1: operations.skip_if_key_not_pressed
}
if opcode.nn in opcodes_e:
return opcodes_e[opcode.nn]
if opcode.a == 0xF:
opcodes_f = {
0x07: operations.set_x_to_delay_timer,
0x0A: operations.wait_for_key_press,
0x15: operations.set_delay_timer,
0x18: operations.set_sound_timer,
0x1E: operations.add_x_to_i,
0x29: operations.load_character_address,
0x33: operations.save_x_as_bcd,
0x55: operations.save_registers_zero_to_x,
0x65: operations.load_registers_zero_to_x
}
if opcode.nn in opcodes_f:
return opcodes_f[opcode.nn]
raise KeyError(f"La instrucción {word:#06x} no se encuentra entre las previstas")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
La forma de realizar el mapeo utiliza una técnica Python que puede resultar difícil de seguir si no se está habituado a este lenguaje. Básicamente consiste en definir diccionarios en los que la clave es la parte más significativa del opcode y el valor la función dentro del módulo operations.py
que codifica la operación correspondiente. Sí, en Python una función es un valor permitido como puede ser un entero o un real en otro lenguaje. Lo vemos con un ejemplo:
def una_funcion(argumento):
print(argumento + " desde una_funcion")
def otra_funcion(argumento):
print(argumento + " desde otra_funcion")
mapeo = {
0: una_funcion,
1: otra_funcion
}
mapeo[0]("hola")
mapeo[1]("adios")
2
3
4
5
6
7
8
9
10
11
12
13
Por ejemplo, en el código anterior, mapeo[0]
será equivalente al identificador de la función una_funcion
por lo que podemos utilizarlo para invocarla. Si ejecutamos el código anterior, la salida será:
hola desde una_funcion
adios desde otra_funcion
2
# Funciones de manipulación CPU
Al principio mostramos la estructura de datos de la clase Cpu
. Ahora vamos a introducir el código que falta que se encarga de gestionar el ciclo de vida de la CPU así como de la gestión de las entradas/salidas de CHIP-8 (pantalla, teclado y sonido):
# Avanza a la siguiente instrucción
def move_to_next_instruction(self):
self.reg_PC += Cpu.WORD_SIZE
# Retrocede a la instrucción anterior
def move_to_previous_instruction(self):
self.reg_PC -= Cpu.WORD_SIZE
# Carga una ROM en la memoria
def load_rom(self, rom_bytes):
for i, byte_value in enumerate(rom_bytes):
self.ram[Cpu.PROGRAM_START_ADDRESS + i] = byte_value
# Activa el flag de acarreo
def set_arithmetic_flag(self):
self.reg_Vx[self.ARITHMETIC_FLAG_REGISTER] = 1
# Borra el flag de acarreo
def clear_arithmetic_flag(self):
self.reg_Vx[self.ARITHMETIC_FLAG_REGISTER] = 0
# Ejecuta una instrucción de código máquina
def emulate_cycle(self):
current_word = self.fetch_word()
opcode = Opcode(current_word)
current_operation = find_operation(opcode)
self.move_to_next_instruction()
current_operation(opcode, self)
# Carga de RAM la instrucción apuntada por el Program Counter
def fetch_word(self):
return self.ram[self.reg_PC] << 8 | self.ram[self.reg_PC + 1]
# Actualiza los timers y gestiona el sonido
def update_timers(self):
if self.delay_timer > 0:
self.delay_timer -= 1
if self.sound_timer > 0:
self.sound_timer -= 1
self.buzzer.duty_u16(32768)
else:
self.buzzer.deinit()
# Registra la pulsación de teclas
def handle_input(self):
for key, pin in self.key_map.items():
if pin:
if pin.value():
self.keys.discard(key)
else:
self.keys.add(key)
# Proceso de instrucciones
def run(self):
self.display.fill(0)
# Contadores de tiempo para sincronizar reloj procesador y pantalla
last_ticks_op = time.ticks_us()
last_ticks_sc = last_ticks_op
# Bucle principal
while True:
self.handle_input()
self.emulate_cycle()
self.last_keys = self.keys.copy()
self.update_timers()
# La frecuencia más recomendable para CHIP-8 es de 500 hz => Cada
# iteración del bucle debería durar 2000 ms
current_ticks = time.ticks_us()
leftover_ticks = 2000 - time.ticks_diff(current_ticks, last_ticks_op)
if leftover_ticks > 0:
time.sleep_us(leftover_ticks)
current_ticks += leftover_ticks
last_ticks_op = current_ticks
# Refrescamos la pantalla a 60FPS => Sólo refrescamos cada 16667 ms
if time.ticks_diff(current_ticks, last_ticks_sc) > 16667:
self.display.show()
last_ticks_sc = current_ticks
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# Main
Ya sólo nos queda crear el programa principal que instanciará la CPU, cargará una ROM y la pondrá en marcha:
from cpu import Cpu
# ROM a cargar
ROM = "Games/bc_test.ch8"
# Instancia de la CPU
cpu = Cpu()
# Cargamos ROM
rom_bytes = open(ROM, "rb").read()
cpu.load_rom(rom_bytes)
# Arrancamos procesador
cpu.run()
2
3
4
5
6
7
8
9
10
11
12
13
14
Una buena ROM a ejecutar para comprobar si nuestro emulador funciona correctamente es bc_test.ch8 (opens new window) que testea las instrucciones de salto condicional, y de operaciones matemáticas y lógicas de CHIP-8. Si todo va bien en pantalla aparecerá lo siguiente:
En caso de problemas, aparecerá uno de los códigos de error que se listan en este documento (opens new window).
# Instalación en PiConsole
Vamos a ver por último cómo instalar el emulador que hemos visto en este documento y que como se ha comentado al principio, puede descargarse de aquí (opens new window). Vamos a hacer uso del Thumby IDE para ello.
- Descargar los ficheros del emulador (opens new window).
- Acceder al Thumby IDE (opens new window) adaptado para PiConsole. Recordar que hay que utilizar Google Chrome o Microsoft Edge.
- Conectar PiConsole al PC mediante el puerto microUSB.
- Pulsar el botón
CONNECT THUMBY
hasta que se veaConnected
en verde en el widgetShell
. - En el widget
Filesystem
se verán los ficheros cargados en la memoria interna de PiConsole. Por medio del botónUPLOAD
del widgetFilesystem
subir los ficheros que contenía el repositorio descargado en el punto 1. Subir los ficheros a la raíz (/
) del sistema de archivo de PiConsole. Los ficheros que ya hubiera podemos dejarlos siempre que no aparezcan problemas de espacio de almacenamiento en PiConsole. Las ROMs se recomienda subirlas al directorioGames
para mantener el directorio raíz organizado. Si no existe el directorio, pulsar el botónFORMAT
que crea el sistema de archivos básico para PiConsole.
En estos momentos el sistema de archivos debería verse de esta forma (de momento sólo hemos subido la ROM bc_test.ch8
):
Como el fichero principal tiene nombre main.py
, el emulador arrancará al encender PiConsole. Sólo tenemos que desconectar el cable USB y alimentar normalmente PiConsole desde las pilas, es decir encender el interruptor.
Si queremos cargar una ROM diferente, tenemos que:
- Subir la ROM al directorio
Games
- Cambiar el nombre de la ROM en la línea 4 del programa principal
main.py
Finalmente un pequeño vídeo donde se muestra el emulador de PiConsole junto a la versión para RetroArch (opens new window) ejecutando la misma ROM en una consola portátil comercial.