仮想マシンがどのように実現されているか気になったので、勉強がてら簡単なハイパーバイザを作ってみました。ソースコードはGitHubで公開しています。
方針
RaspberryPi3で動作するAArch64向けのType-1(ベアメタル)ハイパーバイザを作ることにしました。名前は"raspvisor"とします。
スクラッチから作り始める気力はなかったので、なるべく流用できるものは使っていくことにしました。そこで、RaspberryPiのOS開発教材であるRPi OSをハイパーバイザに改造していくことにしました。RPi OSはコード量が少なく読みやすい上、割り込み処理、プロセススケジューラ、ユーザプロセス、システムコール、仮想メモリといった機能が一通り実装されています。OSを改造することにしたのは、プロセス管理や仮想メモリ、割り込み処理といった部分を、ハイパーバイザの実装に流用できそうだと思ったからです。
とりあえずの目標は、raspvisor上でRPiOSが動作することとします。
この記事では、どの部分を改造したり新たに実装したのかをARMv8の仮想化支援機構について簡単に説明しつつ紹介します。
参考にした資料
-
Armv8-A Virtualization - Learn the Architecture (https://developer.arm.com/architectures/learn-the-architecture/armv8-a-virtualization)
ARMv8-Aの仮想化について簡潔にまとめられています。図も多いため分かりやすいです。 -
ARM Architecture Reference Manual ARMv8, for ARMv8-A architecture profile
-
「ハイパーバイザの作り方」(http://syuu1228.github.io/howto_implement_hypervisor/)
ARMではなくx86についての記事ですが、仮想化支援機構やFreeBSDのハイパーバイザbhyveの実装について詳しくまとめられており勉強になります。
RPiOSからの主な改造箇所
例外レベルの変更(boot.S)
AArch64には例外レベル(Exception level, EL)と呼ばれる動作モードがあります。例外レベルはEL0〜EL3の4つあり、数字が大きくなるほど権限が大きくなり使用できるシステムレジスタや命令が増えます。一般的には以下のように使い分けます。
- EL0: アプリケーション
- EL1: OS
- EL2: ハイパーバイザ
- EL3: ファームウェア
RaspberryPiはEL3で起動するため、RPiOSでははじめに例外レベルをEL3からEL1に落とします。ハイパーバイザを実装する際にはEL2から書き込み可能なシステムレジスタを変更する必要があるため、例外レベルをEL3からEL2に落とすよう修正します。
システムレジスタの初期化(boot.S)
ハイパーバイザの実装において重要なシステムレジスタのひとつがHCR_EL2
です。これはHypervisor Configuration Registerという名前の通りハイパーバイザのための設定を行うレジスタです。ハイパーバイザとして動作させるために、HCR_EL2
レジスタを適切な値に設定します。
まず重要なビットがAMO
,IMO
,FMO
ビットです。通常、非同期例外(ハードウェアによって発生した割り込み等)はOSが受け取り処理します。しかし、ハイパーバイザがいる場合には、ハイパーバイザがすべての割り込みを受け取り、適切な仮想マシンに割り込みを通知する必要があります。これらのビットを1にすることで、非同期例外がEL2のハイパーバイザにルーティングされるようになります。
VM
ビットは仮想化を有効にするビットです。具体的には、後述するStage 2 translationを有効にします。
T*
ビットは、ハイパーバイザへのトラップを制御します。例えば、TWI
ビットを1にすると、EL1でWFI命令(割り込みが来るまで待つ)が実行されたときにハイパーバイザにトラップされるようになります。TVM
ビットを1にすると、EL1での仮想メモリ関連のシステムレジスタへの書き込みでトラップされるようになります。
VTCR_EL2
レジスタは、後述するStage 2 translationの設定を行うレジスタです。これについても適切な値を書き込んでおきます。
タスクまわりの変更(task.c)
raspvisorでは、RPiOSのタスク(ユーザモードで動作するアプリケーション)を1つの仮想マシンとして扱うよう改造することにします。これは、タスクが仮想アドレス空間やCPU時間を持つという性質が仮想マシンと共通しているからです。
例外レベルの変更
RPiOSのタスクはEL0で動作しますが、これをEL1で動作するように変更します。
Stage 2 translation
RPiOSはページングによる仮想記憶に対応しており、TTBR*_EL1
にセットされたページテーブルにより仮想アドレス(VA)から物理アドレス(PA)への変換が行われます。タスク毎にページテーブルを持ち、タスク切替時にページテーブルも切り替えます。
OSの下にハイパーバイザがいる場合、OSから見える物理メモリも仮想化されている必要があり、もう一段階のアドレス変換が必要です。これを実現するのがStage 2 translationです。Stage 2 translationが有効である場合、仮想マシンから見える物理アドレスは中間物理アドレス(IPA)となり、VTTBR_EL2
にセットされたページテーブルにより行われる二段階目のアドレス変換によりIPAがPAに変換されます。
これに対応するため、仮想マシン毎にStage 2 translation用のページテーブルを用意し、仮想マシンの切り替え時にVTTBR_EL2
にセットするよう変更します。Stage 2 translationではページテーブルエントリのフォーマットが若干異なったり、ページテーブル段数の制約が異なっていたりという落とし穴があり少し苦労しました。
仮想マシンからハイパーバイザへ移る際の処理
システムレジスタの退避処理を追加します。
ハイパーバイザから仮想マシンへ移る際の処理
システムレジスタの復元処理を追加します。また、これから実行を再開する仮想マシンに対応したStage 2 translation用ページテーブルのアドレスをVTTBR_EL2
にセットします。
また、仮想割り込みコントローラをチェックし、割り込み信号が出ている場合にはHCR_EL2
のVI
ビットを1にします。VI
ビットはVirtual IRQ Interruptのことで、セットするとIRQ割り込みが発生している状態になります。割り込みコントローラとしてGICv2以降を搭載している場合は仮想化支援機構を使えるようなのですが、RaspiberryPi3の割り込みコントローラはGICでないためこのような方法をとっています。
ローダの実装(loader.c)
RPiOSでは、ユーザプログラムをファイルからロードするのではなく、カーネルのバイナリの中に埋め込んでおきそこからロードするという方法をとっています。SDカードの制御やファイルシステム操作の必要がなくなるというメリットがありますがかなり不便なので、SDカード上のファイルからロードできるように改良しました。
ロード処理は、アロケートしたページに読み込んだファイルの内容を書き込み、仮想マシンのStage 2 translation用ページテーブルにマッピング情報を書き込むことをファイルの終わりまで繰り返すだけです。ここでついでにメモリマップトIO(MMIO)が行われるアドレス範囲をアクセス禁止に設定しておきます(理由は後述)。
同期例外ハンドラ(sync_exc.c)
RPiOSの同期例外(命令実行に起因する例外)ハンドラでは、システムコールの処理とページフォールトの処理を行っています。ハイパーバイザではそれに加え、HCR_EL2
で設定したトラップの処理やMMIOのエミュレーションも行う必要があり、重要な部分になっています。
WFI/WFE命令のトラップ
現在の実装では、他の仮想マシンへの切り替えを行っています。
システムレジスタアクセスのトラップ
ESR_EL2
レジスタに、どのシステムレジスタであるかやアクセスの種別(読み込み/書き込み)、レジスタ番号の情報がセットされています。これらの情報をもとにレジスタへの代入や必要であればシステムレジスタの更新を行います。
メモリ関連の例外(mm.c)
Translation faultの場合、すなわちIPAに対応するエントリが無い場合は、新しいページを割り当てます。
Permission faultの場合、すなわちアクセス禁止に設定されている場合はMMIOの領域ということです(ローダが設定)。FAR_EL2
にアクセスされたアドレスが、ESR_EL2
にアクセスの種別(読み込み/書き込み)やレジスタ番号が格納されています。これらの情報をもとに、MMIOのエミュレーションを行います。なお、FAR_EL2
にはVAの値が格納されているため、AT
命令でIPAに変換する必要があります。
デバイスのエミュレーション(bcm2837.c)
エミュレーションするデバイスはRaspberryPiと合わせることにし、RPiOSの動作に必要なBCM2835の割り込みコントローラ、システムタイマ、Mini UARTのみ実装しました。いずれもMMIOを介してアクセスされます。デバイスのレジスタやUARTのバッファのような内部状態は仮想マシンごとに持たせています。
動作確認
複数のRPiOSがraspvisor上で動作することを確認し、ひとまず目標は達成しました(使用したRPiOSには、MMU有効化前にisb命令を挟む修正を加えました)。RPiOSはUARTのエコーバックのみのLesson01から始まり最後のLesson06まで徐々にOSへと拡張していく形式の教材になっており、raspvisorの動作確認もLesson01から順番に行っていきました。いきなり大きなものを動かすのに比べ、動作しない場合の原因の切り分けがしやすく助かりました。
また、Linuxの起動も試してみましたが、こちらは残念ながらコンソールへの出力すらされない状態です(少しデバッグしたところ、printk()内部で例外が発生しているらしいとわかったのですが、途方に暮れています)。