Skip to content

07. Handling Interrupts

Jose edited this page Aug 18, 2021 · 14 revisions

An interrupt describes a signal from hardware device / software event which tells the CPU to stop what it is currently doing and switch out to do something else in response to that event. On x86, there are 256 slots of interrupt descriptors stored in a table called the interrupt descriptor table (IDT). Normally, when an interrupt arrives, eventually it will be translated to an interrupt descriptor index, and the processor switches out to execute the corresponding descriptor's interrupt handler.

Interrupts provide us a way of interacting with our system in real-time. We will briefly go through three general classes of interrupts: hardware interrupts, software interrupts, and exceptions.

Main References of This Chapter

Scan through them before going forth:

Overview of Interrupts

There are generally three classes of interrupts:

  • Hardware interrupt (Interrupt request, IRQ): generated by an external device, e.g., pressing a key on a keyboard. Two types of IRQs are commonly used today:
    • Pin-based: a keyboard controller connected to the chip-set signals a special device called the programmable interrupt controller (PIC chip) an IRQ #. The PIC will serialize IRQs from all devices, decide whether the CPU should immediately respond to an IRQ or not, translate the IRQ # to an interrupt descriptor index, and notify the CPU.
    • Message-based: reserve a special memory location and follow a specific device bus protocol. An example is the PCI bus and its successor - PCI-E. We will not look into this advanced type of IRQs for now.
  • Software interrupt (System call): generated by a running software process, to indicate that it needs the kernel to be notified or do something for it. A typical example is that a user process calls a memory allocation system call (through malloc()), which translates to an int instruction on x86. Most modern UNIX-flavor kernels reserve interrupt descriptor 0x80 for software interrupts.
  • Exception: generated internally by the processor. Exceptions are to notify the kernel about exception conditions such as a page fault or a double fault.

On x86, a double fault occurs when the CPU runs into trouble when serving an interrupt, in which case it jumps to a double fault handler. If, unfortunately, it runs into trouble again inside the double fault handler, then a triple fault has occurred and the CPU resets.

An interrupt descriptor typically points to a piece of kernel code, called the interrupt handler (interrupt service routine, ISR). Once the processor knows the index of interrupt descriptor for an incoming interrupt, it jumps to the interrupt handler it points to. The interrupt handler should interact with the device / serve a system call request, then returns to what the system was doing previously through an iret instruction.

Interrupt Descriptor Table (IDT)

IDT is a concept in protected mode. (In real mode, its counter-part is called interrupt vector table (IVT).) So, we need to have a working GDT before starting to implement IDT.

The IDT should contain at least 256 slots since there are 256 possible interrupt descriptor indices. IDT entries are conventionally called gates. There are three types of gates: task gate, interrupt gate, and trap gate. Each gate entry is 8 bytes with the following definition ✭:

Figure from Intel's 64 & IA-32 manual.

  • The offset is a 32-bit value representing the address of its ISR
  • The selector field is a 16-bit value which must point to a valid segment descriptor in our GDT
  • For definition of other flags, check this section

Much like GDT, the memory address and boundary (= length - 1) of IDT is recorded in a special 48-bit register IDTR:

IDTR := |47   IDT Base Addr   16|15   Boundary   0|

Interrupt Descriptor Index Specifications

Among all 256 interrupt indices, the first 32, i.e. 0x00 - 0x1F, are reserved by x86 IA32 CPU for exceptions. They are (from James' page):

  • 0: Division by zero exception
  • 1: Debug exception
  • 2: Non maskable interrupt
  • 3: Breakpoint exception
  • 4: Into detected overflow
  • 5: Out of bounds exception
  • 6: Invalid opcode exception
  • 7: No coprocessor exception
  • 8: Double fault
  • 9: Coprocessor segment overrun
  • 10: Bad TSS
  • 11: Segment not present
  • 12: Stack fault
  • 13: General protection fault
  • 14: Page fault
  • 15: Unknown interrupt exception
  • 16: Coprocessor fault
  • 17: Alignment check exception
  • 18: Machine check exception
  • 19-31: Reserved

where in interrupt 8, 10, 11, 12, 13, 14, 17, or 21, the CPU also pushes an error code on stack to the ISR handler.

Higher indices (0x20 - 0xFF) are free for our OS kernel to define.

Making Interrupts Work

To program our kernel for interrupts handling, we should do the following things (learned from this section):

  1. Allocate space for IDT, and tell the processor where it is
  2. Program the PIC chips
  3. Write ISRs for different types of interrupts (detailed ISR implementation will be done in later chapters; Here we first make a dummy one)
  4. Put the addresses our ISRs into corresponding interrupt descriptors in IDT
  5. Enable all supported interrupts in PIC's IRQ mask (will be done later)

IDT Implementation

First, following the gate entry format in the above sections, we pack them into structs @ src/interrupt/idt.h:

/**
 * IDT gate entry format.
 * Check out https://wiki.osdev.org/IDT for detailed anatomy
 * of fields.
 */
struct idt_gate {
    uint16_t base_lo;       /** Base 0:15. */
    uint16_t selector;      /** Segment selector. */
    uint8_t  zero;          /** Unused. */
    uint8_t  type_attr;     /** Type and attributes flags. */
    uint16_t base_hi;       /** Base 16:31. */
} __attribute__((packed));
typedef struct idt_gate idt_gate_t;


/**
 * 48-bit IDTR address register format.
 * Used for loading the IDT with `lidt` instruction.
 */
struct idt_register {
    uint16_t boundary;  /** Boundary = length in bytes - 1. */
    uint32_t base;      /** IDT base address. */
} __attribute__((packed));
typedef struct idt_register idt_register_t;


/** Length of IDT. */
#define NUM_GATE_ENTRIES 256


void idt_init();

For a more detailed explanation of what each bit in flags field stands for, check out the "Interrupt Descriptor Table" page.

Implement some initialization routines for setting up and loading the IDT, @ src/interrupt/idt.c:

/**
 * The IDT table. Should contain 256 gate entries.
 *   -  0 -  31: reserved by x86 CPU for various exceptions
 *   - 32 - 255: free for our OS kernel to define
 */
static idt_gate_t idt[NUM_GATE_ENTRIES];

/** IDTR address register. */
static idt_register_t idtr;


/**
 * Setup one IDT gate entry.
 * Here, BASE is in its complete version, SELECTOR represents the selector
 * field, and FLAGS represents the 8-bit flags field.
 */
static void
idt_set_gate(int idx, uint32_t base, uint16_t selector, uint8_t flags)
{
    idt[idx].base_lo  = (uint16_t) (base & 0xFFFF);
    idt[idx].selector = (uint16_t) selector;
    idt[idx].zero     = (uint8_t) 0;
    idt[idx].flags    = (uint8_t) flags;
    idt[idx].base_hi  = (uint16_t) ((base >> 16) & 0xFFFF);
}


/** Extern our load routine written in ASM `idt-load.s`. */
extern void idt_load(uint32_t idtr_ptr);

The idt_load function should be done in a separate assembly piece of code, calling the lidt instruction, @ src/interrupt/idt-load.s:

/**
 * Load the IDT.
 * 
 * The `idt_load` function will receive a pointer to the `idtr` value. We
 * will then load the `gdtr` value into IDTR register by the `lidt`
 * instruction.
 */


.global idt_load
.type idt_load, @function
idt_load:
    
    /** Get the argument. */
    movl 4(%esp), %eax

    /** Load the IDT. */
    lidt (%eax)

    ret

Interrupt Service Routines (ISR)

The first 32 gate entries are for CPU-generated exceptions - these are reserved for exceptions (faults). They each point to an ISR handler for that exception. We need to implement all these 32 ISR handlers and register them in the first 32 slots in IDT.

ISRs are recommended to be implemented in either one of the following three ways:

  • As plain x86 assembly
  • Two-stage assembly: write a wrapper in assembly which calls a C function and finally doing iret. We will do it this way. We use a wrapper to push the interrupt number to a single handler function written in C
  • If your compiler supports a special function attribute __attribute__((interrupt)), you can write them in C and annotate the handler functions with this attribute

Do NOT abuse inline assembly - it is particularly dangerous because inline assembly does not maintain register & stack state between instructions ✭. There are other problems of abusing inline assembly. Check out the "Interrupt Service Routines" page for an example.

First, make our 32 wrappers @ src/interrupt/isr-stub.s. Notice that interrupts # 8, 10, 11, 12, 13, 14, 17, and 21 have a CPU-pushed error code, and others do not. Thus, we push a dummy error code for those without CPU-pushed error code to maintain a unified function signature.

/**
 * We make 32 wrappers for all 32 exception gates. We registered them as
 * interrupt gates so they will automatically disable interrupts and restore
 * interrupts when entering and leaving ISR, so no need of `cli` and `sti`
 * here.
 *
 * Traps # 8, 10, 11, 12, 13, 14, 17, or 21 will have a CPU-pushed error
 * code, and for others we push a dummy one.
 */
.global isr0    // Example of stub with dummy error code.
.type isr0, @function
isr0:
    pushl $0                /** Dummy error code. */
    pushl $0                /** Interrupt index code. */
    jmp isr_handler_stub    /** Jump to handler stub. */

...

.global isr8    // Example of stub without dummy error code.
.type isr8, @function
isr8:
    pushl $8                /** Interrupt index code. */
    jmp isr_handler_stub

...

These stubs all call a common handler stub which in turn saves the interrupt state and calls our isr_handler function written in C, which we will implement very soon.

/**
 * Handler stub. Saves processor state, load kernel data segment, pushes
 * interrupt number and error code as arguments and calls the `isr_handler`
 * function in `isr.c`. When the function returns, we restore the stack
 * frame.
 *
 * Be sure that kenrel code segment is at `0x10`.
 */
extern isr_handler  /** Extern `isr_handler` from C code. */

isr_handler_stub:
    /** Saves EDI, ESI, EBP, ESP, EBX, EDX, ECX, EAX. */
    pushal

    /** Saves DS (as lower 16 bits). */
    movw %ds, %ax
    pushl %eax

    /**
     * Push a pointer to the current stuff on stack, which are:
     *   - DS
     *   - EDI, ESI, EBP, ESP, EBX, EDX, ECX, EAX
     *   - Interrupt number, Error code
     *   - EIP, CS, EFLAGS, User's ESP, SS (these are previously pushed
     *     by the processor when entering this interrupt)
     *
     * This pointer is the argument of our `isr_handler` function.
     */
    movl %esp, %eax
    pushl %eax

    /** Load kernel mode data segment descriptor. */
    movw $0x10, %ax
    movw %ax, %ds
    movw %ax, %es
    movw %ax, %fs
    movw %ax, %gs

    /** == Calls the ISR handler. == **/
    call isr_handler
    /** == ISR handler finishes.  == **/

    addl $4, %esp   /** Cleans up the pointer argument. */

    /** Restore previous segment descriptor. */
    popl %eax
    movw %ax, %ds
    movw %ax, %es
    movw %ax, %fs
    movw %ax, %gs

    /** Restores EDI, ESI, EBP, ESP, EBX, EDX, ECX, EAX. */
    popal

    addl $8, %esp   /** Cleans up error code and ISR number. */

    iret            /** This pops CS, EIP, EFLAGS, SS, and ESP. */

Be extra careful with the order in which we push interrupt state register values. Right before we call isr_handler, our stack looks like:

This is how we pass the interrupt state (the "snapshot" taken when the interrupt happens) to our interrupt handler. These states must present and our handler must be able to modify them, in order to enable useful interrupt handlers such as system calls handlers. The order MUST be exactly the same as what we will define as the following C struct @ src/interrupt/isr.h:

/**
 * Interrupt state specification, which will be followed by `isr-stub.s`
 * before calling `isr_handler` below.
 */
struct interrupt_state {
    uint32_t ds;
    uint32_t edi, esi, ebp, useless, ebx, edx, ecx, eax;
    uint32_t int_no, err_code;
    uint32_t eip, cs, eflags, esp, ss;
} __attribute__((packed));
typedef struct interrupt_state interrupt_state_t;


/** Allow other modules to register an ISR. */
typedef void (*isr_t)(interrupt_state_t *);

void isr_register(uint8_t int_no, isr_t handler);


/**
 * List of used interrupt numbers in this system. Other parts of the kernel
 * should refer to these macro names instead of using plain numbers.
 *   - 0 - 31 are ISRs for CPU-generated exceptions
 *   - >= 32 are mapped as custom device IRQs, so ISR 32 means IRQ 0, etc.
 */
#define IRQ_BASE_NO     32
#define INT_NO_TIMER    (IRQ_BASE_NO + 0)
#define INT_NO_KEYBOARD (IRQ_BASE_NO + 1)

And a very simple handler function @ src/interrupt/isr.c:

/** Table of ISRs. Unregistered entries MUST be NULL. */
isr_t isr_table[NUM_GATE_ENTRIES] = {NULL};

/** Exposed to other parts for them to register ISRs. */
inline void
isr_register(uint8_t int_no, isr_t handler)
{
    if (isr_table[int_no] != NULL) {
        error("isr: handler for interrupt # %#x already registered", int_no);
        return;
    }
    isr_table[int_no] = handler;
}


/**
 * ISR handler written in C.
 *
 * Receives a pointer to a structure of interrupt state. Handles the
 * interrupt and simply returns. Can modify interrupt state through
 * this pointer if necesary.
 */
void
isr_handler(interrupt_state_t *state)
{
    uint8_t int_no = state->int_no;

    info("caught interrupt # %#x", int_no);

    /** Call actual ISR if registered. */
    if (isr_table[int_no] != NULL)
        isr_table[int_no](state);
}

We will register actual ISRs and strengthen our isr_handler function later, after we have learned external devices IRQs, page faults and more.

Finally, extern the ISR stubs in our C code and make an IDT initialization function to expose to our main function, @ src/interrupt/idt.c:

/** Extern our trap ISR handlers written in ASM `isr-stub.s`. */
extern void isr0 (void);
...
extern void isr31(void);


/**
 * Initialize the interrupt descriptor table (IDT) by setting up gate
 * entries of IDT, setting the IDTR register to point to our IDT address,
 * and then (through assembly `lidt` instruction) load our IDT.
 */
void
idt_init()
{
    /**
     * First, see https://wiki.osdev.org/IDT for a detailed anatomy of
     * flags field.
     *
     * Flags -
     *   - P    = ?: present, 0 for inactive entries and 1 for valid ones
     *   - DPL  = ?: ring level, specifies which privilege level should the
     *               calling segment at least have
     *   - S    = ?: 0 for interrupt and trap gates
     *   - Type =
     *     - 0x5: 32-bit task gate
     *     - 0x6: 16-bit interrupt gate
     *     - 0x7: 16-bit trap gate
     *     - 0xE: 32-bit interrupt gate
     *     - 0xF: 32-bit trap gate
     * Hence, all interrupt gates have flag field 0x8E and all trap gates
     * have flag field 0x8F for now. The difference between trap gates and
     * interrupt gates is that interrupt gates automatically disable
     * interrupts upon entry and restores upon `iret` instruction (which
     * restores the saved EFLAGS). Trap gates do not do this.
     * 
     * Selector = 0x08: pointing to kernel code segment.
     *
     * Unused entries and field all default to 0, so memset first.
     */
    memset(idt, 0, sizeof(idt_gate_t) * 256);

    idt_set_gate(0 , (uint32_t) isr0 , SEGMENT_KCODE << 3, 0x8E);

    ...

    idt_set_gate(31, (uint32_t) isr31, SEGMENT_KCODE << 3, 0x8E);

    /** Setup the IDTR register value. */
    idtr.boundary = (sizeof(idt_gate_t) * NUM_GATE_ENTRIES) - 1;
    idtr.base     = (uint32_t) &idt;

    /**
     * Load the IDT.
     * Passing pointer to `idtr` as unsigned integer.
     */
    idt_load((uint32_t) &idtr);
}

Progress So Far

Let's try out our interrupt handling implementation! Manually interrupt the CPU through inline int instructions @ src/kernel.c:

/** The main function that `boot.s` jumps to. */
void
kernel_main(unsigned long magic, unsigned long addr)
{
    /** Initialize VGA text-mode terminal support. */
    terminal_init();

    /** Double check the multiboot magic number. */
    if (magic != MULTIBOOT_BOOTLOADER_MAGIC) {
        error("invalid bootloader magic: %#x", magic);
        return;
    }

    /** Get pointer to multiboot info. */
    multiboot_info_t *mbi = (multiboot_info_t *) addr;

    /** Initialize debugging utilities. */
    debug_init(mbi);

    /** Initialize global descriptor table (GDT). */
    gdt_init();

    /** Initialize interrupt descriptor table (IDT). */
    idt_init();

    asm volatile ( "int $0x05" );
}

This should produce a terminal window as the following after booting up:

Current repo structure:

hux-kernel
├── Makefile
├── scripts
│   ├── gdb_init
│   ├── grub.cfg
│   └── kernel.ld
├── src
│   ├── boot
│   │   ├── boot.s
│   │   ├── elf.h
│   │   └── multiboot.h
│   ├── common
│   │   ├── debug.c
│   │   ├── debug.h
│   │   ├── port.c
│   │   ├── port.h
│   │   ├── printf.c
│   │   ├── printf.h
│   │   ├── string.c
│   │   ├── string.h
│   │   ├── types.c
│   │   └── types.h
│   ├── display
│   │   ├── terminal.c
│   │   ├── terminal.h
│   │   └── vga.h
│   ├── memory
│   │   ├── gdt-load.s
│   │   ├── gdt.c
│   │   └── gdt.h
│   ├── interrupt
│   │   ├── idt-load.s
│   │   ├── idt.c
│   │   ├── idt.h
│   │   ├── isr-stub.s
│   │   ├── isr.c
│   │   └── isr.h
│   └── kernel.c