Demilade Sonuga's blog

All posts

The Interrupt Descriptor Table III

2023-02-13 · 22 min read

In the previous post, we started modeling the IDT but stopped at the problem of determining how to appropriately associate different types of service routines with entries. This is what we're going to resolve now.

The Current Unsafe Situation

The problem was mentioned in the previous post but to make it more concrete, consider this code:

fn add_double_fault_handler(idt: &mut IDT) {
idt.double_fault.set_handler(entry with address of breakpoint_handler);
}

extern "x86-interrupt" fn breakpoint_handler(frame: InterruptStackFrame) {
/* Some Code */
}

In the above code, the breakpoint service routine is used as the routine for the double fault entry. Depending on the situation at hand, this will could lead to hard-to-track bugs because projects like these are usually big and the problem could be anywhere.

We need to remember that the whole point and primary reason for Rust and all that it offers are to protect us from ourselves. The truth is we programmers are dangerous to ourselves and we're often the number one cause of our problems. One small mistake here and one small mistake there and BOOM! Nothing works anymore. Then hours are wasted looking for bugs hiding in the open.

The point of Rust is to prevent errors like that as much as possible and this silly mistake displayed here is one of those avoidable problems.

What We Need

First, we need to ask ourselves: what exactly is it that we need to tell the compiler that "only entries with this kind of service routines can be in this part of the table"?

In other words, we need our code to look like this:

#[repr(C, align(8))]
pub struct IDT {
pub divide_by_zero: Entry with regular ServiceRoutine,
pub debug: Entry with regular ServiceRoutine,
pub nmi_interrupt: Entry with regular ServiceRoutine,
pub breakpoint: Entry with regular ServiceRoutine,
pub overflow: Entry with regular ServiceRoutine,
pub bound_range_exceeded: Entry with regular ServiceRoutine,
pub invalid_opcode: Entry with regular ServiceRoutine,
pub device_not_available: Entry with regular ServiceRoutine,
pub double_fault: Entry with ServiceRoutineWithNoReturn,
pub coprocessor_segment_overrun: Entry with regular ServiceRoutine,
pub invalid_tss: Entry with ServiceRoutineWithErrCode,
pub segment_not_present: Entry with ServiceRoutineWithErrCode,
pub stack_segment_fault: Entry with ServiceRoutineWithErrCode,
pub general_protection: Entry with ServiceRoutineWithErrCode,
pub page_fault: Entry with ServiceRoutineWithErrCode,
reserved1: Entry with regular ServiceRoutine,
pub floating_point_error: Entry with regular ServiceRoutine,
pub alignment_check: Entry with ServiceRoutineWithErrCode,
pub machine_check: Entry with ServiceRoutineWithNoReturn,
pub simd_floating_point_exception: Entry with regular ServiceRoutine,
pub virtualization_exception: Entry with regular ServiceRoutine,
pub control_protection_exception: Entry with ServiceRoutineWithErrCode,
reserved2: [Entry with regular ServiceRoutine; 10],
pub interrupts: [Entry with regular ServiceRoutine; 256 - 32]
}

That is, we want the type Entry to be associated with the type of service routine it's referring to.

An Answer

There are probably many ways of resolving this problem but the one we'll use here is Rust's generics. Generics is just one of Rust's mechanisms of polymorphism, that is allowing different types to be in the same context.

A Few Things About Generics

If you aren't too familiar with generics, take a look at this:

enum Option<T> {
Some(T),
None
}

This is the definition of the Option enum. It has a single generic parameter T. This definition just says "An option is either a None value or a Some(x) where x is a value of any type".

It's because of this generic definition we can have Options of many different types like Some(24), Some("hello generics"), Some(32.0f32), and so on.

Result too is defined similarly:

enum Result<T, E> {
Ok(T),
Err(E)
}

This just says "a value of type Result is either Ok(x) where x is a value of any type or it's Err(y) where y is a value of any type".

Imagine Result was defined like this:

enum Result {
Ok(i32),
Err(i32)
}

If Result was defined like this, then the Ok and Err variants will only be able to hold 32-bit signed integers, not strings, not floats, not anything else. This completely defeats the usefulness of Result because Result is supposed to be a type that indicates a potential failure in any context and not just when dealing with i32s.

It's because of the generics that types like Option and Result can be used with so many types.

If you want to dive deeper into this generic stuff, check the references.

Entries With Generics

Just like we indicate a result holding an i32 with Result<i32>, we can indicate an entry holding an address of a regular service routine with Entry<ServiceRoutine>, an entry with a service routine that takes an error code with Entry<ServiceRoutineWithErrCode>, an entry with a service routine that never returns with Entry<ServiceRoutineWithNoReturn>.

All we need to do is to add a generic parameter to Entry and specify these entry restrictions in the IDT definition.

In interrupts.rs

#[repr(C)]
#[derive(Clone, Copy)]
// DELETED: pub struct Entry { /* ...Others */ }
pub struct Entry<T> { /* ...Others */ } // NEW

// DELETED: impl Entry { /* ...Others */ }
impl<T> Entry<T> { /* ...Others */ } // NEW

/* DELETED:
#[repr(C, align(8))]
pub struct IDT { /* ...Others */ }
*/


// NEW:
#[repr(C, align(8))]
pub struct IDT {
pub divide_by_zero: Entry<ServiceRoutine>,
pub debug: Entry<ServiceRoutine>,
pub nmi_interrupt: Entry<ServiceRoutine>,
pub breakpoint: Entry<ServiceRoutine>,
pub overflow: Entry<ServiceRoutine>,
pub bound_range_exceeded: Entry<ServiceRoutine>,
pub invalid_opcode: Entry<ServiceRoutine>,
pub device_not_available: Entry<ServiceRoutine>,
pub double_fault: Entry<ServiceRoutineWithNoReturn>,
pub coprocessor_segment_overrun: Entry<ServiceRoutine>,
pub invalid_tss: Entry<ServiceRoutineWithErrCode>,
pub segment_not_present: Entry<ServiceRoutineWithErrCode>,
pub stack_segment_fault: Entry<ServiceRoutineWithErrCode>,
pub general_protection: Entry<ServiceRoutineWithErrCode>,
pub page_fault: Entry<ServiceRoutineWithErrCode>,
reserved1: Entry<ServiceRoutine>,
pub floating_point_error: Entry<ServiceRoutine>,
pub alignment_check: Entry<ServiceRoutineWithErrCode>,
pub machine_check: Entry<ServiceRoutineWithNoReturn>,
pub simd_floating_point_exception: Entry<ServiceRoutine>,
pub virtualization_exception: Entry<ServiceRoutine>,
pub control_protection_exception: Entry<ServiceRoutineWithErrCode>,
reserved2: [Entry<ServiceRoutine>; 10],
pub interrupts: [Entry<ServiceRoutine>; 256 - 32]
}

Now, we've been able to encode the restrictions on what type of service routines should be in each entry.

The fictional code snippet in the problem definition at the beginning will not even be compiled by the compiler anymore because the type of breakpoint_handler (Entry<ServiceRoutine>) does not match the type required by the double fault handler (Entry<ServiceRoutineWithNoReturn>).

If you try running the code now, you'll get an error:

error[E0392]: parameter `T` is never used
  --> blasterball/src/interrupts.rs:20:18
   |
20 | pub struct Entry<T> {
   |                  ^ unused parameter
   |
   = help: consider removing `T`, referring to it in a field, or using a marker such as `PhantomData`
   = help: if you intended `T` to be a const parameter, use `const T: usize` instead

Apparently, a struct with a generic parameter must have that generic used in the struct.

The thing is we don't use the generic for anything in the struct, it's just there to provide some restrictions on what service routines can and can't be used with an entry.

To resolve this, we place a dummy field to make use of the generic:

use core::arch::asm;
use crate::gdt::SegmentSelector;
use core::marker::PhantomData; // NEW

// ...Others

#[repr(C)]
#[derive(Clone, Copy)]
pub struct Entry<T> {
handler_ptr_low: u16,
segment_selector: SegmentSelector,
options: Options,
handler_ptr_middle: u16,
handler_ptr_upper: u32,
reserved: u32,
// Dummy field to resolve problem of no field making
// use of generic type
phantom: PhantomData<T> // NEW
}

impl<T> Entry<T> {
// Creates an empty entry
fn empty() -> Self {
Self {
handler_ptr_low: 0,
segment_selector: SegmentSelector(0),
options: Options(0),
handler_ptr_middle: 0,
handler_ptr_upper: 0,
reserved: 0,
phantom: PhantomData // NEW
}
}
}

PhantomData is a zero-sized type. The whole purpose of this struct is to solve this problem. If you want to learn more about it, check the references.

Compiling again will give another bunch of visibility errors.

Resolving:

#[repr(C)]
// DELETED: struct InterruptStackFrame { /* ...Others */ }
pub struct InterruptStackFrame { /* ...Others */ } // NEW

// DELETED: type ServiceRoutine = extern "x86-interrupt" fn(InterruptStackFrame);
pub type ServiceRoutine = extern "x86-interrupt" fn(InterruptStackFrame); // NEW

// DELETED: type ServiceRoutineWithErrCode = extern "x86-interrupt" fn(InterruptStackFrame, u64);
pub type ServiceRoutineWithErrCode = extern "x86-interrupt" fn(InterruptStackFrame, u64); // NEW

// DELETED: type ServiceRoutineWithNoReturn = extern "x86-interrupt" fn(InterruptStackFrame, u64) -> !;
pub type ServiceRoutineWithNoReturn = extern "x86-interrupt" fn(InterruptStackFrame, u64) -> !; // NEW

The compilation is now successful.

Take Away

  • Generics are used when multiple types can be used in the same context.

For the full code, go to the repo

In The Next Post

We'll continue with the IDT

References