Handlers

OS in Rust

Your Task

  • We are trying to get a working exception handler.

Implementation

Now that we’ve understood the theory, it’s time to handle CPU exceptions in our kernel. We’ll start by creating a new interrupts module in src/interrupts.rs, that first creates an init_idt function that creates a new InterruptDescriptorTable:

src/lib.rs
pub mod interrupts;
src/interrupts.rs
pub fn init_idt() {
    let mut idt = x86_64::structures::idt::InterruptDescriptorTable::new();
}

Now we can add handler functions. We start by adding a handler for the breakpoint exception. The breakpoint exception is the perfect exception to test exception handling. Its only purpose is to temporarily pause a program when the breakpoint instruction int3 is executed.

The breakpoint exception is commonly used in debuggers: When the user sets a breakpoint, the debugger overwrites the corresponding instruction with the int3 instruction so that the CPU throws the breakpoint exception when it reaches that line. When the user wants to continue the program, the debugger replaces the int3 instruction with the original instruction again and continues the program.

For our use case, we don’t need to overwrite any instructions. Instead, we just want to print a message when the breakpoint instruction is executed and then continue the program. So let’s create a simple breakpoint_handler function and add it to our IDT:

src/interrupts.rs
pub fn init_idt() {
    let mut idt = x86_64::structures::idt::InterruptDescriptorTable::new();
    idt.breakpoint.set_handler_fn(breakpoint_handler);
}

extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: x86_64::structures::idt::InterruptStackFrame)
{
    crate::println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}

Our handler just outputs a message and pretty-prints the interrupt stack frame.

When we try to compile it, the following error occurs:

error[E0658]: the extern "x86-interrupt" ABI is experimental and subject to change
 --> src/interrupts.rs:6:8
  |
6 | extern "x86-interrupt" fn breakpoint_handler(
  |        ^^^^^^^^^^^^^^^
  |
  = note: see issue #40180 <https://github.com/rust-lang/rust/issues/40180> for more information
  = help: add `#![feature(abi_x86_interrupt)]` to the crate attributes to enable
  = note: this compiler was built on 2026-02-06; consider upgrading it if it is out of date

For more information about this error, try `rustc --explain E0658`.
error: could not compile `osirs` (lib) due to 1 previous error

This error occurs because the x86-interrupt calling convention is still unstable. To use it anyway, we have to explicitly enable it by adding #![feature(abi_x86_interrupt)] at the top of our lib.rs.

Loading the IDT

In order for the CPU to use our new interrupt descriptor table, we need to load it using the lidt instruction. The InterruptDescriptorTable struct of the x86_64 crate provides a [load][InterruptDescriptorTable::load](https://docs.rs/x86_64/0.14.2/x86_64/structures/idt/struct.InterruptDescriptorTable.html#method.load) method for that. Let’s try to use it:

src/interrupts.rs
pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
    idt.breakpoint.set_handler_fn(breakpoint_handler);
    idt.load();
}

When we try to compile it now, the following error occurs:

error[E0597]: `idt` does not live long enough
 --> src/interrupts.rs:4:5
  |
2 |     let mut idt = x86_64::structures::idt::InterruptDescriptorTable::new();
  |         ------- binding `idt` declared here
3 |     idt.breakpoint.set_handler_fn(breakpoint_handler);
4 |     idt.load();
  |     ^^^-------
  |     |
  |     borrowed value does not live long enough
  |     argument requires that `idt` is borrowed for `'static`
5 | }
  | - `idt` dropped here while still borrowed

For more information about this error, try `rustc --explain E0597`.
error: could not compile `osirs` (lib) due to 1 previous error

So the load method expects a &'static self, that is, a reference valid for the complete runtime of the program.

I am completely capable of getting Rust to do whatever I want without engaging with the “lifetime” system, which I find to be (1) clunky and (2) a very poor fit for anything hardware relevant.

The reason is that the CPU will access this table on every interrupt until we load a different IDT. So using a shorter lifetime than 'static could lead to use-after-free bugs.

In fact, this is exactly what happens here. Our idt is created on the stack, so it is only valid inside the init function. Afterwards, the stack memory is reused for other functions, so the CPU would interpret random stack memory as IDT. Luckily, the InterruptDescriptorTable::load method encodes this lifetime requirement in its function definition, so that the Rust compiler is able to prevent this possible bug at compile time.

In order to fix this problem, we need to store our idt at a place where it has a 'static lifetime. To achieve this, we could allocate our IDT on the heap using [Box] (https://doc.rust-lang.org/std/boxed/struct.Box.html) and then convert it to a 'static reference, but we are writing an OS kernel and thus don’t have a heap (yet).

As an alternative, we could try to store the IDT as a static:

src/interrupts.rs
static IDT: x86_64::structures::idt::InterruptDescriptorTable =
    x86_64::structures::idt::InterruptDescriptorTable::new();

pub fn init_idt() {
    IDT.breakpoint.set_handler_fn(breakpoint_handler);
    IDT.load();
}

However, there is a problem: Statics are immutable, so we can’t modify the breakpoint entry from our init function.

error[E0596]: cannot borrow `IDT.breakpoint` as mutable, as `IDT` is an immutable static item
 --> src/interrupts.rs:5:5
  |
1 | static IDT: x86_64::structures::idt::InterruptDescriptorTable =
  | ------------------------------------------------------------- this `static` cannot be borrowed as mutable
...
5 |     IDT.breakpoint.set_handler_fn(breakpoint_handler);
  |     ^^^^^^^^^^^^^^ cannot borrow as mutable

For more information about this error, try `rustc --explain E0596`.

We could solve this problem by using our most beloved static mut

src/interrupts.rs
static mut IDT: InterruptDescriptorTable = InterruptDescriptorTable::new();

I draw a series of errors here that are left as an exercise to the student.

Running it

The last step for making exceptions work in our kernel is to call the init_idt function from our main.rs. Instead of calling it directly, we introduce a general init function in our lib.rs:

src/lib.rs
pub fn init() {
    interrupts::init_idt();
}

With this function, we now have a central place for initialization routines that can be shared between the different _start functions in our main.rs, lib.rs, and integration tests.

Now we can update the _start function of our main.rs to call init and then trigger a breakpoint exception:

src/main.rs
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
    println!("Hello World{}", "!");

    osirs::init(); // new

    // invoke a breakpoint exception
    x86_64::instructions::interrupts::int3(); // new

    // as before
    #[cfg(test)]
    test_main();

    println!("It did not crash!");
    loop {}
}

When we run it in QEMU now (using cargo run), we see the following:

EXCEPTION: BREAKPOINT
InterruptStackFrame {
    instruction_pointer: VirtAddr(
        0x203d61,
    ),
    code_segment: 8,
    cpu_flags: 0x6,
    stack_pointer: VirtAddr(
        0x10000201fe8,
    ),
    stack_segment: 0,
}

It works! The CPU successfully invokes our breakpoint handler, which prints the message, and then returns back to the _start function, where the It did not crash! message is printed.

We see that the interrupt stack frame tells us the instruction and stack pointers at the time when the exception occurred. This information is very useful when debugging unexpected exceptions.

Adding a Test

Let’s create a test that ensures that the above continues to work.

tests/except.rs
#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
    // Your code here.
}

Remember, this _start function is used when running cargo t, since Rust tests the tests/except.rs completely independently of the main.rs.

Your test should invoke the int3 (as we have shown) to trigger a breakpoint exception. By checking that the execution continues afterward, we verify that our breakpoint handler is working correctly.

You can try this new test by running cargo test (all tests) or cargo t --test except to run only this test.