Emulador

1/4/2022

# 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()
1
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
1
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
1
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
1
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]
1
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")
1
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")
1
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
1
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
1
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()
1
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.

  1. Descargar los ficheros del emulador (opens new window).
  2. Acceder al Thumby IDE (opens new window) adaptado para PiConsole. Recordar que hay que utilizar Google Chrome o Microsoft Edge.
  3. Conectar PiConsole al PC mediante el puerto microUSB.
  4. Pulsar el botón CONNECT THUMBY hasta que se vea Connected en verde en el widget Shell.
  5. En el widget Filesystem se verán los ficheros cargados en la memoria interna de PiConsole. Por medio del botón UPLOAD del widget Filesystem 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 directorio Games para mantener el directorio raíz organizado. Si no existe el directorio, pulsar el botón FORMAT 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:

  1. Subir la ROM al directorio Games
  2. 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.

Última actualización: 29/4/2022, 17:13:32