Build a traffic light! (Part 3)

Build a traffic light! (Part 3)
Photo by Noah Dominic / Unsplash

In part 2 we wrote a simple BBC BASIC program to drive our traffic light model. In this part we'll do the same thing in z80 assembler. There's no compelling reason to do this apart from the fun of writing z80 assembler - it's not like we can benefit from any sort of performance improvement.

GPIO

GPIO in assembler is arguably even easier than in the BBC BASIC version: we use OUT (PORTA_CTRL),A to write to the port command register, and OUT (PORTA_DATA), A to write to the port data register.

Timers

Timing is a little more complicated in assembly language, as we don't have the convenient TIME variable that BBC BASIC provides.

What we can do though is use the same mechanism that BBC BASIC uses to drive TIME forwards: use the MicroBeast's User Interrupt facility.

bios_1_7.inc has all the info we need:

; CALL MBB_SET_USR_INT - Set or query the user interrupt. 
; The specified routine will be called after keyboard polling, 
; every 60th of a second. The shadow register 
; set is selected before the call (EXX), and AF is preserved. 
; The routine should RETurn normally. 
; Interrupt routines survive warm reboots, but no special measures are 
; taken to ensure the memory they occupy is preserved.
;
;   Parameters: 
;       HL = Address of user interrupt routine, or zero to disable. 
;       Call with 0FFFFh to query the current value
;   Returns:
;       The address of the current user interrupt routine, or zero 
;       if none is configured.
;
MBB_SET_USR_INT         .EQU    0FDC7h
☠️
An important note about the User Interrupt: always remove your handler when your program is finished. Otherwise you'll get weird random crashes at some point in the future!

Not much more to say than that really, the z80 code is a straight port of the BBBC BASIC version. Here it is in all its glory:

; TRAFFIC.Z80 - Traffic-light demo for the Feersum MicroBeast (CP/M 2.2)
;
; Z80 / sjasmplus port of TRAFFIC.BAS.  Drives three LEDs (red / amber /
; green) from Z80 PIO port A and sequences them like a UK traffic light.
;
; Delays are timed off the firmware's 64 Hz "user interrupt" rather than a
; busy loop: an ISR decrements a tick counter every tick, and the main loop
; spins on that counter while also polling the keyboard so the user can quit
; at any time.  On exit we ALWAYS unhook the ISR - otherwise the firmware
; keeps calling our (now-stale) handler and the next program load corrupts
; it mid-interrupt.
;
; Build (sjasmplus 1.23.0):  sjasmplus TRAFFIC.Z80   -> TRAFFIC.COM
;
; Ant, 2026-06-27

DOS             EQU     5               ; CP/M BDOS entry
MBB_SET_USR_INT EQU     0FDC7H          ; firmware: install 64Hz user int (HL=handler)

; Z80 PIO port A (LEDs are active-low: a 0 output bit lights the LED)
PORTA_CTRL      EQU     12H
PORTA_DATA      EQU     10H

; Lamp bit masks (port A bits 0..2)
RED             EQU     1               ; bit 0
AMBER           EQU     2               ; bit 1
GREEN           EQU     4               ; bit 2
RED_AMBER       EQU     RED OR AMBER
ALL             EQU     RED OR AMBER OR GREEN
OFF             EQU     0

; Convert seconds -> ticks at 64 Hz (all of these are exact integers)
TICKS_PER_SEC   EQU     64

                DEVICE  NOSLOT64K
                ORG     0100H

start:
;
; Hook the 64 Hz user interrupt, then EI - delivery needs CPU interrupts on,
; and nothing has enabled them for us.
;
                LD      HL,tickisr
                CALL    MBB_SET_USR_INT
                EI
;
; PROCsetup - configure PIO port A: mode 3, bits 0..2 outputs, no port-A IRQ.
; (Port A only; the system 64 Hz tick is on port B and is left untouched.)
;
                LD      A,0CFH          ; mode 3 (bit control)
                OUT     (PORTA_CTRL),A
                LD      A,0F8H          ; B[2:0] = outputs (0 = output)
                OUT     (PORTA_CTRL),A
                LD      A,03H           ; disable port-A interrupts
                OUT     (PORTA_CTRL),A
;
; Lamp test, then lights out.
;
                LD      DE,msg_test
                CALL    puts
                LD      A,ALL
                CALL    light
                LD      BC,5*TICKS_PER_SEC
                CALL    delay
                JR      C,done

                LD      DE,msg_out
                CALL    puts
                LD      A,OFF
                CALL    light
                LD      BC,2*TICKS_PER_SEC
                CALL    delay
                JR      C,done
;
; Main sequence - red, red+amber, green, amber, repeat.
;
loop:
                LD      A,RED
                CALL    light
                LD      BC,3*TICKS_PER_SEC
                CALL    delay
                JR      C,done

                LD      A,RED_AMBER
                CALL    light
                LD      BC,2*TICKS_PER_SEC
                CALL    delay
                JR      C,done

                LD      A,GREEN
                CALL    light
                LD      BC,7*TICKS_PER_SEC
                CALL    delay
                JR      C,done

                LD      A,AMBER
                CALL    light
                LD      BC,3*TICKS_PER_SEC
                CALL    delay
                JR      NC,loop
;
; Exit: lights off, unhook the ISR, return to CCP.
;
done:
                LD      A,OFF
                CALL    light
                LD      HL,0            ; 0 = "no handler"; firmware null-checks it
                CALL    MBB_SET_USR_INT
                LD      DE,msg_bye
                CALL    puts
                RET                     ; back to CP/M

;
; light - drive the LEDs.  A = lamp bit mask; outputs (NOT A) because the
;         LEDs are active-low.  Destroys A,F.
;
light:
                CPL
                OUT     (PORTA_DATA),A
                RET

;
; delay - wait BC ticks (64 Hz) while polling the keyboard.
;         Returns CARRY SET if a key was pressed (caller should exit),
;         CARRY CLEAR if the full delay elapsed.  Destroys A,F (+ BDOS trashes
;         everything, but we keep all live state in memory).
;
delay:
                DI
                LD      (tickctr),BC    ; arm the countdown
                EI
delay_lp:
                LD      C,6             ; BDOS 6 = direct console I/O
                LD      E,0FFH          ; FF = read key, no echo, no wait
                CALL    bdos
                OR      A
                JR      NZ,delay_key    ; A != 0 -> a key is waiting
                DI
                LD      HL,(tickctr)    ; atomic read of the ISR's counter
                EI
                LD      A,H
                OR      L
                JR      NZ,delay_lp     ; not yet zero -> keep waiting
                OR      A               ; A=0 here -> carry clear (timed out)
                RET
delay_key:
                SCF                     ; signal "key pressed, please exit"
                RET

;
; puts - print a $-terminated string at DE.  Destroys A,F (BDOS trashes rest).
;
puts:
                LD      C,9
                CALL    bdos
                RET

;
; bdos - BDOS call with C=function.  Saves IX/IY across the call (CP/M
;        preserves no registers; we shield only the index regs we care about).
;
bdos:
                PUSH    IX
                PUSH    IY
                CALL    DOS
                POP     IY
                POP     IX
                RET

;
; tickisr - 64 Hz user interrupt handler.  Decrements tickctr toward zero
;           (stopping at zero).  Firmware preserves registers around the call,
;           and we touch only the main set, so a plain RET is correct.
;
tickisr:
                LD      HL,(tickctr)
                LD      A,H
                OR      L
                RET     Z               ; already zero - nothing to do
                DEC     HL
                LD      (tickctr),HL
                RET

msg_test:       DEFB    "Lamp test...",0DH,0AH,"$"
msg_out:        DEFB    "Lights out...",0DH,0AH,"$"
msg_bye:        DEFB    "Bye.",0DH,0AH,"$"

tickctr:        DEFW    0               ; ticks remaining in current delay

end:
                SAVEBIN "TRAFFIC.COM",start,end-start
                END

This is SJASMPlus syntax: if you have that tool, you can assemble it yourself. Otherwise feel free to try the TRAFFIC.COM binary in the repository.

In part 4 as a special bonus we will re-implement this program again, but this time in the ultimate language....Forth!