はじめに
KVMを利用したナイーブで実験的なVMMを作ってみた。 ioctl で /dev/kvm を叩いて仮想マシンを作成し、その上でLinux Kernelとユーザプロセスを起動できる。 Kernelのデバイスドライバから認識できる程度の非常に簡素なシリアルコンソールのエミュレーションも実装したので、 ログインシェルから操作ができる。 一方で、ネットワーキングやディスクについては現時点ではまだサポートしていない。
最近はKVMを従来のような仮想マシンとしての使い方だけでなく、 マルチテナントなクラウド環境において分離レベルを強化するために、 Google gVisor 1 や Kata Containers 2、 Amazon Firecracker 3 をはじめとした コンテナやマイクロVMでの使い方が登場してきた。 今回作ったgokvmは標準ライブラリのみを使いGo言語で実装したもので、 全体で1,500行程度(ブログ記事作成時点)なので、 自分と同じようにKVMやLinuxのブートプロセスに興味のある方にはとっかかりとして役立つかなと思う。
コミットログを見ながら、何をどう実装したのかについて振り返ってみる。
2021/1/30 プロジェクト始動 632c6e0
最初のコミット。README.md、.gitignore、LICENSEファイルを配置しただけで、 特に特筆することはない。似たようなプロジェクト 4 5 や LWN.net の記事 6 を調べていた。 ミニマムな実装で Linux ユーザランドまでブートさせるようなものは見当たらなかった。 ざっと調べただけなので調査漏れがあるかも。 もともとはkvmtool 4 がその立ち位置だったのかもしれないが、 ちょっとコードが巨大に感じた。kvm-host.c 5 は250行程度のCのコードでkernelのブートができるが、 ユーザランドまでは到達できていないようだ。
2021/2/4 bzImage・initrdのビルドとKVMのラッパー実装 69e3ebb
動作確認用のbzImageとinitrdを make コマンドから生成できるようにした。 bzImageはLinux Kernel本体、initrd はメモリ上の一時的なファイルシステムに対応する。 Linux Kernel バージョンはプロジェクト開始時点で最新の 5.10 を使った。 make tinyconfig を実施したのち、make menuconfig を使って追加で必要なconfigを有効にした。 initrdは、Busyboxをベースとした。 Linux KernelとBusyboxの .config は、リポジトリの中で管理しているので、 詳細はそちらを参考にしてください。 CIの選定において、Github Actionは /dev/kvm を利用できないとのことだったので、 Travis CIを選択した。
KVM API は ioctl によって制御するような構造になっている。 そこで、ファイルディスクリプタを受け取り、各種KVM APIを実装できるような簡単な ioctl のラッパー関数を実装した。 構造体については、Linuxのヘッダファイルから必要なものを Go 言語の構造体として移植した。
各種レジスタの初期化も行った。 全てのセグメントディスクリプタでBase=0、Limit=0xFFFFFFFFとして、 フラットなセグメンテーションを行うように設定した。 G(Granularity)フラグは1として単位を4Kbyteとした。 簡単のため、CR0のPE(Protected Mode Enable)を1として、起動直後からKernelがProtected Modeで起動するようにした。 つまり、ブートローダのようなブートストラップの機構は必要ない。 この時点ではゲスト物理メモリのサイズは固定値で 1GBとしている。RIPはカーネルの先頭アドレスを指すように変更した。
出典:Intel 64 and IA-32 ArchitecturesSoftware Developers Manual
出典:Intel 64 and IA-32 ArchitecturesSoftware Developers Manual
2+2=4を計算するコード 6 をテストとして追加した。 さらに、Go言語のLinter golanglint-ciを導入して、テスト駆動で開発できるような体制を整えた。
2021/2/5 code climate の導入 cb71b9d
CI が気になってきた。カバレッジやコード品質を自動的に測定してくれる Code Climate というサービスを導入した。
2021/2/6 KVM APIの不足分を追加 458753d
KVM_SET_TSS_ADDR
、KVM_SET_IDENTITY_MAP_ADDR
、KVM_CREATE_IRQCHIP
、KVM_CREATE_PIT2
、KVM_GET_SUPPORTED_HV_CPUID
、KVM_SET_CPUID2
が不足していた。
CPUIDは"KVMKVMKVM"となるよう以下の設定を行った 7。
eax = 0x40000001
ebx = 0x4b4d564b
ecx = 0x564b4d56
edx = 0x4d
20201/2/7 Kernelのブートまで 099cc55
The Linux/x86 Boot Protocol 8 を参考にして、 bzImageをどのように起動するかについて調べた。 関連しそうなところを以下の表に引用する。
Offset/Size Proto Name Meaning 01F1/1 ALL(1) setup_sects The size of the setup in sectors 0218/4 2.00+ ramdisk_image initrd load address (set by boot loader) 021C/4 2.00+ ramdisk_size initrd size (set by boot loader) 0228/4 2.02+ cmd_line_ptr 32-bit pointer to the kernel command line
bzImageには以下のようにOffset 0x202にマジックナンバー HdrS が記載されているので、 ひとまずそれを探してから他のヘッダを見ていくとよい。
$ hexdump -C bzImage -s 0x202 -n 4
00000202 48 64 72 53 |HdrS|
00000206
今回はカーネルを0x100000に配置し、 RIPも同アドレスを初期値とした。realmode用のコードやブートローダーについては、 今回のスコープ外とした。Protected Mode のエントリポイントは、 boot protocol 内の setup_sects を用いて、 bzImageの先頭から(setup_sects + 1) * 512 バイト目となる。
initrdは0x0f000000に配置した。 このinitrdのアドレスについては実装をサボっているので、後々直したい。 ちなみに kvmtool では動的に空いているメモリ領域を探している 9。 まとめると、全体のメモリマップは以下のようになる。
InitialRegState GuestPhysAddr Binary files [+ offsets in the file]
0x00000000 +------------------+
| |
RSI --> 0x00010000 +------------------+ bzImage [+ 0]
| |
| boot protocol |
| |
+------------------+
| |
0x00020000 +------------------+
| |
| cmdline |
| |
+------------------+
| |
RIP --> 0x00100000 +------------------+ bzImage [+ 512 * (setup_sects + 1)]
| |
| Protected Mode |
| Kernel |
| |
+------------------+
| |
0x0f000000 +------------------+ initrd [+ 0]
| |
| initrd |
| |
+------------------+
| |
0x40000000 +------------------+
カーネルコマンドラインパラメータは cmd_line_ptr で与えられるアドレスに null 終端の文字列として書き込むとよい。 この時点で起動してみると、 カーネルのブートメッセージがIOポート 0x3f8 経由で取得できるようになった。 initプロセスの起動についてはこれから。
2021/2/11 8250 UART Serial のエミュレーションを追加 95a61ba
カーネルの起動メッセージは出力されるが、 なぜかユーザプロセスの出力はない。 シリアル初期化箇所のデバッグのためにカーネルにパッチを当てたり、 Busyboxの inittab を修正したりしたものの原因はよく分からなかった。 QEMUやkvmtoolのioctl を strace してみて、 どうやらちゃんとSerialをエミュレートしなければならないと気づいた。
カーネルは起動中に Serial の初期化を行っている。 この初期化では単純にIOポートに読み書きするだけでは不十分で、8250ファミリを模倣して、 割り込みを入れたりキューの機構を実装したりということが必要になる。
8250ファミリの仕様を見ながら、必要そうなレジスタ(PBR、THR、IET、LCRなど)の挙動を模倣した。 あんまり関係ないレジスタは無視して、実装をサボっている。 割り込みはKVM_IRQ_LINEでLevelを0->1として入れているが、これで正しいのだろうか。
ここまで実装するとログインシェルが出力され、キー入力もできるようになった。 rootユーザでログインできるように、/etc/passwdや/etc/init.d/rsSを適当に配置した。
2021/2/12 termiosを独自実装 08037be
Raw Inputを受け取る部分は golang.org/x/term に依存していた。 必要な部分だけを自分で再実装した。ioctlでtemiosを操作するようにするだけ。
2021/2/12 goreleaserの導入 20b2850
git tagを元に自動的にreleaseしてくれる便利なツール。 稚拙ながらv0.0.1としてリリースできた。
これから
ディスクやネットワーキングなどデバイスエミュレーションを拡充したい。 Linux以外のOS、x86-64以外のアーキテクチャについても取り組みたい。