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:

  • VMWare ESXi
  • Microsoft Hyper-V
  • Linux KVM1
  • BitVisor

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:

sh
mkdir ymir && cd ymir
git init
zig init

The project structure will be as follows:

txt
.
├── build.zig
├── build.zig.zon
└── src
    ├── main.zig
    └── root.zig
  • build.zig4: 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:

txt
.
├── 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:

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 LevelDescription
DebugDefault level. A check for undefined behavior (UD) is inserted.
ReleaseFastMost aggressive optimization. No checks are made for undefined behavior (UD).
ReleaseSafeOptimization is performed, but a check for undefined behavior (UD) is performed.
ReleaseSmallOptimize 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:

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:

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:

build.zig
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 with apt, 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 port 1234.

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:

sh
zig build run -Doptimize=Debug

It's okay if QEMU starts and does not proceed as shown below:

txt
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セクションは以下のようになっています。とても小さいです:

S
> 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:

txt
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

1

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.

2

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.

3

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.

5

The output directory can be overridden from command line options.

6

As each 8-bit unit is called a byte, each 4-bit unit is called a nibble.