Integrate the x86_64 Crate for CPU Exception Handling

Mục tiêu: Add the x86\_64 crate as a project dependency in Cargo.toml. This crate provides essential, type-safe abstractions for the x86-64 architecture, which are required to create an Interrupt Descriptor Table (IDT) and handle CPU exceptions.


Congratulations on achieving the “Hello, World!” of operating system development! Seeing your own message appear on the screen from a kernel you’ve built from scratch is a monumental milestone. Your kernel is now alive, running on bare metal within the QEMU emulator.

While this is a huge success, our current kernel is still very fragile. It exists in a “perfect world” where nothing goes wrong. But what happens if we write code that attempts an illegal operation, such as dividing by zero or accessing an invalid memory address? In these situations, the CPU itself detects the error and triggers an exception. Our current kernel has no mechanism to handle these exceptions, which would cause it to fault and crash, likely leading to an immediate reboot of the virtual machine.

Our next major goal is to build a robust safety net by implementing handlers for these CPU exceptions. To do this, we need to configure a crucial CPU data structure called the Interrupt Descriptor Table (IDT). The IDT is essentially a lookup table that the CPU uses to find the correct handler function for each specific exception or interrupt.

Defining the complex structures for the IDT and its entries according to the x86-64 architecture specification would be a tedious and highly error-prone task if done manually. Fortunately, the Rust ecosystem provides a crate that has already done this hard work for us. This leads us to our current task: adding the x86_64 crate to our project.

Adding a Powerful Abstraction: The x86_64 Crate

The x86_64 crate is a foundational library for any kernel or low-level project written in Rust for the x86-64 architecture. It provides type-safe, idiomatic Rust abstractions for many of the architecture’s specific features. Most importantly for us right now, it contains a well-designed representation of the Interrupt Descriptor Table and its entries.

Key benefits of using this crate include: * Safety: It handles the complex and unsafe bit-twiddling required to create these structures internally, exposing a much safer and easier-to-use API to us. * Correctness: The structures are modeled directly from the official Intel and AMD architecture manuals, ensuring they are correct and will be properly understood by the CPU. * no_std Compatibility: The crate is designed from the ground up to work in a #![no_std] environment, making it a perfect fit for our kernel. * Future-Proofing: It also includes abstractions for memory management (paging tables), CPU control registers, and other special instructions that we will need in future steps of our project.

Updating Your Dependencies

Adding this crate as a dependency is a straightforward process handled entirely by Cargo. You simply need to declare it in your Cargo.toml file.

Open your Cargo.toml file and add the highlighted line to the [dependencies] section:

[dependencies]
spin = "0.5.2"
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }
x86_64 = "0.14.10"

Let’s break down what this line does: * x86_64 = "0.14.10": This tells Cargo that our project depends on the x86_64 crate. We specify a version number to ensure our build is reproducible and doesn’t break if a future version of the crate introduces incompatible changes. When you next build your project, Cargo will automatically download the specified version of the x86_64 crate from the crates.io registry and compile it alongside your own code.

Verifying the Change

With the dependency added, you can verify that everything is working correctly by running a build command:

cargo build

You will see Cargo download and compile the new x86_64 crate and its own dependencies. The build should complete successfully without any errors.

If you now run your kernel with cargo run, you will see the exact same “Hello, World!” message as before. This is expected! Adding a library doesn’t change our kernel’s behavior. We have simply equipped ourselves with the necessary tools for the tasks ahead. We have added the blueprints for an IDT to our toolbox, but we haven’t started building it yet.

With the x86_64 crate now part of our project, we have all the necessary data structures at our fingertips to begin handling CPU exceptions.

Our very next task is to create a new module dedicated to interrupt handling and, within it, use the InterruptDescriptorTable type from our new dependency to create a static, empty IDT.

Further Reading

To understand the concepts we’re about to dive into, these resources will be very helpful:

Create a Rust Module for Interrupts

Mục tiêu: Organize the kernel’s code by creating a dedicated ‘interrupts’ module. This involves creating the ‘interrupts.rs’ file and declaring the module in the crate root to prepare for future interrupt handling logic.


Excellent progress! By adding the x86_64 crate in the previous task, you’ve equipped your kernel with the essential, pre-built tools needed to handle the complex, low-level details of the CPU architecture. We now have the blueprints for critical structures like the Interrupt Descriptor Table (IDT).

However, a good craftsperson needs not only the right tools but also a well-organized workshop. As our kernel grows, placing all our code into main.rs would quickly become messy and difficult to manage. Our current task is to create a dedicated, organized space for all our exception and interrupt handling logic by creating a new module.

The Power of Organization: Rust’s Module System

In Rust, a module is a namespace that allows you to group related definitions—such as structs, enums, functions, and even other modules—together. This is a cornerstone of writing clean, maintainable, and scalable code. For an operating system kernel, where different components have very distinct responsibilities (e.g., memory management, interrupt handling, screen output), separating these concerns into different modules is not just a best practice; it’s essential for managing complexity.

We will create a new module named interrupts. This module will become the definitive home for: * The definition of our Interrupt Descriptor Table (IDT). * The implementation of our various exception handler functions (e.g., for breakpoints and double faults). * The function that loads our IDT into the CPU registers.

Creating this module is a simple, two-step process in Rust.

Step 1: Create the Module File

First, we need to create the file that will contain the module’s code. In your project’s src directory, create a new file named interrupts.rs.

After you create this file, your project’s structure should look like this:

os_kernel/
├── .cargo/
│   └── config.toml
├── src/
│   ├── interrupts.rs  <-- NEW FILE
│   ├── main.rs
│   └── vga_buffer.rs
├── Cargo.toml
└── x86_64-os_kernel.json

This new file is currently empty, but it is now ready to hold all of our interrupt-handling code.

Step 2: Declare the Module in Your Crate Root

Creating the file is not enough; we also need to tell the Rust compiler that this file is part of our project and should be included in the compilation. We do this by declaring the module in our crate root, which for a binary project is src/main.rs.

Open your src/main.rs file and add the mod interrupts; declaration near the top, right after your mod vga_buffer; line.

Here are the highlighted changes for src/main.rs:

// src/main.rs

#![no_std]
#![no_main]

mod vga_buffer;
mod interrupts; // <-- ADD THIS LINE

use core::panic::PanicInfo;

// ... the rest of your main.rs file remains the same ...

This simple mod interrupts; line instructs the Rust compiler to look for a file named src/interrupts.rs (or src/interrupts/mod.rs) and incorporate its contents into the crate’s module tree. This makes all the public items we will later define inside interrupts.rs available to the rest of our project under the interrupts:: namespace.

Verifying Our Work

This task was purely structural. We’ve rearranged our workshop but haven’t built anything new yet. Therefore, running the kernel should produce the exact same result as before.

Let’s confirm this. Run the following command in your terminal:

cargo run

The project should compile successfully, and QEMU should launch showing your “Hello, World!” message, just as it did before. A successful build is our confirmation that the new module has been correctly integrated into our project.

You have now created a clean, dedicated space for one of the most critical parts of your kernel. With this organized structure in place, we are perfectly set up to begin the actual implementation of our exception handling.

In the very next task, we will start populating our new interrupts.rs file by using the InterruptDescriptorTable struct from the x86_64 crate to define a static, empty IDT.

Further Reading

To learn more about Rust’s powerful module system and how to organize larger projects, these resources are excellent:

Define the Interrupt Descriptor Table (IDT)

Mục tiêu: Create a static, lazily initialized Interrupt Descriptor Table (IDT) in the interrupts.rs module using the lazy\_static! macro and the x86\_64 crate. This sets up the foundational data structure for handling CPU exceptions and hardware interrupts.


Excellent! In the last task, you established a clean and organized home for our interrupt handling logic by creating the interrupts.rs module. This was a crucial structural step, akin to setting up a new workshop. Now, it’s time to build the most important piece of equipment in that workshop: the central workbench upon which all exception handling will be built, the Interrupt Descriptor Table (IDT).

Our current task is to define this table as a static, globally accessible structure using the powerful abstractions provided by the x86_64 crate.

What is the Interrupt Descriptor Table (IDT)?

Before we write the code, let’s understand what the IDT is and why it’s so fundamental. The IDT is a data structure residing in memory that is used directly by the CPU. When an event occurs that interrupts the normal flow of execution—such as a hardware signal from the keyboard, a divide-by-zero error, or a special int instruction—the CPU automatically looks up this table.

Think of it as the emergency contact list for the CPU. Each type of interrupt or exception has a unique number, from 0 to 255. The CPU uses this number as an index to find the corresponding entry in the IDT. Each entry in the table contains crucial information, most importantly, the memory address of the handler function that should be executed for that specific interrupt.

By creating and loading our own IDT, we take control of this process, telling the CPU exactly which of our Rust functions to run for any given event.

Creating a Static, Lazily Initialized IDT

The IDT must be available for the entire lifetime of our kernel, so it makes sense to define it as a static variable. However, there’s a small challenge: the InterruptDescriptorTable::new() function provided by the x86_64 crate is not a const function. This means it cannot be evaluated at compile time, which is a requirement for standard static initializers.

This is the perfect scenario for the lazy_static! macro, which you’ve already used successfully for our VGA WRITER. It allows us to define a static variable that is initialized safely at runtime, the very first time it is accessed.

Let’s write the code. Open your new, empty src/interrupts.rs file and add the following content:

// src/interrupts.rs

use x86_64::structures::idt::InterruptDescriptorTable;
use lazy_static::lazy_static;

lazy_static! {
    /// The Interrupt Descriptor Table for our kernel.
    /// This is a static, lazily initialized structure. The CPU will use this table
    /// to find the correct handler for interrupts and exceptions.
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        // Handler entries will be set here in the following tasks.
        idt
    };
}

This compact piece of code accomplishes a great deal. Let’s break it down in detail:

  • use x86_64::structures::idt::InterruptDescriptorTable;: We import the key struct we need from the x86_64 crate that we added as a dependency. This struct perfectly models the complex, 256-entry table that the CPU hardware expects.
  • use lazy_static::lazy_static;: We import the macro that enables safe, one-time runtime initialization for our static IDT.
  • lazy_static! { ... }: This macro block defines our static variable.
  • static ref IDT: InterruptDescriptorTable: Inside the macro, we declare a static variable named IDT. The ref keyword indicates that we’re creating a static reference, and its type is the InterruptDescriptorTable struct we just imported.
  • let mut idt = InterruptDescriptorTable::new();: This is the core of the initialization. We call the new() function on the struct, which creates a fresh IDT where all 256 entries are marked as “not present.” This gives us a clean slate to work with.
  • idt: The final expression in the block is the value that the static variable will hold. In this case, we return our newly created, empty table.

Verifying Our Progress

With this code in place, you have successfully defined the data structure that will form the backbone of your kernel’s error handling and hardware interrupt system.

However, if you run your kernel now, you will see no change.

cargo run

QEMU will launch and display the same “Hello, World!” message as before. This is absolutely correct and expected. We have defined the IDT in our kernel’s memory, but we haven’t done two crucial things yet:

  1. We haven’t added any handler functions to its entries.
  2. We haven’t told the CPU to actually use our new IDT.

A successful compilation is the sign of success for this task. You have laid the foundation perfectly.

Now that we have our empty IDT structure, the next logical step is to start filling it. In the upcoming task, we will write our very first exception handler—for the “Breakpoint” exception—and register it in our IDT.

Further Reading

To learn more about the concepts introduced in this task, these resources are invaluable:

Create a Breakpoint Exception Handler in Rust

Mục tiêu: Define a function to handle the CPU’s breakpoint exception. This task includes refactoring to create print! and println! macros for kernel logging and implementing the handler function using the special x86-interrupt calling convention to print the interrupt stack frame.


You have laid the perfect foundation for handling CPU exceptions by creating an empty, lazily-initialized Interrupt Descriptor Table (IDT). This table is the “emergency contact list” for the CPU, but right now, all its entries are blank. Our next step is to start filling in this list with the actual emergency contacts: the handler functions.

We will begin with one of the simplest and most useful exceptions to handle: the Breakpoint exception. This exception is special because it’s designed to be triggered intentionally by software, making it the perfect candidate for testing our new interrupt handling mechanism. Our current task is to create the Rust function that will serve as the handler for this exception.

A Quick Refactor: Creating print! and println! Macros

Before we write the handler, let’s make a small but powerful improvement to our kernel. In our exception handler, we will want to print information to the screen. Calling crate::vga_buffer::WRITER.lock()... every time is verbose. A much more idiomatic and convenient approach is to create our own print! and println! macros that wrap this logic. This is a common pattern in #![no_std] development.

Let’s add these macros to your src/main.rs file. They need to be at the crate root to be exported and made available to other modules like interrupts.rs.

Add the following code to src/main.rs, for example, right after your mod declarations:

// In src/main.rs

// ... (your existing #![...] attributes and mod declarations) ...

#[macro_export]
macro_rules! print {
    ($($arg:tt)*) => ($crate::vga_buffer::WRITER.lock().write_fmt(format_args!($($arg)*)).unwrap());
}

#[macro_export]
macro_rules! println {
    () => ($crate::print!("\n"));
    ($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}

// ... (the rest of your main.rs file) ...

Let’s break this down: * #[macro_export]: This attribute makes the macro visible to other modules in the crate. Without it, our println! would only be usable within main.rs. * macro_rules! println { ... }: This defines our println macro. * The first arm, () => (...), handles the case where println! is called with no arguments, simply printing a newline. * The second arm, ($($arg:tt)*) => (...), is the general case. It captures any arguments ($arg) passed to the macro. * It then expands to a call to our print! macro, adding a newline character \n at the end. * macro_rules! print { ... }: This is the core macro. * It accesses our global WRITER and locks it. * It uses the write_fmt method, which comes from the core::fmt::Write trait we implemented earlier. This method is the engine that processes Rust’s formatting machinery. * format_args! is a standard macro that takes the arguments and bundles them into a structure that write_fmt can understand. * .unwrap() is safe here because our write_str implementation never returns an error.

With these macros in place, we can now write println!("Hello {}", "world"); from anywhere in our kernel, which is much cleaner!

Defining the Breakpoint Handler

Now, let’s create the actual handler function. This code goes into your src/interrupts.rs file.

// In src/interrupts.rs

use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};
use lazy_static::lazy_static;
use crate::println; // Import our new println macro

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        // We will set the handler here in the next task
        idt
    };
}

/// The handler function for the breakpoint exception.
/// This function is called by the CPU when an `int3` instruction is executed.
extern "x86-interrupt" fn breakpoint_handler(
    stack_frame: InterruptStackFrame)
{
    println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);
}

This function is the core of this task. Let’s analyze every part of its definition, as it’s very different from a normal Rust function.

  • extern "x86-interrupt": This is a calling convention. A calling convention (or ABI, Application Binary Interface) is a set of low-level rules that dictates how functions are called, how arguments are passed (e.g., on the stack or in CPU registers), and how return values are handled.

    • Normally, Rust functions use a convention that is optimized for speed but might not preserve all CPU registers.
    • An interrupt can happen at any time, between any two instructions. It is absolutely critical that the interrupted code can resume exactly where it left off, with all of its registers intact.
    • The x86-interrupt calling convention ensures this. It’s a special convention that tells the compiler to generate a function prologue that saves all general-purpose registers before running the function body and a function epilogue that restores them all before returning. This makes the handler safe but slightly slower than a normal function.
  • fn breakpoint_handler(stack_frame: InterruptStackFrame): This is the function signature.

    • The CPU automatically pushes information about the interrupt onto the stack before calling our handler. This includes the instruction pointer at the time of the interrupt and the state of the CPU flags.
    • The x86_64 crate provides the InterruptStackFrame struct, which is a type-safe representation of this information. The CPU passes a pointer to this structure as an argument to our handler function.
    • This stack_frame is an incredibly powerful tool for debugging. It tells us exactly which instruction caused the exception.
  • The Function Body:

    • println!("EXCEPTION: BREAKPOINT\n{:#?}", stack_frame);: We use our newly created println! macro to print a message to the screen, confirming our handler was called.
    • We also print the stack_frame itself. The :#? formatter is called “pretty-print debug,” which will print the struct with its field names and nice indentation, making it easy to read.
    • Note: We are not adding an infinite loop (loop {}) here. A breakpoint is a non-fatal exception. After the handler finishes, we want the program to resume execution at the instruction immediately following the int3. The special x86-interrupt calling convention, combined with an iretq instruction that the compiler generates for us, handles this return process correctly.

What We’ve Accomplished

You have now written your first exception handler! It is correctly defined to follow the interrupt calling convention and prints useful debugging information.

If you cargo run your project now, you will still see the same “Hello, World!” message. This is the correct and expected outcome. We have successfully defined the handler function, but we haven’t yet told the IDT where to find it. The CPU’s “emergency contact list” is still blank for the breakpoint entry.

Our very next task is to bridge this gap: we will modify our IDT initialization logic to add a pointer to our new breakpoint_handler at the correct index, officially registering it with the system.

Further Reading

Register the Breakpoint Handler in the IDT

Mục tiêu: Update the Interrupt Descriptor Table (IDT) to register the breakpoint\_handler function. This task involves using the type-safe set\_handler\_fn method to connect the handler to the CPU’s breakpoint exception vector.


In the previous task, you expertly crafted your very first exception handler, the breakpoint_handler. You defined it with the special "x86-interrupt" calling convention, ensuring it can safely handle CPU interrupts without corrupting the state of the interrupted program. This handler function is like a trained paramedic, ready and waiting to respond to an emergency call.

However, just having a paramedic on standby isn’t enough; you also need to make sure the emergency dispatch system knows their phone number. Our Interrupt Descriptor Table (IDT) is that dispatch system, and right now, its entry for the breakpoint exception is empty. Our current task is to connect the two by registering our breakpoint_handler function in the IDT at the correct index.

The CPU’s “Phonebook”: Exception Vector Numbers

When a specific exception occurs, the CPU doesn’t search for a handler by name. Instead, it uses a predefined number, known as an interrupt vector or vector number. The x86_64 architecture reserves the first 32 vector numbers (0-31) for specific CPU exceptions. For example, a division-by-zero error is always vector 0, and an invalid memory access (a page fault) is vector 14.

The Breakpoint exception, which is triggered by the int3 instruction, is assigned vector number 3. Therefore, to handle this exception, we must place a pointer to our breakpoint_handler function in the 3rd entry of our IDT (remembering that indices are 0-based).

A Type-Safe Approach to Setting Handlers

We could manually access the third entry of the IDT like an array (idt[3] = ...), but this approach is brittle and relies on “magic numbers”. A typo like idt[2] or idt[4] would lead to maddeningly difficult bugs, as the wrong handler would be called for the wrong exception.

This is where the power of the x86_64 crate truly shines. The InterruptDescriptorTable struct it provides offers a much safer and more readable API. Instead of using numeric indices for the first 32 standard exceptions, it provides named fields. To access the entry for the breakpoint exception, we can simply use idt.breakpoint. This completely eliminates the risk of off-by-one errors and makes our code self-documenting.

Let’s now update our IDT initialization logic to register the handler. Open your src/interrupts.rs file and modify the lazy_static! block to include the handler registration.

Here are the highlighted changes for src/interrupts.rs:

// In src/interrupts.rs

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

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt
    };
}

// ... breakpoint_handler function remains the same ...

This single new line is the crucial connection we’ve been working towards. Let’s break it down:

  • idt.breakpoint: This accesses the 3rd entry of the IDT through a clearly named field. The type of this field is IdtEntry<HandlerFunc>, which represents a single entry in the table.
  • .set_handler_fn(breakpoint_handler): This is the key method call.
    • It takes a function pointer to our breakpoint_handler. The Rust compiler checks that the function’s signature and calling convention match what is expected for an exception handler, providing compile-time safety.
    • Internally, this method performs all the necessary unsafe operations to correctly format the IDT entry. It sets the “present” bit, encodes the handler function’s address into the correct fields, and configures other flags. It completely abstracts away the complex, low-level bit-fiddling required by the CPU architecture.

What to Expect Now

You have now successfully defined your IDT and registered your first valid exception handler within it. The “emergency contact list” now has a valid entry for breakpoints.

However, if you run your kernel now, you will see no change whatsoever.

cargo run

The QEMU window will appear, and your familiar “Hello, World!” message will be printed to the screen. This is the correct and expected outcome. Why? Because we have only performed half of the process. We’ve meticulously prepared a new contact list (our IDT in memory), but we haven’t yet told the CPU’s dispatch center to throw away its old, empty list and start using our new one. The CPU is still operating with a default IDT, completely unaware of the one we’ve so carefully constructed.

You have set the stage perfectly. All the data structures are now in place and correctly configured in memory. The next and final piece of the puzzle is to inform the CPU about our new IDT. In the upcoming task, we will write a function that uses the special lidt (“Load Interrupt Descriptor Table Register”) assembly instruction to make our IDT active.

Further Reading

To learn more about the topics covered in this task, these resources are highly recommended:

Load the Interrupt Descriptor Table (IDT)

Mục tiêu: Create a public init\_idt function that uses the x86\_64 crate’s .load() method to load the static Interrupt Descriptor Table into the CPU’s IDTR register, making it the active table for handling interrupts and exceptions.


You have done an excellent job. You’ve created a handler function and registered it in your Interrupt Descriptor Table (IDT). Your IDT object in memory is now a perfectly configured “emergency contact list” for the CPU. However, as we noted, the CPU is completely unaware of its existence. It continues to use a default, empty table from its initial state.

Our current task is to bridge this final gap. We need to explicitly instruct the CPU to stop using its old IDT and start using the one we have so carefully prepared. This is achieved by loading our IDT’s location into a special CPU register.

The IDTR: The CPU’s Pointer to the IDT

The CPU doesn’t search through memory to find the active IDT. Instead, it relies on a special-purpose register called the IDTR (Interrupt Descriptor Table Register). This register stores two crucial pieces of information:

  1. The base address: The starting memory address of the active IDT.
  2. The limit: The size of the active IDT in bytes.

When an interrupt or exception occurs, the CPU hardware automatically uses the address in the IDTR to locate the table and find the correct handler. Our goal, therefore, is simple: we must load the address and size of our static IDT into the IDTR.

The lidt Instruction: A Privileged Operation

You cannot modify special registers like the IDTR with a normal mov instruction. The x86_64 architecture provides a specific, privileged assembly instruction for this purpose: lidt (Load Interrupt Descriptor Table).

This instruction is privileged, meaning it can only be executed when the CPU is in Ring 0, the highest privilege level, which is where our kernel code runs. This is a critical security feature of the CPU. If any user-level application could change the IDT, it could hijack interrupt handlers and take complete control of the system.

While we could write inline assembly to execute lidt ourselves, this would be unsafe and require manual creation of a pointer structure. Once again, the x86_64 crate provides a safe, high-level abstraction that handles this complexity for us.

Implementing init_idt

Let’s create a public initialization function in our interrupts module that will be responsible for loading the IDT. This function will be called once from our kernel’s entry point to set everything up.

Add the following function to your src/interrupts.rs file:

// In src/interrupts.rs

// ... (existing use statements, lazy_static! block, and breakpoint_handler) ...

/// Initializes the Interrupt Descriptor Table (IDT).
/// This function loads our custom IDT into the CPU's IDTR register.
pub fn init_idt() {
    IDT.load();
}

This is all the code we need. It seems deceptively simple, but the call to IDT.load() is doing a lot of important work behind the scenes. Let’s break it down.

  • pub fn init_idt(): We define a new public function, init_idt, so that we can call it from main.rs in a later step.
  • IDT.load(): This is the key method call. We are calling the load method on our static IDT instance.

The Safety Guarantee of .load() and the 'static Lifetime

The load method provided by the x86_64 crate has a very specific signature: pub fn load(&'static self). Notice the 'static lifetime on self. This is a powerful compile-time guarantee from Rust. It means this method can only be called on an InterruptDescriptorTable that is guaranteed to live for the entire duration of the program ('static).

Why is this so important? Imagine if you could create an IDT on the stack inside a function, load it into the IDTR, and then the function returned. The stack memory would be deallocated and reused, but the IDTR would still be pointing to that now-invalid memory. The very next interrupt would cause the CPU to jump to a garbage location, resulting in an immediate triple fault and system reset.

By requiring a 'static lifetime, the x86_64 crate’s authors use the Rust compiler to prevent this entire class of catastrophic bugs. Since our IDT is defined in a lazy_static! block, it has a 'static lifetime, and this call is perfectly valid and safe.

Inside the load method, the x86_64 crate performs the following steps:

  1. It gets a pointer to our IDT and calculates its size.
  2. It creates a DescriptorTablePointer struct, which is the exact format the lidt instruction expects.
  3. Within an unsafe block, it executes the lidt instruction with a pointer to this descriptor.

This provides us with the perfect abstraction: a single, safe method call that performs a complex, privileged, and unsafe operation correctly.

Checking Our Progress

You have now created the final piece of the puzzle: a function that activates our custom IDT.

If you cargo run your kernel now, you will see no change in the output. The QEMU window will launch and display your “Hello, World!” message as before. This is the correct and expected outcome.

A successful compilation and run is our proof of success. It means that our init_idt function is valid and, if it were called, it would load the IDT without causing a fault. We have successfully armed the system, but we haven’t pulled the trigger yet.

The next steps are to call our new init_idt() function from our _start entry point and then intentionally trigger the breakpoint exception to test if our handler is correctly invoked.

Further Reading

Call the IDT Initialization Function from the Kernel Entry Point

Mục tiêu: Modify the kernel’s \_start entry point function to call init\_idt. This crucial step loads the Interrupt Descriptor Table at the earliest possible moment, activating exception handlers for the entire system before any other code runs.


In our previous step, you successfully created the init_idt function, a powerful piece of code that encapsulates the privileged lidt instruction. This function is the switch that tells the CPU to start using our custom Interrupt Descriptor Table (IDT). However, a switch is useless until it’s flipped. Our current, crucial task is to call this initialization function from the right place and at the right time.

When and Where to Initialize Critical Systems

The _start function is the genesis of our kernel—it is the very first line of our Rust code that gets executed after the bootloader hands over control. It is therefore the perfect, and indeed the only, place to perform the most fundamental system initializations.

The IDT is one of these critical systems. We must ensure it is loaded and active before any other significant operations take place. By setting up our exception handlers at the very beginning, we create a safety net that will be present for all subsequent code we write. If an error were to occur later during the initialization of other components (like memory management or device drivers), our pre-loaded IDT would be ready to catch the resulting CPU exception and prevent a silent, catastrophic crash.

Calling the Initialization Function

Let’s now modify our kernel’s entry point to perform this initialization. We will call the public init_idt function that we defined in our interrupts module.

Open your src/main.rs file and add the call to init_idt() at the very beginning of your _start function.

// In src/main.rs

// ... (attributes, mod declarations, and macros) ...

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    println!("{}", _info); // We can now print the panic info!
    loop {}
}

#[no_mangle]
pub extern "C" fn _start() -> ! {
    // Initialize our Interrupt Descriptor Table.
    // This is the first thing we do, to ensure our exception
    // handlers are active from the very beginning.
    crate::interrupts::init_idt();

    // The rest of the function remains the same.
    println!("Hello, World!");
    use core::fmt::Write;
    writeln!(crate::vga_buffer::WRITER.lock(), "The numbers are {} and {}", 42, 1337).unwrap();

    loop {}
}

Let’s examine the new line and a small improvement we can make to our panic handler:

  • crate::interrupts::init_idt();: This is the function call that activates our IDT.

    • crate: This is a path that always refers to the root of our current crate (our os_kernel project).
    • interrupts: This is the module where our function is defined.
    • init_idt: This is the pub fn we created in the previous step. This single line of code is the final step in activating our exception handling mechanism. When this line executes, the IDT.load() method is called, which in turn executes the lidt assembly instruction, pointing the CPU’s IDTR register to our custom IDT.
  • Improvement to panic_handler: Notice that we’ve updated the panic function to use our println! macro: println!("{}", _info);. Now that we have a robust printing mechanism and our custom macros, we can make our panic handler much more useful by having it print the file, line, and message of the panic to the screen before it halts.

The Invisible Success

With this code in place, it’s time to run our kernel.

cargo run

QEMU will launch, and you will see your familiar “Hello, World!” message printed to the screen. The output will be exactly the same as before.

This might feel anticlimactic, but it is the picture of success. It means our init_idt function executed correctly, performed its privileged operation, and successfully loaded our new IDT without causing any faults. The system is now armed. Our breakpoint handler is registered, and the CPU knows where to find it. All the groundwork has been laid.

Now that the trap is set, our very next task is to intentionally spring it. We will add a special instruction to our _start function that triggers the breakpoint exception, allowing us to finally see our handler in action and confirm that the entire mechanism is working perfectly.

Further Reading

To learn more about the typical initialization sequence of a kernel, these resources are excellent:

Trigger a Breakpoint Exception

Mục tiêu: Intentionally trigger a breakpoint exception using the int3 instruction to test the custom interrupt handler and verify the end-to-end interrupt handling pipeline.


You’ve done a phenomenal job setting up the entire interrupt handling pipeline. In the previous task, you flipped the master switch by calling init_idt() from your _start function. This single line of code armed a complex system: your custom Interrupt Descriptor Table (IDT) is now active, and the CPU knows exactly where to find your breakpoint_handler if it’s ever needed. The trap is meticulously set.

It’s now time to intentionally spring that trap. Our current task is to trigger the breakpoint exception from our code and verify that our handler is called correctly, demonstrating that the entire system works from end to end.

The int3 Instruction: The Software Interrupt

Most CPU exceptions, like page faults or division-by-zero errors, are triggered by hardware in response to a faulty operation. The Breakpoint Exception (Interrupt Vector 3) is special. It’s designed to be triggered on purpose by software. The dedicated assembly instruction for this is int3.

This instruction is the cornerstone of debuggers. When you set a breakpoint in your code using a tool like GDB or the Visual Studio Debugger, the debugger replaces the instruction at that location with an int3 instruction. When the program execution reaches that point, the CPU hits the int3, triggers the breakpoint exception, and transfers control to the debugger’s handler, allowing you to inspect the program’s state.

We will do the same thing, but instead of transferring control to a debugger, the CPU will transfer control to our own breakpoint_handler.

Triggering the Breakpoint Safely from Rust

While we could use inline assembly (asm!) to execute the int3 instruction, this would require an unsafe block. As always, it’s best to look for a safe abstraction first. The x86_64 crate provides exactly what we need in the form of a simple, safe function: x86_64::instructions::interrupts::int3(). This function is nothing more than a minimal, safe wrapper around the single int3 instruction.

Let’s now modify our _start function to call this function, intentionally triggering the exception.

Open your src/main.rs file and add the int3() call right after your first println!.

// In src/main.rs

// ... (attributes, mod declarations, and macros) ...

#[panic_handler]
// ... (panic handler remains the same) ...

#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello, World!");

    // Initialize our Interrupt Descriptor Table.
    crate::interrupts::init_idt();

    // Trigger a breakpoint exception to test our handler.
    x86_64::instructions::interrupts::int3();

    // The rest of the function remains the same.
    use core::fmt::Write;
    writeln!(crate::vga_buffer::WRITER.lock(), "This should be printed after the breakpoint!").unwrap();

    // We add another message to confirm execution continues.
    println!("Execution continues after breakpoint!");

    loop {}
}

We’ve made a few small but important changes to _start():

  1. We moved init_idt() to be the first thing that happens after printing our initial “Hello World”. It’s good practice to initialize core systems early, but for this test, we want to see the “Hello World” first to confirm the sequence.
  2. x86_64::instructions::interrupts::int3();: This is the crucial new line. When this line executes, the CPU will immediately halt the execution of _start and jump to our registered handler.
  3. We’ve updated the subsequent writeln! and added a println! to make it clear that execution continues after the handler has finished.

The Moment of Truth: Seeing the Handler in Action

Now, run your kernel.

cargo run

QEMU will launch, and you will see a beautiful sight on the screen, which should look exactly like this:

Hello, World!
EXCEPTION: BREAKPOINT
InterruptStackFrame {
    instruction_pointer: VirtAddr(0x...),
    code_segment: 8,
    cpu_flags: 0x...,
    stack_pointer: VirtAddr(0x...),
    stack_segment: 0,
}
This should be printed after the breakpoint!
Execution continues after breakpoint!

This output is a massive success! It confirms that every single part of our exception handling system is working perfectly. Let’s trace the flow of control to understand what just happened:

  1. The _start function begins and prints “Hello, World!”.
  2. init_idt() is called, loading our IDT.
  3. int3() is executed.
  4. The CPU immediately stops executing _start. It saves the current state (like the instruction pointer and flags) onto the stack.
  5. It looks at our active IDT, goes to index 3, and finds the address of our breakpoint_handler.
  6. It jumps to our breakpoint_handler function.
  7. Our handler executes, printing “EXCEPTION: BREAKPOINT” and the contents of the InterruptStackFrame, which gives us a snapshot of the CPU’s state at the moment of the interrupt.
  8. The handler function returns. The extern "x86-interrupt" calling convention ensures the compiler emits a special iretq (“interrupt return”) instruction.
  9. The iretq instruction tells the CPU to pop the saved state off the stack and resume execution where it left off.
  10. Execution resumes in _start at the very next instruction after the int3() call.
  11. The final writeln! and println! are executed, proving that the interrupt was handled gracefully and the program continued.
  12. The final loop {} is reached, and the kernel halts.

You have now built and tested a robust mechanism for handling CPU exceptions. This is a fundamental building block for a stable operating system.

What’s Next?

Our breakpoint handler is for a non-fatal, recoverable exception. But what happens if something goes catastrophically wrong? For example, what if a bug in one of our exception handlers causes another exception? This scenario leads to a dreaded Double Fault exception. A double fault is a critical error, and if we don’t handle it, the system will triple fault and reset.

In our next task, we will implement a handler for the Double Fault exception. This will serve as a final, critical safety net, ensuring that even if our kernel’s error handling has an error, we can still catch it and provide diagnostic information instead of crashing silently.

Further Reading

Implement a Double Fault Exception Handler

Mục tiêu: Create and register a handler for the critical Double Fault exception (#DF) in a Rust kernel. This handler acts as a final safety net to catch errors when the CPU fails to invoke a primary exception handler, preventing a system-resetting triple fault.


Fantastic! You’ve successfully built and tested a working exception handling pipeline. By triggering an int3 instruction, you proved that your kernel can gracefully catch a CPU exception, execute a custom handler, and then resume normal operation. This is a robust foundation for a stable system.

The breakpoint exception, however, is a “benign” and recoverable event. Now, we must prepare our kernel for a much more serious scenario: what happens when something goes catastrophically wrong, even while handling another error? This leads us to the crucial task of implementing a handler for the Double Fault exception, our kernel’s ultimate safety net.

The Last Line of Defense: The Double Fault

A Double Fault (#DF, vector 8) is a special and critical exception. It does not occur because of a specific instruction; rather, it’s triggered when the CPU fails to invoke an exception handler for a prior exception.

Consider this sequence of events:

  1. Your code performs an illegal operation, for example, trying to access an invalid memory address. This triggers a Page Fault (exception 14).
  2. The CPU attempts to transfer control to the Page Fault handler. It consults our Interrupt Descriptor Table (IDT) at index 14.
  3. However, we haven’t registered a handler for Page Faults yet, so that IDT entry is empty.
  4. The CPU’s attempt to call a non-existent handler is a critical failure. It cannot proceed. At this point, it gives up on the original exception and triggers a Double Fault.

If the CPU then fails to call the Double Fault handler (for example, if that entry in the IDT is also empty), it triggers a Triple Fault. A triple fault is uncatchable and unrecoverable. The CPU hardware immediately resets the system. On QEMU, this usually causes the emulator to exit abruptly.

Therefore, a working Double Fault handler is non-negotiable. It’s our guarantee that even if the kernel enters a deeply broken state, we can still catch the failure and print diagnostic information instead of silently crashing.

Implementing the Double Fault Handler

The handler for a double fault has a slightly different signature than our breakpoint handler. Certain exceptions, including this one, cause the CPU to push an extra error code onto the stack to provide more information about the fault. The x86_64 crate models this for us.

Another key difference is that a double fault is non-recoverable. The system is in an unknown and likely corrupted state. We cannot safely return from this handler. Therefore, it must be a diverging function, signified by the -> ! return type.

Let’s add the handler to src/interrupts.rs.

// In src/interrupts.rs

// ... (existing use statements, lazy_static! block, and breakpoint_handler) ...

/// The handler function for the double fault exception.
/// This is a critical safety net. A double fault is non-recoverable,
/// so this handler must not return. It prints an error and halts.
extern "x86-interrupt" fn double_fault_handler(
    stack_frame: InterruptStackFrame,
    _error_code: u64,
) -> ! {
    panic!("EXCEPTION: DOUBLE FAULT\n{:#?}", stack_frame);
}

Let’s break down this function: * extern "x86-interrupt": We use the same special interrupt calling convention to ensure all registers are preserved. * _error_code: u64: We add a second parameter to accept the error code pushed by the CPU. For double faults, this code is always 0, so we don’t use it and prefix it with an underscore (_) to avoid a compiler warning. However, it must be part of the signature to correctly match what the CPU puts on the stack. * -> !: This is the never type. It tells the Rust compiler that this function is diverging and will never return. This is crucial for safety. * panic!(...): Instead of just printing, we now call panic!. This serves two purposes: it prints our formatted error message using the panic handler we configured in main.rs, and it fulfills the requirement of being a diverging function, as panic! itself never returns. This halts the system in a clean, defined way.

Registering the Handler

Now, we must register this new handler in our IDT. Just like with the breakpoint, the x86_64 crate provides a convenient, type-safe field for this.

Modify the lazy_static! block in src/interrupts.rs to add the double fault handler.

// In src/interrupts.rs

// ...

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt.double_fault.set_handler_fn(double_fault_handler); // <-- ADD THIS LINE
        idt
    };
}

// ... (breakpoint_handler and double_fault_handler) ...

This is it. The single line idt.double_fault.set_handler_fn(double_fault_handler); registers our function at the correct index (8) for the Double Fault exception.

Testing Our Final Safety Net

How do we test it? We need to create the exact conditions for a double fault: trigger an exception for which there is no handler. We can do this by causing a page fault by dereferencing an invalid memory address.

Let’s modify our _start function in src/main.rs to trigger this condition.

// In src/main.rs

// ...

#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Hello, World!");

    crate::interrupts::init_idt();

    // This code will trigger a page fault, because the address 0xdeadbeef is not mapped.
    // Since we don't have a page fault handler yet, this will cause a double fault.
    unsafe {
        *(0xdeadbeef as *mut u64) = 42;
    };

    // We should never reach this code, because the double fault handler halts the system.
    println!("This should not be printed!");

    loop {}
}

Note: we have removed the int3 call and other printing from the previous task to focus on this test.

This unsafe block attempts to write the value 42 to the memory address 0xdeadbeef. This is an unmapped, invalid address, which will cause a Page Fault. Since we have no page fault handler, the CPU will immediately escalate this to a Double Fault, and our new handler should be called.

The Result

Run your kernel now:

cargo run

QEMU will start, and you will see the following output on the screen:

Hello, World!
panicked at 'EXCEPTION: DOUBLE FAULT
InterruptStackFrame {
    instruction_pointer: VirtAddr(0x...),
    code_segment: 8,
    cpu_flags: 0x...,
    stack_pointer: VirtAddr(0x...),
    stack_segment: 0,
}', src/interrupts.rs:NN:5

(The exact addresses and line number (NN) may vary.)

This output is a resounding success! It proves that:

  1. The invalid memory access correctly triggered a page fault.
  2. The lack of a page fault handler correctly escalated the situation to a double fault.
  3. Our double_fault_handler was successfully invoked.
  4. The handler printed its diagnostic message via panic! and then halted the system, preventing the println!("This should not be printed!"); line from ever being executed.

You have now built a truly robust foundation for your kernel. With handlers for both standard and critical, cascading exceptions, your system is no longer fragile and can report errors gracefully instead of crashing.

What’s Next?

So far, we have only dealt with exceptions, which are synchronous events caused by the code that is currently executing. The next major frontier in OS development is handling interrupts, which are asynchronous events originating from external hardware devices like timers, keyboards, and network cards.

In the next step, you will learn how to configure the Programmable Interrupt Controller (PIC), the hardware chip responsible for routing these external interrupts to the CPU, preparing your kernel to interact with the outside world.

Further Reading