At the weekend I wanted a way to receive serial data, and I don't have any UARTs, so I chose to do it through a 6522. I searched around but only found examples of bit-banging, which I didn't want to do - so I wrote some code to do it using the shift register, posted below. It uses CB2, CB1, the shift register, and Timer 2. Some benefits of this approach are low CPU cost and avoiding using up general purpose pins on the VIA; a drawback is that it is sensitive to interrupt response time.
The idea is quite simple - the TTL-level active-high serial RX line is connected to pin CB2, and we get an interrupt when it goes low for the start bit. We disable that interrupt temporarily, and trigger the shift register under T2 control to read in the bits, having set specific values on T2 to wait long enough for the first bit, and then the right amount between bits. The CPU can get on with other things for a while, and gets another interrupt when the shift register has read 8 bits. At this point we turn off the shift register, read out the byte, and re-enable the CB2 interrupt ready for the next byte.
My VIA is clocked at 16MHz, so for 115200 baud I need 139 ticks between bits. Higher baud rates would probably work but I haven't tried them; equally, slower VIA clocks would also work well, as 139 ticks is much more than is needed for stability I think.
This was fairly simple to do and has low CPU load - two interrupts per byte received. It is however sensitive to interrupt response time, especially for the CB2 interrupt, and this would get worse at higher baud rates. My system doesn't currently use interrupts for anything else, but if this was a problem then you could connect RXD straight to the CPU's /NMI pin and manually ignore those interrupts when they occur during the byte. Or just get a UART.
Note that the bytes arrive with their bits in the wrong order, and serial_getchar has to reverse them as it reads them out of the buffer. It would be possible to efficiently reverse two bytes at once, if there's another waiting in the buffer, but I didn't handle that yet.
Code:
; IRQ handling
;
; Currently the only IRQs used are for serial input. VIA's CB2 is connected to our TTL-level
; active high serial receive line. The start bit causes an interrupt, then the VIA's shift
; register is used to shift in the bits, causing another interrupt to read the byte and reset
; ready for the next one.
;
; The byte is placed in a circular input buffer.
;
;
; More low-level details:
;
; After CB2 goes low for the start of the start bit, we need the first shift register sample
; to be taken about 1.5 baud periods after the interrupt. CB1 will be driven high at first,
; then toggled each time T2 expires. After the first half-cycle, remaining half cycles will
; be equal to whatever we leave in T2's low latch, which needs to be half of BAUD_TICKS which
; itself is the baud period converted into I/O clocks.
;
; The time until the first sample is BAUD_TICKS plus whatever we initially load into T2. To
; sample in the middle of each bit, we want to load BAUD_TICKS as the first period - this
; causes us to wait an entire baud period before CB1 goes low, and then as usual it will wait
; a further half baud period before it goes high, in the middle of the bit.
;
; Due to interrupt latency we may need to subtract some time from this first half-period
; though, and that's IRQLATENCY_TICKS. We can also overcompensate here in order to sample
; the data stream earlier in the bit window. This makes us less tolerant of drift, but gives
; a bit more time at the end of the byte to read the shift register, put everything back, and
; be ready for the next one. It also makes us more resilient against a delayed CB2 interrupt
; for whatever reason. Either of these might matter more at faster baud rates.
; Number of I/O clocks per baud bit
BAUD_TICKS = 16000000 / 115200
; Latency before T2 is loaded after start bit begins. This depends on the amount of work
; the IRQ handler does and the ratio between the CPU clock and the I/O clock, and also whether
; IRQs were disabled or an IRQ was already being handled, etc
IRQLATENCY_TICKS = 66 * 16000 / 25175
irq_init:
stz zp_serial_in_head
stz zp_serial_in_tail
stz zp_stray_interrupts
; Disable VIA interrupts and clear CB2 and SR flags
lda #$7f : sta VIA_IER : sta VIA_IFR
; Disable shift register
lda #VIA_DEF_ACR : sta VIA_ACR
; Define CB2 interrupt to occur on falling edge, other Cxx interrupts don't matter
lda #$20 : sta VIA_PCR
; Enable CB2 interrupt and SR interrupt
lda #$8c : sta VIA_IER
cli
rts
serial_getchar:
.(
phy
bit zp_serial_error ; set overflow flag to bit 6
ldy zp_serial_in_tail
; Wait for the head to be ahead of the tail
nodata:
cpy zp_serial_in_head : beq nodata
iny : sty zp_serial_in_tail
lda serial_in_buffer,Y
; Swap the order of bits
ldy #4
swaploop:
asl : ror zp_scratch
asl : ror zp_scratch
dey : bne swaploop
ply
lda zp_scratch
rts
.)
&irq:
.(
; It should be either a CB2 interrupt or a shift register interrupt
pha
lda VIA_IFR
lsr : lsr : lsr : bcs serial_interrupt_sr ; check for SR interrupt (bit 2)
lsr : bcs serial_interrupt_cb2 ; check for CB2 interrupt (bit 3)
; otherwise it's a stray interrupt for some reason - increment a counter, signal an error and return
inc zp_stray_interrupts
lda #$f1 : sta DEBUGPORT
pla : rti
serial_interrupt_cb2:
lda #BAUD_TICKS-IRQLATENCY_TICKS-2 : sta VIA_T2CL ; set T2 larger, to wait an extra half-bit before the first sample
stz VIA_T2CH ; start T2 right away as enabling the SR doesn't seem to do this consistently
lda #VIA_DEF_ACR+$04 : sta VIA_ACR : bit VIA_SR ; enable the shift register and issue a dummy read to start it
lda #$08 : sta VIA_IER ; disable the CB2 interrupt while we read the data
lda #(BAUD_TICKS/2)-2 : sta VIA_T2CL ; set T2 to half the bit gap, minus two, for the rest of the byte
pla : rti
serial_interrupt_sr:
.(
lda #VIA_DEF_ACR : sta VIA_ACR ; disable shift register
lda #$08 : sta VIA_IFR ; clear CB2 flag
lda #$88 : sta VIA_IER ; enable CB2 interrupt
lda VIA_SR ; read the byte
phy
; Increase the head pointer and store the data, unless it would conflict with the tail
ldy zp_serial_in_head : iny
cpy zp_serial_in_tail : beq full
sty zp_serial_in_head
sta serial_in_buffer,Y
ply : pla : rti
full:
; The input buffer is full, discard the data and signal an error
lda #$f6 : sta DEBUGPORT
lda #$ff : sta zp_serial_error
ply : pla : rti
.)
.)