There's a profound difference between emulation and the real thing. While my previous post covered running CP/M on a software-based Z80 emulator, this post documents the journey of bringing CP/M 2.2 to life on actual Z80 silicon - a real Zilog Z80 CPU executing real machine code, with 1MB of DRAM and SD card storage for disk images.

The result? A fully functional CP/M system running Zork, all on an Arduino Mega 2560 acting as the glue between vintage and modern technology.

The Hardware Stack

Building a working CP/M system requires three essential components: a CPU, memory, and storage. Here's what I used:

The complete hardware stack: Arduino Mega 2560, KDRAM2560 DRAM shield, RetroShield Z80, and SD card module

The RetroShield Z80

The RetroShield from 8bitforce is a clever piece of engineering. It's a shield that holds a real Z80 CPU and lets an Arduino Mega control it cycle-by-cycle. The Arduino provides the clock, handles bus transactions, and emulates peripherals - but the Z80 is doing the actual computation.

Close-up of the RetroShield Z80 with a Zilog Z84C0004PSC CPU - the real silicon that runs CP/M

The RetroShield uses nearly every pin on the Arduino Mega:

Function Arduino Pins
Address Bus (A0-A15) Pins 22-37
Data Bus (D0-D7) Pins 42-49
Control Signals Pins 38-41, 50-53

This pin-hungry design means we need to be creative about adding peripherals.

KDRAM2560: 1MB of Dynamic RAM

The KDRAM2560 is another 8bitforce product - a DRAM shield that provides a full megabyte of memory to the Arduino Mega. It uses the analog pins (A0-A15) for its interface, leaving digital pins available for other uses.

Why DRAM instead of SRAM? Cost and density. A megabyte of SRAM would be expensive and physically large. DRAM is cheap but requires periodic refresh to maintain data integrity. The KDRAM2560 library handles this automatically using one of the Arduino's hardware timers.

For CP/M, we only need 64KB of the available 1MB, but having extra memory opens possibilities for RAM disks or bank switching in future projects.

#define DRAM_REFRESH_USE_TIMER_1
#include <kdram2560.h>

void setup() {
    // Initialize DRAM - this also starts the refresh interrupt
    if (DRAM.begin(&Serial)) {
        Serial.println("KDRAM2560: OK (1MB DRAM)");
    } else {
        Serial.println("KDRAM2560: FAILED!");
        while (1) {}  // Halt
    }
}

The API is beautifully simple:

// Read a byte from any address in the 1MB space
byte data = DRAM.read8(address);

// Write a byte
DRAM.write8(address, data);

Internally, the library handles the complex multiplexed addressing that DRAM requires - splitting the 20-bit address into row and column components, managing RAS/CAS timing, and ensuring refresh cycles happen frequently enough to prevent data loss.

Software SPI SD Card

Here's where things get interesting. The obvious choice for SD card storage would be the Arduino's hardware SPI on pins 50-53. But look back at that pin table - the RetroShield uses pins 50-53 for Z80 control signals!

The solution is software SPI - bit-banging the SPI protocol on different pins. I chose pins 4-7, safely away from both the RetroShield and KDRAM2560:

SD Card Pin Arduino Pin
MISO Pin 4
MOSI Pin 5
SCK Pin 6
CS Pin 7

The SD card module connected via rainbow ribbon cable to the KDRAM2560's prototyping area

The SdFat library supports software SPI through its SoftSpiDriver template class. One important note: you must set SPI_DRIVER_SELECT to 2 in SdFatConfig.h to enable this:

// In SdFat/src/SdFatConfig.h
#define SPI_DRIVER_SELECT 2  // Enable software SPI

Then in your sketch:

#include "SdFat.h"

const uint8_t SOFT_MISO_PIN = 4;
const uint8_t SOFT_MOSI_PIN = 5;
const uint8_t SOFT_SCK_PIN  = 6;
const uint8_t SD_CS_PIN     = 7;

SoftSpiDriver<SOFT_MISO_PIN, SOFT_MOSI_PIN, SOFT_SCK_PIN> softSpi;
#define SD_CONFIG SdSpiConfig(SD_CS_PIN, DEDICATED_SPI, SD_SCK_MHZ(0), &softSpi)

SdFs sd;

void setup() {
    if (sd.begin(SD_CONFIG)) {
        Serial.println("SD Card: OK (Software SPI)");
    }
}

Software SPI is slower than hardware SPI - roughly 20-50 KB/s compared to 1-2 MB/s. For loading programs at boot and occasional disk access, this is perfectly acceptable. You won't notice the difference playing Zork.

The Complete System Architecture

Here's how all the pieces fit together:

┌─────────────────────────────────────────────────────────────┐
                      Arduino Mega 2560                      
├─────────────────────────────────────────────────────────────┤
  Z80 RetroShield (top layer)                                
    Real Zilog Z80 CPU @ ~100kHz                             
    Address: pins 22-37 (directly mapped)                    
    Data: pins 42-49 (directly mapped)                       
    Control: pins 38-41, 50-53                               
├─────────────────────────────────────────────────────────────┤
  KDRAM2560 (middle layer)                                   
    1MB DRAM via analog pins A0-A15                          
    Timer 1 interrupt for automatic refresh                  
    Only 64KB used for Z80 address space                     
├─────────────────────────────────────────────────────────────┤
  MicroSD Card (external, via jumper wires)                  
    Software SPI on pins 4-7                                 
    FAT32 formatted, 32GB                                   
    Contains: boot.bin, CPM.SYS, A.DSK, B.DSK                
└─────────────────────────────────────────────────────────────┘

Peripheral Emulation: The Arduino's Role

While the Z80 executes code, the Arduino handles peripheral I/O. When the Z80 performs an IN or OUT instruction, the Arduino intercepts it and provides the appropriate response.

MC6850 ACIA (Serial Console)

The console uses a virtual MC6850 ACIA on I/O ports 0x80 (control/status) and 0x81 (data). This connects to the Arduino's Serial interface, which in turn connects to your terminal:

#define ADDR_6850_CONTROL     0x80
#define ADDR_6850_DATA        0x81

// In the I/O read handler:
if (ADDR_L == ADDR_6850_DATA) {
    // Z80 is reading from serial
    prevDATA = Serial.read();
}
else if (ADDR_L == ADDR_6850_CONTROL) {
    // Z80 is checking status
    // Bit 0: Receive data ready
    // Bit 1: Transmit buffer empty
    prevDATA = reg6850_STATUS;
}

// In the I/O write handler:
if (ADDR_L == ADDR_6850_DATA) {
    // Z80 is writing to serial
    Serial.write(DATA_IN);
}

SD Card Interface

The SD card interface uses ports 0x10-0x19, providing commands for file operations and DMA block transfers:

Port Function
0x10 Command register
0x11 Status register
0x12 Data byte (single-byte I/O)
0x13 Filename character input
0x14-0x15, 0x19 Seek position (24-bit)
0x16-0x17 DMA address (16-bit)
0x18 Block command (0=read, 1=write)

The key innovation is the DMA block transfer. Instead of the Z80 reading 128 bytes one at a time through port 0x12 (which would require 128 IN instructions), it sets a DMA address and issues a single block command. The Arduino then copies 128 bytes directly between the SD card and DRAM:

void sd_do_block_read() {
    uint8_t buffer[128];
    sdFile.read(buffer, 128);

    // Copy directly to DRAM
    for (int i = 0; i < 128; i++) {
        DRAM.write8((unsigned long)(sdDmaAddr + i), buffer[i]);
    }
    sdBlockStatus = 0;  // Success
}

This makes disk operations reasonably fast despite the software SPI limitation.

The Boot Process

When the Arduino powers up, here's what happens:

  1. Arduino Setup
  2. Initialize Serial at 115200 baud
  3. Initialize KDRAM2560 (starts refresh interrupt)
  4. Initialize SD card via software SPI
  5. Load boot.bin from SD card into DRAM at address 0x0000
  6. Release Z80 from reset

  7. Z80 Boot Loader (boot.bin)

  8. Initialize the MC6850 ACIA
  9. Print boot banner
  10. Open CPM.SYS from SD card
  11. Load it into DRAM at 0xE000 (53 sectors = 6,784 bytes)
  12. Jump to BIOS cold start at 0xF600

  13. CP/M BIOS Cold Start

  14. Initialize disk variables
  15. Set up page zero jump vectors
  16. Print the welcome message
  17. Jump to CCP (Console Command Processor)

  18. You see the A> prompt!

The boot loader is about 330 bytes of Z80 assembly:

;========================================================================
; CP/M Boot Loader for RetroShield Z80
;========================================================================

CCP_BASE:       equ     0xE000
BIOS_BASE:      equ     0xF600
LOAD_SIZE:      equ     53              ; Sectors to load

                org     0x0000

BOOT:
                di
                ld      sp, 0x0400

                ; Print boot message
                ld      hl, MSG_BOOT
                call    PRINT_STR

                ; Open CPM.SYS
                ld      hl, FILENAME
                call    SD_SEND_NAME
                ld      a, CMD_OPEN_READ
                out     (SD_CMD), a

                ; Load CP/M system to memory
                ld      hl, CCP_BASE
                ld      b, LOAD_SIZE

LOAD_LOOP:
                push    bc
                push    hl

                ; Set DMA address
                ld      a, l
                out     (SD_DMA_LO), a
                ld      a, h
                out     (SD_DMA_HI), a

                ; Read 128-byte block via DMA
                xor     a
                out     (SD_BLOCK), a

                ; Print progress dot
                ld      a, '.'
                call    PRINT_CHAR

                pop     hl
                ld      de, 128
                add     hl, de
                pop     bc
                djnz    LOAD_LOOP

                ; Jump to BIOS
                jp      BIOS_BASE

CP/M Disk Images

CP/M uses a specific disk format based on the 8-inch floppy standard:

  • 77 tracks
  • 26 sectors per track
  • 128 bytes per sector
  • 256KB total capacity

The first two tracks are reserved for the system (though we load from CPM.SYS instead). The directory starts at track 2, sector 0 (byte offset 6,656 or 0x1A00).

I wrote a Python tool to create and manage these disk images:

# Create an empty disk image
python3 cpm_disk.py create A.DSK

# Add a file
python3 cpm_disk.py add A.DSK ZORK1.COM

# List files
python3 cpm_disk.py list A.DSK

The directory entry format is straightforward - 32 bytes per entry:

Offset Size Description
0 1 User number (0xE5 = empty/deleted)
1-8 8 Filename (space-padded)
9-11 3 Extension (space-padded)
12-15 4 Extent info and record count
16-31 16 Block allocation map

One gotcha: empty directory entries must be marked with 0xE5, not 0x00. A disk full of zeros will confuse CP/M into thinking it has files with blank names!

Loading Classic Software: Zork on Real Hardware

With the infrastructure in place, loading classic software is straightforward. I grabbed Zork I, II, and III from the cpm-dist repository:

# Add Zork to the A: drive
python3 cpm_disk.py add A.DSK ZORK1.COM
python3 cpm_disk.py add A.DSK ZORK1.DAT
python3 cpm_disk.py add A.DSK ZORK2.COM
python3 cpm_disk.py add A.DSK ZORK2.DAT

# Hitchhiker's Guide goes on B:
python3 cpm_disk.py add B.DSK HITCH.COM
python3 cpm_disk.py add B.DSK HITCHHIK.DAT

Copy the disk images to the SD card, insert it into the module, and reset the Arduino:

Arduino IDE showing Zork I running on CP/M on the RetroShield Z80

======================================
RetroShield Z80 CP/M 2.2
======================================

KDRAM2560:  OK (1MB DRAM)
SD Card:    OK (Software SPI)
Loading boot.bin...
Loaded 331 bytes to DRAM at 0x0000
Starting Z80...

RetroShield Z80 Boot Loader
Copyright (c) 2025 Alex Jokela, tinycomputers.io

Loading CPM.SYS.....................................................
Boot complete.


RetroShield CP/M 2.2
56K TPA

A>DIR
A: ZORK1    COM : ZORK1    DAT : ZORK2    COM : ZORK2    DAT
A>ZORK1

ZORK I: The Great Underground Empire
Copyright (c) 1981, 1982, 1983 Infocom, Inc. All rights reserved.
ZORK is a registered trademark of Infocom, Inc.
Revision 88 / Serial number 840726

West of House
You are standing in an open field west of a white house, with a boarded
front door.
There is a small mailbox here.

>

There's something deeply satisfying about this. The Z80 CPU running this game is the same architecture that ran it in 1981. The actual opcodes being executed are identical. We've just swapped floppy drives for SD cards and CRT terminals for USB serial.

The CPU Tick Loop: Where It All Comes Together

The heart of the system is the cpu_tick() function - called continuously in loop(), it handles one Z80 clock cycle:

inline __attribute__((always_inline))
void cpu_tick() {
    // Check for serial input
    if (Serial.available()) {
        reg6850_STATUS |= 0x01;  // Set RDRF
    }

    CLK_HIGH;  // Rising clock edge
    uP_ADDR = ADDR;  // Capture address bus

    // Memory access?
    if (!STATE_MREQ_N) {
        if (!STATE_RD_N) {
            // Memory read - get byte from DRAM
            DATA_DIR = DIR_OUT;
            DATA_OUT = DRAM.read8((unsigned long)uP_ADDR);
        }
        else if (!STATE_WR_N) {
            // Memory write - store byte to DRAM
            DRAM.write8((unsigned long)uP_ADDR, DATA_IN);
        }
    }
    // I/O access?
    else if (!STATE_IORQ_N) {
        if (!STATE_RD_N && prevIORQ) {
            // I/O read
            DATA_DIR = DIR_OUT;
            if (sd_handles_port(ADDR_L)) {
                prevDATA = sd_read_port(ADDR_L);
            }
            else if (ADDR_L == ADDR_6850_DATA) {
                prevDATA = Serial.read();
            }
            else if (ADDR_L == ADDR_6850_CONTROL) {
                prevDATA = reg6850_STATUS;
            }
            DATA_OUT = prevDATA;
        }
        else if (!STATE_WR_N && prevIORQ) {
            // I/O write
            DATA_DIR = DIR_IN;
            if (sd_handles_port(ADDR_L)) {
                sd_write_port(ADDR_L, DATA_IN);
            }
            else if (ADDR_L == ADDR_6850_DATA) {
                Serial.write(DATA_IN);
            }
        }
    }

    prevIORQ = STATE_IORQ_N;
    CLK_LOW;  // Falling clock edge
    DATA_DIR = DIR_IN;
}

This runs at roughly 100kHz - slow by modern standards, but plenty fast for interactive programs. The Z80 was designed for clock speeds of 2-4MHz, so we're running at about 3-5% of original speed. Text adventures don't mind.

The BIOS: Hardware Abstraction in 1KB

The CP/M BIOS is where the magic happens. It's the only part of CP/M that needs to be written for each new hardware platform. The BDOS and CCP are universal - they work on any machine with a conforming BIOS.

Our BIOS implements 17 entry points:

                org     BIOS_BASE       ; 0xF600

                jp      BOOT            ; 00 - Cold boot
WBOOTE:         jp      WBOOT           ; 03 - Warm boot
                jp      CONST           ; 06 - Console status
                jp      CONIN           ; 09 - Console input
                jp      CONOUT          ; 0C - Console output
                jp      LIST            ; 0F - List output
                jp      PUNCH           ; 12 - Punch output
                jp      READER          ; 15 - Reader input
                jp      HOME            ; 18 - Home disk
                jp      SELDSK          ; 1B - Select disk
                jp      SETTRK          ; 1E - Set track
                jp      SETSEC          ; 21 - Set sector
                jp      SETDMA          ; 24 - Set DMA address
                jp      READ            ; 27 - Read sector
                jp      WRITE           ; 2A - Write sector
                jp      LISTST          ; 2D - List status
                jp      SECTRAN         ; 30 - Sector translate

The most complex routines are the disk operations. READ and WRITE must calculate the byte offset within the disk image from track and sector numbers:

CALC_OFFSET:
        ; offset = (track * 26 + sector) * 128
        ld      hl, (TRACK)
        ld      de, 26          ; Sectors per track
        call    MULT16          ; HL = track * 26
        ld      de, (SECTOR)
        add     hl, de          ; HL = track * 26 + sector

        ; Multiply by 128 (shift left 7 times)
        xor     a               ; Clear carry byte
        add     hl, hl          ; *2
        adc     a, 0
        add     hl, hl          ; *4
        adc     a, a
        ; ... continue shifting ...

        ld      (SEEKPOS), hl
        ld      (SEEKPOS+2), a  ; 24-bit result
        ret

The Disk Parameter Block (DPB) tells CP/M about our disk geometry:

DPB:
        defw    26              ; SPT - sectors per track
        defb    3               ; BSH - block shift (1K blocks)
        defb    7               ; BLM - block mask
        defb    0               ; EXM - extent mask
        defw    242             ; DSM - total blocks - 1
        defw    63              ; DRM - directory entries - 1
        defb    0xC0            ; AL0 - allocation bitmap
        defb    0x00            ; AL1
        defw    16              ; CKS - checksum size
        defw    2               ; OFF - reserved tracks

These parameters define a standard 256KB 8-inch floppy format - the same format used by countless CP/M machines in the late 1970s.

Understanding CP/M's Memory Model

CP/M's memory layout is elegantly simple. The entire operating system fits in the top 8KB of the 64KB address space:

┌───────────────────────────────────────┐ 0xFFFF
              BIOS                      ~1KB - Hardware abstraction
├───────────────────────────────────────┤ 0xF600
              BDOS                      ~3.5KB - File system, I/O
├───────────────────────────────────────┤ 0xE800
              CCP                       ~2KB - Command processor
├───────────────────────────────────────┤ 0xE000
                                       
                                       
              TPA                       ~56KB - Your programs!
     (Transient Program Area)          
                                       
                                       
├───────────────────────────────────────┤ 0x0100
          Page Zero                     256 bytes - System variables
└───────────────────────────────────────┘ 0x0000

Page Zero contains crucial system information:

  • 0x0000-0x0002: Jump to warm boot
  • 0x0005-0x0007: Jump to BDOS entry
  • 0x005C: Default FCB (File Control Block)
  • 0x0080: Default DMA buffer / command tail

When you type ZORK1 at the command prompt, CP/M loads ZORK1.COM at address 0x0100 and jumps there. The program has nearly 56KB to work with - a luxurious amount of memory for 1970s software.

Debugging Tips

Getting CP/M running required extensive debugging. Here are some tips if you're attempting something similar:

Enable Debug Output

Set outputDEBUG to 1 in the Arduino sketch to see every I/O operation:

#define outputDEBUG     1

This prints every port read/write, which is invaluable for tracking down why the BIOS isn't finding files or why sectors are being read from wrong locations.

Check Your Directory Format

The most common issue I encountered was improperly formatted disk images. Use a hex editor to verify:

  • Directory starts at offset 0x1A00 (6656 bytes)
  • Empty entries have 0xE5 in byte 0, not 0x00
  • Filenames are space-padded to 8 characters, extensions to 3

Verify DMA Addresses

If programs load but crash immediately, check that the DMA address is being set correctly. The BIOS must output both low and high bytes:

ld      a, l
out     (SD_DMA_LO), a
ld      a, h
out     (SD_DMA_HI), a

Watch for Register Clobbering

Z80 subroutine calls don't preserve registers by default. If your SELDSK routine returns garbage, check whether the OPENDISK helper is destroying HL before the return.

Performance Considerations

The system runs at approximately 100kHz - about 3% of the Z80's original 4MHz speed. This is limited by:

  1. Arduino loop overhead: Each cpu_tick() call has function call overhead
  2. DRAM access time: Software-controlled DRAM is slower than dedicated hardware
  3. Software SPI: Bit-banging SPI adds latency to disk operations

For interactive programs like text adventures, this is imperceptible. For computation-heavy tasks, you'd notice the slowdown. WordStar feels sluggish but usable; compiling code would test your patience.

Future optimizations could include:

  • Assembly-optimized cpu_tick() routine
  • Hardware SPI with a different pin arrangement
  • Overclocking the Arduino (at your own risk)

Challenges and Solutions

Challenge 1: Pin Conflicts

The RetroShield claims the hardware SPI pins (50-53). Solution: software SPI on alternate pins. The SdFat library's SoftSpiDriver template makes this painless.

Challenge 2: Memory Refresh

DRAM needs refresh every few milliseconds or it loses data. Solution: the KDRAM2560 library uses Timer 1 interrupts to handle this transparently. The refresh happens in the background - you never need to think about it.

Challenge 3: Disk Image Format

CP/M expects 0xE5 (not 0x00) for empty directory entries. A disk image initialized to all zeros will confuse CP/M into displaying phantom files. Solution: the cpm_disk.py tool properly initializes the directory.

Challenge 4: 24-bit Seek Positions

Disk images are 256KB, requiring 18 bits to fully address. My initial 16-bit seek implementation couldn't access sectors past track 51. Solution: added a third seek port (0x19) for bits 16-23.

Challenge 5: SELDSK Return Value Bug

CP/M's BDOS expects SELDSK to return a pointer to the Disk Parameter Header in HL. My initial code calculated this pointer, then called OPENDISK which clobbered HL. Solution: push/pop HL around the OPENDISK call.

Getting Started: Bill of Materials

To build your own CP/M machine, you'll need:

Item Approximate Cost
Arduino Mega 2560 $15-40
Z80 RetroShield $35
KDRAM2560 $20
MicroSD Card Module $5
MicroSD Card (≤32GB FAT32) $5-10
Jumper wires $5
Total ~$100-130

Files and Resources

The complete Arduino sketch and supporting files are available on GitHub:

Required libraries: - KDRAM2560 - 1MB DRAM library - SdFat - SD card with software SPI support

Conclusion

Building this system was a journey through computing history. CP/M's clean architecture - the separation of BIOS, BDOS, and CCP - made it possible to port a 45-year-old operating system to completely alien hardware in a matter of days. For a fascinating look at CP/M in its heyday, check out the Computer Chronicles episode on CP/M from 1984.

The Z80 doesn't know it's being fed clock pulses by an Arduino, that its memory is dynamic RAM on a shield, or that its "floppy drives" are files on an SD card. It just executes its opcodes, one after another, exactly as it did in 1978.

And somewhere in that stream of opcodes, a small mailbox waits west of a white house, just as it has for over four and a half decades.

>open mailbox
Opening the small mailbox reveals a leaflet.

>read leaflet
"WELCOME TO ZORK!

ZORK is a game of adventure, danger, and low cunning. In it you will
explore some of the most amazing territory ever seen by mortals..."

Welcome to the underground empire. The password is nostalgia, and the treasure is understanding how elegantly simple these early systems really were.