Back to blog

Apple Silicon ハイパーバイザー:Linux カーネルローダーの実装(Week 4)

macOS Hypervisor.framework を使った ARM64 ハイパーバイザーの実装記録。Week 4 では Linux カーネルローダーと boot_linux() メソッドを実装し、バイトレベルメモリ操作の課題を解決しました。

はじめに

これまで Week 1〜3 で以下の機能を実装してきました。

  • Week 1: MMIO ハンドリング基盤(Data Abort 処理)
  • Week 2: UART エミュレーション(PL011)
  • Week 3: Device Tree 生成(vm-fdt)

Week 4 では、いよいよこれらを統合して Linux カーネルをブートする機能を実装します。この記事では、カーネルローダーの実装、バイトレベルメモリ操作の課題、そして ARM64 Linux Booting Protocol について詳しく解説します。

Week 4 の目標

Week 4 で実装する機能は以下の 3 つです。

  1. カーネルローダー - Linux カーネルイメージを読み込む
  2. バイトレベルメモリ操作 - カーネルをメモリに配置する
  3. boot_linux() メソッド - すべてを統合してブートする

最終的に、以下のような簡単なコードで Linux カーネルをブートできるようになります。

let mut hv = Hypervisor::new(0x40000000, 128 * 1024 * 1024)?;
let kernel = KernelImage::load("vmlinux")?;
hv.boot_linux(&kernel, "console=ttyAMA0", None)?;

1. カーネルローダーの実装

1.1 KernelImage 構造体の設計

まず、Linux カーネルイメージを扱うための構造体を設計します。

pub struct KernelImage {
    /// カーネルバイナリデータ
    data: Vec<u8>,
    /// エントリーポイントアドレス(ARM64 標準: 0x40080000)
    entry_point: u64,
}

この構造体は 2 つのフィールドを持ちます。

  • data: カーネルのバイナリデータ(機械語命令)
  • entry_point: カーネルの開始アドレス

なぜエントリーポイントが必要なのか

カーネルは任意のアドレスから実行を開始するわけではありません。ARM64 Linux では、カーネルのエントリーポイントは通常 0×40080000 に固定されています。

これは、ARM64 Linux Booting Protocol で定められた標準アドレスです。

参考: ARM64 Linux Booting Protocol

1.2 カーネルイメージの読み込み

カーネルイメージをファイルから読み込む load() メソッドを実装します。

pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn Error>> {
    let data = fs::read(path)?;
    let entry_point = 0x4008_0000; // ARM64 標準エントリーポイント

    Ok(Self { data, entry_point })
}

これは単純にファイルを読み込んで、Vec<u8> として保持します。

1.3 テスト用のカーネルイメージ作成

実際の Linux カーネルは数十 MB のサイズがあるため、テスト用に小さなブートコードを作成できるようにします。

pub fn from_bytes(data: Vec<u8>, entry_point: Option<u64>) -> Self {
    Self {
        data,
        entry_point: entry_point.unwrap_or(0x4008_0000),
    }
}

この from_bytes() メソッドを使えば、以下のようにテスト用のコードを作成できます。

let boot_code = vec![
    0x00, 0x00, 0x20, 0xd4, // brk #0(終了命令)
];
let kernel = KernelImage::from_bytes(boot_code, Some(0x40080000));

2. バイトレベルメモリ操作の課題と解決

2.1 なぜバイトレベル操作が必要なのか

Week 3 まで実装してきた write_instruction()write_data() は、それぞれ 4-byte(32-bit)と 8-byte(64-bit)単位でメモリに書き込む機能でした。

しかし、Linux カーネルイメージは任意のサイズのバイト列です。例えば、100 KB のカーネルをメモリに配置するには、1 バイトずつ書き込む機能が必要です。

2.2 Mapping の制限

macOS Hypervisor.framework の Mapping クラスは、以下のメソッドのみを提供しています。

  • write_dword() - 4-byte(32-bit)書き込み
  • write_qword() - 8-byte(64-bit)書き込み
  • read_dword() - 4-byte(32-bit)読み取り
  • read_qword() - 8-byte(64-bit)読み取り

1-byte 単位での read/write メソッドは提供されていません。

2.3 解決策:4-byte 単位での部分更新

この制限を回避するため、4-byte 単位で読み書きして、その中の 1 バイトだけを更新する実装をします。

write_byte() の実装

pub fn write_byte(&mut self, addr: u64, byte: u8) -> Result<(), Box<dyn Error>> {
    // 1. アドレスを 4-byte 境界にアライン
    let aligned_addr = addr & !0x3;

    // 2. 4-byte 境界内のオフセットを計算
    let offset = (addr & 0x3) as usize;

    // 3. アライン済みアドレスから 4-byte 読み取り
    let mut word = self.mem.read_dword(aligned_addr)?;

    // 4. リトルエンディアンでバイト配列に変換
    let mut bytes = word.to_le_bytes();

    // 5. 目的のバイトだけを更新
    bytes[offset] = byte;

    // 6. バイト配列を 4-byte に戻す
    word = u32::from_le_bytes(bytes);

    // 7. アライン済みアドレスに 4-byte 書き込み
    self.mem.write_dword(aligned_addr, word)?;

    Ok(())
}

なぜこの実装が機能するのか

例えば、アドレス 0x40000002 に 1 バイト書き込む場合を考えます。

  1. アライン: 0x40000002 & !0x3 = 0x40000000
  2. オフセット: 0x40000002 & 0x3 = 2
  3. 読み取り: 0x40000000 から 4-byte 読み取り
    • 例: [0xAA, 0xBB, 0xCC, 0xDD]
  4. 更新: オフセット 2 のバイトを更新
    • [0xAA, 0xBB, 0x42, 0xDD]
  5. 書き込み: 0x40000000 に 4-byte 書き込み

このように、4-byte の一部だけを更新することで、実質的に 1-byte 単位の書き込みを実現します。

2.4 read_byte() の実装

読み取りも同じ原理で実装します。

pub fn read_byte(&self, addr: u64) -> Result<u8, Box<dyn Error>> {
    let aligned_addr = addr & !0x3;
    let offset = (addr & 0x3) as usize;
    let word = self.mem.read_dword(aligned_addr)?;
    let bytes = word.to_le_bytes();
    Ok(bytes[offset])
}

これで、カーネルイメージをバイト単位でメモリに配置できるようになりました。

3. ARM64 Linux Booting Protocol とは

Linux カーネルを起動するには、特定のレジスタに特定の値を設定する必要があります。これを ARM64 Linux Booting Protocol と呼びます。

3.1 必要なレジスタ設定

ARM64 Linux Booting Protocol では、以下のレジスタ設定が要求されます。

レジスタ意味
X0DTB アドレスDevice Tree のメモリアドレス
X10Reserved(将来の拡張用)
X20Reserved(将来の拡張用)
X30Reserved(将来の拡張用)
PCエントリーポイントカーネルの開始アドレス(通常 0×40080000)
CPSR0×3c5プロセッサ状態(後述)

3.2 Device Tree とは

Device Tree(デバイスツリー) は、ハードウェア構成を記述するデータ構造です。

Linux カーネルは起動時に、以下の情報を知る必要があります。

  • メモリの開始アドレスとサイズ
  • UART などのデバイスのアドレス
  • CPU の数と種類
  • コマンドラインパラメータ

これらの情報を、Device Tree というバイナリ形式(FDT: Flattened Device Tree)でメモリに配置し、そのアドレスを X0 レジスタに設定します。

Week 3 で実装した generate_device_tree() がこの Device Tree を生成します。

3.3 CPSR(Current Program Status Register)とは

CPSR は、プロセッサの現在の状態を示すレジスタです。

ARM64 Linux ブート時には、CPSR を 0×3c5 に設定する必要があります。

0×3c5 の内訳

0x3c5 = 0b001111000101

このビット列の意味を分解すると以下のようになります。

  • M[4:0] = 0b00101 (0×5): EL1h モード

    • EL1: Exception Level 1(カーネル権限レベル)
    • h: ハンドラモード(専用スタックポインタを使用)
  • DAIF = 0b1111: すべての割り込みをマスク

    • D: デバッグ例外マスク
    • A: SError マスク
    • I: IRQ マスク
    • F: FIQ マスク

なぜ割り込みをマスクするのか

カーネルが起動する前は、割り込みハンドラがまだ設定されていません。この状態で割り込みが発生すると、システムがクラッシュします。

そのため、カーネルが初期化を完了するまで、すべての割り込みをマスク(無効化)します。

4. boot_linux() メソッドの実装

すべての機能を統合した boot_linux() メソッドを実装します。

4.1 全体の流れ

pub fn boot_linux(
    &mut self,
    kernel: &crate::boot::kernel::KernelImage,
    cmdline: &str,
    dtb_addr: Option<u64>,
) -> Result<HypervisorResult, Box<dyn Error>> {
    // 1. Device Tree 生成
    // 2. Device Tree をメモリに配置
    // 3. カーネルをメモリに配置
    // 4. ARM64 Linux ブート条件を設定
    // 5. VM Exit ループ
}

4.2 ステップ 1: Device Tree 生成

Week 3 で実装した generate_device_tree() を使って Device Tree を生成します。

let dtb = crate::boot::device_tree::generate_device_tree(
    &crate::boot::device_tree::DeviceTreeConfig {
        memory_base: self.guest_addr,
        memory_size: self.mem.get_size() as u64,
        uart_base: 0x0900_0000,
        cmdline: cmdline.to_string(),
    },
)?;

この Device Tree には以下の情報が含まれます。

  • メモリ情報(ベースアドレス、サイズ)
  • UART デバイス情報
  • CPU 情報
  • カーネルコマンドライン

4.3 ステップ 2: Device Tree をメモリに配置

生成した Device Tree をゲストメモリに配置します。

let dtb_addr = dtb_addr.unwrap_or(0x4400_0000);
for (i, &byte) in dtb.iter().enumerate() {
    self.write_byte(dtb_addr + i as u64, byte)?;
}

ここで、Week 4 で実装した write_byte() が活躍します。Device Tree の各バイトを順番にメモリに書き込んでいきます。

なぜ 0×44000000 なのか

メモリレイアウトは以下のようになります。

0x40000000: メモリ開始(128 MB)
0x40080000: カーネルエントリーポイント
0x44000000: Device Tree 配置位置
0x48000000: メモリ終了(128 MB の場合)

カーネルと Device Tree が重ならないように、十分な距離を空けています。

4.4 ステップ 3: カーネルをメモリに配置

カーネルイメージをメモリに配置します。

let kernel_addr = kernel.entry_point();
for (i, &byte) in kernel.data().iter().enumerate() {
    self.write_byte(kernel_addr + i as u64, byte)?;
}

これも write_byte() を使って、カーネルの各バイトをメモリに書き込みます。

4.5 ステップ 4: ARM64 Linux ブート条件を設定

ARM64 Linux Booting Protocol に従って、レジスタを設定します。

// Device Tree アドレス
self.set_reg(Reg::X0, dtb_addr)?;

// Reserved
self.set_reg(Reg::X1, 0)?;
self.set_reg(Reg::X2, 0)?;
self.set_reg(Reg::X3, 0)?;

// エントリーポイント
self.set_reg(Reg::PC, kernel_addr)?;

// プロセッサ状態
self.set_reg(Reg::CPSR, 0x3c5)?;

// デバッグ例外のトラップを有効化
self.vcpu.set_trap_debug_exceptions(true)?;

この設定により、vCPU が実行を開始すると、カーネルの最初の命令(0×40080000 番地)から実行が始まります。

4.6 ステップ 5: VM Exit ループ

最後に、既存の run() メソッドを呼び出して、VM Exit ループを実行します。

self.run(Some(0x3c5), Some(true))

これで、カーネルが実行され、UART への出力や BRK 命令による終了が処理されます。

5. テストの実装

5.1 ユニットテスト

KernelImage のユニットテストを実装します。

#[test]
fn test_kernel_image_from_bytes() {
    let data = vec![0x00, 0x00, 0x00, 0x14]; // b #0
    let kernel = KernelImage::from_bytes(data.clone(), None);

    assert_eq!(kernel.entry_point(), 0x4008_0000);
    assert_eq!(kernel.size(), 4);
    assert_eq!(kernel.data(), &data);
}

5.2 統合テスト

カーネルと Device Tree を組み合わせた統合テストを実装します。

#[test]
fn test_kernel_image_and_device_tree_integration() {
    // 1. カーネルイメージを作成
    let boot_code = vec![
        0x00, 0x00, 0xa1, 0xd2, // movz x1, #0x9000, lsl #16
        0x40, 0x08, 0x80, 0xd2, // movz x0, #0x42
        0x20, 0x00, 0x00, 0xf9, // str x0, [x1]
        0x00, 0x00, 0x20, 0xd4, // brk #0
    ];
    let kernel = KernelImage::from_bytes(boot_code, Some(0x4008_0000));

    // 2. Device Tree を生成
    let config = DeviceTreeConfig {
        memory_base: 0x4000_0000,
        memory_size: 128 * 1024 * 1024,
        uart_base: 0x0900_0000,
        cmdline: "console=ttyAMA0".to_string(),
    };
    let dtb = generate_device_tree(&config).unwrap();

    // 3. カーネルと Device Tree が正しく生成されていることを確認
    assert_eq!(kernel.entry_point(), 0x4008_0000);
    assert_eq!(kernel.size(), 16);
    assert_eq!(dtb[0..4], [0xd0, 0x0d, 0xfe, 0xed]);
}

このテストでは、カーネルと Device Tree が正しく生成され、メモリレイアウトが重複しないことを確認します。

6. テスト結果

すべてのテストを実行します。

cargo test

結果は以下の通りです。

running 15 tests
test boot::device_tree::tests::test_device_tree_config_default ... ok
test boot::kernel::tests::test_kernel_image_empty_data ... ok
test boot::kernel::tests::test_kernel_image_from_bytes ... ok
test boot::kernel::tests::test_kernel_image_from_bytes_with_custom_entry_point ... ok
test boot::device_tree::tests::test_generate_device_tree_with_default_config ... ok
test boot::device_tree::tests::test_generate_device_tree_with_custom_config ... ok
test devices::uart::tests::test_uart_base_and_size ... ok
test devices::uart::tests::test_uart_dr_write ... ok
test devices::uart::tests::test_uart_fr_read ... ok
test devices::uart::tests::test_uart_unknown_register_read ... ok
test devices::uart::tests::test_uart_unknown_register_write ... ok
test mmio::tests::test_mmio_manager_register ... ok
test mmio::tests::test_mmio_manager_unhandled_address ... ok
test mmio::tests::test_mmio_manager_write_read ... ok
test boot::kernel::tests::test_kernel_image_large_data ... ok

test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

running 3 tests
test test_kernel_image_creation ... ok
test test_device_tree_with_kernel ... ok
test test_kernel_image_and_device_tree_integration ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

すべてのテスト(18 件)が通過しました。

7. 実装の完成度

Week 4 の実装により、以下の機能が完成しました。

7.1 実装した機能

  • ✅ カーネルローダー(KernelImage)
  • ✅ バイトレベルメモリ操作(write_byte/read_byte)
  • ✅ Linux ブート機能(boot_linux)
  • ✅ ARM64 Linux Booting Protocol 準拠
  • ✅ 包括的なテスト(ユニット + 統合)

7.2 コード統計

  • boot/kernel.rs: 159 行
  • lib.rs への追加: 111 行
  • tests/integration_test.rs: 68 行
  • examples/kernel_boot_test.rs: 100 行
  • 合計: 438 行

7.3 テストカバレッジ

  • ユニットテスト: 15 件
  • 統合テスト: 3 件
  • Doc テスト: 3 件
  • 合計: 21 件(すべて通過)

8. 実装時にハマったポイント

8.1 Mapping の 4-byte 制限

最初は Mapping が 1-byte 単位の read/write をサポートしていると思い込んでいましたが、実際には 4-byte 単位しかサポートしていませんでした。

この問題に気づいたのは、コンパイルエラーが出たときです。

error[E0599]: no method named `write_byte` found for struct `Mapping`

この制限を回避するために、4-byte 単位での部分更新を実装しました。

8.2 リトルエンディアンとビッグエンディアン

ARM64 はリトルエンディアン(下位バイトが先)なので、to_le_bytes() を使う必要があります。

最初、to_be_bytes()(ビッグエンディアン)を使ってしまい、バイト順が逆になってしまいました。

8.3 CPSR の値

ARM64 Linux Booting Protocol では CPSR を 0×3c5 に設定する必要がありますが、最初は 0×3c4(EL1h, DAIF なし)を使っていました。

これでは割り込みがマスクされないため、カーネルが起動前にクラッシュする可能性があります。

ドキュメントを読み直して、0×3c5(DAIF マスク付き)が正しいことを確認しました。

9. 技術的な学び

9.1 メモリアライメントの重要性

メモリアクセスは、アライメント(境界)を意識する必要があります。

  • 4-byte アクセスは 4-byte 境界(アドレスが 4 の倍数)
  • 8-byte アクセスは 8-byte 境界(アドレスが 8 の倍数)

アライメントされていないアクセスは、パフォーマンスが低下したり、エラーになったりする可能性があります。

9.2 ARM64 ブートプロトコルの理解

Linux カーネルをブートするには、ハードウェアとソフトウェアの間の「契約」を理解する必要があります。

ARM64 Linux Booting Protocol は、この契約を明確に定義しています。

  • どのレジスタに何を設定するか
  • Device Tree の形式
  • メモリレイアウト

これらを正しく理解することで、カーネルが正常に起動します。

9.3 段階的な実装の重要性

Week 1〜4 を通して、段階的に機能を実装してきました。

  • Week 1: MMIO ハンドリング基盤
  • Week 2: UART エミュレーション
  • Week 3: Device Tree 生成
  • Week 4: すべてを統合

この段階的なアプローチにより、各ステップでテストを書き、問題を早期に発見できました。

10. 次のステップ

Week 4 の実装により、Phase 1(最小限の Linux ブート)の基盤が完成しました。

次のステップとしては、以下が考えられます。

Phase 2 の計画

  • VirtIO Block デバイス: ディスク I/O をサポート
  • VirtIO Net デバイス: ネットワーク I/O をサポート
  • 複数 vCPU: マルチコアサポート
  • 実際の Linux カーネル: 実際の Linux カーネルイメージでのブートテスト

ブログ記事の予定

  • Phase 1 完了の振り返り
  • VirtIO Block デバイスの実装
  • パフォーマンス最適化

まとめ

Week 4 では、Linux カーネルローダーと boot_linux() メソッドを実装しました。

主な成果は以下の通りです。

  1. カーネルローダー: Linux カーネルイメージを読み込む機能
  2. バイトレベルメモリ操作: 4-byte 制限を回避した効率的な実装
  3. Linux ブート機能: ARM64 Linux Booting Protocol に準拠
  4. 包括的なテスト: 21 件のテストがすべて通過

これにより、Week 1〜4 で構築してきた MMIO ハンドリング、UART エミュレーション、Device Tree 生成がすべて統合され、Linux カーネルをブートする基盤が完成しました。

次は、実際の Linux カーネルイメージでのブートテストや、VirtIO デバイスの実装に進む予定です。

参考資料

リポジトリ

実装コードは以下のリポジトリで公開しています。

Week 4 の実装を PR #14 としてマージ済みです。