Create your own
Lesson illustration

Installing an x86-64 Breakpoint Interrupt Descriptor Table Entry

Hello. In the previous lesson, you established a debugger-driven view of the running kernel: QEMU executes the guest, GDB can stop it, and registers plus stack memory provide evidence about what the CPU is doing. Now you will make a CPU event observable inside the guest itself.

You will install a real x86-64 Interrupt Descriptor Table entry for vector 3, the breakpoint exception. After initialization, executing int3 will enter your Rust handler, print the hardware-saved exception state through serial, and return safely to the instruction after int3. This is the small but essential pattern behind later fault handling.


From an exception vector to your handler

An exception is not an ordinary Rust call. At an arbitrary instruction boundary, the CPU detects an event and needs a defined destination for it. The Interrupt Descriptor Table (IDT) is that routing structure: it has 256 entries, indexed by an eight-bit vector number.

For this lesson:

  • int3 causes the breakpoint exception, written #BP.
  • #BP has vector number 3.
  • The CPU uses IDT entry 3 to locate the handler.
  • The saved instruction pointer identifies the instruction after the one-byte int3 instruction.
  • A correct handler return resumes execution after int3.

The IDT is active machine state, not just Rust data. The processor’s IDTR register holds the base address and size of the active table. An IDT entry is a 16-byte descriptor containing, among other things, the handler address, a code-segment selector, gate options, a privilege level, and a present bit.

CPU Exceptions | Writing an OS in Rust

Read the “The Interrupt Descriptor Table” and “The Interrupt Calling Convention” sections of CPU Exceptions | Writing an OS in Rust. They connect the hardware descriptor table to the Rust handler type you will use.

In “The Interrupt Descriptor Table,” read from the opening explanation through the numbered sequence describing what the CPU does during an exception. Focus on the descriptor and dispatch path: the vector selects an entry, the CPU checks that it is present, then transfers control. Then read “The Interrupt Calling Convention,” especially the explanation of why an exception cannot follow an ordinary function-call ABI. Read exception entry mechanics. Notice that the CPU records interrupted execution state and that the Rust ABI handles the special return sequence.

Why the handler needs a special ABI

A normal call gives the compiler advance notice: it emits a call, follows the platform’s register conventions, and arranges a normal ret. An exception can happen at virtually any instruction, with any general-purpose registers holding live values.

The extern "x86-interrupt" ABI tells Rust that this is an exception handler rather than a normal function. The compiler then preserves registers it needs to overwrite, obtains the exception frame using the architecture-defined stack layout, and emits an interrupt return rather than an ordinary ret.

For the breakpoint exception, the handler takes one argument:

extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: InterruptStackFrame,
)

There is no error-code argument for #BP. That distinction matters: later, a page-fault handler will have a different signature because page faults supply an error code.

The InterruptStackFrame provides the processor-saved control state needed to investigate the event. Depending on whether entry crossed a privilege boundary or changed stacks, the underlying hardware frame differs in detail; the x86_64 crate exposes the appropriate architectural representation to the handler.


Constructing an IDT that remains valid

The crucial lifetime rule is simple: once loaded, the CPU may consult the IDT at any time. Therefore the table cannot be a local variable in init_idt.

This is broken in principle, even if it appears to work briefly:

pub fn init_idt() {
    let mut idt = InterruptDescriptorTable::new();
    // configure idt
    idt.load();
} // `idt` ceases to exist here

The CPU would retain the address of stack storage that later code is free to reuse. Rust’s InterruptDescriptorTable::load avoids this by requiring a 'static reference.

Use lazy_static to initialize the IDT once, keep it at a stable address for the kernel’s lifetime, and avoid a static mut global. If your kernel does not already depend on it, add the no-std-compatible configuration:

lazy_static = { version = "1.5", features = ["spin_no_std"] }

The x86_64 dependency must also have its instructions feature available. It normally already does if you used x86_64::instructions::interrupts::int3() in the previous workflow.

CPU Exceptions | Writing an OS in Rust

Continue with the same article’s “Implementation,” “Loading the IDT,” “Lazy Statics to the Rescue,” and “Running It” subsections. The code is directly applicable to a freestanding Rust kernel, though you should keep your own serial-printing macro and crate name.

In “Implementation,” follow the breakpoint example and identify the handler registration call. In “Loading the IDT” and “Lazy Statics to the Rescue,” read the static lifetime argument carefully: this is the safety property your code must preserve. Finally, in “Running It,” read the expected result. Compare it with the verification procedure below, using serial output rather than relying on a graphical text buffer.

Create src/interrupts.rs with the following implementation. If your serial macro has a different name, replace crate::serial_println! with the logging macro from your boot-and-serial setup.

use lazy_static::lazy_static;
use x86_64::structures::idt::{
    InterruptDescriptorTable,
    InterruptStackFrame,
};

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();

        idt.breakpoint.set_handler_fn(breakpoint_handler);

        idt
    };
}

pub fn init_idt() {
    IDT.load();
}

extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: InterruptStackFrame,
) {
    crate::serial_println!("EXCEPTION: BREAKPOINT");
    crate::serial_println!("{:#?}", stack_frame);
}

There are three important operations here:

  1. InterruptDescriptorTable::new() creates a table whose entries are initially non-present.
  2. idt.breakpoint.set_handler_fn(breakpoint_handler) configures the crate’s vector-3 entry with the correct handler address and suitable descriptor details.
  3. IDT.load() executes the architectural action corresponding to lidt, making this table the processor’s active IDT.

You are deliberately using the crate’s typed breakpoint field rather than indexing with idt[3]. It documents intent and ensures that the handler must have the correct no-error-code function type.

Enable the ABI only when your compiler requires it

On toolchains where x86-interrupt is still experimental, add this crate attribute near the top of the crate root, alongside #![no_std]:

#![feature(abi_x86_interrupt)]

Use the compiler diagnostic as the authority here. If your selected Rust toolchain reports that this ABI is stable or rejects feature attributes, omit the line. The handler definition itself remains the same.

Now expose the module and centralize initialization in your crate root, commonly src/lib.rs:

pub mod interrupts;

pub fn init() {
    interrupts::init_idt();
}

Do not enable maskable hardware interrupts merely by loading this table. lidt installs a descriptor table; it does not set the interrupt-enable flag. Hardware timers, keyboards, interrupt controllers, and sti belong to a later stage. For now, you are handling one synchronous exception that you explicitly trigger.


Triggering and verifying vector 3

In the kernel entry path, call initialization before executing int3. Use your library crate’s actual name where the example says kernel.

kernel::init();

serial_println!("about to execute int3");

x86_64::instructions::interrupts::int3();

serial_println!("returned from breakpoint handler");

Build and boot using the serial-console workflow from earlier lessons:

cargo +nightly bootimage

Then start QEMU with your existing boot image and serial configuration. A successful run should show three distinct facts:

  1. The message before int3 appears.
  2. The breakpoint handler prints EXCEPTION: BREAKPOINT and an InterruptStackFrame.
  3. The message after int3 appears.

The third line is decisive. It shows that Rust generated the proper interrupt return sequence and that the CPU resumed after the breakpoint instruction rather than crashing or restarting the VM.

A QEMU serial console shows a breakpoint handler printing an `ExceptionStackFrame`, followed by “It did not crash!”, demonstrating that execution returned safely after the `int3` exception.

You can also use the GDB setup from the previous lesson to observe the handler directly:

(gdb) break breakpoint_handler
(gdb) continue
(gdb) info registers rip rsp
(gdb) x/8i $rip

At this stop, RIP should be in compiler-generated code for or near breakpoint_handler. The serial output is the guest’s own report; GDB is the external observer. Keeping those roles distinct will prevent confusion as faults become more complex.

Common failure patterns

SymptomLikely causeFirst check
Compilation says x86-interrupt is experimentalRequired ABI feature gate is absentAdd #![feature(abi_x86_interrupt)] when using the relevant nightly toolchain
Compilation cannot find lazy_staticDependency is absent or lacks no-std supportAdd lazy_static with the spin_no_std feature
QEMU resets or reaches a triple fault after int3The IDT was not loaded, its entry is not present, or the wrong handler ABI was usedConfirm kernel::init() executes before int3 and the handler is extern "x86-interrupt"
The handler prints but code after int3 never runsHandler return path is invalid or the handler itself does not returnVerify the one-argument breakpoint signature and remove any deliberate panic or infinite loop
The output frame seems unfamiliarThe values are interrupted machine state, not ordinary Rust function argumentsInterpret RIP, code segment, and flags as exception-entry evidence

A breakpoint handler is intentionally forgiving: it is a controlled exception with no error code and a clear resumption point. That makes it the ideal first proof that the IDT, handler ABI, serial logging, and interrupt return path agree.


You have now installed a persistent x86-64 IDT, configured its vector-3 breakpoint entry, loaded it with lidt through the Rust abstraction, and verified that int3 enters and returns from a guest-visible handler.

The key invariant is that the active IDT must have a stable 'static address for as long as the CPU can use it. Next, you will apply the same framework to page faults, where the handler must decode an error code and read the faulting virtual address before deciding what the kernel should do.

Can't find a good explanation? Sign up and we'll make it for you

Sign up