Interrupts
Three things can interrupt the processor, and each of them jumps through a pointer in RAM that you are allowed to change.
| Vector | Address | Fires when |
|---|---|---|
IRQ_PTR | $0300 | A card asks for attention — a key, a serial byte, a timer |
BRK_PTR | $0302 | Your program executes BRK |
NMI_PTR | $0304 | The non-maskable interrupt line is pulled |
At power-on the Kernal points all three at its own handlers. IRQ_PTR goes to the routine that empties the serial port and the keyboards into the ring buffer; BRK_PTR goes to the break report; NMI_PTR goes to an rti.
What the machine's own handler does
Every time a key is pressed, a serial byte arrives, or the timer that SysDelay uses runs out, the processor stops what it is doing and runs the Kernal's interrupt handler. It:
- Checks whether this was a
BRKrather than a hardware interrupt, and if so hands over to whateverBRK_PTRpoints at. - Asks the serial port whether it has a byte; if it has, puts it in the ring buffer, and raises RTS if the buffer is filling up.
- Asks the keyboard card the same about each of its two ports.
- Returns.
None of that is optional — take it out and typing stops working. So the way to add your own is to put yourself in front of it.
Chaining
; Counting interrupts — a handler of your own, hooked in front of the one the
; machine already runs.
;
; Every key that arrives interrupts the processor. This program adds a link to
; the front of that chain, counts what goes past, then unhooks itself and says
; how many it saw. The counting handler is one instruction long on purpose:
; the handler behind it reads the processor status off the stack at a fixed
; depth, so a link in the chain must leave the stack exactly as it found it.
.setcpu "65C02"
.include "6502-VDP.inc"
.segment "CODE"
BasicStartup:
.byte $0A, $08, $0A, $00, $A5, $32, $30, $36, $30, $00, $00, $00
Start:
stz Count
lda #<Prompt
ldy #>Prompt
jsr PrintStr
sei ; never swap a vector with interrupts live
lda IRQ_PTR
sta Chain
lda IRQ_PTR + 1
sta Chain + 1
lda #<CountOne
sta IRQ_PTR
lda #>CountOne
sta IRQ_PTR + 1
cli
Waiting:
jsr Chrin ; carry set = a key was waiting, already echoed
bcc Waiting
cmp #CHAR_CR
bne Waiting
sei ; put the old handler back
lda Chain
sta IRQ_PTR
lda Chain + 1
sta IRQ_PTR + 1
cli
jsr PrintCRLF
lda #<Saw
ldy #>Saw
jsr PrintStr
lda Count
ldx #0
jsr PrintDecU16
lda #<Times
ldy #>Times
jsr PrintStr
jsr PrintCRLF
rts
; The new front of the chain. INC touches no register and pushes nothing, so
; the handler it hands over to finds the stack the way the processor left it.
CountOne:
inc Count
jmp (Chain)
Count: .byte 0
Chain: .word 0
Prompt: .byte "TYPE SOMETHING AND PRESS ENTER", CHAR_CR, CHAR_LF, $00
Saw: .byte "THE PROCESSOR WAS INTERRUPTED ", $00
Times: .byte " TIMES", $00RUN
TYPE SOMETHING AND PRESS ENTER
HELLO
THE PROCESSOR WAS INTERRUPTED 6 TIMES
OKFive letters and an Enter: six characters, six interrupts.
Down a serial line the count comes out lower
Typing at the machine's own keyboard, every character is an interrupt. Over a serial console the Kernal echoes each character back up the line, and while it is doing that it looks at the port itself and keeps anything that has landed — so a character that arrives during the echo of the one before it costs no interrupt at all. Nothing is lost; it simply did not need waking up for. Run this over a cable and the number is three or four, and not the same number twice.
Three things in there are the whole technique.
Save the old vector, install yours, put it back when you are done. A program that returns to BASIC leaving IRQ_PTR pointing into its own code will work perfectly until the next thing loads over it.
sei while you swap. Two bytes have to change, and an interrupt arriving between them jumps through half of each address.
Push nothing. This one is specific to this machine, and it is the one that bites:
Your link must leave the stack exactly as it found it
The Kernal's handler works out whether it was called by BRK by reading the saved status register off the stack at a fixed depth — past the three registers it has just pushed itself. If your handler pushes anything before jumping to it, that arithmetic lands on the wrong byte and every hardware interrupt looks like a BRK, which stops your program with a break report it never asked for.
So a chained handler either uses only instructions that touch no register — inc, dec, stz on absolute addresses are the useful ones — or it saves and restores everything it used before the jmp. It does not leave anything on the stack.
If you want to do real work in an interrupt, the way around that is to replace rather than chain: take the vector entirely, do your work, push and pull as much as you like, and end with rti. You then own the serial port and the keyboards too — everything the Kernal's handler was doing. That is a reasonable thing for a game to do; it is not a reasonable thing to do by accident.
A handler starts in binary, with interrupts off
The processor sets I and clears D as it takes the vector, and it does that after pushing the flags — so decimal mode cannot leak into your handler, nothing else can interrupt it until you say so, and rti hands both flags back exactly as the interrupted code left them. The cld that 6502 handlers open with is not needed here.
Where interrupts come from
The keyboard card's VIA is the busiest source: one interrupt per key, on either port. The serial chip raises one per received byte. The same VIA's timer 1 is what SysDelay counts on, and the clock card can be set to interrupt at a chosen time.
The video card can interrupt too — at the end of every picture, at a chosen line of the screen, or when sprites overflow or collide — though it never does until a program asks it to.
All of them arrive on the same line and land in the same handler, which is why a handler's first job is always to ask each chip "was it you?".
MyHandler:
lda GPIO_IFR ; the card's interrupt flag register
and #GPIO_INT_CB1
beq NotMine
; ... it was, deal with it — reading the port clears the flag
NotMine:
jmp (Chain)A flag you do not clear fires for ever
Every source has to be told it has been dealt with, and how depends on the chip: reading the port clears the keyboard flags, reading the data register clears the serial one, writing the timer's latch clears the timer's. Miss one and the processor spends the rest of its life in your handler.
The video card's interrupts
The card can interrupt at four moments: when it finishes drawing a picture — sixty times a second — when it reaches a chosen line of the screen, when too many sprites share a line, and when two sprites touch. It does none of them until a program sets the matching bit of its IRQEN register.
Talking to the card from a handler has a trap the other chips don't. Every command to the card is two writes, and the Kernal and BASIC are sending those all the time, with interrupts on. A handler that used the same addresses could arrive between the two halves of one, and both would be garbled. So the card has a second set of addresses, port B at $9C02–$9C03, that the Kernal never touches. A handler that uses port B can't collide with anything, and there is no need to turn interrupts off around video work.
This program counts the pictures the card draws in one second:
; Counting pictures: the video card's interrupt, handled on port B.
;
; The card can interrupt at the end of every picture it draws. This program
; asks it to, counts the interrupts for one second, and says how many arrived.
; The handler reads the card through port B, which the Kernal never uses, so it
; can never land between the two halves of a command the Kernal is sending on
; port A.
.setcpu "65C02"
.include "6502-VDP.inc"
.segment "CODE"
BasicStartup:
.byte $0A, $08, $0A, $00, $A5, $32, $30, $36, $30, $00, $00, $00
Start:
jsr KernalVersion ; A = major version
cmp #2
bcc NoCard
jsr VdpInfo ; carry set: no 6502-PICOVDP
bcs NoCard
; Print first. The screen comes up on the first thing printed, and setting it
; up switches the card's interrupts off, so they are switched on after.
lda #<Counting
ldy #>Counting
jsr PrintStr
sei ; two bytes of IRQ_PTR change together
lda IRQ_PTR
sta OldIrq
lda IRQ_PTR+1
sta OldIrq+1
lda #<FrameIrq
sta IRQ_PTR
lda #>FrameIrq
sta IRQ_PTR+1
lda #VC_STAT1 ; port B's status address reads STAT1 ...
sta VC_REG2
lda #VC_REG_WRITE | VC_REG_STATSEL_B
sta VC_REG2 ; ... set through port B's own command address
stz Frames
lda #VC_IRQ_VBLANK ; interrupt at the end of every picture
ldx #VC_REG_IRQEN
jsr VdpWriteReg
cli
lda #100 ; one second: 100 hundredths
ldx #0
jsr SysDelay
lda #0 ; the card stops interrupting ...
ldx #VC_REG_IRQEN
jsr VdpWriteReg
sei ; ... and the Kernal gets its vector back
lda OldIrq
sta IRQ_PTR
lda OldIrq+1
sta IRQ_PTR+1
cli
lda Frames
ldx #0
jsr PrintDecU16
lda #<Pictures
ldy #>Pictures
jmp PrintStr
NoCard:
lda #<NeedsCard
ldy #>NeedsCard
jmp PrintStr
; Every interrupt on the machine comes here first. Only the card's is counted,
; and everything goes on to the Kernal's handler with the stack as it arrived.
FrameIrq:
pha
lda VC_STATUS2 ; STAT1: which of the card's interrupts fired,
lsr a ; and reading it acknowledges them
bcc @chain ; bit 0, the end of a picture, into carry
inc Frames
@chain:
pla
jmp (OldIrq)
Counting: .byte "COUNTING PICTURES FOR ONE SECOND", CHAR_CR, CHAR_LF, $00
Pictures: .byte " PICTURES", CHAR_CR, CHAR_LF, $00
NeedsCard: .byte "NEEDS BIOS 2 AND A 6502-PICOVDP", CHAR_CR, CHAR_LF, $00
OldIrq: .word $0000
Frames: .byte $00RUN
COUNTING PICTURES FOR ONE SECOND
60 PICTURES
OKOr 61, now and then: the second starts part way through a picture.
Four details make it work.
The handler reads STAT1. Port B's status address is set to show status register 1, which says which of the card's interrupts are waiting — bit 0 for the end of a picture — and reading it is also how the handler tells the card it has been dealt with. It clears nothing the foreground might be waiting for.
It pushes A and pulls it again before chaining. That is the rule from the warning above, kept: the stack is as the processor left it when the jmp happens.
The interrupt is switched on after the first thing is printed. Bringing the text screen up — which happens on the first output — sets IRQEN to zero, and so does anything else that calls InitVideo. A program that uses the card's interrupts switches them on after the screen is up, and again after anything that puts text mode back.
VBANK and VINC belong to both ports. This handler never changes them. A handler that does must put them back before it returns, because the Kernal's next command on port A depends on them.
BRK
BRK is a software interrupt, and on this machine it stops the program and reports it:
BREAK $07 AT $0A04
A=2A X=03 Y=00 P=30 S=FBThe address is the BRK itself, $07 is the byte after it — yours to use as a breakpoint number — and the second line is every register as the BRK found it, with S the stack pointer from before the processor pushed anything. Then BASIC's prompt comes back, with the program in memory still there. That makes BRK a breakpoint you can leave in a program and a debugging tool that needs no debugger. Reaching the machine has one to run.
The registers stay behind after the report, for a program or for PEEK:
| Name | Address | Holds |
|---|---|---|
BRK_A, BRK_X, BRK_Y | $0313–$0315 | A, X and Y |
BRK_P | $0310 | The flags |
BRK_SP | $0316 | The stack pointer |
BRK_PCL, BRK_PCH | $0311–$0312 | The address the processor pushed: the BRK plus two |
If a program had taken the video card out of text mode, the report puts the console back first, so it is always readable.
Point BRK_PTR at your own routine and you have caught it instead. The processor has already pushed the status register and the return address, and your handler needs to know that the address is the BRK plus two. A cartridge has to: the report ends by going back to BASIC, and a cartridge has no BASIC to go back to.
WAI, if your assembler will let you
The W65C02S has an instruction that stops the processor until an interrupt arrives. As a way to wait it is both instant to wake from and dramatically cheaper than a polling loop:
cli
Wait:
wai ; sleeps here until something happens
lda Flag
beq WaitIt needs .setcpu "W65C02" and an assembler new enough to accept that — Installing cc65 has the details.
Next: what's fitted.

