Demilade Sonuga's blog
All postsRefactoring VI
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 GDT
s 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