As a part of the OS project for the university there has been a request to also write up the experiences and challenges encountered. This is the first post of the series on writing a x64 operating system when booting straight from UEFI. Please keep in mind that these posts are written by a not-even-hobbyist and content in these posts should be taken with a grain of salt.
Kernel and UEFI
I’ve decided to write a kernel targeting x64 as an UEFI application. There are a number of reasons to write a kernel as an UEFI application as opposed to writing a multiboot kernel. Namely:
- For x86 family of processors, you avoid the work necessary to upgrade from real mode to protected mode and then from protected mode to long mode which is more commonly known as a 64-bit mode. As an UEFI application your kernel gets a fully working x64 environment from the get-go;
- Unlike BIOS, UEFI is a well documented firmware. Most of the interfaces provided by BIOS are de facto and you’re lucky if they work at all, while most of these provided by UEFI are de jure and usually just work;
- UEFI is extensible, whereas BIOS is not really;
- Finally, UEFI is a modern technology which is likely to stay around, while BIOS is a 40 years old piece of technology on the death row. Learning about soon-to-be-dead technology is a waste of the effort.
Despite my strong attachment to the Rust community and Rust’s perfect suitability for kernels1, I’ll be writing the kernel in C. Mostly because of how unlikely it is for people at the university to be familiar with Rust. Also because GNU-EFI is a C library and I cannot be bothered to bind it. I’d surely be writing it in Rust was I more serious about the project.
Toolchain
As it turns out, developing a x64 kernel on a x64 host greatly simplifies setting up the build toolchain. I’ll be using:
clang
to compile the C code (no cross-compiler is necessary2!);gnu-efi
library to interact with the UEFI firmware;qemu
emulator to run my kernel; andOVMF
as the UEFI firmware.
The UEFI “Hello, world!”
The following snippet of code is all you need to print something on the screen as an UEFI application:
// main.c
#include <efi.h>
#include <efilib.h>
#include <efiprot.h>
EFI_STATUS(EFI_HANDLE ImageHandle, EFI_SYSTEM_TABLE *SystemTable)
efi_main {
(ImageHandle, SystemTable);
InitializeLib(L"Hello, world from x64!");
Printfor(;;) __asm__("hlt");
}
However, compiling this code correctly is not as trivial. Following three commands are necessary to produce a working UEFI application:
clang -I/usr/include/efi -I/usr/include/efi/x86_64 -I/usr/include/efi/protocol -fno-stack-protector -fpic -fshort-wchar -mno-red-zone -DHAVE_USE_MS_ABI -c -o src/main.o src/main.c
ld -nostdlib -znocombreloc -T /usr/lib/elf_x86_64_efi.lds -shared -Bsymbolic -L /usr/lib /usr/lib/crt0-efi-x86_64.o src/main.o -o huehuehuehuehue.so -lefi -lgnuefi
objcopy -j .text -j .sdata -j .data -j .dynamic -j .dynsym -j .rel -j .rela -j .reloc --target=efi-app-x86_64 huehuehuehuehue.so huehuehuehuehue.efi
The clang
command is pretty self-explanatory: we tell the compiler where to look for the EFI
headers and what to compile into an object file. Probably the most non-trivial option here is the
-DHAVE_USE_MS_ABI
– x64 UEFI uses the Windows’ x64 calling convention, and not the regular C
one, thus all arguments in calls to UEFI functions must be passed in a different way than it is
usually done in C code. Historically this conversion was done by the uefi_call_wrapper
wrapper,
but clang
supports the calling convention natively, and we tell that to the gnu-efi library with
this option3.
Then, I manually link my object file and UEFI-specific C runtime up into a shared library using a
custom linker script provided by the gnu-efi library. The result is an ELF library about 250KB in
size. However, UEFI expects its applications in PE executable format, so we must convert our
library into the desired format with the objcopy
command. At this point huehuehuehuehue.efi
file should be produced and majority of UEFI firmwares should be able to run it.
In practice, I’ve automated these steps along with a considerably complex sequence of building image files I’ve stolen from OSDEV’s tutorial on creating images into a Makefile. Feel free to copy it in parts or in whole for your own use cases.
UEFI boot and runtime services
An UEFI application has 2 distinct stages over its lifetime: a stage where so-called boot services are available and stage after these boot services are disabled. An UEFI application will be launched by the UEFI firmware and both boot and runtime services will be available to the application. Most notably, boot services provide APIs for loading other UEFI applications (e.g. to implement bootloaders), handling (allocating and deallocating) memory and using protocols (speaking to other active UEFI applications).
Once the kernel is done with using boot services it calls ExitBootServices
which is a method
provided by… a boot service. Past that point only runtime services are available and you cannot
ever return to a state where boot services are available except by resetting the system. Managing
UEFI variables, system clock and resetting the system is pretty much the only things you can do
with the runtime services.
For my kernel, I will use the graphics output protocol to set up the video frame buffer, exit the
boot services and, finally, shut down the machine before reaching the hlt
instruction. Following
piece of code implements the described sequence. I left some code out, you can see it in full at
Gitlab. For example, the definition of init_graphics
.
EFI_STATUS(EFI_HANDLE ImageHandle, EFI_SYSTEM_TABLE *SystemTable)
efi_main {
;
EFI_STATUS status(ImageHandle, SystemTable);
InitializeLib
// Initialize graphics
*graphics;
EFI_GRAPHICS_OUTPUT_PROTOCOL = EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID;
EFI_GUID graphics_proto = SystemTable->BootServices->LocateProtocol(
status &graphics_proto, NULL, (void **)&graphics
);
if(status != EFI_SUCCESS) return status;
= init_graphics(graphics);
status if(status != EFI_SUCCESS) return status;
// Figure out the memory map (should be identity mapping)
.memory_map = LibMemoryMap(
boot_state&boot_state.memory_map_size,
&boot_state.map_key,
&boot_state.descriptor_size,
&boot_state.descriptor_version
);
// Exit the boot services...
->BootServices->ExitBootServices(
SystemTable, boot_state.map_key
ImageHandle);
// and set up the memory map we just found.
->RuntimeServices->SetVirtualAddressMap(
SystemTable.memory_map_size,
boot_state.descriptor_size,
boot_state.descriptor_version,
boot_state.memory_map
boot_state);
// Once we’re done we power off the machine.
->RuntimeServices->ResetSystem(
SystemTable, EFI_SUCCESS, 0, NULL
EfiResetShutdown);
for(;;) __asm__("hlt");
}
Note, that some protocols can either be attached to your own EFI_HANDLE
or some other
EFI_HANDLE
(i.e. protocol is provided by another UEFI application). Graphics output protocol I’m
using here is an example of a protocol attached to another EFI_HANDLE
, therefore we use
LocateProtocol
boot service to find it. In the off-chance a protocol is attached to the
application’s own EFI_HANDLE
, the HandleProtocol
method should be used instead:
*loaded_image = NULL;
EFI_LOADED_IMAGE = LOADED_IMAGE_PROTOCOL;
EFI_GUID loaded_image_protocol = SystemTable->BootServices->HandleProtocol(
EFI_STATUS status , &loaded_image_protocol, &loaded_image
ImageHandle);
Next steps
At this point I have a bare bones frame for my awesome kernel called “huehuehuehuehue”. From this point onwards the development of the kernel should not differ much from the traditional development of any other x64 kernel.
As proved by numerous kernels written in Rust out there.↩︎
That being said, clang can cross-compile without having to build it from source for that purpose in the first place, like it is necessary to do with
gcc
.↩︎If gnu-efi was a perfect library, the
-DGNU_EFI_USE_MS_ABI
option should be used instead, but the library only version-checks forgcc
and always reports that clang does not support the option.↩︎