🥾

Zig で作ったブートローダーから Hello World するまで

2023/04/10に公開

この記事で書くこと

最近、低レイヤー周りの技術を学習しているなかでブートローダーを実装したので、頭の整理もかねて記事を書くことにしました。
ブートローダーで実行するカーネルは Hello World を出力するだけの自作カーネルで、メモリ管理やシステムコールなどの実装はしません。そのためブートローダーも自作カーネルを実行するのに必要な範囲の実装になっています。
ブートローダー、カーネルともに Zig で実装します。プログラムのビルドにも Zig が使えるので他にビルドツールを揃えなくてもいいところが嬉しいです。
Zig Language Reference

ブートローダーを実行する

リポジトリをクローンしてブートローダーを実行することができます。

git clone https://github.com/ssstoyama/bootloader_zig.git

動作確認した環境は MacOS のみです。
ブートローダーを実行する前に XQuartz のセットアップが事前に必要です。以下の記事が参考になります。
「ゼロからのOS自作入門」の副読本的記事 - XQuartz
セットアップできたらスクリプトを実行してコンテナを起動します。

./run.sh

終了したいときは QEMU モニターで q コマンドを実行します。

(qemu) q

また、VSCode Dev Containers でも実行できます。まずは、拡張機能 Remote Development をインストールします。
Open Folder in Container(参考) でコンテナを起動したあと下記のコマンドでブートローダーを実行できます。

# プログラムをビルドして QEMU で実行する
zig build run

実行するとチラッとログが映ったあと、

Hello World が表示されます。

ブート処理の流れ

ブートローダーは UEFI の機能を使って実装します。

UEFIとは、コンピュータ内の各装置を制御するファームウェアとオペレーティングシステム(OS)の間の通信仕様を定めた標準規格の一つ。従来のBIOSに代わるもの。UEFI対応ファームウェアを指してUEFIと呼ぶこともある。
https://e-words.jp/w/UEFI.html

UEFI の機能を使うと各デバイスの違いを意識せずにディスプレイに文字を表示したりファイルの読み書きができるようになります。
UEFI はプロトコルやサービスという単位で機能が別れていて、ブートローダーではログの表示やカーネルファイルの読み取り、メモリ領域の確保のために使います。

zig buildでプログラムをビルドすると fs/efi/boot/bootx64.efi にブートローダーを出力するようにしました。QEMU のパラメーターに -hda:fat:rw:fs を指定しているので、zig build run すると fs ディレクトリをハードディスクとみなして QEMU を実行します。そして UEFI は /efi/boot/bootx64.efi にあるファイルを自動的にブートローダーと判断して実行してくれます。カーネルは fs/kernel.elf に出力するので QEMU 上はルートディレクトリ直下にカーネルファイルがあるように見えます。

ブートローダー初期化処理

まずはブート処理に必要なプロトコルやサービスを UEFI から取得します。それぞれのプロトコルやサービスの用途は以下のようになります。

src/boot.zig
const std = @import("std");
const uefi = std.os.uefi;
const elf = std.elf;

var bs: *uefi.tables.BootServices = undefined;
var con_out: *uefi.protocols.SimpleTextOutputProtocol = undefined;
var fs: *uefi.protocols.SimpleFileSystemProtocol = undefined;
var gop: *uefi.protocols.GraphicsOutputProtocol = undefined;

pub fn main() uefi.Status {
    var status: uefi.Status = undefined;

    // ----------------------
    // ブートローダーの初期化処理
    // ----------------------

    // SimpleTextOutputProtocol 取得
    con_out = uefi.system_table.con_out orelse return .Unsupported;
    // 画面クリア
    status = con_out.clearScreen();
    if (status != .Success) {
        printf("failed to clear screen: {d}\r\n", .{status});
        return status;
    }

    // BootServices 取得
    bs = uefi.system_table.boot_services orelse {
        printf("unsupported boot services\r\n", .{});
        return .Unsupported;
    };

    // SimpleFileSystemProtocol 取得
    status = bs.locateProtocol(&uefi.protocols.SimpleFileSystemProtocol.guid, null, @ptrCast(*?*anyopaque, &fs));
    if (status != .Success) {
        printf("failed to locate file system protocol: {d}\r\n", .{status});
        return status;
    }

    // GraphicsOutputProtocol 取得
    status = bs.locateProtocol(&uefi.protocols.GraphicsOutputProtocol.guid, null, @ptrCast(*?*anyopaque, &gop));
    if (status != .Success) {
        printf("failed to locate graphics output protocol: {d}\r\n", .{status});
        return status;
    }

    printf("initialized boot loader\r\n", .{});

ログ出力用の printf 関数も定義しました。
SimpleTextOutputProtocol の OutputString 関数を使っています。

src/boot.zig
fn printf(comptime format: []const u8, args: anytype) void {
    var buf: [1024]u8 = undefined;
    const text = std.fmt.bufPrint(&buf, format, args) catch unreachable;
    for (text) |c| {
        con_out.outputString(&[_:0]u16{ c, 0 }).err() catch unreachable;
    }
}

カーネルのヘッダ取得

プログラムを実行するためにはファイルからメモリにプログラムをロードしなければいけません。
そのために必要な情報はヘッダにあるので kernel.elf からヘッダの情報を取得します。
Executable and Linkable Format - ヘッダ
まずはルートディレクトリ[1]を開いて、次にルートディレクトリ直下にある kernel.elf を開きます。そして kernel.elf の先頭からヘッダサイズ分のデータを読み込めばヘッダを取得できます。

src/boot.zig
    // ----------------------
    // カーネルヘッダ取得
    // ----------------------

    // ルートディレクトリを開く
    var root_dir: *uefi.protocols.FileProtocol = undefined;
    status = fs.openVolume(&root_dir);
    if (status != .Success) {
        printf("failed to open root directory: {d}\r\n", .{status});
        return status;
    }
    printf("opened root directory\r\n", .{});

    // カーネルファイルを開く
    var kernel_file: *uefi.protocols.FileProtocol = undefined;
    status = root_dir.open(
        &kernel_file,
        &[_:0]u16{ 'k', 'e', 'r', 'n', 'e', 'l', '.', 'e', 'l', 'f' },
        uefi.protocols.FileProtocol.efi_file_mode_read,
        uefi.protocols.FileProtocol.efi_file_read_only,
    );
    if (status != .Success) {
        printf("failed to open kernel file: {d}\r\n", .{status});
        return status;
    }
    printf("opened kernel file\r\n", .{});

    // ヘッダ読み込み用のバッファ
    var header_buffer: [*]align(8) u8 = undefined;
    var header_size: usize = @sizeOf(elf.Elf64_Ehdr);
    // ヘッダ読み込み用のメモリを確保する
    status = bs.allocatePool(uefi.tables.MemoryType.LoaderData, header_size, &header_buffer);
    if (status != .Success) {
        printf("failed to allocate memory for kernel header: {d}\r\n", .{status});
        return status;
    }
    // kernel.elf の先頭からヘッダのみ読み込む
    status = kernel_file.read(&header_size, header_buffer);
    if (status != .Success) {
        printf("failed to read kernel header: {d}\r\n", .{status});
        return status;
    }
    // header_buffer を Header 構造体にパースする
    const header = elf.Header.parse(header_buffer[0..@sizeOf(elf.Elf64_Ehdr)]) catch |err| {
        printf("failed to parse kernel header: {}\r\n", .{err});
        return .LoadError;
    };
    printf("read kernel header\r\n", .{});

readelf -h fs/kernel.elf を実行するとヘッダの情報が確認できます。ヘッダの Entry point address はカーネル呼び出しステップで使うので後ほど説明します。

readelf -h fs/kernel.elf

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x1014f0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          17280 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         4
  Size of section headers:           64 (bytes)
  Number of section headers:         16
  Section header string table index: 14

カーネルのロード

前のステップでカーネルをロードするためにヘッダの情報が必要だと説明しましたが、具体的にはプログラムヘッダの情報を使ってメモリにカーネルの各セグメントをロードします。
Executable and Linkable Format - ヘッダ

まずはターミナルでカーネルのプログラムヘッダを確認してみます。

readelf -l fs/kernel.elf

..
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000100040 0x0000000000100040
                 0x00000000000000e0 0x00000000000000e0  R      0x8
  LOAD           0x0000000000000000 0x0000000000100000 0x0000000000100000
                 0x00000000000004f0 0x00000000000004f0  R      0x1000
  LOAD           0x00000000000004f0 0x00000000001014f0 0x00000000001014f0
                 0x0000000000000383 0x0000000000000383  R E    0x1000
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000001000000  RW     0x0

 Section to Segment mapping:
  Segment Sections...
   00     
   01     .rodata 
   02     .text 
   03         

ELF ファイルは複数のセグメントで構成されていて Section to Segment mapping をみると kernel.elf には text, rodata の 2 つセグメントがあることがわかります。rodata セグメントには読み取り専用(ReadOnly)データが、text セグメントはプログラムの機械語がそれぞれ置かれています。そして、rodata セグメントは Program Headers の 1 つめの LOAD, text セグメントは 2 つめの LOAD にそれぞれ対応していることがわかります。

カーネルをメモリにロードするということは、プログラムヘッダのタイプが LOAD の項目をみて kernel.elf ファイルから機械語のプログラムやデータをメモリに移動するということです。
Loading ELF Binaries

そこで以下のステップでカーネルのロード処理を実装しました。

  1. セグメントの開始位置と終了位置のアドレスを求める。今回の例だと

    • 開始位置: 0x100000(1 つめの LOAD ヘッダの VirtAddr)
    • 終了位置: 0x101873(2 つめの LOAD ヘッダの VirtAddr+MemSiz)
  2. セグメントが収まるサイズのメモリを確保する。メモリはページ単位(1 ページ = 4 KiB)で確保するため、今回の例だと

    • 0x101873 - 0x100000 = 0x1873 なので 0x2000(2 ページ = 8 KiB) 分のメモリが必要な計算
  3. kernel.elf ファイルから各セグメント(rodata, text)をメモリにロードする。

  4. 初期化していないグローバル変数のメモリ領域を 0 で埋める(今回のカーネルには必要ありませんが)。

src/boot.zig
    // ----------------------
    // カーネルのロード
    // ----------------------

    // カーネルのロードに必要なメモリを確保するためにページ数(1ページ=4KiB)を計算する
    var kernel_first_addr: elf.Elf64_Addr align(4096) = std.math.maxInt(elf.Elf64_Addr);
    var kernel_last_addr: elf.Elf64_Addr = 0;
    var iter = header.program_header_iterator(kernel_file);
    while (true) {
        // プログラムヘッダを1つ読み込む
        const phdr = iter.next() catch |err| {
            printf("failed to iterate program headers: {}\r\n", .{err});
            return .LoadError;
        } orelse break;
        // プログラムヘッダタイプ LOAD 以外はスキップ
        if (phdr.p_type != elf.PT_LOAD) continue;
        if (phdr.p_vaddr < kernel_first_addr) {
            kernel_first_addr = phdr.p_vaddr;
        }
        if (phdr.p_vaddr + phdr.p_memsz > kernel_last_addr) {
            kernel_last_addr = phdr.p_vaddr + phdr.p_memsz;
        }
    }
    const pages = (kernel_last_addr - kernel_first_addr + 0xfff) / 0x1000; // 0x1000=4096
    printf("kernel first addr: 0x{x}, kernel last addr: 0x{x}, pages=0x{x}\r\n", .{ kernel_first_addr, kernel_last_addr, pages });

    // kernel_first_addr から pages ページ分のメモリを確保する
    status = bs.allocatePages(.AllocateAddress, .LoaderData, pages, @ptrCast(*[*]align(4096) u8, &kernel_first_addr));
    if (status != .Success) {
        printf("failed to allocate pages for kernel: {d}\r\n", .{status});
        return status;
    }
    printf("allocated pages for kernel\r\n", .{});

    iter = header.program_header_iterator(kernel_file);
    while (true) {
        // プログラムヘッダを1つ読み込む
        const phdr = iter.next() catch |err| {
            printf("failed to iterate program headers: {}\r\n", .{err});
            return .LoadError;
        } orelse break;
        // プログラムヘッダタイプ LOAD 以外はスキップ
        if (phdr.p_type != elf.PT_LOAD) continue;

        // ファイルの読み込み位置をあわせる
        // phdr.p_offset はカーネルファイルの先頭からのオフセット
        status = kernel_file.setPosition(phdr.p_offset);
        if (status != .Success) {
            printf("failed to set file position: {d}\r\n", .{status});
            return status;
        }

        // セグメント読み込み先のポインタ
        var segment: [*]u8 = @intToPtr([*]u8, phdr.p_vaddr);
        // セグメントのサイズ
        var mem_size: usize = phdr.p_memsz;
        // メモリにセグメントを読み込む
        status = kernel_file.read(&mem_size, segment);
        if (status != .Success) {
            printf("failed to load segment: {d}\r\n", .{status});
            return status;
        }
        printf(
            "load segment: addr=0x{x}, offset=0x{x}, mem_size=0x{x}\r\n",
            .{ phdr.p_vaddr, phdr.p_offset, phdr.p_memsz },
        );

        // 初期化していない変数がある場合はメモリの値を 0 で埋める。
        // bss セグメント(初期化していないグローバル変数用のセグメント)がある場合は必要。
        var zero_fill_count = phdr.p_memsz - phdr.p_filesz;
        if (zero_fill_count > 0) {
            bs.setMem(@intToPtr([*]u8, phdr.p_vaddr + phdr.p_filesz), zero_fill_count, 0);
        }
        printf("zero fill count: 0x{x}\r\n", .{zero_fill_count});
    }

カーネルに渡す情報を用意する

カーネルの実行に必要な情報を BootInfo という構造体に詰めてブートローダーから渡そうと思います。
今回はカーネルで Hello World が描画できればいいので FrameBufferConfig 構造体にピクセル描画に必要な情報をセットして BootInfo に含めることにしました。

src/boot.zig

    // ----------------------
    // カーネルに渡す情報(BootInfo)を用意する
    // ----------------------

    const frame_buffer_config = FrameBufferConfig{
        .frame_buffer = @intToPtr([*]u8, gop.mode.frame_buffer_base),
        .pixels_per_scan_line = gop.mode.info.pixels_per_scan_line,
        .horizontal_resolution = gop.mode.info.horizontal_resolution,
        .vertical_resolution = gop.mode.info.vertical_resolution,
        .pixel_format = switch (gop.mode.info.pixel_format) {
            .PixelRedGreenBlueReserved8BitPerColor => PixelFormat.PixelRGBResv8BitPerColor,
            .PixelBlueGreenRedReserved8BitPerColor => PixelFormat.PixelBGRResv8BitPerColor,
            else => unreachable,
        },
    };
    const boot_info = BootInfo{
        .frame_buffer_config = &frame_buffer_config,
    };
    printf("kernel entry point: 0x{x}\r\n", .{header.entry});
    printf("boot info pointer: {*}\r\n", .{&boot_info});

ブートサービス終了

ブートローダー用のリソースを解放してカーネルへ制御を移すためにブートサービスを終了させます。
ExitBootServices 実行後は SimpleTextOutputProtocol を使っている printf 関数も実行できなくなります。

src/boot.zig
    // ----------------------
    // ブートサービス終了処理
    // ----------------------

    // 不要になったメモリ、ファイルの後始末
    status = bs.freePool(header_buffer);
    if (status != .Success) {
        printf("failed to free memory for kernel header: {d}\r\n", .{status});
        return status;
    }
    status = kernel_file.close();
    if (status != .Success) {
        printf("failed to close kernel file: {d}\r\n", .{status});
        return status;
    }
    status = root_dir.close();
    if (status != .Success) {
        printf("failed to close root directory: {d}\r\n", .{status});
        return status;
    }

    // map_key を取得してブートローダーを終了する
    var map_size: usize = 0;
    var descriptors: [*]uefi.tables.MemoryDescriptor = undefined;
    var map_key: usize = 0;
    var descriptor_size: usize = 0;
    var descriptor_version: u32 = 0;
    _ = bs.getMemoryMap(&map_size, descriptors, &map_key, &descriptor_size, &descriptor_version);
    status = bs.exitBootServices(uefi.handle, map_key);
    if (status != .Success) {
        printf("failed to exit boot services: {d}\r\n", .{status});
        return status;
    }

カーネル呼び出し

ブートローダーからカーネルを呼ぶ

src/boot.zig
    // ----------------------
    // カーネル呼び出し
    // ----------------------
    const kernel_main = @intToPtr(*fn (*const BootInfo) callconv(.SysV) void, header.entry);
    kernel_main(&boot_info);

header.entry はカーネルのエントリポイントである kernel_main 関数のアドレスです。私の環境だと 0x1014f0 になっています。
前のステップでカーネルをメモリにロードしましたが、そのときメモリアドレス 0x1014f0 の位置に kernel_main 関数のプログラムがロードされています。プログラムの実行位置を 0x1014f0 に移すことで kernel_main 関数の処理にはいることができます。

readelf -h fs/kernel.elf

ELF Header:
  ..
  Entry point address:               0x1014f0
  ..

カーネル起動

ここからはブートローダーからカーネルに処理が移ります。
エントリーポイントの kernel_main が呼ばれると画面全体を白色で塗りつぶしたあと Hello World という文字を描画しています。
drawBG 関数のように、ブートローダーから渡された frame_buffer に色の情報(RGB など)をセットすることで塗りつぶしや文字を描画できます。
drawHello 関数も仕組みは同じです。フォントデータを用意する代わりに 2 次元配列を使って文字を表現しました。2 次元配列の値が 1 の部分に黒色を塗ります。

src/kernel.zig
const BootInfo = @import("boot.zig").BootInfo;
const FrameBufferConfig = @import("boot.zig").FrameBufferConfig;

export fn kernel_main(boot_info: *const BootInfo) void {
    drawBG(boot_info.frame_buffer_config);
    drawHello(boot_info.frame_buffer_config, 100, 100);
    while (true) asm volatile ("hlt");
}

// 画面全体を白で塗りつぶす
pub fn drawBG(frame_buffer_config: *const FrameBufferConfig) void {
    var x: usize = 0;
    while (x < frame_buffer_config.horizontal_resolution) : (x += 1) {
        var y: usize = 0;
        while (y < frame_buffer_config.vertical_resolution) : (y += 1) {
            // ピクセルに白色をセットする
            var p = @ptrCast([*]u8, &frame_buffer_config.frame_buffer[4 * (frame_buffer_config.pixels_per_scan_line * y + x)]);
            p[0] = 255;
            p[1] = 255;
            p[2] = 255;
        }
    }
}

// 黒い文字で Hello, World! を描画する
pub fn drawHello(frame_buffer_config: *const FrameBufferConfig, x: usize, y: usize) void {
...
}
...
const word_h = Word{
    [8]u1{ 0, 1, 1, 0, 0, 1, 1, 0 },
    [8]u1{ 0, 1, 1, 0, 0, 1, 1, 0 },
    [8]u1{ 0, 1, 1, 0, 0, 1, 1, 0 },
    [8]u1{ 0, 1, 1, 0, 0, 1, 1, 0 },
    [8]u1{ 0, 1, 1, 1, 1, 1, 1, 0 },
    [8]u1{ 0, 1, 1, 1, 1, 1, 1, 0 },
    [8]u1{ 0, 1, 1, 0, 0, 1, 1, 0 },
    [8]u1{ 0, 1, 1, 0, 0, 1, 1, 0 },
    [8]u1{ 0, 1, 1, 0, 0, 1, 1, 0 },
    [8]u1{ 0, 1, 1, 0, 0, 1, 1, 0 },
};
...

まとめ

以上で Zig で作ったブートローダーから Hello World するまで の説明はおわりです。
まだまだ理解できていないところもありますが、ブートローダーを実装してみて、コンピューターが起動してからカーネルを実行するまでの流れ、メモリやレジスタの使い方など、コンピューターの仕組みに対する解像度があがったように感じます。
最近は ChatGPT を使えば簡単に知識を教えてもらえますが、コンピューターサイエンスなどの基礎を知らないと実装するのは難しいと思いました(私は苦戦しました)。
500 行ほどのコード量なので基礎力をつけるためにブートローダーを実装してみるのもいいかもしれません。

(2023/04/19)
Zig で作ったブートローダーを実機で動かすには という記事を書きました。実機で動かす場合は参考にしてください。

参考情報

ゼロからのOS自作入門
フルスクラッチで作る!UEFIベアメタルプログラミング
UEFI Specification 2.10
stakach/uefi-bootstrap

脚注
  1. 実機で動かす場合はルートディレクトリを開く処理に修正が必要です。 Zig で作ったブートローダーを実機で動かすには の記事を参考ください ↩︎

Discussion