Implement the Timer Interrupt Handler

Mục tiêu: Create a handler function for the hardware timer interrupt from the Programmable Interval Timer (PIT). The function will use the ‘x86-interrupt’ calling convention, provide visual feedback by printing to the screen, and send an End-of-Interrupt (EOI) signal to the PIC.


In the last step, you masterfully configured the hardware interrupt system. You remapped the PICs to safe vectors and then enabled interrupts on the CPU by executing the sti instruction. This resulted in a double fault, which, as we discussed, was a sign of resounding success. It proved that the entire chain is working: the hardware timer is firing, the PIC is correctly routing the signal to vector 32, the CPU is listening, and our double-fault safety net caught the error that occurred because the handler was missing.

Our current task is to build that missing piece: a dedicated handler function for the timer interrupt. This will be the first function in our kernel that responds to an event from the outside world.

The System Heartbeat: The Programmable Interval Timer (PIT)

Before writing the code, let’s briefly discuss the hardware we’re about to service. The Programmable Interval Timer (PIT) is a standard chip on x86 motherboards that generates periodic interrupts. It’s the default “heartbeat” of the system, and operating systems use it for everything from updating the system clock to, most importantly, driving the task scheduler for multitasking. By default, it fires at a frequency of about 18.2 times per second.

For our purposes today, we won’t be changing its frequency. We’ll simply use its steady, predictable signal to verify that our handler is being called correctly.

Defining the Timer Handler Function

Just like our CPU exception handlers, a hardware interrupt handler requires a specific structure to be safe. It must use the x86-interrupt calling convention to ensure that it doesn’t corrupt the state of whatever code was running when the interrupt occurred.

Let’s create this handler in src/interrupts.rs. For now, its job will be to simply print a character to the screen. This will give us simple, immediate visual feedback that it’s working.

// In src/interrupts.rs

// ... (existing use statements and constants) ...
use crate::{print, println}; // Make sure `print!` is in scope

// ... (PICS static, IDT lazy_static, and other handlers) ...

/// The handler function for the hardware timer interrupt (IRQ 0).
/// This function is called at a regular interval by the PIT.
extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame)
{
    print!(".");
}

Let’s break this down: * extern "x86-interrupt" fn timer_interrupt_handler(...): We define the function with the special interrupt calling convention. This is essential because a timer interrupt is asynchronous—it can happen at any time, in the middle of any other code. This convention ensures all registers are saved before our code runs and restored afterward, preserving the state of the interrupted program. * _stack_frame: InterruptStackFrame: The CPU still pushes a stack frame when a hardware interrupt occurs, giving us information about the state of the interrupted code. We don’t need to use it for this simple handler, so we prefix it with an underscore to avoid a compiler warning. * print!(".");: We use our print! macro to write a single period to the screen. If our handler is called repeatedly, we expect to see a stream of dots appearing on the screen.

The Critical Handshake: End-of-Interrupt (EOI)

We’re not quite done. There’s one more crucial step that every hardware interrupt handler must perform. The PIC, having sent an interrupt signal to the CPU, now waits for an acknowledgment that the interrupt has been handled. This acknowledgment is called an End-of-Interrupt (EOI) signal.

Think of it like hanging up the phone after a call. If you don’t send the EOI signal, the PIC assumes the interrupt is still being processed. It will not send any more interrupts from that IRQ line, or any lines with a lower priority. Forgetting this signal is a classic bug that leads to a system where interrupts seem to “fire once and then stop.”

The pic8259 crate provides a safe way to send this signal through our global PICS static. We must tell it which interrupt we are acknowledging. Since the timer is on IRQ 0, which we mapped to vector PIC_1_OFFSET, this is the number we must send.

Let’s add the EOI signal to the end of our handler.

// In src/interrupts.rs

// ...

extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame)
{
    print!(".");

    // The EOI signal must be sent to the PIC to let it know that
    // the interrupt has been handled. Otherwise, no more interrupts
    // of that type will be delivered.
    // This operation is `unsafe` because sending an EOI for the wrong
    // interrupt number can lead to system instability.
    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(PIC_1_OFFSET);
    }
}

The call to notify_end_of_interrupt is wrapped in an unsafe block. This is a deliberate choice by the crate’s author. Sending an EOI signal for the wrong interrupt vector can confuse the PIC and potentially lock up the entire interrupt system. The unsafe block is our assertion to the Rust compiler that we know what we’re doing: we are correctly acknowledging the interrupt vector associated with the timer.

What to Expect Now

You have now created a complete and correct handler function for the system’s timer interrupt. It provides visual feedback and correctly performs the EOI handshake with the hardware.

However, if you run your kernel now, you will see the exact same double fault as before.

cargo run

Why? We have built the new fire station, but we haven’t told the 911 dispatch system (our IDT) its address yet. The function exists in our compiled kernel, but the IDT entry for vector 32 is still empty. A successful compilation is the only sign of success for this specific task.

You are perfectly positioned for the next step. In the upcoming task, we will finally bridge this gap by registering our timer_interrupt_handler in the IDT, which will stop the double fault and allow us to see our stream of dots appear on the screen.

Further Reading

Register the Hardware Timer Interrupt Handler

Mục tiêu: Modify the Interrupt Descriptor Table (IDT) to register the timer\_interrupt\_handler at the correct hardware interrupt vector. This connects the PIC’s timer IRQ to the Rust handler function, enabling the kernel to process hardware interrupts and fixing the double fault.


You have done an excellent job in the previous task by creating a complete and correct handler function for the hardware timer interrupt. Your timer_interrupt_handler is a fully-equipped paramedic, ready to respond to the system’s heartbeat, complete with the knowledge of how to perform the crucial End-of-Interrupt (EOI) handshake.

However, as you’ve seen, having the paramedic ready isn’t enough. The emergency dispatch system—our Interrupt Descriptor Table (IDT)—still has no record of them. When the timer interrupt arrives on vector 32, the CPU checks the IDT, finds a blank entry, and escalates the situation to a Double Fault. Our current, crucial task is to fix this by registering our new handler at the correct index in the IDT, finally connecting the hardware signal to our Rust code.

From Vector Number to IDT Index

We need to tell the IDT to associate interrupt vector 32 with our timer_interrupt_handler function. Why 32? Let’s quickly re-trace the path:

  1. The hardware timer is physically connected to IRQ 0 on the Master PIC.
  2. In our init function, we remapped the Master PIC to start its vector range at PIC_1_OFFSET, which we defined as 32.
  3. Therefore, the PIC will translate an IRQ 0 signal into interrupt vector 32 + 0 = 32.

This means we must modify the 32nd entry (remembering that indices are 0-based) of our IDT.

Unlike the first 32 CPU exceptions, which have convenient named fields in the x86_64 crate’s InterruptDescriptorTable struct (like idt.breakpoint or idt.double_fault), the hardware interrupt entries from vector 32 onwards do not. For these, we must use standard array indexing syntax: idt[index].

We will use our PIC_1_OFFSET constant as the index. This is a best practice that avoids “magic numbers” and makes our code self-documenting. If we were ever to change the offset, we’d only need to do it in one place. One small detail is that the IDT is indexed by a usize, but our constant is a u8. We must perform a simple type cast using the as keyword to satisfy the compiler.

Let’s make the change. Open your src/interrupts.rs file and add the highlighted line to your IDT’s lazy_static! block.

// 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 the handler for the timer interrupt (IRQ 0).
        // The index is our PIC_1_OFFSET, which we set to 32.
        idt[PIC_1_OFFSET as usize]
            .set_handler_fn(timer_interrupt_handler);

        idt
    };
}

This single new statement completes the entire interrupt handling chain. Let’s break it down one more time:

  • idt[PIC_1_OFFSET as usize]: This accesses the 32nd entry of the InterruptDescriptorTable. We use our public constant for clarity and cast it to usize, the required type for indexing.
  • .set_handler_fn(timer_interrupt_handler): This is the familiar, type-safe method from the x86_64 crate. It takes a pointer to our timer_interrupt_handler, verifies its signature is correct at compile time, and configures the IDT entry with the handler’s address and the necessary flags to make it valid.

The Moment of Truth: Seeing the Heartbeat

With this final connection made, the double fault should no longer occur. Let’s run the kernel and see our very first hardware interrupt in action.

cargo run

QEMU will launch, and you should now see the following output:

Hello, World!
It did not crash!
..................................................

This is a monumental success! The stream of dots proves that every single piece of the hardware interrupt system is working in perfect harmony:

  1. Your _start function initializes the PICs and the IDT, and then enables interrupts.
  2. It enters the final loop {}, waiting for an event.
  3. The hardware timer chip fires an interrupt (IRQ 0).
  4. The Master PIC receives the signal, remaps it to vector 32, and notifies the CPU.
  5. The CPU pauses the loop {} and looks up entry 32 in our IDT.
  6. It finds our timer_interrupt_handler and jumps to it.
  7. Our handler runs, printing a single . to the screen.
  8. Our handler sends the EOI signal to the PIC, letting it know it can send more interrupts.
  9. The handler returns, and the CPU resumes the loop {} exactly where it left off.
  10. This entire process repeats every time the timer ticks, creating the steady stream of dots.

You have successfully breathed life into your kernel, enabling it to react to asynchronous events from the outside world. This is a fundamental milestone in operating system development.

What’s Next?

The timer is the system’s heartbeat, but it’s not very interactive. The next logical step is to handle input from the user. You will apply the exact same pattern you just learned—creating a handler, adding it to the IDT, and sending an EOI signal—to handle interrupts from the PS/2 keyboard controller. In the next task, you will see your kernel respond to your own keystrokes.

Further Reading

To solidify your understanding of the concepts used in this task, these resources are excellent:

Verifying the Timer Interrupt Handler

Mục tiêu: Understand and verify the functionality of the hardware timer interrupt handler by analyzing the interrupt pipeline and modifying the handler’s code to observe changes in real-time.


Congratulations! By registering your timer handler in the Interrupt Descriptor Table (IDT), you have just witnessed one of the most exciting milestones in OS development: your kernel is no longer just executing a linear sequence of instructions but is now reacting to events from the outside world. That steady stream of dots appearing on your screen is the very first “heartbeat” of your operating system, a visual confirmation that the entire hardware interrupt pipeline is functioning perfectly.

This task is all about understanding and verifying this new reality. The code you wrote in the previous two tasks achieved this, and now we will deeply analyze the result to solidify your understanding.

Deconstructing the “Heartbeat”: How the Dots Appear

Let’s pause and appreciate the intricate dance between hardware and software that is happening dozens of times per second to print those dots. The print!(".") line inside your timer_interrupt_handler is the final step in a long and perfectly executed chain of events:

  1. Hardware Tick: The Programmable Interval Timer (PIT) chip on the motherboard, a separate piece of hardware, reaches the end of its countdown interval and generates an electrical signal.
  2. IRQ Signal: This signal travels along Interrupt Request Line 0 (IRQ 0) to the Master PIC.
  3. PIC Remapping: The Master PIC, which you correctly remapped, receives the signal on IRQ 0. It translates this into interrupt vector 32 (because you set its offset to 32) and signals the CPU.
  4. CPU Interruption: The CPU, with its interrupt flag enabled by your sti call, immediately pauses whatever it was doing (in our case, spinning in the loop {} inside _start). It pushes the current state (instruction pointer, flags, etc.) onto the stack.
  5. IDT Lookup: The CPU uses the vector number 32 as an index into your active IDT.
  6. Handler Invocation: It finds the entry you just set, which contains the memory address of your timer_interrupt_handler function, and jumps to that address.
  7. Visual Proof: Your Rust code now executes. The very first thing it does is print!("."), and a dot instantly appears on the screen. This is the verification. This is the tangible proof that your handler is being called.
  8. Acknowledgment: Your handler then sends the crucial End-of-Interrupt (EOI) signal back to the PIC, informing it that the interrupt has been serviced and it’s free to send more.
  9. Graceful Return: The handler function returns. The special x86-interrupt calling convention ensures an iretq instruction is executed, which pops the saved state off the stack and seamlessly resumes the loop {} in _start as if nothing had ever happened.

This entire cycle is a beautiful example of asynchronous, event-driven programming at the lowest level. Your main line of execution (_start) is completely unaware of the timer; it’s just looping infinitely. The timer interrupt arrives unexpectedly, is handled, and control is returned, all without the main loop’s knowledge.

A Simple Experiment to Confirm Your Control

To truly feel the connection between your code and the hardware’s behavior, let’s make a tiny change. Modify the print! call inside your timer_interrupt_handler in src/interrupts.rs to print a different character.

// In src/interrupts.rs

// ...

extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame)
{
    // Let's print a different character to see the change happen live!
    print!("-");

    // The EOI signal must be sent to the PIC to let it know that
    // the interrupt has been handled. Otherwise, no more interrupts
    // of that type will be delivered.
    // This operation is `unsafe` because sending an EOI for the wrong
    // interrupt number can lead to system instability.
    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(PIC_1_OFFSET);
    }
}

Now, run your kernel again with cargo run. You should see a stream of hyphens (-) instead of dots. This simple change powerfully demonstrates that it is your Rust code, and not some random hardware behavior, that is executing in response to the timer tick.

You have successfully built and verified a handler for the system’s most fundamental hardware interrupt. This mechanism is the foundation for all time-based activities in an OS, most notably preemptive multitasking, where the timer interrupt is used to force a switch between different running programs.

What’s Next?

The timer interrupt is a passive, periodic event. The next logical step is to handle an active, user-initiated event: a key press. You will now apply the exact same pattern—create a handler, register it in the IDT at the correct remapped index, and handle the EOI signal—to bring your keyboard to life and see your kernel respond directly to your input.

Further Reading

To deepen your understanding of the concepts you’ve just put into practice, these resources are highly valuable:

  • Asynchronous Procedure Call (APC): While the term is more common in Windows development, the underlying concept is universal. Understanding the difference between synchronous and asynchronous calls is fundamental to systems programming.
  • The Role of the Timer in Multitasking: Explore articles and resources that explain how operating systems use the PIT interrupt as the driver for their schedulers.
  • The volatile Keyword in Systems Programming: While not needed here thanks to Rust’s safe abstractions, learning about volatile helps understand the challenges of interacting with hardware memory and I/O ports where values can change asynchronously.

Define a Rust Handler for Keyboard Interrupts (IRQ 1)

Mục tiêu: Create a new x86-interrupt handler function in Rust for keyboard interrupts (IRQ 1 / Vector 33). The handler will print a character for verification and send an End-of-Interrupt (EOI) signal to the PIC. This task focuses only on defining the function, not registering it in the IDT.


Excellent work! You’ve successfully brought your kernel to life by handling the system’s timer interrupt. That steady stream of characters on the screen is the “heartbeat” of your operating system, proving that your kernel can now respond to asynchronous events from the outside world. This is a monumental achievement.

We’ve conquered a passive, periodic interrupt. Now, let’s take the next logical step: handling an active, user-initiated interrupt. We will apply the exact same pattern you’ve just mastered to make the kernel respond to your keystrokes. Our current task is to define the handler function that will be triggered every time you press a key on the keyboard.

The Keyboard’s Cry for Attention: IRQ 1

In a standard PC architecture, the PS/2 keyboard controller is wired to Interrupt Request Line 1 (IRQ 1) on the Master PIC. When you press or release a key, the keyboard controller sends a signal along this line to the PIC, which then alerts the CPU.

Just as we did for the timer, we need to know which interrupt vector this corresponds to. The calculation is straightforward and follows the same logic:

  • Master PIC’s starting vector offset: PIC_1_OFFSET (which is 32).
  • Keyboard’s IRQ line: 1.
  • Resulting interrupt vector: 32 + 1 = 33.

So, our goal is to create a function that will be invoked by the CPU whenever interrupt 33 occurs.

Defining the Keyboard Handler

The structure of our keyboard handler will be remarkably similar to the timer handler. It must use the x86-interrupt calling convention to safely interrupt any running code, and it must send an End-of-Interrupt (EOI) signal when it’s finished.

For this task, we won’t decode the key press yet. We will simply print a character to the screen to get immediate visual confirmation that our handler is being called.

Let’s add the new handler function to your src/interrupts.rs file, right alongside the timer_interrupt_handler.

// In src/interrupts.rs

// ... (existing code: timer_interrupt_handler, etc.)

/// The handler function for the keyboard interrupt (IRQ 1).
/// This function is called every time a key is pressed or released.
extern "x86-interrupt" fn keyboard_interrupt_handler(
    _stack_frame: InterruptStackFrame)
{
    // For now, we just print a character to prove it's working.
    // In later tasks, we will read the scancode from the keyboard controller.
    print!("k");

    // We must send an EOI to the PIC for the keyboard interrupt.
    // The keyboard is on IRQ 1, so its vector is PIC_1_OFFSET + 1.
    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(PIC_1_OFFSET + 1);
    }
}

This function is the core of our task. Let’s analyze it in detail:

  • extern "x86-interrupt" fn keyboard_interrupt_handler(...): We define the function with the special interrupt calling convention. This is essential, as a key press is completely asynchronous and can happen at any moment. This convention ensures that the state of the interrupted program is perfectly preserved.
  • _stack_frame: InterruptStackFrame: The CPU provides a stack frame with information about the interrupted code. We don’t need it for this simple handler, so we prefix it with an underscore to prevent a compiler warning.
  • print!("k");: This is our simple, immediate verification. If the handler is called, a k will appear on the screen.
  • The EOI Signal:
    • unsafe { ... }: Just like with the timer, acknowledging an interrupt is an unsafe operation. We are asserting that we are sending the correct EOI for the interrupt that just occurred.
    • PICS.lock().notify_end_of_interrupt(PIC_1_OFFSET + 1);: This is the crucial handshake. We lock our global PICS static and tell it that we have finished handling the interrupt for vector 32 + 1 = 33. Forgetting this would cause the PIC to stop sending keyboard interrupts, effectively “freezing” the keyboard after the first key press.

What to Expect Now

You have now defined a complete and correct handler for the keyboard. It’s ready and waiting in your kernel’s code.

However, if you run your kernel now with cargo run, you will see no change in behavior. The stream of characters from your timer interrupt will continue as before, but pressing keys on your keyboard will do nothing.

This is the correct and expected outcome. Why? We have built the new handler, but we haven’t yet registered it in our “emergency dispatch system”—the IDT. The entry for vector 33 in our IDT is still empty. When a keyboard interrupt occurs, the CPU will find no handler and trigger a Double Fault.

A successful compilation is the sign of success for this task. You have perfectly prepared the code that will soon bring your keyboard to life.

In the very next task, you will complete the connection by adding a pointer to your new keyboard_interrupt_handler in the IDT at the correct index.

Further Reading

To learn more about the hardware and concepts involved in this task, these resources are excellent:

  • OSDev.org Wiki: PS/2 Keyboard: An in-depth article about how the keyboard controller works, including the I/O ports we will use in a later task to read key press data.
  • OSDev.org Wiki: IRQs: A great reference page showing the standard assignment of hardware devices to IRQ lines on a legacy PC. This helps you understand why the keyboard is on IRQ 1.

Add the keyboard handler to the IDT.

Mục tiêu:


Excellent! In the previous task, you crafted a complete and correct handler function for the keyboard interrupt. Your keyboard_interrupt_handler is now a fully-equipped “first responder,” ready to be dispatched the moment a key is pressed. It knows how to provide immediate feedback by printing a character and, critically, it knows how to perform the End-of-Interrupt (EOI) handshake to keep the interrupt system flowing.

However, as you’ve observed, this first responder is currently sitting idle at the station. The emergency dispatch system—our Interrupt Descriptor Table (IDT)—hasn’t been given its contact information yet. When a key is pressed, the PIC dutifully forwards the signal as interrupt vector 33, but the CPU looks up this entry in the IDT, finds it empty, and correctly triggers a Double Fault. Our current mission is to make that final, crucial connection by registering our new handler in the IDT.

Connecting the Wires: From IRQ to IDT Entry

Let’s quickly re-trace the path to ensure we register the handler at the correct location. This logical chain is the bedrock of our interrupt system:

  1. The PS/2 keyboard controller is physically wired to Interrupt Request Line 1 (IRQ 1) on the Master PIC.
  2. During initialization, our init function remapped the Master PIC to start its vector numbering at PIC_1_OFFSET, which is 32.
  3. Therefore, the PIC translates a signal on IRQ 1 into interrupt vector 32 + 1 = 33.

Our task is to populate the 33rd entry of our IDT with a pointer to our keyboard_interrupt_handler function. Just as we did for the timer interrupt, we will use array-style indexing on our IDT static. We’ll use our constants to calculate the index, which is a robust practice that avoids “magic numbers” and makes the code’s intent crystal clear.

Let’s make this vital connection now. Open your src/interrupts.rs file and add the highlighted lines to your IDT’s lazy_static! block.

// 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 the handler for the timer interrupt (IRQ 0).
        idt[PIC_1_OFFSET as usize]
            .set_handler_fn(timer_interrupt_handler);

        // Add the handler for the keyboard interrupt (IRQ 1).
        // The index is our PIC_1_OFFSET + 1, which we calculate to be 33.
        idt[(PIC_1_OFFSET + 1) as usize]
            .set_handler_fn(keyboard_interrupt_handler);

        idt
    };
}

This small addition is the final piece of the puzzle for keyboard input. Let’s break down exactly what it does:

  • idt[(PIC_1_OFFSET + 1) as usize]: This expression pinpoints the exact entry in the IDT we need to modify.
    • We calculate the vector number 32 + 1 = 33 using our public constants. This ensures that if we ever change the base offset, the keyboard handler’s registration will update automatically.
    • We cast the u8 result to usize, which is the type Rust requires for indexing arrays and slices.
  • .set_handler_fn(keyboard_interrupt_handler): We call the familiar, type-safe method from the x86_64 crate. This method performs several crucial actions:
    1. It takes a function pointer to our keyboard_interrupt_handler.
    2. It verifies at compile time that the function has the correct extern "x86-interrupt" calling convention and signature. This is a powerful safety feature that prevents us from registering an incompatible function.
    3. It populates the IDT entry with the handler’s memory address and sets the “present” flag, officially activating it.

The Moment of Truth: User Input

With this final connection soldered into place, the double fault on key presses will cease. The entire chain from your finger to the screen is now complete. It’s time to see it in action.

Run your kernel:

cargo run

QEMU will launch, and you will see the familiar stream of characters from your timer interrupt. Now, click inside the QEMU window to give it focus and start typing on your keyboard. You should see the following behavior:

Hello, World!
It did not crash!
..k..k..k..k..k..k..k..k..k..k..k..k..k..k..k..k..

This is a massive success! Every time you press a key, a k appears on the screen, interspersed with the timer’s characters. This proves that your kernel is now handling two different hardware interrupts concurrently. Your OS is not just passively responding to a heartbeat; it’s actively responding to your input.

What’s Next?

This is incredibly exciting, but you’ve probably noticed a small limitation: no matter what key you press—’a’, ‘b’, ‘enter’, ‘space’—the same character k appears. This is because our handler is just a placeholder; it confirms the interrupt is firing but doesn’t actually get any information from the keyboard.

The keyboard controller doesn’t just send a generic “a key was pressed” signal. It places a specific numeric code, called a scancode, into its data port. Each key on the keyboard has a unique scancode for its press and release events.

In the next task, you will take the crucial next step: you will learn how to read this scancode from the keyboard controller’s I/O port, finally allowing your kernel to know which key was actually pressed.

Further Reading

To prepare for the next steps and deepen your understanding, these resources will be invaluable:

Read Raw Keyboard Scancodes

Mục tiêu: Modify the keyboard interrupt handler to read raw scancodes from the PS/2 keyboard controller’s data port (0x60) using the x86\_64 crate’s Port abstraction.


Brilliant! In the last task, you successfully wired up the keyboard interrupt to your kernel. Every key press now calls your handler, resulting in a k appearing on the screen. This is a huge milestone—your kernel is now directly responding to your physical actions. You’ve proven that the entire chain, from the keyboard hardware to the PIC, through the CPU and your IDT, is complete and functional.

While seeing the k is exciting, it’s just a placeholder. It confirms that the interrupt is firing, but it doesn’t tell us anything about which key was pressed. To build a truly interactive system like a shell, we need to move beyond just knowing that a key was pressed and find out which key it was. This brings us to our current, crucial task: reading the raw scancode from the keyboard controller.

From Interrupt Signal to Scancode Data

When you press a key, the keyboard controller does more than just raise the IRQ 1 line. In a separate action, it places a single byte of data—a unique number identifying the key—into its data buffer. This number is called a scancode. Our mission is to retrieve this byte from the hardware.

How does a CPU communicate with hardware devices like a keyboard controller? It doesn’t use the main system memory bus. Instead, it uses a separate, smaller address space known as the I/O port space. Each device is assigned one or more port addresses. To talk to the device, the CPU executes special assembly instructions (in and out) to read from or write to these ports.

The standard PS/2 keyboard controller has its data buffer mapped to I/O port 0x60. To get the scancode, all we need to do is read one byte from this specific port.

A Safe Abstraction for I/O Ports

As you might guess, directly executing in instructions would require an unsafe inline assembly block. This is a perfect opportunity to rely on the powerful abstractions provided by the x86_64 crate. It gives us a type-safe and idiomatic way to interact with I/O ports through its Port struct. This struct is a thin wrapper that makes I/O operations much cleaner and clearer in Rust.

Let’s modify our keyboard_interrupt_handler to use this Port struct to read the scancode and print it to the screen.

Open your src/interrupts.rs file and update the keyboard_interrupt_handler. We’ll only need to change the body of the function.

// In src/interrupts.rs

// Add this new `use` statement at the top of the file.
use x86_64::instructions::port::Port;

// ... (other use statements, constants, IDT, handlers, etc.)

extern "x86-interrupt" fn keyboard_interrupt_handler(
    _stack_frame: InterruptStackFrame)
{
    // Create a port handle to the PS/2 keyboard data port.
    // Port 0x60 is the standard data port for the keyboard controller.
    let mut port = Port::new(0x60);

    // Read the scancode from the keyboard's data port.
    // This is `unsafe` because reading from an I/O port can have side-effects
    // that the Rust compiler cannot reason about.
    let scancode: u8 = unsafe { port.read() };

    // Instead of printing 'k', print the scancode we just read.
    // We use the hex formatter `{:#x}` which is conventional for scancodes.
    print!("Scancode: {:#x} ", scancode);

    // The EOI signal remains the same and is still crucial.
    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(PIC_1_OFFSET + 1);
    }
}

This is a small change, but it’s a giant leap in functionality. Let’s break down the new code inside the handler:

  1. use x86_64::instructions::port::Port;: We start by importing the Port type from our trusted x86_64 crate.
  2. let mut port = Port::new(0x60);: We create an instance of Port. The type parameter is inferred to be u8 because the read method will return a u8. We pass the port number 0x60, which is the universally recognized address for the PS/2 keyboard data port. This line doesn’t perform any I/O; it simply creates a typed handle to the port.
  3. let scancode: u8 = unsafe { port.read() };: This is where the magic happens.
    • The port.read() method executes the in al, dx assembly instruction to read a single byte from the port.
    • This operation is unsafe because it’s a direct hardware interaction. We are asserting to the Rust compiler that we know this is the correct port and that reading from it is the intended action. Reading from I/O ports can have side effects (for example, some hardware clears its “data ready” flag upon being read), which is something the compiler can’t manage or verify.
    • We store the returned byte in a variable named scancode.
  4. print!("Scancode: {:#x} ", scancode);: We’ve replaced our old print!("k") with this new line.
    • It prints the raw scancode value we just read from the hardware.
    • The {:#x} format specifier is very important. It tells print! to format the number in hexadecimal (base 16) with a 0x prefix (e.g., 0x1e). This is the standard way to represent scancodes and makes it easy to look them up in reference tables.

The Moment of Truth: Seeing the Raw Data

With this modification in place, run your kernel.

cargo run

QEMU will launch. Click inside the window to give it focus, and then start typing. For example, press and release the ‘A’ key, then the ‘B’ key. You should see output similar to this:

Hello, World!
It did not crash!
.......Scancode: 0x1e ..Scancode: 0x9e ......Scancode: 0x30 ..Scancode: 0xb0 ...........

This is a phenomenal success! You are now seeing the raw data directly from the keyboard controller. Notice a few things: * Unique Codes: Each key press generates a unique code. Pressing ‘A’ likely gave you 0x1e, and ‘B’ gave you 0x30. * Make and Break Codes: You get two codes for each key action! 0x1e is the “make code” for pressing the ‘A’ key. 0x9e is the “break code” for releasing the ‘A’ key. Break codes are often the make code with the most significant bit set (e.g., 0x1e + 0x80 = 0x9e). This allows the OS to know about key-down and key-up events, which is essential for features like holding down Shift or Ctrl.

What’s Next?

You have successfully tapped into the raw data stream from the keyboard. However, printing 0x1e is not the same as printing the character ‘a’. These scancodes are just numbers; they have no inherent meaning as characters. The next logical step is to translate these raw scancodes into something more useful, like an actual character (‘a’, ‘b’, ‘!’) or a key event (Enter, Backspace, ArrowUp).

Fortunately, we don’t have to build a giant lookup table for this ourselves. The Rust ecosystem provides a crate specifically for this purpose. In the next task, you will add the pc-keyboard crate to your project and use it to perform this translation, bringing you one step closer to a fully functional keyboard input system.

Further Reading

To learn more about the concepts you’ve just implemented, these resources are invaluable:

Implement Keyboard Scancode Translation using pc-keyboard

Mục tiêu: Integrate the pc-keyboard crate into the Rust kernel to translate raw hardware scancodes from the keyboard controller into high-level key events, such as Unicode characters and special keys, handling state like Shift and Caps Lock.


You’ve done a phenomenal job! In the previous task, you successfully tapped into the raw data stream from the keyboard controller. Seeing those raw hexadecimal scancodes like 0x1e and 0x9e appear on the screen is a massive accomplishment. It proves you can read directly from a hardware I/O port in response to an interrupt. You are now communicating with hardware at one of its most fundamental levels.

However, while 0x1e is meaningful to the hardware, it’s gibberish to a user who just wants to type the letter ‘a’. The journey from a raw scancode to a useful character is surprisingly complex. You need to consider:

  • Statefulness: Is the Shift key being held down? Is Caps Lock active? The scancode for the ‘A’ key is the same whether Shift is pressed or not; it’s the state of the keyboard that determines if you get ‘a’ or ‘A’.
  • Key Releases: We need to process “break codes” (key releases) to know when the Shift key is no longer held down.
  • Layouts: The scancode 0x10 produces ‘q’ on a US QWERTY keyboard but ‘a’ on an AZERTY keyboard.
  • Special Keys: How do you translate scancodes into non-character events like Enter, Backspace, or ArrowUp?

Tackling this from scratch would mean building a complex state machine and large mapping tables. This is a solved problem, and it’s the perfect opportunity to leverage the Rust ecosystem. Our current task is to use a specialized crate to handle this complex translation for us.

Introducing the pc-keyboard Crate

The pc-keyboard crate is a no_std library designed for exactly this purpose. It provides a stateful parser that can take a stream of raw scancodes and produce high-level KeyEvent and DecodedKey objects, which can represent either a Unicode character or a special key. It handles all the complexity of tracking modifier keys like Shift, Ctrl, and Alt for us.

Step 1: Adding the Dependency

First, we need to add the crate to our project. Open your Cargo.toml file and add the highlighted line to your [dependencies] section.

[dependencies]
spin = "0.5.2"
lazy_static = { version = "1.4.0", features = ["spin_no_std"] }
x86_64 = "0.14.10"
pic8259 = "0.10.1"
pc-keyboard = "0.7.0"

This tells Cargo to download and compile the pc-keyboard crate the next time we build.

Step 2: Integrating the Crate into the Keyboard Handler

To use the crate, we need to do two things:

  1. Create a static, global instance of the Keyboard parser. It needs to be static because it must remember the state (e.g., “is Shift pressed?”) between interrupt calls. We’ll use the lazy_static and spin::Mutex pattern you’re already familiar with.
  2. Update our keyboard_interrupt_handler to feed the scancode into this parser and then print the translated result.

Open your src/interrupts.rs file and let’s add the necessary code.

// In src/interrupts.rs

// --- Add these new `use` statements at the top ---
use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;
// ---

use x86_64::instructions::port::Port;
// ... other existing use statements

// --- Add this lazy_static block for the KEYBOARD instance ---
lazy_static! {
    /// A static, mutex-guarded instance of a `Keyboard` parser.
    /// This is necessary because the keyboard needs to track state,
    /// such as whether the shift or caps lock keys are active.
    static ref KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> =
        Mutex::new(Keyboard::new(layouts::Us104Key, ScancodeSet1,
            HandleControl::Ignore)
        );
}
// ---

// ... existing code ...

extern "x86-interrupt" fn keyboard_interrupt_handler(
    _stack_frame: InterruptStackFrame)
{
    // Lock the static keyboard parser
    let mut keyboard = KEYBOARD.lock();
    // Read the scancode from the keyboard controller's data port
    let mut port = Port::new(0x60);
    let scancode: u8 = unsafe { port.read() };

    // The `add_byte` method processes the scancode and returns a `KeyEvent`
    // if a full key press/release event is decoded.
    if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
        // The `process_keyevent` method translates the `KeyEvent` into a
        // `DecodedKey` (a character or a special key) using the current
        // keyboard state (modifiers like Shift).
        if let Some(key) = keyboard.process_keyevent(key_event) {
            match key {
                DecodedKey::Unicode(character) => print!("{}", character),
                DecodedKey::RawKey(key) => print!("{:?}", key),
            }
        }
    }

    // The EOI signal remains the same and is still crucial.
    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(PIC_1_OFFSET + 1);
    }
}

This is a significant upgrade to our handler. Let’s break it down in detail:

  • New use statements: We import the necessary types from the pc-keyboard crate.
  • lazy_static! { static ref KEYBOARD: ... }:
    • We create a static KEYBOARD instance protected by a Mutex for safe concurrent access (essential for interrupts).
    • Keyboard::new(...) initializes the parser. It requires three parameters:
      1. layouts::Us104Key: This specifies the keyboard layout. We’re choosing a standard 104-key US QWERTY layout.
      2. ScancodeSet1: This specifies the hardware protocol for the scancodes themselves. “Set 1” is the oldest and most widely emulated standard, used by QEMU.
      3. HandleControl::Ignore: This tells the parser how to handle Ctrl key combinations. For now, we’ll ignore them.
  • Inside the handler:
    • let mut keyboard = KEYBOARD.lock();: We lock our static instance to get mutable access.
    • let scancode: u8 = unsafe { port.read() };: This part is unchanged. We still need to read the raw byte from the hardware.
    • if let Ok(Some(key_event)) = keyboard.add_byte(scancode): This is the first translation step. We feed the raw scancode byte to the parser. It returns a Result containing an Option. This pattern handles cases where multiple bytes are needed for a full key event. If it successfully decodes a full event (like a key press or release), this if let block runs.
    • if let Some(key) = keyboard.process_keyevent(key_event): This is the second translation step. We take the low-level key_event and process it using the current keyboard state (Shift, Caps Lock). This produces the final DecodedKey.
    • match key { ... }: We match on the DecodedKey enum.
      • DecodedKey::Unicode(character): If the key corresponds to a printable character (like ‘a’, ‘A’, ‘!’, or ‘7’), we use our print! macro to print it directly to the screen.
      • DecodedKey::RawKey(key): If the key is a special, non-printable key (like Enter, Escape, or F1), we print its debug representation (e.g., ArrowUp). This is incredibly useful for seeing that all keys are being recognized.

The Moment of Truth: A Working Keyboard

You have now fully integrated the scancode translation logic. Run your kernel now.

cargo run

Click inside the QEMU window to give it focus. The timer interrupts will still be printing their characters. Now, start typing! You should see the actual characters you type appear on the screen. Try pressing Shift and a letter key—you should get an uppercase character. Try pressing Enter or the arrow keys—you should see their debug names appear.

Hello, World!
It did not crash!
..Hello, kernel!..[ArrowUp]..[Enter]..

Congratulations! You now have a fully functional keyboard input system. You have bridged the gap from a raw hardware signal to a meaningful, high-level event that your kernel can understand and use.

What’s Next?

Right now, we are printing characters the moment they are typed. This is great for verification, but to build something like a command shell, we need to collect a full line of input before processing it. In the next step of the project, you will move beyond simply printing characters and begin storing them in a buffer, processing them only when the ‘Enter’ key is pressed. This is the first step toward building a command-line interface.

Further Reading

To learn more about the powerful crate you just used and the underlying concepts, these resources are excellent:

Verifying Keyboard Input Handling

Mục tiêu: Run the kernel to test the keyboard interrupt handler. This task involves verifying that raw scancodes from the keyboard are correctly decoded into Unicode characters and raw key events using the pc-keyboard crate, confirming that the kernel can now process user input.


In the previous task, you took a giant leap forward by integrating the pc-keyboard crate into your keyboard interrupt handler. You transformed your kernel from a system that could only detect a key press into one that could understand which key was pressed. You have already written the code to do this, and this task is dedicated to running it, verifying the result, and deeply understanding the magic you’ve just unleashed.

This is the moment of truth. You are about to bridge the final gap between a raw hardware signal and meaningful user input.

From Scancode to Character: The Final Translation

Let’s revisit the core logic you implemented in your keyboard_interrupt_handler. After reading the raw scancode from the I/O port, you passed it through the pc-keyboard state machine, culminating in this crucial match statement:

match key {
    DecodedKey::Unicode(character) => print!(\"{}\", character),
    DecodedKey::RawKey(key) => print!(\"{:?}\", key),
}

This small block of code is the engine of your keyboard input system. It takes the high-level DecodedKey enum produced by the crate and makes a decision. Let’s analyze both arms of this match to understand what you’re about to see on screen.

DecodedKey::Unicode(character)

This is the case for any key that directly maps to a printable character. When you press the ‘a’ key, the pc-keyboard crate processes the scancode, checks its internal state to see if Shift or Caps Lock is active, and if not, it returns DecodedKey::Unicode('a'). Your match statement then executes the first arm, calling print!('a'), and the character appears on the screen.

The power here lies in the statefulness of the KEYBOARD object you created. If you hold down the Shift key, the crate processes the “Shift-pressed” scancode and updates its internal state. The next time you press the ‘a’ key, it sees the same scancode for ‘a’, but because its internal state knows Shift is active, it now returns DecodedKey::Unicode('A'). This is how you get uppercase letters, symbols like ! and @, and more, without having to manage this complex logic yourself.

DecodedKey::RawKey(key)

What about keys that don’t produce a character, like Enter, Escape, the arrow keys, or F1? For these, the crate returns a DecodedKey::RawKey(key), where key is another enum representing the specific non-printable key.

Your second match arm, print!(\"{:?}\", key), handles this case perfectly. The {:?} formatter is Rust’s debug print format. It prints a developer-friendly representation of a type. So, when you press the up arrow key, you won’t see a character, but you will see the text ArrowUp printed to the screen. This is an incredibly useful way to verify that your kernel is correctly recognizing all key presses, not just the printable ones.

The Moment of Truth: A Fully Interactive Kernel

It’s time to see your work in action. Run your kernel with the now-familiar command:

cargo run

QEMU will launch, and you’ll see the steady stream of characters from your timer interrupt. Now, click inside the QEMU window to give it focus and begin to type.

  • Type hello world. You should see hello world appear on the screen, interspersed with the timer characters.
  • Hold Shift and type RUST. You should see RUST.
  • Press the Enter key. You should see Enter appear.
  • Press the arrow keys. You should see ArrowUp, ArrowDown, etc.

The output should look something like this:

Hello, World!
It did not crash!
..h.e.l.l.o. .w.o.r.l.d.!.R.U.S.T.Enter.ArrowUp..

Congratulations! This is a monumental success. You have created a kernel that can handle multiple, concurrent hardware interrupts and can translate low-level keyboard signals into the high-level characters and events that form the basis of all user interaction.

You have successfully verified that the final piece of the keyboard input puzzle is in place. You are not just printing a placeholder; you are printing the actual decoded character, which is the tangible result of a sophisticated translation process.

What’s Next?

You may have noticed that your interrupt handlers for both the timer and the keyboard contain a similar, crucial unsafe block at the end:

unsafe {
    PICS.lock()
        .notify_end_of_interrupt(interrupt_vector_number);
}

You’ve already correctly implemented this for both handlers, which is why your interrupts continue to fire correctly. The final task in this step is to take a closer look at this specific piece of code. You will delve into the concept of the End-of-Interrupt (EOI) signal, understanding precisely why forgetting this one line of code is one of the most common and frustrating bugs in OS development, causing interrupts to fire once and then mysteriously stop.

Further Reading

To deepen your understanding of the concepts you’ve just successfully implemented, these resources are highly recommended:

Implement sending an End-of-Interrupt (EOI) signal to the PIC at the end of each handler.

Mục tiêu:


You have achieved something truly remarkable. Your kernel is no longer a simple, linear program; it’s a dynamic, event-driven system that handles multiple, concurrent hardware interrupts. The characters from your keyboard appearing alongside the steady heartbeat of the timer is tangible proof that you have mastered one of the most fundamental and challenging aspects of operating system development.

This incredible functionality hinges on a small, almost hidden, piece of code that you have correctly placed at the end of both your timer_interrupt_handler and keyboard_interrupt_handler. Our final task in this step is to put that specific code under the microscope and understand its profound importance. We’re talking about the End-of-Interrupt (EOI) signal.

A Polite Conversation with Hardware

Think of the interaction between your interrupt handler and the Programmable Interrupt Controller (PIC) as a polite, but strict, conversation.

  1. The PIC Shouts: A key is pressed. The PIC signals the CPU, effectively shouting, “Hey, Interrupt 33 needs your attention, right now!”
  2. The CPU Responds: The CPU stops what it’s doing, finds your keyboard_interrupt_handler in the IDT, and runs it.
  3. The Handler Must Reply: Your handler does its work (reads the scancode, prints the character). But the PIC is still waiting. It assumes the emergency is ongoing until it’s told otherwise. The EOI signal is your handler politely replying, “Thank you, I’ve finished handling Interrupt 33. You can carry on.”

Without this final reply, the PIC would keep the line “busy” and refuse to send any more keyboard interrupts. This is the root of one of the most classic and frustrating bugs in OS development: an interrupt that fires exactly once and then mysteriously stops.

The Technical Heart: The In-Service Register (ISR)

This “busy line” is a real thing inside the PIC hardware. The PIC maintains an 8-bit internal register for each chip called the In-Service Register (ISR). Each bit in this register corresponds to an IRQ line (bit 0 for IRQ 0, bit 1 for IRQ 1, and so on).

Here’s the sequence:

  1. When the PIC issues an interrupt for IRQ 1 (the keyboard), it sets bit 1 of the master PIC’s ISR to 1.
  2. As long as bit 1 of the ISR is 1, the PIC considers IRQ 1 to be “in-service” or “being handled.”
  3. While a bit is set in the ISR, the PIC will not issue any new interrupts for that IRQ line or any lower-priority IRQ line.
  4. The only way to clear this bit and tell the PIC that the handler is finished is by sending it a specific EOI command.

Your correct implementation of the EOI signal is what clears this bit, allowing the next keyboard interrupt to be processed.

Analyzing the Code You’ve Already Written

Let’s look at the crucial unsafe block you’ve placed at the end of your handlers. This code is already perfect and is the reason your kernel works.

// This is the code at the end of your timer and keyboard handlers

// The interrupt_number is PIC_1_OFFSET for the timer, 
// and PIC_1_OFFSET + 1 for the keyboard.
unsafe {
    PICS.lock()
        .notify_end_of_interrupt(interrupt_number);
}

Let’s break it down piece by piece:

  • PICS.lock(): The two PICs are a shared, global hardware resource. The spin::Mutex ensures that we have exclusive access to them, preventing race conditions if multiple interrupt handlers were somehow trying to access them at the exact same time on a multi-core system.
  • .notify_end_of_interrupt(interrupt_number): This is the beautiful abstraction provided by the pic8259 crate.
    • It takes the interrupt vector number (e.g., 32 for the timer, 33 for the keyboard) as its argument, not the IRQ number.
    • It handles the master/slave complexity for you. If you were acknowledging an interrupt from the slave PIC (e.g., vector 40), this function is smart enough to send an EOI command to both the slave and the master PICs, which is a requirement of the cascaded setup.
    • Internally, this function performs unsafe I/O port writes to send the EOI command byte (0x20) to the PIC’s command port.
  • unsafe { ... }: Why is this operation unsafe? The Rust compiler can’t verify that the interrupt_number you’re passing is the correct one for the interrupt that just occurred. Sending an EOI for the wrong interrupt is a serious bug. Imagine if your keyboard handler accidentally sent an EOI for the timer’s vector. The timer’s bit in the ISR would be cleared, but the keyboard’s bit would remain set forever. The keyboard would freeze, and you’d have a very difficult bug to track down. The unsafe block is your promise to the compiler: “I am the OS developer. I have designed my system correctly, and I guarantee that the number I am passing here corresponds to the interrupt that this handler is responsible for.”

You have fulfilled this promise perfectly, which is why your keyboard and timer are both fully functional.

What’s Next? The Next Grand Challenge: Memory

You have now built a robust system for handling events from both inside the CPU (exceptions) and the outside world (hardware interrupts). You’ve laid the complete foundation for interacting with hardware and responding to events.

The next major frontier in building a modern operating system is memory management. Right now, our kernel exists in a simple world where all memory addresses are real, physical addresses. This is not scalable or safe.

In the next step of this project, you will embark on one of the most important and rewarding topics in OS development: setting up a 4-level paging memory model. You will learn how to create virtual address spaces, translate them to physical addresses, and protect memory, which are the cornerstones of process isolation and security in all modern operating systems.

Further Reading

To solidify your understanding of the EOI signal and the PIC, these resources are the definitive guides.