この章では、/dev/kvmを直接叩く簡単なプログラムをいくつか作って、KVMを使うVMがどのように作られているのかを理解します。
ただし、次章で既存のプログラムを改造する際、どこを改造するかは説明するので、特にここらへんの理解が不要な場合、この章を飛ばしても大丈夫です。
LinuxカーネルにはKernel-based Virtual Machine(KVM)という機能があり、その名の通り仮想マシンを実現するための機能を提供しています。
KVMは/dev/kvmというデバイスファイルでカーネルから提供されています。
この項では/dev/kvmを直接叩いてVMを作成し、"Hello KVM!"と表示させてみます。
KVMでVMを作る上で、まずはKVMが何をしてくれて、KVMでVMを作るためには何をしなければならないかを説明します。
KVMはVM(構造のみ)と仮想的なCPU(VCPU)といくつかの周辺ICを用意してくれます。
KVMへ「VMを作って」とリクエストするとKVMはVMを作ってくれます。ただしKVMが作ってくれるのはKVMがVMを管理するための構造のみです。VM内の各種の仮想的なハードウェアはCPUと一部の周辺ICを除きKVMを使うアプリ側で用意します。(そして用意した仮想的なハードウェアをKVMが管理するVMへ登録します)
以上から、KVMでVMを作る大まかな流れは以下の通りです。
VM作成後、VM実行をKVMへリクエストすると、カーネル側でVMの実行が始まります。
特権が必要な命令が実行された場合や、IOの処理が実行された場合に処理がカーネルから戻ってきます。
そのため、IOなどはVM実行後、カーネルから処理が戻ってきた際にハンドリングします。
この記事で作るVMを図示すると図1.1の通りです。
ROM("Hello KVM!"とシリアルのIOへ出力するプログラムが書かれている)とシリアルがCPUにつながっている構成です。
メモリマップは図1.2の通りです。
ROMはリニアアドレス空間の0番地にマップしていて、シリアル送信のレジスタはIOアドレス空間の0x01番地にあることとします。(簡単のためにこうしているだけで、実機やQEMUでこの様になっているわけではありません。)
この章のサンプルコードは以下のリポジトリで公開しています。
この項で説明するサンプルは「01_hello」というディレクトリに格納しています。
01_helloにはリスト1.1のようにファイルが格納されています。
VMのソースコード自体はmain.cのみです。
VM上で実行する"Hello KVM!"を出力するプログラムはromディレクトリに格納しています。romディレクトリ内のMakefileでコンパイルすると、バイナリを16進の配列にしたヘッダファイルrom.hを生成します(後述)。
01_hello直下のMakefileに、romディレクトリ内のMakefileの呼び出し含め、ビルドルールが記載されています。01_hello直下で"make"を実行するだけでビルドでき、"vm"という実行バイナリを生成します。"vm"バイナリに上述のromバイナリも含んでいるため、単体で実行できます。
また他にも、"make run"で実行(単に"vm"バイナリを実行するだけですが)、"make clean"でビルド時の生成物の削除が行えます。
それでは、次節からmain.cについてコードブロック毎に紹介してみます。
まず、/dev/kvmをopenし、ファイルディスクリプタを取得します(リスト1.2)。
次に、変数kvmfdへ格納したファイルディスクリプタを使用してKVMへVM作成をリクエストします。
リクエストはioctlを使用して行います(リスト1.3)。
以降は、ここで取得したVMのファイルディスクリプタを使用してioctlでハードウェアをVMへ登録していきます。
先程取得したvmfdへROMを登録します。
まず、今回作るVM上で"Hello KVM!"を出力する実行バイナリを用意します。
そして、用意した実行バイナリをROMとしてvmfdへ登録します。
romディレクトリのMakefileをみると分かりますが、"Hello KVM!"の実行バイナリはrom.hというC言語のヘッダファイルとして生成されます。(中にunsigned char配列として実行バイナリが用意されます。)
rom.hは、同じ階層にあるrom.sをアセンブルし、rom.ldに従ってリンクした結果の実行バイナリを、xxdコマンドでCの配列の形式へ変換したものです。
そして、rom.sはシリアル送信レジスタのIOアドレス0x01へ1文字ずつ書き込むアセンブラのプログラムです(リスト1.4)。
"Hello KVM!"と1文字ずつ出力しているのがなんとなくわかるかと思います。mov命令で1文字ずつ"AL"という1バイトのレジスタに格納し、out命令でそれをIOアドレス0x01番へ書き込んでいます。
一通り文字を出力した後はhlt命令を実行するようにしています。今回のVMは「hlt命令でVM自体終了する」こととしてみます。
そして、実行バイナリをCの配列の形式へ変換して得られるrom.hはリスト1.5の通りです。
VM側ではこのrom.hをincludeし、rom_bin配列の内容をROMへ配置するバイナリとして使用します。
用意した実行バイナリをROMとしてVMへ登録します(リスト1.6)。
リスト1.6では、mmapで確保した領域へrom_binをコピーしています。
その後リスト1.6では、kvm_userspace_memory_region構造体の変数を定義し、KVM_SET_USER_MEMORY_REGIONというioctlのリクエストでmmapで確保した領域をVMへ登録します。
kvm_userspace_memory_regionのメンバは最低限必要なもののみ値を設定しています。
guest_phys_addrがVM上のゲストが見るアドレスで、memory_sizeがメモリ領域のサイズ(バイト単位)、userspace_addrがVMが確保した領域のアドレスです。
今回の場合、定数ROM_SIZE(4KB)の大きさのmmapで確保した領域(先頭アドレスがポインタ変数memに入っている)が、VM上のゲストからは0x00000000のアドレスから見えるようになります。
次にCPUを作成します(リスト1.7)。
CPU(Vritual CPU、VCPU)の実体はKVM側(カーネル側)にあり、作成する際はKVM_CREATE_VCPUというioctlをリクエストをするだけです。
VCPU作成時にカーネル側で作られるkvm_run構造体へは、後々、VM側からもアクセスします。そのため、KVM_CREATE_VCPUの後、kvm_run構造体へアクセスできるように用意しています。
やっていることは、「カーネル側のkvm_run構造体の領域をVM自身のメモリ空間へマップ(mmap)」です。
VCPUをmmapする際のサイズを取得(KVM_GET_VCPU_MMAP_SIZE)し、mmapでVM自身のメモリ空間にマップします。
mmapの結果はrunというkvm_run構造体のポインタ変数に格納しています。
KVMのVCPUが持つレジスタの初期値を設定します。
VCPUのレジスタへのアクセスもioctlで行います。KVM_SET_SREGSとKVM_SET_REGSという2つのioctlに分かれており、それぞれで扱えるレジスタが異なります。これらはそれぞれ値を設定するioctlで、逆に取得はKVM_GET_SREGSとKVM_GET_REGSで行えます。
また、これらのioctlへは引数として、KVM_SET_SREGSの場合はkvm_sregs構造体、KVM_SET_REGSの場合はkvm_regs構造体を渡します。これらの構造体を通してレジスタ値の設定/取得を行います。
まずは、KVM_SET_SREGSを実施します(リスト1.8)。
リスト1.8では、CPUが実行する命令のアドレスに関する設定を行っています。
ここで、この項のVMは「起動されると0x00000000の命令から実行を始める」こととします。そのための「セグメンテーション」と呼ばれるx86 CPUの機能の設定を行っているのがリスト1.8です。セグメンテーションとはアドレス空間を「セグメント」と呼ぶ領域に分けてアクセスする方式です。セグメントには用途が決まっているものもあり、リスト1.8では「コードセグメント(CS)」という「CPUが実行する命令が配置されているセグメント」の設定を行っています。
セグメンテーションについてここでこれ以上説明はしませんが、やっていることは単にCSがアドレス0x00000000から始まる事を設定しているだけです。
次にKVM_SET_REGSを実施します(リスト1.9)。
レジスタripはCSの先頭からのオフセットです。KVM_SET_SREGSでCSは0x00000000から始まるように設定したので、ripも0を設定しておくことで、VCPUはVM起動後、0x00000000の命令から実行を始めるようになります。
レジスタrflagsはCPUの状態を示すフラグです。予約ビットで1を書くことが決められているビットを除き、すべてのビットを0で初期化します。
ここまででVMのセットアップは完了です。
KVM_RUNリクエストをVCPUに対して発行するとVMの実行が始まります(リスト1.10)。
KVM_RUNのioctlは、特権命令の実行や、IOの処理などがあるまで帰ってきません。
帰ってきたらリスト1.11のように帰ってきた理由(exit_reason)を確認し対応する処理を行います。
KVM_EXIT_HLTのcaseは単に、標準出力をフラッシュしてwhileループを抜けるだけです。
KVM_EXIT_IOは、対象のIOが0x01であり、かつアクセスが書き込み(KVM_EXIT_IO_OUT)である場合、渡された文字を画面表示しています。これは、「シリアルポートの送信レジスタがIOアドレス空間のアドレス1番にある」という状態です。
IO処理をユーザ側で実装する際、カーネル側で動作しているKVMとの値の受け渡しにもVCPUをマップした領域を使います。
マップした領域の何処を使うのかを示すオフセットがrun->io.data_offsetで、マップした領域の先頭からのオフセットが書かれています。
今回の場合、run変数に格納されたアドレスにrun->io.data_offsetを足したアドレスを参照することで、VM上で動くプログラムがIOアドレス1番へ書き込んだデータを取得できます。
実行すると図1.3のように"Hello KVM!"の文字列が表示されることを確認できます。
"Hello KVM!"の後に'%'が付いているのは、最後に改行文字が無い印として私の使っているzshがつけているものです。他の一般的なシェルでは"Hello KVM!"に続いてシェルのプロンプトが表示されます。
前項で/dev/kvmへioctlを発行することでVMを作成し、その上で"Hello KVM!"を出力するプログラムを動作させてみました。
次はx86 PCにおいて電源を入れてから一番最初に実行されるソフトウェアとしてBIOSを動作させてみます。
この項のサンプルコードはリポジトリ内の「02_bios」ディレクトリに格納しています。GitHub上のURLとしては以下の場所です。
また、エラー処理など削除してコードの見通しを良くしたものを「02_bios_nodebug」ディレクトリに格納しています。GitHub上のURLとしては以下です。
前項同様、makeでビルドし、make runで実行できます。
実行する際、BIOSとしてSeaBIOSを使うので、インストールされていない場合はインストールしておいてください。
$ sudo apt install seabios
この項では以下を行います。
この項のサンプルのアーキテクチャは図1.4の通りです。
また、メモリマップは図1.5の通りです。
前項で、KVMでVMを作る際にはIO命令等に対する挙動を自分で記述する事を紹介しました。
完全に自分独自のハードウェアをVMで作ってみるのであれば、前項のように「シリアルポートはIOアドレス空間の1番にする」と決めて作っていくのも面白いです。
ただ、本書の場合はオープンソースのBIOSであるSeaBIOSを動作させてみます。
SeaBIOSを動作させるためにVM側で用意する必要があるものは以下の5つです。
そのため、次節からこれらをVMへ追加する方法を紹介します。
まず、割り込みコントローラを追加します。
KVMではCPUの他にいくつかの機能はKVM側(カーネル側)で持っています。
割り込みコントローラもKVM側で持っている機能で、リスト1.12のように設定します。
KVM_CREATE_IRQCHIPというioctlを発行するだけで、vmfdで指定したVMに割り込みコントローラ(IRQ Chip)を割り当ててくれます。
タイマーも割り込みコントローラと同様です。リスト1.13のようにioctlを発行します。
KVM_CREATE_PITというioctlにより、vmfdで指定したVMにタイマー(PIT)を割り当ててくれます。
BIOS ROMの追加はbios_rom_installという関数に分けています。
bios_rom_install関数は、引数pathで指定されたBIOS ROMバイナリ(main.cで"/usr/share/seabios/bios.bin"(BIOS_PATH定数)を指定)をvmfdで指定されたVMへROMとして追加します。
関数内にはいくつか処理がありますが、やりたいことは「KVM_SET_USER_MEMORY_REGIONというioctlでユーザ空間側で用意したメモリ領域をVMへ割り当てる」ということで、それ以外の処理はKVM_SET_USER_MEMORY_REGIONのための準備です。
それでは、bios_rom_install関数を先頭から見ていきます。
まず、BIOS ROMファイルを開き、ファイルサイズを取得します(リスト1.14)。
ファイルサイズ取得処理はlseekで一旦ファイル末尾までシークし、その際にファイルサイズを取得して、またファイル先頭まで戻す、ということをしているだけです。
なお、この項では128KBサイズのBIOS ROMファイルを想定しているため、ファイルサイズが128KBを超えていた場合はassertでエラー終了します。seabiosパッケージでBIOS ROMファイルがインストールされるパス"/usr/share/seabios/"には"bios-256k.bin"というものもありますが、128KBサイズである"bios.bin"を使うようにしてください。
次に、openしたBIOSのバイナリを配置する領域を確保し、確保した領域へロードします(リスト1.15)。
ROMにしろ後述するRAMにしろVMへ用意するメモリ領域はユーザ空間で用意してそれをioctlでVMへ割り当てる、というのがKVMでVMにメモリを割り当てる流れです。
メモリの確保にはposix_memalignという関数を使っています。これはアラインメントされたメモリの確保を行ってくれる関数で、第2引数にアラインメントサイズを指定しています。
ここでは4096バイトアラインメントされたBIOS_MEM_SIZE(128KB)のメモリ領域を確保しbios_memへ先頭アドレスを格納しています。これは、KVM_SET_USER_MEMORY_REGIONの仕様で、ドキュメント*1にはそのようなことは書かれていないのですが、コードには「ページサイズ(4096バイト)アラインメントされていないアドレスが渡された場合EINVAL(Invalid argument)を返す」ように書かれています*2。
ここまでで、ユーザ空間側でBIOS ROMの内容が書かれたメモリ領域を用意できたので、ioctlでVMへ割り当てます。
KVM_SET_USER_MEMORY_REGIONのioctlを呼び出す処理は少し行数があるのでkvm_set_user_memory_regionという関数名で関数化しています(リスト1.16)。
KVM_SET_USER_MEMORY_REGIONのioctlは、「VMへ割り当てたいユーザ空間側で用意した領域の先頭アドレス」や「VM上のどこのアドレスへ割り当てるか」といった情報を「struct kvm_userspace_memory_region」という構造体のポインタで渡します。そのため、構造体変数を作って、構造体のメンバへ値を設定し、ioctl時に引数に渡す、ということを行っています。
kvm_set_user_memory_region関数を使って、ユーザ空間で用意したBIOS ROMのメモリ空間をVMへ割り当てます(リスト1.17)。
同じBIOS ROM領域をVM上のBIOS_LEGACY_ADDR(0x000e0000)とBIOS_SHADOW_ADDR(0xfffe0000)という2つの領域へ割り当てています。
これは、電源投入直後のCPUの動作とBIOSが慣例的に割り当てられるアドレス領域によるものです。電源投入直後、CPUは0x...fffff0という最後の4ビットを0にしたアドレスから命令を読み込んで実行します。しかし、IntelのCPUは互換性のために電源投入直後は古いCPUの設定で立ち上がるため、起動直後はアドレスの20ビット目以上が全て無効(0)になります。そのため、実際には0xffff0から命令を読み込んで実行することになります。このアドレスは古くからBIOSが配置されているアドレス(0xe0000 - 0xfffff)であり、問題なくBIOSが実行されます。
ただし、0xffff0から実行を開始する場合、アドレスの末尾である0xfffffまでには16バイトしかありません。そのため、0xffff0から16バイトの領域にはBIOSの空間の先頭へジャンプする命令が書かれています。
ここまでが、実機でBIOSが実行開始されるまでの一般的な流れです。KVMのVCPUの場合、実はこのような流れにはなりません。
KVMのVCPUの場合に(少なくとも特に設定をしないで使う場合は、)「アドレスの20ビット目以上を全て無効にする」というようなことはせず、起動直後は0xfffffff0から実行します。そのため、0xfffe0000 - 0xffffffffにもBIOSを配置し、0xfffffff0から実行を開始された場合も0xe0000からのBIOSの空間へジャンプできる様にしています*3。
[*3] もちろん、前項のようにCPUの実行開始アドレスを明示的に指定すればこのような面倒なことをしなくても良いです。
SeaBIOSが動作する上で必要なRAMを用意します(リスト1.18)。
VM上に用意するRAMの領域は2つで、VM上のベースアドレスとサイズは定数の通りです。
VMへのRAM割り当てはram_installという名前で関数化しています(リスト1.19)。
指定されたサイズのメモリを確保してkvm_set_user_memory_region関数でVMへ割り当てています。メモリ確保やVM割り当ての方法はROMの時と同じです。
SeaBIOSは自身の動作ログを0x0402というIOアドレスへ1文字(1バイト)ずつ出力します。そこで、前項でIOアドレス1番としていたものを0x0402番へ変更します。
KVM_RUNをIO要因でExitした場合にIOをハンドリングする処理はio_handleという関数へ分けてみました(リスト1.20、リスト1.21)。
実行すると図1.6のようにSeaBIOSの動作ログが表示され、SeaBIOSが実行されている様子を確認できます。
SeaBIOS自体を動かすことはできましたが、動いた結果としてはフロッピーディスクやハードディスク等からのロードに失敗、という状態です。
それではディスクを扱えるように、まずはハードディスクに比べて簡単なフロッピーディスクからVM上に実装していきたい所です。そのためにはまずPCIを扱えるようにする必要があり、PCIをBIOSで認識するためにはPCIコンフィグレーション空間というPCIの設定値が格納された領域をVM側に用意し、BIOSからはそれをIO命令で読み出せるようにする必要があり、、となります。
それらをコツコツと作っていっても良いのですが、本書の場合、自作OS自動テストの第一歩である「MBRのテスト」を実現することが目的で、スクラッチで1から作っていくことを目的とはしていません。KVMを扱う既存のコードを改造するための知識としてはひとまずここら辺で十分*4なので、次の章からは早速、既存の比較的手軽なコードを改造して「MBR自動テスト」を実現してみます。
[*4] PCIコンフィグレーション空間もそうですが、引き続き作っていく場合、今後はBIOSが求めるIOをKVM_RUNから戻ってきた後のIOハンドラに実際のPCと同様のものを一つ一つ実装していく作業です。デバイス毎の違いはあれど、VMがその上で動くプログラムへIOの結果を返したり、逆にIOの出力を受け取ったりする方法はシリアルポートで説明した通りなので、同じような作業を繰り返すことになります。