Writing a UEFI bootloader - part 2

At the end of the last post, I said that I’d probably start working on filesystems and image rendering. And I’m pleased to say that both of those have been a success.

I started writing this as a single post, but when trying to arrange it into something actually readable, realised it made more sense to split into two. This will likely end up being fairly short, with a much longer post releasing tomorrow.

‘Simple’ File System Protocol

Ok, that might be tad disingenuous - it is actually pretty simple once you read the documentation to discover that uefi-rs Paths only accept \ as a separator, unlike the std Paths (although looking further into this, it seems like this is due to UEFI and not the crate being lazy).

Update (2023-06-04): It seems the PathBuf type (an owned Path) does automatic separator conversion when creating from a CString16

The easiest way to start is to use the same filesystem that the bootloader was loaded from. We know it definitely exists, and furthermore, we know it’s FAT32, which I think is a requirement for the Simple File System Protocol. We also don’t need to worry about searching through connected disks to find it, since the Handle passed as the first parameter to main() lets us find it easily. Calling BootServices::get_image_file_system() and passing the Handle opens the file protocol for us, and gives access to the root directory on the EFI partition.


An aside: UEFI character encoding

For some reason, UEFI uses UCS-2 internally for all its character encoding (which is slightly different to UTF-16, the former using fixed width 16-bit codepoints, and the latter using either 16 or 32 bit characters to address the same codepoint space as UTF-8 can), whereas Rust uses UTF-8 for all string literals. On top of that, UEFI also expects all strings to be null terminated, like C does. Unfortunately this means that we can’t use the native str and String types, and instead have to use the uefi-rs provided CStr16 and CString16 types. To convert string literals, we can wrap each literal in the cstr16!() macro.


Once we have the filesystem object, uefi-rs provides a nice wrapper API, called FileSystem, around the underlying UEFI Simple File System Protocol. I placed a text.txt file in /EFI on the EFI partition qemu used. Then we can easily read it into a Vec<u8> with the read() function. One small difference to std::fs here is that, while both the std and uefi read()s take anything that can be borrowed as a Path, CStr16 (unlike str) can’t be implicitly borrowed as a Path. This means we need to call Path::new() on the string to convert it. (However, this is just some pointer casting and so just a no-op.) With the file now in a Vec<u8>, we can convert it to a str (assuming the file is UTF-8), and print it out to the screen.

The other way to read files is closer to std::fs APIs (where the caller provides the buffer to read into), and is probably the more suitable method when loading the kernel image later, since we want to be able to control alignment and location of the loaded kernel code. It also doesn’t need memory allocation, and so can be used in a bootloader without a heap. Like the GOP, it requires us getting a handle for the protocol, and then opening the protocol.

1
2
3
4
5
let Ok(fs_handle) = services.get_handle_for_protocol::<SimpleFileSystem>() else {
	panic!("Unable to locate FS handle");
};
let fs = services.open_protocol_exclusive::<SimpleFileSystem>(fs_handle);
let Ok(mut fs) = fs else { panic!("FS protocol not opened") };

Like with opening the GOP, if we use open_protocol_exclusive() we need to make sure the rest of our bootloader doesn’t already have it open. If you’ve used the FileSystem API elsewhere, you need to make sure it’s been drop()-ed first, otherwise exclusively opening the protocol will fail. To get the root directory of the volume, there’s a open_volume() function on the filesystem object. Since the medium could have been removed between opening the protocol and opening the volume, or the filesystem could be corrupted, this can error. If it opens successfully, then we can call read(), passing in the path (but this time taking a raw CStr16), the mode (likely FileMode::Read) and file attributes (which are ignored unless creating a file, so can be left as FileAttribute::empty()). The open() function however, doesn’t distinguish between files and directories, so to actually read it we need to first convert it into a RegularFile using the into_regular_file(). Then we can finally call read(), passing in the buffer to read into.


With file access in place, we’re now well on the way to a functional bootloader. In theory you could go straight from here to loading a kernel, but I’m going to take a detour into graphics and user input in the next few posts, to try and make a more configurable bootloader.