Atari Lynx programming tutorial series:
In part 13 we covered UART and serial communication. Then in part 14 we had a look at the timers inside of the Lynx. Both parts referred to interrupts as an important bit of functionality. Now it is time to dive deeper into interrupts and use their power to take your Lynx games to the next level.
Before we dig in deep into the Atari Lynx’s interrupts, you should have a good understanding of how interrupts function at the processor level. This will be a detailed overview, one that holds true for any 6502 processor, not just Mikey.
Backgrounder on 6502 family processors’ interrupts
There is an excellent write-up on 6502 interrupts by Garth Wilson over at wilsonminesco.com. I suggest you read this when you want to know the nitty gritty details. I will provide a higher level overview of what is important. Garth’s article fills in the gaps and the deeper details.
During normal operation the 6502 executes instructions by evaluating the opcodes at the current program counter (PC). It fetches the instruction located there and spends a couple of processor cycles performing the work. The PC is updated to point to the next instruction. Usually this is the next instruction in memory, but it can be somewhere else in case of branching (e.g. BEQ, BMI) or jumping (JMP, JSR). This mode of operation simply follows the flow of your code.
Normal operation can be interrupted by special events that occur. In most cases this is the hardware telling the processor that something important has happened, such as input that is available (keyboard, serial IO), or a timer that has expired and wants you to give you a chance to handle that. These special events are appropriately called interrupts and they trigger a specific sequence of action by the processor. The 6502 has two kinds of interrupts:
- IRQ: Interrupt ReQuests, normal interrupts
- NMI: Non-Maskable Interrupts, more important interrupts than the IRQ interrupts.
Both IRQ and NMI are essentially the same interrupts, except for an important distinction: normal interrupts can be “ignored” (also called masked), while NMI interrupts cannot be masked. This means that you can specify you do not want the IRQ interrupts to actually interrupt you, for example if you are in a critical piece of code execution, while NMI can never be suppressed.
The processor has an interrupt pin for IRQ and NMI that are (optionally) connected to the hardware that can signal an interrupt. The most critical hardware will use the NMI pin, while other hardware uses the IRQ pin.
Both the IRQ and NMI signal are high by default and will trigger when it goes low. These lines can be edged or level sensitive. Usually IRQ lines are level sensitive and will keep firing as long as it stays low. The NMI line on the other hand is edge sensitive in most cases and only triggers on a falling edge to avoid having it triggered over and over again. That would be bad since these cannot be ignored, so it will cause havoc.
The 6502 interrupt sequence
Whenever an interrupt occurs, be it an IRQ or NMI, the 6502 executes a sequence to stop the current execution of code, and render control for the handling of the interrupt to an interrupt service routine (ISR). The ISR location is determined from what is called a vector. Essentially a vector is a memory location where the processor can find the jump address for the reset, IRQ or NMI routine. There are three vectors in the 6502:
|NMI||Vector to NMI ISR||$FFFA (low byte) and $FFFB (high byte)|
|Reset||Vector to address of reset routine||$FFFC (low byte) and $FFFD (high byte)|
|IRQ||Vector to IRQ interrupt service routine||$FFFE (low byte) and $FFFF (high byte)|
The current instruction that was executing is finished. As soon as that is done, the current program counter (address of next instruction) is pushed onto the stack. First the high byte, then the low byte. Next, the status register (processor status or PS) is also pushed onto the stack. Finally the IRQ or NMI vector is fetched from their respective addresses and the processor will continue execution at the vector addresses.
You can think of the interrupt and the vectors as JSR to the addresses specified at $FFFE and $FFFA. Something like JSR ($FFFE) for IRQ and JSR ($FFFA) for NMI. It is not exactly the same, because the PS is also placed onto the stack and the exact PC value is somewhat different, plus you return from a JSR with an RTS, but with a RTI (ReTurn from Interrupt) for a IRQ or NMI. Other than that the two are comparable to a certain extent.
An “Hello World” Interrupt Service Routine
A really simple interrupt service routine might look like this:
F000 INC $1337
It could have been as simple as RTI, but that would have been essentially a stubbed out handler to would return as soon as it is called without actually doing anything. Useful only when you want to have an empty ISR. Instead the example above shows a counter at address $1337 is increased every time that the ISR is executed.
You need to put the address of the ISR ($F000 in this case) in the IRQ interrupt vector during startup, or an interrupt will jump off into unknown byteland. The wiring-up boils down to a bit of code like this:
STA $FFFE ; or STZ $FFFE for short
STZ $1337 ; Initialize the data register
which puts the bytes $00 and $F0 at the low and high byte of the IRQ vector at $FFFE and $FFFF respectively. We will talk about the SEI and CLI instruction in a moment.
Writing an interruptor in CC65
The CC65 compiler allows you to create an interrupt service routine through assembler code. There is some special syntax required to wire your code to be called when an interrupt fires. Here is a sample that implements the simple handler
.interruptor _handler .proc _handler: near .segment "CODE" inc $1337 done: clc rts .endproc
But wait, what’s this? There is no RTI at the end and a mysterious CLC instruction. The reason is that interruptor handlers are wired together by the CA65 assembler. Each interruptor address is stored in an table. Whenever an IRQ occurs every handler in the table is called in order of priority. Each handler can indicate whether the other handlers still need to be called. It is the carry flag that conveys this intention. When the carry flag is cleared, the calling of other handlers should continue. If set, the handler tells the runtime that it has hcompletely handled and cleared the interrupts, and calling the others is not needed anymore.
A priority is specified as follows:
.interruptor _vbl, 15
The number indicates the priority. A higher value gives the handler a higher priority. The default value is 7. You can read some more at the CC65 documentation wiki on the .interruptor control command.
There is always a VBL handler created if you use the TGI library. TGI uses a handler to perform a swap of the video buffers at the right time, so no screen tearing occurs. Screen tearing would happen when the swap is performed midway during the drawing of the current buffer. The VBL interrupt is an excellent moment to do it, hence the choice for a VBL handler.
Here are a couple of strategies for building your handlers:
- Strategy 1: Create a big handler that checks for each and every interrupt source. This would keep the handler table small and give a single point to have your own interrupt handling logic.
- Strategy 2: A handler per interrupt type. It will give small and concise handlers that are easy to maintain. It implies a little more overhead of multiple jumps, but it is disputable if that is noticeable or significant.
- Strategy 3: Override the TGI handler by your own. Given that you would need to recompile the TGI library to alter its VBL handler, you could specify your handler with a higher priority and return with a SEC call before the RTS.
Don’t interrupt me
There are occassions when you do not want interrupts to occurs. These are some typical moments when it is inconvenient to be disturbed:
- An ISR is already executing
Once an ISR is executing it can be impractical to have a new IRQ come in and trigger a new ISR from the current ISR. That would make it a bit like the movie Inception, where dreams occur within dreams within dreams within dreams… You get the picture.
- You are manipulating the vector address values
When you have changed either the low or high byte but not the other, there is a very brief moment where the vector address is invalid. Should an interrupt request come in at that particular time, it will probably lead to unwanted and unexpected behavior.
- Bootstrapping or initialization code is running
At this time things may not have been properly setup for the program and data registers to start executing ISR code.
The 6502 processor has a bit flag in the processor status called I (for Interrupt Disabled) that determines whether an IRQ is acknowledged or not. NMI interrupt requests are unaffected by the bit, because they are unmaskable and cannot be suppressed or ignored.
When the I bit is set, no IRQ requests are responded to. You can influence the bit with two instructions:
SEI ; Set Interrupt Disable flag: masks IRQ interrupts
CLI ; Clear Interrupt Disable flag: listen to IRQs again.
By default new IRQ interrupts are ignored during an ISR. So, you do not have to call SEI at the start of your ISR code, be it IRQ or NMI triggered.
Remember that NMI interrupts are always acknowledged, whether the I flag is set or not. However, when an NMI or IRQ interrupt service routine is executing, you can choose to call CLI and let new IRQ requests come through, should they occur.
You might want to clear the source of the interrupt before calling CLI to accept new IRQs or RTI to return from an interrupt. Since the IRQ line is level-sensitive it is important to note that when the level is still low, a new IRQ will immediately fire. Luckily, the Lynx has edge-sensitive IRQs, so you don’t have to take that into account, except for UART interrupts as these are level sensitive. We will talk about this later.
Interrupt sources in the Lynx
The Lynx has 8 distinct hardware sources that trigger input. The hardware is always a timer. And, since the Lynx has 8 timers, an interrupt can come from each (and all) of those sources.
A quick recap of the timers that the Mikey holds:
|Timer #||Description||Relation to interrupt|
|0||Horizontal blank (HBLANK or HBL).||Fires when the end of a “scanline” has been reached.|
|1||General purpose timer 1|
|2||Vertical blank (VBLANK or VBL)||Fires interrupt after all lines on a screen have been drawn. Useful for doing work that is screen critical (such as the moment of swapping screen buffers).|
|3||General purpose timer 3|
|4||UART RX or TX related||Doesn’t fire at timer expiration, but rather at the moment when data has arrived in receive buffer or when transmit buffer is empty.|
|5||General purpose timer 5|
|6||General purpose timer 6|
|7||General purpose timer 7|
Each of these timers have an Enable Interrupt bit in their static control register A. Only when this bit is 1 (enabled) will the interrup fire at the moment of timer expiration. In code this would look something like this:
#define ENABLE_INTERRUPT 0x80 MIKEY.timer1.control = ENABLE_INTERRUPT | 0x1E; MIKEY.timer1.reload = 255; MIKEY.timer1.count = 255;
The one exception here is the UART timer #4. This timer’s interrupt does not fire at the timer expiration. The timer’s purpose is to generate the baud rate for UART and it will expire at a steady pace to transfer the single bits of data, plus some extra such as the start, stop and parity bit. Lots of expirations that do not really matter. The relevant moment to fire an interrupt for UART is when data has arrived in the receive buffer, or when there is no more data to be sent (if the transmit buffer runs empty). For that, you need to set the TX and RX Interrupt Enable flags (TXINTEN and RXINTEN) to 1 for enabled.
MIKEY.serctl = TXINTEN | RXINTEN | PAREN | RESETERR | TXOPEN | PAREVEN;
This enables both receive and transmit interrupts, besides the normal settings for enabling even parity while resetting any errors and switching the UART to open collector.
In summary, the timers will generate IRQs when they are configured to do so. The timers will always run, no matter what type of code is executing. Once expired they will generate an interrupt, but this will only cause the call of the ISR through the IRQ vector when the I flag of the processor status register is not set.
Inspecting the sources
When an IRQ occurs it is often necessary to determine the source of the interrupt. It could be any one of the 8 timer sources or a combination of them. Each of the timers has a interrupt flag associated with it. Each and every interrupt flag that is set will cause the IRQ signal to be low and raises an interrupt.
The 8 bits of the interrupts flag would fit nicely into a byte, right? Mikey has two special interrupt related hardware registers for that very byte. These are INTRST ($FD80) and INTSET ($FD81).
Their purpose is to allow you to expect and manipulate the sources of the interrupts by looking at the bytes of the value located in each of them. They both hold the same set of bits when you read from either address. The value for INTSET or INTRST has the bits from the interrupt flags in this order: timer 0 at bit 0 up to timer 7 at bit 7. Writing to INTSET and INTRST is a totally different thing.
The INTRST will set interrupt flags to zero when written to. It will set the flags for the bits that are present in the (mask) value you write. It leaves the other bits unaffected.
INTSET will push the values written into it to the interrupt flags. It provides an easy way to reset them all by writing a zero to it (just like writing $FF to INTRST would). On the other hand writing a non-zero value will cause an interrupt flag (or flags) to be set, effectively causing an IRQ indirectly.
The best practice is to read from the INTSET at the beginning of your ISR code. It will get you the bits for the expired timers and serial interrupt. After you have nearly finished your ISR you can write the value from INTSET to INTRST causing those interrupts to be reset. If a new interrupt occurred during the execution of your ISR, the respective bit or bits are unaffected. When the ISR returns to normal code, there is still a bit set in the interrupt flags and a new IRQ will occur. That is probably intented, because you missed a new interrupt and want to handle that as well.
Reading INTSET and writing that to INTRST is usually a good approach.
However, the UART triggered interrupts are level-sensitive, so they will keep triggering unless you clear the source explicitly. Here is an abstract from the Epyx development kit’s documentation on UART and ComLynx:
“7. Unusual interrupt condition.
Well, we did screw something up after all. Both the transmit and receive interrupts are ‘level’ sensitive, rather than ‘edge’ sensitive. This means that an interrupt will be continuously generated as long as it is enabled and its UART buffer is ready. As a result, the software must disable the interrupt prior to clearing it.
Another example: HBL and VBL interrupts
A more complete example is one where we do some effects based on horizontal and vertical blank (HBL and VBL) interrupts. The goal is to change the color of the black pixel each scan line, which creates the banded effect on screen. It is as simple as increasing the red value at $FDB0 (BLUERED0). The difficult part is that this has to be done for every scanline.
By now we know that the HBL occurs when timer 0 expires. It has its interrupt enabled by the boot rom initialization. That part is covered. This is the interruptor we need to include in our code:
.interruptor _hbl .include "lynx.inc" .export _hblcount _hblcount: .byte $00 .proc _hbl: near .segment "CODE" lda INTSET and #TIMER0_INTERRUPT beq done inc RBCOLMAP+0 inc _hblcount done: clc rts .endproc
The bolded statements are of most interest. Taking it from the top, an interruptor is declared to point to the handler routine called _hbl. There is also an exported variable called _hblcount that serves as a counter for the total number of HBL interrupts. The CODE segment loads the interrupt flags from INTSET and checks whether the flag for timer 0 (the HBL timer). If so, this handler is called for an HBL IRQ and it can continue by increasing the red value (note that it will also increase the blue value every 16th HBL) and the HBL counter. If not, the two increase operations are skipped. Finally, we clear the carry flag to indicate that other handlers should still execute.
The other handler for vertical blanks (VBL) interrupts is fairly similar. It does a check for timer 2 instead of 2 and will
- Increase a frame counter variable
- Reset the Red/Blue value to zero, so we always start the new frame with the same value
- Store the last HBL count, so we can see how many HBL interrupts fire per frame.
.interruptor _vbl .include "lynx.inc" .export _framecount .export _lasthblcount .import _hblcount _framecount: .byte $00 _lasthblcount: .byte $00 .proc _vbl: near .segment "CODE" lda INTSET and #TIMER2_INTERRUPT ; Check for VBL timer beq done inc _framecount ; stz RBCOLMAP+0 ; Reset Red/Blue value to create steady image lda _hblcount sta _lasthblcount stz _hblcount done: clc rts .endproc
When you look at the screenshot above you can see a couple of remarkable things. First, there are 105 horizontal blanks. This is in accordance with the documented 3 scanlines of blank time every frame. Plus, you can see that the HBL for the 3 invisible lines are right after a VBL interrupt. The first band is 3 pixels smaller than the others, which can only happen if the HBL counter was already running 3 lines before the first visible line is drawn. This observation was already shared by TailChao at the AtariAge forum.
This time we dove into interrupts for the 6502 processors and looked at some Lynx console specific details.
In the meantime, some additional reading on interrupts in the Lynx is available in the Epyx documentation: