• Skip to main content
  • Skip to header right navigation
  • Skip to site footer
makerhacks-logo

Maker Hacks

Ideas, news & tutorials for makers and hackers – Arduino/Raspberry Pi, 3D printing, robotics, laser cutting, and more

  • Home
  • About
  • 3D Printing
  • Laser Cutting
  • YouTube
  • Free Arduino Course
  • Recommendations
  • Contact

DIY Z80 Output to LCD: Homebrew Retro Computer Part 3

You are here: Home / Electronics and Hardware / DIY Z80 Output to LCD: Homebrew Retro Computer Part 3
FacebookTweetPin
Author: Chris Garrett

Our home made Z80 DIY computer finally gets to communicate with the outside world!

Or in this case, a 16×2 LCD screen.

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.

  1. The Z80 CPU has the control lines, data and address lines connected to the Pico as before.
  2. 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.
  3. 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.

Arduino Uno and 1602 LCD pin diagram

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!

Category: Electronics and HardwareTag: arduino, raspberry pi, z80
FacebookTweetPin

About Chris Garrett

Marketing nerd by day, maker, retro gaming, tabletop war/roleplaying nerd by night. Co-author of the Problogger Book with Darren Rowse. Husband, Dad, ?? Canadian.

Check out Retro Game Coders for retro gaming/computing.

☕️ Support Maker Hacks on Ko-Fi and get exclusive content and rewards!

Previous Post:Z80 using Pico as ROM/RAM Pico as Z80 RAM & RAM: Homebrew Retro Computer Part 2
Next Post:Arduino SID Player: Play C64 Music on Your UnoArduino SID player

Sidebar

  • Facebook
  • Twitter
  • Instagram
  • YouTube

Join the Newsletter

Sign up and get a free Arduino course + more!

Subscription Form

Recently Popular

  • xTool S1 Review
  • xTool P2 Review (first look)
  • IKIER K1 Pro Max Review
  • Gweike Cloud Review
  • How to choose the right 3D printer for you
  • Glowforge Review – Glowforge Laser Engraver Impressions, Plus Glowforge Versus Leading Laser Cutters
  • Flux Ador
  • Prusa i3 Mk3S Review
  • Best 3D Printing Facebook Groups
  • xTool D1 Pro Laser Cutter Review
  • Elegoo Mars Review – Review of the Elegoo Mars MSLA Resin 3D Printer
  • Glowforge ‘Pass-Through’ Hack: Tricking the Front Flap of the Glowforge with Magnets to Increase Capacity
  • How to Make a DIY “Internet of Things” Thermometer with ESP8266/Arduino
  • Wanhao Duplicator i3 Review
  • IKEA 3D Printer Enclosure Hack for Wanhao Di3
  • Creality CR-10 3d printer review – Large format, quality output, at a low price!
  • 3D Printed Tardis with Arduino Lights and Sounds
  • Anet A8 Review – Budget ($200 or less!) 3D Printer Kit Review
  • Make your own PEI 3D printer bed and get every print to stick!
  • Upgrading the Wanhao Di3 from Good to Amazing
  • How to Install and Set Up Octopi / Octoprint
  • Creality CR-10 S5 Review
  • Glowforge Air Filter Review
  • Thunder Laser Review

30 Days to Arduino

Arduino Tutorials
  • 3D Printing
  • CNC
  • Electronics and Hardware
  • Laser Cutting
  • News and Reviews
  • Software and Programming

arduino budget cad cnc diode laser glowforge Hacks Ideas laser cutter linux Makes making python raspberry pi resin Reviews technology tips xTool Lasers

.

Maker Hacks Blog Copyright © 2026 · All Rights Reserved · Privacy Policy