Introduction

Welcome to the Rust UEFI Book. The focus of this book is how to use uefi-rs to build UEFI applications in Rust, but it also describes some general UEFI concepts, as well as relevant tools such as QEMU.

Tutorial

This tutorial describes the process of creating and running a simple x86_64 UEFI application in Rust. The application will print "Hello World", pause for 10 seconds, then exit.

Creating a UEFI application

Install dependencies

Follow the Rust installation instructions to set up Rust.

Create a minimal application

Create an empty application and change to that directory:

cargo new my-uefi-app
cd my-uefi-app

Add a few dependencies:

cargo add log uefi

Replace the contents of src/main.rs with this:

#![no_main]
#![no_std]

use log::info;
use uefi::prelude::*;

#[entry]
fn main(_image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
    uefi::helpers::init(&mut system_table).unwrap();
    info!("Hello world!");
    system_table.boot_services().stall(10_000_000);
    Status::SUCCESS
}

Walkthrough

Let's look a quick look at what each part of the program is doing, starting with the #![...] lines at the top:

#![allow(unused)]
#![no_main]
#![no_std]
fn main() {
}

This is some boilerplate that all Rust UEFI applications will need. no_main is needed because the UEFI application entry point is different from the standard Rust main function. no_std is needed to turn off the std library; the core and alloc crates can still be used.

Next up are some use lines. Nothing too exciting here; the uefi::prelude module is intended to be glob-imported, and exports a number of commonly-used types.

#![allow(unused)]
fn main() {
use log::info;
use uefi::prelude::*;
}

Now we get to the UEFI application main function, and here things look a little different from a standard Rust program.

#[entry]
fn main(_image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {

The main function in a Uefi application always takes two arguments, the image handle and the system table. The image handle represents the currently-running executable, and the system table provides access to many different UEFI services. The main function returns a Status, which is essentially a numeric error (or success) code defined by UEFI.

The first thing we do inside of main is initialize uefi_services:

#![allow(unused)]
fn main() {
    uefi::helpers::init(&mut system_table).unwrap();
}

The uefi_services crate is not strictly required to make a UEFI application with the uefi crate, but it makes things much simpler by setting a simple memory allocator, initializing the logger, and providing a panic handler.

Next we use the standard log crate to output "Hello world!". Then we call stall to make the system pause for 10 seconds. This just ensures you have enough time to see the output.

#![allow(unused)]
fn main() {
    info!("Hello world!");
    system_table.boot_services().stall(10_000_000);
}

Finally we return Status::SUCCESS indicating that everything completed successfully:

#![allow(unused)]
fn main() {
    Status::SUCCESS
}
}

Building

Toolchain

In order to compile for UEFI, an appropriate target must be installed. The easiest way to set this up is using a rustup toolchain file. In the root of your repository, add rust-toolchain.toml:

[toolchain]
targets = ["aarch64-unknown-uefi", "i686-unknown-uefi", "x86_64-unknown-uefi"]

Here we have specified all three of the currently-supported UEFI targets; you can remove some if you don't need them.

Build the application

Run this command to build the application:

cargo build --target x86_64-unknown-uefi

This will produce an x86-64 executable: target/x86_64-unknown-uefi/debug/my-uefi-app.efi.

Running in a VM

Install dependencies

Two dependencies are needed: QEMU, which implements the virtual machine itself, and OVMF, which provides UEFI firmware that QEMU can run.

The details of how to install QEMU and OVMF will vary depending on your operating system.

Debian/Ubuntu:

sudo apt-get install qemu ovmf

Fedora:

sudo dnf install qemu-kvm edk2-ovmf

Firmware files

The OVMF package provides two firmware files, one for the executable code and one for variable storage. (The package may provide multiple variations of these files; refer to the package's documentation for details of the files it includes.)

For ease of access we'll copy the OVMF code and vars files to the project directory. The location where OVMF is installed depends on your operating system; for Debian, Ubuntu and Fedora the files are under /usr/share/OVMF.

Copy the files to your project directory:

cp /usr/share/OVMF/OVMF_CODE.fd .
cp /usr/share/OVMF/OVMF_VARS.fd .

System partition

Now create a directory structure containing the executable to imitate a UEFI System Partition:

mkdir -p esp/efi/boot
cp target/x86_64-unknown-uefi/debug/my-uefi-app.efi esp/efi/boot/bootx64.efi

Launch the VM

Now we can launch QEMU, using VVFAT to access the esp directory created above.

qemu-system-x86_64 -enable-kvm \
    -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

A QEMU window should appear, and after a few seconds you should see the log message:

[ INFO]:  src/main.rs@011: Hello world!

Running on Hardware

To run on real hardware you'll need a specially-prepared USB drive.

Preparation

The general steps to prepare the drive are:

  1. Partition the drive using GPT.
  2. Create a partition.
  3. Set the partition type GUID to C12A7328-F81F-11D2-BA4B-00A0C93EC93B. That marks it as an EFI System partition. (On many UEFI implementations this is not strictly necessary, see note below.)
  4. Format the partition as FAT.
  5. Mount the partition.
  6. Create the directory path EFI/BOOT on the partition. (FAT is case insensitive, so capitalization doesn't matter.)
  7. Copy your EFI application to a file under EFI/BOOT. The file name is specific to the architecture. For example, on x86_64 the file name must be BOOTX64.EFI. See the boot files table for other architectures.

The details of exactly how to do these steps will vary depending on your OS.

Note that most UEFI implementations do not strictly require GPT partitioning or the EFI System partition GUID; they will look for any FAT partition with the appropriate directory structure. This is not required however; the UEFI Specification says "UEFI implementations may allow the use of conforming FAT partitions which do not use the ESP GUID."

Example on Linux

Warning: these operations are destructive! Do not run these commands on a disk if you care about the data it contains.

# Create the GPT, create a 9MB partition starting at 1MB, and set the
# partition type to EFI System.
sgdisk \
    --clear \
    --new=1:1M:10M \
    --typecode=1:C12A7328-F81F-11D2-BA4B-00A0C93EC93B \
    /path/to/disk

# Format the partition as FAT.
mkfs.fat /path/to/disk_partition

# Mount the partition.
mkdir esp
mount /path/to/disk_partition esp

# Create the boot directory.
mkdir esp/EFI/BOOT

# Copy in the boot executable.
cp /path/to/your-executable.efi esp/EFI/BOOT/BOOTX64.EFI

Booting the USB

Insert the USB into the target computer. Reboot the machine, then press the one-time boot key. Which key to press depends on the vendor. For example, Dell uses F12, HP uses F9, and on Macs you hold down the Option key.

Once the one-time boot menu appears, select your USB drive and press enter.

How-to

This chapter contains practical how-to guides.

Using Protocols

The open a protocol, you must first get a handle, then open a protocol on that handle. See Handles and Protocols for an overview of what these terms mean.

To get a handle you can use:

Once you have obtained a handle, use BootServices::open_protocol_exclusive to open a protocol on that handle. This returns a ScopedProtocol, which automatically closes the protocol when dropped.

Using BootServices::open_protocol_exclusive is the safest way to open a protocol, but in some cases a protocol cannot be opened in exclusive mode. The unsafe BootServices::open_protocol can be used in that case.

Example

For this example we'll look at a program that opens a couple different protocols. This program opens the LoadedImage protocol to get information about an executable (the currently-running program in this case). It also opens the DevicePathToText protocol to get the file system path that the program was launched from.

We'll walk through the details of this program shortly, but first here's the whole thing:

#![no_main]
#![no_std]

use log::info;
use uefi::prelude::*;
use uefi::proto::device_path::text::{
    AllowShortcuts, DevicePathToText, DisplayOnly,
};
use uefi::proto::loaded_image::LoadedImage;
use uefi::table::boot::SearchType;
use uefi::{Identify, Result};

#[entry]
fn main(image_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
    uefi::helpers::init(&mut system_table).unwrap();
    let boot_services = system_table.boot_services();

    print_image_path(boot_services).unwrap();

    boot_services.stall(10_000_000);
    Status::SUCCESS
}

fn print_image_path(boot_services: &BootServices) -> Result {
    let loaded_image = boot_services
        .open_protocol_exclusive::<LoadedImage>(boot_services.image_handle())?;

    let device_path_to_text_handle = *boot_services
        .locate_handle_buffer(SearchType::ByProtocol(&DevicePathToText::GUID))?
        .first()
        .expect("DevicePathToText is missing");

    let device_path_to_text = boot_services
        .open_protocol_exclusive::<DevicePathToText>(
            device_path_to_text_handle,
        )?;

    let image_device_path =
        loaded_image.file_path().expect("File path is not set");
    let image_device_path_text = device_path_to_text
        .convert_device_path_to_text(
            boot_services,
            image_device_path,
            DisplayOnly(true),
            AllowShortcuts(false),
        )
        .expect("convert_device_path_to_text failed");

    info!("Image path: {}", &*image_device_path_text);
    Ok(())
}

When the program is run it will print something like this:

[ INFO]: example.rs@058: Image path: \EFI\BOOT\BOOTX64.EFI

Walkthrough

The main function looks much like the "Hello world!" example. It sets up logging, calls print_image_path, and pauses for ten seconds to give you time to read the output. Let's look at print_image_path:

#![allow(unused)]
fn main() {
fn print_image_path(boot_services: &BootServices) -> Result {
}

The return type is a uefi::Result, which is a Result alias that combines uefi::Status with the error data. Both the success and error data types are () by default.

The function starts by opening the LoadedImage protocol:

#![allow(unused)]
fn main() {
    let loaded_image = boot_services
        .open_protocol_exclusive::<LoadedImage>(boot_services.image_handle())?;
}

The open_protocol_exclusive method takes a type parameter, which is the type of Protocol you want to open (LoadedImage in this case). It also takes one regular argument of type Handle. For this example we want the handle of the currently-running image, which was passed in as the first argument to main. The handle is conveniently accessible through BootServices::image_handle, so we use that here.

Next the program opens the DevicePathToText protocol:

#![allow(unused)]
fn main() {
    let device_path_to_text_handle = *boot_services
        .locate_handle_buffer(SearchType::ByProtocol(&DevicePathToText::GUID))?
        .first()
        .expect("DevicePathToText is missing");

    let device_path_to_text = boot_services
        .open_protocol_exclusive::<DevicePathToText>(
            device_path_to_text_handle,
        )?;
}

This protocol isn't available for the image_handle, so we start by using locate_handle_buffer to find all handles that support DevicePathToText. We only need one handle though, so we call first() and discard the rest. Then we call open_protocol_exclusive again. It looks more or less like the previous time, but with DevicePathToText as the type parameter and device_path_to_text_handle as the handle.

Now that we have both protocols open, we can use them together to get the program's path and convert it to text:

#![allow(unused)]
fn main() {
    let image_device_path =
        loaded_image.file_path().expect("File path is not set");
    let image_device_path_text = device_path_to_text
        .convert_device_path_to_text(
            boot_services,
            image_device_path,
            DisplayOnly(true),
            AllowShortcuts(false),
        )
        .expect("convert_device_path_to_text failed");

    info!("Image path: {}", &*image_device_path_text);
    Ok(())
}
}

Since protocols do a wide range of different things, the methods available to call are very specific to each individual protocol. The best places to find out what each protocol can do are the uefi-rs reference documentation and the UEFI Specification.

Drawing to the Screen

This example shows how to draw to the screen using the graphics output protocol. The code will a SierpiƄski triangle using the "chaos game" method.

screenshot

The core abstraction used here is a linear buffer:

#![allow(unused)]
fn main() {
struct Buffer {
    width: usize,
    height: usize,
    pixels: Vec<BltPixel>,
}

impl Buffer {
    /// Create a new `Buffer`.
    fn new(width: usize, height: usize) -> Self {
        Buffer {
            width,
            height,
            pixels: vec![BltPixel::new(0, 0, 0); width * height],
        }
    }

    /// Get a single pixel.
    fn pixel(&mut self, x: usize, y: usize) -> Option<&mut BltPixel> {
        self.pixels.get_mut(y * self.width + x)
    }

    /// Blit the buffer to the framebuffer.
    fn blit(&self, gop: &mut GraphicsOutput) -> Result {
        gop.blt(BltOp::BufferToVideo {
            buffer: &self.pixels,
            src: BltRegion::Full,
            dest: (0, 0),
            dims: (self.width, self.height),
        })
    }
}
}

This Buffer type stores a Vec of BltPixels, which are BGRX 32-bit pixels (8 bites each for blue, green, and red, followed by 8 unused bits of padding). We use the pixel method to alter a single pixel at a time. This is often not an efficient method; for more complex graphics you could use a crate like embedded-graphics.

The Buffer::blit method calls the graphics output protocol's blt method to copy the buffer to the screen.

Most of the rest of the code is just implementing the algorithm for drawing the fractal. Here's the full example:

#![no_main]
#![no_std]

extern crate alloc;

use alloc::vec;
use alloc::vec::Vec;
use core::mem;
use uefi::prelude::*;
use uefi::proto::console::gop::{BltOp, BltPixel, BltRegion, GraphicsOutput};
use uefi::proto::rng::Rng;
use uefi::Result;

#[derive(Clone, Copy)]
struct Point {
    x: f32,
    y: f32,
}

impl Point {
    fn new(x: f32, y: f32) -> Self {
        Self { x, y }
    }
}

struct Buffer {
    width: usize,
    height: usize,
    pixels: Vec<BltPixel>,
}

impl Buffer {
    /// Create a new `Buffer`.
    fn new(width: usize, height: usize) -> Self {
        Buffer {
            width,
            height,
            pixels: vec![BltPixel::new(0, 0, 0); width * height],
        }
    }

    /// Get a single pixel.
    fn pixel(&mut self, x: usize, y: usize) -> Option<&mut BltPixel> {
        self.pixels.get_mut(y * self.width + x)
    }

    /// Blit the buffer to the framebuffer.
    fn blit(&self, gop: &mut GraphicsOutput) -> Result {
        gop.blt(BltOp::BufferToVideo {
            buffer: &self.pixels,
            src: BltRegion::Full,
            dest: (0, 0),
            dims: (self.width, self.height),
        })
    }
}

/// Get a random `usize` value.
fn get_random_usize(rng: &mut Rng) -> usize {
    let mut buf = [0; mem::size_of::<usize>()];
    rng.get_rng(None, &mut buf).expect("get_rng failed");
    usize::from_le_bytes(buf)
}

fn draw_sierpinski(bt: &BootServices) -> Result {
    // Open graphics output protocol.
    let gop_handle = bt.get_handle_for_protocol::<GraphicsOutput>()?;
    let mut gop = bt.open_protocol_exclusive::<GraphicsOutput>(gop_handle)?;

    // Open random number generator protocol.
    let rng_handle = bt.get_handle_for_protocol::<Rng>()?;
    let mut rng = bt.open_protocol_exclusive::<Rng>(rng_handle)?;

    // Create a buffer to draw into.
    let (width, height) = gop.current_mode_info().resolution();
    let mut buffer = Buffer::new(width, height);

    // Initialize the buffer with a simple gradient background.
    for y in 0..height {
        let r = ((y as f32) / ((height - 1) as f32)) * 255.0;
        for x in 0..width {
            let g = ((x as f32) / ((width - 1) as f32)) * 255.0;
            let pixel = buffer.pixel(x, y).unwrap();
            pixel.red = r as u8;
            pixel.green = g as u8;
            pixel.blue = 255;
        }
    }

    let size = Point::new(width as f32, height as f32);

    // Define the vertices of a big triangle.
    let border = 20.0;
    let triangle = [
        Point::new(size.x / 2.0, border),
        Point::new(border, size.y - border),
        Point::new(size.x - border, size.y - border),
    ];

    // `p` is the point to draw. Start at the center of the triangle.
    let mut p = Point::new(size.x / 2.0, size.y / 2.0);

    // Loop forever, drawing the frame after each new point is changed.
    loop {
        // Choose one of the triangle's vertices at random.
        let v = triangle[get_random_usize(&mut rng) % 3];

        // Move `p` halfway to the chosen vertex.
        p.x = (p.x + v.x) * 0.5;
        p.y = (p.y + v.y) * 0.5;

        // Set `p` to black.
        let pixel = buffer.pixel(p.x as usize, p.y as usize).unwrap();
        pixel.red = 0;
        pixel.green = 100;
        pixel.blue = 0;

        // Draw the buffer to the screen.
        buffer.blit(&mut gop)?;
    }
}

#[entry]
fn main(_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
    uefi::helpers::init(&mut system_table).unwrap();
    let bt = system_table.boot_services();
    draw_sierpinski(bt).unwrap();
    Status::SUCCESS
}

You can run this example from the uefi-rs repository with:

cargo xtask run --example sierpinski

Building drivers

There are three types of UEFI images:

  • Application
  • Boot service driver
  • Runtime driver

By default, Rust's UEFI targets produce applications. This can be changed by passing a subsystem linker flag in rustflags and setting the value to efi_boot_service_driver or efi_runtime_driver.

Example:

# In .cargo/config.toml:
[build]
rustflags = ["-C", "link-args=/subsystem:efi_runtime_driver"]

Concepts

The canonical source of information about UEFI is the UEFI specification. The specification is huge (currently nearly 2500 pages). Much of that content relates to optional services, understanding of which is not critical to understanding UEFI as a whole. This chapter summarizes some of the more important UEFI concepts and links to the relevant uefi-rs documentation.

Boot Stages

A UEFI system goes through several distinct phases during the boot process.

  1. Platform Initialization. This early-boot phase is mostly outside the scope of uefi-rs. It is described by the UEFI Platform Initialization Specification, which is separate from the main UEFI Specification.
  2. Boot Services. This is when UEFI drivers and applications are loaded. Both the BootServices and RuntimeServices tables are accessible. This stage typically culminates in running a bootloader that loads an operating system. The stage ends when SystemTable::exit_boot_services is called, putting the system in Runtime mode.
  3. Runtime. This stage is typically active when running an operating system such as Linux or Windows. UEFI functionality is much more limited in the Runtime mode. The BootServices table is no longer accessible, but the RuntimeServices table is still available. Once the system is in Runtime mode, it cannot return to the Boot Services stage until after a system reset.

Tables

UEFI has a few table structures. These tables are how you get access to UEFI services.

SystemTable (EFI_SYSTEM_TABLE in the specification) is the top-level table that provides access to the other tables.

BootServices (EFI_BOOT_SERVICES in the specification) provides access to a wide array of services such as memory allocation, executable loading, and optional extension interfaces called protocols. This table is only accessible while in the Boot Services stage.

RuntimeServices (EFI_RUNTIME_SERVICES in the specification) provides access to a fairly limited set of services, including variable storage, system time, and virtual-memory mapping. This table is accessible during both the Boot Services and Runtime stages.

When writing a UEFI application, you get access to the system table from one of the arguments to the main entry point:

fn main(handle: Handle, mut system_table: SystemTable<Boot>) -> Status;

Then use SystemTable::boot_services and SystemTable::runtime_services to get access to the other tables. Once SystemTable::exit_boot_services is called, the original system table is consumed and a new system table is returned that only provides access to the RuntimeServices table.

GUID

GUID is short for Globally Unique Identifier. A GUID is always 16 bytes, and has a standard string representation format that looks like this: 313b0d7c-fed4-4de7-99ed-2fe48874a410. The details of the GUID format aren't too important, but be aware that the actual byte representation is not in the same order as the string representation because the first three fields are little-endian. For the most part you can treat GUIDs as opaque identifiers.

The UEFI specification uses GUIDs all over the place. GUIDs are used to identify protocols, disk partitions, variable groupings, and much more. In uefi-rs, GUIDs are represented by the Guid type.

Handles and Protocols

Handles and protocols are at the core of what makes UEFI extensible. Together they are the mechanism by which UEFI can adapt to a wide array of hardware and boot conditions, while still providing a consistent interface to drivers and applications.

Handles

Handles represent resources. A resource might be a physical device such as a disk drive or USB device, or something less tangible like a loaded executable.

A Handle is an opaque pointer, so you can't do anything with it directly. To operate on a handle you have to open a protocol.

Protocols

Protocols are interfaces that provide functions to interact with a resource. For example, the BlockIO protocol provides functions to read and write to block IO devices.

Protocols are only available during the Boot Services stage; you can't access them during the Runtime stage.

The UEFI Specification defines a very large number of protocols. Because protocols are inherently very diverse, the best place to learn about individual protocols is the UEFI Specification. There are many chapters covering various protocols. Not all of these protocols are wrapped by uefi-rs yet (contributions welcome!) but many of the most commonly useful ones are.

See the Using Protocols how-to for details of the uefi-rs API for interacting with protocols.

Device Paths

A device path is a very flexible packed data structure for storing paths to many kinds of device. Note that these device paths are not the same thing as file system paths, although they can include file system paths. Like handles, device paths can be used to uniquely identify resources such as consoles, mice, disks, partitions, and more. Unlike handles, which are essentially opaque pointers, device paths are variable-length structures that contain parseable information.

The uefi::proto::device_path module documentation describes the details of how device paths are encoded.

Device paths can also be converted to and from human-readable text representations that look like this:

PciRoot(0x0)/Pci(0x1F,0x2)/Sata(0x0,0xFFFF,0x0)/HD(1,MBR,0xBE1AFDFA,0x3F,0xFBFC1)

See uefi::proto::device_path::text for details.

Variables

UEFI provides fairly flexible key/value variable storage.

Each variable is identified by a key consisting of a UCS-2 null-terminated name plus a vendor GUID. The vendor GUID serves as a namespace for variables so that different vendors don't accidentally overwrite or misinterpret another vendor's variable if they happen to have the same name.

The data stored in each variable is an arbitrary byte array.

Attributes

Each variable has attributes (represented as bit flags) associated with it that affect how it is stored and how it can be accessed.

If the BOOTSERVICE_ACCESS and RUNTIME_ACCESS bits are set, the variable can be accessed during both the Boot Services and Runtime stages. If only BOOTSERVICE_ACCESS is set then the variable can neither be read nor written to after exiting boot services.

Another important attribute is the NON_VOLATILE bit. If this bit is not set, the variable will be stored in normal memory and will not persist across a power cycle. If this bit is set, the variable will be stored in special non-volatile memory. You should be careful about writing variables of this type, because the non-volatile storage can be very limited in size. There have been cases where a vendor's poor UEFI implementation caused the machine not too boot once the storage became too full. Even figuring out how much space is in use can be tricky due to deletion being implemented via garbage collection. Matthew Garret's article "Dealing with UEFI non-volatile memory quirks" has more details.

Most of the other attributes relate to authenticated variables, which can be used to prevent changes to a variable by unauthorized programs.

GPT

GPT is short for GUID Partition Table. It's a more modern alternative to MBR (master boot record) partition tables. Although it's defined in the UEFI specification, it often gets used on non-UEFI systems too. There are a couple big advantages of using GPT over MBR:

  • It has a relatively clear and precise standard, unlike MBR where implementations often just try to match what other implementations do.
  • It supports very large disks and very large numbers of partitions.

A GPT disk contains a primary header near the beginning of the disk, followed by a partition entry array. The header and partition entry array have a secondary copy at the end of the disk for redundancy. The partition entry arrays contain structures that describe each partition, including a GUID to identify the individual partition, a partition type GUID to indicate the purpose of the partition, and start/end block addresses. In between the entry arrays is the actual partition data.

System partition

The system partition is UEFI's version of a bootable partition. The system partition is sometimes called the ESP, or EFI System Partition. It is identified by a partition type of c12a7328-f81f-11d2-ba4b-00a0c93ec93b. The system partition always contains a FAT file system. There are various standardized paths that can exist within the file system, and of particular importance are the boot files. These are the files that UEFI will try to boot from by default (in the absence of a different boot configuration set through special UEFI variables).

Boot files are under \EFI\BOOT, and are named BOOT<ARCH>.efi, where <ARCH> is a short architecture name.

ArchitectureFile name
Intel 32-bitBOOTIA32.EFI
X86_64BOOTX64.EFI
ItaniumBOOTIA64.EFI
AArch32BOOTARM.EFI
AArch64BOOTAA64.EFI
RISC-V 32-bitBOOTRISCV32.EFI
RISC-V 64-bitBOOTRISCV64.EFI
RISC-V 128-bitBOOTRISCV128.EFI

Reference