vCPUを実行して実際にLinuxカーネルを起動する——Intel VMX向け設定とin-kernel irqchip、initramfs
linux-loaderでLinuxカーネルをロードする——boot protocolのzero pageとe820マップでは、カーネルイメージとboot_params(zero page)をゲストメモリ上に正しく組み立てるところまでを扱い、vCPUの作成やKVM_RUNによる実行は行いませんでした。今回はその続きとして、実際にvCPUを作成しCPUを走らせ、Linuxカーネルが初期化を完了してユーザースペース(initramfs内の最初のプロセス)へ制御を渡すところまでを実装します。
Intel VMX向けの必須設定
vCPUを作成する前に、Intel VMX(Intel製CPUのハードウェア仮想化支援機能)を使う場合に必須となる設定が2つあります。
vm.set_tss_address(TSS_ADDR as usize).unwrap();
vm.set_identity_map_address(IDENTITY_MAP_ADDR).unwrap();KVM_SET_TSS_ADDRとKVM_SET_IDENTITY_MAP_ADDRは、KVMがVMX特有のリアルモード/16bitコードのエミュレーションを行う際に内部的に使う、ゲスト物理アドレス上の小さな作業領域をKVM自身に予約させるためのものです。今回はゲストRAM(0〜256MiB)と重ならない、十分に高いアドレス(0xffff_d000/0xffff_c000)を割り当てています。AMD-V(SVM)ではこの設定は不要ですが、Intel CPU環境でこれを省略すると、後述のvCPU実行がKVM_RUNのたびにエラーで失敗します。
in-kernel irqchipとPIT
vm.create_irq_chip().unwrap();
vm.create_pit2(kvm_pit_config::default()).unwrap();KVM_CREATE_IRQCHIPはPIC/IOAPIC相当の割り込みコントローラを、KVM_CREATE_PIT2はタイマー(Intel 8253/8254 PIT)を、それぞれKVMのカーネル内部(in-kernel)に用意します。この2つを用意しないと、ゲストがHLT命令を実行するたびにVM Exitが発生してVMMへ制御が戻ってしまい、タイマー割り込みも一切発生しないため、Linuxカーネルはスケジューラの初期化付近で実質的に停止してしまいます。in-kernel実装を使うことで、HLT後の割り込み待ちやタイマーの発火をKVM内部で完結させ、VMMを介さずにゲストの実行を継続できます。
64bitロングモードへ直接エントリするためのページテーブル
Linux x86 boot protocolの64bitエントリポイント(0x100000 + 0x200)は、CPUがすでにページングを有効にした64bitロングモードであることを前提としています。ブートローダー(今回のVMM)側で、最小限のページテーブルを事前に構築しておく必要があります。
fn write_identity_page_tables(guest_memory: &GuestMemoryMmap<()>) {
guest_memory
.write_obj(PDPTE_ADDR | 0x3, GuestAddress(PML4_ADDR))
.unwrap();
guest_memory
.write_obj(PDE_ADDR | 0x3, GuestAddress(PDPTE_ADDR))
.unwrap();
for i in 0u64..512 {
let entry = (i << 21) | 0x83;
guest_memory
.write_obj(entry, GuestAddress(PDE_ADDR + i * 8))
.unwrap();
}
}PML4(第4階層)の0番目のエントリがPDPT(第3階層)を指し、PDPTの0番目のエントリがPD(第2階層)を指す、という2段の間接参照のあと、PDの512エントリすべてに「2MiB huge page」として物理アドレスを直接割り当てています。これにより、先頭1GiB(512エントリ × 2MiB)の仮想アドレスと物理アドレスが1:1で対応する、恒等マッピング(identity mapping)が完成します。通常の4KBページを使ったより一般的な4階層(PML4→PDPT→PD→PT)のページテーブルと違い、PDの段階でPSビット(下位から数えて8bit目、0x80)を立てることで2MiBページとして扱わせ、最下層のPTを省略しています。今回はカーネルの起動に必要な範囲だけをカバーできればよいため、この簡略化したページテーブルで十分です。
| 0x3と| 0x83は、それぞれのエントリに立てているフラグビットです。0x3はPresent(ビット0)とWritable(ビット1)、0x83はそれにPS(Page Size、ビット7)を加えたものです。
CPUID・sregs・regsの設定
vCPUを作成したあと、CPUID・セグメントレジスタ・汎用レジスタを設定します。
let mut vcpu = vm.create_vcpu(0).unwrap();
let cpuid = kvm.get_supported_cpuid(KVM_MAX_CPUID_ENTRIES).unwrap();
vcpu.set_cpuid2(&cpuid).unwrap();
let mut sregs = vcpu.get_sregs().unwrap();
configure_long_mode_sregs(&mut sregs);
vcpu.set_sregs(&sregs).unwrap();
let regs = kvm_regs {
rip: KERNEL_LOAD_ADDR + 0x200,
rsp: 0x8000,
rbp: 0x8000,
rsi: ZERO_PAGE_ADDR,
rflags: 0x2,
..Default::default()
};
vcpu.set_regs(®s).unwrap();CPUIDはkvm.get_supported_cpuid()で取得したホストCPUの対応機能一覧をそのままゲストに渡しています。フィルタリングは行わず素通しです。ripはカーネルの64bitエントリオフセット(+0x200)、rsiはboot_params(zero page)へのポインタで、Linux x86 boot protocolでは「64bitエントリ時、rsiにzero pageのアドレスを入れておく」という規約になっています。
configure_long_mode_sregsでは、ページング有効化(CR0.PG)やPAE有効化(CR4.PAE)、ロングモード有効化(EFER.LME/EFER.LMA)といった制御レジスタと、64bitコードセグメント(Lビットを立てたセグメント)を設定しています。
sregs.cr0 = CR0_PE | CR0_MP | CR0_ET | CR0_NE | CR0_WP | CR0_AM | CR0_PG;
sregs.cr3 = PML4_ADDR;
sregs.cr4 = CR4_PAE;
sregs.efer = EFER_LME | EFER_LMA;cr3には先ほど構築したページテーブルのPML4アドレスを設定し、CPUが実際にそのページテーブルを参照するようにしています。
実行ループとI/Oハンドリング
ここまでの準備が整うと、vcpu.run()をループで呼び続けるだけでゲストが実際に動き始めます。
loop {
match vcpu.run().unwrap() {
VcpuExit::IoIn(port, data) => { /* ... */ }
VcpuExit::IoOut(port, data) => { /* ... */ }
VcpuExit::Hlt => { /* ... */ }
VcpuExit::Shutdown | VcpuExit::FailEntry(_, _) | VcpuExit::InternalError => { /* ... */ }
_ => {}
}
}in-kernel irqchip/PITがあるため、通常のHLTはKVM内部でブロックされ、VMMまでVcpuExit::Hltが返ってくるのは「二度と起きないことが確定した最終停止」の場合だけです。実際にVMMが継続的にハンドルする必要があるのはIoIn/IoOut(I/Oポート命令によるVM Exit)で、今回は最小限、次の3種類のポートだけを実装しています。
シリアル(COM1、0x3f8〜0x3ff): LSR(Line Status Register、0x3fd)の読み取りに対して常に「送信可能(THRE/TEMTビットが立っている)」を返し、THR(Transmitter Holding Register、0x3f8)への書き込みをそのままホストの標準出力へ書き出します。
5 => 0x60, // LSR: THRE | TEMT (送信可能)この最小実装でも、カーネル自身がprintkで出力するブートログ(console=ttyS0で有効化される、同期的なポーリングI/Oの経路)は最後まで正しく画面に流れます。一方で、initramfs内のユーザースペースプロセスがwrite()システムコールで出力する分は、この実装では画面に出ません。通常のtty経由の出力は、ハードウェア側が送信完了を知らせる割り込み(IRQ4)に依存して継続されるため、KVM_IRQ_LINEによる割り込み配線を行っていない現時点の実装では、書き込んだデータがドライバ内部のバッファに留まったまま先へ進みません。レジスタ単位で正確な16550エミュレーションと割り込み配線は、今後実装予定です。
CMOS/RTC(0x70/0x71): 実時刻を提供する必要はなく、カーネルがステータスレジスタA(インデックス0x0a)のUIP(Update In Progress)ビットを見て「更新中でない」と判断できれば十分です。
0x0a => 0x00, // Status Register A: UIP=0このスタブが無いと、カーネルはRTCの読み取り時に「更新中でなくなるまで待つ」ビジーループに入ったまま進まなくなります。未実装のI/Oポートに対して固定で0xffのような値を返してしまうと、UIPビットが常に1(更新中)に見えてこのループから永久に抜けられません。
i8042(0x64): キーボードコントローラのポートで、0xFEの書き込みはCPUリセット要求です。カーネルコマンドラインにreboot=kを指定しているため、リブート処理はこの経路を使います。VMM側ではこれを「ゲストからのクリーンな終了要求」として検知し、実行ループを抜けています。
initramfsとRust製init
initramfsは、カーネルが起動の最後にユーザースペースへ制御を渡す先を用意するためのものです。今回はbin/vmm-initという最小限のRustバイナリを1つだけ置き、/initをそこへの相対シンボリックリンクにしています。
initramfs/
├── init -> bin/vmm-init
└── bin/
└── vmm-init初期化処理は何も行わず、起動確認のためにreboot()システムコールを呼ぶだけです。
// src/bin/vmm-init.rs
fn main() {
unsafe {
libc::syscall(
libc::SYS_reboot,
libc::LINUX_REBOOT_MAGIC1,
libc::LINUX_REBOOT_MAGIC2,
libc::LINUX_REBOOT_CMD_RESTART,
0,
);
loop {}
}
}VMM本体(src/main.rs)と同じCargoプロジェクト内のsrc/bin/に置いているため、Cargo.tomlの追加や依存クレートの重複管理は不要です。ただしビルド方法は異なります。initramfsの中には動的リンカもCの共有ライブラリも置かないため、通常のビルドで生成される動的リンクの実行ファイルはゲスト内で実行できません。ゲスト用バイナリだけ、静的リンクを指定してビルドします。
cargo build --release
RUSTFLAGS="-C target-feature=+crt-static -C relocation-model=static" \
cargo build --release --bin vmm-init --target x86_64-unknown-linux-gnutarget-feature=+crt-staticはglibcを動的リンクではなく静的リンクする指定です。relocation-model=staticを付けない場合、静的リンクではあるものの実行時に自己再配置を行うstatic-pie形式になります。今回はより単純な、固定アドレスに配置される通常の静的実行ファイルにするため、両方を指定しています。--targetを明示すると、通常のホスト向けビルド成果物(target/release/)とは別のディレクトリ(target/x86_64-unknown-linux-gnu/release/)に出力されるため、2種類のビルド成果物が衝突することもありません。
initramfs内のシンボリックリンクは、必ず相対パスで作成する必要があります。ln -s bin/vmm-init initのように相対パスで作成すればゲスト内でも正しく解決されますが、ホスト側の絶対パスを埋め込んでシンボリックリンクを作成すると、ゲスト内には存在しないパスを指すリンクになり、/initのexecがENOENTで失敗します。
実行結果
console=ttyS0 reboot=k panic=-1 pci=offを指定して実行すると、カーネルの初期化ログがシリアル経由で最後まで流れます。
[ 0.000000] Linux version 5.15.0-187-generic ...
[ 0.000000] Command line: console=ttyS0 reboot=k panic=-1 pci=off
...
[ 0.094435] printk: console [ttyS0] enabled
...
[ 0.233694] Trying to unpack rootfs image as initramfs...
...
[ 0.417412] Run /init as init process
[ 0.418252] reboot: Restarting system
[ 0.418933] reboot: machine restart
guest requested reset (i8042), stopping VM
VM stopped.Run /init as init processのあと、initramfs内のRust製initが実行され、reboot()システムコールがカーネルのリブート処理(i8042経由のリセット要求)を起動し、VMMがそれを検知して終了しています。カーネル自身の初期化からユーザースペースへの制御移譲、そしてVMMによる検知までが一通り繋がったことを確認できました。
現時点の実装では、シリアルはTHR/LSRのみの最小実装、CMOSも固定値を返すだけのスタブです。ユーザースペースプロセスの出力をホストの画面へ届けるには、レジスタ単位で正確な16550エミュレーションと、KVM_IRQ_LINEによる割り込み配線が今後実装予定です。