picoZ80 Developer's Guide
picoZ80 Developer's Guide
This guide is a comprehensive reference for developers who want to understand the picoZ80 firmware internals and write their own peripheral drivers. It covers the full software architecture from the Core 1 bus dispatch loop through to driver registration, memory hook installation, and I/O virtualisation. The Sharp MZ-700 driver (
MZ700.c) is used throughout as a concrete worked example.
No prior experience with the picoZ80 codebase is assumed. Each concept is explained from first principles and then shown in actual source code. By the end of this guide you will be able to write a complete driver from scratch, add it to the build system, register it in the framework, and configure it via JSON.
For hardware architecture, PIO bus interface details, and JSON configuration reference see the picoZ80 Technical Guide. For end-user setup see the picoZ80 User Manual.
Source Tree
All source code lives under
projects/tzpuPico/ within the repository root (the exact checkout location will depend on your system). The layout below shows the files relevant to driver development:
tzpuPico/ ├── CMakeLists.txt Top-level build file ├── src/ │ ├── CMakeLists.txt Source-level build file — add new driver files here │ ├── Z80CPU.c Main Z80 emulation, bus dispatch, driver framework │ ├── Z80CPU.h (legacy — included via include/) │ ├── M6502CPU.c 6502 parallel (same architecture) │ ├── FSPI.c / FSPI.h Flash SPI interface │ ├── ESP.c / ESP.h ESP32 communication layer │ ├── psram.c / psram.h PSRAM allocation and management │ ├── cJSON.c / cJSON.h JSON parser for config.json │ ├── include/ │ │ ├── Z80CPU.h *** KEY FILE: all type definitions and macros *** │ │ └── drivers/ │ │ └── Sharp/ │ │ ├── MZ.h MZ-series common constants │ │ ├── MZ700.h MZ-700 driver header │ │ ├── RFS.h / TZFS.h Filing system headers │ │ ├── WD1773.h Floppy controller header │ │ └── QDDrive.h QuickDisk header │ ├── drivers/ │ │ └── Sharp/ │ │ ├── MZ700.c *** EXAMPLE DRIVER *** │ │ ├── RFS.c ROM Filing System driver │ │ ├── TZFS.c TranZPUter Filing System driver │ │ ├── WD1773.c WD1773 floppy controller driver │ │ ├── QDDrive.c QuickDisk drive driver │ │ ├── MZ-1E05.c / MZ-1E14.c / MZ-1E19.c Peripheral interface cards │ │ └── MZ-1R12.c / MZ-1R18.c RAM expansion cards │ └── model/ │ ├── BaseZ80/ │ │ ├── CMakeLists.txt Per-model build targets │ │ ├── main.c Entry point (Core 0 + Core 1 launch) │ │ ├── main_memmap_partition_1.ld Linker script for Slot 1 │ │ └── main_memmap_partition_2.ld Linker script for Slot 2 │ └── Bootloader/
Key Types and Data Structures
Before looking at the driver framework, it is essential to understand the core data structures defined in
src/include/Z80CPU.h. These structures are passed to every driver function and are the primary means by which a driver interacts with the memory and I/O system.
Memory Block Type Constants
Every 512-byte block in the Z80 64KB address space has a type encoded in the top 8 bits of its
membankPtr entry. The type tells Core 1's dispatch loop how to handle bus transactions that fall within that block.
// src/include/Z80CPU.h #define MEMBANK_TYPE_UNKNOWN 0x00 // Uninitialised — should never appear at runtime #define MEMBANK_TYPE_PHYSICAL 0x01 // Pass-through: RP2350 releases bus, host hardware responds #define MEMBANK_TYPE_PHYSICAL_VRAM 0x02 // Pass-through with wait states for host video RAM #define MEMBANK_TYPE_PHYSICAL_HW 0x04 // Pass-through for host I/O-mapped hardware registers #define MEMBANK_TYPE_RAM 0x08 // Read/write — backed by PSRAM bank #define MEMBANK_TYPE_VRAM 0x10 // PSRAM video RAM — writes also mirrored to physical VRAM #define MEMBANK_TYPE_ROM 0x20 // Read-only — backed by PSRAM bank; writes silently ignored #define MEMBANK_TYPE_FUNC 0x40 // Virtual device — every access calls a C function handler #define MEMBANK_TYPE_PTR 0x80 // Indirection — each byte redirects to another address
The type determines what happens in
Z80CPU_readMem() and Z80CPU_writeMem():
- PHYSICAL / PHYSICAL_VRAM / PHYSICAL_HW — the RP2350 does not intercept the bus transaction; real hardware on the host board responds. Use this for any region where the host's own chips (ROM, RAM, video hardware) must be left in control.
- RAM — reads and writes go to a 64KB bank in the 8MB PSRAM. If a
memioPtrfunction is installed for the specific address, that function is called instead of (for FUNC) or alongside the PSRAM access (handlers can intercept or post-process). Wait states and T1 sync can be configured per block. - ROM — reads come from PSRAM (typically loaded from an image file at boot). Write cycles still reach any installed
memioPtrhandler but the PSRAM is not modified — useful for ROM banking registers that sit in a ROM-mapped address region. - VRAM — reads from PSRAM; writes go to both PSRAM and to the physical host VRAM in parallel. This allows software to maintain a shadow copy of the video buffer while also updating the real screen.
- FUNC — there is no PSRAM backing. Every read and write calls the function installed in
memioPtr[addr]. Use this for virtualised hardware registers, banking control ports mapped into memory space, and any resource with no real RAM behind it. - PTR — every byte of the 512-byte block can independently point to a different PSRAM location or memory type. Used for very fine-grained address-space manipulation.
membankPtr Encoding
The
_membankPtr[] array has 128 entries — one per 512-byte block of the 64KB Z80 address space. Each entry is a single 32-bit value that encodes three fields:
Bit 31..24 = Memory type (MEMBANK_TYPE_xxx constant) Bit 23..16 = PSRAM bank (0-63, which 64KB bank in the 8MB PSRAM) Bit 15..0 = Z80 address (base address of the block within the bank)
To set a block's type and bank you pack these three values into a single 32-bit integer using bitwise OR:
// Map block idx (each block = 512 bytes) to RAM type in bank 0:
cpu->_membankPtr[idx] = (MEMBANK_TYPE_RAM << 24) // type in top byte
| (MZ700_MEMBANK_0 << 16) // bank number
| (idx * MEMORY_BLOCK_SIZE); // base address of this block
// Map same block to ROM in bank 2:
cpu->_membankPtr[idx] = (MEMBANK_TYPE_ROM << 24)
| (2 << 16)
| (idx * MEMORY_BLOCK_SIZE);
// Map same block to physical (pass-through):
cpu->_membankPtr[idx] = (MEMBANK_TYPE_PHYSICAL << 24)
| (0 << 16)
| (idx * MEMORY_BLOCK_SIZE);
MEMORY_BLOCK_SIZE is 512 bytes. There are 128 blocks covering 0x0000–0xFFFF. The block index for a given Z80 address is: addr / MEMORY_BLOCK_SIZE = addr >> 9.
Memory Attributes (t_memAttr)
Each block also has a
t_memAttr entry that controls wait states and T-cycle synchronisation. These are stored in a 2D array indexed by bank and block:
// src/include/Z80CPU.h
typedef struct {
uint8_t waitStates; // Number of additional T-cycle wait states inserted on access
bool tCycSync; // true = sync PSRAM access to the T1 rising edge of each bus cycle
} t_memAttr;
// Access pattern:
cpu->_memAttr[bank][idx].waitStates = 1;
cpu->_memAttr[bank][idx].tCycSync = true;
waitStates: If your PSRAM-backed memory region requires more time to respond (e.g. because the handler function does extra work), add wait states. Each wait state extends the bus cycle by one T-cycle of the host clock. RAM regions typically use 1 wait state; ROM regions that serve pre-loaded data can often use 0.
tCycSync: When set to
true, the PIO z80_sync state machine delays the PSRAM access until the T1 rising edge of the current bus cycle. This prevents internal PSRAM operations from introducing timing drift in host software that relies on precise clock-cycle timing (cassette I/O, serial bit-banging, delay loops). Set this to true for RAM/ROM regions that the host's timing-sensitive software will access.
The PSRAM Structure (t_Z80PSRAM)
The 8MB external PSRAM is mapped into a single
t_Z80PSRAM structure. This is allocated once at startup and pointed to by cpu->_z80PSRAM. It is the largest and most important data structure in the system — everything the Z80 can access lives here.
// src/include/Z80CPU.h
typedef struct {
// 4MB data space: 64 banks x 64KB RAM/ROM image storage
uint8_t RAM[MEMORY_PAGE_BANKS * MEMORY_PAGE_SIZE];
// 64KB per-byte redirect table (used by MEMBANK_TYPE_PTR blocks)
uint32_t memPtr[MEMORY_PAGE_SIZE];
// 64KB memory address function pointer table
// Index = Z80 address (0x0000-0xFFFF)
// Value = NULL (no override) or pointer to a C handler function
MemoryFunc memioPtr[MEMORY_PAGE_SIZE];
// 64KB I/O port function pointer table
// Index = Z80 I/O port address (0x0000-0xFFFF; Z80 uses lower 8 bits for port)
// Value = NULL (pass through to physical I/O) or pointer to a C handler function
MemoryFunc ioPtr[IO_PAGE_SIZE];
} t_Z80PSRAM;
The four sub-arrays have distinct roles:
- RAM[] — raw byte storage for all PSRAM-backed memory banks. The total size is 64 banks × 64KB = 4MB. ROM images loaded from the SD card or Flash are written here at boot. During Z80 execution, reads and writes to RAM/ROM/VRAM type blocks access this array.
- memPtr[] — used only by PTR-type blocks. Each entry is a full
membankPtrvalue (encoded the same way ascpu->_membankPtr[]) that redirects a single byte's access to a completely different location. Allows byte-level remapping within the 64KB space. - memioPtr[] — the memory function hook table. One slot per Z80 address. When a slot is non-NULL, Core 1 calls the function at that slot on every memory access to that address, regardless of the block type (RAM, ROM, or FUNC). This is how drivers intercept or override specific memory locations without changing the overall block type.
- ioPtr[] — the I/O port hook table. One slot per Z80 I/O address (port A0–A15 wide, though the Z80 uses only A0–A7 as the actual port number; the upper bits can carry additional context). When non-NULL, the function is called for every IN or OUT instruction targeting that port. If NULL, the I/O cycle passes to physical hardware.
The MemoryFunc Handler Signature
Both
memioPtr[] and ioPtr[] slots hold function pointers of the same type — MemoryFunc:
// src/include/Z80CPU.h typedef uint8_t (*MemoryFunc)(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data);
Parameters:
- cpu — pointer to the Z80CPU context. Gives you access to
_membankPtr[],_z80PSRAM, and everything else. Never modify_membankPtr[]from inside an I/O handler that can be called from Core 1's hot loop; use the intercore queue for such operations (see Core Interaction). - read —
trueif this is a read cycle (Z80 is reading);falseif this is a write cycle (Z80 is writing). - addr — the full Z80 address (0x0000–0xFFFF for memory, 0x0000–0xFFFF for I/O ports). For I/O, the Z80 uses only the lower 8 bits as the actual port number (A0–A7); the upper 8 bits (A8–A15) are the value of the B register during the instruction.
- data — on a write cycle, the byte the Z80 is writing. On a read cycle from a RAM/ROM block, this is the current value at that address in PSRAM (you can use it or ignore it).
- On a read: the byte to return to the Z80. This is the value the Z80 sees on the data bus.
- On a write: the return value is generally unused for I/O handlers. For
memioPtrhandlers on RAM-type blocks, the return value is written back to the PSRAM in place of the original data — use this to modify or sanitise what gets stored.
debugf, or perform any operation that could stall Core 1. File I/O and UART communication must be posted to Core 0 via the intercore queue.
The Z80CPU Context Structure
Every driver function receives a
Z80CPU *cpu pointer. This is the master context for the entire emulation. The fields most relevant to driver writers are:
// src/include/Z80CPU.h (simplified)
struct Z80CPU {
Z80 _Z80; // Zeta Z80 emulator state (registers, flags, PC, etc.)
// Fast dispatch table: 128 entries, one per 512-byte block of Z80 address space
uint32_t _membankPtr[MEMORY_PAGE_BLOCKS]; // MEMORY_PAGE_BLOCKS = 128
// Per-block wait state and sync attributes, indexed [bank][block]
t_memAttr _memAttr[MEMORY_PAGE_BANKS][MEMORY_PAGE_BLOCKS];
// Pointer to the 8MB PSRAM structure (RAM[], memPtr[], memioPtr[], ioPtr[])
t_Z80PSRAM *_z80PSRAM;
// Loaded driver configurations (from JSON parsing)
t_drivers _drivers;
// Intercore communication queues (Core 1 → Core 0 and Core 0 → Core 1)
queue_t requestQueue;
queue_t responseQueue;
bool halt; // Z80 is in HALT state
bool hold; // Core 0 is requesting Core 1 to pause
bool holdAck; // Core 1 acknowledges hold request
bool forceReset; // Asynchronous reset flag
};
Driver Configuration Structures
When a driver is initialised, it receives a pointer to a
t_drvConfig structure that was populated by the JSON configuration parser. This tells the driver what interfaces it has been given, what ROM images to load, what address remaps to apply, and what parameters the user has configured.
// src/include/Z80CPU.h
// A single parameter key/value pair (from JSON "param" array)
typedef struct {
const char *name; // Parameter name string
const char *value; // Parameter value string (always a string; parse as needed)
} t_ifParam;
// A single ROM image assignment (from JSON "rom" array)
typedef struct {
const char *file; // SD card path to ROM file
uint16_t addr; // Z80 target address for this ROM
uint8_t bank; // PSRAM bank to load into
uint16_t size; // Size in bytes to load
uint32_t fileofs; // Byte offset into the ROM file
uint8_t waitStates; // Wait states for this block
bool tCycSync; // T1 sync for this block
} t_drvROMConfig;
// A single address-space remap (from JSON "addrmap" array)
typedef struct {
uint16_t srcAddr; // Original Z80 address
uint16_t dstAddr; // Redirected-to address
uint16_t size; // Range size
uint8_t bank; // PSRAM bank
uint8_t type; // MEMBANK_TYPE_xxx
} t_addrReMap;
// A single I/O remap (from JSON "iomap" array)
typedef struct {
uint16_t srcAddr; // I/O port address
uint16_t size; // Port range size
const char *funcName; // Name of handler function (looked up in memory func map)
} t_ioReMap;
// One interface block (one entry in JSON "if" array)
typedef struct t_drvIFConfig {
const char *name; // Interface name (e.g. "RFS", "MZ-1E05")
bool isPhysical; // Virtual or physical interface
int romCount; // Number of ROM entries
t_drvROMConfig *romConfig; // Array of ROM configurations
int addrMapCount; // Number of address remaps
t_addrReMap *addrMap; // Address remap table
int ioMapCount; // Number of I/O remaps
t_ioReMap *ioMap; // I/O remap table
int ifParamCount; // Number of parameters
t_ifParam *ifParam; // Parameter array
} t_drvIFConfig;
// Top-level driver config (one entry in JSON "drivers" array)
typedef struct t_drvConfig {
const char *name; // Driver name — must match a virtualFuncMap entry
bool isPhysical; // Virtual or physical driver
int ifCount; // Number of interfaces
t_drvIFConfig *ifConfig; // Interface array
ResetFunc reset_ptr; // Reset handler set by driver init
PollFunc poll_ptr; // Poll handler set by driver init
TaskFunc task_ptr; // Task handler set by driver init
} t_drvConfig;
The
reset_ptr, poll_ptr, and task_ptr fields are not set from JSON — they are set by your driver's init function so that Core 1 can call your driver's housekeeping functions at the appropriate times.
The Core 1 Dispatch Loop
Core 1 runs a tight infinite loop in
Z80CPU_cpu(). Understanding what happens in this loop is fundamental to writing correct drivers.
Main Loop Structure
// src/Z80CPU.c — Core 1 entry point
void __func_in_RAM(Z80CPU_cpu)(Z80CPU *cpu)
{
// Signal Core 0 that Core 1 is running
multicore_fifo_push_blocking(1);
while(1)
{
// --- HOLD CHECK ---
// Core 0 can pause Core 1 (e.g. for safe config reload)
if(cpu->hold == true)
{
cpu->holdAck = true;
while(cpu->hold == true); // Spin-wait
cpu->holdAck = false;
}
// --- DRIVER POLL ---
// Call each driver's poll handler for brief periodic housekeeping
for(int idx = 0; idx < cpu->_drivers.drvCount; idx++)
{
if(cpu->_drivers.driver[idx].poll_ptr != NULL)
cpu->_drivers.driver[idx].poll_ptr(cpu);
}
// --- Z80 EXECUTION ---
// Run the Z80 emulator for 2048 clock cycles
z80_run(&cpu->_Z80, 2048);
// --- RESET CHECK ---
// PIO IRQ 3 = hardware RESET asserted on the host bus
if(cpu->forceReset || (pio_2->irq & (1u << 3)) != 0)
{
Z80CPU_reset(cpu);
CLEAR_IRQ(pio_2, 3);
}
}
}
Key points:
- The loop runs
z80_run()for 2048 cycles per iteration. Between iterations, all driver poll handlers are called. This means poll handlers are called approximately every 2048 Z80 clock cycles — at 3.5MHz that is roughly every 585 microseconds. - Poll handlers must be extremely short. They are on the critical path of the emulation. A slow poll handler introduces jitter into Z80 bus timing.
- The
z80_run()function (from the Zeta Z80 library) executes Z80 instructions, calling back intoZ80CPU_readMem(),Z80CPU_writeMem(),Z80CPU_readIO(), andZ80CPU_writeIO()for every bus transaction.
Memory Read Dispatch
When the Z80 emulator performs a memory read,
Z80CPU_readMem() is called. This function is the heart of the memory system — understanding it tells you exactly what your handlers need to do.
// src/Z80CPU.c (simplified and annotated)
uint8_t __func_in_RAM(Z80CPU_readMem)(Z80CPU *cpu, uint16_t addr)
{
// Step 1: Look up the 512-byte block that contains this address
uint8_t blockIdx = addr >> 9; // addr / 512
uint32_t membankptr = cpu->_membankPtr[blockIdx]; // 32-bit encoded entry
// Step 2: Extract the three fields from the encoded entry
uint8_t memType = (membankptr >> 24) & 0xFF; // Top byte = type
uint8_t bank = (membankptr >> 16) & 0xFF; // Middle byte = bank
uint16_t blockBase = membankptr & 0xFFFF; // Lower 16 bits = base addr
// Step 3: Calculate the offset into the PSRAM bank for this address
uint32_t RAMaddr = (bank * MEMORY_PAGE_SIZE) + blockBase;
uint16_t blockOfs = addr & (MEMORY_BLOCK_SIZE - 1); // addr % 512 = offset in block
uint8_t waitStates = cpu->_memAttr[bank][blockIdx].waitStates;
uint8_t data = 0x00;
switch(memType)
{
case MEMBANK_TYPE_PHYSICAL:
case MEMBANK_TYPE_PHYSICAL_VRAM:
case MEMBANK_TYPE_PHYSICAL_HW:
// Pass-through: let the host hardware respond
data = Z80CPU_readPhysicalMem(cpu, addr);
break;
case MEMBANK_TYPE_RAM:
case MEMBANK_TYPE_VRAM:
case MEMBANK_TYPE_ROM:
if(cpu->_z80PSRAM->memioPtr[addr] != NULL)
{
// A handler is installed for this specific address.
// Pass the current PSRAM value as 'data' so the handler can use or modify it.
data = cpu->_z80PSRAM->memioPtr[addr](
cpu, true, addr,
cpu->_z80PSRAM->RAM[RAMaddr + blockOfs]);
}
else
{
// No handler — read directly from PSRAM
data = cpu->_z80PSRAM->RAM[RAMaddr + blockOfs];
}
if(waitStates) Z80CPU_waitPhysicalStates(cpu, waitStates);
break;
case MEMBANK_TYPE_FUNC:
// Pure virtual device — no PSRAM backing
if(cpu->_z80PSRAM->memioPtr[addr] != NULL)
data = cpu->_z80PSRAM->memioPtr[addr](cpu, true, addr, 0);
break;
case MEMBANK_TYPE_PTR:
// Indirect — follow the per-byte pointer and recurse
data = Z80CPU_readMem(cpu, addr, cpu->_z80PSRAM->memPtr[addr]);
break;
}
return data;
}
Memory Write Dispatch
// src/Z80CPU.c (simplified and annotated)
void __func_in_RAM(Z80CPU_writeMem)(Z80CPU *cpu, uint16_t addr, uint8_t data)
{
uint8_t blockIdx = addr >> 9;
uint32_t membankptr = cpu->_membankPtr[blockIdx];
uint8_t memType = (membankptr >> 24) & 0xFF;
uint8_t bank = (membankptr >> 16) & 0xFF;
uint16_t blockBase = membankptr & 0xFFFF;
uint32_t RAMaddr = (bank * MEMORY_PAGE_SIZE) + blockBase;
uint16_t blockOfs = addr & (MEMORY_BLOCK_SIZE - 1);
uint8_t waitStates = cpu->_memAttr[bank][blockIdx].waitStates;
switch(memType)
{
case MEMBANK_TYPE_PHYSICAL:
case MEMBANK_TYPE_PHYSICAL_VRAM:
case MEMBANK_TYPE_PHYSICAL_HW:
Z80CPU_writePhysicalMem(cpu, addr, data);
break;
case MEMBANK_TYPE_RAM:
case MEMBANK_TYPE_VRAM:
if(cpu->_z80PSRAM->memioPtr[addr] != NULL)
{
// Handler intercepts the write — the return value replaces 'data'
// before it is written to PSRAM (handler can sanitise or transform data)
data = cpu->_z80PSRAM->memioPtr[addr](cpu, false, addr, data);
}
// Write (possibly modified) data to PSRAM
cpu->_z80PSRAM->RAM[RAMaddr + blockOfs] = data;
if(waitStates) Z80CPU_waitPhysicalStates(cpu, waitStates);
break;
case MEMBANK_TYPE_ROM:
// ROM: handler is called if installed (e.g. to detect banking writes to ROM space)
// but the PSRAM is NOT written
if(cpu->_z80PSRAM->memioPtr[addr] != NULL)
cpu->_z80PSRAM->memioPtr[addr](cpu, false, addr, data);
break;
case MEMBANK_TYPE_FUNC:
// Pure virtual — handler only, no PSRAM
if(cpu->_z80PSRAM->memioPtr[addr] != NULL)
cpu->_z80PSRAM->memioPtr[addr](cpu, false, addr, data);
break;
case MEMBANK_TYPE_PTR:
Z80CPU_writeMem(cpu, addr, cpu->_z80PSRAM->memPtr[addr], data);
break;
}
}
I/O Port Dispatch
// src/Z80CPU.c (simplified and annotated)
uint8_t __func_in_RAM(Z80CPU_readIO)(Z80CPU *cpu, uint16_t addr)
{
// addr = full 16-bit Z80 address during I/O instruction (A0-A15)
// Port number = addr & 0xFF (A0-A7 = lower byte)
if(cpu->_z80PSRAM->ioPtr[addr] != NULL)
{
// Virtual I/O: call handler
return cpu->_z80PSRAM->ioPtr[addr](cpu, true, addr, 0);
}
else
{
// Physical I/O: RP2350 releases bus, host hardware responds
return Z80CPU_readPhysicalIO(cpu, addr);
}
}
void __func_in_RAM(Z80CPU_writeIO)(Z80CPU *cpu, uint16_t addr, uint8_t data)
{
if(cpu->_z80PSRAM->ioPtr[addr] != NULL)
{
cpu->_z80PSRAM->ioPtr[addr](cpu, false, addr, data);
}
else
{
Z80CPU_writePhysicalIO(cpu, addr, data);
}
}
Notice that the I/O dispatch is simpler than memory dispatch — there is no block-type concept for I/O ports. Every I/O address either has a handler in
ioPtr[] or it goes to physical hardware. There is no ROM/RAM/FUNC distinction for I/O.
Also note: the Z80 uses a 16-bit address on I/O instructions — the lower 8 bits are the port number; the upper 8 bits carry the contents of register B (during IN r,(C) / OUT (C),r instructions). If you want different handler behaviour depending on register B, examine the high byte of addr. For simple port-number-only matching, mask to addr & 0xFF.
The Driver Framework
The driver framework is the mechanism by which C driver modules are discovered, instantiated from JSON config, and wired into the memory and I/O systems. It has two levels:
- Top-level drivers (also called personas) — registered in
virtualFuncMap[]inZ80CPU.c. Each persona sets up an entire machine personality: memory layout, banking, I/O ports, and optionally a set of sub-interfaces (interface cards). - Interface drivers — registered in the persona's own
interfaceFuncMap[]. Each interface driver adds a specific peripheral (floppy, QuickDisk, RAM expansion, filing system) to the persona.
virtualFuncMap — Top-Level Driver Registration
The
virtualFuncMap[] array in src/Z80CPU.c maps a string name to a driver init function. Every top-level driver (persona) must have an entry here. The string name must match the "name" field in the JSON "drivers" array exactly (case-insensitive).
// src/Z80CPU.c
// Type definition for a top-level driver init function
typedef uint8_t (*VirtualFunc)(Z80CPU *cpu,
t_FlashAppConfigHeader *appConfig,
t_drvConfig *config,
const char *ifName);
// Map entry structure
typedef struct {
const char *virtualFuncName; // String name (must match JSON "name" field)
VirtualFunc virtual_func_ptr; // C function to call
} t_VirtualFuncMap;
// THE REGISTRATION TABLE — add your driver here
static const t_VirtualFuncMap virtualFuncMap[] = {
#ifdef INCLUDE_SHARP_DRIVERS
{"MZ700", MZ700_Init}, // Sharp MZ-700 persona
// {"MZ80A", MZ80A_Init}, // Add new persona entries here
#endif
};
static const size_t virtualFuncMapSize = sizeof(virtualFuncMap) / sizeof(virtualFuncMap[0]);
The lookup function that searches this table by name is:
// src/Z80CPU.c
VirtualFunc Z80CPU_getVirtualFunc(const char *funcName)
{
for(size_t i = 0; i < virtualFuncMapSize; i++)
{
if(strncasecmp(funcName, virtualFuncMap[i].virtualFuncName,
strlen(virtualFuncMap[i].virtualFuncName)) == 0)
return virtualFuncMap[i].virtual_func_ptr;
}
return NULL; // Name not found — driver will be skipped
}
Initialisation Flow
After the RP2350 reads and parses
config.json, driver initialisation happens in the following order:
Z80CPU_configFromJSON()
├── Z80CPU_configDriversFromJSON() Parse "drivers" array from JSON
│ For each driver entry:
│ ├── Look up name in virtualFuncMap[]
│ ├── Parse interface configs (ROM, addrmap, iomap, param)
│ └── Call VirtualFunc(cpu, appConfig, &drvConfig, NULL)
│ ↓
│ Driver init sets up:
│ ├── _membankPtr[] entries (block types and banks)
│ ├── _memAttr[][] entries (wait states, sync)
│ ├── memioPtr[] handlers (memory address hooks)
│ ├── ioPtr[] handlers (I/O port hooks)
│ ├── config->reset_ptr = MyDriver_Reset
│ ├── config->poll_ptr = MyDriver_PollCB
│ └── config->task_ptr = MyDriver_TaskProcessor
│
├── Z80CPU_configMemoryFromJSON() Apply "memory" array (overwrites driver defaults)
└── Z80CPU_configIOFromJSON() Apply "io" array (overwrites driver defaults)
Note the ordering: drivers are initialised first, then the JSON
"memory" and "io" arrays are applied. This means that any explicit entries in the memory or io arrays in config.json will override whatever the driver set up for those addresses. This allows the user to fine-tune driver defaults without modifying driver source code.
Driver Lifecycle Callbacks
A driver registers three ongoing callbacks by storing function pointers in its
t_drvConfig structure during init. Core 1 calls these at specific points during execution:
| Callback | Signature | When called | Typical use |
|---|---|---|---|
reset_ptr |
uint8_t f(Z80CPU *cpu) |
Host RESET line asserted; Z80 PC = 0x0000 | Restore default memory map, clear bank state |
poll_ptr |
uint8_t f(Z80CPU *cpu) |
Every ~2048 Z80 cycles (on Core 1) | Check status flags, post requests to Core 0 |
task_ptr |
uint8_t f(Z80CPU *cpu, enum Z80CPU_TASK_NAME task, char *param) |
In response to inter-core task requests | Handle file I/O results, disk sector delivery |
Important:
poll_ptr is called from Core 1 and must be fast. If it needs to perform I/O (load a disk sector, send a UART command), it should post a message to Core 0 via cpu->requestQueue and return immediately. Core 0 will perform the I/O and deliver the result back via cpu->responseQueue, triggering task_ptr on the next available opportunity.
Worked Example: The MZ-700 Driver
The Sharp MZ-700 driver (
src/drivers/Sharp/MZ700.c) is the most complete persona driver in the codebase. Walking through it in detail shows every pattern you will need for your own drivers.
The MZ-700 is a Sharp 8-bit computer from 1982 based on the Z80A. Its memory map has some distinctive features that make it an ideal learning example:
- The lower 4KB (0x0000–0x0FFF) is a Monitor ROM at power-on but can be swapped out for RAM via I/O port writes — the so-called "MZ-700 bank-switching".
- The upper region (0xD000–0xFFFF) contains Video RAM, colour VRAM, and memory-mapped hardware registers. The entire upper region can also be swapped to RAM via I/O ports.
- Memory banking is controlled via six I/O ports (0xE0–0xE6) that swap blocks in and out.
Interface Function Map
At the top of
MZ700.c, an interfaceFuncMap[] table lists all the peripheral interface cards (sub-drivers) that the MZ-700 persona knows about. This is the MZ-700 equivalent of virtualFuncMap[] — it maps interface names (from the JSON "if" array) to init functions for each add-on card.
// src/drivers/Sharp/MZ700.c
typedef struct {
const char *interfaceFuncName; // Interface name string (matches JSON "if.name")
bool active; // true once this interface has been initialised
InitFunc init_func_ptr; // Called during driver init when this interface appears in JSON
ResetFunc reset_func_ptr; // Called on RESET
PollFunc poll_func_ptr; // Called every ~2048 Z80 cycles
TaskFunc task_func_ptr; // Called for inter-core task delivery
} t_InterfaceFuncMap;
static t_InterfaceFuncMap interfaceFuncMap[] = {
{"RFS", false, RFS_Init, RFS_Reset, RFS_PollCB, RFS_TaskProcessor},
{"MZ-1E05", false, MZ1E05_Init, MZ1E05_Reset, MZ1E05_PollCB, MZ1E05_TaskProcessor},
{"MZ-1E14", false, MZ1E14_Init, MZ1E14_Reset, MZ1E14_PollCB, MZ1E14_TaskProcessor},
{"MZ-1E19", false, MZ1E19_Init, MZ1E19_Reset, MZ1E19_PollCB, MZ1E19_TaskProcessor},
{"MZ-1R12", false, MZ1R12_Init, MZ1R12_Reset, MZ1R12_PollCB, MZ1R12_TaskProcessor},
{"MZ-1R18", false, MZ1R18_Init, MZ1R18_Reset, MZ1R18_PollCB, MZ1R18_TaskProcessor},
};
static const size_t interfaceFuncMapSize =
sizeof(interfaceFuncMap) / sizeof(interfaceFuncMap[0]);
Banking State Structure
Because banking state must persist across bus transactions (a write to 0xE0 must be remembered so that subsequent memory accesses go to the right bank), the driver keeps a static state structure:
// src/drivers/Sharp/MZ700.c (abbreviated)
typedef struct {
bool loDRAMen; // true = lower 4KB (0x0000-0x0FFF) is mapped to RAM bank 1
bool hiDRAMen; // true = upper region (0xD000-0xFFFF) is mapped to RAM bank 1
bool inhibit; // true = upper bank-swap is inhibited (INHIBIT port written)
// Saved membankPtr values for the upper region when hi DRAM is swapped in
// (needed to restore the original mapping when hi DRAM is swapped back out)
uint32_t upmembankPtr[MZ700_UPPERMEM_BLOCKS];
} t_MZ700Ctrl;
static t_MZ700Ctrl MZ700Ctrl = {
.loDRAMen = false,
.hiDRAMen = false,
.inhibit = false,
};
Static variables like this are safe because there is only one Z80CPU instance and Core 1 is the only thread that calls the handler functions. If you ever have multiple CPU instances (not the current design), you would move this state into
t_drvConfig.
The Init Function — MZ700_Init()
MZ700_Init() serves a dual purpose. It is called in two different contexts, identified by which arguments are NULL:
- Validation mode (
ifName != NULL,config == NULL): Called byZ80CPU_configDriversFromJSON()to ask "do you support this interface name?" Returns 1 if yes, 0 if no. This allows the JSON parser to validate interface names against the driver before trying to init them. - Configuration mode (
ifName == NULL,config != NULL): The real init — set up the memory map, install hooks, configure interfaces.
// src/drivers/Sharp/MZ700.c
uint8_t MZ700_Init(Z80CPU *cpu, t_FlashAppConfigHeader *appConfig,
t_drvConfig *config, const char *ifName)
{
// --- VALIDATION MODE ---
if(ifName != NULL && config == NULL)
{
// Check if ifName is in our interfaceFuncMap
for(size_t i = 0; i < interfaceFuncMapSize; i++)
{
if(strncasecmp(ifName, interfaceFuncMap[i].interfaceFuncName,
strlen(interfaceFuncMap[i].interfaceFuncName)) == 0)
return 1; // Yes, we support this interface
}
return 0; // Unknown interface
}
// --- CONFIGURATION MODE ---
if(ifName == NULL && config != NULL)
{
// Determine if this is a physical (pass-through) or virtual (emulated) driver
bool isPhysical = config->isPhysical;
// ------------------------------------------------------------------
// STEP 1: Set up the flat memory map (membankPtr[] + memAttr[][])
// ------------------------------------------------------------------
// Walk all 128 blocks and assign a type/bank for each
for(int idx = 0; idx < MEMORY_PAGE_BLOCKS; idx++)
{
uint32_t memType = MEMBANK_TYPE_PHYSICAL; // Default: pass-through
uint8_t bank = MZ700_MEMBANK_0;
uint8_t waitStates = 0;
bool tCycSync = false;
// Blocks 0-7 = 0x0000-0x0FFF (Monitor ROM / low 4KB)
// If not physical, these will be overridden by JSON "memory" array.
// Leave as PHYSICAL here so that JSON can assign ROM or RAM as needed.
// Blocks 8-103 = 0x1000-0xCFFF (main RAM)
if(idx >= 8 && idx <= 103)
{
if(!isPhysical)
{
memType = MEMBANK_TYPE_RAM;
waitStates = 1;
tCycSync = true;
}
}
// Blocks 104-111 = 0xD000-0xDFFF (VRAM + colour VRAM)
if(idx >= 104 && idx <= 111)
memType = MEMBANK_TYPE_PHYSICAL_VRAM;
// Blocks 112-119 = 0xE000-0xE7FF (hardware registers: PPI, timer, etc.)
if(idx >= 112 && idx <= 119)
memType = MEMBANK_TYPE_PHYSICAL_HW;
// Blocks 120-127 = 0xF000-0xFFFF (FDC address space)
// Left as PHYSICAL so physical FDC hardware can respond if installed
// Pack into membankPtr entry
cpu->_membankPtr[idx] = (memType << 24)
| (bank << 16)
| (idx * MEMORY_BLOCK_SIZE);
cpu->_memAttr[bank][idx].waitStates = waitStates;
cpu->_memAttr[bank][idx].tCycSync = tCycSync;
}
// ------------------------------------------------------------------
// STEP 2: Clear the memioPtr and ioPtr tables for our address range
// (ensure no stale handler pointers from a previous config)
// ------------------------------------------------------------------
for(int idx = 0; idx < MEMORY_PAGE_SIZE; idx++)
cpu->_z80PSRAM->memioPtr[idx] = NULL;
for(int idx = 0; idx < IO_PAGE_SIZE; idx++)
cpu->_z80PSRAM->ioPtr[idx] = NULL;
// ------------------------------------------------------------------
// STEP 3: Install I/O port handlers for banking control ports
// ------------------------------------------------------------------
if(!isPhysical)
{
// MZ-700 memory banking ports 0xE0-0xE6
for(int port = 0xE0; port <= 0xE6; port++)
cpu->_z80PSRAM->ioPtr[port] = (MemoryFunc)MZ700_IO_MemoryBankPorts;
}
// ------------------------------------------------------------------
// STEP 4: Register lifecycle callbacks
// ------------------------------------------------------------------
config->reset_ptr = (ResetFunc)MZ700_Reset;
config->poll_ptr = (PollFunc)MZ700_PollCB;
config->task_ptr = (TaskFunc)MZ700_TaskProcessor;
// ------------------------------------------------------------------
// STEP 5: Initialise sub-interfaces listed in the JSON "if" array
// ------------------------------------------------------------------
for(int ifNo = 0; ifNo < config->ifCount; ifNo++)
{
t_drvIFConfig *ifcfg = &config->ifConfig[ifNo];
// Find the interface in our map
for(size_t i = 0; i < interfaceFuncMapSize; i++)
{
if(strncasecmp(ifcfg->name, interfaceFuncMap[i].interfaceFuncName,
strlen(interfaceFuncMap[i].interfaceFuncName)) == 0)
{
// Call the sub-driver init function
interfaceFuncMap[i].init_func_ptr(cpu, appConfig, ifcfg);
interfaceFuncMap[i].active = true;
break;
}
}
}
}
return 1;
}
The Banking Handler — MZ700_IO_MemoryBankPorts()
This is the I/O handler that manages all six MZ-700 banking control ports. It is called by
Z80CPU_writeIO() every time the Z80 writes to ports 0xE0–0xE6. Reading from these ports has no side-effect (returns 0xFF). Writing changes the memory map by modifying _membankPtr[] entries in real time.
// src/drivers/Sharp/MZ700.c
uint8_t MZ700_IO_MemoryBankPorts(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data)
{
uint8_t port = (uint8_t)(addr & 0xFF); // Extract port number from Z80 address
// Reads have no side-effect
if(read) return 0xFF;
// --- PORT 0xE0: Enable lower 4KB DRAM ---
// Swaps Monitor ROM (0x0000-0x0FFF) for RAM in bank 1
if(port == 0xE0 && !MZ700Ctrl.loDRAMen)
{
for(int idx = 0; idx < (0x1000 / MEMORY_BLOCK_SIZE); idx++)
{
cpu->_membankPtr[idx] = (MEMBANK_TYPE_RAM << 24)
| (MZ700_MEMBANK_1 << 16)
| (idx * MEMORY_BLOCK_SIZE);
}
MZ700Ctrl.loDRAMen = true;
}
// --- PORT 0xE1: Enable upper DRAM (0xD000-0xFFFF) ---
// Saves the current upper membankPtr entries and replaces them with RAM in bank 1
if(port == 0xE1 && !MZ700Ctrl.inhibit && !MZ700Ctrl.hiDRAMen)
{
int startBlock = 0xD000 / MEMORY_BLOCK_SIZE;
int endBlock = 0x10000 / MEMORY_BLOCK_SIZE;
for(int idx = startBlock; idx < endBlock; idx++)
{
// Save current mapping so we can restore it later (port 0xE3)
MZ700Ctrl.upmembankPtr[idx - startBlock] = cpu->_membankPtr[idx];
// Replace with RAM
cpu->_membankPtr[idx] = (MEMBANK_TYPE_RAM << 24)
| (MZ700_MEMBANK_1 << 16)
| (idx * MEMORY_BLOCK_SIZE);
}
MZ700Ctrl.hiDRAMen = true;
}
// --- PORT 0xE2: Restore lower ROM ---
// Swaps bank 1 RAM back out, restoring Monitor ROM at 0x0000-0x0FFF
if(port == 0xE2 && MZ700Ctrl.loDRAMen)
{
for(int idx = 0; idx < (0x1000 / MEMORY_BLOCK_SIZE); idx++)
{
// Restore to ROM in bank 0
cpu->_membankPtr[idx] = (MEMBANK_TYPE_ROM << 24)
| (MZ700_MEMBANK_0 << 16)
| (idx * MEMORY_BLOCK_SIZE);
}
MZ700Ctrl.loDRAMen = false;
}
// --- PORT 0xE3: Restore upper hardware mapping ---
if(port == 0xE3 && MZ700Ctrl.hiDRAMen)
{
int startBlock = 0xD000 / MEMORY_BLOCK_SIZE;
int endBlock = 0x10000 / MEMORY_BLOCK_SIZE;
for(int idx = startBlock; idx < endBlock; idx++)
{
// Restore saved mapping
cpu->_membankPtr[idx] = MZ700Ctrl.upmembankPtr[idx - startBlock];
}
MZ700Ctrl.hiDRAMen = false;
}
// --- PORT 0xE4: Inhibit upper DRAM swap ---
if(port == 0xE4)
MZ700Ctrl.inhibit = true;
// --- PORT 0xE5: Enable upper inhibit (alias) ---
if(port == 0xE5)
MZ700Ctrl.inhibit = false;
// PORT 0xE6: Memory protect (not implemented in this example)
return 0; // Return value ignored for write I/O handlers
}
This handler demonstrates the most important pattern in driver writing: modifying
_membankPtr[] in response to an I/O write to implement memory banking. The changes take effect immediately — the very next memory access from the Z80 will use the new mapping.
The save/restore pattern for the upper memory region (MZ700Ctrl.upmembankPtr[]) is important: when you swap out hardware-mapped regions for RAM, you must remember what was there so you can restore it when the software swaps back. Simply assigning PHYSICAL again would lose any custom mappings that were set up by sub-drivers or the JSON config.
The Reset Handler — MZ700_Reset()
When the host asserts RESET, Core 1 calls each driver's
reset_ptr. The MZ-700 reset handler restores the memory map to power-on state (ROM at 0x0000, VRAM and hardware at 0xD000+) and then calls all active interface reset handlers:
// src/drivers/Sharp/MZ700.c
uint8_t MZ700_Reset(Z80CPU *cpu)
{
// Restore lower 4KB to ROM if it was swapped to DRAM
if(MZ700Ctrl.loDRAMen)
{
for(int idx = 0; idx < (0x1000 / MEMORY_BLOCK_SIZE); idx++)
{
cpu->_membankPtr[idx] = (MEMBANK_TYPE_ROM << 24)
| (MZ700_MEMBANK_0 << 16)
| (idx * MEMORY_BLOCK_SIZE);
}
MZ700Ctrl.loDRAMen = false;
}
// Restore upper region if it was swapped to DRAM
if(MZ700Ctrl.hiDRAMen)
{
int startBlock = 0xD000 / MEMORY_BLOCK_SIZE;
int endBlock = 0x10000 / MEMORY_BLOCK_SIZE;
for(int idx = startBlock; idx < endBlock; idx++)
cpu->_membankPtr[idx] = MZ700Ctrl.upmembankPtr[idx - startBlock];
MZ700Ctrl.hiDRAMen = false;
}
MZ700Ctrl.inhibit = false;
// Propagate reset to all active sub-interfaces
for(size_t i = 0; i < interfaceFuncMapSize; i++)
{
if(interfaceFuncMap[i].active)
interfaceFuncMap[i].reset_func_ptr(cpu);
}
return 0;
}
The Poll Handler — MZ700_PollCB()
// src/drivers/Sharp/MZ700.c
uint8_t MZ700_PollCB(Z80CPU *cpu)
{
// The MZ-700 persona itself has nothing to do in the poll loop —
// all polling is delegated to active sub-interfaces
for(size_t i = 0; i < interfaceFuncMapSize; i++)
{
if(interfaceFuncMap[i].active)
interfaceFuncMap[i].poll_func_ptr(cpu);
}
return 0;
}
The Task Processor — MZ700_TaskProcessor()
// src/drivers/Sharp/MZ700.c
uint8_t MZ700_TaskProcessor(Z80CPU *cpu, enum Z80CPU_TASK_NAME task, char *param)
{
// Dispatch incoming task results to the sub-interface that requested them
for(size_t i = 0; i < interfaceFuncMapSize; i++)
{
if(interfaceFuncMap[i].active)
interfaceFuncMap[i].task_func_ptr(cpu, task, param);
}
return 0;
}
The task processor pattern is consistent across all drivers: the persona simply fans out the task to all active sub-interfaces. Each sub-interface checks whether the task is relevant to it and ignores it if not.
Writing a New Driver — Step by Step
This section walks through every step required to create a complete driver from scratch. The example creates a simple RAM disk (a 64KB block of PSRAM that the Z80 accesses as I/O-mapped memory) to illustrate all the patterns without the complexity of real hardware emulation.
Step 1 — Create the Source Files
Create two files. The header file declares the functions that other modules will call; the C file implements them.
// File: src/include/drivers/Sharp/MyDriver.h
#ifndef MYDRIVER_H
#define MYDRIVER_H
#include "Z80CPU.h"
#include "flash_ram.h" // For t_FlashAppConfigHeader
// Top-level init (registered in virtualFuncMap)
uint8_t MyDriver_Init(Z80CPU *cpu,
t_FlashAppConfigHeader *appConfig,
t_drvConfig *config,
const char *ifName);
// Lifecycle callbacks (set by init, called by Core 1 loop)
uint8_t MyDriver_Reset(Z80CPU *cpu);
uint8_t MyDriver_PollCB(Z80CPU *cpu);
uint8_t MyDriver_TaskProcessor(Z80CPU *cpu,
enum Z80CPU_TASK_NAME task,
char *param);
// Memory / I/O handler functions
uint8_t MyDriver_MemHandler(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data);
uint8_t MyDriver_IOHandler(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data);
#endif // MYDRIVER_H
// File: src/drivers/Sharp/MyDriver.c
#include <string.h>
#include <stdio.h>
#include "Z80CPU.h"
#include "flash_ram.h"
#include "drivers/Sharp/MyDriver.h"
// ------------------------------------------------------------------
// Internal state
// ------------------------------------------------------------------
#define MYDRIVER_BANK 8 // Use PSRAM bank 8 for our RAM region
// (Banks 0-7 reserved for MZ-700 persona in this example;
// choose a bank not used by any other driver)
#define MYDRIVER_IO_STATUS 0xC0 // I/O port: read = status byte
#define MYDRIVER_IO_CONTROL 0xC1 // I/O port: write = control byte
typedef struct {
uint8_t controlReg; // Last value written to control port
bool enabled; // Is the RAM region currently mapped in?
} t_MyDriverState;
static t_MyDriverState MyState = {
.controlReg = 0,
.enabled = false,
};
// ------------------------------------------------------------------
// Memory handler: called for every access to 0x8000-0xBFFF
// when the RAM region is mapped in as MEMBANK_TYPE_FUNC
// ------------------------------------------------------------------
uint8_t MyDriver_MemHandler(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data)
{
// For FUNC-type blocks, there is no PSRAM backing.
// We use a portion of a PSRAM bank as our backing store,
// but we service reads and writes manually here.
uint32_t bankBase = MYDRIVER_BANK * MEMORY_PAGE_SIZE; // Start of bank 8 in RAM[]
uint16_t localAddr = addr - 0x8000; // Offset within our region
if(read)
{
// Return the byte from our backing store
return cpu->_z80PSRAM->RAM[bankBase + localAddr];
}
else
{
// Store the byte into our backing store
cpu->_z80PSRAM->RAM[bankBase + localAddr] = data;
return data;
}
}
// ------------------------------------------------------------------
// I/O handler: called for ports 0xC0 and 0xC1
// ------------------------------------------------------------------
uint8_t MyDriver_IOHandler(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data)
{
uint8_t port = (uint8_t)(addr & 0xFF);
if(read)
{
// Port 0xC0: return status byte
if(port == MYDRIVER_IO_STATUS)
return MyState.enabled ? 0x01 : 0x00;
return 0xFF;
}
else
{
// Port 0xC1: control byte
if(port == MYDRIVER_IO_CONTROL)
{
MyState.controlReg = data;
if(data & 0x01)
{
// Bit 0 = enable: map our RAM into 0x8000-0xBFFF
if(!MyState.enabled)
{
int startBlock = 0x8000 / MEMORY_BLOCK_SIZE; // = 64
int endBlock = 0xC000 / MEMORY_BLOCK_SIZE; // = 96
for(int idx = startBlock; idx < endBlock; idx++)
{
// Use FUNC type so MyDriver_MemHandler is called for every access
cpu->_membankPtr[idx] = (MEMBANK_TYPE_FUNC << 24)
| (MYDRIVER_BANK << 16)
| (idx * MEMORY_BLOCK_SIZE);
// Install the memory handler for every address in range
}
// Install memioPtr handler for the entire range
for(uint32_t a = 0x8000; a < 0xC000; a++)
cpu->_z80PSRAM->memioPtr[a] = (MemoryFunc)MyDriver_MemHandler;
MyState.enabled = true;
}
}
else
{
// Bit 0 = 0: unmap, restore PHYSICAL pass-through
if(MyState.enabled)
{
int startBlock = 0x8000 / MEMORY_BLOCK_SIZE;
int endBlock = 0xC000 / MEMORY_BLOCK_SIZE;
for(int idx = startBlock; idx < endBlock; idx++)
{
cpu->_membankPtr[idx] = (MEMBANK_TYPE_PHYSICAL << 24)
| (0 << 16)
| (idx * MEMORY_BLOCK_SIZE);
}
// Remove memioPtr handlers
for(uint32_t a = 0x8000; a < 0xC000; a++)
cpu->_z80PSRAM->memioPtr[a] = NULL;
MyState.enabled = false;
}
}
}
return 0;
}
}
// ------------------------------------------------------------------
// Reset handler: restore power-on state
// ------------------------------------------------------------------
uint8_t MyDriver_Reset(Z80CPU *cpu)
{
// If our RAM was mapped in, restore physical pass-through
if(MyState.enabled)
{
int startBlock = 0x8000 / MEMORY_BLOCK_SIZE;
int endBlock = 0xC000 / MEMORY_BLOCK_SIZE;
for(int idx = startBlock; idx < endBlock; idx++)
{
cpu->_membankPtr[idx] = (MEMBANK_TYPE_PHYSICAL << 24)
| (0 << 16)
| (idx * MEMORY_BLOCK_SIZE);
}
for(uint32_t a = 0x8000; a < 0xC000; a++)
cpu->_z80PSRAM->memioPtr[a] = NULL;
}
// Reset state
MyState.controlReg = 0;
MyState.enabled = false;
return 0;
}
// ------------------------------------------------------------------
// Poll callback: nothing to do in this example
// ------------------------------------------------------------------
uint8_t MyDriver_PollCB(Z80CPU *cpu)
{
(void)cpu;
return 0;
}
// ------------------------------------------------------------------
// Task processor: nothing to do in this example
// ------------------------------------------------------------------
uint8_t MyDriver_TaskProcessor(Z80CPU *cpu, enum Z80CPU_TASK_NAME task, char *param)
{
(void)cpu; (void)task; (void)param;
return 0;
}
// ------------------------------------------------------------------
// Init function: called by virtualFuncMap dispatch
// ------------------------------------------------------------------
uint8_t MyDriver_Init(Z80CPU *cpu,
t_FlashAppConfigHeader *appConfig,
t_drvConfig *config,
const char *ifName)
{
// Validation mode: does this driver support the named interface?
if(ifName != NULL && config == NULL)
{
// This simple driver has no sub-interfaces — always return 0
return 0;
}
// Configuration mode
if(ifName == NULL && config != NULL)
{
// Default state: 0x8000-0xBFFF is PHYSICAL (host hardware responds)
// The Z80 can enable our RAM by writing to port 0xC1.
// At init time, leave the memory map unchanged and just install I/O hooks.
// Install I/O handlers for our control and status ports
cpu->_z80PSRAM->ioPtr[MYDRIVER_IO_STATUS] = (MemoryFunc)MyDriver_IOHandler;
cpu->_z80PSRAM->ioPtr[MYDRIVER_IO_CONTROL] = (MemoryFunc)MyDriver_IOHandler;
// Register lifecycle callbacks
config->reset_ptr = (ResetFunc)MyDriver_Reset;
config->poll_ptr = (PollFunc)MyDriver_PollCB;
config->task_ptr = (TaskFunc)MyDriver_TaskProcessor;
// Check for any parameters the user set in JSON "param" array
for(int p = 0; p < config->ifCount; p++)
{
// (no sub-interfaces in this example)
}
}
return 1;
}
Step 2 — Add to CMakeLists.txt
Open
src/CMakeLists.txt and add your new source file to the Sharp driver list:
# src/CMakeLists.txt — add your file to the Sharp driver list
set(pZ80_drivers_sharp_src
${CMAKE_CURRENT_LIST_DIR}/drivers/Sharp/MZ700.c
${CMAKE_CURRENT_LIST_DIR}/drivers/Sharp/RFS.c
${CMAKE_CURRENT_LIST_DIR}/drivers/Sharp/WD1773.c
${CMAKE_CURRENT_LIST_DIR}/drivers/Sharp/QDDrive.c
${CMAKE_CURRENT_LIST_DIR}/drivers/Sharp/MZ-1E05.c
${CMAKE_CURRENT_LIST_DIR}/drivers/Sharp/MZ-1E14.c
${CMAKE_CURRENT_LIST_DIR}/drivers/Sharp/MZ-1E19.c
${CMAKE_CURRENT_LIST_DIR}/drivers/Sharp/MZ-1R12.c
${CMAKE_CURRENT_LIST_DIR}/drivers/Sharp/MZ-1R18.c
# ADD YOUR DRIVER HERE:
${CMAKE_CURRENT_LIST_DIR}/drivers/Sharp/MyDriver.c
)
You must also add your header's directory to the include path if it is in a new subdirectory. For the Sharp driver directory this is already set up, so no additional
target_include_directories call is needed.
Step 3 — Include the Header in Z80CPU.c
Open
src/Z80CPU.c and add an include for your driver header alongside the existing Sharp driver includes:
// src/Z80CPU.c — near the top with other driver includes #ifdef INCLUDE_SHARP_DRIVERS #include "drivers/Sharp/MZ700.h" #include "drivers/Sharp/RFS.h" #include "drivers/Sharp/WD1773.h" #include "drivers/Sharp/QDDrive.h" #include "drivers/Sharp/MZ-1E05.h" #include "drivers/Sharp/MZ-1E14.h" #include "drivers/Sharp/MZ-1E19.h" #include "drivers/Sharp/MZ-1R12.h" #include "drivers/Sharp/MZ-1R18.h" // ADD YOUR INCLUDE: #include "drivers/Sharp/MyDriver.h" #endif
Step 4 — Register in virtualFuncMap
Still in
src/Z80CPU.c, find the virtualFuncMap[] array and add your entry. The string "MyDriver" is what the JSON "name" field must contain:
// src/Z80CPU.c
static const t_VirtualFuncMap virtualFuncMap[] = {
#ifdef INCLUDE_SHARP_DRIVERS
{"MZ700", MZ700_Init},
// ADD YOUR DRIVER:
{"MyDriver", MyDriver_Init},
#endif
};
The lookup is case-insensitive, so
"mydriver", "MyDriver", and "MYDRIVER" in the JSON will all match this entry.
Step 5 — Add the Driver to config.json
Add a
"drivers" entry to your config.json on the SD card. The "name" field must match the string you registered in virtualFuncMap[]:
{
"rp2350": {
"core": { "cpufreq": 300000000, "psramfreq": 133000000, "voltage": 1.10 },
"z80": [
{
"memory": [
{ "enable": 1, "addr": "0x0000", "size": "0x1000",
"type": "ROM", "bank": 0, "tcycwait": 0, "tcycsync": 0,
"task": "", "file": "/ROM/mz700.rom", "fileofs": 0 },
{ "enable": 1, "addr": "0x1000", "size": "0xCFFF",
"type": "RAM", "bank": 0, "tcycwait": 1, "tcycsync": 1,
"task": "", "file": "", "fileofs": 0 }
],
"io": [],
"drivers": [
{
"enable": 1,
"name": "MZ700",
"type": "VIRTUAL",
"if": []
},
{
"enable": 1,
"name": "MyDriver",
"type": "VIRTUAL",
"if": []
}
]
}
]
}
}
Step 6 — Build and Test
# From the project root ./build_tzpuPico.sh DEBUG # Firmware output: # build/bin/model/BaseZ80/BaseZ80_0x10020000.elf (debug ELF — use with GDB) # build/bin/model/BaseZ80/BaseZ80_0x10020000.bin (OTA binary) # Flash via OTA web page, or debug directly: openocd -f interface/cmsis-dap.cfg -f target/rp2350_tzpu.cfg -c "adapter speed 5000" & cd build/bin/model/BaseZ80 gdb-multiarch BaseZ80_0x10020000.elf (gdb) break MyDriver_Init (gdb) continue
Memory Hook Patterns in Detail
This section describes every hook pattern in detail with complete examples. These are the building blocks of all driver memory management.
Pattern 1 — Pure Virtual Device (FUNC block)
Use this when you want a region of the Z80 address space to be completely controlled by your handler, with no PSRAM backing. The Z80 reads and writes always call your function. Nothing is stored in PSRAM.
// Map 0xC000-0xCFFF as a pure virtual device in bank 4
int startBlock = 0xC000 / MEMORY_BLOCK_SIZE; // = 96
int endBlock = 0xD000 / MEMORY_BLOCK_SIZE; // = 104
for(int idx = startBlock; idx < endBlock; idx++)
{
cpu->_membankPtr[idx] = (MEMBANK_TYPE_FUNC << 24)
| (4 << 16)
| (idx * MEMORY_BLOCK_SIZE);
}
// Install handler for every address in range
for(uint32_t addr = 0xC000; addr < 0xD000; addr++)
cpu->_z80PSRAM->memioPtr[addr] = (MemoryFunc)MyVirtualDevice_Handler;
// Handler:
uint8_t MyVirtualDevice_Handler(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data)
{
uint16_t reg = addr - 0xC000; // Register offset within the device
if(read)
{
switch(reg)
{
case 0x00: return myDevice.statusReg;
case 0x01: return myDevice.dataReg;
default: return 0xFF;
}
}
else
{
switch(reg)
{
case 0x01: myDevice.dataReg = data; break;
case 0x02: myDevice.controlReg = data; break;
}
return data;
}
}
Pattern 2 — Intercept Writes to a RAM Region
Use this when you want a region to behave as normal RAM (reads return PSRAM data, writes update PSRAM) but you also want to be notified of writes — for example, to mirror video RAM writes to a shadow buffer or to trigger a hardware update. The block type stays
RAM; you install a memioPtr handler that post-processes the write.
// Map 0xD000-0xD7FF as RAM but intercept all writes
int startBlock = 0xD000 / MEMORY_BLOCK_SIZE;
int endBlock = 0xD800 / MEMORY_BLOCK_SIZE;
for(int idx = startBlock; idx < endBlock; idx++)
{
cpu->_membankPtr[idx] = (MEMBANK_TYPE_RAM << 24)
| (MY_VRAM_BANK << 16)
| (idx * MEMORY_BLOCK_SIZE);
cpu->_memAttr[MY_VRAM_BANK][idx].waitStates = 2;
cpu->_memAttr[MY_VRAM_BANK][idx].tCycSync = true;
}
// Only install handler for write-intercept addresses
for(uint32_t addr = 0xD000; addr < 0xD800; addr++)
cpu->_z80PSRAM->memioPtr[addr] = (MemoryFunc)MyVRAM_WriteIntercept;
// Handler — called by Z80CPU_writeMem() for RAM type with a handler:
// The returned value is what gets stored in PSRAM (not the original 'data').
uint8_t MyVRAM_WriteIntercept(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data)
{
if(read)
{
// On read: 'data' already contains the PSRAM value — just return it
return data;
}
else
{
// On write: update our shadow copy, then return 'data' so PSRAM is also updated
uint16_t vramOffset = addr - 0xD000;
myVRAMShadow[vramOffset] = data;
markDirty(vramOffset); // e.g. signal renderer that this cell changed
return data; // PSRAM is written with the returned value
}
}
Pattern 3 — Trap Writes to a ROM Region
Some hardware uses writes to ROM-mapped addresses as banking register writes (the write is "decoded" by hardware but does not modify the ROM). The block stays
ROM; writes trigger your handler but the PSRAM is not modified.
// ROM at 0x0000-0x0FFF, but writes to 0x0000-0x001F are banking registers
for(int idx = 0; idx < (0x1000 / MEMORY_BLOCK_SIZE); idx++)
{
cpu->_membankPtr[idx] = (MEMBANK_TYPE_ROM << 24)
| (ROM_BANK << 16)
| (idx * MEMORY_BLOCK_SIZE);
}
// Install write-trap handler only for addresses 0x0000-0x001F
for(uint32_t addr = 0x0000; addr < 0x0020; addr++)
cpu->_z80PSRAM->memioPtr[addr] = (MemoryFunc)MyROM_WriteTrap;
// Handler:
uint8_t MyROM_WriteTrap(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data)
{
if(read)
{
// Return ROM data — 'data' already contains PSRAM value at this address
return data;
}
else
{
// Write to ROM address — treat as banking register write
MyBankSwitch(cpu, addr, data);
// Return value is ignored for ROM writes (PSRAM not written)
return data;
}
}
Pattern 4 — Sparse Handlers (Individual Addresses)
You do not have to install handlers for an entire block. You can install a handler on a single specific address within a RAM or ROM block. The block type controls what happens to all other addresses in the block; the specific address handler overrides just that one address.
// The block containing 0x1234 is configured as RAM
// We want 0x1234 specifically to call a handler on write only
cpu->_z80PSRAM->memioPtr[0x1234] = (MemoryFunc)MySpecialHandler;
uint8_t MySpecialHandler(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data)
{
if(read)
return data; // Normal RAM read — return PSRAM value
else
{
// Special action on write to 0x1234
triggerSomething(data);
return data; // Write data to PSRAM as normal
}
}
Pattern 5 — I/O Port Handler
I/O handlers are simpler — there is no PSRAM backing for I/O ports. The handler is either called (if installed) or the I/O cycle goes to physical hardware. There is no block-type concept.
// Install handler for I/O ports 0x80-0x8F (16 ports)
for(int port = 0x80; port <= 0x8F; port++)
cpu->_z80PSRAM->ioPtr[port] = (MemoryFunc)MyIO_Handler;
uint8_t MyIO_Handler(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data)
{
uint8_t port = (uint8_t)(addr & 0xFF); // Actual port number
// uint8_t regB = (uint8_t)(addr >> 8); // Register B during IN r,(C) / OUT (C),r
if(read)
{
switch(port)
{
case 0x80: return myDevice.status;
case 0x81: return myDevice.rxData;
default: return 0xFF;
}
}
else
{
switch(port)
{
case 0x80: myDevice.control = data; applyControl(); break;
case 0x82: myDevice.txData = data; sendByte(data); break;
}
return 0;
}
}
Adding a Sub-Interface to an Existing Persona
If your new peripheral is a card that plugs into an MZ-700 (or another existing persona), you implement it as a sub-interface rather than a top-level driver. This is the pattern used by RFS, WD1773, QDDrive, MZ-1E05, and the RAM expansion cards.
Required Functions for a Sub-Interface
A sub-interface needs four functions with these signatures:
// Called once during MZ700_Init when this interface name appears in the JSON "if" array
uint8_t MyCard_Init(Z80CPU *cpu,
t_FlashAppConfigHeader *appConfig,
t_drvIFConfig *ifConfig); // Note: t_drvIFConfig, not t_drvConfig
// Called on RESET
uint8_t MyCard_Reset(Z80CPU *cpu);
// Called every ~2048 Z80 cycles (must be very fast)
uint8_t MyCard_PollCB(Z80CPU *cpu);
// Called for inter-core task delivery
uint8_t MyCard_TaskProcessor(Z80CPU *cpu, enum Z80CPU_TASK_NAME task, char *param);
Registering the Sub-Interface
Add your sub-interface to the
interfaceFuncMap[] in the parent persona's C file (e.g. MZ700.c):
// src/drivers/Sharp/MZ700.c — add to interfaceFuncMap[]
#include "drivers/Sharp/MyCard.h" // Add this include at top of MZ700.c
static t_InterfaceFuncMap interfaceFuncMap[] = {
{"RFS", false, RFS_Init, RFS_Reset, RFS_PollCB, RFS_TaskProcessor},
{"MZ-1E05", false, MZ1E05_Init, MZ1E05_Reset, MZ1E05_PollCB, MZ1E05_TaskProcessor},
// ... existing entries ...
// ADD YOUR SUB-INTERFACE:
{"MyCard", false, MyCard_Init, MyCard_Reset, MyCard_PollCB, MyCard_TaskProcessor},
};
The
"MyCard" string must match the "name" field of the interface entry in the JSON "if" array (case-insensitive):
"drivers": [
{
"enable": 1,
"name": "MZ700",
"type": "VIRTUAL",
"if": [
{
"enable": 1,
"name": "MyCard",
"type": "VIRTUAL",
"rom": [],
"addrmap": [],
"iomap": [],
"param": [
{ "name": "myParam", "value": "42" }
]
}
]
}
]
Reading JSON Parameters in a Sub-Interface
The
t_drvIFConfig *ifConfig passed to your sub-interface init contains all the JSON-configured data. To read a named parameter:
uint8_t MyCard_Init(Z80CPU *cpu,
t_FlashAppConfigHeader *appConfig,
t_drvIFConfig *ifConfig)
{
// Read a named parameter from the "param" array
int myParamValue = 0;
for(int p = 0; p < ifConfig->ifParamCount; p++)
{
if(strcasecmp(ifConfig->ifParam[p].name, "myParam") == 0)
{
myParamValue = atoi(ifConfig->ifParam[p].value);
break;
}
}
// Load ROM images listed in the "rom" array
for(int r = 0; r < ifConfig->romCount; r++)
{
t_drvROMConfig *rom = &ifConfig->romConfig[r];
if(rom->file != NULL && rom->file[0] != '\0')
{
// Load ROM from SD card into PSRAM bank at the configured address
uint32_t bankBase = rom->bank * MEMORY_PAGE_SIZE;
Z80CPU_ReadROM(appConfig, rom->file, NULL, NULL, NULL,
&cpu->_z80PSRAM->RAM[bankBase + rom->addr],
rom->size, rom->fileofs);
// Set up membankPtr for this ROM region
int startBlock = rom->addr / MEMORY_BLOCK_SIZE;
int endBlock = (rom->addr + rom->size) / MEMORY_BLOCK_SIZE;
for(int idx = startBlock; idx < endBlock; idx++)
{
cpu->_membankPtr[idx] = (MEMBANK_TYPE_ROM << 24)
| (rom->bank << 16)
| (idx * MEMORY_BLOCK_SIZE);
cpu->_memAttr[rom->bank][idx].waitStates = rom->waitStates;
cpu->_memAttr[rom->bank][idx].tCycSync = rom->tCycSync;
}
}
}
// Install I/O handlers from the "iomap" array
for(int m = 0; m < ifConfig->ioMapCount; m++)
{
t_ioReMap *iomap = &ifConfig->ioMap[m];
// Look up the named handler function
MemoryFunc handler = Z80CPU_getMemoryFunc(iomap->funcName);
if(handler != NULL)
{
for(uint32_t port = iomap->srcAddr;
port < iomap->srcAddr + iomap->size; port++)
{
cpu->_z80PSRAM->ioPtr[port] = handler;
}
}
}
return 1;
}
Core 0 / Core 1 Interaction
Driver handlers run on Core 1 inside the hot loop. Any operation that takes more than a few microseconds (file I/O, UART commands to the ESP32, malloc) must be offloaded to Core 0 using the inter-core queue.
Using the Intercore Queue
The pattern is:
- Your handler (on Core 1) detects that a file I/O or similar operation is needed (e.g. the Z80 wrote a sector number to a disk command register).
- The handler sets a state flag (e.g.
diskState.pendingRead = true) and returns immediately — it does not perform the I/O. - Your
poll_ptr(also on Core 1, called every ~2048 cycles) checks the state flag and, if set, pushes a request message intocpu->requestQueue. - Core 0 receives the message, performs the file I/O (e.g. reads a disk sector from the SD card), and pushes the result back into
cpu->responseQueue. - Your
task_ptris called (on Core 1) with the task result. It copies the sector data into PSRAM and clears the pending flag.
// In your I/O handler (Core 1 — must be fast):
uint8_t MyDisk_IOHandler(Z80CPU *cpu, bool read, uint16_t addr, uint8_t data)
{
if(!read && (addr & 0xFF) == 0xFE)
{
// Z80 issued a read sector command
diskState.pendingSector = data;
diskState.pendingRead = true;
// Return immediately — do NOT call file I/O here
}
return 0;
}
// In your poll handler (Core 1 — fast check only):
uint8_t MyDisk_PollCB(Z80CPU *cpu)
{
if(diskState.pendingRead)
{
// Build a request and push to Core 0
t_intercoreMsg msg = {
.taskId = TASK_READ_SECTOR,
.param1 = diskState.pendingSector,
.dataPtr = diskState.sectorBuffer,
};
if(queue_try_add(&cpu->requestQueue, &msg))
diskState.pendingRead = false; // Request sent
}
return 0;
}
// In your task processor (Core 1 — called when Core 0 has completed the task):
uint8_t MyDisk_TaskProcessor(Z80CPU *cpu, enum Z80CPU_TASK_NAME task, char *param)
{
if(task == TASK_READ_SECTOR)
{
// Sector data is now in diskState.sectorBuffer
// Copy into PSRAM at the DMA transfer address
uint32_t dmaAddr = DISK_BUFFER_BANK * MEMORY_PAGE_SIZE + diskState.dmaAddr;
memcpy(&cpu->_z80PSRAM->RAM[dmaAddr],
diskState.sectorBuffer, SECTOR_SIZE);
// Signal the Z80 that data is ready (e.g. set a status flag in a FUNC register)
diskState.statusReg |= 0x01; // DRQ bit
}
return 0;
}
Common Pitfalls
- Blocking in a handler. The most common mistake. Any call to
debugf,sleep_ms,fopen, or any UART function from inside a handler or poll callback will stall Core 1 and cause the host Z80 to see incorrect bus timing. Move all blocking operations to Core 0 via the request queue. - Wrong closing tag order in handler registration. When installing handlers across a range using a loop, ensure the loop bounds use
<not<=for the end address — off-by-one errors can corrupt adjacent handlers. - Forgetting to clear handlers on driver shutdown or reset. If your reset handler does not clear the
memioPtr[]orioPtr[]slots that your driver installed, those handlers will continue to be called after reset, with potentially stale state. - Bank number collision. Each PSRAM bank is 64KB. The JSON config assigns banks to memory regions. If two drivers use the same bank number, they will overwrite each other's data. Use unique bank numbers for each driver. Banks 0–7 are typically used by the MZ-700 persona; use banks 8+ for sub-interfaces and additional drivers.
- MEMBANK_TYPE_FUNC with no handler installed. If you set a block to FUNC type but do not install a
memioPtrhandler, reads will return 0x00 and writes will be silently dropped. This is valid behaviour but is often a bug — always install the handler before setting the block type. - Mismatched virtualFuncMap name and JSON name. The lookup is case-insensitive but the string must otherwise match exactly. A typo in either location will cause the driver to be silently skipped with no error message. Add a temporary
debugfcall inZ80CPU_getVirtualFunc()if your driver is not initialising —debugfis a macro that can be disabled or rate-limited in production builds so it does not impact bus timing. - Forgetting to set reset_ptr / poll_ptr / task_ptr. If you do not assign these in your init function, Core 1 will never call your reset, poll, or task functions. The driver will initialise correctly but will not respond to RESET or perform any periodic housekeeping.
Reference Sites
| Resource | Link |
|---|---|
| picoZ80 project page | /picoz80/ |
| picoZ80 User Manual | /picoz80-usermanual/ |
| picoZ80 Technical Guide | /picoz80-technicalguide/ |
| pico6502 project page | /pico6502/ |
| RP2350 Datasheet | datasheets.raspberrypi.com |
| Pico SDK Multicore API | raspberrypi.github.io/pico-sdk-doxygen |
| Zeta Z80 Library | github.com/superzazu/z80 |
| Zilog Z80 CPU User Manual | zilog.com |
| cJSON Library | github.com/DaveGamble/cJSON |