/dev/kvmを直接叩く - Hello KVM!

LinuxカーネルにはKernel-based Virtual Machine(KVM)という機能があり、その名の通り仮想マシンを実現するための機能を提供しています。


KVMは/dev/kvmというデバイスファイルでカーネルから提供されています。

この記事では/dev/kvmを直接叩いてVMを作成し、"Hello KVM!"と表示させてみます。

0. 手っ取り早く

↓コードを見て、「なるほどこんな風に/dev/kvmは叩けるのか」と思ってもらった方が早いかも
https://github.com/cupnes/bare_metal_kvm/tree/master/01_hello/main.c

1. KVMがやってくれること、KVMVMを作るためにやらなきゃいけないこと

KVMVMを作る上で、まずはKVMが何をしてくれて、KVMVMを作るためには何をしなければならないかを説明します。


KVMVM(構造のみ)と仮想的なCPU(VCPU)といくつかの周辺ICを用意してくれます。


KVMへ「VMを作って」とリクエストするとKVMVMを作ってくれます。ただしKVMが作ってくれるのはKVMVMを管理するための構造のみです。VM内の各種の仮想的なハードウェアはCPUと一部の周辺ICを除きKVMを使うアプリ側で用意します。(そして用意した仮想的なハードウェアをKVMが管理するVMへ登録します)


以上から、KVMVMを作る大まかな流れは以下の通りです。

1. KVMVMとVCPUの作成をリクエストする
2. メモリなどを用意しVMへ登録


VM作成後、VM実行をKVMへリクエストすると、カーネル側でVMの実行が始まります。


特権が必要な命令が実行された場合や、IOの処理が実行された場合に処理がカーネルから戻ってきます。
そのため、IOなどはVM実行後にハンドリングします。


この記事で作るVMを図示すると↓の通りです。

アーキテクチャ


"Hello KVM!"とシリアルのIOへ出力するプログラムが書かれたROMとシリアルがCPUにつながっている構成です。


【メモリマップ】


ROMはリニアアドレス空間の0番地にマップされていて、シリアル送信のレジスタはIOアドレス空間の0x01番地にあることとします。
(簡単のためにこうしているだけで、実機やQEMUでこの様になっているわけではありません。)

2. 実装

サンプルコード
https://github.com/cupnes/bare_metal_kvm/blob/master/01_hello/

  • VMソースコードはmain.cのみです。
  • VM上で実行する"Hello KVM!"プログラムはromディレクトリに格納しています。
  • Makefile同梱しています
    • make : ビルド
    • make run : 実行
    • make clean: ビルド時に生成したファイルを削除


以降はmain.cについてコードブロック毎に簡単に紹介してみます。

2.1. /dev/kvmをopen

まず、/dev/kvmをopenし、ファイルディスクリプタを取得します。

/* /dev/kvmをopen */
int kvmfd = open("/dev/kvm", O_RDWR);
2.2. VM作成

次に、変数kvmfdへ格納したファイルディスクリプタを使用してKVMVM作成をリクエストします。
リクエストはioctlを使用して行います。

/* VM作成 */
int vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);


以降は、ここで取得したVMのファイルディスクリプタを使用してioctlでハードウェアをVMへ登録していきます。

2.3. ROM作成

2.3.1. "Hello KVM!"の実行バイナリについて

romディレクトリのMakefileをみると分かりますが、
"Hello KVM!"の実行バイナリはrom.hというC言語のヘッダファイルとして生成されます。
(中にunsigned char配列として実行バイナリが用意されます。)


rom.hは、同じ階層にあるrom.sをコンパイルし、rom.ldに従ってリンクした結果の実行バイナリを、
xxdコマンドでCの配列の形式へ変換したものです。


そして、rom.sはシリアル送信レジスタのIOアドレス0x01へ1文字ずつ書き込むアセンブラのプログラムです。


例えば、最初の文字'H'は以下のように出力します。

	mov	$'H',	%al
	out	%al,	$0x01


一通り文字を出力した後はhlt命令を実行するようにしており、
今回のVMは「hlt命令でVM自体終了する」こととしてみます。


2.3.2. ROMをVMへ登録

ハードウェアとしてまずは、ROMを作ってVMへ登録します。

/* ROMを用意 */
unsigned char *mem = mmap(NULL, ROM_SIZE, PROT_READ|PROT_WRITE,
			  MAP_SHARED|MAP_ANONYMOUS|MAP_NORESERVE,
			  -1, 0);
memcpy(mem, rom_bin, sizeof(rom_bin));  /* メモリへコードを配置 */
struct kvm_userspace_memory_region region = {
	.guest_phys_addr = 0,
	.memory_size = ROM_SIZE,
	.userspace_addr = (unsigned long long)mem
};
ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &region); /* VMへメモリを設定 */


mmapで確保した領域へrom_binをコピーしています。
rom_binは、VM上で実行する実行バイナリで、rom/rom.hでcharの配列として定義されます。


その後、kvm_userspace_memory_region構造体の変数を定義し、
KVM_SET_USER_MEMORY_REGIONというリクエストでVMへ登録します。


kvm_userspace_memory_regionのメンバは最低限必要なもののみ値を設定しています。
guest_phys_addrがVM上のゲストが見るアドレスで、memory_sizeがメモリ領域のサイズ(バイト単位)、userspace_addrがVMが確保した領域のアドレスです。
今回の場合、定数ROM_SIZE(4KB)の大きさのmmapで確保した領域(先頭アドレスがポインタ変数memに入っている)が、VM上のゲストからは0x00000000のアドレスから見えるようになります。

2.4. CPU作成

次にCPUを作成します。

/* VCPU作成 */
int vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0);
					/* 第3引数は作成するvcpu id */
size_t mmap_size = ioctl(kvmfd, KVM_GET_VCPU_MMAP_SIZE, NULL);
struct kvm_run *run = mmap(NULL, mmap_size, PROT_READ|PROT_WRITE,
			   MAP_SHARED, vcpufd, 0);


CPU(Vritual CPU、VCPU)の実体はKVM側(カーネル側)にあり、作成する際はKVM_CREATE_VCPUというリクエストをするだけです。


CPUを作ること自体はioctlで完了なのですが、後々、VCPU作成時にカーネル側で用意してくれたkvm_run構造体へアクセスできる必要が出てくるので、用意しておきます。


やることは、「カーネル側のkvm_run構造体の領域をVM自身のメモリ空間へマップ(mmap)」です。


VCPUをmmapする際のサイズを取得(KVM_GET_VCPU_MMAP_SIZEリクエスト)し、mmapVM自身のメモリ空間にマップします。
mmapの結果はrunというkvm_run構造体のポインタ変数に格納しておきます。

2.5. CPUレジスタ初期設定

CPUレジスタの初期値を設定しておきます。
ここで設定する内容は、「VM起動でCPUがどこから実行を始めるか」というものです。


2.5.1. kvm_sregs設定

KVMのVCPUのレジスタにアクセスする構造体にはkvm_sregsとkvm_regsがあります。
まずは、kvm_sregsでアクセスできるレジスタの設定を行います。

struct kvm_sregs sregs;  /* セグメントレジスタ初期値設定 */
ioctl(vcpufd, KVM_GET_SREGS, &sregs);
sregs.cs.base = 0;
sregs.cs.selector = 0;
ioctl(vcpufd, KVM_SET_SREGS, &sregs);


ここでは、x86 CPUの「セグメンテーション」というアドレス指定方法で、実行コードが配置されている領域(セグメント)であるコードセグメント(CS)の設定を行っています。(CSのみを変更したいので、現在の値をKVM_GET_SREGSで読み出してから変更しています)


「セグメンテーション」についてここで詳しく説明はしませんが、やっていることは単にCSがアドレス0x00000000から始まる事を設定しているだけです。


2.5.2. kvm_regs設定

次にkvm_regsのレジスタを初期化します。

struct kvm_regs regs = {  /* ステータスレジスタ初期値設定 */
	.rip = 0x0,
	.rflags = 0x02, /* RFLAGS初期状態 */
};
ioctl(vcpufd, KVM_SET_REGS, &regs);


ripは実行する命令のセグメント先頭からのオフセットです。実行バイナリはVM上の0番地から配置しているので、こちらも0にしておきます。


rflagsはCPUの状態を示すフラグです。予約ビットで1を書くことが決められているビットを除き、すべてのビットを0で初期化します。


ここまででVMのセットアップは完了です。

2.6. 実行

KVM_RUNリクエストをCPUに対して発行すると実行が始まります。

/* 実行 */
unsigned char is_running = 1;
while (is_running) {
	ioctl(vcpufd, KVM_RUN, NULL);

	/* 何かあるまで返ってこない */


KVM_RUNのioctlは、特権命令の実行や、IOの処理などがあるまで帰ってきません。


帰ってきたら↓のように帰ってきた理由(exit_reason)を確認し対応する処理を行います。

	switch (run->exit_reason) {	/* 何かあった */
	case KVM_EXIT_HLT:	/* HLTした */
		/* printf("KVM_EXIT_HLT\n"); */
		is_running = 0;
		fflush(stdout);
		break;

	case KVM_EXIT_IO:	/* IO操作 */
		if (run->io.port == 0x01
		    && run->io.direction == KVM_EXIT_IO_OUT) {
			putchar(*(char *)((unsigned char *)run + run->io.data_offset));
		}
	}
}


KVM_EXIT_HLTの方は単に、標準出力をフラッシュしてwhileループを抜けるだけです。


KVM_EXIT_IOは、対象のIOが0x01(シリアル送信)であり、かつアクセスがレジスタへの書き込み(KVM_EXIT_IO_OUT)である場合、
putcharでレジスタに渡された文字(ASCII)を画面へ出力しています。


IO処理をユーザ側で実装する際、カーネル側で動作しているKVMとの値の受け渡しにもVCPUをマップした領域を使います。
マップした領域の何処を使うのかを示すオフセットがrun->io.data_offsetで、マップした領域の先頭からのオフセットが書かれています。


今回の場合、run変数に格納されたアドレスにrun->io.data_offsetを足したアドレスにシリアル送信レジスタへ書かれた値が設定されるため、これを1文字ずつ読みだしています。