BUS / 03
Memory-mapped I/O: when a write talks to hardware
STA always stores a byte. The address decides who receives it.
Early in the emulator, memory can look like an array. Read an address, return a byte. Write an address, change an element. That gets a few CPU tests running.
Then a game writes to $2007 and expects the picture to change. An array assignment isn't enough anymore. That address talks to the PPU, and the write has to go through its registers.
Run the same store against different addresses
This example first points the PPU at palette address $3F00. Then it executes STA against the CPU address you select. Try $0200, then $2007, using value $21.
A write to $0200 changes RAM, so the program displays RAM 21. A write to $2007 changes the PPU's backdrop color, while the RAM readback stays 00. Both results are drawn by the emulated NES, running in WebAssembly.
Run a small NES program
The result is rendered by the same WebAssembly emulator as the home page.
LDA $2002
LDA #$3F
STA $2006
LDA #$00
STA $2006
LDA #$21
STA $2007Ready. Change the inputs or run the defaults.
The ROM includes startup and display code around the example. ROM source
The program turns rendering off while setting up memory and enables it after the writes. Updating VRAM during normal rendering has timing restrictions; we're keeping those out of this first experiment.
The CPU address is a routing decision
The NES CPU has a 16-bit address bus, which can name 65,536 addresses. But different ranges lead to different things:
| CPU address | What receives the access |
|---|---|
$0000 to $07FF |
2 KB of internal RAM |
$0800 to $1FFF |
Mirrors of that RAM |
$2000 to $2007 |
PPU registers |
$2008 to $3FFF |
Mirrors of the eight PPU registers |
$4000 to $4017 |
APU and I/O registers, including controllers and DMA |
$4020 to $FFFF |
Cartridge space, whose mapping depends on the cartridge |
This is an overview of the ranges relevant here, rather than a complete implementation of every address. $4018 to $401F, for example, is normally disabled test functionality. Cartridge space also doesn't mean that every address contains ROM.
The CPU still executes a store instruction. The bus decides whether that store changes RAM, calls the PPU, or reaches cartridge hardware. The same idea applies to reads, though some reads also have side effects.
Mirroring is an address translation
The CPU's 2 KB RAM repeats through $1FFF. The usual routing expression is address & 0x07FF. $0200 and $0A00 therefore access the same byte. $0800, on the other hand, maps to $0000.
Try the two RAM choices in the example. They should produce the same RAM 21 display. There's one byte behind those two addresses.
The eight PPU registers repeat too. $200F reaches the same PPUDATA register as $2007. A bus implementation can normalize either address with 0x2000 | (address & 7) before passing it to the PPU.
In Crystal, the relevant part of a write router looks roughly like this:
case address
when 0x0000..0x1FFF
@ram[address & 0x07FF] = value
when 0x2000..0x3FFF
register = 0x2000 | (address & 0x0007)
@ppu.write_register(register, value)
end
This is just the RAM and PPU part. A complete bus also routes the APU, controllers, DMA and cartridge accesses. The book adds those as the corresponding components become useful.
Two address spaces, connected through registers
The CPU and PPU don't share one flat memory map. To write a byte to PPU memory, the CPU first chooses the destination through PPUADDR at CPU address $2006. Then it sends the byte through PPUDATA at $2007.
PPUADDR takes two writes: high byte first, low byte second. Our program reads PPUSTATUS at $2002 first, which resets the shared write latch. The two address writes then select PPU address $3F00.
LDA $2002
LDA #$3F
STA $2006
LDA #$00
STA $2006
LDA #$21
STA $2007
Here, $2007 is a CPU register address and $3F00 is the PPU memory address selected through it. Keeping those separate makes this much easier to follow.
After the PPUDATA write, the PPU advances its internal address by 1 or 32, selected by PPUCTRL. The helper program uses an increment of 1. Reading PPUDATA has its own behavior, including a buffer for most addresses; don't implement it as a perfectly symmetric read/write array.
Small enough to test
When implementing this, I'd start with two checks: write to one RAM mirror and read from another, then point PPUADDR at a known location and write through PPUDATA. You can check the destination instead of trying to debug an entire game at once.
Something to try
Run $2007 and $200F with the same value. Then run $0200 and $0A00. Which pairs should produce the same output? Why does the RAM value stay zero in the first pair?
Check the result
Each pair produces the same output. $200F mirrors PPUDATA, so both PPU writes change the palette, leaving the RAM byte untouched. $0A00 mirrors RAM address $0200, so both RAM writes change the byte that the program reads back.
Where this fits in the book
Chapter 3.1 introduces the bus when the CPU needs memory. Chapters 5 and 5.1 add the PPU registers and the routing between the two chips. This example joins those parts without needing a commercial game to exercise them.
References: CPU memory map, PPU registers, PPU memory map, the book's source code.
Related book chapters: 3.1, 5 and 5.1. Crystal.