(これは 東京大学 品川研究室 Advent Calendar 2020 の3日目の記事です)
1. はじめに
Windows 上で Intel VT や AMD-V などのハードウェア仮想化支援機能を利用するための OS 標準の API として Windows Hypervisor Platform (WHP) があります。
この OS 標準 API を使うとわりと簡単に仮想マシン( Virtual Machine (VM) )や仮想マシンモニタ( Virtual Machine Monitor (VMM) )を作ることが出来ます。
しかし、WHP の API のドキュメントだけでは、最初にどうやって動かせば良いかが分かりにくいと思います。
そこで、とりあえず VM を1つ作って、VM entry してから、すぐに VM exit してホストに戻ってくるだけの簡単なサンプルコードを作ってみました。
2. WHP の準備
まずは WHP を有効化します。管理者権限で Windows PowerShell を起動して、以下のコマンドを入力します。
Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform
「Windows の機能の有効化または無効化」で「Windows ハイパーバイザー プラットフォーム」にチェックを入れても良いです。
また、最新の Windows SDK を以下の URL からダウンロードしてインストールして下さい。
3. 全体の流れ
WHP における VM 作成の主な流れは以下のようになります。
- WHP がサポートされているかどうか調べる( WHvGetCapability() )
- VM を作成する( WHvCreatePartition(), WHvSetPartitionProperty(), WHvSetupPartition() )
- VM の物理アドレス空間を設定する( WHvMapGpaRange() )
- VM 内に仮想CPU(vCPU)を作成する( WHvCreateVirtualProcessor() )
- vCPU のレジスタを設定する( WHvSetVirtualProcessorRegisters() )
- VM 内の vCPU を実行する( WHvRunVirtualProcessor() )
- Exit Reason を調べて処理を続行する
4. 実際のコード
それでは、実際のコードを見ていきます。
4.0. 準備
Windows の関数がエラーを返したら即時終了するための簡単なマクロ OR
と panic()
を定義します。ちょっと変わった書き方ですが、後で出てくるコードが見やすくなります。
#define OR >=0?(void)0:
#define panic(x) (void)(fputs("panic: " x "\n",stderr),exit(1))
また VM を管理するための WHV_PARTITION_HANDLE
型のハンドル handle
と、vCPU の番号を格納するための変数 vcpu
を定義します。
WHV_PARTITION_HANDLE handle;
UINT16 vcpu = 0;
4.1. WHP がサポートされているかどうか調べる
WHvGetCapability()
という関数で WHvCapabilityCodeHypervisorPresent
というケーパビリティコードを指定して問い合わせると、WHV_CAPABILITY
型の構造体に値が返ってきます。この構造体の .HypervisorPresent
というフラグが立っていれば WHP が使用可能です。
// Is Windows Hypervisor Platform (WHP) enabled?
UINT32 size;
WHV_CAPABILITY capability;
WHvGetCapability(
WHvCapabilityCodeHypervisorPresent,
&capability, sizeof(capability), &size);
if (!capability.HypervisorPresent)
panic("Windows Hypervisor Platform is not enabled");
4.2. VM を作成する
まず、WHvCreatePartition()
で VM を作成します。WHP では VM のことを Partition と呼んでいます。handle
のポインタを渡して VM のハンドルを受け取ります。
// create a VM
WHvCreatePartition(&handle)
OR panic("create partition");
次に、WHvSetPartitionProperty()
で VM の属性を設定します。少なくとも WHvPartitionPropertyCodeProcessorCount
という属性コードを使って、VM で使う vCPU の数を設定する必要があります。
UINT32 cpu_count = 1;
WHvSetPartitionProperty(
handle,
WHvPartitionPropertyCodeProcessorCount,
&cpu_count, sizeof(cpu_count))
OR panic("set partition property (cpu count)");
また、後で使うので、WHvPartitionPropertyCodeExtendedVmExits
という属性コードで、WHV_EXTENDED_VM_EXITS
型の構造体の .HypercallExit
を 1 に設定して、ハイパーコール命令(vmcall, vmmcall命令)が実行されたときに専用のエラーコードが返るようにします。
WHV_EXTENDED_VM_EXITS vmexits = { 0 };
vmexits.HypercallExit = 1;
WHvSetPartitionProperty(
handle,
WHvPartitionPropertyCodeExtendedVmExits,
&vmexits, sizeof(vmexits))
OR panic("set partition property (vmexits)");
最後に、WHvSetupPartition()
で実際に VM を作成して使える状態にします。
WHvSetupPartition(handle)
OR panic("setup partition");
4.3. VM の物理アドレス空間を設定する
VM の中の物理アドレス空間を設定します。カーネル用のデータ構造を置く領域と、ユーザ空間のコードを置くための領域を確保して設定します。
カーネル用データ構造は、実際にはページテーブルだけです。ここではラージページを使ってゲスト仮想アドレス空間の最初の 1 GiB だけをストレートマッピング(仮想アドレス=物理アドレス)するので、PML4 と PDPT だけを用意します。ページテーブルは、とりあえずゲスト物理アドレス空間の 1 GiB から始まる領域に置くことにします。
const UINT64 user_start = 4 KiB;
const UINT64 kernel_start = 1 GiB;
struct kernel {
UINT64 pml4[512];
UINT64 pdpt[512];
} *kernel;
まずは、ホストの Windows 側でページテーブル用のメモリを確保して初期化します。4 KiB アラインされたメモリ領域を aligned_alloc()
で確保して、memset()
でゼロフィルします。次に、pml4 の最初のエントリに pdpt のゲスト物理アドレス(1 GiB + struct kernel での pdpt のオフセット)とフラグ(Present, Writable, User)を設定します。また、pdpt の最初のエントリに物理アドレス 0 番地とフラグ(Present, Writable, User, 1 GiB page)を設定します。 最後に、WHvMapGpaRange()
でカーネルのメモリ領域をゲスト物理アドレス空間に読み書き可能(WHvMapGpaRangeFlagRead | WHvMapGpaRangeFlagWrite
)でマッピングします。
ページテーブルの構造については、Intel® 64 and IA-32 Architectures Software Developer's Manual Volume 3A: System Programming Guide, Part 1 を参照して下さい。
https://software.intel.com/content/www/us/en/develop/download/intel-64-and-ia-32-architectures-sdm-volume-3a-system-programming-guide-part-1.html
// prepare and map kernel data structures
assert((sizeof(*kernel) & (4 KiB - 1)) == 0); // 4 KiB align
kernel = (struct kernel *)aligned_alloc(4 KiB, sizeof(*kernel));
if (!kernel)
panic("aligned_alloc");
memset(kernel, 0, sizeof(*kernel));
kernel->pml4[0] = (kernel_start + offsetof(struct kernel, pdpt))
| (PTE_P | PTE_RW | PTE_US);
kernel->pdpt[0] = 0x0
| (PTE_P | PTE_RW | PTE_US | PTE_PS);
WHvMapGpaRange(handle, kernel, kernel_start, sizeof(*kernel),
WHvMapGpaRangeFlagRead | WHvMapGpaRangeFlagWrite)
OR panic("map the kernel region");
この領域は、ゲスト物理アドレス空間の 1GiB から始まる領域に存在しますが、ゲストのページテーブルでマッピングしていないので、ゲスト側のコードからはアクセスできません。ページテーブルの書き込みはホスト側で可能ですし、ページテーブルの物理アドレスを CPU が認識できれば良いので、このようになっています。
ユーザ空間のコードは、ゲスト物理アドレスの 4 KiB から始まる 1 ページ分だけを用意することにします。
まず、ユーザ空間のコードを置くためのページを aligned_alloc()
で1ページ分確保して、あらかじめ定義した vmcall のオペコード(user_code[]
)をコピーしておきます。Intel CPU では vmcall (0x0f, 0x01, 0xc1)
ですが、AMD CPU では vmmcall (0x0f, 0x01, 0xd9)
に変更して下さい。最後に、WHvMapGpaRange()
でゲスト物理アドレス空間に読み込み及び実行可能(WHvMapGpaRangeFlagRead | WHvMapGpaRangeFlagExecute
)でマッピングします。
// map user space
void *user_page = aligned_alloc(4 KiB, 4 KiB);
if (!user_page)
panic("aligned_alloc");
const UINT8 user_code[] = {
0x0f, 0x01, 0xc1, // vmcall
};
memcpy(user_page, user_code, sizeof(user_code));
WHvMapGpaRange(handle, user_page, user_start, 4 KiB,
WHvMapGpaRangeFlagRead | WHvMapGpaRangeFlagExecute)
OR panic("map the user region");
4.4. VM 内に仮想CPU(vCPU)を作成する
vCPU の作成は、WHvCreateVirtualProcessor()
を呼ぶだけです。2番目の引数で vCPU の番号を指定します。
// create a vCPU
WHvCreateVirtualProcessor(handle, vcpu, 0)
OR panic("create virtual processor");
4.5. vCPU のレジスタを設定する
vCPU のレジスタの初期値を設定します。
ここで設定するのは、CR0, CR3, CR4, EFER の各コントロールレジスタと、CS, SS, DS, ES の各セグメントレジスタ、RIP のプログラムカウンタレジスタです。
設定するレジスタは、WHV_REGISTER_NAME
型の配列を使って複数同時に指定することが出来ます。各レジスタに設定する値は WHV_REGISTER_VALUE
型の同じ長さの配列を使って指定します。対応関係がずれないように、enum を使って名前とインデックスを一致させています。
CR0 には Protection Enable (PE) ビットと Paging Enable (PG) ビットを設定します。
CR3 にはページテーブル(pml4)の物理アドレスを設定します。
CR4 には Page Size Extensions (PSE) と Physical Address Extension (PAE) を指定します。
EFER には IA-32e Mode Enable (LME) と IA-32e Mode Active (LMA) を指定して、いきなり 64 ビットのロングモードで実行を開始させます。
CS, SS, DS, ES の各セグメントレジスタには、適切な値を設定しておきます。
最後に、WHvSetVirtualProcessorRegisters()
でレジスタの値を設定します。
// setup vCPU registers
enum {
Cr0, Cr3, Cr4, Efer,
Cs, Ss, Ds, Es, Rip,
RegNum
};
WHV_REGISTER_NAME regname[RegNum];
regname[Cr0] = WHvX64RegisterCr0;
regname[Cr3] = WHvX64RegisterCr3;
regname[Cr4] = WHvX64RegisterCr4;
regname[Efer] = WHvX64RegisterEfer;
regname[Cs] = WHvX64RegisterCs;
regname[Ss] = WHvX64RegisterSs;
regname[Ds] = WHvX64RegisterDs;
regname[Es] = WHvX64RegisterEs;
regname[Rip] = WHvX64RegisterRip;
WHV_REGISTER_VALUE regvalue[RegNum];
regvalue[Cr0].Reg64 = (CR0_PE | CR0_PG);
regvalue[Cr3].Reg64 = kernel_start + offsetof(struct kernel, pml4);
regvalue[Cr4].Reg64 = (CR4_PSE | CR4_PAE);
regvalue[Efer].Reg64 = (EFER_LME | EFER_LMA);
WHV_X64_SEGMENT_REGISTER CodeSegment;
CodeSegment.Base = 0;
CodeSegment.Limit = 0xffff;
CodeSegment.Selector = 0x08;
CodeSegment.Attributes = 0xa0fb;
regvalue[Cs].Segment = CodeSegment;
WHV_X64_SEGMENT_REGISTER DataSegment;
DataSegment.Base = 0;
DataSegment.Limit = 0xffff;
DataSegment.Selector = 0x10;
DataSegment.Attributes = 0xc0f3;
regvalue[Ss].Segment = DataSegment;
regvalue[Ds].Segment = DataSegment;
regvalue[Es].Segment = DataSegment;
regvalue[Rip].Reg64 = user_start;
WHvSetVirtualProcessorRegisters(
handle, vcpu, regname, RegNum, regvalue)
OR panic("set virtual processor registers");
なお、この例では割り込み禁止で VM entry した後すぐに戻ってくるので、IDTR, TR などのレジスタは設定していません。また、セグメントレジスタも設定済みなので、GDTR も設定していません。
4.6. VM 内の vCPU を実行する
WHvRunVirtualProcessor()
で VM 内の vCPU の実行を開始させます。
// run the VM
WHV_RUN_VP_EXIT_CONTEXT context;
WHvRunVirtualProcessor(handle, vcpu, &context, sizeof(context))
OR panic("run virtual processor");
4.7. Exit Reason を調べて処理を続行する
WHvRunVirtualProcessor()
に渡した WHV_RUN_VP_EXIT_CONTEXT
型の変数に exit reason が返ってくるので、その値に基づいて適切な処理を実行します。
ここでは、ユーザ空間で vmcall
命令が実行されたことを確認するために、ExitReason
が WHvRunVpExitReasonHypercall
かどうか確認しています。
printf("Exit reason: %x\n", context.ExitReason);
if (context.ExitReason == WHvRunVpExitReasonHypercall)
puts("The vmcall instruction is executed");
以上で、とりあえず VM entry と VM exit をすることができます。
5. 実行結果
実際の実行結果は以下のようになります。
ハイパーコールによる VM exit の際は、WHvRunVpExitReasonHypercall (0x1005) が返ることになっているので、実際に vmcall 命令が実行されたことを確認できます。
ちなみに、vmcall の代わりに無限ループ(0xeb 0xfe)を実行すると、強制終了するまで実行し続けます。
6. まとめ
Windows Hypervisor Platform (WHP) の簡単なサンプルコードを作って解説しました。
実際には、VM や vCPU をもっと細かく設定してから、ループ内でWHvRunVirtualProcessor()
を呼び出して、exit reason に応じた処理を行うという形になると思います。
7. コード
GitHub にコードを置いてあります。