BitVisor本体のブート仕様について簡単に紹介します。
BitVisorのソースコードは https://bitbucket.org/bitvisor/bitvisor からダウンロードできます。
概要
BitVisor本体を起動する方法には以下の種類があります。
- Multiboot Specification (GNU GRUB, BIOS用)
- boot/loader (BitVisor付属, BIOS用)
- boot/uefi-loader (BitVisor付属、UEFI用)
これらはそれぞれ微妙にロードのされ方が異なっていますので、それぞれ説明します。
Multiboot Specification
Multiboot Specificationでは、Multiboot headerというヘッダーを内部に持つELFバイナリーを使用します。このヘッダーは先頭の8,192バイト以内になければならないとか、いろいろ決まりがあります。
Multiboot headerはcore/entry.sにあって、.entryというセクションに置かれます。このセクションは、bitvisor.ldsというファイルにあるリンカースクリプトによって、リンク時に一番先頭に置かれるので、必ず先頭の8,192バイト以内に入ります。BitVisorでは最小限のフィールドしか使用していません。その中のフラグで、page alignと、Multiboot informationのmem_*フィールドを埋めることをブートローダーに要求しています。
Multibootを使用した場合は、物理アドレス0x100000 (1MiB)に仮想アドレス0x40100000が来るような形で読み込まれて、32bit保護モード、ページングオフの状態でcore/entry.sにあるエントリーポイントに入ります。エントリーポイントには以下のような簡単なコードにより32bit動作かどうかを判定する処理があります。
# boot/loader jumps to here in real-address mode
test $0xF9F9F9F9,%eax # (test;stc;stc in 16bit mode)
jc 1f # must be short jump for 16bit & 32bit
shl %al
inc %eax # (rex prefix in 64bit mode)
rcr %al # undo %al
jnc uefi64_entry
jmp multiboot_entry
32bitの場合は見ての通りのコードが実行されます。test命令はキャリーフラグをクリアするのでjc命令は分岐しません。次に最下位ビットをセットした状態で右ローテートが行われるので今度はキャリーフラグがセットされ、jnc命令も分岐せず、multiboot_entryにジャンプします。
multiboot_entryではブートローダーから渡されたMultiboot informationのアドレスが入っている%ebxレジスターを保存し、bssセクションの消去を行った後、セグメント等の初期設定、ページテーブルの準備をしてページングをオンにし、Cの関数vmm_mainを呼び出します。
miniosと呼ばれるモジュールがある場合は、ブートローダーによって事前に読み込まれ、BitVisorはMultiboot informationの情報を元に処理します。
boot/loader
boot/loaderにあるブートローダーは、ディスクの指定LBAから指定セクター数分をメモリーに読み込んで実行する、みたいな簡単な処理を行うものです。BitVisor 1.3までのboot/loaderは、BitVisorが動くだけの最低限のMultiboot informationを作成して、保護モード移行を行いMultibootの場合と同じようにブートしていました。もともと、boot/loaderを単純化するために、リンカースクリプトbitvisor.ldsにちょっとしたトリックが加えてあります。これによって、bitvisor.elfのファイルの先頭が物理アドレス0x100000 (1MiB)に来るように読み込んで、Multiboot informationを作って保護モードでエントリーポイントにジャンプするだけで良いようになっていました。
しかし、コードサイズの制約から凝ったことができないため、主要なコードがcore/entry.sに移されました。現在のboot/loaderは、保護モードへの移行すらも行わない極めて単純なものになっています。具体的には、スタックに必要な情報を積んだ後、bitvisor.elfの先頭65,536バイトを物理アドレス0xF000以降に読み込み、コードセグメントを0xF00、インストラクションポインターをエントリーポイントのアドレスの下位16ビットにするだけです。
これで、core/entry.sのエントリーポイントに16ビットのリアルアドレスモードで入ってきます。エントリーポイントのコードを16ビットのリアルアドレスモードで実行すると以下のコードになります。
$ objdump -d -m i8086 bitvisor.elf|sed -n /4010100c/,/40101011/p
4010100c <entry>:
4010100c: a9 f9 f9 test $0xf9f9,%ax
4010100f: f9 stc
40101010: f9 stc
40101011: 72 10 jb 40101023 <entry+0x17>
test命令の後stc命令でキャリーフラグがセットされますので、jb (core/entry.sではjc) 命令で分岐します。core/entry.sの中で、先頭の65,536バイトを物理アドレス0x100000にコピーした上で、続きの読み取り処理が実行されます。そしてMultiboot informationが必要最低限だけ作成されて、Multibootの時と同様の処理に移ります。
また、boot/loaderはインストールスクリプトにも工夫があります。ブートローダー本体はELFヘッダー等の解釈を (エントリーポイントを見る以外) 行わないため、bssセクションのサイズがわかりません。それで単純に作ると、後続のminiosモジュールを壊してしまうという問題が起こったため、BitVisor 1.3までは、リンカースクリプトでbssセクションをわざとdataセクションに埋め込んでしまうことによって回避していました。しかし、バイナリーが巨大化してしまうため、現在はインストールスクリプト内でbssセクションのサイズを特定し、miniosモジュールの手前にそのぶんの隙間を作る仕組みとなっています。
bootloaderusb
boot/loaderにはbootloaderusbというブートローダーが入っています。これは、USBマスストレージからBitVisorを起動後、続けて内蔵のHDDやSSDから起動を続けようという大変胡散臭いプログラムです。
こちらはBitVisor 1.3の頃から変更されていません。そのため、32ビット保護モードへの移行も内部で行っています。MBRには、パーティションテーブルやdisk signatureがあるので、ブートプログラムは440バイト (0x1b8バイト) に収めなければなりません。bootloaderusbのプログラムは (さらに縮めることはできそうですが) この限界ギリギリに達しています。
BitVisor 1.3のbootloaderとの差分を見ると、以下の差分があります。
- DATA16セグメントのディスクリプターと切り替え処理の削除
- hookcodeの追加
- %eaxレジスターをクリアする場所をより手前に移動し、そこにフックインストール処理を追加
まず、DATA16セグメントに関しては、プログラムサイズの削減が目的で、ディスクリプターの8バイトと、リアルアドレスモードに戻す処理でセレクターをロードする8バイトを削減しています。
次に、hookcodeについては、DISK BIOS (int $0x13) のフックを行い、ドライブ番号をずらすものです。ドライブ番号の0x80がUSBメモリー、0x81が内蔵HDDやSSDになっているのをずらして、0x80が内蔵HDDやSSDとして見えるようにします。以下のような極めて簡単なコードで、割り込みベクターの中の使用されていなさそうな場所である物理アドレス0x25f以降に書き込まれます。
hookcode:
inc %dl # 025F
jge hookdecjmp # 0261
int $0x9B # 0263 (pushf; lcall *0x26C)
dec %dx # 0265
lret $2 # 0266
hookdecjmp:
dec %dl # 0269
hookjmp:
ljmp $0,$0 # 026B
hookend:
hookorig = hookjmp + 1
%dlがドライブ番号です。インクリメントして、サインフラグ (SF) とオーバーフローフラグ (OF) が一致する場合、すなわち、結果が0x80になった場合 (SF=OF=1)、および、0x00から0x7fになった場合 (SF=OF=0) には、デクリメントして元に戻して、元のルーチンにジャンプします。ロングジャンプ命令のジャンプ先には、フックインストール処理内で元の割り込み処理ルーチンのアドレスが書き込まれます。
それ以外の0x81から0xffになった場合は、そのままで元の割り込みルーチンを呼び出します。うまい具合にint命令で呼び出せる位置にロングジャンプ命令のオペランドが来るようにしてあるので、2バイトのint命令で呼び出せます。割り込み処理が終わったらドライブ番号をデクリメントしますが、バイト数削減のため%dlではなく%dxをデクリメントします。0x81から0xffなので%dlをデクリメントするのと (フラグレジスターを除いて) 同じ結果となります。最後に、DISK BIOSはキャリーフラグでエラーを通知するので、フラグレジスターを残したままリターン (lret $2) します。
最後のフックインストール処理は、Multiboot information作成直前の32ビットコードで、一般的なrep movsb命令により行っています。
なぜBitVisor 1.4になっても変更されていないかというと、このDISK BIOSフックルーチンは、BitVisor本体の読み込み後に入れなければならないわけですが、現在のbootloaderの仕組みではBitVisor本体の中で読み込み処理を行うので、読み込み後に入れるには、BitVisor本体にフックルーチンを入れなければならなくなってしまっているためです。
boot/uefi-loader
UEFIではMultibootの仕組みは一切使用しません。UEFI環境とBIOS環境との大きな違いは、読み込むアドレスをBIOS環境のように物理アドレス0x100000固定にしていない点です。BIOS環境は1981年のIBM PC互換で、メモリーは好きなように使ってねというスタイルでしたが、UEFI環境では、ファームウェアにデバイス制御をやめさせるまで (ExitBootServicesまで)、ファームウェアの管理下におかれるのでファームウェアでのメモリー確保が必要となります。
もしかすると物理アドレス0x100000の確保もできるかも知れませんが、BIOS環境ではBitVisor自身が結局上位アドレスへ移動していたことから、UEFI環境では最初から移動しなくて済む位置にメモリーを確保して読み込んでしまおうというスタイルになっています。しかし、ブートローダーはBitVisorが確保したいメモリーのサイズ (128MiB) を知らないので、最初から全部確保して全部読み込むというふうには書けない部分があって、ブートローダーは必要最小限の部分を読み込む仕組みになっています。
ブートローダーの動きとしては少しだけboot/loaderに似ているところがあります。0x40000000 (1GiB) 未満のアドレスから65,536バイトのメモリーを確保し、そこにbitvisor.elfの先頭を読み込んで、エントリーポイントを呼び出すところと、その続きの読み取りはその65,536バイトに含まれるコードによって行われるところです。なぜ0x40000000 (1GiB) 未満のアドレスにしているかは後述します。
UEFI対応は現在のところ64bit環境のみを前提としています。エントリーポイントを64bitで実行すると以下のようになります。
$ objdump -d -m i386:x86-64 bitvisor.elf|sed -n /4010100c/,/40101018/p
4010100c <entry>:
4010100c: a9 f9 f9 f9 f9 test $0xf9f9f9f9,%eax
40101011: 72 10 jb 40101023 <entry+0x17>
40101013: d0 e0 shl %al
40101015: 40 d0 d8 rcr %al
40101018: 0f 83 09 02 00 00 jae 40101227 <realmode_entry_end>
32bitの時にあったインクリメント命令が、特に意味のないREX prefixに化けているため、右ローテートの前に最下位ビットのセットがされません。よってjae (core/entry.sではjnc) 命令で分岐します。
このときインストラクションポインターはどこにあるかわからないままですが、物理アドレスと一致している状態です。それを元に、core/entry.sの中では割り込み禁止でいきなりページテーブルを準備して、Cのuefi_init関数を呼び出します。uefi_init関数とその他必要な関数などは、__attribute__ ((section (".entry.text")))
のような属性指定により.entryセクションに入れられているため、先頭の65,536バイトに含まれるようになっています。
UEFIの仕様にもページテーブルを切り替える場合の話が書いてあって、それに沿うような感じで実装されています。自前のページテーブルを使用中は割り込み禁止、とか、UEFIファームウェアを呼び出す場合は元のページテーブルに戻す、とかです。最初の処理のUEFIファームウェア呼び出しはuefi_entry_call関数を経由して行っています。
core/uefi.cの中で、必要なサイズのメモリー確保、ファイル読み込みが行われて、すべて成功したらcore/entry.sに戻って、uefi_entry_startのところに来ます。そしてCの関数vmm_mainを呼び出しますのでここからはBIOS環境の場合と同じ、なのですが、引き続き文字表示などで、UEFIファームウェアを呼び出す処理が一部存在します。今度はGDTまで切り替わっているので面倒なのですが、calluefi関数がそのへんのめんどくさい切り替えをやっています。
さて、なぜ最初に0x40000000 (1GiB) 未満のアドレスに読み込んでいたか、ですが、これは最初のCのuefi_init関数を呼び出すための準備と関わりがあります。UEFIアプリケーションはファームウェアによってリロケーションが行われるのですが、独自の方法でELFバイナリーをロードするBitVisorの場合はリロケーションがされません。そのために、ページングを利用してリンク時に決定した仮想アドレス空間を使ってCのコードを実行しています。この時、仮想アドレスは0x40000000 (1GiB) 以降を使っているので、最初の処理をわざと0x40000000 (1GiB) 未満のアドレスに置いて、仮想アドレスの0x40000000 (1GiB) 未満のアドレスを物理アドレスと対応付けることで、切り替え処理を大幅に簡略化できます。0x40000000 (1GiB) 以降のアドレスに置くと切り替えが複雑になることと、0x40000000 (1GiB) 未満のアドレスという制約があっても、わずか65,536バイトが確保できないとは考えられないことから、このようなアドレスの使い方になっています。
参考資料
BitVisor Summit 2の以下の発表資料が参考になると思います。
http://bitvisor.org/summit2/slides/bitvisor-summit-2-03-eiraku.pdf