Demilade Sonuga's blog

All posts

Refactoring VI

2023-02-04 · 17 min read

We've gotten the GDT in code. Before we move on to the Task State Segment and the Interrupt Descriptor Table, we have to do a little refactoring.

Let's start with efi_main:

#[no_mangle]
extern "efiapi" fn efi_main(
handle: *const core::ffi::c_void,
sys_table: *mut SystemTable,
) -> usize {
// ...Others

boot_services.exit_boot_services(handle).unwrap();

let mut gdt = GDT::new();
let cs = gdt.add_descriptor(Descriptor::code_segment()).unwrap();
let ds = gdt.add_descriptor(Descriptor::data_segment()).unwrap();
interrupts::disable_interrupts();
let gdt_pointer = gdt.as_pointer();
gdt.load(&gdt_pointer);
interrupts::enable_interrupts();
gdt::CS.set(cs);
gdt::DS.set(ds);
gdt::SS.set(ds);

game::blasterball(screen);

// ...Others
}

These lines have a single purpose: set up the GDT.

New function in main.rs:

// Creates a new GDT and sets it up
fn setup_gdt() {
let mut gdt = GDT::new();
let cs = gdt.add_descriptor(Descriptor::code_segment()).unwrap();
let ds = gdt.add_descriptor(Descriptor::data_segment()).unwrap();
interrupts::disable_interrupts();
let gdt_pointer = gdt.as_pointer();
gdt.load(&gdt_pointer);
interrupts::enable_interrupts();
gdt::CS.set(cs);
gdt::DS.set(ds);
gdt::SS.set(ds);
}

Refactoring:

#[no_mangle]
extern "efiapi" fn efi_main(
handle: *const core::ffi::c_void,
sys_table: *mut SystemTable,
) -> usize {
// ...Others

boot_services.exit_boot_services(handle).unwrap();

setup_gdt();

game::blasterball(screen);

// ...Others
}

Doing this introduces a new problem, or rather, exposes a problem that had already been there.

Consider GDT's load function:

pub fn load(&self, pointer: &DescriptorTablePointer) {
unsafe {
asm!("lgdt [{}]", in(reg) pointer);
}
}

This uses the lgdt instruction to tell the CPU the location of the GDT. The problem here is there is a hidden contract in this function, and that is, the GDT must remain valid until the end of the program.

To understand this, let's go back to the setup_gdt function:

fn setup_gdt() {
let mut gdt = GDT::new();
let cs = gdt.add_descriptor(Descriptor::code_segment()).unwrap();
let ds = gdt.add_descriptor(Descriptor::data_segment()).unwrap();
interrupts::disable_interrupts();
let gdt_pointer = gdt.as_pointer();
gdt.load(&gdt_pointer);
interrupts::enable_interrupts();
gdt::CS.set(cs);
gdt::DS.set(ds);
gdt::SS.set(ds);
}

It begins with the creation of a new GDT instance. This GDT instance is saved on the stack because it is a local variable. The gdt.load loads a descriptor pointer with the GDT instance's address, which is on the stack, with the lgdt instruction. After the function returns, the stack space that held the GDT may be overwritten by some other function's stack frame. But at that point, the CPU will still wrongly think the GDT is at that stack location. This could lead to headaches and long hours looking for bugs.

To resolve this, we can take advantage of Rust's type system and tell the compiler about the nature of the function's contract, that is, expectations that the function assumes we've met. The GDT instance loaded with the load function must remain valid until the end of the program.

To do this, we first modify the DescriptorTablePointer:

#[repr(C, packed)]
pub struct DescriptorTablePointer {
limit: u16,
// DELETED: base: u64
base: &'static GDT // NEW
}

And reflecting this change in the as_pointer function:

pub fn as_pointer(&self) -> DescriptorTablePointer {
DescriptorTablePointer {
// DELETED: base: self as *const _ as u64,
base: &self, // NEW
limit: (mem::size_of::<Self>() - 1) as u16
}
}

A reference is an address and since we're generating code for x86-64, the addresses will be 64-bit values. So base is still a 64-bit value. The difference now is the compiler will be able to reason with the code to verify that what is used as base is a valid GDT address and the 'static lifetime, which tells the compiler that the reference should remain valid till the end of the program, is upheld.

This way, any GDT that is loaded with the load function is guaranteed to live long enough.

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

blog-blasterball on  refactoring-vi [!] via 🦀 v1.67.0-nightly took 15s 
❯ cargo runner
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/runner`
   Compiling blasterball v0.1.0 (/home/demilade/Documents/blog-blasterball/blasterball)

...

error: lifetime may not live long enough
  --> blasterball/src/gdt.rs:57:19
   |
55 |     pub fn as_pointer(&self) -> DescriptorTablePointer {
   |                       - let's call the lifetime of this reference `'1`
56 |         DescriptorTablePointer {
57 |             base: self,
   |                   ^^^^ this usage requires that `'1` must outlive `'static`

warning: `blasterball` (bin "blasterball") generated 4 warnings
error: could not compile `blasterball` due to previous error; 4 warnings emitted

This error is essentially telling us that a GDT instance that calls as_pointer may not remain valid for the whole program, violating the 'static lifetime required by DescriptorTablePointer.

To deal with this, we just add to the contract of this as_pointer function, a constraint that tells the compiler that only GDTs that live to the end of the program should be able to call this function.

In gdt.rs:

pub fn as_pointer(&'static self) -> DescriptorTablePointer {
DescriptorTablePointer {
base: self,
limit: (mem::size_of::<Self>() - 1) as u16
}
}

Another attempt at running the code and you'll get an error correctly complaining about some lines in our setup_gdt function.

The first error:

error[E0597]: `gdt` does not live long enough
  --> blasterball/src/main.rs:69:23
   |
69 |     let gdt_pointer = gdt.as_pointer();
   |                       ^^^^^^^^^^^^^^^^
   |                       |
   |                       borrowed value does not live long enough
   |                       argument requires that `gdt` is borrowed for `'static`
...
75 | }
   | - `gdt` dropped here while still borrowed

This is an error telling us that gdt doesn't meet the requirements for calling as_pointer because it doesn't last long enough.

To resolve this, we can just create a static variable, like SCREEN.

In main.rs

mod gdt;
use gdt::{Descriptor, GDT, SegmentRegister};

mod interrupts;

mod game;

static mut SCREEN: Option<&mut Screen> = None;

static mut GDT: Option<GDT> = None; // NEW

Remember that statics are hardcoded into the binary, that is, at compilation time, there is a segment of the program that is reserved just to keep static variables. When the program is loaded into memory, these static variables are loaded with them and stay in their reserved memory area from the beginning till the end of the program.

Refactoring to use this static:

fn setup_gdt() {
// DELETED: let mut gdt = GDT::new();
// NEW
let gdt: &mut GDT;
unsafe {
GDT = Some(GDT::new());
gdt = GDT.as_mut().unwrap();
}
let cs = gdt.add_descriptor(Descriptor::code_segment()).unwrap();
let ds = gdt.add_descriptor(Descriptor::data_segment()).unwrap();
interrupts::disable_interrupts();
let gdt_pointer = gdt.as_pointer();
gdt.load(&gdt_pointer);
interrupts::enable_interrupts();
gdt::CS.set(cs);
gdt::DS.set(ds);
gdt::SS.set(ds);
}

Running gives no errors.

Done with refactoring, for now.

Take Away

  • Function contracts are expectations that a function assumes we've met.
  • Rust's type system can be used to make implicit function contracts explicit.

For the full code, go to the repo

In The Next Post

We'll get into the Task State Segment