Reference: Exceptions & Interrupts
While the words are used interchangeably and the code to handle them is intertwined, exceptions and interrupts have a difference. The easiest way to think about the difference is exceptions happen inside the processor itself, interrupts happen somewhere else on the circuit board and generally toggle an input pin change.
That is the general case, now lets get to the Nintendo 64 implementation details.
The Exceptions (Inside the MIPS VR4300i):
TLB Modification
TLB Load/Fetch
TLB Store
Address Error Load/Fetch
Address Error Store
Bus Error Fetch
Bus Error Load/Store
SysCall
Breakpoint
Reserved Instruction
Co-processor Unusable
Arithmetic Overflow
Trap
Floating Point
Watch
The Interrupts (Triggered outside the VR4300i):
MIPS VR4300i
Software 0
Software 1
RCP - INT0
PIF - INT2
Timer - INT7
RCP (the following trigger the MIPS VR4300i - RCP interrupt above)
SP
SI
AI
VI
PI
DP
So what can we use any of these for?
TLB Exceptions
TLB is generally more useful for server/desktop operating systems. It can be used on the N64, for example to map level code or data to the same location in RAM.
Address and Bus Errors and Co-processor Unusable
Address and Bus Errors are probably more common when the MIPS processor is used in a workstation with add-in cards that are not as predictable
Co-Processor Unusable can be used to optimize debug exceptions that save and restore all of the registers, by only enabling the Co-Processor after it's first used. Then any debug exceptions prior to first use only save and restore Scalar registers.
Syscall
This is a hardware feature used to provide very low level software services, for example threading.
Breakpoint & Trap & Watch
These can be hooked in order to create a debugger.
Reserved Instruction
This is used in some unlicensed MIPS processors to re-implement Patented instructions.
Arithmetic Overflow and Floating Point
Some assembly instructions can cause an exception if the values or result are outside of a specified range.
Software 0 and Software 1 Interrupts
These are a feature for programmers to use, for example when using a global message loop, there might be some messages that must be processed immediately.
Timer interrupt
This is just a count up timer that starts counting from power on, set a timer value and when the value is reached an interrupt fires. A simple usage could be a timeout for displaying a logo or startup screen, but there are many other possible uses.
PIF interrupt
The Reset button triggers the PIF to initiate a Reset of the system. This interrupt gives the game code 1/2 of a second to respond and cleanup before the actual reset occurs.
RCP Interrupt also referred to as the MI Interrupt
The N64 specific (vs MIPS) Interrupts come from the RCP/MI
The exact interrupt reason is determined by reading an RCP Cause Register
The following interrupts are mostly 'DMA complete' type notifications
SI Serial Interface Interrupt (RCP/MI)
Serial Interface is for Controllers and EEPROM (DMA Complete)
AI Audio Interface Interrupt (RCP/MI)
RDRAM to Audio Buffer
Buffer ready for more data
VI Video Interface Interrupt (RCP/MI)
Vertical Line ### has been set to TV Interrupt
Ideal usage is to swap double buffered screens
Initiate Controller input, because of it's consistent timing it's better than the Timer.
PI Peripheral Interface Interrupt (RCP/MI)
Copy code/data to/from RDRAM and Cartridge Interface
DMA Complete
SP Reality Signal Processor Interrupt (RCP/MI)
RDRAM to/from IMEM
DMA Complete
DP Reality Display Processor Interrupt (RCP/MI)
Not sure what actually triggers this yet.
RDRAM to/from DMEM - DMA Complete
Sync Full - Instruction triggers this interrupt
That is an overwhelming list which makes this an 'advanced topic'. With a good code library it's not too bad.
Usage
The code to use an exception/interrupt is fairly clean and easy with the available 'bass' library. The first part to understand is how to use the resulting library code.
arch n64.cpu
endian msb
fill 1052672
origin $0000'0000
base $8000'0000
include "../LIB/N64.INC"
include "../LIB/N64_INTERRUPT.INC"
include "N64_HEADER.ASM"
insert "../LIB/N64_BOOTCODE.BIN"
Start:
N64_INIT()
ScreenNTSC(320, 240, BPP32, $A0100000)
interrupt_init()
interrupt_set_vblank($200,interrupt_vblank_handler)
//
// Other Initialization Code
//
Loop:
//
// Game Loop
//
j Loop
nop
interrupt_vblank_handler:
interrupt_register_save()
//
// Your Code Here
//
interrupt_register_restore()
jr ra
nop
include "../LIB/N64_SYS.S"
include "../LIB/N64_INTERRUPT.S"
Many of the lines above are standard code that provide some anchors to understanding what is different.
The first line of this discussion are:
include "../LIB/N64_INTERRUPT.INC"
The include files is a collection of constants and macros to assist in quickly using these code libraries. Please take a look at the files directly as there are many things supported that are not directly mentioned here.
interrupt_init()
interrupt_set_vblank($200, interrupt_vblank_handler)
The initialization seems pretty normal, the next line sets an "event handler" or "function pointer" for the event we want our code to respond to. The vblank macro takes the Horizontal Line that should trigger the vblank interrupt.
interrupt_vblank_handler:
interrupt_register_store()
//
// Your Code Here
//
interrupt_register_load()
jr ra
nop
Just beyond the Game Loop is our custom code handler, fairly normal stuff. Be sure to use the macros shown for saving and restoring the Registers. Since there are a lot of things that are best done every frame, this is a good starter Interrupt to handle.
Things to do in the VI Interrupt:
Read from Controller
Swap Frame Buffers
Fill Audio Buffer
include "../LIB/N64_SYS.S"
include "../LIB/N64_INTERRUPT.S"
include "../LIB/N64_INTERRUPT_HANDLER.S"
This last code section is kind of interesting because most of the code performing the work is in these files. These may include several KB of code and therefore it's a little more important about where these are laid out in the final ROM file. Generally I just prefer them near the end. If you actually care where they end up check out the macro fixed_gap(label, 0xdistance) explained below.
Inside the Custom Interrupt Handlers
Interrupt handlers may not need very many registers, while complex debugging handlers should save all registers. If the handler can be written using just k0 and k1 doesn't touch 'ra', skip using the register store/ load macros. Interrupt handlers may use some or all of the following macros:
interrupt_register_scalar_store() and interrupt_register_scalar_load() (recommended)
interrupt_register_float_store() and interrupt_register_float_load()
interrupt_register_COP0_store() and interrupt_register_COP0_load()
interrupt_register_misc_store() and interrupt_register_misc_load()
The following macros should be used so that when returning from the exception, the desired instruction is executed. Without using these macros, the Branch Delay Slot may be ignored which may cause an infinite loop.
interrupt_epc_retry()
Rerun the instruction or Branch instruction if in a Branch Delay Slot
interrupt_epc_continue()
Execute the hardware specified Instruction, respecting the Branch Delay Slot
Conclusion - N64 bass Game Developer
The above code pattern should be pretty easy to follow and maintain. It's part of a larger pattern used in the rest of the library being generated for this series. All of the library code is compiled in so there isn't anything hidden or that can't be studied. Continue reading for a deep dive on the guts of the Exception/Interrupt Code.
Exception/Interrupt - Deep Dive
Creating an Exception/Interrupt Handler (EIH) has a lot of constraints and challenges especially when your trying to include even a little bit of flexibility. The following goals were considered:
Code size - 4 slots of maximum 32 Instructions each
Flexibility
Minimum of 'jump'ing around
Readability
To be honest I generally feel that code comments should be used when code is taking advantage of side effects, or other uncommon or non-obvious things are happening. In most cases good variable names and good use of white space are enough. The fact that I've decided to write 1000+ words about this code suggests that the code is probably related to madness whether it's good or evil is to be determined.
The other advantage to this document is to force me to think through all of the code paths.
MIPS VR4300i Exceptions and Interrupts
Exceptions and interrupts are hardware events that allow software to respond to them. Generally the handler routines are small and return quickly to the main process. It really is a single core processor managing the most basic threading model.
User code is processing
A hardware event occurs!
The CPU executes the memory mapped handler
Which calls the User's handler
Handler completes and execution continues in user code.
Out expectation of handler developers is the following:
Save current registers to RAM as needed
Handler code here
Set EPC with Macro
Handler code or here
Re-load registers from RAM
The following table shows the Memory area that is reserved for our Exception Handlers. For other MIPS processors the Status Register BEV bit = 1 changes these addresses, on the N64 at power on the BEV = 1 and the PIF boot code sets the BEV bit = 0.
Some quick observations about this table:
#1 This is the PIF Address, so no control over the code stored here
#1 The BEV bit doesn't affect this address
#2 N64 operates in Kernel mode
#2 Writing between 0x0000'0000 and 0x7FFF'FFFF
#3 N64 operates in Kernel mode
#3 Writing to unmapped memory between 0x8000'0000 0xFFFF'FFFF
#4 This is the same memory except the non-cached version, if a cache exception occurs the cache cannot be trusted.
#5 Technically there isn't an end defined to this space
#5 There are some values that are stored by IPL code at 0x8000 0300 - 0318
Also note:
Each handler is 0x80 (128) bytes or 32 instructions
4 handlers will occupy a total of 512 bytes of memory
This is small area of memory reserved for the standard exception handlers, but in many cases it's enough for a simple handler or to test a condition and exit. In our case we will attempt to reduce the game developers code and mental overhead in working with the N64's Interrupts/Exceptions.
Code Concept
The goals of the library handler code are:
Give the developer the option to handle the error
If not handled by developer, 'fail gracefully' or implement simple handlers
Everything that we change, needs to be changed back before returning from the handler
Avoid using the Stack because it may of been the problem
To achieve this goal with the limited instruction count the easiest way is to use a 'jump table' or 'function pointer' concept. In this case it's easier if we keep the Jump table values inside the Exception block. It helps because the addresses are guaranteed to be in the same range 0x8000 0000 which will minimize instruction counts and also keeps related data near the code, in addition there are some ranges in the exception handler block that are available.
The first step is to write assembly code that will generate an exact 512 byte file.
arch n64.cpu
endian msb
origin $00000000
include "../LIB/N64.INC"
define DEBUG_SHOW_STATIC_VALUE(yes)
align(8)
base $80000000
_inthandler_overlay:
_tlb_handler:
// tlb_handler code here
fixed_gap(_tlb_handler, 0x80)
_xtlb_handler:
// xtlb_handler code here
fixed_gap(_xtlb_handler, 0x80)
_cache_handler:
// cache_handler code here
fixed_gap(_cache_handler, 0x80)
_default_handler:
// default_handler code here
fixed_gap(_default_handler, 0x80)
fixed_gap(_tlb_handler, 0x200)
At first glance this doesn't look much like assembly language and you would be right, but upon closer inspection the structure is there exactly as described in the table above. The real work is the combination of the define statement and the fixed_gap(label,gap) macro.
Since it's hard to develop optimized code on the first pass, it would be reasonable to change the last 0x200 to 0x300, since there is some available space. There are important values in RAM starting at 0x300 so don't exceed that. When we are done, the hope is to be either at or under the 0x200 value.
This fixed_gap() macro will actually force a compile error if we exceed our specified size. The define shows an alternate byte pattern that makes this type of space more obvious when viewed in a hex editor.
// fixed_gap(label, 0xdistance)
macro fixed_gap(base, gap) {
if (pc() > {base} + {gap}) {
error "Gap is greater than specified size."
}
while (pc() < {base} + {gap}) {
if {defined DEBUG_SHOW_STATIC_VALUE} {
db 0xAB
} else {
db 0x0
}
}
}
Compiling the code at this point generates a file of exactly 512 bytes of 0xAB not very exciting but helpful as we progress.
Default Handler - Starting at the end
The 'default handler' is the catch all. It can also save us duplicating some code, by sending the other handlers to some of the sections of the default handler. Since it's the last handler it can technically run a little long until we optimize (important system values are written to RAM at 0x8000'0300).
The first and last section of the default handler are setup / clean up then exit from the interrupt handler.
_default_handler:
lui k0, $8000
sw ra, _interrupt_ra(k0)
lui ra, $8000
// More code coming here!
_interrupt_return_handler:
mfc0 k0, SR
andi k0, 0x01
mtc0 k0, SR
eret
The MIPS calling convention includes k0 and k1 that are for use in kernel mode (Interrupts/Exceptions run in Kernel mode). This is a critical assumption that we will use to our advantage.
In this code we can see that k0 is immediately modified without saving it's current value, also since the 'ra' register has hard coded behavior that we can use, we save it's value off before doing anything else. Since we will not be using the 'ra' behavior until the end of the handler, we will use it as a constant.
Reminder that the 'ra' behavior is when the 'and link' feature of the jump instruction is used (jal, jalr).
Before we return, it's safe to assume that global interrupts were enabled in order to get here so let's enable them before leaving. The 'eret' instruction does most of the work of restoring the processors state (move EPC to the PC) and restarting execution.
The common standard is to save all of the processor registers to RAM so they can be used by interrupt handlers. In this case I'll use a different approach with the goal of reducing the instruction count, remember only 32 per memory mapped handler (handler developers are unlimited). Basically our handler code will only use the k0, k1 and ra registers. If the handler developers choose to use only those registers they can also save a lot of instructions (approx 384 instructions, i.e. performance improvement) between the save and restore especially if multiple interrupts are triggered per frame.
Limiting the registers used in our code, can give some flexibility back to the handler developer. See above for optional macros to save off some of the registers.
_interrupt_custom_handler:
beq k0, r0, _interrupt_return_handler
nop
jalr k0
nop
The custom handler code block, assumes that the k0 register is populated with a memory address for the function pointer to handle the Exception. If either are 0 execution should proceed to the _interrupt_return_handler.
Jump Table Entries
The Jump Table Entries can be located in many places through out this code and later we will force them into very specific places but I just want to take a minute and talk about the code and how it works.
The standard way to do this would be:
In our code they are wrapped in a macro like this:
The macro definition:
jump_entry1:
dw 0x00
jump_entry1:
static_value(DEBUG_SHOW_STATIC_VALUE, dw 0x0, db "jpe1")
macro static_value(conditional, default, alternative) {
if {defined {conditional}} {
{alternative}
} else {
{default}
}
}
This is very similar to what we saw in our fixed_gap() macro, including using the define 'DEBUG_SHOW_STATIC_VALUE'. Most of the exception handler code is position dependent and it's easier when we can identify the location of each value in our hex editor. When all of the values are 0x00 they all 'run' together and it's hard to tell if they are all where you expected.
Remember: before executing this code, comment out the 'define DEBUG_SHOW_STATIC_VALUE' line.
TLB (refill) Handler
The next step is to create the TLB (refill) Handler, other TLB Exceptions are handled by the default handler. In our case we are going to check if the developer wants to handle this exception themselves or ignore it. It could also be handled by a debug handler which is assigned by the developer.
lui k0, $8000
lw k0, tlb_handler_ptr(k0)
lui k1, $8000
sw ra, _interrupt_save_ra(k1)
j _interrupt_custom_handler
nop
tlb_handler_ptr:
static_value(DEBUG_SHOW_STATIC_VALUE, dw 0x0, db "TLBH")
_interrupt_save_ra:
static_value(DEBUG_SHOW_STATIC_VALUE, dw 0x0, db "ra ")
This code starts to get interesting because of the use of a side effect of the bass assembler. The normal way to write the first 2 lines would be:
At first glance this code doesn't look that different? The difference is that 'la' is a psuedo instruction that becomes 2 instructions something like:
Unfortunately we don't have the extra instruction space, Technically we could count the actual position of the variable _interrupt_save_ra and create our own 2 line version like the above code:
BUT we would loose the flexibility of moving or adjusting that variables location. Instead we take advantage of the 'bass' assemblers truncation behavior. We loaded the high bytes of k0 then let the compiler truncate the _interrupt_save_ra variable to the last 2 bytes for us. If bass were to change it's behavior we would have a problem, but at this time bass is a very stable program so we can trust this (at least for now). We can see the same technique is used to get the _interrupt_save_ra location.
Since the beq depends on the value of k0 and that value is in RAM, we need to wait 2 instruction cycles before using it. It works nicely that we can save off 'ra' while waiting for k0 to be loaded.
With 'ra' saved and k0 loaded with our function pointer jump to the handler code covered above.
XTLB (refill) Handler
The XTLB comes into play in 64-bit situations in other words not applicable on the N64, but since it's easy we'll do essentially the same handler so copy and paste the same code, remember to rename the tlb_handler_ptr to start with an 'x' and change the "TLB " to "XTLB" in the macro. Also we don't need the 'ra' memory location duplicated.
la k0, tlb_handler_ptr
sw ra, 0(k0)
lui k0, $8000
ori k0, $0040
sw ra, 0(k0)
lui k0, $8000
sw ra, $0040(k0)
lui k0, $8000
lw k0, xtlb_handler_ptr(k0)
lui k1, $8000
sw ra, _interrupt_save_ra(k1)
j _interrupt_custom_handler
nop
xtlb_handler_ptr:
static_value(DEBUG_SHOW_STATIC_VALUE, dw 0x0, db "XTLB")
Cache Error Handler
For now we will treat this one identical to the TLB and XTLB so we can get to the much harder 'other handler'.
lui k0, $8000
lw k0, xtlb_handler_ptr(k0)
lui k1, $8000
sw ra, _interrupt_save_ra(k1)
j _interrupt_custom_handler
nop
xtlb_handler_ptr:
static_value(DEBUG_SHOW_STATIC_VALUE, dw 0x0, db "XTLB")
Function Pointers
There are 17 Exception Types, 2 software interrupts, 6 hardware interrupts, 6 MI Interrupts and 3 function pointers already created.
First let's recognize we have 31 function pointers to store in our space, let's start by grouping them.
17 Exception Types (0 - 15, 23)
8 Interrupts (0 - 7)
6 MI Interrupts (0 - 5)
3 Existing Function Pointers (TLB, XTLB, CACHE)
because we might move these around
The first item that stands out is the largest 'linear' group also has a huge gap in the Exception Types (missing 16-22). If we count carefully the range 16-22 inclusive, is 7 and our "standard" exception handler is 6 instructions. Can we wrap this list around an exception handler?
This process is a personal preference, compromising readability/maintainability with efficiency.
Starting inside the tlb_handler move the tlb_handler_ptr and _interrupt_save_ra to another code block. I moved them to be inside the _cache_handler block.
Move the xtlb_handler_ptr to the same space.
Create the 0 - 15 Exception Function Pointers inside the _tlb_handler block
To "jump the gap" for number 22 the 0 - 15 need to be right up against the _xtlb_handler method.
Open the output file in a hex editor, hopefully you used the static_value() macro and you are looking for the description field for #15 which I called "FPE " with a quick count I can see there is room for 10 instructions or pointers before the xtlb_handler code.
Copy the macro fixed_gap(_tlb_handler, 0x80) and paste after the _tlb_handler code, then change the value to be 0x1C. Compile and view that the function pointers all moved down by 1 function pointer.
With a little counting and trial and error I found the desired value to be 0x40
After the _xtlb_handler code add a fixed_gap() macro and our Exception #23 lines of code
Set the label to be Exception Handler Zero location, then the value to 0x5C
When viewed in a hex viewer it should now be clear that there is room for 1 more instruction in the _xtlb_handler
While the process above is fairly detailed, after a few tries it's not to bad and the macro will output errors if there is a mistake.
Next I took the Int0-Int7 function pointers and placed them after the _tlb_handler code and before the Exception pointers. Using the macro I placed a 4 byte gap on either side of them.
Next I moved the tlb_handler_ptr, xtlb, cache and ra save locations to after the Exception 23 function pointer.
All that's left is the six MI Interrupts, these should fit in the same _xtlb_handler block, I suggest using the fixed_gap() macro to create some "blank" space between the different types, or use it to make 'labels' in the compiled code.
Other Handler
This one has a lot going on! We need to handle 31 different situations, using only 32 instructions of which 10 are already used!
I love to write tight optimized code the first time but reality is you have to prove that it works first, then make it small. To do that we need more "breathing room". Looking in the Hex Editor we can see there is still some space in the _cache_handler code block. Let's move our "common" handlers (_interrupt_custom_handler, _interrupt_return_handler) there.
The Memory Value Map for the Exception address space is as follows
ASM Instruction
Function Pointer
Unused
Interrupt/Exception Causes