Hello UEFI
In this chapter, we will start implementing Surtr, the bootloader of Ymir. Surtr runs as a UEFI application in 64-bit mode. We will first review the classification of hypervisors and the overall structure of Ymir. After that, we will create a template for a UEFI application to run on QEMU. Although it is still an application that just runs, we can learn the basis for Surtr, which we will implement in the next chapters and beyond.
important
Source code for this chapter is in whiz-surtr-hello_uefi
branch.
Table of Contents
Type of Hypervisor
Hypervisors can be broadly classified into two types: Type-1 and Type-2.
Type-1 Hypervisor
Type-1 Hypervisor is a hypervisor that runs on bare metal. While it provides direct access to hardware, it requires low-level resource management. Compared to Type-2, Type-1 hypervisors have the advantage of more VM-specific resource management and faster resource access speeds since it has no need to do a context switch. Ymir is also classified as Type-1 and runs directly on hardware (though in this series, it runs on QEMU...). Hypervisors classified as Type-1 are as follows:
Type-2 Hypervisor
Type-2 Hypervisor is a hypervisor that runs on the host OS. It requires access to hardware resources via the host OS, which incurs more overhead than Type-1. It can be said to be easier to implement than Type-1 in that low-level resource management can be left to the OS. Hypervisors classified as Type-2 include the following:
- Oracle VirtualBox
- VMWare Workstation
- QEMU
Surtr Bootloader
The hypervisor implemented in this series is Type-1. It runs on bare metal. It cannot depend on the OS for its operations. Therefore, it must necessarily implement the functions provided by the OS. Since the ultimate goal of this series is to run Linux as a guest OS, we will not implement all the functions provided by common OS, but will implement the following basic functions:
- Bootloader
- Memory management
- Serial communication
- Interrupts and exceptions
We will call the bootloader part Surtr2. The rest of the kernel part is called Ymir3. Within Ymir, the part that provides general OS functions is called Ymir Kernel, and the part that provides virtualization functions is called Ymir VMM. The goal of several chaptes is to implement Surtr and boot Ymir Kernel.
Creating UEFI application
Let's get started with mplemening Surtr. In this series, we will use UEFI as the firmware. Legacy BIOS will not be used in this blog.
Zig has Tier-2 support for x64 UEFI platforms. Linux / macOS / Windows on common CPUs are Tier-1 supported. There are some differences between Tier-1 and Tier-2, such as automated tests not being run and some tests being disabled, but my impression is that general functionality can be used without problems. Thus, you can create a UEFI app just as you would build a "normal" native app. The structures and constants required by UEFI apps are included in Zig's standard libraries, making development very easy.
Configure Build Script
First, create a Zig project. After installing Zig referring to setup chapters, and then create a project with the following commands:
mkdir ymir && cd ymir
git init
zig init
The project structure will be as follows:
.
├── build.zig
├── build.zig.zon
└── src
├── main.zig
└── root.zig
build.zig
4: Build script. Zig describes the project build configuration itself in Zig.build.zig.zon
: Describe dependencies in the form of ZON format. Surtr and Ymir has no dependencies at all and the file is therefore not required.src
: Source directory.
In this blog, we will make a slight change from the default project structure to the following:
.
├── build.zig
├── build.zig.zon
├── surtr
└── ymir
Instead of src
, we created source directories for Surtr and Ymir, respectively. First, write the configuration for Surtr in build.zig
:
const std = @import("std");
pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
// Surtr Executable
const surtr = b.addExecutable(.{
.name = "BOOTX64.EFI",
.root_source_file = b.path("surtr/boot.zig"),
.target = b.resolveTargetQuery(.{
.cpu_arch = .x86_64,
.os_tag = .uefi,
}),
.optimize = optimize,
.linkage = .static,
});
b.installArtifact(surtr);
}
b.standardOptimizeOption
is a function to get the default optimization option. The optimization level can be specified from the command line and defaults to Debug
. There are four optimization levels available:
Optimization Level | Description |
---|---|
Debug | Default level. A check for undefined behavior (UD) is inserted. |
ReleaseFast | Most aggressive optimization. No checks are made for undefined behavior (UD). |
ReleaseSafe | Optimization is performed, but a check for undefined behavior (UD) is performed. |
ReleaseSmall | Optimize for smaller binary size. |
Add an executable with b.addExecutable()
. Specify the output name with .name
. Specify the source file to be the entry point with .root_source_file
. Zig does not require you to specify all the files to compile or include as you do in C. It will automatically include in the build tree the files referenced by the file specified in .root_source_file
. Specify the compile target in .target
. In this case, out target is x64 architecture & UEFI platform. Since this is a bare-metal bootloader and does not have a dynamic loading mechanism for libraries, .linkage
should be .static
.
Finally, add Surtr to the install
target with b.installArtifact()
. You can build it with zig build <target>
command. If you omit <target>
, the default target install
is executed. The installArtifact()
adds an artifact to this install
target.
tip
.static
is an abbreviation for std.builtin.LinkMode.static
. Zig allows you to omit the type name of enum
if the type of the value is known, such as function arguments. For this reason, many codes in this blog are written without FQDN. VSCode + ZLS also completes possible enum
values when you just type a period.
Also, . {...}
, the argument of addExecutable()
, is a structure. You instantiates the structure like Struct {}
, but again, the structure name can be omitted if the type is known.
Defining an Entry Point
Next, create an entry point in surtr/boot.zig
:
const std = @import("std");
const uefi = std.os.uefi;
pub fn main() uefi.Status {
while (true)
asm volatile ("hlt");
return .success;
}
The return type should be std.os.uefi.Status
. In the return
, the FQDN is omitted as described above. This is a UEFI app that does nothing but simply loop hlt
.
Configuration to Run on QEMU
Let's run this UEFI app on QEMU. Add the following settings to the build.zig
:
// EFI directory
const out_dir_name = "img";
const install_surtr = b.addInstallFile(
surtr.getEmittedBin(),
b.fmt("{s}/efi/boot/{s}", .{ out_dir_name, surtr.name }),
);
install_surtr.step.dependOn(&surtr.step);
b.getInstallStep().dependOn(&install_surtr.step);
Zig outputs the build product to the directory zig-out
by default5. addInstallFile()
copies the generated BOOTX64.EFI.efi
to zig-out/img/efi/boot/BOOTX64.EFI
. In install_surtr.step.dependOn()
, a dependency is declared to build Surtr before this copy. The following dependOn()
declares the copy operation to be performed as a dependency of the default install
target. This ensures that when you zig build
, the build of Surtr and copying of the product will be performed.
Then, add following settings to run QEMU:
const qemu_args = [_][]const u8{
"qemu-system-x86_64",
"-m",
"512M",
"-bios",
"/usr/share/ovmf/OVMF.fd",
"-drive",
b.fmt("file=fat:rw:{s}/{s},format=raw", .{ b.install_path, out_dir_name }),
"-nographic",
"-serial",
"mon:stdio",
"-no-reboot",
"-enable-kvm",
"-cpu",
"host",
"-s",
};
const qemu_cmd = b.addSystemCommand(&qemu_args);
qemu_cmd.step.dependOn(b.getInstallStep());
const run_qemu_cmd = b.step("run", "Run QEMU");
run_qemu_cmd.dependOn(&qemu_cmd.step);
It defines options to be passed to QEMU. The meanings of the options are as follows:
-m 512M
: Set the memory to 512 MB. It will probably work with less or more than this.-bios /usr/share/ovmf/OVMF.fd
: Specify OVMF as FW. If you installed it withapt
, the path becomes like this. If you built it by yourself, specify the path to the generated binary.-drive file=...
: Configure hard drive settings. QEMU uses a virtual drive called VVFAT (Virtual FAT filesystem) to pass the host directory to the guest.-nographic
: Disable graphical mode.-serial mon:stdio
: Sets serial communication as standard input/output. Input from the terminal is passed to the guest as serial input, and conversely, serial output is written to the terminal.-enable-kvm
: Use KVM as the backend.-cpu host
: Pass-through host CPU.-s
: Start the GDB server and make it listen on port1234
.
A new build target is added by b.step()
. This will cause zig build run
to execute the above QEMU command that you set as its dependency.
Run on QEMU
Now, let's actually build the app and run it on QEMU:
zig build run -Doptimize=Debug
It's okay if QEMU starts and does not proceed as shown below:
BdsDxe: loading Boot0001 "UEFI QEMU HARDDISK QM00001 " from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Primary,Master,0x0)
BdsDxe: starting Boot0001 "UEFI QEMU HARDDISK QM00001 " from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Primary,Master,0x0)
HLT 命令によってCPUが停止していることを確認してみましょう。 生成された UEFI アプリをディスアセンブルしてみると、.text
セクションは以下のようになっています。とても小さいです:
> objdump -D ./zig-out/img/efi/boot/BOOTX64.EFI | less
0000000000001000 <.text>:
1000: 55 push rbp
1001: 48 83 ec 30 sub rsp,0x30
1005: 48 8d 6c 24 30 lea rbp,[rsp+0x30]
100a: 48 89 4d f0 mov QWORD PTR [rbp-0x10],rcx
100e: 48 89 55 f8 mov QWORD PTR [rbp-0x8],rdx
1012: 48 89 0d e7 0f 00 00 mov QWORD PTR [rip+0xfe7],rcx # 0x2000
1019: 48 89 15 e8 0f 00 00 mov QWORD PTR [rip+0xfe8],rdx # 0x2008
1020: e8 0b 00 00 00 call 0x1030
1025: 48 83 c4 30 add rsp,0x30
1029: 5d pop rbp
102a: c3 ret
102b: 0f 1f 44 00 00 nop DWORD PTR [rax+rax*1+0x0]
1030: 55 push rbp
1031: 48 89 e5 mov rbp,rsp
1034: eb 00 jmp 0x1036
1036: f4 hlt
1037: eb fd jmp 0x1036
There is a hlt
instruction at +1036
and code to jump to hlt
at the +1037
. Then, while QEMU is running, type Ctrl+A C
to open QEMU monitor and look at the register values:
BdsDxe: loading Boot0001 "UEFI QEMU HARDDISK QM00001 " from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Primary,Master,0x0)
BdsDxe: starting Boot0001 "UEFI QEMU HARDDISK QM00001 " from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Primary,Master,0x0)
QEMU 8.2.2 monitor - type 'help' for more information
(qemu) info registers
CPU#0
RAX=000000001e32dc18 RBX=0000000000000000 RCX=000000001ed7a298 RDX=000000001f9ec018
RSI=0000000000000000 RDI=000000001e32dc18 RBP=000000001fe967d0 RSP=000000001fe967d0
R8 =00000000000000af R9 =0000000000000400 R10=000000001feb1258 R11=000000001feae6b0
R12=0000000000000000 R13=000000001ed8d000 R14=0000000000000000 R15=000000001feafa20
RIP=000000001e235037 RFL=00000202 [-------] CPL=0 II=0 A20=1 SMM=0 HLT=1
ES =0030 0000000000000000 ffffffff 00c09300 DPL=0 DS [-WA]
CS =0038 0000000000000000 ffffffff 00a09b00 DPL=0 CS64 [-RA]
SS =0030 0000000000000000 ffffffff 00c09300 DPL=0 DS [-WA]
DS =0030 0000000000000000 ffffffff 00c09300 DPL=0 DS [-WA]
FS =0030 0000000000000000 ffffffff 00c09300 DPL=0 DS [-WA]
GS =0030 0000000000000000 ffffffff 00c09300 DPL=0 DS [-WA]
LDT=0000 0000000000000000 0000ffff 00008200 DPL=0 LDT
TR =0000 0000000000000000 0000ffff 00008b00 DPL=0 TSS64-busy
GDT= 000000001f9dc000 00000047
IDT= 000000001f537018 00000fff
CR0=80010033 CR2=0000000000000000 CR3=000000001fc01000 CR4=00000668
...
You can see that the RIP is 000000001e235037
. You can see that the bottom 3nibble6 matches the address of the jmp
instruction in the assembly we just looked at. So it is looping as intended (the base address this app was loaded was apparently 0x1E235000
).
note
The results of info registers
alone can tell us several things. For example, if UEFI is not using GDT and IDT have already been set. The fact that CR3 is set also indicates that UEFI has already enabled paging. We can assume that the current page table would be a direct map of virtual and physical addresses. If you are curious, you may want to explore the page table by following CR3. Even if you don't do it by yourself, you can easily list the memory map by using the vmmap
command of gef GDB extension.
Summary
In this chapter, we created a UEFI app skeleton in preparation for implementing the Surtr bootloader. The app just repeats HLT loop. QEMU monitor confirmed that the HLT and JMP were repeating as intended. Since it is not interesting to loop silently all the time, in the next chapter, we will add an output function to Surtr to make it speak something.
References
At first glance, KVM may seem to be Type-2 since it interacts with Linux (the host OS), but considering that it operates at the same lowest layer as Linux, it makes sense that it is often classified as Type-1.
Surtr is a fire giant in Norse mythology. Not much is said about them in the mythology, and they appear only in the final war, Ragnarok, when they rampage and destroy the world. It is a mysterious being that existed before Ymir, the primordial Titan. It has been adopted as the name of the bootloader for the reason that it existed before Ymir.
Ymir is the primordial giant in Norse mythology. He was born from the frost that melted before the world was born, and grew up drinking the milk of a cow named Ausumbra, with whom he was born. Odin, the Almighty God, is a descendant of Ymir, who was killed by Odin and the world was created from his corpse. The name of the hypervisor (the kernel part of the hypervisor) that hosts a guest OS is taken from the legend that the world was born from Ymir.
The output directory can be overridden from command line options.
As each 8-bit unit is called a byte, each 4-bit unit is called a nibble.