An interrupt service routine (ISR) is the code that runs in response to a hardware interrupt. It handles the I/O event that triggered the interrupt — read a character from the keyboard, write a buffer to the disk, acknowledge a network packet — and then returns to the interrupted program.

ISRs look like subroutines but with critical differences:

  • Not invoked by call. The hardware jumps to the ISR when an interrupt fires — there’s no caller to save the return address into a link register the way call does. Where the hardware saves PC and the Processor Status (PS) register depends on the ISA. On Nios II (the model used in this note), they go into special registers ea (exception address, i.e. saved PC) and estatus (saved status). On x86 they’re pushed onto the stack along with EFLAGS. ARM Cortex-M auto-stacks 8 registers including PC and xPSR onto the current stack pointer. The “saves to a register” assumption is a Nios II-ism — don’t carry it over to other ISAs.
  • Asynchronous. Can fire at any moment, between any two instructions of the interrupted program.
  • Must preserve everything. The interrupted program is unaware; if the ISR modifies a register and doesn’t restore it, the interrupted program corrupts.
  • Returns via return-from-interrupt instruction, not regular ret.

ISR structure

The standard skeleton:

  1. Save context. Push registers used by the ISR onto the stack.
  2. Do the work. Read or write the device, do whatever processing is needed.
  3. Restore context. Pop the saved registers.
  4. Return. Execute the return-from-interrupt instruction.

Example for a keyboard ISR:

ILOC:
    Subtract SP, SP, #8           ; Make space on stack
    Store R2, 4(SP)               ; Save R2
    Store R3, (SP)                ; Save R3
    
    Load R2, PNTR                 ; Load buffer pointer
    LoadByte R3, KBD_DATA         ; Read character (clears KIN)
    StoreByte R3, (R2)            ; Store in buffer
    Add R2, R2, #1                ; Increment pointer
    Store R2, PNTR                ; Update pointer variable
    
    # Check for Carriage Return (End of Line)
    Move R2, #CR
    Branch_if_Not_Equal R3, R2, RTRN
    
    # If CR, set EOL flag and disable keyboard interrupts
    Move R2, #1
    Store R2, EOL
    Clear R2
    StoreByte R2, KBD_CONT
    
RTRN:
    Load R3, (SP)                 ; Restore R3
    Load R2, 4(SP)                ; Restore R2
    Add SP, SP, #8                ; Restore stack pointer
    Return-from-interrupt

Reading the character (LoadByte R3, KBD_DATA) automatically clears the KIN flag in the device’s STATUS register — the device interface knows the data has been consumed and can prepare for the next character. The ISR doesn’t have to manually clear the interrupt.

If the character was a Carriage Return, the ISR sets an end-of-line flag and disables further keyboard interrupts (by clearing the device’s CONT bit). The main program polls the EOL flag at its convenience to know the line is ready.

Why save context

The ISR runs in the middle of an unsuspecting program. Whatever registers that program was using are likely sitting at meaningful values. If the ISR uses R2 and R3 without saving them, those registers come back with new values when the original program resumes — and chaos ensues.

So the ISR’s first job is to save anything it’ll touch (and only what it’ll touch — saving everything is wasteful but always correct), and its last job is to restore them.

PC and PS are saved by the hardware at interrupt entry, not by the ISR. The return-from-interrupt instruction restores them. So the ISR doesn’t need to handle PC and PS itself.

Re-entry and nested interrupts

By default, when an interrupt fires, the hardware clears the IE bit in PS so the ISR can’t be immediately re-interrupted by the same source. Without this protection, you’d get an infinite stack of nested ISRs.

If higher-priority interrupts should still be allowed during this ISR, the ISR can set IE back on after saving context — at the cost of needing to handle nested ISRs correctly (each one needs its own saved context on the stack).

The return-from-interrupt instruction restores the saved PS, which restores the original IE state — re-enabling interrupts as the interrupted program expected.

Choosing what an ISR should do

Best practice: ISRs should be short. Long ISRs delay other interrupts and slow down the system. The pattern is usually:

  • ISR does the minimum work needed to acknowledge the interrupt and queue the data.
  • A regular subroutine, called from the main program when convenient, does the heavy processing.

For example, a network ISR might just copy the incoming packet to a queue and exit. A separate “network processing” routine, called from the main program loop, then parses the packet, looks up the destination, etc.

Determining which device caused the interrupt

When multiple devices share the interrupt line, the ISR has to figure out which one fired. Two ways:

  1. Software polling: the ISR reads each device’s STATUS register, looking for the one with its IRQ bit set.
  2. Vectored interrupts: the device identifies itself with a code, and the hardware looks up the device-specific ISR in an Interrupt vector table. The processor jumps directly to the right ISR — no polling.

Vectored is faster but requires more hardware support. Most modern processors offer it.