ホスト型ハイパーバイザー の作り方 part.1
こんにちは、かずみん (@k2warugaki) | Twitter です。
この記事は、Linuxその2 Advent Calendar 2020 - Qiita の21 日目の記事です。
前日の記事は、 aiinkiestism - Qiitaさんによる Zorin OSとStarLabsの紹介 - Qiitaでした。
CPUの仮想化支援機能やバイナリートランスレーションを利用しないで、linux kernelの機能を用いたホスト型ハイパーバイザーの作り方を解説します。今回は、そのpart.1です。 ※続編が出るかもしれない...?まだハイパーバイザー を開発途中で序章しか書けないので、part1としています。
大まかな手順
- まず、好きなOSのエミュレーターを作ります。
- 次に、ハイパーバイザー 化します。
環境
エミュレーターを作る
今回、エミュレーターを作る部分ってのは省略します。 代わりに、昔私が書いたこの記事を見ると良いかも。エミュレーターの作り方(はりぼてOSが動く) - KazuminEngine
※バイナリトランスレーションしない、命令を全部エミュレートするものを用意してください。 エミュレートするOSによっては、ハイパーバイザー 化できないものもあるので、注意してください。
ハイパーバイザー化
概要
何ができれば、パイパーバイザーと言えるのかを説明します。
ゲスト用のプロセスを立てて、その中で、次のことをすれば良い。
センシティブな命令やアクセス不可の資源へのアクセスをトラップして、エミュレートしてあげる ・・・これを(1)とする
それ以外をcpuが直接実行 ・・・これを(2)とする
アルゴリズム
- (1)について、
sigactionというlinuxのシステムコールを使います。それを使うと、特定のシグナルが発生した際に呼び出されるハンドラーを設定することができる。
センシティブな命令とアクセス不可の資源へのアクセスをした時は、SIGSEGVシグナルかSIGILLシグナルが発行される。
それらシグナルに対して、ハンドラーを設定します。
ハンドラーが呼び出されると、その呼び出された時のコンテキスト情報がハンドラー関数の第3引数に渡されるので、それをsig_ucontext_t構造体に変換してやるとripが手に入るので、そこからオペコードなどの情報を得ます。また、レジスタなどの情報も得ることができます。 そのオペコードをエミュレーターで実行してあげます。この後に、忘れてはいけないのが、シグナル復帰するために、ripを更新することだ。
- (2)について、
cpuが命令を直接実行するのは簡単です。まず、動的メモリに動かしたいOSを読み込んであげます。そして、その先頭アドレスを実行するように、アセンブラのjmp命令に先頭アドレスを渡してあげることでできます。(プログラムカウンターをOSの先頭アドレスにする)
実装(サンプルプログラム)
コメントを入れておくので、それを読んでください。
以下のサンプルコードでは、0xFA(cli)にしか対応していない。
全部のコードを書くと大変なことになるので、必要最低限を載せています。
以下、main関数の中。in main.cpp
int main() { int p_id, status; if ((p_id = fork()) == 0) { //子プロセススタート emu = new Emulator();//エミュレーター本体を作成 emu->LoadBinary("../xv6-public/xv6.img", 0x7c00, 1024 * 1024 * 1024);//OSを動的メモリ上に読み込む struct sigaction sigact; sigact.sa_sigaction = trap; sigact.sa_flags = SA_RESTART | SA_SIGINFO; sigaction(SIGSEGV, &sigact, (struct sigaction *)NULL);//SIGSEGVが呼ばれた時に呼ばれるハンドラーにtrap関数を登録 sigaction(SIGILL, &sigact, (struct sigaction *)NULL);//SIGILLが呼ばれた時に呼ばれるハンドラーにtrap関数を登録 _change_pc(emu->memory + 0x7c00);//アセンブラで書かれている_change_pc関数を呼び出す。 delete emu; exit(EXIT_SUCCESS); } wait(&status); // 子プロセス全部終わるまで待つ exit(EXIT_FAILURE); }
以下、SIGSEGVとSIGILLが起こったときに、呼び出されるtrap関数。in main.cpp
typedef struct _sig_ucontext { unsigned long uc_flags; struct ucontext *uc_link; stack_t uc_stack; struct sigcontext uc_mcontext; sigset_t uc_sigmask; } sig_ucontext_t; void trap(int sig_num, siginfo_t * info, void * ucontext){ instruction_func_t* func; sig_ucontext_t* uc = (sig_ucontext_t *) ucontext;//voidポインター のコンテキストの型変換 uint8_t * pc = (uint8_t *)uc->uc_mcontext.rip;//ripポインター を入手 func = instructions16[*pc];//オペコードに対応する関数をとってくる func(emu,uc);//実行 }
以下、命令が書かれている、 instructions16配列の本体。in instruction16.cpp。
instruction_func_t* instructions16[0xffff]; namespace instruction16{ void cli(Emulator *emu,sig_ucontext_t* uc){ emu->set_interrupt(false);//割り込みが起きないようにする。 uc->uc_mcontext.rip++;//ripを一つ増やす。 } void InitInstructions16(){ instruction_func_t** func = instructions16; func[0xFA] = cli; } }
以下が、プログラムカウンターを動かしたいOSの先頭に持っていくためのアセンブリ。in pc.S。
GLOBAL _change_pc SECTION .text _change_pc: jmp rdi//rdiは第一引数。jmp命令でemu->memoryアドレスにジャンプ ret//戻ってこないので、なくてもいいが一応、リターン
参考文献
最後に
私はまだ、ハイパーバイザー の開発途中です。これから命令をたくさん追加していき、割り込みなどに対応していく予定です。続きの記事を書けるように、どうか温かい目で見守っていてください。
私のリポジトリは、こちらです。一人で開発していて、読みやすさとか気にしていないので、汚いコードですが大目に見てください。