21 KiB
+++ title = "Hardware Interrupts" order = 8 path = "hardware-interrupts" date = 2018-07-26 template = "second-edition/page.html" +++
In this post we set up the programmable interrupt controller to correctly forward hardware interrupts to the CPU. To handle these interrups we add new entries to our interrupt descriptor table, just like we did for our exception handlers. We will learn how to get periodic timer interrupts and how to get input from the keyboard.
This blog is openly developed on Github. If you have any problems or questions, please open an issue there. You can also leave comments at the bottom.
Overview
Interrupts provide a way to notify the CPU from attached hardware devices. So instead of letting the kernel periodically check the keyboard for new characters (a process called polling), the keyboard can notify the kernel of each keypress. This is much more efficient because the kernel only needs to act when something happened. It also allows faster reaction times, because the kernel can react immediately and not only at the next poll.
Connecting all hardware devices directly to the CPU is not possible. Instead, a separate interrupt controller aggregates the interrupts from all devices and then notifies the CPU:
____________ _____
Timer ------------> | | | |
Keyboard ---------> | Interrupt |---------> | CPU |
Other Hardware ---> | Controller | |_____|
Etc. -------------> |____________|
Most interrupt controllers are programmable, which means that they support different priority levels for interrupts. For example, this allows to give timer interrupts a higher priority than keyboard interrupts to ensure accurate timekeeping.
Unlike exceptions, hardware interrupts occur asynchronously. This means that they are completely independent from the executed code and can occur at any time. Thus we suddenly have a form of concurrency in our kernel with all the potential concurrency-related bugs. Rust's strict ownership model helps us here because it forbids mutable global state. However, deadlocks are still possible, as we will see later in this post.
The 8259 PIC
The Intel 8259 is a programmable interrupt controller (PIC) introduced in 1976. It has long been replaced by the newer APIC, but its interface is still supported on current systems for backwards compatibiliy reasons. The 8259 PIC is significantly easier to set up than the APIC, so we will use it to introduce ourselves to interrupts before we switch to the APIC in a later post.
The 8259 has 8 interrupt lines and several lines for communicating with the CPU. The typical systems back then where equipped with two instances of the 8259 PIC, one primary and one secondary PIC connected to one of the interrupt lines of the primary:
____________ ____________
Real Time Clock --> | | Timer -------------> | |
ACPI -------------> | | Keyboard-----------> | | _____
Available --------> | Secondary |----------------------> | Primary | | |
Available --------> | Interrupt | Serial Port 2 -----> | Interrupt |---> | CPU |
Mouse ------------> | Controller | Serial Port 1 -----> | Controller | |_____|
Co-Processor -----> | | Parallel Port 2/3 -> | |
Primary ATA ------> | | Floppy disk -------> | |
Secondary ATA ----> |____________| Parallel Port 1----> |____________|
This graphic shows the typical assignment of interrupt lines. We see that most of the 15 lines have a fixed mapping, e.g. line 4 of the secondary PIC is assigned to the mouse.
Each controller can be configured through two I/O ports, one “command” port and one “data” port. For the primary controller these ports are 0x20 (command) and 0x21 (data). For the secondary controller they are 0xa0 (command) and 0xa1 (data). For more information on how the PICs can be configured see the article on osdev.org.
Implementation
The default configuration of the PICs is not usable, because it sends interrupt vector numbers in the range 0–15 to the CPU. These numbers are already occupied by CPU exceptions, for example number 8 corresponds to a double fault. To fix this overlapping issue, we need to remap the PIC interrupts to different numbers. The actual range doesn't matter as long as it does not overlap with the exceptions, but typically the range 32–47 is chosen, because these are the first free numbers after the 32 exception slots.
The configuration happens by writing special values to the command and data ports of the PICs. Fortunately there is already a crate called pic8259_simple, so we don't need to write the initialization sequence ourselves. In case you are interested how it works, check out its source code, it's fairly small and well documented.
To add the crate as dependency, we add the following to our project:
# in Cargo.toml
[dependencies]
pic8259_simple = "0.1.1"
// in src/lib.rs
extern crate pic8259_simple;
The main abstraction provided by the crate is the ChainedPics struct that represents the primary/secondary PIC layout we saw above. It is designed to be used in the following way:
// in src/lib.rs
pub mod interrupts;
// in src/interrupts.rs
use pic8259::ChainedPics;
use spin;
pub const PIC_1_OFFSET: u8 = 32;
pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8;
pub static PICS: spin::Mutex<ChainedPics> =
spin::Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });
We're setting the offsets for the pics to the range 32–47 as we noted above. By wrapping the ChainedPics struct in a Mutex we are able to get safe mutable access (through the lock method), which we need in the next step. The ChainedPics::new function is unsafe because wrong offsets could cause undefined behavior.
We can now initialize the 8259 PIC from our _start function:
// in src/main.rs
#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::gdt::init();
init_idt();
unsafe { PICS.lock().initialize() }; // new
println!("It did not crash!");
loop {}
}
We use the initialize function to perform the PIC initialization. Like the ChainedPics::new function, this function is also unsafe because it can cause undefined behavior if the PIC is misconfigured.
If all goes well we should continue to see the "It did not crash" message when executing bootimage run.
Enabling Interrupts
Until now nothing happened because interrupts are still disabled in the CPU configuration. This means that the CPU does not listen to the interrupt controller at all, so no interrupts can reach the CPU. Let's change that:
// in src/main.rs
#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
println!("Hello World{}", "!");
blog_os::gdt::init();
init_idt();
unsafe { PICS.lock().initialize() };
x86_64::instructions::interrupts::enable(); // new
println!("It did not crash!");
loop {}
}
The interrupts::enable function of the x86_64 crate executes the special sti instruction (“set interrupts”) to enable external interrupts. When we try bootimage run now, we see that a double fault occurs:
TODO screenshot
The reason for this double fault is that the hardware timer (the Intel 8253 to be exact) is enabled by default, so we start receiving timer interrupts as soon as we enable interrupts. Since we didn't define a handler function for it yet, our double fault handler is invoked.
Handling Timer Interrupts
As we see from the graphic above, the timer uses line 0 of the primary PIC. This means that it arrives at the CPU as interrupt 32 (0 + offset 32). Therefore we need to add a handler for interrupt 32 if we want to handle the timer interrupt:
// in src/interrupts.rs
pub const TIMER_INTERRUPT_ID: u8 = PIC_1_OFFSET; // new
// in src/main.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[…]
let timer_interrupt_id = usize::from(interrupts::TIMER_INTERRUPT_ID); // new
idt[timer_interrupt_id].set_handler_fn(timer_interrupt_handler); // new
idt
};
}
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: &mut ExceptionStackFrame)
{
print!(".");
}
We introduce a TIMER_INTERRUPT_ID constant to keep things organized. Our timer_interrupt_handler has the same signature as our exception handlers, because the CPU reacts identically to exceptions and external interrupts (the only difference is that some exceptions push an error code). The InterruptDescriptorTable struct implements the IndexMut trait, so we can access individual entries through array indexing syntax.
In our timer interrupt handler, we print a dot to the screen. As the timer interrupt happens periodically, we would expect to see a dot appearing on each timer tick. However, when we run it we see that only a single dot is printed:
TODO screenshot
End of Interrupt
The reason is that the PIC expects an explicit “end of interrupt” (EOI) signal from our interrupt handler. This signal tells the controller that the interrupt was processed and that the system is ready to receive the next interrupt. So the PIC thinks we're still busy processing the first timer interrupt and waits patiently for the EOI signal before sending the next one.
To send the EOI, we use our static PICS struct again:
// in src/main.rs
extern "x86-interrupt" fn timer_interrupt_handler(
_stack_frame: &mut ExceptionStackFrame)
{
print!(".");
unsafe { PICS.lock().notify_end_of_interrupt(interrupts::TIMER_INTERRUPT_ID) }
}
The notify_end_of_interrupt figures out wether the primary or secondary PIC sent the interrupt and then uses the command and data ports to send an EOI signal to respective controllers. If the secondary PIC sent the interrupt both PICs need to be notified because the secondary PIC is connected to an input line of the primary PIC.
We need to be careful to use the correct interrupt vector number, otherwise we could accidentally delete an important unsent interrupt or cause our system to hang. This is the reason that the function is unsafe.
When we now execute bootimage run we see dots periodically appearing on the screen:
TODO screenshot gif
Configuring The Timer
The hardware timer that we use is called the Progammable Interval Timer or PIT for short. Like the name says, it is possible to configure the interval between two interrupts. We won't go into details here because we will switch to the APIC timer soon, but the OSDev wiki has an extensive article about the configuring the PIT.
The hlt Instruction
Until now we used a simple empty loop statement at the end of our _start and panic functions. This causes the CPU to spin endlessly and thus works as expected. But it is also very inefficient, because the CPU continues to run at full speed even though there's no work to do. You can see this problem in your task manager when you run your kernel: The QEMU process needs close to 100% CPU the whole time.
What we really want to do is to halt the CPU until the next interrupt arrives. This allows the CPU to enter a sleep state in which it consumes much less energy. The hlt instruction does exactly that. Let's use this instruction to create an energy efficient endless loop:
// in src/lib.rs
pub fn hlt_loop() -> ! {
loop {
x86_64::instructions::hlt();
}
}
The instructions::hlt function is just a thin wrapper around the assembly instruction. It is safe because there's no way it can compromise memory safety.
We can now use this hlt_loop instead of the endless loops in our _start and panic functions:
// in src/main.rs
#[cfg(not(test))]
#[no_mangle]
pub extern "C" fn _start() -> ! {
[…]
println!("It did not crash!");
blog_os::hlt_loop(); // new
}
#[cfg(not(test))]
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("{}", info);
blog_os::hlt_loop(); // new
}
When we run our kernel now in QEMU, we see a much lower CPU usage.
Keyboard Input
Now that we are able to handle interrupts from external devices we are finally able to add support for keyboard input. This will allow us to interact with our kernel for the first time.
Like the hardware timer, the keyboard controller is already enabled by default. So when you press a key the keyboard controller sends an interrupt to the PIC, which forwards it to the CPU. The CPU looks for a handler function in the IDT, but the corresponding entry is empty. Therefore a double fault occurs.
So let's add a handler function for the keyboard interrupt. It's quite similar to how we defined the handler for the timer interrupt, it just uses a different interrupt number:
// in src/interrupts.rs
pub const KEYBOARD_INTERRUPT_ID: u8 = PIC_1_OFFSET + 1; // new
// in src/main.rs
lazy_static! {
static ref IDT: InterruptDescriptorTable = {
let mut idt = InterruptDescriptorTable::new();
idt.breakpoint.set_handler_fn(breakpoint_handler);
[…]
// new
let keyboard_interrupt_id = usize::from(interrupts::KEYBOARD_INTERRUPT_ID);
idt[keyboard_interrupt_id].set_handler_fn(keyboard_interrupt_handler);
idt
};
}
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: &mut ExceptionStackFrame)
{
print!("k");
unsafe { PICS.lock().notify_end_of_interrupt(KEYBOARD_INTERRUPT_ID) }
}
As we see from the graphic above, the keyboard uses line 1 of the primary PIC. This means that it arrives at the CPU as interrupt 33 (1 + offset 32). We again create a KEYBOARD_INTERRUPT_ID constant to keep things organized. In the interrupt handler, we print a k and send the end of interrupt signal to the interrupt controller.
We now see that a k appears whenever we press or release a key. The keyboard controller generates continuous interrupts if the key is hold down, so we see a series of ks on the screen.
Reading the Scancodes
To find out which key was pressed, we need to query the keyboard controller. We do this by reading from the from the data port of the PS/2 controller, which is the I/O port with number 0x60:
// in src/main.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: &mut ExceptionStackFrame)
{
use x86_64::instructions::port::Port;
let port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
print!("{}", scancode);
unsafe { PICS.lock().notify_end_of_interrupt(KEYBOARD_INTERRUPT_ID) }
}
We use the Port type of the x86_64 crate to read a byte from the keyboard's data port. This byte is called the scancode and is a number that represents the key press/release. We don't do anything with the scancode yet, we just print it to the screen:
TODO image/gif
The above image shows me slowly typing "123". We see that adjacent keys have adjacent scancodes and that pressing a key causes a different scancode than releasing it. But how do we translate the scancodes to the actual key actions exactly?
Interpreting the Scancodes
There are three different standards for the mapping between scancodes and keys, the so-called scancode sets. All three sets go back to the keyboards of early IBM computers: the IBM XT, the IBM 3270 PC, and the IBM AT. Later computers fortunatly did not continue the trend of defining new scancodes, but rather emulated the existing scancode sets and extending them. Today most keyboards can be configured to emulate any of the three sets.
By default, PS/2 keyboards emulate scancode set 1 ("XT"). In this set, the lower 7 bits of a scancode byte define the key, and the most significant bit defines whether it's a press ("0") or an release ("1"). Keys that were not present on the original IBM XT keyboard, such as the enter key on the keypad, generate two scancodes in succession: a 0xe0 escape byte and then a byte representing the key. For a list of all the set 1 scancodes and their corresponding keys, check out the OSDev Wiki.
To translate the scancodes to keys, we can use a match statement:
// in src/main.rs
extern "x86-interrupt" fn keyboard_interrupt_handler(
_stack_frame: &mut ExceptionStackFrame)
{
use x86_64::instructions::port::Port;
let port = Port::new(0x60);
let scancode: u8 = unsafe { port.read() };
// new
let key = match scancode {
0x02 => Some('1'),
0x03 => Some('2'),
0x04 => Some('3'),
0x05 => Some('4'),
0x06 => Some('5'),
0x07 => Some('6'),
0x08 => Some('7'),
0x09 => Some('8'),
0x0a => Some('9'),
0x0b => Some('0'),
_ => None,
};
if let Some(key) = key {
print!("{}", key);
}
unsafe { PICS.lock().notify_end_of_interrupt(KEYBOARD_INTERRUPT_ID) }
}
The above code just translates the numbers 0-9 and ignores all other keys. Now we can write numbers:
TODO image
Translating the other keys could work in the same way, probably with an enum for control keys such as escape or backspace. Such a translation function would be a good candidate for a small external crate, but I couldn't find one that works with scancode set 1. In case you'd like to write such a crate and need mentoring, just let us know, we're happy to help!
Summary
In this post we learned how to enable and handle external interrupts. We learned about the 8259 PIC and its primary/secondary layout, the remapping of the interrupt numbers, and the "end of interrupt" signal. We saw that the hardware timer and the keyboard controller are active by default and start to send interrupts as soon as we enable them in the CPU. We learned about the hlt instruction, which halts the CPU until the next interrupt, and about the scancode sets of PS/2 keyboards.
Now we are able to interact with our kernel and have some fundamential building blocks for creating a small shell or simple games.
What's next?
As already mentioned, the 8259 APIC has been superseded by the APIC, a controller with more capabilities and multicore support. In the next post we will explore this controller and learn how to use its integrated timer and interrupt priorities.