CPU / 01

El 6502 para desarrolladores web

Un par de registros, unos pocos bytes y algo que podés ejecutar.

Cuando arranqué el emulador de NES lo hice en Ruby. Quería entender qué estaba haciendo la máquina antes de preocuparme por hacerlo rápido. El primer avance útil fue bastante chico: cargar un número en un registro e incrementar otro.

Vamos a hacer eso acá. El CPU de la NES está basado en el 6502. Lee bytes de memoria, los interpreta como instrucciones y modifica su estado. Si alguna vez escribiste un intérprete, una parte de esto te va a resultar familiar.

Primero, ejecutemos algo

El ejemplo de abajo corre un programa de NES en el mismo emulador Rust/WebAssembly de este sitio. Guarda A, X y los flags, y después usa la PPU para dibujar esos valores. JavaScript arma la ROM y muestra el framebuffer del emulador.

Con los valores iniciales vas a ver A 42, X 01, N 0 Z 0. Los números están en hexadecimal. Cambiá X a FF y ejecutalo de nuevo. El incremento da la vuelta a 00 y Z pasa a 1.

Ejecutá un programa de NES

El resultado lo dibuja el mismo emulador WebAssembly de la página principal.

LDA #$42
LDX #$00
INX
STA $0200
STX $0201
PHP
PLA
STA $0202
Ejecutá la ROM para ver su salida.Framebuffer de NES · WASM

Listo. Cambiá los valores o ejecutá el ejemplo inicial.

La ROM incluye código de arranque y dibujo alrededor del ejemplo. Código de la ROM

Para dibujar hay bastante preparación, así que el panel muestra las instrucciones que nos interesan. También podés descargar la ROM completa que se genera y cargarla en el emulador de la página principal. No necesitás la ROM de ningún juego.

Un registro es una variable muy chica

El 6502 tiene un acumulador llamado A, dos registros índice llamados X e Y, un puntero de pila, un contador de programa y un registro de estado. A, X e Y guardan un byte cada uno: un valor de 0 a 255.

El contador de programa, o PC, es distinto. Guarda la dirección de 16 bits del próximo byte de instrucción. Eso le da al CPU un espacio de direcciones de 64 KB. No quiere decir que la NES tenga 64 KB de RAM. En la guía de memory-mapped I/O vamos a ver esa diferencia.

El prefijo $ indica hexadecimal. $42 es 66 en decimal y $FF es 255. Si usás JavaScript, 0x42 es el mismo número. Assembly usa otra forma de escribirlo.

LDA #$42  ; cargar el valor literal $42 en A
LDX #$00  ; arrancar X en cero
INX       ; incrementar X

El # importa. LDA #$42 carga el número. LDA $42 lee un byte de la dirección $0042. Es la misma familia de instrucciones con otro modo de direccionamiento.

De dónde salen las instrucciones

El CPU no lee las letras LDA. Un ensamblador convierte ese texto en bytes. Para las tres instrucciones anteriores, los bytes son:

A9 42 A2 00 E8

A9 significa LDA en modo inmediato. El byte siguiente, 42, es su operando. A2 es LDX inmediato, seguido de 00. E8 es INX y no necesita operando.

Imaginá que esos bytes empiezan en $8000. El CPU busca A9 y después 42, así que PC llega a $8002. Después de LDX llega a $8004. Después de INX, a $8005. Las instrucciones no ocupan todas la misma cantidad de bytes. Tampoco consumen todas la misma cantidad de ciclos de reloj.

Ese es el trabajo básico del emulador: buscar un opcode, llamar a su implementación y contabilizar cuánto tiempo llevó. En el CPU del libro, escrito en Crystal, cargar A termina en un método así de chico:

def lda(value)
  @a = value
  set_z_flag(@a)
  set_n_flag(@a)
end

El handler del modo inmediato busca el operando y llama a este método. Los otros handlers de LDA primero obtienen el valor desde memoria. La operación es la misma.

Los flags también son estado

Z registra si el resultado fue cero. N copia el bit 7 del resultado. Cuando interpretás un byte como un número con signo en complemento a dos, ese es el bit de signo. Un valor sin signo como $80 igual activa N: el CPU no tiene otro tipo de registro para números con signo.

Probá X = 7F. Después de INX, X vale 80 y N queda activo. Probá X = FF. X pasa a 00 y Z queda activo.

En este ejemplo los flags corresponden al resultado de INX, que corre después de las cargas. Cambiar A a cero no deja Z activo si después INX produce 01. Es un detalle fácil de pasar por alto cuando un salto condicional parece no tener sentido.

STA y STX guardan los resultados sin modificar N ni Z. El programa captura el estado con PHP antes de que el código de dibujo empiece a cambiar los registros. Lo que ves es esa captura, no un debugger detenido después de cada instrucción.

Y la escritura en memoria

STA $0200 guarda A en RAM. Sus bytes son 8D 00 02: opcode, byte bajo de la dirección, byte alto. El 6502 usa direcciones little-endian, así que $0200 se codifica como 00 02.

Con esto ya tenía un punto de partida útil para construir el emulador. Unas pocas instrucciones, algo de memoria y programas chicos donde podía calcular el resultado esperado a mano. Los saltos y las interrupciones pueden esperar hasta que eso funcione.

Algo para probar

Poné A en 00 y X en FF. Antes de ejecutarlo, calculá los cuatro valores que debería mostrar. Después cambiá solamente X a 7F.

Ver el resultado

La primera ejecución guarda A = $00, X = $00, N = 0, Z = 1. La segunda guarda A = $00, X = $80, N = 1, Z = 0. INX determina los valores finales de N y Z en los dos casos.

Dónde sigue en el libro

Los capítulos 3 y 3.1 construyen el CPU, LDA, INX y sus pruebas en Crystal. El 3.2 agrega las escrituras y los modos de direccionamiento. El ejemplo del navegador usa la portación a Rust para que lo puedas ejecutar sin instalar Crystal.

Si te interesa el lado del hardware, el post del CPU en protoboard llega a la misma pregunta desde el otro lado.

Referencias: instrucciones del 6502, mapa de memoria del CPU, código del libro.

Capítulos relacionados del libro: 3, 3.1 y 3.2. Crystal.