Our breadboard DIY Z80 computer has only done it’s computing silently. We had to spy on what was going on by lighting up LEDs using the control pins or monitoring the pins using a modern microcontroller (the Pico also happened to be emulating the Z80’s memory!).
Traditionally, however, the Z80 would be the processor for a computer that had the ability to input and output, be that via a serial interface and a terminal, or a built-in keyboard and a monitor or TV.
Let’s look at how the Z80 handles IO now. First, take a look at it in action to see what we are building here:
DIY Z80 Computer and LCD In Action
As you can see in the video, the Pico is still operating as the ROM, RAM and the Clock, and is outputting to the serial terminal over UART to report what it is doing.
Once we are happy that everything is working, though, we can speed everything up by quite a lot by stopping the print statements and removing most of the delays that were necessary.
Instead, our final output will be via a two line 1602 LCD display, though it does still need some small delays to not overwhelm the screen which I found outputs gibberish if you hit it too hard.
Z80 + Memory + IO
Here is the arrangement I ended up with.
- The Z80 CPU has the control lines, data and address lines connected to the Pico as before.
- Our Pico, as previously, also generates the clock signal, which I wired to a Red LED because now I am also connecting the Clock to our Arduino Uno.
- As well as having the Clock line as an input, the Uno also needs to check for Z80 IOREQ going LOW (enabled), and then read from the data lines from the Z80.
We only have one IO device so we are not even looking at the address lines, but a computer would ordinarily have logic to decide which device is being selected using the bottom 8 bits of the 16 bit address, giving 256 such opportunities.
Home computers back in the day, such as the Sinclair Spectrum, combined all that logic into a ULA chip. My first DIY computer like this used a GAL22V10 programmable logic chip for the same reason.
Arduino Uno and 1602 I2C LCD
Rather than add even more wires to the mess, I went with an I2C LCD, which only requires two wires in addition to power and ground.
The Arduino community has a whole bunch of libraries that make it easy to control these guys, and if you purchase from Adafruit or another branded store they will likely have their own. For my cheap part I went with the LCD-I2C.h that can be added right from the Arduino IDE.
As hinted earlier, we could optionally have used D3 as a “chip enable” if we wanted to have multiple IO devices but I forgo that in my project.
I am sure the code can be optimized a great deal, and I am sad to report that I could not get the Interrupt to work which would have run a function any time the IO line went low (“FALLING”).
attachInterrupt(0, output_lcd, FALLING);
If you know why I couldn’t get it to work, I would love to know. My guess was something to do with using I2C because I have used interrupts before ok.
Instead I added logic to wait to the end of the clock pulse to check if the IO line is active (0), in which case run our LCD function.
Output_LCD() reads the data lines (D4-D11) and converts the result into a binary number which we then convert into a text character. We are controlling both ends of this communication so I didn’t add any validation – we just trust whatever is sent is understandable by the LCD!
Finally, we wait for the IOREQ line to become disabled (HIGH). If we did not do this then we would be continually checking for data, which would make the LCD get filled with multiples of each character we were sent.
#include <LCD-I2C.h>
#define IOREQ 2
#define CLOCK 12
int data_pins[8] = { 4, 5, 6, 7, 8, 9, 10, 11 };
LCD_I2C lcd(0x27, 16, 2); // Default address of most PCF8574 modules,
// change accordingly
char read_data() {
char data = 0b00000000;
data |= (digitalRead(4) << (0));
data |= (digitalRead(5) << (1));
data |= (digitalRead(6) << (2));
data |= (digitalRead(7) << (3));
data |= (digitalRead(8) << (4));
data |= (digitalRead(9) << (5));
data |= (digitalRead(10) << (6));
data |= (digitalRead(11) << (7));
return data;
}
void output_lcd()
{
char data;
char buffer[16];
delay(10);
data = read_data();
sprintf(buffer, "%c", data);
lcd.print(buffer);
while(digitalRead(IOREQ) == 0)
{
digitalWrite(LED_BUILTIN, 0);
}
digitalWrite(LED_BUILTIN, 1);
}
void setup() {
// Init the LCD
lcd.begin();
lcd.display();
lcd.backlight();
pinMode(LED_BUILTIN, OUTPUT);
// Pins
for (int p = 0; p < 8; p++) {
pinMode(p, INPUT);
}
// This will fire our IRQ handler any time the pin goes low
pinMode(IOREQ, INPUT);
// Sync with the clock
pinMode(CLOCK, INPUT);
// Wanted to do this but it didn't work
// attachInterrupt(0, output_lcd, FALLING);
// Clear the screen and output message
lcd.clear();
lcd.print("Z80 Started");
lcd.setCursor(0, 1);
lcd.print("Waiting ...");
digitalWrite(LED_BUILTIN, 0);
delay(500);
lcd.clear();
}
void loop() {
if (digitalRead(CLOCK) == 1)
{
while(digitalRead(CLOCK) == 1) {}
if(digitalRead(IOREQ) == 0)
{
output_lcd();
}
}
}
Faster Pico RAM MicroPython Code
Now we have a “display”, we do not need to be sending all the Print statements to the UART or monitor using a terminal emulator, but they are still handy to have in the code for if we want to debug later.
My solution here is to check if we are debugging before printing. There are likely much better ways to do this, including Python modules dedicated to the task, but this works ok.
Setting freq=1000 gives a reasonable output speed while still being able to see the blinky lights do their thing. If you wanted faster then we wouldn’t be using a Pico as the memory or using MicroPython!
import sys
from ansi import *
import machine
from machine import Pin, Timer
from utime import sleep
debugging = False
machine.freq(270000000)
if debugging: print(machine.freq())
is_IO = False
is_reading = False
is_writing = False
this_address = 0
this_data = 0
# Address Pins
address = []
address_pins = [2,3,4,5,6,7,8,9,10,11,12,13,14,15]
for pin_number in address_pins:
address.append(Pin(pin_number, Pin.IN))
# Data pins
datavalues = []
data_pins = [16,17,18,19,20,21,22,26]
for pin_number in data_pins:
datavalues.append(Pin(pin_number, Pin.OUT))
# Write and Read signals
Clock = Pin("GP28", Pin.OUT)
Clock_Timer = Timer()
WR = Pin("GP0", Pin.IN, Pin.PULL_UP)
RD = Pin("GP1", Pin.IN, Pin.PULL_UP)
# IO Request
IOREQ = Pin(27, Pin.IN, Pin.PULL_UP)
# LED status light
led_pin = Pin("LED", Pin.OUT)
# Clock Ticks
tick = 0
# ROM/RAM
ram_memory = [
0x3E, 0x48, #LD A,48H ("H")
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x45, #LD A,45H ("e")
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x4C, #LD A,4CH
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x4C, #LD A,4CH
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x4F, #LD A,4FH
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x20, #LD A,20H
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x57, #LD A,57H
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x4F, #LD A,4FH
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x52, #LD A,52H
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x4C, #LD A,4CH
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x44, #LD A,44H
0xD3, 0x0A, #OUT (0AH),A
0x3E, 0x21, #LD A,21H
0xD3, 0x0A, #OUT (0AH),A
0xC3, 00, 00, #JP 0000
0x76 #HALT
]
if debugging: print("Booting")
# Get the address pins
def get_address():
global tick, is_IO, is_reading, is_writing, this_address, address, this_data, datavalues, ram_memory
this_address = 0
for address_pin in reversed(address):
this_address = (this_address << 1) + address_pin.value()
#if debugging: print(this_address)
return this_address
# Set the pins to correct values
def set_pins(pins, data):
global tick, is_IO, is_reading, is_writing, this_address, address, this_data, datavalues, ram_memory
for pin in pins:
this_bit = data & 0x01
if(this_bit == 1):
pin.on()
else:
pin.off()
data = (data >> 1)
#if debugging: print(data)
# Output the Z80 values
def print_status():
global tick, is_IO, is_reading, is_writing, this_address, address, this_data, datavalues, ram_memory
# Check if IO
is_IO = (IOREQ.value()==0)
# When RD is low then z80 is reading
is_reading = (RD.value()==0)
# Check if is writing
is_writing = (WR.value()==0)
# Get the address and the data for it
this_address = get_address()
this_data = ram_memory[this_address]
#if debugging: print(top)
if debugging: print('{:8}'.format(tick), end='')
if debugging: print(bold + " IO: " + reset, abs(is_IO),end='')
if debugging: print(bold + " RD: " + reset, abs(is_reading), end='')
if debugging: print(bold + " WR: " + reset, abs(is_writing), end='')
if debugging: print(bold + " Address:" + reset + " {:#018b}".format(this_address), end='')
if debugging: print(bold + " Data:" + reset + " {:#010b}".format(this_data), end='')
if debugging: print(" {:#06x}".format(this_data), reset, end='')
if this_data < 126 and this_data >= 32:
if debugging: print(chr(this_data), end='')
else:
if debugging: print("", end='')
if debugging: print()
#if debugging: print(clearline)
# Write handler
def wr_handler(pin):
global tick, is_IO, is_reading, is_writing, this_address, address, this_data, datavalues, ram_memory
# NOT IO so is writing to memory
if(IOREQ.value()==1):
this_address = get_address()
# WAIT.value(0)
for data_pin in reversed(datavalues):
data_pin.init(mode=Pin.IN)
for data_pin in reversed(datavalues):
this_data = (this_data << 1) + data_pin.value()
try:
ram_memory[this_address] = this_data
except:
if debugging: print("OOPS! Requested to write ", this_address)
# WAIT.value(1)
if debugging: print_status()
tick = tick + 1
# Read handler
def rd_handler(pin):
global tick, is_IO, is_reading, is_writing, this_address, address, this_data, datavalues, ram_memory
#if debugging: print("IO :",IOREQ.value())
# Not IO - Reading from memory
if(IOREQ.value()==1):
this_address = get_address()
for data_pin in reversed(datavalues):
data_pin.init(mode=Pin.OUT)
this_data = ram_memory[this_address]
set_pins(datavalues, this_data)
#if debugging: print(this_data)
if debugging: print_status()
tick = tick + 1
# IO Handler
def io_handler(pin):
global tick, is_IO, is_reading, is_writing, this_address, address, this_data, datavalues, ram_memory
for data_pin in reversed(datavalues):
data_pin.init(mode=Pin.IN)
if debugging: print_status()
tick = tick + 1
# Handle clock ticks
def clock_tick(timer):
global tick
led_pin.value(1)
Clock.toggle()
sleep(0.005)
led_pin.value(0)
tick = tick + 1
if(RD.value()==0): rd_handler(RD)
if(WR.value()==0): wr_handler(WR)
if(IOREQ.value()==0): io_handler(IOREQ)
# Fill rest of the 8kb with NOP
# 8kb = 8181
# 16kb = 16382
for b in range(16382-len(ram_memory)):
ram_memory.append(0)
# Interupts
# WR.irq(handler=wr_handler, trigger=Pin.IRQ_FALLING)
# RD.irq(handler=rd_handler, trigger=Pin.IRQ_FALLING)
# IOREQ.irq(handler=io_handler, trigger=Pin.IRQ_FALLING)
# Wait
#WAIT = Pin("GP15", Pin.OUT)
#WAIT.LOW()
# Boot up procedure
if debugging: print("Memory: ", len(ram_memory))
# Read "ROM" data as binary into memory array
#with open("z80.bin", mode='rb') as file:
# ram_memory = bytearray(file.read())
# Clock input
Clock_Timer.init(mode=Timer.PERIODIC, callback=clock_tick, freq=1000)
What Next?
We have a working computer, in that it can compute and it can output results, but there is more that we can do.
That said, we are at the limit of what is wise on a breadboard (believe me – any time a cat jumped on my desk I have to spend an hour tracing back which wire had wobbled enough to make the whole thing stop working!).
How far can we push this thing? Subscribe and look out for more!