Build a traffic light! (Part 3)
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 0FDC7hNot 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
ENDThis 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!
