Implementing interruptors in cc65

Understanding cc65 uses interruptor routines
Word count: 3091

The cc65 compiler allows you to create an interrupt service routine (ISR) through 6502 assembler code as well as C code. In cc65 any such routine to handle the occurrence of an interrupt is called an ‘interruptor’. There is common and centralized infrastructure in cc65 to handle incoming IRQ requests, regardless of the console or computer that cc65 targets. We will look at this infrastructure as well as the specific details for the Atari Lynx.

Writing an interruptor

There is some special syntax required to wire your code to be called when an interrupt fires. It uses the interruptor keyword followed by the name of a function that does the actual handling. You can define as many interruptor methods as you like. They will be put together and will be executed whenever an interrupt fires. There is no relation between an interruptor and a specific interrupt firing. For the Atari Lynx this means that you can use a single or multiple interruptors for any timer or serial interrupt firing. You will have to check at the start of each interruptor whether the correct interrupt has fired and if it should continue executing.

Here is a short sample that implements a simple handler, incrementing a memory location $1337 every time the interrupt fires:

.interruptor _handler

.proc _handler: near

.segment "CODE"
    inc $1337

done:
    clc
    rts

.endproc

Notice how the interruptor does not have a RTI at the end. The reason is that interruptor handlers are wired together by the ca65 assembler. Each interruptor address is stored in an table in order of priority. Whenever an IRQ occurs, every handler in the table is called in this prioritized order. Each handler can indicate whether the remaining handlers still need to be called. It is the carry flag C that conveys this intention. When the carry flag is not set, the calling of other handlers should continue. You can easily clear C by using the CLC instruction. If the carry flag is set, the handler tells the runtime that it has completely handled and cleared the interrupts, and calling the other interruptors is not needed anymore. In the example there is a CLC instruction just returning with ‘RTS’, clearing the carry flag. This means that the handler wants other handlers to continue.

The priority of an interrupt handler is specified as follows:

.interruptor _handler, 15

The number indicates the priority. A higher value gives the handler a higher priority and makes it being handled before other handlers. The default value is 7.

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.

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

Starting 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) is set. If so, this handler was called because an HBL IRQ occurred. The logic can continue by increasing the red/blue value in the palette using RBCOLMAP and the HBL counter value. Note that increasing the red color through hardware register BLUERED0 at address$FDA0 increases the blue value every 16th increment.

If the interrupt bit for timer 0 was not set, the two increase operations are skipped by a branch to done. Finally, we clear the carry flag with CLC to indicate that other handlers should still execute, as our handler only intends to handle at most one timer.

The other handler for vertical blanks (VBL) interrupts is fairly similar. It does a check for timer 2 instead of 0. In addition it will:

  • Increase a frame counter variable
  • Reset the red/blue value to zero: a new frame always starts with the same color values
  • Store last HBL count to count the number of HBL interrupts firing 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

cc65interruptsexample

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.

Interruptor infrastructure in cc65

Since cc65 targets many different systems, the initialization of the interruptor infrastructure is implemented separately for each. This allows for variations to the implementations. The Atari Lynx has its implementation in irq.s and consists of the initirq and doneirq methods that are expected for each system.

.segment        "ONCE"

initirq:
    lda     #<IRQStub
    ldx     #>IRQStub
    sei
    sta     INTVECTL
    stx     INTVECTH
    cli
    rts

The initialization will set the interrupt vector of the 6502 processor to point to a method IRQStub. It guards setting the low and high byte values for the interrupt vector address INTVECTL and INTVECTH from interrupts by setting the Interrupt Disable flag I using SEI and clearing it at the end before returning. The code for initirq is located in the ONCE memory section, as it is only used at startup and can be discarded afterwards.

The stub for the Interrupt Service Routine has some logic to properly prepare and finish interrupt handling. First, it pushes Y, X and A to the stack before calling the actual interrupt handling routine callirq as a subroutine.

.segment "LOWCODE"

IRQStub:
    phy
    phx
    pha
    jsr     callirq
    lda     INTSET
    sta     INTRST
    pla
    plx
    ply
    rti

After the return from callirq the implementation is specific for the Atari Lynx. It loads the interrupt bits from INTSET and writes these to INTRST. This will clear all bits that where set at the moment of reading in the previous instruction. Be aware that this approach might cause any interrupt bits that were set during the call to callirq to also be reset, potentially missing a handler that already checked for that particular bit.

The stub is put in the LOWCODE segment by convention, although the Lynx does not have banked memory and no real use for code placed low in the available memory range.

The Atari Lynx does not need to release the IRQs, so the doneirq method is a simple return from the call.

.code

doneirq:
    ; as Lynx is a console there is not much point in releasing the IRQ
    rts

When the cc65 compiler finds .interruptor marked methods it will count the number total number of such interruptor routines in a variable called __INTERRUPTOR_COUNT__. A jump vector table __INTERRUPTOR_TABLE__ holds the values for the interruptor method address, so they can be used to as jump locations for the corresponding interrupt. The method callirq found in callirq.s is a piece of self-modyfing code that loops through all entries in the table, starting at the highest and working its way down.

callirq:
        ldy     #.lobyte(__INTERRUPTOR_COUNT__*2)
callirq_y:
        clc                             ; Preset carry flag
loop:   dey
        lda     __INTERRUPTOR_TABLE__,y
        sta     jmpvec+2                ; Modify code below
        dey
        lda     __INTERRUPTOR_TABLE__,y
        sta     jmpvec+1                ; Modify code below
        sty     index+1                 ; Modify code below
jmpvec: jsr     $FFFF                   ; Patched at runtime
        bcs     done                    ; Bail out if interrupt handled
index:  ldy     #$FF                    ; Patched at runtime
        bne     loop
done:   rts

The jmpvec label is used as an offset to modify the jump location for each iteration in loop. The destination of the subroutine is taken from the current entry in the jump table, as it is found by the Y value for the interruptor iteration. Similarly, the index label is used to modify the test condition for doing another iteration in the loop. Whenever Y reaches zero, all interruptors have been called and the interrupt service routine is considered done. The loop is also ended whenever the carry flag is set on returning from the JSR to the modified jump location. The carry flag is a way for the interruptor to signal that processing additional interruptors is needed. When C is clear on return, a new iteration is done. If C is set, the loop terminates. At the beginning the carry flag is cleared, so the default assumes handling should continue, unless indicated otherwise by the interruptor that uses SEC.

The Atari Lynx library implementation has three interruptors:

  1. Clock (update_clock in libsrc/lynx/clock.s with priority 2)
    Driven by the VBL timer, it counts the number of seconds.
  2. Uploader (UploaderIRQ in libsrc/lynx/uploader.s with default priority of 7)
    Inspects serial interrupt flag to check for debug command bytes to initiate object file upload.
  3. Serial driver (ser_irq in libsrc/serial/ser-kernel.s with priority of 29)

C-level interrupts

The cc65 suite also offers the option to handle IRQs with functions implemented in C. During an interrupt it is even possible to call into other C functions from this handler, using a special dedicated stack available during handling. The cc65 infrastructure takes care of saving and restoring the zero page variables, as well as switching to the special stack and back.

The 6502.h header file includes the definitions for a C-level IRQ handler:

/* Possible returns for irq_handler() */
#define IRQ_NOT_HANDLED 0
#define IRQ_HANDLED     1

typedef unsigned char (*irq_handler) (void);
/* Type of the C level interrupt request handler */

The interrupt handler is a single method with a signature as defined in irq_handler: a void method returning an unsigned character with a value IRQ_HANDLED or IRQ_NOT_HANDLED. The method that can handle more than a single interrupt requests if needed. You will need to check for the correct bits in INTSET or INTRST and clear the ones you have handled, just like you would from any other IRQ handler. After validating that the correct interrupts have fired, you can implement the rest of the handling.

The method below will increase a value of irq_counter and changes the red and blue values of the palette RGB color used for background drawing. It first checks for the presence of the VERTICAL_INT interrupt bit in INTSET to and returns if this bit is not set. In that case the interrupt was not because of a time 2 expiring and firing an IRQ. It returns IRQ_NOT_HANDLED

volatile unsigned int irq_counter = 0;

unsigned char vbl(void)
{
    if ((MIKEY.intset & VERTICAL_INT) == 0)
    {
        return IRQ_NOT_HANDLED;
    }

    MIKEY.palette[17] = ++irq_counter;
    return IRQ_HANDLED;
}

Notice how the signature of the method `vbl` matches that of the `irq_handler`. 

cc65 offers two functions `set_irq` and `reset_irq` that allow you to set your interruptor routine. 

```c
void __fastcall__ set_irq (irq_handler f, void *stack_addr, size_t stack_size);
/* Set the C level interrupt request vector to the given address */

void reset_irq (void);
/* Reset the C level interrupt request vector */

The set_irq function takes a pointer to your handling function. In addition it accepts a stack address pointer and the size of the stack. You should specify the stack if you plan on using C function calls during the interrupt handling. The example below shows how you can setup the vbl methods as the interrupt handler, with a 128 byte stack size.

#define IRQ_STACK_SIZE 128
unsigned char irq_stack [ IRQ_STACK_SIZE ];
unsigned char vbl(void);

void initialize()
{
    tgi_install(&tgi_static_stddrv);
    tgi_init();

    set_irq (&vbl, irq_stack, IRQ_STACK_SIZE);
    CLI();
}

If you do not call other C functions, can pass in a NULL pointer and zero stack size to save unused memory.

set_irq (&vbl, NULL, 0);

The implementation for the interruptors in C is located in libsrc/common/interrupt.s. You can inspect it to get a better understanding of the internals. The code fragment below shows an abbreviated version of clevel_irq:

.export         _set_irq, _reset_irq
.interruptor    clevel_irq, 1           ; Export as low priority IRQ handler

.data
irqvec: jmp     $00FF           ; Patched at runtime

.proc   clevel_irq

    ; Is C level interrupt request vector set?
    lda     irqvec+2        ; High byte is enough
    bne     @L1
    clc                     ; Interrupt not handled
    rts

    ; Save our zero page locations
    ; Save jmpvec
    ; Set C level interrupt stack

    ; Call C level interrupt request handler
    jsr     irqvec

    ; Mark interrupt handled / not handled
    lsr

    ; Restore our zero page content
    ; Restore jmpvec and return
.endproc

In short, the use of either set_irq or reset_irq will include the module containing an interruptor clevel_irq. The interruptor takes care of saving zero-page and the current jump vector from the ISR jump table, as well as switching to the IRQ dedicated stack.

After the preparation, the subroutine irqvec is called, which does an immediate jump to the location of your handler function. The call to set_irq made earlier modified the jump address of irqvec. This implies that your method’s return value of IRQ_NOT_HANDLED and IRQ_HANDLED is put in the accumulator and the RTS will return to right after the call to JSR irqvec. The instruction LSR will use the zero or one accumulator value to populate the carry flag C as the indication whether other handlers should be called when the infrastructure continues going through the ISR with its jump table.

The use if a C-level interruptor allows for an easy implementation of an IRQ handler. It does incur additional overhead, so it is recommended to use assembly whenever possible for optimal performance.