BUS / 03

Memory-mapped I/O: cuando una escritura habla con el hardware

STA guarda un byte. La dirección decide quién lo recibe.

Al principio del emulador, la memoria puede parecer un array. Leés una dirección y devolvés un byte. Escribís una dirección y cambiás un elemento. Con eso ya pasan unas cuantas pruebas del CPU.

Después un juego escribe en $2007 y espera que cambie la imagen. Asignar un elemento del array ya no alcanza. Esa dirección habla con la PPU y la escritura tiene que pasar por sus registros.

La misma escritura en distintas direcciones

Este ejemplo primero apunta la PPU a la dirección de paleta $3F00. Después ejecuta STA contra la dirección del CPU que elijas. Probá $0200 y después $2007, con el valor $21.

Escribir en $0200 modifica la RAM, así que el programa muestra RAM 21. Escribir en $2007 cambia el color de fondo de la PPU, mientras que la lectura de RAM sigue dando 00. Los dos resultados los dibuja la NES emulada, corriendo en WebAssembly.

Ejecutá un programa de NES

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

LDA $2002
LDA #$3F
STA $2006
LDA #$00
STA $2006
LDA #$21
STA $2007
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

El programa apaga el renderizado mientras prepara la memoria y lo activa después de las escrituras. Actualizar VRAM durante el renderizado normal tiene restricciones de tiempo; las dejamos afuera de este primer experimento.

La dirección del CPU decide el destino

El CPU de la NES tiene un bus de direcciones de 16 bits que puede identificar 65.536 direcciones. Pero distintos rangos llevan a distintas cosas:

Dirección del CPU Quién recibe el acceso
$0000 a $07FF Los 2 KB de RAM interna
$0800 a $1FFF Espejos de esa RAM
$2000 a $2007 Registros de la PPU
$2008 a $3FFF Espejos de los ocho registros de la PPU
$4000 a $4017 Registros del APU y de I/O, incluidos controles y DMA
$4020 a $FFFF Espacio del cartucho, cuyo mapa depende del cartucho

Es un resumen de los rangos que nos interesan acá, no una implementación de todas las direcciones. Por ejemplo, $4018 a $401F es funcionalidad de prueba que normalmente está deshabilitada. El espacio del cartucho tampoco significa que todas esas direcciones contengan ROM.

El CPU sigue ejecutando una instrucción de escritura. El bus decide si esa escritura modifica RAM, llama a la PPU o llega al hardware del cartucho. Con las lecturas pasa lo mismo, aunque algunas también tienen efectos secundarios.

Un espejo traduce la dirección

La RAM de 2 KB se repite hasta $1FFF. La expresión habitual para ubicar el byte es address & 0x07FF. Por eso $0200 y $0A00 acceden al mismo byte. $0800, en cambio, corresponde a $0000.

Probá las dos opciones de RAM del ejemplo. Deberían mostrar el mismo RAM 21. Detrás de esas dos direcciones hay un solo byte.

Los ocho registros de la PPU también se repiten. $200F llega al mismo registro PPUDATA que $2007. El bus puede normalizar las dos direcciones con 0x2000 | (address & 7) antes de pasárselas a la PPU.

En Crystal, la parte de un router de escrituras que nos interesa se parece a esto:

case address
when 0x0000..0x1FFF
  @ram[address & 0x07FF] = value
when 0x2000..0x3FFF
  register = 0x2000 | (address & 0x0007)
  @ppu.write_register(register, value)
end

Esto cubre solamente RAM y PPU. El bus completo también conecta APU, controles, DMA y cartucho. El libro los va agregando a medida que sirven para lo que estamos construyendo.

Dos mapas de memoria, conectados por registros

CPU y PPU no comparten un mapa de memoria plano. Para escribir un byte en memoria de la PPU, el CPU primero elige el destino mediante PPUADDR, en la dirección CPU $2006. Después manda el byte por PPUDATA, en $2007.

PPUADDR recibe dos escrituras: primero el byte alto y después el bajo. Nuestro programa primero lee PPUSTATUS en $2002, que reinicia el latch compartido de escritura. Las dos escrituras siguientes seleccionan la dirección PPU $3F00.

LDA $2002
LDA #$3F
STA $2006
LDA #$00
STA $2006
LDA #$21
STA $2007

Acá $2007 es una dirección de registro del CPU y $3F00 es la dirección de memoria de la PPU que seleccionamos a través de ese registro. Separar las dos cosas ayuda bastante a entender el recorrido.

Después de escribir por PPUDATA, la PPU incrementa su dirección interna en 1 o en 32, según PPUCTRL. El programa auxiliar usa un incremento de 1. Leer PPUDATA tiene su propio comportamiento, incluido un buffer para la mayoría de las direcciones. No lo implementes como un array con lectura y escritura perfectamente simétricas.

Lo bastante chico para probarlo

Al implementarlo, arrancaría por dos pruebas: escribir en un espejo de RAM y leer desde otro, y después apuntar PPUADDR a una posición conocida y escribir por PPUDATA. Podés revisar el destino concreto en vez de debuggear un juego entero de una.

Algo para probar

Ejecutá $2007 y $200F con el mismo valor. Después probá $0200 y $0A00. Qué pares deberían dar la misma salida? Por qué la RAM queda en cero con el primer par?

Ver el resultado

Cada par da la misma salida. $200F es un espejo de PPUDATA, así que las dos escrituras de PPU cambian la paleta y dejan intacto el byte de RAM. $0A00 es un espejo de la RAM $0200, así que las dos escrituras de RAM modifican el byte que después lee el programa.

Dónde sigue en el libro

El capítulo 3.1 presenta el bus cuando el CPU necesita memoria. Los capítulos 5 y 5.1 agregan los registros de la PPU y la conexión entre los dos chips. Este ejemplo une esas partes sin necesitar un juego comercial para usarlas.

Referencias: mapa de memoria del CPU, registros de la PPU, mapa de memoria de la PPU, código del libro.

Capítulos relacionados del libro: 3.1, 5 y 5.1. Crystal.