LoginSignup
3

More than 3 years have passed since last update.

Windows Hypervisor Platform (WHP) の使い方

Last updated at Posted at 2020-12-17

(これは 東京大学 品川研究室 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 作成の主な流れは以下のようになります。

  1. WHP がサポートされているかどうか調べる( WHvGetCapability() )
  2. VM を作成する( WHvCreatePartition(), WHvSetPartitionProperty(), WHvSetupPartition() )
  3. VM の物理アドレス空間を設定する( WHvMapGpaRange() )
  4. VM 内に仮想CPU(vCPU)を作成する( WHvCreateVirtualProcessor() )
  5. vCPU のレジスタを設定する( WHvSetVirtualProcessorRegisters() )
  6. VM 内の vCPU を実行する( WHvRunVirtualProcessor() )
  7. Exit Reason を調べて処理を続行する

4. 実際のコード

それでは、実際のコードを見ていきます。

4.0. 準備

Windows の関数がエラーを返したら即時終了するための簡単なマクロ ORpanic() を定義します。ちょっと変わった書き方ですが、後で出てくるコードが見やすくなります。

#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 命令が実行されたことを確認するために、ExitReasonWHvRunVpExitReasonHypercall かどうか確認しています。

    printf("Exit reason: %x\n", context.ExitReason);
    if (context.ExitReason == WHvRunVpExitReasonHypercall)
        puts("The vmcall instruction is executed");

以上で、とりあえず VM entry と VM exit をすることができます。

5. 実行結果

実際の実行結果は以下のようになります。
WHP-simple.png
ハイパーコールによる VM exit の際は、WHvRunVpExitReasonHypercall (0x1005) が返ることになっているので、実際に vmcall 命令が実行されたことを確認できます。

ちなみに、vmcall の代わりに無限ループ(0xeb 0xfe)を実行すると、強制終了するまで実行し続けます。

6. まとめ

Windows Hypervisor Platform (WHP) の簡単なサンプルコードを作って解説しました。

実際には、VM や vCPU をもっと細かく設定してから、ループ内でWHvRunVirtualProcessor() を呼び出して、exit reason に応じた処理を行うという形になると思います。

7. コード

GitHub にコードを置いてあります。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3