Ymir: The x64 Type-1 Hypervisor
Surtr Bootloader ~ Ymir Page Allocator
暇なのでZigでType-1 Hypervisorを作ろうと思い、その前準備としてkernelの部分を実装中。
概略
- とりあえずx64をサポート (他のアーキに対応するつもりはないが、とりあえずアーキに強く依存する部分だけレイヤーは分けてる)
- UEFIのみ対応。UEFIは使用可能メモリ領域をダイレクトマップして、64bitモードにしてくれるところまでやってくれることを想定。
- 開発はQEMUでOVMFを動かしてその上にブートローダをUEFIアプリとして載せる。
- コードを書く上で名前が必要なので適当に:
-
Surtr: Bootloader
- カーネルのロードが主な仕事。カーネルのELFが要求する仮想空間をマップして、そこにロードしてくれる。
- Ymir: kernel部分
-
Surtr: Bootloader
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
Heap Allocator ~ VMX root mode
Heap Allocator
前回で Page Allocator を実装した。この Page Allocator は UEFI から渡されたメモリマップをもとに物理ページをビットマップで管理する。確保したページは、0xFFFF_8880_0000_0000
からマップされている Direct Mapping エリアの仮想アドレスで返却する。kmalloc()
と同様に、連続された物理ページから確保されることが保証されており、かつ Virtual <=> Physical アドレス変換が以下のように容易にできる:
これをもとにして、小さいサイズも確保できる General Purpose Allocator を実装した。
こいつは、 Page Allocator を backing allocator として利用する。Linuxの SLUB allocator のように、あるページは固定サイズのチャンクのみを確保するようにしつつ、glibcの ptmalloc のように未使用のチャンクにポインタ入りのメタデータを持たせることで管理している:
なお、Zigでは free()
にはスライスを渡すため、開放したいメモリサイズが明確。よって、メタデータの中にはサイズをもたせる必要がなかった。また、チャンク同士の backward consolidation も今の所予定していないため、glibcのチャンクのように直前の free chunk のアドレスを記録しておく必要もなかった。
なお、Zigでは Allocator
が割とZigにしてはhackyな方法で実装されており、 vtable とアロケータインスタンスを Allocator
に渡すことで共通のAPIをもたせている:
VMX root mode
kernel のコア部分が実装できたため、とりあえず VMX root mode に入ることにした。なお、今の所 SMP 非対応。
CPUID だったり、CR4だったり、MSRを見たりいじったりしてVMXがサポートされていることを確認。そのあと、vmxon
で VMX root mode に入る:
VMX instruction は CF/ZF を見ることで成功・失敗を判断できるが、vmxon
後にCFがセットされてしまった。CFがセットされているのは、VMCS pointer is invalid という意味らしい。これを解決するのに1時間くらい費やしたが、結局vmxon
の引数である&vmxon_phys
をvmxon_phys
にしてしまっていたのが原因だった。
拡張インラインアセンブラ記法、いつまでたってもこの短い人生で覚えられる気がしません。
なお、VMX root mode に入っているかどうかはレジスタ等を見て判断することはできないという認識。
だが、VMX root mode ではない状態で vmxoff
をすると #UD
例外になるため、vmxoff
が成功すればちゃんと VMX root mode に入れたという証拠になる。
VMCS設定の準備
ここからがメインになる。その下準備として、vmread/vmwrite()
における VMCS フィールドのエンコーディングを定義した:
ただただSDMとにらめっこしつつ、Copilotの力を借りて定義していった。
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
をする関数を改善すると以下の感じ:
RFLAGS を読んだ後、vmxtry()
をすることで操作が成功しているかどうかを確認している。vmxtry()
では、CF/ZFをチェックする:
ちなみに、vmwrite()
は以下のように改良した:
VMWRITE 命令のオペコード自体は、対象のVMCSフィールドのビット幅に関わらず常に64bitなのだが、この関数に渡すときは実際のフィールド幅で渡したいことがある。また、CR0を単にu64
として見るのではなくstruct
として渡せたほうが都合が良い場合もある。それらを考慮して、任意の(自然な幅の)整数型と、任意の(自然な幅を持つ)構造体を渡せるようにして、関数内部で適切にキャストするようにした。
VMCSの設定
とりあえずの目標は、VMX non-root operation に入ること。入った後はHLTでもNOPでもなんでも良いから命令が実行できていることを確認できればいい。
1. Execution Control
VMX non-root operation における命令実行について制御するフィールド群。 Pin-based Execution Control と Primary Processor-based Execution Control の2つだけ設定すれば良い:
どちらも基本的にはデフォルト(VMPTRLDで設定される値)のままで良い。唯一、HLTしたときにVM-exitするようにしておいた(デバッグ用)。
VMCSのフィールドには、特定のビットを必ずセット/クリアしておかなければならないものがある。それらの情報は、フィールドに対応するMSRに書いてある。adjustRegMandatoryBits()
では、MSRの情報をもとにフィールドの値を調整してくれる:
2. VM-Exit Control
VM-exit 時の挙動を制御するフィールド群。これもほぼデフォルトだが、VM-exit 時には64-bit modeになるようにだけ設定しておく:
3. VM-Entry Control
VM-entry 時の挙動を制御するフィールド群。これもほぼデフォルトだが、ゲストは IA-32e モードで動くように設定する。EFERとPATに関するやつは、今回は設定しなくても良い:
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 が書き込まれる:
今回の原因は 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がただの関数呼び出しのように見える (ようにする必要がある)
今回はデバッグ用に前者としてハンドラを作成:
何故かトランポリンを入れているが、今回は別に必要ない。 vmexitHandler()
が値を返せるようにするためには必要。単純に VM-exit reason を取得して表示するだけ。RSPも設定する必要があるが、今回は適当に .bss に確保しておいてそれを使うようにした。
VMLAUNCH
いよいよVMLAUNCHのお時間。今回のゲストはこちら:
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 に遷移することには成功した。
定義系のコードがかなり汚くなっているので整理したり、今後実装するべきことを整理するつもりだったけど、それはまた今度で。
京都、暑すぎ。
空の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を設定だけすることにした:
直近で使う予定がないため、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
の以下のメソッド:
self.asmVmEntry()
に処理を委譲しているだけ。メインの処理はこっち:
ほぼアセンブリで書かれている。
まず、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 に指定された値からホストの処理が開始する。ここには、以下の関数のアドレスを指定している:
このハンドラに飛んだ時点で、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つの方針があると思う:
- VM-exit ハンドラの中で、また VM-entry を呼ぶ。感覚的には、VM-entry 関数自体は
noreturn
扱いになる。 - VM-exit ハンドラの中でいい感じにスタックとかを操作して、VM-entry のcallerに戻る。感覚的には、VM-entry がただの関数呼び出しと同じになる。今回のパターン。
今回は 2 のパターンを選択した。これにより、 asmVmEntry()
の呼び出し後に通常通り処理を続行できる(ように見える)。今はまだ実装していないが、呼び出し後に VM-exit Reason を読み取って、適切なハンドラを呼び出せば良い。
5行のゲスト
現在のゲストコードは5行(+jmp)だけ:
前回同様にcli
/hlt
をする他、RAX/RBX/R15に測地を代入している。vmentry()
で保存したゲストの状態を表示するとこのような感じ:
ちゃんとゲストで代入した値が、ホストでも見えていることが分かる。ちゃんとゲスト状態のload/storeができている証拠。
今後の予定
とりあえず Linux をブートするのはもっと後の話。とりあえずはホストの状態 (メモリ空間・RIP・MSR等)をそのまま引き継いだゲストを動かしつつ、必要な処理を実装していくことにする。
まずはゲストの中でシリアルコンソールで文字を出力することを目的とする。これにより、基本的な exit handler・PIC仮想化・シリアルコンソール仮想化等が出来上がると思う。
d8e1e6186b109ffe090fe9da7be02a4dd8fd8c49
方針転換
前回の最後で、とりあえずの目標をシリアルコンソールの仮想化とした。その準備として仮想化なしで直接ゲストにシリアルを触らせてログを出力させたところ、何も苦労することなくログ出力ができた。
その後、全ての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 になる。
というわけで、新しいハンドラはこちら:
変更点として、 u8
を返す関数にした。VMLAUNCH/VMRESUME 後に処理が続行した場合には VMX Instruction Error が発生したとみなし、返り値を 1
に設定する。逆に VM-exit handler には返り値を 0
にする処理を入れた。また、VM-exit handler と同様にスタックを破棄して呼び出し元に戻るための復帰処理を追加した。最後に return 0
を入れているが、これは Zig がインラインアセンブラの ret
を認識できないため、エラーを抑えるために入れているダミーコードである。
呼び出し側では、以下のように成功を判断することができる:
EPT の設定
Linux をロードするにあたって必要なものが、EPTの設定。まずはホスト側でゲスト用のページを物理的に連続して確保する。今はとりあえず 100MiB 固定で確保:
それをもとにして EPT を初期化する。なお、 Ymir のページアロケータが返すアドレスは仮想アドレスだが、これは virt2phys() で簡単に物理アドレスに変換できる:
EPT の構造自体はほぼ普通のページテーブルと同じであるため、実装するのがとても面白くなかった。現在のところゲストページを細かく制御するつもりはないため、全てのページを 2MiB ページで登録することにした。EPT を初期化したら、EPTP を VMCS に設定して設定完了。
ゲストイメージの読み込み
ゲストイメージをロードするためには、イメージをまず読み込む必要がある。ゲストイメージを FS のどこに置くかは諸説あるが、現在のところ Ymir はFSを実装していないため、UEFIに任せるためにブートパーティションの /EFI
直下に置くことにした。
というわけで、Surtr 側にゲストイメージをロードして Ymir に渡す処理を入れる:
ロードしたゲストイメージの情報は、ブートパラメタとして Ymir に渡す:
なお、Surtr が動いている段階ではメモリマップはストレートマップになっているため、ブートパラメタに入っているアドレスも物理アドレスである。よって、 Ymir 側でアドレスを使う際には以下のように仮想アドレスに変換する:
ゲストイメージのロードとLinuxブートパラメタの設定
ロードするゲストのイメージが手に入ったため、これをゲストメモリにロードする。
Linux には、カーネルに処理が渡った時点のアドレッシングモードに応じてブートプロトコルが存在する。32bit protected mode では boot_params
という情報を適切に埋めた上でカーネルに処理を渡す必要がある。
以前にKVMベースのVMMを書いた際にこの辺の処理は実装したことがあるため、それらのコードをほぼそのまま使うことができた:
Control Fields / Guest State の設定
EPT を使い、かつ 32-bit protected mode にする上でVMCSの値を変更する必要があった。
Execution Control で EPT を有効化:
VM-entry Control で IA-32e モードを無効化:
Guest State の CR0/CR4 をクリア。CR.PE と CR0.NE だけ有効化してページングを有効にする。また、PAEページングは無効化する。CR4.VMXEについては後述:
MSRの一部をクリア:
セグメントの設定。長いので省略。基本は全部リミットを0・ベースを0・リミットを最大にするだけ。
地獄の Invalid Guest State 編 パート2
ここまでで、以下ができている:
- EPTの設定: ゲストPAをホストPAにマップ。ゲストPAは0から2MiB。
- ゲストのロード: Linuxの必要な情報を設定した上で、イメージをゲストメモリにロード。
- RIP/RSPの設定:
RIP
は0x10_000
に、RSP`はLinuxコマンドライン情報が入ったアドレスに設定。
これでゲストに入ると 0x10_000
の命令から実行を開始するはずである。
だが、いざ VMLAUNCH してみるとまたもや Invalid Guest State
で VM-entry に失敗する (厳密には、すぐ VM-exit する)。
よって、SDMとにらめっこして、それなりにちゃんと guest state をチェックしてくれる関数を書いた:
地獄みたいなコードだ。結局最後まで間違えていたのは、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
EPT のバグ修正
前回のEPT実装でバグがあった。現在はメモリ節約のため && 今の所細かく権限設定をしていないため、ゲストページは全て2MiBページでマッピングしていた。つもりだったが、実際にマップするところで誤って4KiB間隔でマップしてしまっていたので、修正:
Linux 32bit Protected Boot Protocol でのバグ修正
Ymir は 32bit Protected Mode におけるブートプロトコルに従って struct boot_params
を初期化してLinuxに処理を移している。本来であればブートローダ (今回はYmir) が RSP に struct boot_params
のアドレスをセットしなければならないのだが、誤って RSI にセットしてしまっていたので修正:
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 もセットする:
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の該当箇所はこちら:
XSAVE は、浮動小数点数レジスタ等を一括でセーブするための命令。この辺のレジスタはCPU世代が上がるにつれてどんどん数やビット幅が増えていくが、その度に専用の命令を用意するのはめんどうなので、共通して XSAVE/SRSTORS 命令でセーブ・リストアしようという目的。X87FPU/MMX/SSE/AVXなど、どの機能がサポートされているかは CPUID を使って読み取ることになっている。この辺のざっくりとした理解は以下を読むのが良さそう:
今回の警告では、 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にセットして有効化する:
すると、無事にFPU設定の初期化が通るようになった:
現状と今後の予定
現在、I/Oは全くフックしていない。よって、シリアルコンソールはゲストにそのまま使わせている。仮にゲストがYmirと異なる設定をすると、どちらかが壊れることにはなるが、今の所うまく行っている。
また、EPTでMMIOの設定も全くしていないため、APIC周りも全く。それから、PCIも同様に全くサポートできていない。
この後は、ひとまずLinuxがこれらの処理を開始するまで CPUID や MSR のフックを対応していき、必要に応じて他の仮想化もしていこうと思う。
そういえば、現在は VM-entry/exit 時にホストの xmm レジスタを退避・復帰してないけど、やる必要がある気がするな。
15e6a0df3e52d82cd62576827add06c79568d558