BitVisor の UEFI 向けのブートローダーを読む(2nd stage)

この記事は BitVisor Advent Calender 2017,20 日目の記事(大遅刻)です。

UEFI 向けのローダーでも読んで記事書くかーって思ってたら先にやられてしまってウンウン唸ってましたが, 彼の記事の続きを勝手に書けば良いことに気がつきました。

では元気よく,UEFI の 1st stage loader から entry_func に飛んだ後を追っていきましょう。
いきなり findgrep を使っても良いのですが,折角手掛りがあるのでまずリンカスクリプト, bitvisor.lds を読んでみます。1st stage loader を読む記事に因ると,bitvisor.elf0x10000 だけ読んで,paddr (おそらく物理アドレス,physical address の略でしょう)に読んで,そこからエントリポイントを計算して,entry_func に飛んでるらしいです。

bitvisor.lds リンカスクリプト 2 行目,phys = 0x00100000; となっており,11 行目, .entry : AT (phys + (code - head)) { をみるとこのアドレスは bitvisor.elf.entry というセクションになってるらしいので,このセクションを探せば良さそう。ついでに,このスクリプトから bitvisor.elf がメモリに置かれるときの仮想アドレスの開始位置もわかります。

core/entry.s .entry セクションをみると,まず multiboot のヘッダが書いてあって,次に 32-bit のコードが始まってます。test 命令の結果,もしリアルモードであるならば,jc によってリアルモード用の初期化コードに飛んでますが,今回は割愛。
で,その後,32-bit なら jnc 命令で分岐せず multiboot のためのコードにジャンプ,ロングモードなら uefi64_entry に分岐しているわけですが,この分岐のやりかたが結構トリッキーというか,あんまりちゃんとなんでこれでいいのかわかってないなあと思ったら, BitVisor本体のブート仕様 2 年前のえいらくさんの記事にしっかり書いてあった……。やだ,もしかしてこの記事を書く必要なかった……?
でももう書き始めちゃったのでこのまま書いちゃいます。

core/entry.s まず後々 uefi_init 関数の引数として 1st stage loader から渡された引数を渡すために,整数として渡されてる rcx, rdx を ポインタとして扱うため rdi, rsi へ,r8rdx にコピーしておいて,準備してますね。そのあとは uefi_init を呼び出すまでページテーブル準備してるだけっぽいですね。

core/entry.s entry_pd とかのページディレクトリとかの定義はこの通り。まあこれも見たとおり。uefi64_entry 17 行目(この Gist での行数。以下,注釈なく行数が書かれた場合基本的に Gist に貼ったコードでの行数とする。)で ecx レジスタはゼロにされており,また,18 行目でページディレクトリのアドレスを ebx レジスタに(entry_pd ラベルは相対アドレスなので,インストラクションポインタに足して絶対アドレスを取ってる,で合ってますよね……?→えいらくさんから解説頂きました。mov a, %raxmov a(%rip), %rax は同じだけど前者が絶対アドレス,後者が %rip 相対アドレスになるそうです。 ),19 行目でこの行に続く 20 行目のラベルの位置の仮想アドレス計算して,eax レジスタにロードしているようです。ここで出てくる head は,リンカスクリプトの 9 行目で定義されてるアドレスですね。21 行目〜25 行目のループで,さきほど計算した仮想アドレスを格納している rax レジスタとページディレクトリの物理アドレスを格納している rbx を利用して,ecx をループカウンタにしてエントリを 512 コほど設定しています。

そのあとは,entry_pd の先頭アドレスから 7 バイト目を entry_pd の最初に設定したり,add を利用して PML4 テーブルと PDP テーブルを設定してますね。また,この時,rax レジスタの中身が,head-0x100000 にページテーブルのぶんのアドレスを足したアドレスになってるので,ここから head-0x100000 を引いたアドレスを uefi_entry_physoff に入れて,このエントリポイントの物理アドレスのオフセットを格納しているようです。

ここからは簡単で,セグメントレジスタを全部スタックに積んで,cr3 のアドレスとスタックポインタのアドレスをそれぞれ uefi_entry_cr3uefi_entry_rsp に記録し,また,uefi_entry_ret という関数の終了処理をする部分のアドレスを uefi_entry_ret_addr に記録しています。これは,おそらく,えいらくさんのスライドを参考にするに VMM から UEFI に戻るときにこれらの記録したアドレスをひっぱり出してまた UEFI に帰ってくるためではないでしょうか?
さて,その後設定した PML4 テーブルのアドレスを cr3 レジスタに設定し,スタックのアドレスを設定し,57 行目のラベルにジャンプします。ここで,uefi_init 関数をコールしています。

core/uefi.c uefi_init 関数はこの通り。
まずは 1st stage loader で entry_func の引数として渡されている UEFI ローダーの引数とブートオプションをこの関数の引数として受けとってます。
頻りに uefi_entry_pcpy を呼び出してますが,これは,

core/entry.s をみると一瞬ページテーブルを UEFI に戻して,第二引数に渡した UEFI で扱う SystemTable とかの情報を第一引数で渡した変数の物理アドレスに書き込んでから元のページテーブルに戻してるっぽい。uefi_entry_pcpyadd とか sub とかを rdx レジスタにしてるのは書き込みたい変数のサイズを確認してるからで,movsqmovsb でオペランドが指定されてないのは,この関数の第一引数と第二引数として既に rdirsi が設定されてるから第二引数から第一引数へデータがコピーされてる,んですよね?
これによって,60 行目まで SystemTableBootServices が持つ重要な関数のアドレスや情報をひっぱってきてます。 その後,起動時のオプションから,bitvisor.elf のファイルハンドルと,読み込んだアドレスと,そのサイズを取得しています。その後,

core/mm.c これを使って BitVisor のサイズを取得してから,86 行目でこのサイズに必要なぶんだけ UEFI の機能でページを確保しています。 そのあと,96 行目(_PRINT ("Load");)から 107 行目(_PRINT ("ing");)の間でアライメントして余計なページを開放してるみたいですね(ちょっと本当にそうなのか自信がないです)。しかしこの“Loading”の表示のしかたちょっと面白くて好きです。
108 行目では改めて UEFI の機能で確保したページに BitVisor の読み込んだバイナリをコピーしてます。続く 112 行目から続くループで,64 KiB しかロードしてなかった bitvisor.elf の残りのデータも全部ロードしています。
その後,ロードが完了したら uefi_entry_start を呼び出し,core/entry.s に戻ってきています。
core/entry.s まずこの関数には引数として改めてロードした BitVisor の確保されているアドレスの先頭位置が rdi レジスタに保存されています。 また,関数のプロローグでまたページテーブルやスタックを UEFI のものに戻したあとに,DIFFPHYS に設定されている値 0x40000000,つまりいままで 2nd stage loader が使ってた仮想アドレスのローダーが読まれてた位置と BitVisor がロードされているアドレスを足した値を entry_pml4 などのラベルから引いたアドレスに,rdi の値を設定しています。これで,読み込み直して BitVisor の物理アドレスがズレた分だけ動かした値をページテーブルに改めて設定しているようですね。466 行であらためて cr3 レジスタに新しいページテーブルを登録した後に,BitVisor のロードされている先頭アドレスを vmm_start_phys に保存して,BitVisor がロードされている物理アドレスを保存しておきます。また,GDTIDT,セグメントレジスタ,UEFI のページテーブルへのアドレスといったものは,全部 calluefi_uefi_* へ保存しています。これの実体は core/calluefi_asm.s にあります。
479 行目から 485 行目ではメモリの初期化をしており,486 行目から 492 行目は cr4 レジスタの設定と cr3, cr4 レジスタの保存をして,GDTR のアドレスをロードしてから,496 行目で callmain64 を呼び出しています。

core/entry.s ds,es,ss を 0x20,fs,gs をゼロに設定し,スタックポインタを設定,最初は bspinit_done に何も触れてないので jne で分岐せずにそのまま処理を続け,vmm_main に飛ぶことで,ようやく BitVisor 本体の処理が始まります。なお, マルチコアで,BSP にならなかったコアだと,bspinit_done が既に設定されている状態で jne で分岐し,apinitproc0 の呼び出しのほうに行くと思われます


とりあえず,BitVisor 本体の処理に飛ぶまでの,VMM Loader(2nd stage)を読んできました。間違って読んでる箇所があったら教えてください。 BitVisor はなんとなくどういうことに使われてるかは知ってたけどちゃんとした処理は知らなかったので,最初のローダーの部分だけでも,後々の切り替えや UEFI に戻るための仕掛けなんかがとてもなるほどと感心しました。すごい。どうせなのでこの後の部分も色々読もうと思います。