Software by mistakes

OS Development Notes #1: UEFI Boot

|

Setting up

My first iteration of exploring making Operating Systems is based on the idea of actually making anything possible with Rust, UEFI, and QEMU.

I know as a basic fact, that a computer needs to boot a system, and a modern x86_64 system uses UEFI. For this we need to use Rust nightly:

cargo new kattegat
cd kattegat
echo 'nightly' >> rust-toolchain

My project code names are defined by Danish waters, starting with Kattegat.

We also need to tell Rust that we need to build an EFI target in the file .cargo/config.toml:

[build]
target = "x86_64-unknown-uefi"

[unstable]
build-std-features = ["compiler-builtins-mem"]
build-std = ["core", "alloc", "compiler_builtins"]

[target.x86_64-unknown-uefi]
rustflags = ["-C", "link-args=/debug:dwarf"]

We also define to not use std and only use core instead, as we create our own memory allocator.

UEFI specification

The first code to write in src/main.rs is to use the EFI entrypoint, and define that we don't use std and we have no main function to build from. We also need to have a Panic Handler as default, which we will use later on:

#![no_std]
#![no_main]

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}

#[no_mangle]
extern "C" fn efi_main(_image_handle: Handle, _system_table: *mut SystemTable) -> usize {
    loop {}
}

For the EFI Entrypoint we need to define the Handle and SystemTable structs based on what the UEFI specification specifies:

Handle

Handle is defined as an C-style interface:

A collection of related interfaces. Type VOID *.

use core::{ffi::c_void, ptr::NonNull};

#[repr(C)]
struct Handle(NonNull<c_void>);

For this Rust core library can give us access to c-style void pointers, which we want to define as existing, hence the NonNull.

SystemTable

SystemTable is a big table in UEFI, that is the basis of all UEFI features.

UEFI uses the EFI System Table, which contains pointers to the runtime and boot services tables. The definition for this table is shown in the following code fragments. Except for the table header, all elements in the service tables are pointers to functions as defined in Services — Boot Services and Services — Runtime Services. Prior to a call to EFI_BOOT_SERVICES.ExitBootServices(), all of the fields of the EFI System Table are valid.

The last sentence is very important, as we will see later on. Basically it means that most of the features in UEFI will cease to exist after this point, only leaving you with some information about the system and runtime services.

The system table, as other tables uses a header, defined as:

#[repr(C)]
struct EFIHeader {
    signature: u64,
    revision: u32,
    header_size: u32,
    crc32: u32,
    _reserved: u32,
}

The rest we can define as an Handle interface until used. All these entries are pointers, which we can define more closely when we want to use them.

#[repr(C)]
struct SystemTable {
    hdr: EFIHeader,
    firmware_vendor: *const u16,
    firmware_revision: u32,
    console_in_handle: Handle,
    console_in: Handle,
    console_out_handle: Handle,
    console_out: Handle,
    standard_error_handle: Handle,
    std_err: Handle,
    runtime_services: Handle,
    boot_services: Handle,
    number_of_table_entries: usize,
    configuration_table: Handle,
}

We can now run cargo build and get no errors. To use the EFI build, we can use QEMU to run it as network boot:

qemu-system-x86_64
-m 128
-bios OVMF.fd
-device driver=e1000,netdev=n0
-netdev user,id=n0,tftp=target/x86_64-unknown-uefi/debug,bootfile=kattegat.efi

For this we need access to a OVMF file for the UEFI Firmware emulation.

As a result we see the emulator hanging after a network boot. If there is an EFI error, we would get the UEFI Shell instead. As an example change loop {} to e.g. 1 as a return value that would be considered an error, and we would see the UEFI Shell.

That's it for the first notes. Next I will continue on UEFI for accessing the Graphics Output Protocol in the Boot Services.