Demilade Sonuga's blog

All posts

The Interrupt Descriptor Table II

2023-02-10 · 30 min read

When we began our first steps into interrupts in the first IDT post, we concluded with the following steps:

  1. Understand, model and set up the GDT.
  2. Understand, model and set up the TSS.
  3. Get on with the IDT.

Now that we're done with the first 2, we can get on with the IDT.

Some Missing Info

According to the Intel SDM, the Interrupt Descriptor Table must start on an 8-byte boundary, meaning that the address of its first byte must be a multiple of 8.

Modeling The IDT

Let's start modeling the IDT by first modeling the IDT entry. This structure has already been described in the first IDT post. Before proceeding, model it yourself.

The Entries

In interrupts.rs

// An entry in the IDT
#[repr(C)]
pub struct Entry {

}

The #[repr(C)] is required because field order matters.

The first 16 bits of the entry are the first 16 bits of the handler's interrupt service routine:

#[repr(C)]
pub struct Entry {
// The lower 16 bits of the handler's interrupt service routine
handler_ptr_low: u16, // NEW
}

Bits 16..=31, the next 16 bits, is the segment selector of the code segment.

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

// ...Others

#[repr(C)]
pub struct Entry {
handler_ptr_low: u16,
// The segment selector of the code segment
segment_selector: SegmentSelector, // NEW
}

Bits 32..=47, the next 16 bits, are the Options that specify some info about the entry.

We can just model Options as a simple wrapper around a u16:

// Options in an IDT entry
#[repr(transparent)]
struct Options(u16);

It would have been nice if we could model it like this:

#[repr(C)]
struct Options {
ist_offset: u3,
reserved: u5,
entry_type: u4,
reserved: u1,
privilege_level: u2,
present: u1
}

Where un is a type meaning an unsigned n-bit number. This is not possible because these number sizes are not offered by Rust as types. This wrapper stuff is about the best we can do. We can then provide functions to perform bitwise operations on the bits to extract specific information.

Back to Entry:

#[repr(C)]
pub struct Entry {
handler_ptr_low: u16,
segment_selector: SegmentSelector,
// The IDT entry options
options: Options,
}

The next 16 bits of the entry, bits 48..=63, are the next 16 bits of the handler's interrupt service routine:

#[repr(C)]
pub struct Entry {
handler_ptr_low: u16,
segment_selector: SegmentSelector,
options: Options,
// The next 16 bits of the interrupt service routine
handler_ptr_middle: u16,
}

Bits 64..=95: the next 32 bits of the handler interrupt service routine.

#[repr(C)]
pub struct Entry {
handler_ptr_low: u16,
segment_selector: SegmentSelector,
options: Options,
handler_ptr_middle: u16,
// The upper 32 bits of the interrupt service routine
handler_ptr_upper: u32,
}

The remaining bits 96..=127, 32 bits, are reserved:

#[repr(C)]
pub struct Entry {
handler_ptr_low: u16,
segment_selector: SegmentSelector,
options: Options,
handler_ptr_middle: u16,
handler_ptr_upper: u32,
reserved: u32
}

And a function for creating a new empty entry:

impl Entry {
// 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
}
}
}

The Table Itself

Recall from the first IDT post that the table is an array of entries, the first 32 of which are named exception handlers. We model it like so:

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

A function for creating a new IDT:

impl IDT {
pub fn new() -> Self {
Self {
divide_by_zero: Entry::empty(),
debug: Entry::empty(),
nmi_interrupt: Entry::empty(),
breakpoint: Entry::empty(),
overflow: Entry::empty(),
bound_range_exceeded: Entry::empty(),
invalid_opcode: Entry::empty(),
device_not_available: Entry::empty(),
double_fault: Entry::empty(),
coprocessor_segment_overrun: Entry::empty(),
invalid_tss: Entry::empty(),
segment_not_present: Entry::empty(),
stack_segment_fault: Entry::empty(),
general_protection: Entry::empty(),
page_fault: Entry::empty(),
reserved1: Entry::empty(),
floating_point_error: Entry::empty(),
alignment_check: Entry::empty(),
machine_check: Entry::empty(),
simd_floating_point_exception: Entry::empty(),
virtualization_exception: Entry::empty(),
control_protection_exception: Entry::empty(),
reserved2: [Entry::empty(); 10],
interrupts: [Entry::empty(); 256 - 32]
}
}
}

Compiling now will yield errors about Entry not implementing core::marker::Copy. This is because for Entry to be used in the array initialization [Entry::empty(); 10], it has to implement Copy.

Resolving this:

#[repr(C)]
#[derive(Clone, Copy)] // NEW
pub struct Entry {
handler_ptr_low: u16,
segment_selector: SegmentSelector,
options: Options,
handler_ptr_middle: u16,
handler_ptr_upper: u32,
reserved: u32
}

For Entry to be made Copy, all its fields too have to be Copy. The only left to implement Copy is Options:

#[repr(transparent)]
#[derive(Clone, Copy)]
struct Options(u16);

Compiling now yields no errors.

The Interrupt Service Routines

As for these functions, there are a number of things we need to note about them.

Calling Convention

For one thing, they are called by the CPU, not our code. This implies that the way Rust functions are called is not the way these functions will be called. In other words, these functions will have a different calling convention from the normal Rust functions we've been using. For a brief reminder of this calling convention stuff, you can refer back to the second Getting Started post.

Remember that calling conventions are the contracts that function callers and callees follow when writing and calling functions to determine which arguments will be in which registers and which area of the stack and how the function will return.

Luckily for us, rather than us having to deal with these peculiarities ourselves, Rust provides a mechanism that we can use to tell the compiler to generate code for functions with this convention in mind.

First, we need to enable a new feature in the compiler:

In main.rs

#![no_std]
#![no_main]
// DELETED: #![feature(abi_efiapi)]
#![feature(abi_efiapi, abi_x86_interrupt)] // NEW
#![feature(panic_info_message)]

Just like the way we were able to specify functions that adhere to UEFI's calling conventions, we can now specify functions that adhere to x86's interrupt calling convention with extern "x86-interrupt" fn().

In interrupts.rs

type ServiceRoutine = extern "x86-interrupt" fn();

Arguments

The routines also take arguments depending on the routine being called. For the first function argument, they all accept a structure called the interrupt stack frame.

The Interrupt Stack Frame

This is a structure passed as the first argument to any interrupt service routine. Well, actually, saying that the structure is "passed" as an argument is an oversimplification. Rather, the structure is made up of values that are pushed on the stack whenever a routine is about to be executed. These values in push order are:

  1. The stack segment.
  2. The stack pointer.
  3. The contents of the flags register.
  4. The code segment selector in the CS register.
  5. The current instruction pointer.

So, the current instruction pointer is pushed first and the stack segment is pushed last.

A segment selector for the new stack (if there is one) will be loaded into the stack segment register. The address of a new stack, if implied will be loaded into the RSP register (the stack pointer register) (If the entry says there is a stack to switch to).

These values are pushed onto the stack so that after the routine finishes executing, the processor will be able to resume execution from where it stopped. After these values are pushed, an error code may or may not be pushed and the segment selector specified by the entry will also be loaded into CS and the address of the routine will be loaded into the RSP register (the instruction pointer).

Anyways, these values pushed onto the stack make up the interrupt stack frame. Because of the last-in-first-out nature of a stack, these values will be in reverse order in the ISF structure.

Translating to Rust, we proceed like this:

In interrupts.rs

// The values pushed on the stack when an interrupt service routine
// is called by the processor
#[repr(C)]
struct InterruptStackFrame {

}

The first field will be the current instruction pointer. This is a 64-bit value because addresses on x86-64 are 64 bits in size.

#[repr(C)]
struct InterruptStackFrame {
// The address of the instruction that was executing
// before the processor switched to the service routine
original_instruction_ptr: u64,
}

Up next is the code segment selector that was being used before the entry's segment selector replaced it in the CS register. The segment selector is normally a 16-bit value but it is padded here to become 64-bit.

#[repr(C)]
struct InterruptStackFrame {
original_instruction_ptr: u64,
// The code segment selector that was being used before
// the control switch to the service routine
// It is padded to become 64 bits
original_code_segment: u64,
}

Next is the content of the FLAGS register. The FLAGS register is a special register used to control some aspects of the processor's operations. The cli and sti instructions we used to disable and enable interrupts manipulated this register to affect their operations. It's also used for other stuff. It, like most other registers on x86-64, is 64 bits in size.

#[repr(C)]
struct InterruptStackFrame {
original_instruction_ptr: u64,
original_code_segment: u64,
// The contents of the FLAGS register at the time of
// the control switch to the service routine
flags: u64,

}

Then we have the stack pointer which is the address of the stack before the switch.

#[repr(C)]
struct InterruptStackFrame {
original_instruction_ptr: u64,
original_code_segment: u64,
flags: u64,
// Address of the stack at the time of the switch
original_stack_ptr: u64,
}

Lastly, we have the stack segment selector, padded to become 64 bits:

#[repr(C)]
struct InterruptStackFrame {
original_instruction_ptr: u64,
original_code_segment: u64,
flags: u64,
original_stack_ptr: u64,
// The stack segment selector at the time of the switch
original_stack_segment: u64
}

The service routine now:

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

Error Codes

As mentioned earlier, there are some routines that push error codes onto the stack with their interrupt stack frames. These are routines of exceptions:

  1. Double fault
  2. General protection
  3. Invalid TSS
  4. Segment Not Present
  5. Stack Segment Fault
  6. Page Fault
  7. Alignment Check
  8. Control Protection Exception

When the service routines associated with these exceptions are called, an error code is also passed. These routines have these error codes as their second function arguments:

type ServiceRoutine = extern "x86-interrupt" fn(InterruptStackFrame);

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

No Return

Most service routines return with no values but some don't return at all. These are the routines of irrecoverable exceptions:

  1. Double Fault
  2. Machine Check

We have to account for their nature with yet another type alias:

type ServiceRoutine = extern "x86-interrupt" fn(InterruptStackFrame);

type ServiceRoutineWithErrCode = extern "x86-interrupt" fn(InterruptStackFrame, u64);

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

The Problem With The Routines

We need to create functions to add new handlers to the table.

Each entry needs the address of their associated service routines so such a function will need to receive the function pointer of the routine as an argument.

The problem here is how would we know which function type should be used for which entry. We need a way to encode this in the type system to avoid screwing ourselves over. For example, suppose we put a routine with type ServiceRoutine as the routine for the Double Fault exception. This will lead to problems that can be easily avoided with Rust's safety mechanisms.

We'll get to this in the next post.

Take Away

For the full code, go to the repo

In The Next Post

We'll continue with the IDT.

References