Open6

Ymir: The x64 Type-1 Hypervisor

smallkirbysmallkirby

Surtr Bootloader ~ Ymir Page Allocator

暇なのでZigでType-1 Hypervisorを作ろうと思い、その前準備としてkernelの部分を実装中。

https://github.com/smallkirby/ymir

概略

  • とりあえずx64をサポート (他のアーキに対応するつもりはないが、とりあえずアーキに強く依存する部分だけレイヤーは分けてる)
  • UEFIのみ対応。UEFIは使用可能メモリ領域をダイレクトマップして、64bitモードにしてくれるところまでやってくれることを想定。
    • 開発はQEMUでOVMFを動かしてその上にブートローダをUEFIアプリとして載せる。
  • コードを書く上で名前が必要なので適当に:
    • Surtr: Bootloader
      • カーネルのロードが主な仕事。カーネルのELFが要求する仮想空間をマップして、そこにロードしてくれる。
    • Ymir: kernel部分

Surtr Bootloader

Zig (というかLLVM) がUEFIをちゃんとサポートしているため、特に難しいことはなし。

Ymir Page Allocator

とりあえず以下を実装した:

  • シリアル通信によるロギング
  • GDTの初期化
  • IDTの初期化。ハンドラの登録。
  • UEFI/Bootloaderが用意したページテーブルのクローン。
  • 物理メモリをオフセット付きでダイレクトマップ
  • ページアロケータ
  • PICの初期化
  • PITの初期化
  • キーボード入力

メモリマップに関しては、ユーザランドを作るつもりがないため、正直純粋なダイレクトマップだけでも問題がないはず。でもなんとなくLinuxの仮想メモリレイアウトに似たように作った。今の所こんな感じ:

(qemu) info mem
// Direct Mapping (物理アドレス 0x0 ... をすべてマップ)
ffff888000000000-ffff88801ea00000 000000001ea00000 -rw
ffff88801ea00000-ffff88801ec00000 0000000000200000 -r-
ffff88801ec00000-ffff88801f800000 0000000000c00000 -rw
ffff88801f800000-ffff88801fe00000 0000000000600000 -r-
ffff88801fe00000-ffff898000000000 000000ffe0200000 -rw
// Kernel Image (Ymirのイメージをマップ)
ffffffff80000000-ffffffff80039000 0000000000039000 -r-
ffffffff80039000-ffffffff80499000 0000000000460000 -rw

ページアロケータはUEFIから渡されたメモリマップをもとに、使用可能な領域をビットマップで管理するだけのシンプルなもの。
ページアロケータを初期化する前に、UEFIのページテーブルのクローンやダイレクトマップ領域の作成をする必要があり、ページテーブルのコピー・作成はそれ自体がアロケータを必要とするのがやや面倒。よって、正式なページアロケータを初期化する前に使うブートストラップ用のアロケータを実装し、ページテーブルの作成等はそれを使うようにした。
ブートストラップ用のアロケータはdeallocationをサポートしておらず、正式なページアロケータの初期化時にブートストラップアロケータを渡すことで、それまでに確保したページの管理を正式なページアロケータに委譲するようになっている。
Linuxのvmalloc()みたいなものは実装する予定がなく、このPageAllocatorは必ず物理的に連続したページを取得する (direct mapping領域から取得しているので、それはそう)。kmalloc()と一緒。

Ref

f55f34b

smallkirbysmallkirby

Heap Allocator ~ VMX root mode

Heap Allocator

前回で Page Allocator を実装した。この Page Allocator は UEFI から渡されたメモリマップをもとに物理ページをビットマップで管理する。確保したページは、0xFFFF_8880_0000_0000からマップされている Direct Mapping エリアの仮想アドレスで返却する。kmalloc()と同様に、連続された物理ページから確保されることが保証されており、かつ Virtual <=> Physical アドレス変換が以下のように容易にできる:

https://github.com/smallkirby/ymir/blob/ba9aa65b7984a6ed33cee91387a1f28c6560ac26/ymir/mem.zig#L41-L59

これをもとにして、小さいサイズも確保できる General Purpose Allocator を実装した。
こいつは、 Page Allocator を backing allocator として利用する。Linuxの SLUB allocator のように、あるページは固定サイズのチャンクのみを確保するようにしつつ、glibcの ptmalloc のように未使用のチャンクにポインタ入りのメタデータを持たせることで管理している:

https://github.com/smallkirby/ymir/blob/ba9aa65b7984a6ed33cee91387a1f28c6560ac26/ymir/mem/BinAllocator.zig#L146-L151

なお、Zigでは free() にはスライスを渡すため、開放したいメモリサイズが明確。よって、メタデータの中にはサイズをもたせる必要がなかった。また、チャンク同士の backward consolidation も今の所予定していないため、glibcのチャンクのように直前の free chunk のアドレスを記録しておく必要もなかった。

なお、Zigでは Allocator が割とZigにしてはhackyな方法で実装されており、 vtable とアロケータインスタンスを Allocator に渡すことで共通のAPIをもたせている:

https://github.com/smallkirby/ymir/blob/ba9aa65b7984a6ed33cee91387a1f28c6560ac26/ymir/mem/BinAllocator.zig#L9-L13

VMX root mode

kernel のコア部分が実装できたため、とりあえず VMX root mode に入ることにした。なお、今の所 SMP 非対応。
CPUID だったり、CR4だったり、MSRを見たりいじったりしてVMXがサポートされていることを確認。そのあと、vmxonで VMX root mode に入る:

https://github.com/smallkirby/ymir/blob/ba9aa65b7984a6ed33cee91387a1f28c6560ac26/ymir/arch/x86/vmx.zig#L54-L80

VMX instruction は CF/ZF を見ることで成功・失敗を判断できるが、vmxon後にCFがセットされてしまった。CFがセットされているのは、VMCS pointer is invalid という意味らしい。これを解決するのに1時間くらい費やしたが、結局vmxonの引数である&vmxon_physvmxon_physにしてしまっていたのが原因だった。
拡張インラインアセンブラ記法、いつまでたってもこの短い人生で覚えられる気がしません。

なお、VMX root mode に入っているかどうかはレジスタ等を見て判断することはできないという認識。
だが、VMX root mode ではない状態で vmxoff をすると #UD 例外になるため、vmxoff が成功すればちゃんと VMX root mode に入れたという証拠になる。

VMCS設定の準備

ここからがメインになる。その下準備として、vmread/vmwrite()における VMCS フィールドのエンコーディングを定義した:

https://github.com/smallkirby/ymir/blob/ba9aa65b7984a6ed33cee91387a1f28c6560ac26/ymir/arch/x86/vmcs.zig#L24-L98

ただただSDMとにらめっこしつつ、Copilotの力を借りて定義していった。

ba9aa65b7984a6ed33cee91387a1f28c6560ac26

smallkirbysmallkirby

VMCSの設定 ~ VMLAUNCH

エラーとVMREAD/VMWRITEの改良

前回まででVMCSの設定の準備ができたため、いよいよVMCSの設定をした。
このVMCSの設定がかなり厄介で、設定項目が本当に大量にある。正しくない設定をすると、以下の2種類のパターンでエラーが返される:

  • VMX Instruction Error: VMX命令自体のエラー。VMX命令が失敗すると、RFLAGSのCF/ZFのいずれかがセットされる。CFが設定されていると、VMCSを指すポインタ自体が不正という意味。ZFが設定されていると、VMCSのVM Instruction Errorフィールドに大まかなエラーの理由が書いてあるという意味。
  • VM-exit Reason: VMX non-root operation に入ったあとで、やっぱ状態おかしいわ〜と判断されてVM-exitした場合に通知されるエラー。VMCSのVM-exit Reasonフィールドに大まかな原因が入っている。

これを踏まえて vmread/vmwrite をする関数を改善すると以下の感じ:
https://github.com/smallkirby/ymir/blob/25af0ac87c67c3c2ea5c8e4646a0d71a9b9f65a3/ymir/arch/x86/vmx/vmcs.zig#L9-L26

RFLAGS を読んだ後、vmxtry()をすることで操作が成功しているかどうかを確認している。vmxtry()では、CF/ZFをチェックする:
https://github.com/smallkirby/ymir/blob/25af0ac87c67c3c2ea5c8e4646a0d71a9b9f65a3/ymir/arch/x86/vmx.zig#L21-L26

ちなみに、vmwrite()は以下のように改良した:
https://github.com/smallkirby/ymir/blob/25af0ac87c67c3c2ea5c8e4646a0d71a9b9f65a3/ymir/arch/x86/vmx/vmcs.zig#L28-L60

VMWRITE 命令のオペコード自体は、対象のVMCSフィールドのビット幅に関わらず常に64bitなのだが、この関数に渡すときは実際のフィールド幅で渡したいことがある。また、CR0を単にu64として見るのではなくstructとして渡せたほうが都合が良い場合もある。それらを考慮して、任意の(自然な幅の)整数型と、任意の(自然な幅を持つ)構造体を渡せるようにして、関数内部で適切にキャストするようにした。

VMCSの設定

とりあえずの目標は、VMX non-root operation に入ること。入った後はHLTでもNOPでもなんでも良いから命令が実行できていることを確認できればいい。

1. Execution Control

VMX non-root operation における命令実行について制御するフィールド群。 Pin-based Execution ControlPrimary Processor-based Execution Control の2つだけ設定すれば良い:
https://github.com/smallkirby/ymir/blob/25af0ac87c67c3c2ea5c8e4646a0d71a9b9f65a3/ymir/arch/x86/vmx.zig#L131-L144

どちらも基本的にはデフォルト(VMPTRLDで設定される値)のままで良い。唯一、HLTしたときにVM-exitするようにしておいた(デバッグ用)。
VMCSのフィールドには、特定のビットを必ずセット/クリアしておかなければならないものがある。それらの情報は、フィールドに対応するMSRに書いてある。adjustRegMandatoryBits()では、MSRの情報をもとにフィールドの値を調整してくれる:
https://github.com/smallkirby/ymir/blob/25af0ac87c67c3c2ea5c8e4646a0d71a9b9f65a3/ymir/arch/x86/vmx.zig#L364-L369

2. VM-Exit Control

VM-exit 時の挙動を制御するフィールド群。これもほぼデフォルトだが、VM-exit 時には64-bit modeになるようにだけ設定しておく:
https://github.com/smallkirby/ymir/blob/25af0ac87c67c3c2ea5c8e4646a0d71a9b9f65a3/ymir/arch/x86/vmx.zig#L152-L158

3. VM-Entry Control

VM-entry 時の挙動を制御するフィールド群。これもほぼデフォルトだが、ゲストは IA-32e モードで動くように設定する。EFERとPATに関するやつは、今回は設定しなくても良い:
https://github.com/smallkirby/ymir/blob/25af0ac87c67c3c2ea5c8e4646a0d71a9b9f65a3/ymir/arch/x86/vmx.zig#L166-L174

4. Host State

VM-exit した時にリストアするホストの情報。今回は、現在のホストのレジスタの状態をそのまま設定した。ただし、現在 Ymir では TR (Task Register) をセットしていないため NULL descriptor を指してしまっている。だが、VM-entryのチェックでなぜか TR selector が 0 でないことをチェックしているため、今回は適当に Kernel CS を指すようにしておいた。今回は使わないからこれでOK。

5. Guest State

VM-entry した時にリストアするゲストの情報。これがまじでめんどい。今回設定する必要があるのは以下:

  • Control Registers: CR0, CR3, CR4
  • Segment Registers: CS/SS/DS/ES/FS/GS, TR, GDTR, IDTR, LDTR
    • Base
    • Limit
    • Selector (とDPL)
    • Access Rights
  • RIP, RSP, RFLAGS
  • SYSENTER_CS / SYSENTER_ESP / SYSENTER
  • EFER
  • VMCS link pointer: 今回は使わないので maxInt(u64) をセットする必要がある

CRやセグメントレジスタについては、ホストのものをほとんどそのまま使うことにした。

Invalid Guest State 地獄

適当にVMCSを設定したあとでVMLAUNCHをしたら、ゲストに遷移した直後にVM-exitした。VM-exitをすると VMCS に VM-exit reason が書き込まれる:
https://github.com/smallkirby/ymir/blob/25af0ac87c67c3c2ea5c8e4646a0d71a9b9f65a3/ymir/arch/x86/vmx/error.zig#L53-L210

今回の原因は Invalid Guest State。原因がわかってるなら簡単だと思うかもしれないが、この理由でexitするケースは、200種類くらいある。すべてのケースについては Intel SDM Vol.3C 27.3.1 を参照。ちなみに、Guest Stateに対するチェックの失敗は、VMX Instruction Error と VM-exit の両方のパターンで発生する。今回は後者。

まあ仕方がないので、ありえるケースを全部確認した...。それでも解決せず、およそ3日ほどが経過。ふとしたことで、ホストのTR/LDT/GDT/IDTの値が他のセグメントとだいぶ異なることに気がつく:

(Selector, Base, Limit, Access Rights)
ES =0008 0000000000000000 ffffffff 00c09300 DPL=0 DS   [-WA]
CS =0010 0000000000000000 ffffffff 00a09b00 DPL=0 CS64 [-RA]
SS =0008 0000000000000000 ffffffff 00c09300 DPL=0 DS   [-WA]
DS =0008 0000000000000000 ffffffff 00c09300 DPL=0 DS   [-WA]
FS =0008 0000000000000000 ffffffff 00c09300 DPL=0 DS   [-WA]
GS =0008 0000000000000000 ffffffff 00c09300 DPL=0 DS   [-WA]
LDT=0000 0000000000000000 0000ffff 00008200 DPL=0 LDT
TR =0000 0000000000000000 0000ffff 00008b00 DPL=0 TSS64-busy
GDT=     ffffffff8054d010 0000007f
IDT=     ffffffff8054e000 00000fff

他は limit が 0xFFFF_FFFF なのに対し、4つのセグメントは違う値になっている。ちなみに LDT/TR は Ymir では今の所使っていないため、UEFIが勝手に設定した値 (NULL descriptor) のまま。
今までは selector は maxInt(u32) のように固定値を入れていたため、それがだめだったっぽい。ちゃんと SIDT や SGDT 命令を使って IDTR/GDTR を読み込み、その値を使うようにした、また、Access Rights についてもホストと全く同じになるように再確認した。

すると、Invalid Guest State にならなくなった...。正直どこのチェックで落ちたのかはあまり分かっていないため、これを踏まえた上であとで確認する。

一時的な VM-exit Handler

VM-exit した後にホストがどこから処理を開始するかは、VMCSのHost Stateフィールドに設定する。実用的には以下のケースがあると思う:

  • VM-exit ハンドラを関数として作成し、その関数のアドレスを設定。この場合、vmlaunchをする関数自体は noreturn 的な感じになる。
  • VMLAUNCH の直後にアドレスを設定。この場合、VMLAUNCHがただの関数呼び出しのように見える (ようにする必要がある)

今回はデバッグ用に前者としてハンドラを作成:
https://github.com/smallkirby/ymir/blob/25af0ac87c67c3c2ea5c8e4646a0d71a9b9f65a3/ymir/arch/x86/vmx.zig#L343-L362

何故かトランポリンを入れているが、今回は別に必要ない。 vmexitHandler()が値を返せるようにするためには必要。単純に VM-exit reason を取得して表示するだけ。RSPも設定する必要があるが、今回は適当に .bss に確保しておいてそれを使うようにした。

VMLAUNCH

いよいよVMLAUNCHのお時間。今回のゲストはこちら:
https://github.com/smallkirby/ymir/blob/25af0ac87c67c3c2ea5c8e4646a0d71a9b9f65a3/ymir/arch/x86/vmx.zig#L118-L124

nopループするだけ。この関数のアドレスをVMCSの Guest State の RIP フィールドに設定すると、VM-entry 後にここから処理がスタートする。 cli をしている理由は、現在は割り込み用の諸々の設定をしておらず、割り込みが来てほしくないから。

VMLAUNCHをした結果がこちら:

ちゃんと無限ループしてくれているため、何も起こらなくなる。QEMU monitorで見てみると、RSPの値がちゃんとVMCSに設定した 0xDEAD0000 になっている。
また、nopの変わりに HLT をすると以下のようになる:

[INFO ] (main): Entered VMX root operation.
[INFO ] (main): Entering VMX non-root operation...
[DEBUG] (vmx): [VMEXIT handler]
[DEBUG] (vmx):    VMEXIT reason: arch.x86.vmx.error.ExitInformation{ .basic_reason = arch.x86.vmx.error.ExitReason.hlt, ._zero = 0, ._reserved1 = 0, ._one = 0, .pending_mtf = 0, .exit_vmxroot = false, ._reserved2 = 0, .entry_failure = false }

ちゃんと VM-exit reason が HLT になっていることが分かる。

かなり疲れたけど、ひとまず VMX non-root operation に遷移することには成功した。
定義系のコードがかなり汚くなっているので整理したり、今後実装するべきことを整理するつもりだったけど、それはまた今度で。
京都、暑すぎ。

smallkirbysmallkirby

空のTRの設定

前回、VMLAUNCH後に Invalid Guest State でエラーになっていたと書いた。GDTR/LDTR/IDTRやTRが問題だったのだが、最初の3つはとりあえずホストのものを設定した。しかし、TR (Task Register) に関してはホストのTR自体が NULL descriptor を指している (selector = 0) 。これは、Ymirが今のところ TR を使っていないため UEFI が設定したものをそのまま使っていることによる。SDMによると、VM-entry 時にTRのセレクタが0であることは不正であるため、このままではゲストに入ることができない。

ということで、ブート時にTRを設定だけすることにした:
https://github.com/smallkirby/ymir/blob/d8e1e6186b109ffe090fe9da7be02a4dd8fd8c49/ymir/arch/x86/gdt.zig#L49-L54

直近で使う予定がないため、base=0 / limit=0 に設定している。実際のTSS (Tasg State Segment) も準備していない。とりあえず今のところはこれで動いているからOK。

VM-entry / VM-exit 時のゲストレジスタの設定・退避

前回までで VMX non-root operation に入って hlt 命令を実行することはできた。次はゲストの状態を管理することにする。
そもそも、 VMX non-root / root の状態遷移においてHWが勝手にロード・ストアしてくれるレジスタは限られている。代表的なものだと、RSP/RIP/RFLAGS/一部のMSRなど。その他の一般的なレジスタ群(RAXとかRBPとか)は全く管理してくれないため、ソフトウェア的に管理する必要がある。

VM-entry

VM-entry のエントリポイントは、 struct Vcpu の以下のメソッド:
https://github.com/smallkirby/ymir/blob/d8e1e6186b109ffe090fe9da7be02a4dd8fd8c49/ymir/arch/x86/vmx.zig#L32-L44
https://github.com/smallkirby/ymir/blob/d8e1e6186b109ffe090fe9da7be02a4dd8fd8c49/ymir/arch/x86/vmx.zig#L85-L98

self.asmVmEntry() に処理を委譲しているだけ。メインの処理はこっち:
https://github.com/smallkirby/ymir/blob/d8e1e6186b109ffe090fe9da7be02a4dd8fd8c49/ymir/arch/x86/vmx.zig#L100-L180

ほぼアセンブリで書かれている。
まず、Zig の通常の calling convention がどうなっているか分からないため、SystemV の calling convention を指定する。調べたことはないが、これをつけないと引数の種類等によって呼び出し方法に最適化がかかるのではないかと思っている。
calling convention には .Naked というものもある。これを指定すると関数のプロローグがなくなるのだが呼び出しもアセンブラで書く必要があるため、今回は SystemV 方式で妥協した。おそらく .Naked の方がいい。
アセンブラでやっていることは以下:

  • callee saved なレジスタ (R12-R15 + RBX) の保存
  • 引数である &self.guest_regs をpush
  • setHostStack() で、現在のRSPを VMCS Host State の RSP に設定する。これにより、VM-exit 後にこのRSPで復帰するようになる。
  • self.launch_done が 0 かどうかを RFLAGS に保存しておく。これは後に VMLAUNCH と VMRESUME のどちらを呼ぶべきかの判断に使われる。
  • self.guest_regs (ゲストレジスタ群) をレジスタにセットする。
  • VMLAUNCH か VMRESUME のどちらかを呼ぶ

本来、拡張インラインアセンブリ記法では displacement にはリテラルを指定することができないが、Zigには std.fmt.cmptimePrint() があるためある程度 computic に値を指定することができる。今回は、 %rax が指す struct GuestRegister における各フィールドのオフセットを displacement に指定するために使用している。これによって、 GuestRegister のフィールド順序を変更したり、フィールドを追加したとしても、このアセンブリを書き換える必要がない。素晴らしい。

Host RSP を設定する箇所では、 callHostStack() という関数を呼ぶ必要がある都合上 RDI を一旦スタックに保存している。こうすると RSP が本来の値より 8 だけ小さくなってしまうため、LEA 8(%%RSP), %%RDI で 8 だけ加算して callHostStack() に保存している。

VM-exit

VM-exit が起こると、VMCS Host State の RIP に指定された値からホストの処理が開始する。ここには、以下の関数のアドレスを指定している:
https://github.com/smallkirby/ymir/blob/d8e1e6186b109ffe090fe9da7be02a4dd8fd8c49/ymir/arch/x86/vmx.zig#L190-L263

このハンドラに飛んだ時点で、HW的に管理されないレジスタ群はゲストのものがそのままになっている。そこで、まず RAX をスタックに保存。
その後、VM-entry エントリポイントでスタックに積んでおいた &self.guest_regs をRAXに入れて、必要なすべてのゲストレジスタを GuestRegister 構造体に保存する。この処理は、諸々をやってレジスタを汚す前に何よりも先に行う必要がある。
これが終わったら、 callee saved なレジスタを復元する。その後、RSPに0x10だけ足す。これは、 VM-entry の関数のプロローグにおいてコンパイラが sub %rsp, $0x10 するようなコードを入れるからである。正直、このプロローグコードがZig(LLVM)のアップデートに応じて変化する可能性も0ではないため、やはり VM-entry 関数も .Naked にするのが良いのかもしれない。
それが済んだら、あとは RBP を復元して ret するだけ。

VM-entry / -exit handler

さて、VM-entry ~ VM-exit の流れの実装には以下の2つの方針があると思う:

  1. VM-exit ハンドラの中で、また VM-entry を呼ぶ。感覚的には、VM-entry 関数自体は noreturn 扱いになる。
  2. VM-exit ハンドラの中でいい感じにスタックとかを操作して、VM-entry のcallerに戻る。感覚的には、VM-entry がただの関数呼び出しと同じになる。今回のパターン。

今回は 2 のパターンを選択した。これにより、 asmVmEntry() の呼び出し後に通常通り処理を続行できる(ように見える)。今はまだ実装していないが、呼び出し後に VM-exit Reason を読み取って、適切なハンドラを呼び出せば良い。

5行のゲスト

現在のゲストコードは5行(+jmp)だけ:
https://github.com/smallkirby/ymir/blob/d8e1e6186b109ffe090fe9da7be02a4dd8fd8c49/ymir/arch/x86/vmx.zig#L342-L352

前回同様にcli/hltをする他、RAX/RBX/R15に測地を代入している。vmentry()で保存したゲストの状態を表示するとこのような感じ:

ちゃんとゲストで代入した値が、ホストでも見えていることが分かる。ちゃんとゲスト状態のload/storeができている証拠。

今後の予定

とりあえず Linux をブートするのはもっと後の話。とりあえずはホストの状態 (メモリ空間・RIP・MSR等)をそのまま引き継いだゲストを動かしつつ、必要な処理を実装していくことにする。

まずはゲストの中でシリアルコンソールで文字を出力することを目的とする。これにより、基本的な exit handler・PIC仮想化・シリアルコンソール仮想化等が出来上がると思う。

d8e1e6186b109ffe090fe9da7be02a4dd8fd8c49

smallkirbysmallkirby

方針転換

前回の最後で、とりあえずの目標をシリアルコンソールの仮想化とした。その準備として仮想化なしで直接ゲストにシリアルを触らせてログを出力させたところ、何も苦労することなくログ出力ができた。
その後、全てのI/OアクセスでVM-exitするようにコントロールフィールドを設定し、いい感じにシリアルへのI/Oをハンドルすることもできた。

ただ、今書こうとしているのが極限まで薄いハイパーバイザであるため、シリアルの仮想化をするモチベーションがそこまで湧かなかった。別にシリアルは直接触らせつつ Ymir 側でシリアルを使うこともできるといえばできる (勿論、ゲスト側にシリアルの設定をいじられたり、勝手にEOIを通知されるとバグることはありそうだが。そのへんは必要が出てきた時に初めて対応すれば良さそう)

というわけで、直近の目標を変更。Linuxがとりあえずブートしようとすることを目標にする。

VM-entry の修正

前回までで VM-entry はできるようになっていたが、その処理の一部に不具合があった。というのも、 VMLAUNCH/VMRESUME 命令は、「それ自体が」失敗し得るということを考慮し忘れていた。VMLAUNCH命令自体が成功したが、その後の guest state チェックで失敗したという場合には VM-Exit をする。この場合、ただの VM-exit であるため Host RIP は VMCS に設定したアドレスに設定され、VM-exit handler が実行される。これは前回までで実装できている。

ただ、VMLAUNCH 自体が失敗した場合には、通常の命令実行フロート同様にVMLAUNCH命令の直後から命令が実行されることになる。VMX Instruction Error である。前回までのVMLAUNCH関数では、必ず VM-exit に飛ぶことを想定して noreturn 扱いをしていたため、VMLAUNCH が失敗した場合はUD になる。

というわけで、新しいハンドラはこちら:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/arch/x86/vmx.zig#L213-L330

変更点として、 u8 を返す関数にした。VMLAUNCH/VMRESUME 後に処理が続行した場合には VMX Instruction Error が発生したとみなし、返り値を 1 に設定する。逆に VM-exit handler には返り値を 0 にする処理を入れた。また、VM-exit handler と同様にスタックを破棄して呼び出し元に戻るための復帰処理を追加した。最後に return 0 を入れているが、これは Zig がインラインアセンブラの ret を認識できないため、エラーを抑えるために入れているダミーコードである。
呼び出し側では、以下のように成功を判断することができる:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/arch/x86/vmx.zig#L178

EPT の設定

Linux をロードするにあたって必要なものが、EPTの設定。まずはホスト側でゲスト用のページを物理的に連続して確保する。今はとりあえず 100MiB 固定で確保:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/main.zig#L138-L145

それをもとにして EPT を初期化する。なお、 Ymir のページアロケータが返すアドレスは仮想アドレスだが、これは virt2phys() で簡単に物理アドレスに変換できる:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/arch/x86/vmx.zig#L108-L119

EPT の構造自体はほぼ普通のページテーブルと同じであるため、実装するのがとても面白くなかった。現在のところゲストページを細かく制御するつもりはないため、全てのページを 2MiB ページで登録することにした。EPT を初期化したら、EPTP を VMCS に設定して設定完了。

ゲストイメージの読み込み

ゲストイメージをロードするためには、イメージをまず読み込む必要がある。ゲストイメージを FS のどこに置くかは諸説あるが、現在のところ Ymir はFSを実装していないため、UEFIに任せるためにブートパーティションの /EFI 直下に置くことにした。

というわけで、Surtr 側にゲストイメージをロードして Ymir に渡す処理を入れる:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/surtr/boot.zig#L209-L249

ロードしたゲストイメージの情報は、ブートパラメタとして Ymir に渡す:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/surtr/defs.zig#L7-L14

なお、Surtr が動いている段階ではメモリマップはストレートマップになっているため、ブートパラメタに入っているアドレスも物理アドレスである。よって、 Ymir 側でアドレスを使う際には以下のように仮想アドレスに変換する:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/main.zig#L147-L151

ゲストイメージのロードとLinuxブートパラメタの設定

ロードするゲストのイメージが手に入ったため、これをゲストメモリにロードする。
Linux には、カーネルに処理が渡った時点のアドレッシングモードに応じてブートプロトコルが存在する。32bit protected mode では boot_params という情報を適切に埋めた上でカーネルに処理を渡す必要がある。

以前にKVMベースのVMMを書いた際にこの辺の処理は実装したことがあるため、それらのコードをほぼそのまま使うことができた:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/arch/x86/vmx.zig#L121-L170

Control Fields / Guest State の設定

EPT を使い、かつ 32-bit protected mode にする上でVMCSの値を変更する必要があった。

Execution Control で EPT を有効化:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/arch/x86/vmx.zig#L523-L530

VM-entry Control で IA-32e モードを無効化:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/arch/x86/vmx.zig#L552-L566

Guest State の CR0/CR4 をクリア。CR.PE と CR0.NE だけ有効化してページングを有効にする。また、PAEページングは無効化する。CR4.VMXEについては後述:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/arch/x86/vmx.zig#L599-L608

MSRの一部をクリア:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/arch/x86/vmx.zig#L693-L696

セグメントの設定。長いので省略。基本は全部リミットを0・ベースを0・リミットを最大にするだけ。

地獄の Invalid Guest State 編 パート2

ここまでで、以下ができている:

  • EPTの設定: ゲストPAをホストPAにマップ。ゲストPAは0から2MiB。
  • ゲストのロード: Linuxの必要な情報を設定した上で、イメージをゲストメモリにロード。
  • RIP/RSPの設定: RIP0x10_000 に、RSP`はLinuxコマンドライン情報が入ったアドレスに設定。

これでゲストに入ると 0x10_000 の命令から実行を開始するはずである。
だが、いざ VMLAUNCH してみるとまたもや Invalid Guest State で VM-entry に失敗する (厳密には、すぐ VM-exit する)。

よって、SDMとにらめっこして、それなりにちゃんと guest state をチェックしてくれる関数を書いた:
https://github.com/smallkirby/ymir/blob/15536da3f8d14c4889b71bcdcb96137666bb7ddf/ymir/arch/x86/vmx.zig#L702-L900

地獄みたいなコードだ。結局最後まで間違えていたのは、CR4の値が IA32_VMX_CR4_FIXED0 が示す成約を満たしていなかったこと。このレジスタで 1 になっているビットは、CR4でもセットされている必要がある。今回は、CR4.VMXEビットがセットされていなかったことによるエラーだった。

ゲストへの遷移

設定ができたので、実行して 0x10_000 にブレイクポイントを貼る:

ちゃんと 0x10_000 に遷移していることが分かる。RSP も設定した値になっている。si によるステップ実行もできる。ちゃんとゲストが動き出した。

VM-entry とステップ実行

VMLAUNCH にブレイクポイントを貼ったあとステップ実行をしたが、VM-exit handler まで飛んでしまった。VMLAUNCH や VMRESUME 命令はステップ実行できない?確かにソフトウェア的なBPの貼り方(命令を書き換える)だと、デバッガがVMCSを適切に読んで次の RIP を探してくれない限りは無理な気がする。その場合でも、 VMLAUNCH 自体が失敗して VMLAUNCH の直後に処理が続く可能性もあるので、BPは2箇所に設定する必要があり、たしかにめんどくさそう。

デバッグとアドレス空間

ステップ実行ではなく、アドレス指定でハードウェアBPを仕掛けるとちゃんと動く。
しかし、デバッガは設定したBPがゲストのためのものかホストのためのものかを知らない。今回の場合、 0x10_000 に設定したBPはゲスト空間における物理アドレスであるが、実際にはホスト上の物理アドレスにEPTでマップされている。
Ymir はアドレスレイアウトがLinuxに似たようになっている。そのため、BPを設定した時にゲストとホスト(Ymir)が両方その部分を実行してしまう可能性がある (勿論、その(ゲスト|ホスト)仮想アドレスがマップされるホスト物理アドレスは異なる)。これはデバッグが面倒になるため、Ymir 側のアドレスレイアウトを Linux と被らないように変更したほうが良いかもしれない。

今後の予定: CPUID

ゲストLinuxの実行を続けると、以下のような VM-exit で処理が止まる:

ゲストがCPUIDを実行したため VM-exit が起きている。直近の目標としては、CPUIDを適切にエミュレーションすることとする。

15536da3f8d14c4889b71bcdcb96137666bb7ddf

smallkirbysmallkirby

EPT のバグ修正

前回のEPT実装でバグがあった。現在はメモリ節約のため && 今の所細かく権限設定をしていないため、ゲストページは全て2MiBページでマッピングしていた。つもりだったが、実際にマップするところで誤って4KiB間隔でマップしてしまっていたので、修正:
https://github.com/smallkirby/ymir/commit/230967de5ab5e6abf6a809a0904b4758cde5fb0f

Linux 32bit Protected Boot Protocol でのバグ修正

Ymir は 32bit Protected Mode におけるブートプロトコルに従って struct boot_params を初期化してLinuxに処理を移している。本来であればブートローダ (今回はYmir) が RSP に struct boot_params のアドレスをセットしなければならないのだが、誤って RSI にセットしてしまっていたので修正:
https://github.com/smallkirby/ymir/commit/453856fe25a48350762ca7cb36de6797669bb168

IA-32e mode への遷移

Linux は最初に bzImage の圧縮されていないコード領域に置いてある startup_32 から処理を開始する。その中で IA32-e モードに遷移するのだが、VMXではゲストが IA32-e モードであるかどうかはVMCS Entry Control Field で管理するため、処理をフックする必要がある。

具体的には、 CR0.PG (paging) && CR4.PAE (Physical Address Extension) であり、かつ EFER.LME がセットされている場合に IA32-e モードであるとみなす。よって、CRレジスタへの処理をフックし、そこで上の条件を満たしているかどうかを判定する必要がある。満たしている場合には、 VMCS Entry Control の IA-32e をセットして、かつ EFER.LMA もセットする:
https://github.com/smallkirby/ymir/blob/15e6a0df3e52d82cd62576827add06c79568d558/ymir/arch/x86/vmx/cr.zig#L34-L49

Ymir での XSAVE feature の有効化

つまずいたところ

Linuxが IA-32e モードに入ることには成功したが、以下の警告が出た:

[    0.000000][    T0] ------------[ cut here ]------------
[    0.000000][    T0] XSAVE consistency problem: size 840 != kernel_size 576
[    0.000000][    T0] WARNING: CPU: 0 PID: 0 at arch/x86/kernel/fpu/xstate.c:617 fpu__init_system_xstate+0x4ad/0xa9a
[    0.000000][    T0] Modules linked in:
[    0.000000][    T0] CPU: 0 PID: 0 Comm: swapper Tainted: G        W          6.2.0-dirty #130 937909638ed442137e9e9ece9324618350b5db70
[    0.000000][    T0] RIP: 0010:fpu__init_system_xstate+0x4ad/0xa9a
[    0.000000][    T0] Code: 23 fa ff ff 41 39 dd 74 31 80 3d 96 4c fa ff 00 75 1a 44 89 ea 89 de 48 c7 c7 f0 0c bf 81 c6 05 81 4c fa ff 01 e8 b8 5f 7c ff <0f> 0b e8 f6 f9 ff ff 41 39 dd 0f 85 8c 02 00 00 8b 45 d0 48 8b 3d
[    0.000000][    T0] RSP: 0000:ffffffff81e03e10 EFLAGS: 00010082 ORIG_RAX: 0000000000000000
[    0.000000][    T0] RAX: 0000000000000000 RBX: 0000000000000348 RCX: 0000000000000000
[    0.000000][    T0] RDX: 0000000000000002 RSI: 0000000000000096 RDI: 00000000ffffffff
[    0.000000][    T0] RBP: ffffffff81e03e58 R08: 80000000fffff060 R09: 0000000000ffff0a
[    0.000000][    T0] R10: 0000000000000002 R11: 20657a69735f6c65 R12: 0000000000000001
[    0.000000][    T0] R13: 0000000000000240 R14: 0000000000000207 R15: 0000000000000001
[    0.000000][    T0] FS:  0000000000000000(0000) GS:ffffffff81f09000(0000) knlGS:0000000000000000
[    0.000000][    T0] CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[    0.000000][    T0] CR2: ffff888000010000 CR3: 0000000001f76000 CR4: 00000000000406a0
[    0.000000][    T0] Call Trace:
[    0.000000][    T0]  <TASK>
[    0.000000][    T0]  fpu__init_system+0x13a/0x165

XSAVE に関して何らかの不整合があるらしい。Linuxの該当箇所はこちら:
https://github.com/torvalds/linux/blob/c763c43396883456ef57e5e78b64d3c259c4babc/arch/x86/kernel/fpu/xstate.c#L582-L605

XSAVE は、浮動小数点数レジスタ等を一括でセーブするための命令。この辺のレジスタはCPU世代が上がるにつれてどんどん数やビット幅が増えていくが、その度に専用の命令を用意するのはめんどうなので、共通して XSAVE/SRSTORS 命令でセーブ・リストアしようという目的。X87FPU/MMX/SSE/AVXなど、どの機能がサポートされているかは CPUID を使って読み取ることになっている。この辺のざっくりとした理解は以下を読むのが良さそう:
https://zenn.dev/tanakmura/articles/80391e3284c6bb

今回の警告では、 size が CPUID(leaf=0xD, sub=0x0).EBX で得られる値であり、サポートされている全ての機能を保存するために必要な XSTATE バッファサイズ。 kernel_size はCPUで有効化されている全ての機能を保存するために必要な XSTATE バッファサイズを表している (多分、コード読んだ感じ)。
使ってるPC (QEMU/KVMで開発しているので、Ymirのホストマシン) で cpuid を見てみると、以下のようになっている:

   XSAVE features (0xd/0):
      XCR0 valid bit field mask               = 0x0000000000000207
         x87 state                            = true
         SSE state                            = true
         AVX state                            = true
         MPX BNDREGS                          = false
         MPX BNDCSR                           = false
         AVX-512 opmask                       = false
         AVX-512 ZMM_Hi256                    = false
         AVX-512 Hi16_ZMM                     = false
         PKRU state                           = true
         XTILECFG state                       = false
         XTILEDATA state                      = false
      bytes required by fields in XCR0        = 0x00000a88 (2696)
      bytes required by XSAVE/XRSTOR area     = 0x00000a88 (2696)

XCR0 という拡張コントロールレジスタが、有効化されている機能のビットマップを保持しており、今回は 0x207 になっている。必要なバッファサイズは 0xA88
しかし、Ymir で CPUID を読んでみると以下のようになっていた:

[    0.000000][    T0] ---[ end trace 0000000000000000 ]---
[    0.000000][    T0] CPUID[0d, 00]: eax=00000207 ebx=00000240 ecx=00000a88 edx=00000000
[    0.000000][    T0] CPUID[0d, 01]: eax=0000000f ebx=00000240 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 02]: eax=00000100 ebx=00000240 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 03]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 04]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 05]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 06]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 07]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 08]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 09]: eax=00000008 ebx=00000a80 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 0a]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 0b]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 0c]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 0d]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 0e]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 0f]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 10]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 11]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 12]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 13]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 14]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 15]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 16]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 17]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 18]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 19]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 1a]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 1b]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000
[    0.000000][    T0] CPUID[0d, 1c]: eax=00000000 ebx=00000000 ecx=00000000 edx=00000000

CPUID[0d, 00] を見ればよく、ビットマップは 207・バッファサイズは0x240になっている。ビットマップは同じだが、バッファサイズが小さい。

ホストとYmirでこの値が違う原因に少し迷い、KVMが悪いんじゃないかなどと疑ってみたりもしたが、最終的には XSAVE features を有効化し忘れていたことに気づいた。
CPUID で得られるビットマップは、あくまでも「サポートされている」機能の一覧なのだが、これを「有効化」するためには XCR0 レジスタを操作してあげる必要がある。そしてややこしいことに、 CPUID で得られるバッファサイズは「有効化されている」機能を保存するためのサイズであるため、XCR0w操作することではじめて更新されることになる。この2つを混同させてるの、何。

Ymir での XSAVE feature 有効化

XCR0 レジスタを操作するためには XSETBV 命令を使うが、この命令を使うためには CR4.OSXSAVE を建てる必要がある。その後、CPUID で得られたサポートされている機能を全てXCR0にセットして有効化する:
https://github.com/smallkirby/ymir/blob/15e6a0df3e52d82cd62576827add06c79568d558/ymir/arch/x86/arch.zig#L92-L103

すると、無事にFPU設定の初期化が通るようになった:

現状と今後の予定

現在、I/Oは全くフックしていない。よって、シリアルコンソールはゲストにそのまま使わせている。仮にゲストがYmirと異なる設定をすると、どちらかが壊れることにはなるが、今の所うまく行っている。
また、EPTでMMIOの設定も全くしていないため、APIC周りも全く。それから、PCIも同様に全くサポートできていない。

この後は、ひとまずLinuxがこれらの処理を開始するまで CPUID や MSR のフックを対応していき、必要に応じて他の仮想化もしていこうと思う。

そういえば、現在は VM-entry/exit 時にホストの xmm レジスタを退避・復帰してないけど、やる必要がある気がするな。

15e6a0df3e52d82cd62576827add06c79568d558