【Rustで作る自作OS】自作UEFIブートローダーの第一歩!Cargo Workspace構築と「Hello, World!」の画面出力(Phase 0)

OS開発
この記事は約29分で読めます。
  1. 1. はじめに
    1. 対象読者と本記事のゴール
  2. 2. 開発環境(2026年11月現在)
  3. 3. 仕組みと前提知識
    1. レガシーBIOSとUEFIの違い
    2. UEFIはどうやってブートローダーを見つけるのか?
    3. UEFIアプリケーションの正体
  4. 4. プロジェクト構成とWorkspace設計
  5. 5. 実装手順
    1. Step 1: プロジェクトディレクトリとWorkspaceの作成
    2. Step 2: bootloader の Cargo.toml 設定
    3. Step 3: Cargoのビルドターゲット設定
    4. Step 4: 最小コードの実装(Hello, World!)
    5. Step 5: コードの要所解説
      1. 1. #![no_std] と #![no_main]
      2. 2. #[entry] マクロ
      3. 3. 画面への文字列出力
      4. 4. core::hint::spin_loop() とは?
      5. 5. #[panic_handler]
  6. 6. ビルドとQEMUでの起動確認
    1. Step 1: ブートローダーのコンパイル
    2. Step 2: ESP(FAT32)ディレクトリの作成
    3. Step 3: ワンクリック起動スクリプトの作成
    4. Step 4: 起動と動作確認
  7. 7. トラブルシューティングとハマりどころ
    1. 1. OVMFが見つからない(ファイルパスエラー)
    2. 2. UEFIの文字コードに関する注意点
  8. 8. 深掘り:#[entry] の裏側で何が起きているのか?(UEFIとコードのつながり)
    1. レガシーBIOSとUEFIの「つながり」の違い
    2. UEFIファームウェアがコードを実行するまでのステップ
    3. #[entry] マクロの正体
      1. 1. シンボル名 efi_main と PE/COFF ヘッダ
      2. 2. 呼び出し規約 extern "efiapi"
      3. 3. 安全な Rust 世界への橋渡し(uefi クレートの恩恵)
    4. 全体のつながりマップ
  9. 9. まとめと振り返り
    1. 今回達成したこと
    2. 実際にやってみての所感

1. はじめに

前回の記事で、WSL 3.0とUbuntu 26.04上にVS CodeとQEMU 11を組み合わせた開発環境を整えました。

土台が整ったところで、いよいよOS自作のコードを書き始めていきます。

自作OS開発において、最初に直面する大きな分岐点が**「ブートローダーをどうするか」**です。

Linux等で広く使われている「GRUB」や、近年の自作OS界隈でモダンなブートローダーとして人気を集めている「Limine」といった既存の強力なツールを利用すれば、面倒な初期化をすべて任せて、いきなりカーネル本体の実装から始めることができます。多くのOS自作入門記事でも、既存ローダーの利用が推奨されています。

しかし、「ハードウェアの電源が入ってからOSが立ち上がるまでのすべてを理解したい」という探究心からOS自作を志した場合、ブートローダーをブラックボックスのまま済ませてしまうのは少し寂しいところです。

そこで本連載では、既存のローダーを使わず、ブートローダーもRustでゼロから自作する道を選びます。

今回は「VS Codeで始めるRust自作OS開発環境の構築ガイド」のコード実装編として、自作UEFIブートローダーの第一歩を踏み出します。Cargo Workspaceによるプロジェクト構成の立ち上げから、UEFI上で動作するバイナリのビルド、そしてQEMUの画面に「Hello, UEFI World!」を表示させるところまで進めてみます。

対象読者と本記事のゴール

  • 対象読者:
    • 前回の環境構築(Rust、QEMU、OVMF)を完了している方
    • 既存のブートローダーを使わず、自作UEFIブートローダーを作ってみたい方
    • ベアメタル(OSの助けを借りず、ハードウェア上で直接動作する環境)のRustで no_std アプリケーションを動かしてみたい方
  • 本記事のゴール:
    • Cargo Workspaceによるマルチクレート構成を構築すること
    • x86_64-unknown-uefi ターゲット向けに最小限のUEFIバイナリをビルドできること
    • QEMU+OVMF上で自作ブートローダーを起動し、画面に文字列が出力されることを確認すること

2. 開発環境(2026年11月現在)

本記事で動作確認を行っている環境一覧です。

ツール / レイヤーバージョン / 設定役割
ホストOSWindows 11 (24H2)開発PC母艦
Linux環境WSL 3.0 (Ubuntu 26.04 LTS)ビルド・実行環境
言語ツールチェーンRust 1.99.0-nightly (Edition 2024)コンパイラ
コンパイルターゲットx86_64-unknown-uefiUEFIバイナリ生成用ターゲット
エミュレータQEMU 11.1.x (qemu-system-x86_64)仮想マシン
UEFIファームウェアOVMF 4M版 (OVMF_CODE_4M.fd, OVMF_VARS_4M.fd)x86_64向けオープンソースUEFI
使用クレートuefi = "0.33"UEFIプロトコル・ヘルパー

3. 仕組みと前提知識

コードを書く前に、「UEFIとは何者なのか」「PCはどうやって自作コードを見つけて実行するのか」という仕組みを整理しておきます。

レガシーBIOSとUEFIの違い

かつてのPC自作OS開発では、マザーボードに搭載された「レガシーBIOS(Legacy BIOS)」を相手にするのが一般的でした。

しかし、レガシーBIOSは1980年代初頭のIBM PCとの互換性を引きずっており、OS開発者にとっては非常に扱いづらい仕様でした。x86系CPUは、過去の互換性を保つために「電源を入れた瞬間は昔の16bit CPUとして動作する」という歴史的経緯があるためです。

【レガシーBIOSの起動】
電源ON ──> 16bit「リアルモード」で起動(メモリ空間がわずか1MBしかない状態)
        ──> MBR(ストレージ先頭のわずか512バイトの領域)からコードを読み込む
        ──> 512バイトの厳しい制限の中で、32bit「プロテクトモード」へ切り替える
        ──> ページング(仮想メモリ)を有効化し、ようやく現代の64bit「ロングモード」へ自力で昇格……

この「16bitから段階的に64bitへ移行する」作業は、アセンブリ言語による煩雑なレジスタ操作やディスク読み込みルーチンの自作が必要となり、OS本体のコードを書く前段階で大きな負担となっていました。

これに対して、現代の標準規格である**「UEFI(Unified Extensible Firmware Interface)」**は、起動時から世界がまったく異なります。

【UEFIの起動】
電源ON ──> ファームウェアがハードウェアを初期化
        ──> 最初から「64bit ロングモード」かつ「ページング有効」で動作!
        ──> ストレージのFAT32パーティション(ESP)をネイティブ認識
        ──> ストレージから実行可能ファイル(.efi)を直接ロードして実行

UEFIの最大のメリットは以下の通りです:

  1. 最初から64bitモード: 複雑なCPUモード移行アセンブリを書く必要がなく、電源投入直後からRustの64bitコードがそのまま動きます。
  2. ストレージとファイルシステムを標準サポート: ファームウェア自体がFAT12/16/32のファイルシステムを理解できるため、ディスクの先頭セクタを直に弄る必要がありません。
  3. C言語・Rust親和性の高いAPI: 画面出力、メモリマップ(物理メモリの空き状況一覧)取得、ファイル読み書きなど、豊富な機能が「UEFI Boot Services(OS起動前にファームウェアが提供するAPI群)」として整っています。

UEFIはどうやってブートローダーを見つけるのか?

UEFIマザーボードは、PCに接続されたストレージの中から**「ESP(EFI System Partition)」**と呼ばれるFAT32形式のパーティションを探します。

そして、特に起動順序の設定が指定されていない場合、以下の決まったパスにある実行ファイルを自動的にロードして実行します。

/ (ESP パーティションのルート)
└── EFI/
    └── BOOT/
        └── BOOTX64.EFI   <── x86_64環境におけるデフォルトの起動ファイル!

つまり、**「RustでコンパイルしたUEFIバイナリを BOOTX64.EFI という名前で保存し、FAT32の /EFI/BOOT/ に配置する」**だけで、PCのファームウェアが勝手に自分のコードを読み込んで実行してくれます。

UEFIアプリケーションの正体

一般的なLinux環境での実行ファイル形式は ELF、Windowsでは PE/COFF ですが、UEFIの実行ファイルはWindowsと同じ「PE/COFF(Portable Executable / Common Object File Format)」形式を採用しています。拡張子は通常 .efi です。

Rustでは、公式ターゲットとして x86_64-unknown-uefi が用意されており、これを利用するとリンカ(コンパイルされたコードを結合して実行ファイルをまとめるツール)が自動的にPE/COFF形式の .efi バイナリを出力してくれます。

エントリーポイント(プログラムの開始地点)では、UEFIファームウェアから以下の2つの重要な情報が渡されます:

+--------------------------------------------------------------------------+
| UEFI Firmware                                                            |
|                                                                          |
|   呼び出し: efi_main(image_handle, system_table)                         |
|                                                                          |
|     1. ImageHandle: 実行中の自分自身のバイナリを指す識別子(ポインタ)   |
|     2. SystemTable: 画面出力やブートサービスへアクセスするための司令塔   |
+--------------------------------------------------------------------------+

通常のC言語プログラムでいう main(argc, argv) の引数のように、ファームウェアから ImageHandle と SystemTable(システムテーブル)という構造体が渡されます。

この SystemTable を経由して、UEFIが提供する各種機能(UEFI用語では機能や規格のことを「プロトコル(Protocol)」と呼びます)や標準出力を取得し、画面上にテキストを表示させることができます。


4. プロジェクト構成とWorkspace設計

自作OS開発は長期的なプロジェクトになります。

本連載の仕様書(Phase 0〜Phase 1)では、以下のように役割ごとにクレート(Rustのパッケージ単位)を分割して管理する**Cargo Workspace(マルチクレート構成)**を採用します。

aogane-os/
├── Cargo.toml                # Workspaceルート設定
├── .cargo/
│   └── config.toml           # ビルドターゲット等の共通設定
├── bootloader/               # 【今回実装】自作UEFIブートローダー
│   ├── Cargo.toml
│   └── src/
│       └── main.rs
├── kernel/                   # 【次回以降】独自カーネル本体
│   ├── Cargo.toml
│   └── src/
│       └── main.rs
└── scripts/
    └── run.sh                # ビルド&QEMU起動スクリプト
  • bootloader/: x86_64-unknown-uefi ターゲット向けにビルドされるUEFIアプリケーション。カーネルをメモリにロードし、ページングや画面初期化を行った後にカーネルへ制御を渡します。
  • kernel/: x86_64-unknown-none(OSや既存ランタイムが一切存在しない、純粋なベアメタル環境向けターゲット)向けにビルドされるELF64バイナリ。OS本体です。

このように分けておくことで、ターゲットアーキテクチャや実行環境の異なるブートローダーとカーネルを、1つのGitリポジトリ内で綺麗に役割分担して管理できます。


5. 実装手順

それでは、実際にプロジェクトを作成し、コードを書いていきましょう。

Step 1: プロジェクトディレクトリとWorkspaceの作成

WSLのターミナルを開き、作業用ディレクトリを作成します。

💻 実行場所: WSL (Ubuntu)

# プロジェクト用ディレクトリを作成
mkdir -p ~/work/aogane-os
cd ~/work/aogane-os

# サブディレクトリを作成
mkdir -p bootloader/src kernel/src scripts .cargo

ルート直下の Cargo.toml を作成し、Workspaceを定義します。

📄 ファイル: Cargo.toml

[workspace]
resolver = "2"
members = [
    "bootloader",
    # "kernel", # カーネルは次回以降に有効化します
]

[workspace.package]
version = "0.1.0"
edition = "2024"
authors = ["Aogane OS Developers"]
license = "MIT"

Step 2: bootloader の Cargo.toml 設定

次に、ブートローダー側の bootloader/Cargo.toml を作成します。

UEFIアプリケーションを開発するにあたり、公式仕様に沿った型定義やヘルパー関数を提供してくれるデファクトスタンダードのクレート uefi を導入します。

📄 ファイル: bootloader/Cargo.toml

[package]
name = "bootloader"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true

[dependencies]
# UEFIプロトコルやシステムテーブル操作を提供するクレート
uefi = { version = "0.33", features = ["alloc", "logger", "global_allocator"] }

Step 3: Cargoのビルドターゲット設定

毎回 --target x86_64-unknown-uefi とコマンドラインで指定するのは手間がかかるため、.cargo/config.toml に設定を書いておきます。

📄 ファイル: .cargo/config.toml

[build]
# bootloaderパッケージのデフォルトターゲットをUEFIに設定
target = "x86_64-unknown-uefi"

💡 build-std が不要な理由(Tier 2ターゲットの恩恵) 古いOS自作系の記事では、組み込み環境特有の memcpy や memset などのシンボル未解決を防ぐ目的で -Z build-std や compiler-builtins-mem を指定する手法が広く知られていました。

しかし、現在のRustにおいて x86_64-unknown-uefi は公式の Tier 2ターゲット(※公式CIでクロスビルドが保証され、rustup 経由で事前コンパイル済みライブラリが配布されるサポートレベル)として提供されています。

そのため、rustup target add x86_64-unknown-uefi を実行した時点で、メモリ操作関数を含むビルド済みの標準ライブラリ(core、alloc、compiler_builtins)がそのまま手に入り、ユーザー自身がソースから再コンパイルする必要がありません。

むしろ不要な build-std を指定して compiler_builtins をソースから再ビルドさせてしまうと、内部の拡張浮動小数点(f128)演算コード等に起因する未定義シンボル(fmal / rintl)のリンクエラーを引き起こします。そのため、.cargo/config.toml は上記のようにシンプルにターゲットのみを指定するのが最適解です。

Step 4: 最小コードの実装(Hello, World!)

ブートローダー本体のソースコード bootloader/src/main.rs を作成します。

まずは全体のコードを掲載し、そのあとで各行の意味を詳しく解説します。

📄 ファイル: bootloader/src/main.rs

#![no_std]
#![no_main]

use core::fmt::Write;
use uefi::prelude::*;

#[entry]
fn main() -> Status {
    // UEFIヘルパー(ロガーやアロケータ)を初期化
    if let Err(_) = uefi::helpers::init() {
        return Status::ABORTED;
    }

    // システムテーブル経由で標準出力(コンソール画面)を取得
    uefi::system::with_stdout(|stdout| {
        // 画面をクリアして黒背景にする
        let _ = stdout.clear();
        
        // 画面にメッセージを出力
        let _ = writeln!(stdout, "========================================");
        let _ = writeln!(stdout, " Hello, UEFI World! (AoganeOS Bootloader)");
        let _ = writeln!(stdout, " Phase 0: Project Initialized Successfully.");
        let _ = writeln!(stdout, "========================================");
    });

    // QEMUで画面出力を確認できるよう、無限ループ(スピンループ)で待機
    loop {
        core::hint::spin_loop();
    }
}

/// パニックハンドラ(no_std環境では必須)
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {
        core::hint::spin_loop();
    }
}

Step 5: コードの要所解説

コードの中で押さえておきたい重要なポイントを解説します。

1. #![no_std] と #![no_main]

#![no_std]
#![no_main]
  • #![no_std]: Rustの標準ライブラリ(std)を無効化します。std はLinuxやWindowsなどの既存OSが存在することを前提としているため、OSが存在しないUEFIベアメタル環境では使用できません。代わりに言語コア機能のみを提供する core ライブラリを使用します。
  • #![no_main]: 通常のRustプログラムのエントリーポイント(開始地点)である fn main() を無効化します。UEFIアプリケーションの起動処理はC言語スタイルの特殊な規約(呼び出し規約や引数)を持つため、標準のランタイム開始ルーチンをバイパスします。

2. #[entry] マクロ

#[entry]
fn main() -> Status {

uefi クレートが提供する属性マクロです。このマクロが、UEFIファームウェアから呼び出される低レベルなエントリーポイント関数を内部で自動生成し、Rustとして安全に扱える fn main() -> Status に繋ぎ込んでくれます。

3. 画面への文字列出力

let _ = writeln!(stdout, " Hello, UEFI World! (AoganeOS Bootloader)");

UEFIファームウェアは、画面表示用として「Simple Text Output Protocol」というインターフェースを標準で提供しています(※UEFIにおける「プロトコル」とはネットワーク通信の規格ではなく、ハードウェアや機能にアクセスするための共通API仕様・インターフェースのことです)。

uefi クレートでは、この出力先に対してRust標準の core::fmt::Write トレイトが実装されているため、普段のプログラミングと同様に writeln! マクロを使って直感的に文字列を出力できます。

4. core::hint::spin_loop() とは?

loop {
    core::hint::spin_loop();
}

core::hint::spin_loop() は、CPUに対して「現在ビジーループ(空回り待機)中である」ことを伝えるヒント命令(x86_64アーキテクチャでは PAUSE 命令)を発行する関数です。これを入れることで、CPUの無駄な電力消費や発熱を抑えながら安全に待機させることができます。

5. #[panic_handler]

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    loop {
        core::hint::spin_loop();
    }
}

no_std 環境では、プログラムがパニック(致命的エラー)を起こした際の挙動(スタック巻き戻しやエラー画面の表示など)を標準ライブラリが面倒を見てくれません。そのため、万が一パニックが発生した際にどうするかをプログラマ自身が #[panic_handler] として定義する必要があります。

今回は最小限の実装として、エラー発生時も無限ループで安全に停止させています。


6. ビルドとQEMUでの起動確認

コードが書けたら、実際にビルドしてQEMU上で動かしてみましょう。

Step 1: ブートローダーのコンパイル

Workspaceのルートディレクトリでビルドを実行します。

💻 実行場所: WSL (Ubuntu)

cargo build -p bootloader

ビルドが完了すると、target/x86_64-unknown-uefi/debug/ ディレクトリの中に bootloader.efi という実行可能ファイルが生成されているはずです。

Step 2: ESP(FAT32)ディレクトリの作成

UEFIファームウェアに認識させるため、ESPの標準ディレクトリ構成を作成し、ビルドしたバイナリを BOOTX64.EFI という名前でコピーします。

💻 実行場所: WSL (Ubuntu)

# ESP用の模擬ディレクトリを作成
mkdir -p build/esp/EFI/BOOT

# ビルドしたバイナリをデフォルト起動パスに配置
cp target/x86_64-unknown-uefi/debug/bootloader.efi build/esp/EFI/BOOT/BOOTX64.EFI

💡 QEMUの便利な機能 QEMUには -drive format=raw,file=fat:rw:<ディレクトリパス> という仮想FAT機能があります。わざわざディスクイメージファイル(.img)を作成してマウントしなくても、ホスト側のディレクトリをそのままFAT32ドライブとしてQEMUに見せかけることができます。開発中はこれが非常に便利です。

Step 3: ワンクリック起動スクリプトの作成

毎回手動でコピーやQEMUコマンドを入力するのは大変なので、ビルドから起動までを一発で行うスクリプトを作成しておきます。

📄 ファイル: scripts/run.sh

#!/usr/bin/env bash
set -e

# スクリプトの親ディレクトリ(プロジェクトルート)に移動
cd "$(dirname "$0")/.."

echo "==> Building Bootloader..."
cargo build -p bootloader

echo "==> Setting up ESP directory..."
mkdir -p build/esp/EFI/BOOT
cp target/x86_64-unknown-uefi/debug/bootloader.efi build/esp/EFI/BOOT/BOOTX64.EFI

# OVMFの配置場所(Ubuntu 24.04/26.04等の標準4M版)
OVMF_CODE="/usr/share/OVMF/OVMF_CODE_4M.fd"
OVMF_VARS="/usr/share/OVMF/OVMF_VARS_4M.fd"

# VARSの一時コピーを作成(QEMU実行による改変防止)
mkdir -p build/ovmf
cp -n "$OVMF_VARS" build/ovmf/OVMF_VARS.fd || true

echo "==> Launching QEMU..."
qemu-system-x86_64 \
    -drive if=pflash,format=raw,readonly=on,file="$OVMF_CODE" \
    -drive if=pflash,format=raw,file=build/ovmf/OVMF_VARS.fd \
    -drive format=raw,file=fat:rw:build/esp \
    -m 512M \
    -net none

スクリプトに実行権限を付与します。

💻 実行場所: WSL (Ubuntu)

chmod +x scripts/run.sh

💡 -drive if=pflash とは? QEMUのオプションにある if=pflash は、実機マザーボードの基板上に搭載されている「パラレル・フラッシュROMチップ」を仮想的にエミュレートする指定です。OVMFのようなUEFIファームウェアは通常のハードディスクやUSBメモリではなく、このフラッシュROM領域として仮想マシンに認識させる必要があります。

Step 4: 起動と動作確認

スクリプトを実行してQEMUを起動します。

💻 実行場所: WSL (Ubuntu)

./scripts/run.sh

実行すると、WSLg経由でQEMUのGUIウィンドウが自動的にポップアップ表示されます。

画面に以下のような文字が表示されれば大成功です!

========================================
 Hello, UEFI World! (AoganeOS Bootloader)
 Phase 0: Project Initialized Successfully.
========================================

自分たちがRustで書いたコードが、OSの助けを一切借りずに、マザーボードのファームウェア(OVMF)から直接読み込まれて画面に文字を出力しました。

確認が終わったら、QEMUウィンドウを閉じるか、ターミナルで Ctrl + C を押して終了します。


7. トラブルシューティングとハマりどころ

実際に作業を進める中で遭遇しやすいエラーや注意点をまとめておきます。

1. OVMFが見つからない(ファイルパスエラー)

qemu-system-x86_64: -drive if=pflash...: Could not open '/usr/share/OVMF/OVMF_CODE_4M.fd': No such file or directory

Ubuntuのバージョンやパッケージ構成によって、OVMFファイルの配置パスやファイル名が異なる場合があります(古い環境では OVMF_CODE.fd、ディストリビューションによっては /usr/share/edk2/x64/OVMF_CODE.4m.fd など)。

もし上記のエラーが出た場合は、find コマンドでパスを確認してみてください。

find /usr/share -name "OVMF_CODE*.fd" 2>/dev/null

見つかった実ファイルのパスに合わせて scripts/run.sh 内の OVMF_CODE および OVMF_VARS のパスを書き換えてください。

2. UEFIの文字コードに関する注意点

普段LinuxやWeb開発で扱う文字列は「UTF-8」が標準ですが、**UEFIのコンソール出力仕様は「UCS-2(UTF-16に近い2バイト固定コード)」**です。

uefi クレートの writeln! や内部ヘルパーを使う限りは自動で変換してくれるため普段は気にする必要はありませんが、生のアセンブリやC言語バインディングを直接呼ぶ場合は、ASCII英数字以外の文字(日本語など)を出力しようとすると文字化けや表示不可になるため注意が必要です。


8. 深掘り:#[entry] の裏側で何が起きているのか?(UEFIとコードのつながり)

ここで一度立ち止まり、**「PCの電源が入ってから自作の main() が呼ばれるまで、ハードウェアとメモリの中で何が起きているのか?」**という「つながり」を解明しておきましょう。

レガシーBIOSとUEFIの「つながり」の違い

レガシーBIOSを経験したことがある方なら、ブートの仕組みは非常に明快だったはずです。

【レガシーBIOSの場合(物理アドレス直結)】
ストレージのLBA 0(先頭512バイト) ──> 物理メモリ「0x7C00」番地にそのままロード
                                      ──> CPUが「0x7C00」へジャンプして実行開始!

BIOSでは「ディスクの先頭512バイトがメモリの 0x7C00 に置かれ、CPUのプログラムカウンタ(CS:IP)がそこを指す」というメモリ番地(物理アドレス)による絶対的な約束事がありました。そのため、電源投入から自作コードまでのつながりが直感的に把握できます。

一方、今回のRustコードではどうでしょうか。

#[entry]
fn main() -> Status { ... }

#[entry] というマクロを書くだけで、 main() が動き出します。

しかし、これにより**「UEFIファームウェアはどうやってこの関数を見つけたのか?」「メモリのどこに配置されたのか?」「番地とコードはどう繋がっているのか?」**という低レイヤーのつながりが見えなくなってしまいます。

この裏側で、UEFIファームウェアと uefi クレートが連携して行っている処理を紐解いてみましょう。


UEFIファームウェアがコードを実行するまでのステップ

UEFIにおいて、実行ファイル(BOOTX64.EFI)が実行されるまでの流れは次のようになっています。

+--------------------------------------------------------------------------------+
| 1. ESP(FAT32)から /EFI/BOOT/BOOTX64.EFI を読み込む                           |
|    ↓                                                                           |
| 2. PE/COFFヘッダ(Optional Header)をパースする                                |
|    ↓                                                                           |
| 3. ファームウェアが空いている物理メモリ領域を確保し、バイナリを展開(再配置)  |
|    ↓                                                                           |
| 4. ヘッダ内の「AddressOfEntryPoint」を読み取り、実行開始アドレスを特定         |
|    ↓                                                                           |
| 5. CPUレジスタに引数(RCX: ImageHandle, RDX: SystemTable)をセット              |
|    ↓                                                                           |
| 6. エントリーポイントのアドレスへ CALL(ジャンプ)!                           |
+--------------------------------------------------------------------------------+

BIOSのように「固定の 0x7C00 番地に置く」のではなく、**「OS(ファームウェア)が空きメモリを探してバイナリを展開し、ヘッダに記載されたエントリーポイント番地を調べて呼び出す」**という、現代のLinuxやWindowsのプロセス起動とまったく同じ仕組みをとっています。


#[entry] マクロの正体

では、UEFIファームウェアが探す「エントリーポイント番地」に、書いた fn main() はどうやって結びついているのでしょうか。

実は、#[entry] マクロを展開すると、内部で以下のようなC言語互換の低レベルエントリー関数が自動生成されています。

// #[entry] マクロが内部で自動生成している真のエントリーポイント(疑似コード)
#[no_mangle]
pub unsafe extern "efiapi" fn efi_main(
    image_handle: uefi::Handle,
    system_table: *const uefi::table::SystemTable<uefi::table::Boot>,
) -> uefi::Status {
    // 1. ファームウェアから渡されたシステムテーブルやハンドルをグローバルに安全に保持
    uefi::helpers::init_system_table(image_handle, system_table);

    // 2. プログラマが書いた安全な Rust の main() を呼び出す!
    let status = main();

    // 3. main() の返り値をファームウェアに返す(RAXレジスタ)
    status
}

この自動生成コードには、低レイヤーにおける重要な役割が3つ詰まっています。

1. シンボル名 efi_main と PE/COFF ヘッダ

#[no_mangle] を指定することで、Rustコンパイラによる名前マングリング(関数名の難読化)を防ぎ、シンボル名を efi_main としてそのまま出力します。

リンカ(rust-lld)は、ターゲットが x86_64-unknown-uefi であることを認識すると、この efi_main 関数の配置先アドレスを、生成されるPE/COFFファイルのヘッダ内にある AddressOfEntryPoint(実行開始相対アドレス) に自動的に書き込みます。

これにより、UEFIファームウェアはファイルを開いてヘッダを読むだけで、迷わずコードの先頭にジャンプできるわけです。

2. 呼び出し規約 extern "efiapi"

extern "efiapi" は、UEFI仕様が定める関数呼び出し規約(ABI: Application Binary Interface)に従うことをコンパイラに指示します。x86_64アーキテクチャでは、Windowsと同じ Microsoft x64 呼び出し規約 が使われます。

ファームウェアがジャンプしてくる直前、CPUのレジスタは以下のようにセットされています:

  • RCX レジスタ: 第1引数 image_handle(自分自身のバイナリを指すポインタ)
  • RDX レジスタ: 第2引数 system_table(ファームウェア機能への司令塔ポインタ)
  • RSP レジスタ: スタックポインタ(16バイト境界にアラインされたスタック領域)

extern "efiapi" と書くことで、Rustコンパイラは「第1引数はRCX、第2引数はRDXから取り出し、スタックの整合性を保って関数に入る」というアセンブリ命令を自動的に出力してくれます。

3. 安全な Rust 世界への橋渡し(uefi クレートの恩恵)

ファームウェアから渡される system_table は、そのままでは生のポインタ(*const)であり、C言語ライクなアンセーフ(unsafe)の塊です。

もし uefi クレートを使わずに自前ですべて書く場合、生ポインタを構造体にキャストし、手動で関数ポインタを辿って呼び出す必要があります。

uefi クレートの #[entry] マクロと初期化ヘルパーは、この生ポインタを受け取って安全にラップし、ライフタイムや借用ルールをRustの型システムに適合させた上で、安心して扱える fn main() -> Status や uefi::system::with_stdout() へと橋渡ししてくれていたのです。


全体のつながりマップ

ここまでの流れを1枚のマップにまとめると、以下のようになります。

[ 電源ON ]
    │
    ▼
[ UEFIファームウェア起動 ]
    │ ハードウェア初期化、FAT32(ESP)を認識
    ▼
[ /EFI/BOOT/BOOTX64.EFI を検索・メモリへロード ]
    │ PE/COFFヘッダから「AddressOfEntryPoint」を読み取る
    ▼
[ CPUレジスタの準備 ]
    │ RCX = ImageHandle
    │ RDX = SystemTable(生ポインタ)
    ▼
[ CALL AddressOfEntryPoint ] ────▶ 物理メモリ上のコードへジャンプ!
                                      │
┌─ uefi クレートの世界 ───────────────┘
│
▼
[ efi_main (#[no_mangle] extern "efiapi") ]
│  ・RCX / RDX から引数を受け取る
│  ・生ポインタを安全なRust構造体として初期化・保持
│
▼
[ fn main() -> Status ] ────▶ ★私たちが書いた自作コードに到達!
│  ・画面クリア
│  ・「Hello, UEFI World!」の出力
│
▼
[ Statusを返却 ]
│  ・RAXレジスタにステータスコードを格納してRET
│
└─────────────────────────────────────┘

「物理メモリの 0x7C00 番地に直に飛ぶ」BIOSに対して、UEFIは「ファイルシステムからPE/COFFバイナリをロードし、ヘッダに記載されたアドレスへABI規約に従ってCPUレジスタを整えてジャンプする」という違いがあることが分かります。

#[entry] の背後にあるこのつながりを把握しておくと、今後カーネルローダーやページング、ExitBootServicesへと進む際にも、メモリ空間でいま何が起きているのかを迷わず見通すことができるようになります。


9. まとめと振り返り

今回は、独自OS開発の「Phase 0」として、自作UEFIブートローダーの基盤構築と画面出力を行いました。

今回達成したこと

  • Cargo Workspaceによるマルチクレート構成のセットアップ
  • x86_64-unknown-uefi ターゲット向けビルド環境の構築
  • no_std 環境でのUEFIエントリーポイント(#[entry])の実装
  • #[entry] マクロの裏側(PE/COFFエントリーポイント解決とABI呼び出し規約)の理解
  • SystemTable の標準出力を利用した「Hello, World!」の画面出力
  • QEMU+OVMFによる起動スクリプトの作成と動作検証

実際にやってみての所感

既存のGRUB等を使えば一瞬で終わるフェーズですが、「なぜ .efi という拡張子なのか」「なぜ /EFI/BOOT/BOOTX64.EFI に置くのか」といったハードウェアの決まりごとを1つずつ紐解いていく作業は、低レイヤーならではの面白さがあります。

PCの電源を入れた直後のまっさらな世界で、自分の書いたRustコードが最初にCPU上で動き出し、画面に文字が浮かび上がった瞬間の達成感は格別です。

次回は**「Phase 1:OS起動」**へ進みます。

ブートローダーの本来の役割である「ストレージ(ESP)からELF64形式のカーネルファイルをメモリに読み込んで展開する処理(ELFローダー)」や、「メモリマップの取得」、そしてファームウェアに別れを告げる「ExitBootServices(UEFIファームウェアの管理下から脱出し、完全に自分たちのOSカーネルがハードウェアの支配権を握るための終了関数)」の実装へと足を踏み入れていく予定です。

少しずつOSらしい姿が見えてくるのを楽しみにしつつ、コードを育てていきたいと思う今日この頃です。

コメント