6502 FORTH, Part 4: Basic I/O

14 minute read

Published:

I’ve been posting about creating a Forth interpreter for a 65c02, and at this point I’m pretty close to something that could be tested. However, I still need one more piece of infrastructure before I can begin writing and testing my Forth interpreter: some way to communicate with a user.

My 6502 build includes a 65C22 VIA and 65C11 ACIA for general I/O and serial connections, respectively. The first step was to write some way to receive data over a serial connection. I found the datasheet at this link, and started to figure out how to get my computer to interact with the world around it.

Serial Connections

The 65C51 ACIA adapter is a pretty nifty little device–it even includes an onboard baud rate generator (this is why the computer requires a 1.8432 MHz external clock). The Symon simulator I’m using maps the ACIA to addresses $8000-8004, and examples from their Github use a baud rate of 9600. The actual code to make this work is significantly less daunting than I expected.

ACIA = $8000
ACIA_RX = ACIA         ; high here allows reading, low allows writing
ACIA_TX = ACIA
ACIA_STATUS = ACIA+1   ; Goes low when an interrupt occurs (?)
ACIA_COMMAND = ACIA+2
ACIA_CONTROL = ACIA+3  ; resets 

reset_acia:
  pha
  ; ACIA setup
  lda #$00
  sta ACIA_STATUS       ; writing anything to status resets the chip
  lda #$0B
  sta ACIA_COMMAND
  lda #$1E
  sta ACIA_CONTROL
  pla
  rts

This initial implementation is based off of Michael Billington’s original post. There’s quite a few random hardcoded values in here, so let’s break them down based on the information in the datasheet.

Starting with reset_acia, we push and pull to the a register at the beginning and end of the subroutine to preserve whatever was in the A register when we started. In the middle, we load three seemingly random values into three registers. The ACIA has four main addresses accessible to our computer: R/TX, STATUS, COMMAND, CONTROL. The first of these is the register that receives and transmits data depending on if we read or write to it (resp.). We’ll come back to that register later.

The STATUS register is the simplest: the value at this address contains status of the ACIA device. Writing any value to this address resets the chip, which is why we start by writing $00 to it. The other bits of note in this address are bits 3 and 4, which correspond to the the receiver/transmitter data registers being full/empty (resp.). This will come in handy in our read/write methods later on.

Next is the COMMAND register. This register, when written to, changes how the ACIA handles data. Bits 5-7 control parity check controls, bit 4 changes the receiver between normal and echo mode, and bits 0-3 change if interrupt signals are sent when sending data (and if so, how they’re sent). I had initially planned to use these interrupt signals to trigger interrupts when data is ready to be read/written, but unfortunately I found out from this forum post that the 65C51 ACIA has a hardware bug that renders all the interrupts essentially unusable. Instead, we’ll have to disable them and rely on the 65C22’s clock to trigger interrupts. For the COMMAND register, we’re loading the value $0B = 0000 0111, corresponding to “Parity checks disabled”, “Normal Receiver mode”, “All interrupts disabled, receiver/transmitter enabled”.

Finally, we have the CONTROL register, which controls how the ACIA works. Bit 7 controls number of stop bits, bits 5-6 control the length of each data word, bit 4 controls the clock source for generating baud rate, and the lower 4 bits control the baud rate. We load a value of $1E = 0001 1110, corresponding to “1 stop bit”, “8 bit data word”, “Baud rate generator”, “9600 baud”. If we wanted to process at 19200 baud, we would instead set the value to $1F.

Once we’ve set up the ACIA, we can write some methods to read data from the chip:

acia_read:
  lda #$08
acia_rx_full:
  bit ACIA_STATUS       ; check to see if buffer is full (bit 3 is 1 if not empty)
  beq acia_rx_full
  lda ACIA_RX
  rts

Remember from before that bits 3 and 4 correspond to if the chip is ready to read/write. A 1 in bit 3 corresponds to a value of 0000 0100 = $08, so we do a bitwise comparison between $08 and the STATUS register to see if the data are ready to read. If they are, we read the value. beq acia_rx_full will branch if the result is 0, meaning if the STATUS register does not have a 1 in the third bit. If it does, the result is not zero and we can read the value out of the read register.

The method to send data is very similar, just with an additional operation to store the character in the a register while we check if we’re ready to write.

acia_send_char:
  pha
  lda #$10              ; %0001 0000, bit corresponding to write ready
acia_tx_full:
  bit ACIA_STATUS
  beq acia_tx_full
  pla
  sta ACIA_TX
  ;jsr delay_once_via    ; this delay is recommended to fix 65C51 transmit bug
  rts

Notice there’s a method commented out here. As mentioned before, a hardware bug prevents us from using the 65C51’s internal clock, which is unfortunate. Instead, I set up the 6522 VIA to send continuous interrupts at a timespan equivalent to the 6551’s baud rate. Without waiting a little bit after each transmission, we can get corrupt data or duplicated bytes. Unfortunately, the Symon emulator does too good a job replicating the performance of the machine, and so they don’t seem to have this error at all. As a result, I’m going to skip this for now and revisit it in the future when we get to hardware.

Testing the Serial Connection

It’s time for everyone’s favorite program: the Hello World script. For now, I’m just going to hardcode a text string to print, then have the program print the string. After this, it’ll echo back any input given over the serial connection.

main:
  jsr reset_acia
  jmp printstringtest
mainloop:
  jsr acia_read
  sta $00
  jsr acia_send_char
  lda $00
  jmp mainloop

printstringtest:
  ldy #0
nextchar:
  lda message,y
  beq mainloop
  jsr acia_send_char
  iny
  jmp nextchar

message: 
  .aasc "Hello, world!"
  .dsb 1,0

Most of this code is pretty simple–we just initialize the ACIA chip, jump to a method to print out “Hello, world!”, then return and start echoing back characters. The message initializer stores an ascii string corresponding to the text string, then .dsb 1,0 fills the byte 0 immediately after the string. This makes the string null-terminated, so we can iterate over it by using lda message,y until the value loaded in a is zero. This can be checked with beq, and once we reach the end of the string we jump out of the loop to mainloop. At this point, we just alternate between reading and sending characters forever.

I also wanted to start writing code as if it’s going into the ROM chip, since eventually I’d like this to be loaded into the system from ROM. To do this, I’d need to compile my code so that the file is exactly 16 KiB. Additionally, I’d like to be able to set the beginning of the program and correctly link nmi and irq handlers to the right addresses ($FFFA and $FFFE, with $FFFC-D the start of the program).

This caused some issues, since xa lacks a .org command and has very sparse documentation. I found a post from the original developer of xa, but unfortunately it was from 2005 and the syntax has been updated since then. However, I was able to figure out the following syntax based on the docs and the 2005 post:

* = $C000

; ... code goes here

.dsb $fffa-*, $ea
.word nmi
.word reset
.word irq

This begins by initializing the program counter to $C000, which is the start of the ROM address space. The original suggestion was to use .byte $fffa-*, $ff, but .byte is no longer a command in xa. However, we can instead use .dsb, which specifies a data block to be filled with a particular value. The first part of the command, $fffa-*, specifies that the size of the fill block should be $fffa minus the value of the program counter, meaning the width of the space between the end of the code and $fffa. The second value is the value to fill the blocks with, which in this case is just $ea (which corresponds to a nop instruction). The final three lines fill the last 3 bytes of the program space with addresses for nmi, reset, and irq.

The complete test code looks like this:

* = $C000

main:
  jsr reset_acia
  jmp printstringtest
mainloop:
  jsr acia_read
  sta $00
  jsr acia_send_char
  lda $00
  jmp mainloop

printstringtest:
  ldy #0
nextchar:
  lda message,y
  beq mainloop
  jsr acia_send_char
  iny
  jmp nextchar

message: 
  .aasc "Hello, world!"
  .dsb 1,0

.dsb $fffa-*,$ea
.word nmi
.word ROMSTART
.word irq

ROMSTART is just a constant set to $C000, which I’ve defined in a constants file.

If we compile and hexdump this, we can see that it is actually 16KiB, occupying memory from addresses $0000-3FFF. These will be mapped to $C000-FFFF, so this is all working correctly.

After this, we just need to run the program!

Adding Text Buffers

This setup is not bad, but it’s far from ideal. Since the maximum transmission rate of the ACIA is 19,200 baud, with this current implementation we’re limited to sending one character every ~0.53ms. With my current setup, the computer will have to wait between each of these send/receives, meaning that it can’t do any work in the meantime. It would be nice if we could just precalculate what we want to send over serial, and then process it when the ACIA has time. Likewise, if we send input, we don’t want the computer to have to process it immediately character by character.

To solve this, I’m going to set up a pair of buffers for input/output. I’ll write outgoing text to the $7Dxx memory page, and I’ll write incoming text to the $7Exx page. This way, I can store a string of up to 255 characters prior to having to send it out, and I can receive up to 255 characters before I have to process them with code. My constants file is getting a little large, so I’ll include it here:

;;;
;;; Constant values imported in other files
;;;

VIA = $8000
ACIA = $8800
DELAYCOUNT = $02F9  ; 7 before program counter (4 byte number)
ROMSTART = $C000

IPT1 = $4C
IPT2 = $4D
OPT1 = $4E
OPT2 = $4F

IPTBUFF = $7E00
OPTBUFF = $7D00

I’ve also defined four pointers, corresponding to the start and end of each buffer. One pointer will be incremented as the buffer is read/written to, and the other as the buffer is written/read from. This results in a minor modification to the ACIA software, including two new helper functions:

; These methods control the ACIA's side of read/write
; limited by baud transfer rate, can be blocking
acia_send_char:
  phy
  lda #$10              ; %0001 0000, bit corresponding to write ready
  ldy OPT2              ; load current place in buffer
acia_tx_full:           ; this loop will eventually be removed
  bit ACIA_STATUS       ; we can just check for full ACIA buffer on interrupts 
  beq acia_tx_full
  lda OPTBUFF,y         ; write value in current place in buffer 
  inc OPT2              ; then advance pointer
  sta ACIA_TX
  ;jsr delay_once_via   ; this delay is recommended to fix 65C51 transmit bug
  ply
  rts

; same as acia_send_char, just for reading
acia_read_char:
  phy
  lda #$08              ; %0000 1000, bit corresponding to read ready
  ldy IPT2
acia_rx_full:
  bit ACIA_STATUS       ; check to see if ACIA buffer is full
  beq acia_rx_full      ; this loop will eventually be removed
  lda ACIA_RX
  sta IPTBUFF,y
  inc IPT2
  ply
  rts

; These methods control writing/reading to the buffer
; can be done at anytime, non-blocking
acia_wbuff_char:
  phy
  ldy OPT1              ; load offset location of buffer 
  sta OPTBUFF,y         ; store value in write buffer
  inc OPT1              ; increment offset pointer
  ply
  rts

acia_rbuff_char:
  phy
  ldy IPT1
  lda IPTBUFF,y
  inc IPT1
  ply
  rts

Because we’re now using a buffer to read/write, the test program changes slightly.

* = $C000

main:
  jsr reset_acia
  jmp buffstringtest
mainloop:
  jsr acia_read_char    ; read to buffer from ACIA
  jsr acia_rbuff_char   ; read from buffer to A register
  jsr acia_wbuff_char   ; write from A register to buffer
  jsr acia_send_char    ; write from buffer to ACIA
  jmp mainloop

buffstringtest:
  ldy #0
buffnextchar:           ; write string to buffer
  lda message,y
  beq sendstring
  jsr acia_wbuff_char
  iny
  jmp buffnextchar
sendstring:             ; send string from buffer
  lda OPT1
  cmp OPT2
  beq mainloop
  jsr acia_send_char
  jmp sendstring

message: 
  .aasc "Hello, world!"
  .dsb 1,0

.dsb $fffa-*,$ff
.word $00
.word ROMSTART
.word $00

Shown below is an example output, along with the memory map for page $7E to illustrate that the buffer is getting filled up. I typed a bunch of j’s to verify that the buffer correctly rolls back to $7E00 from $7EFF.

Next Steps

I now have a way to send and receive data from the computer! I know there will be problems down the line with hardware bugs that I can’t replicate on the simulator, but I’m confident I’ll be able to troubleshoot that when I get there. Additionally, we don’t have any way to use arrow keys, backspace, or return/newline, but that can be handled later when I write the interpreter interface. For now, it’s finally time to start testing the Forth interpreter.