OS Experiment in Rust (part 2): UEFI Resource Access
This is a continuation of the series on writing a simply hobby OS project in Rust. The first part of the series is here: OS Experiment in Rust (part 1): Creating a UEFI Loader. This portion of the exercise will cover some details helpful in bootstrapping the (eventual) kernel that will be written. This includes: interrogating UEFI and ACPI for initial details necessary for understanding the hardware configuration that is being booted from, as well as performing some initial framebuffer configuration to provide the booted kernel with standardized display capability immediately on boot.
Bootstrapping For the Kernel
UEFI is the successor to the legacy PC BIOS that largely governed PC boot-up since the introduction of the IBM PC since the 1980s. Similar to BIOS, UEFI provides a standard abstraction for various computer hardware internals that could be manufactured by any number of vendors, and thus, expect a variety of hard/software APIs to interact with them. As well, UEFI firmware is responsible for presenting some hardware details of the running system, in a standard manner, so that the operating system can work from some hints about the running system when it is initializing.
Another standard, which pre-dates UEFI, exists named ACPI (Advanced Configuration and Power Interface). Most commonly associated with power-management functions (like sleep/wake, and battery reporting), this standard also documents some boot-time hardware and software configuration information that can be consumed by the OS. There are additional facilites that provide this information, too, such as SMBIOS. UEFI provides pointers to these tables, so that the OS can find them without having to perform time-consuming discovery tasks. As well, UEFI provides a map of physically-addressable memory ranges, and their type/purpose information, which must be conveyed to the OS, as well.
As mentioned earlier, UEFI also provides some standard interfaces into common features. One of these features is the ability to perform GPU mode-setting and framebuffer allocation. Handling this via the UEFI bootloader will enable us to provide a functional basic display interface for the OS, that is inherited from the bootloader. This way, we can report OS information without having to first accomplish the task of writing a GPU driver for the display hardware (virtualized or not).
Bootstrapping Goals (for this phase)
For our exercise, I don’t want to spend a whole lot of time designing a complicated boot loader. Instead, I just want the boot loader to set up the framebuffer, collect together pointers to the various hardware tables discussed above, and communicate these to the kernel, when it transfers execution to it. Additionally, I’ll demonstrate some rudimentary UEFI input handling (for the purpose of pausing execution for display).
- Announce loader start
- Wait for a keypress
- Collect together the physical addresses of the following tables into a data structure:
- Physical Memory Layout (ranges + type info)
- ACPI
- SMBIOS
- PCI Express Enhanced Configuration Access Mechanism MMIO range(s)
- Report the data structure contents to the screen (using the
log
interface from Part 1) - Wait for a keypress
- Mode-switch the UEFI display into a graphics mode with a 24bpp FrameBuffer
- Load a font and demonstrate writing text and drawing primitives (relying upon the wonderful
embedded_graphics
crate) - Store the Framebuffer structure pointer in the data structure being sent to the kernel
- Display the contents of the data structure again, via text rendered to the framebuffer
- Halt execution
Waiting For a Keypress
UEFI provides a very low-level interface to the keyboard (albeit a better abstraction than the old
8042 PS/2 keyboard controller when booting from BIOS). This
simplified interface means that we must tell UEFI that we want to wait for a key event to occur. The input
system will queue up all unhandled events for us, so we don’t want keypresses prior to our "Wait"
message
to be interpreted as a keypress, so the following sequence informs the user the system is pausing, and clears
the input buffer (resetting the state to no keys pressed, so that we can wait for the next key after this
pause point):
info!("Press a key to contine...");
st.stdin().reset(true)?;
The following instantiates a new “key press event” which will be used to instruct what type of event we want the UEFI system to pause and wait for:
let mut key_press_event = unsafe { [st.stdin().wait_for_key_event().unsafe_clone()] };
And, finally, use the broad wait_for_event
interface, and provide it with the key press event object that
was just instantiated. I do an .unwrap()
here, as I would like it to simply panic if we don’t get a key-press
(meaning something unexpected occurred in the firmware).
st.boot_services().wait_for_event(&mut key_press_event).unwrap();
Stitching it all together, we can create a new utility function:
fn wait_for_keypress(st: &mut SystemTable<Boot>) -> uefi::Result {
info!("Press a key to contine...");
st.stdin().reset(true)?;
let mut key_press_event = unsafe { [st.stdin().wait_for_key_event().unsafe_clone()] };
st.boot_services().wait_for_event(&mut key_press_event).unwrap();
Ok(())
}
Enumerate UEFI Configuration Tables
An initial step will be to ask for a list of the System Configuration Tables from UEFI. The SMBIOS and ACPI tables mentioned earlier are two of these. The total number will vary from system to system. Some of them will be standard tables, while others may be highly specific to a particular vendor or even a particular piece of equipment. Each of them are identified by a unique GUID value.
The uefi
Rust crate provides some constants to help identify some well-known table types:
The config_table
method in uefi::table::SystemTable
provides visibility into the list of configuration tables. These can be listed with the following loop:
for cfg in system_table.config_table() {
info!("Ptr: {:#018x}, GUID: {}", cfg.address as usize, cfg.guid);
}
Using the program from Part1 as a basis, these two features can be added to the program to display the GUIDs of the configuration tables to the screen:
#![no_main]
#![no_std]
// Use the abstracted log interface for console output
use log::{error, info, warn};
// Import a bunch of commonly-used UEFI symbols exported by the crate
use uefi::prelude::*;
fn wait_for_keypress(st: &mut SystemTable<Boot>) -> uefi::Result {
info!("Press a key to contine...");
st.stdin().reset(true)?;
let mut key_press_event = unsafe { [st.stdin().wait_for_key_event().unsafe_clone()] };
st.boot_services().wait_for_event(&mut key_press_event).unwrap();
Ok(())
}
// Tell the uefi crate that this function will be our entrypoint
#[entry]
// Declare "hello_main" to accept two arguments, and use the type definitions provided by uefi
fn hello_main(image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
// In order to use any of the services (input, output, etc...), they need to be manually
// initialized by the UEFI program
uefi_services::init(&mut system_table).unwrap();
info!("Image Handle: {:#018x}", image_handle.as_ptr() as usize);
info!("System Table: {:#018x}", core::ptr::addr_of!(system_table) as usize);
info!(
"UEFI Revision: {}.{}",
system_table.uefi_revision().major(),
system_table.uefi_revision().minor()
);
// Iterate across the Config Tables and enumerate them, displaying them to the log
for cfg in system_table.config_table() {
info!("Ptr: {:#018x}, GUID: {}", cfg.address as usize, cfg.guid);
}
wait_for_keypress(&mut system_table).unwrap();
// Tell the UEFI firmware we exited without error
Status::SUCCESS
}
Compile and re-run, and you should see the following output. As well, rather than waiting
10 seconds and exiting, the VM should wait indefinitely for a key-press.
Making Sense of the Tables’ GUIDs
The above GUIDs don’t really mean much to the observer. However, the uefi
crate has a list of
GUID constants associated with well-known tables:
To make elegant use of this, I’ll make a new type based upon the uefi::Guid
type, using a tuple
struct, and implement it as a separate module (src/cfg_table_type.rs
):
use core::format_args;
use uefi::Guid;
#[derive(Debug)]
pub struct CfgTableType(uefi::Guid);
// Constructor that allows us to use .into() on any uefi::Guid to convert to CfgTableType
impl From<Guid> for CfgTableType {
fn from(guid: Guid) -> Self {
Self(guid)
}
}
/*
* This implements a Display trait that will output the human-readable name for any recognized GUIDs
* while falling back to the Display implementation in uefi::Guid, for unrecognized ones.
*/
impl core::fmt::Display for CfgTableType {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::result::Result<(), core::fmt::Error> {
match self.0 {
uefi::table::cfg::ACPI2_GUID => f.write_str("ACPI2"),
uefi::table::cfg::ACPI_GUID => f.write_str("ACPI1"),
uefi::table::cfg::DEBUG_IMAGE_INFO_GUID => f.write_str("Debug Image"),
uefi::table::cfg::DXE_SERVICES_GUID => f.write_str("DXE Services"),
uefi::table::cfg::ESRT_GUID => f.write_str("EFI System Resources"),
uefi::table::cfg::HAND_OFF_BLOCK_LIST_GUID => f.write_str("Hand-off Block List"),
uefi::table::cfg::LZMA_COMPRESS_GUID => f.write_str("LZMA Compressed filesystem"),
uefi::table::cfg::MEMORY_STATUS_CODE_RECORD_GUID => f.write_str("Hand-off Status Code"),
uefi::table::cfg::MEMORY_TYPE_INFORMATION_GUID => f.write_str("Memory Type Information"),
uefi::table::cfg::PROPERTIES_TABLE_GUID => f.write_str("Properties Table"),
uefi::table::cfg::SMBIOS3_GUID => f.write_str("SMBIOS3"),
uefi::table::cfg::SMBIOS_GUID => f.write_str("SMBIOS1"),
uefi::table::cfg::TIANO_COMPRESS_GUID => f.write_str("Tiano compressed filesystem"),
x => f.write_fmt(format_args!("{}", x)),
}
}
}
The above creates a new type CfgTableType
that can be derived from the uefi::Guid
and will pretty-print
the table name for the known table GUIDs already defined in the uefi::table::cfg
constants.
To use it in src/main.rs
I’ll simply add the following near the top of the file with the other use
lines:
mod cfg_table_type;
use crate::cfg_table_type::CfgTableType;
And then the loop can be rewritten as:
// Iterate across the Config Tables and enumerate them
for cfg in system_table.config_table() {
let cfg_table_name: CfgTableType = cfg.guid.into();
info!("Ptr: {:#018x}, GUID: {}", cfg.address as usize, cfg_table_name);
}

In my VM, most of the tables were identified. Doing some further research, I was able to identify the purpose
of the one unrecognized UEFI Configuration Table, dcfa911d-26eb-469f-a220-38b7dc461220
,
as the Memory Attributes Table.
Defining Additional GUID Constants
The above can be used at compile-time to generate new GUID constants for your use, if desired. This logic
can be added to the src/cfg_table_type.rs
code, with zero modification to src/main.rs
necessary:
use core::format_args;
use uefi::{Guid, guid};
#[derive(Debug)]
pub struct CfgTableType(uefi::Guid);
// Compile a GUID constant from the GUID string
pub const UEFI_MEMORY_ATTRIBUTES_TABLE: Guid = guid!("dcfa911d-26eb-469f-a220-38b7dc461220");
// Constructor that allows us to use .into() on any uefi::Guid to convert to CfgTableType
impl From<Guid> for CfgTableType {
fn from(guid: Guid) -> Self {
Self(guid)
}
}
/*
* This implements a Display trait that will output the human-readable name for any recognized GUIDs
* while falling back to the Display implementation in uefi::Guid, for unrecognized ones.
*/
impl core::fmt::Display for CfgTableType {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::result::Result<(), core::fmt::Error> {
match self.0 {
uefi::table::cfg::ACPI2_GUID => f.write_str("ACPI2"),
uefi::table::cfg::ACPI_GUID => f.write_str("ACPI1"),
uefi::table::cfg::DEBUG_IMAGE_INFO_GUID => f.write_str("Debug Image"),
uefi::table::cfg::DXE_SERVICES_GUID => f.write_str("DXE Services"),
uefi::table::cfg::ESRT_GUID => f.write_str("EFI System Resources"),
uefi::table::cfg::HAND_OFF_BLOCK_LIST_GUID => f.write_str("Hand-off Block List"),
uefi::table::cfg::LZMA_COMPRESS_GUID => f.write_str("LZMA Compressed filesystem"),
uefi::table::cfg::MEMORY_STATUS_CODE_RECORD_GUID => f.write_str("Hand-off Status Code"),
uefi::table::cfg::MEMORY_TYPE_INFORMATION_GUID => f.write_str("Memory Type Information"),
uefi::table::cfg::PROPERTIES_TABLE_GUID => f.write_str("Properties Table"),
uefi::table::cfg::SMBIOS3_GUID => f.write_str("SMBIOS3"),
uefi::table::cfg::SMBIOS_GUID => f.write_str("SMBIOS1"),
uefi::table::cfg::TIANO_COMPRESS_GUID => f.write_str("Tiano compressed filesystem"),
UEFI_MEMORY_ATTRIBUTES_TABLE => f.write_str("Memory Attributes"), // The new GUID
x => f.write_fmt(format_args!("{}", x)),
}
}
}

Organizing SMBIOS and ACPI Data For Kernel
Using the above set of UEFI Configuration Tables, it is feasible for us to populate two of the pointers
we plan to provide to the kernel we intend to (eventually) boot to: the ACPI and SMBIOS tables. So, to start
off, I’ll create a new module kernel_args
to define a new type KernelArgs
and associated methods to help
interfacing with it.
So, I’ll add a new src/kernel_args.rs
:
use core::ffi::c_void;
use uefi::table::cfg::{ACPI_GUID, ACPI2_GUID, ConfigTableEntry, SMBIOS_GUID, SMBIOS3_GUID};
#[derive(Copy, Clone, Debug)]
pub struct KernelArgs {
/// The physical address of the ACPI RSDP
acpi_ptr: *const c_void,
/// The physical address of the SMBIOS table
smbios_ptr: *const c_void,
/// The version of the ACPI RSDP pointed at by `self.acpi_ptr`
acpi_ver: u8,
/// The version of the SMBIOS table pointed at by `self.smbios_ptr`
smbios_ver: u8,
}
// Initially populate an empty struct with every value set to 0. We cannot derive this
// because core::ffi::c_void has no Default implementation.
impl Default for KernelArgs {
fn default() -> Self {
Self {
acpi_ptr: 0 as *const c_void,
smbios_ptr: 0 as *const c_void,
acpi_ver: 0,
smbios_ver: 0,
}
}
}
impl KernelArgs {
/// Populate the SMBIOS and ACPI pointers/versions from a UEFI Config Table
pub fn populate_from_cfg_table(&mut self, cfg_tables: &[ConfigTableEntry]) {
// Iterate across the Config Tables, find the SMBIOS and ACPI tables, and populate their
// pointers. Multiple versions of the standards could exist in memory, so this process will
// search the entire table space and favor the highest-version implementation of the ACPI
// or SMBIO standards, where they are present, and reflect this choice in a separate version
// field.
for cfg in cfg_tables {
match cfg.guid {
ACPI2_GUID => {
if self.acpi_ver < 2 {
self.acpi_ver = 2;
self.acpi_ptr = cfg.address;
}
}
ACPI_GUID => {
if self.acpi_ver < 1 {
self.acpi_ver = 1;
self.acpi_ptr = cfg.address;
}
}
SMBIOS3_GUID => {
if self.smbios_ver < 3 {
self.smbios_ver = 3;
self.smbios_ptr = cfg.address;
}
}
SMBIOS_GUID => {
if self.smbios_ver < 1 {
self.smbios_ver = 1;
self.smbios_ptr = cfg.address;
}
}
_ => {},
}
}
}
/// Returns the ACPI pointer and version as a pair
pub fn get_acpi(&self) -> (*const c_void, u8) {
(self.acpi_ptr, self.acpi_ver)
}
/// Returns the SMBIOS pointer and version as a pair
pub fn get_smbios(&self) -> (*const c_void, u8) {
(self.smbios_ptr, self.smbios_ver)
}
}
Then, src/main.rs
can be further updated to call this code to gather the appropriate tables,
rather than iterating through and displaying them all:
#![no_main]
#![no_std]
mod cfg_table_type;
mod kernel_args;
// Use the abstracted log interface for console output
use log::{error, info, warn};
// Import a bunch of commonly-used UEFI symbols exported by the crate
use uefi::prelude::*;
use crate::kernel_args::KernelArgs;
fn wait_for_keypress(st: &mut SystemTable<Boot>) -> uefi::Result {
info!("Press a key to contine...");
st.stdin().reset(true)?;
let mut key_press_event = unsafe { [st.stdin().wait_for_key_event().unsafe_clone()] };
st.boot_services().wait_for_event(&mut key_press_event).unwrap();
Ok(())
}
// Tell the uefi crate that this function will be our entrypoint
#[entry]
// Declare "hello_main" to accept two arguments, and use the type definitions provided by uefi
fn hello_main(image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
// In order to use any of the services (input, output, etc...), they need to be manually
// initialized by the UEFI program
uefi_services::init(&mut system_table).unwrap();
info!("Image Handle: {:#018x}", image_handle.as_ptr() as usize);
info!("System Table: {:#018x}", core::ptr::addr_of!(system_table) as usize);
info!(
"UEFI Revision: {}.{}",
system_table.uefi_revision().major(),
system_table.uefi_revision().minor()
);
let mut karg = KernelArgs::default();
info!("Empty karg: {:?}", karg);
karg.populate_from_cfg_table(system_table.config_table());
info!("Populated karg: {:?}", karg);
wait_for_keypress(&mut system_table).unwrap();
// Tell the UEFI firmware we exited without error
Status::SUCCESS
}
In the above code, the {:?}
placeholder is used in the format string displaying karg
so
that the “debug representation” of the data structure is displayed. This allows for a simple
method to inspect data at run-time, and enables us to validate that the proper table pointers
were loaded into the data structure.

PCI Express (ECAM) Information
Next, I’ll discover the location of the PCI Express MMIO range. Though this is through ACPI, and conceivably could be done entirely in the kernel, I’m preferring to do it in the loader so that we can validate some of the OS hardware requirements prior to booting the kernel.
To do this, rather than rebuild all the tables and data structures by hand, I’ll use the
handy acpi
crate, by adding the following line to
the [dependencies]
section of Cargo.toml
:
acpi = "4"
acpi Crate Pre-requisite
The acpi
crate is designed to be versatile and work across a variety of platforms, and in
a variety of applications from low-level programs to command-line tools running as an unprivileged
user in a normal OS environment. One of the details that acpi
needs to address is how to convert
physical memory addresses, which is what is stored in ACPI tables, into virtual memory addresses,
which is what the CPU interprets. Modern operating system make use of the
Paging feature of CPUs, which yields a virtual
memory map to applications that is not required to reflect the real physical memory layout of
the underlying hardware. However, in UEFI, we are running code with Ring 0 (supervisor) privilege
rights, and all of the physical memory has been identity mapped to virtual memory pages, meaning
that all CPU addresses will be equivalent to their physical counterparts.
I bring this up because the acpi
crate is written to handle either case (physical == virtual, or
physical != virtual). The crate requires the user to provide an object that implements the
acpi::AcpiHandler
trait, in order to
make use of critical access functions. This trait requires us to implement two methods:
map_physical_region
: Asks the OS to map a physical memory address to a virtual one and give us the resulting mapunmap_physical_region
: Asks the OS to unmap a virtual address mapped using the above method
This pair of operations is similar to malloc
/free
or new
/delete
, except that, instead of
allocating free memory to the program, the program would be asking the OS to expose the data from
the requested physical address that would (typically) be hidden from a normal application. However,
in our case this memory is already always accessible to the UEFI loader we have built, and we don’t
want to unmap it after we’re done exploring it.
Identity-mapped AcpiHandler
In a new file named src/identity_acpi_handler.rs
, I’ll create the following implementation:
use acpi::{AcpiHandler, PhysicalMapping};
#[derive(Clone)]
pub struct IdentityAcpiHandler;
impl AcpiHandler for IdentityAcpiHandler {
unsafe fn map_physical_region<T>(
&self,
physical_address: usize,
size: usize,
) -> PhysicalMapping<Self, T> {
// Since we are working with identity-mapped physical pages, and already
// Ring 0, we can simply return the data requested back to the caller,
// making both physical and virtual addresses equal to physical_address
PhysicalMapping::new(
physical_address,
core::ptr::NonNull::<T>::new_unchecked(physical_address as *mut T),
size,
size,
Self,
)
}
/// This can simply be a no-op because the region is always availabe in UEFI
fn unmap_physical_region<T>(_region: &PhysicalMapping<Self, T>) { }
}
Then, in src/main.rs
add the following to the appropriate locations in the file:
mod identity_acpi_handler;
use crate::identity_acpi_handler::IdentityAcpiHandler;
Initializing the acpi Crate
Now that we have the IdentityAcpiHandler
implemented, we can use it, plus the ACPI pointer we recorded in the
KernelArgs
structure, to populate the AcpiTables
data structure that the loader can use to get more details
to pass to the kernel.
Add the following use
lines to the src/main.rs
:
use acpi::AcpiTables;
use acpi::mcfg::PciConfigRegions;
Then, some time after the call to karg.populate_from_cfg_table(system_table.config_table());
, the following
will parse the ACPI data into structures that can be navigated in Rust:
let ih = IdentityAcpiHandler; // Create a new IdentityAcpiHandler
let acpi_tables = unsafe { AcpiTables::from_rsdp(ih, karg.get_acpi().0 as usize) }.unwrap();
info!("ACPI Revision: {}", acpi_tables.revision); // Report a property collected from ACPI
And then, the following will use acpi_tables
to find the PCI-express MMIO region(s):
let pcie_cfg = PciConfigRegions::new(&acpi_tables).unwrap();
let pcie_first_addr = pcie_cfg.physical_address(0, 0, 0, 0).unwrap();
info!("PCIe(0, 0, 0, 0): {:#018x}", pcie_first_addr);
Running the Loader With acpi
The new src/main.rs
should look like the following:
#![no_main]
#![no_std]
mod cfg_table_type;
mod kernel_args;
mod identity_acpi_handler;
// Use the abstracted log interface for console output
use log::{error, info, warn};
use acpi::AcpiTables;
use acpi::mcfg::PciConfigRegions;
// Import a bunch of commonly-used UEFI symbols exported by the crate
use uefi::prelude::*;
use crate::kernel_args::KernelArgs;
use crate::identity_acpi_handler::IdentityAcpiHandler;
fn wait_for_keypress(st: &mut SystemTable<Boot>) -> uefi::Result {
info!("Press a key to contine...");
st.stdin().reset(true)?;
let mut key_press_event = unsafe { [st.stdin().wait_for_key_event().unsafe_clone()] };
st.boot_services().wait_for_event(&mut key_press_event).unwrap();
Ok(())
}
// Tell the uefi crate that this function will be our entrypoint
#[entry]
// Declare "hello_main" to accept two arguments, and use the type definitions provided by uefi
fn hello_main(image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
// In order to use any of the services (input, output, etc...), they need to be manually
// initialized by the UEFI program
uefi_services::init(&mut system_table).unwrap();
info!("Image Handle: {:#018x}", image_handle.as_ptr() as usize);
info!("System Table: {:#018x}", core::ptr::addr_of!(system_table) as usize);
info!(
"UEFI Revision: {}.{}",
system_table.uefi_revision().major(),
system_table.uefi_revision().minor()
);
let mut karg = KernelArgs::default();
info!("Empty karg: {:?}", karg);
karg.populate_from_cfg_table(system_table.config_table());
info!("Populated karg: {:?}", karg);
let ih = IdentityAcpiHandler; // Create a new IdentityAcpiHandler
let acpi_tables = unsafe { AcpiTables::from_rsdp(ih, karg.get_acpi().0 as usize) }.unwrap();
info!("ACPI Revision: {}", acpi_tables.revision);
let pcie_cfg = PciConfigRegions::new(&acpi_tables).unwrap();
let pcie_first_addr = pcie_cfg.physical_address(0, 0, 0, 0).unwrap();
info!("PCIe(0, 0, 0, 0): {:#018x}", pcie_first_addr);
wait_for_keypress(&mut system_table).unwrap();
// Tell the UEFI firmware we exited without error
Status::SUCCESS
}
Running this, I encountered the following interesting panic!
:

It states that I am missing the MCFG
table from the ACPI tables. After some investigation, I realized that the cause of this problem is can be explained
by looking at the default QEMU configuration via qemu-system-x86_64 -machine help
:
Supported machines are:
microvm microvm (i386)
pc Standard PC (i440FX + PIIX, 1996) (alias of pc-i440fx-8.0)
pc-i440fx-8.0 Standard PC (i440FX + PIIX, 1996) (default)
pc-i440fx-7.2 Standard PC (i440FX + PIIX, 1996)
...
pc-i440fx-1.4 Standard PC (i440FX + PIIX, 1996) (deprecated)
q35 Standard PC (Q35 + ICH9, 2009) (alias of pc-q35-8.0)
pc-q35-8.0 Standard PC (Q35 + ICH9, 2009)
pc-q35-7.2 Standard PC (Q35 + ICH9, 2009)
pc-q35-7.1 Standard PC (Q35 + ICH9, 2009)
...
By default, QEMU virtualizes an i486 running on an i440FX motherboard, which
is an older design that doesn’t support PCI Express. Reading the documentation linked
from the MCFG
link above, it
is clear that the MMIO ECAM feature is only available on newer PCI-express hardware. Lower
down the machine list given by QEMU, there’s a q35
alias that virtualizes an Intel Q35 platform
with an ICH9 controller. This platform is a PCI-express platform, so the next step is to override
the default option by adding -machine q35
to the qemu-system-x86_64
command in
qemu-testing/runtest.sh
:
#!/bin/bash
#
pushd `pwd`
cd $(dirname $0)
exec qemu-system-x86_64 -enable-kvm -machine q35 \
-drive if=pflash,format=raw,readonly=on,file=OVMF_CODE.fd \
-drive if=pflash,format=raw,readonly=on,file=OVMF_VARS.fd \
-drive format=raw,file=fat:rw:esp
popd

It is now working, and it properly identifies the physical memory location of the first PCIe component
(segment_group=0, bus=0, device=0, function=0) as being located at memory location 0x00000000e0000000
.
We can be sure that there is a PCI Express device at this address because the
physical_address
method returns None
if there wasn’t one present, and the .unwrap()
call performed above would cause
our loader to panic, similar to what happened when MCFG
wasn’t found.
Enumerate Segment Groups
Most PCI Express systems on PCs have only a single segment group, however it is possible to have more than one segment group on a system. In theory, there can be up to 65536 segment groups, due to a 16-bit integer being used to index them. To make the discovery code more comprehensive, the PCI Express discovery code can be rewritten with a loop across all the possible indices, rather than just enumerating segment group 0, as follows:
let pcie_cfg = PciConfigRegions::new(&acpi_tables).unwrap();
for sg in 0u16..=65535u16 { // Walk through all 65536 possible segment groups
if let Some(addr) = pcie_cfg.physical_address(sg, 0, 0, 0) {
// When we find one ACPI says is active, report it to the info log
info!("PCIe({}, 0, 0, 0): {:#018x}", sg, addr);
}
}
Testing this change with my QEMU instance results in the exact same output, which is expected, as there’s
only a single segment group present. For this exercise, I’ll planning to just pass the first segment group
in the KernelArgs
data structure, and presume it attaches to everything needed for basic computing functions
(AHCI, GPU, USB). Once a base address is identified for a segment group’s ECAM MMIO base, the PCI Express
busses, devices, and functions within it are accessed via defined offsets from the base memory range, which
makes it relatively easy to work within a PCI Express network working solely off of the base address.
Populating Kernel Args
Next, it is necessary to make additional space and accessor methods for the new PCI Express MMIO pointer. First,
update the KernelArgs
struct definition in src/kernel_args.rs
by adding the following member to the struct
:
#[derive(Copy, Clone, Debug)]
pub struct KernelArgs {
...
/// The pointer to the PCI Express ECAM Space
pcie_ptr: *mut c_void,
}
Similarly, update the Default
implementation:
impl Default for KernelArgs {
fn default() -> Self {
Self {
acpi_ptr: 0 as *const c_void,
smbios_ptr: 0 as *const c_void,
acpi_ver: 0,
smbios_ver: 0,
pcie_ptr: 0 as *mut c_void,
}
}
}
Next, add the following two methods to the impl
:
impl KernelArgs {
...
/// Sets the PCI Express ECAM pointer
pub fn set_pcie(&mut self, ptr: *mut c_void) {
self.pcie_ptr = ptr
}
/// Returns the PCI Express ECAM pointer
pub fn get_pcie(&self) -> *mut c_void {
self.pcie_ptr
}
}
And, finally, modify the PCI Express discovery loop in the main code to set the PCI Express MMIO pointer in
kernel_args
and break
from the loop, after discovering the first available segment group. Then, display
the new contents of kernel_args
using the same logic as we did earlier after populating ACPI and SMBIOS
pointers:
let pcie_cfg = PciConfigRegions::new(&acpi_tables).unwrap();
for sg in 0u16..=65535u16 {
if let Some(addr) = pcie_cfg.physical_address(sg, 0, 0, 0) {
karg.set_pcie(addr as *mut core::ffi::c_void);
break;
}
}
info!("karg after PCIe: {:?}", karg);
Memory Map Information
The last data table that we will collect together for the OS is the system’s Memory Map. This table is provided by the UEFI firmware so that the OS can understand the mapped physical memory regions in the hardware, as well as their purpose. In order for the OS to allocate RAM for programs to use, it needs to know what pages of physical memory are available for use. This also allows us to tell the OS what memory pages are present, but contain important data that we don’t want the OS to clobber. One example of this is the memory where ACPI data is stored (such as the PCI Express Config Table, from above), and the SMBIOS tables.
What Is the Memory Map?
Unlike the interfaces above, the Memory Map requires the UEFI application to reserve enough memory for a buffer that will be filled with the memory map, rather than simply providing a pointer to this location elsewhere in RAM.
The uefi::table::boot::BootServices::allocate_pool
method allows the UEFI program to allocate a buffer from a memory pool, using the same classifications that
will be used for describing regions in the memory map. The available MemoryType
options are listed here:
Assigning the RUNTIME_SERVICES_DATA
option is a reasonable choice, as there may be other data loaded by UEFI for the OS to use that will be marked
with that same label. Thus, when determining what pages to make available for the kernel and programs to use,
we can restrict any pages marked RUNTIME_SERVICES_DATA
so they’re preserved when the OS allocates memory for
the system to use.
Extend KernelArgs
First, let’s make a new field in KernelArgs
for the Memory Map. In addition to saving the pointer to the
data structure, we also will want to convert it from the UEFI-internal representation to one that is more
Rust-native.
I chose to define a new OSMemEntry
struct in src/kernel_args.rs
:
pub struct OSMemEntry {
pub ty: uefi::table::boot::MemoryType,
pub base: usize,
pub pages: usize,
pub att: uefi::table::boot::MemoryAttribute,
}
Then, in the same file, added a new field to struct KernelArgs
:
struct KernelArgs {
...
/// The pointer to the OSMemEntry list
memmap_ptr: usize,
/// The number of entries in memmap_ptr
memmap_ptr: *mut OSMemEntry,
}
Next, update the Default
implementation to populate it with a NULL pointer:
// Initially populate an empty struct with every value set to 0. We cannot derive this
// because core::ffi::c_void has no Default implementation.
impl Default for KernelArgs {
fn default() -> Self {
Self {
acpi_ptr: core::ptr::null(),
smbios_ptr: core::ptr::null(),
acpi_ver: 0,
smbios_ver: 0,
pcie_ptr: core::ptr::null_mut(),
memmap_ptr: core::ptr::null_mut(),
memmap_entries: 0,
}
}
}
Finally, in the impl KernelArgs
, add new accessor methods:
impl KernelArgs {
...
/// Sets the MemMap pointer and slice length
pub fn set_memmap(&mut self, ptr: *mut OSMemEntry, entries: usize) {
self.memmap_ptr = ptr;
self.memmap_entries = entries;
}
/// Returns the MemMap pointer
pub fn get_memmap(&self) -> *mut OSMemEntry {
self.memmap_ptr
}
/// Returns the number of entries pointed at by the MemMap pointer
pub fn get_memmap_entries(&self) -> usize {
self.memmap_entries
}
}
Converting From UEFI MemoryDescriptor to OSMemEntry
Next, in src/main.rs
I want to implement a conversion function to convert from UEFI’s
MemoryDescriptor
to the
new Rust-native OSMemEntry
type. This is being defined in src/main.rs
instead of src/kernel_args.rs
because it is largely specific to the UEFI Loader, and we don’t anticipate needing to use this code in
the kernel:
impl From<&uefi::table::boot::MemoryDescriptor> for OSMemEntry {
fn from(mdesc: &uefi::table::boot::MemoryDescriptor) -> OSMemEntry {
OSMemEntry {
ty: mdesc.ty,
base: mdesc.phys_start as usize,
pages: mdesc.page_count as usize,
att: mdesc.att,
}
}
}
Then, in the same file, I created a new function that will fetch the Memory Map from the UEFI BootService table,
and will make a clone of it into a new buffer that represents a slice of OSMemEntry
objects. The mutable pointer
to the beginning of that slice and the length of the slice will be returned by the function.
fn get_mm(st: &SystemTable<Boot>) -> (*mut OSMemEntry, usize) {
// Allocate a buffer for the memory map
let mm_size = st.boot_services().memory_map_size();
// Make it a few entries bigger than the size that was given
let mm_bytes = mm_size.map_size + (mm_size.entry_size * 5);
let mm_buffer = st.boot_services().allocate_pool(
uefi::table::boot::MemoryType::BOOT_SERVICES_DATA,
mm_bytes,
).unwrap();
// Convert from *mut u8 to &mut [u8]
let mm_ref = unsafe { core::slice::from_raw_parts_mut(mm_buffer, mm_bytes) };
// Populate the memory map from UEFI into this new buffer
let mdesc = st.boot_services().memory_map(mm_ref).unwrap();
// Allocate a new buffer that is guaranteed to fit the same number of OSMemEntry items
// that we have MemoryDescriptor items for
let mem_entries = (mm_bytes / mm_size.entry_size) + 1;
let mementry_ptr = st.boot_services().allocate_pool(
uefi::table::boot::MemoryType::RUNTIME_SERVICES_DATA,
mem_entries * core::mem::size_of::<OSMemEntry>(),
).unwrap() as *mut OSMemEntry;
// Convert it from a *mut OSMemEntry to a &mut [OSMemEntry] to make it safer to index
let mementries = unsafe {
core::slice::from_raw_parts_mut::<OSMemEntry>(mementry_ptr, mem_entries)
};
// Loop across the MemoryDescriptors and make a copy of each one into the &mut [OSMemEntry]
// slice
let mut num_entries = 0;
for (i, e) in mdesc.entries().enumerate() {
mementries[i] = e.into(); // Use the translation code that we wrote
num_entries += 1;
};
(mementry_ptr, num_entries)
}
Finally, in hello_main
these new functions can be called to allocate the new [OSMemEntry]
slice,
and store a pointer to it, and its length, in the kargs
struct:
// Tell the uefi crate that this function will be our entrypoint
#[entry]
// Declare "hello_main" to accept two arguments, and use the type definitions provided by uefi
fn hello_main(image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
...
...
info!("karg after PCIe: {:?}", karg);
wait_for_keypress(&mut system_table).unwrap();
let (mm_ptr, count) = get_mm(&system_table);
karg.set_memmap(mm_ptr, count);
info!("Got memory");
info!("karg after MemMap: {:?}", karg);
// Tell the UEFI firmware we exited without error
wait_for_keypress(&mut system_table).unwrap();
Status::SUCCESS
}
Run It
Running the program should now display (after a keypress) the KernelArgs
structure populated with:
- Pointer to the ACPI RSDP (
0x0777e014
) - Pointer to the SMBIOS tables (
0x075a9000
) - Pointer to the start of the first PCI Express MMIO region (
0x0e0000000
) - Pointer to the Memory Map, as a
[OSMemEntry]
slice (0x075a4018
)

The final piece to complete will be setting the new video mode, switching over to raster operations to
write to the screen, and adding the framebuffer pointer, dimensions, and properties to the KernelArgs
data structure. I plan to cover this in Part 3.
The source code for Part 2 is located here:
Permanent Link: https://blog.malware.re/2023/09/01/rust-os-part2/index.html