linux-loaderでLinuxカーネルをロードする——boot protocolのzero pageとe820マップ

ここまでは12〜15バイトの機械語を直接ゲストメモリに置いて実行してきましたが、ここからは実際のLinuxカーネル(bzImage形式)をロードします。今回は「カーネルイメージとboot_params(zero page)をゲストメモリ上に正しく組み立てる」ところまでを扱い、vCPUの作成やKVM_RUNによる実行は行いません。実際に起動させるには、Intel VMX向けのKVM_SET_TSS_ADDR/KVM_SET_IDENTITY_MAP_ADDRの設定やin-kernel irqchip/PITの用意が別途必要になるため、現時点の実装ではそこまで踏み込まず、代わりに組み立てた内容をホスト側から読み返して確認します。

カーネルイメージの入手

vmlinuzはホストにすでにインストールされているものを使います。/boot/vmlinuz-*root権限がないと読めないパーミッションになっていますが、apt-get downloadでパッケージ自体を取得すれば、展開した中身は誰でも読めます。

apt-get download linux-image-5.15.0-187-generic
dpkg-deb -x linux-image-5.15.0-187-generic_5.15.0-187.197_amd64.deb extracted
cp extracted/boot/vmlinuz-5.15.0-187-generic vmlinuz

apt-get downloadはダウンロードのみでインストールは行わないため、システムへの影響はありません。

linux-loaderでbzImageをロードする

Cargo.tomllinux-loaderを追加します。bzimage/elfの2つのフィーチャーを有効にします。

linux-loader = { version = "0.14.0", features = ["bzimage", "elf"] }

BzImage::loadにゲストメモリと読み込み先のゲスト物理アドレスを渡すと、bzImageのヘッダを解析し、圧縮されたカーネル本体をゲストメモリへ展開してくれます。

let mut kernel_file = File::open("vmlinuz").unwrap();
let kernel_load_result = BzImage::load(
    &guest_memory,
    Some(GuestAddress(KERNEL_LOAD_ADDR)),
    &mut kernel_file,
    Some(GuestAddress(KERNEL_LOAD_ADDR)),
)
.unwrap();

KERNEL_LOAD_ADDR0x100000(1MiB)で、これはLinux x86 boot protocolで定められた標準のロードアドレスです。戻り値のkernel_load_resultには、実際にロードされたアドレス範囲と、bzImageヘッダから読み取ったsetup_headerが含まれます。このsetup_headerは、この後ブートローダー(今回で言うと自分たちのVMM)側で何箇所か書き換える必要があります。

setup_header.vid_mode = 0xffff;
setup_header.type_of_loader = 0xff; // 独自ローダーであることを示す規定値
setup_header.cmd_line_ptr = CMDLINE_ADDR as u32;
setup_header.cmdline_size = CMDLINE.len() as u32 + 1;

type_of_loader = 0xffは、汎用的な独自ブートローダーであることをカーネルに伝えるための規定値です。cmd_line_ptrは、別途ゲストメモリに書き込んだカーネルコマンドライン文字列の場所を指します。

let mut cmdline = Cmdline::new(4096).unwrap();
cmdline.insert_str(CMDLINE).unwrap();
let cmdline_cstring = cmdline.as_cstring().unwrap();
guest_memory
    .write_slice(cmdline_cstring.as_bytes_with_nul(), GuestAddress(CMDLINE_ADDR))
    .unwrap();

boot_params(zero page)とe820マップ

boot_paramsは通称「zero page」と呼ばれる構造体で、カーネル起動時にレジスタ経由で渡す設定一式(先ほどのsetup_headerを含む)をまとめて格納します。この中のe820_tableが、物理メモリのどこが使用可能なRAMかを伝えるメモリマップです。

「e820」という名前は、実機のBIOSが提供していたINT 0x15(AX=0xE820)という割り込みサービス「Query System Address Map」に由来します。仮想マシンやUEFI環境では実際にこの割り込みは発生しませんが、Linuxカーネルの起動処理はこの形式のメモリマップを前提に書かれているため、今もboot_paramsの一部としてこのフォーマットが使われています。

let mut params = boot_params::default();
params.hdr = setup_header;
params.e820_table[0] = boot_e820_entry {
    addr: 0,
    size: KERNEL_LOAD_ADDR,
    r#type: 1, // E820_RAM
};
params.e820_table[1] = boot_e820_entry {
    addr: KERNEL_LOAD_ADDR,
    size: MEM_SIZE as u64 - KERNEL_LOAD_ADDR,
    r#type: 1, // E820_RAM
};
params.e820_entries = 2;

ここで0x0-0x1000000x100000-MEM_SIZEの2エントリに分けているのには理由があります。Linuxカーネルのarch/x86/kernel/e820.cにあるappend_e820_table()は、渡されたエントリ数が1個以下だと丸ごと無効とみなし、「BIOS-e801」という640KB決め打ちの古いフォールバック経路に切り替える仕様になっています。1つの連続領域として0x0-MEM_SIZEをまとめて渡すだけではこのフォールバックに引っかかるため、意図的に2エントリへ分割しています。

組み立てたboot_paramsは、LinuxBootConfiguratorを使ってゲストメモリのZERO_PAGE_ADDRへ書き込みます。

let boot_params_wrapper = BootParams::new::<boot_params>(&params, GuestAddress(ZERO_PAGE_ADDR));
LinuxBootConfigurator::write_bootparams::<GuestMemoryMmap<()>>(&boot_params_wrapper, &guest_memory)
    .unwrap();

書き込んだ内容をホスト側から読み返す

最後に、e820_entriesフィールドを読み返して、意図した値が書き込まれているかを確認します。このフィールドはzero page先頭から0x1e8バイト目という、Linux x86 boot protocolで規定された固定オフセットにあります。

let e820_entries: u8 = guest_memory
    .read_obj(GuestAddress(ZERO_PAGE_ADDR + 0x1e8))
    .unwrap();
println!("zero page written, e820_entries readback = {}", e820_entries);

実行すると、kernel loaded: 0x100000 - 0xc2c328(カーネルサイズはホストのバージョンに依存)とzero page written, e820_entries readback = 2が出力され、カーネル本体のロードとzero pageの構築が意図通りに行われたことが確認できます。