coreboot (QEMU/ARM) のコードリーディング
GSoC2019 の一環として参加している coreboot プロジェクトのコードリーディングをしました。coreboot は BIOS/UEFI を置き換えることを目的としているオープンソースのプロジェクトです。GSoC における私のプロジェクトは、coreboot に QEMU/AArch64 のサポートを加えることです。AArch64 は ARM の64ビットモードアーキテクチャで、ARMv8 以降の CPU で採用されています。プロジェクトを実装するために、まずは AArch64 と一部実行フローが重なる QEMU/ARM のためのコードを大まかに読んでみました。
現在の coreboot では QEMU を使用したエミュレーションをサポートしており、対象のプラットフォームは x86、ARM、POWER8、RISC-V です。
本記事では、coreboot のルートディレクトリにて
$ qemu-system-arm -bios ./build/coreboot.rom -M vexpress-a9 -nographic
と実行した際に、Linux Kernel や GRUB2 などの任意のペイロードに処理を渡すまでを追ってみます。
Architecture
coreboot は Bootblock、Romstage、Ramstage と呼ばれる3つのステージから成り立ちます。それぞれのステージはコンパイル時には別のバイナリとして生成されます。そして最後に全てのバイナリをリンクし、ROM に書き込むためのファームウェア coreboot.rom
を生成します。なぜ最終的には1つのバイナリにするのに、途中ではバラバラなバイナリとして生成するのかは、最初のステージ以外は LZMA 圧縮アルゴリズムを用いてサイズを縮小するためです。RAM よりも容量の制限が厳しい ROM では、圧縮によってサイズを小さくすることが利点となります。現在のステージが次のステージを解凍し、呼び出すことで進んでいきます。
各ステージの役割は、
Bootblock
- ヒープとスタックを使用するためのCache-As-RAM: DRAM の初期化を行う以前は CPU Cache を記憶領域として使用します。これにより、初期の頃から高級言語でコードを書くことが可能です。 *1
- スタックポインタの設定
- BSS 用のメモリをクリア
- 次の Romstage を解凍し、メイン関数を呼び出す
*1: 以前は Cache の代わりに全てローカル変数をレジスタに格納することによって、この問題を解決していました。ROMCC という特殊なコンパイラによって、ローカル変数をレジスタの値に置き換えていたようですが、ROMCC の開発が1人のエンジニアに依存しており、x86 以外の他のアーキテクチャのサポートが難しかったため、Cache-As-RAM に変更をしたそうです。
TODO: Cache-As-RAM、スタックポインタの設定、BSS 用のメモリクリアについてはアセンブリで書かれているようで、まだコードを読めていないです。もしかしたら、x86 特有の処理だったりもするかも。
Romstage
- コンソールの初期化
- 次の Ramstage を解凍し、メイン関数を呼び出す
Ramstage
- プログラミング言語 Ada の初期化: coreboot 内で使用できるが、現在はもうあまり使われていないそうです。
- コンソールの初期化: コンソールに出力する以前には必ず初期化する必要があります。しかし、Romstage でもコンソールの初期化をしていたので、なぜ同じ初期化コードを呼び出す必要があるのかは要確認。
- cbmem の初期化: coreboot 特有の出力方法。
- 例外処理の初期化
- スレッドの初期化
- デバイスの初期化
- チップの初期化
- 接続されている周辺機器の確認
- 周辺機器を繋ぐバスの設定
- バスを有効化
- 周辺機器初期化
- コンパイル時にユーザーが設定できる任意のペイロード (ELF、GRUB2、Kernel 等) を解凍し、呼び出す
以下はそれぞれのステージが UEFI のステージとどのように対応しているかの図です。
Code Reading
C言語で書かれた Bootblock -> Romstage -> Ramstage -> Payload の一連の処理についてコードを読みました。各ステージにおけるエントリポイントは以下の通りです。
- Bootblock: main() - src/lib/bootblock.c
- Romstage: main() - src/mainboard/emulation/qemu-armv7/romstage.c
- Ramstage: main() - src/lib/hardwaremain.c
各ステージが共通で行う処理は、
- 現在のステージで必要な初期化処理等を行う
- 次のステージに関する情報を
prog
構造体によって管理する
この2つのステップを各ステージにて繰り返すことによって、処理を進めています。
ステージの実体は以下の prog
構造体によって管理されます。現在のステージが次のステージの prog
構造体の初期化をします。 entry
には関数ポインタが格納され、現ステージでの処理が終わったらこれを呼ぶことよって、次のステージに移動します。
Ramstage では boot_state
構造体の配列によって必要な処理を管理し、順に実行していきます。id
に現在の状態、run_state
に処理の関数ポインタを格納します。そして、処理が終わったら complete
フラグを立て、次の処理に移動します。
必要な処理は boot_states 配列として予め初期化されており、bs_walk_state_machine 関数の中の while 文によって、各処理が実行されます。
デバイスの検出、デバイスに必要な資源の獲得と有効化等をした後に、payload を呼び出して coreboot の仕事は終了です。以下が具体的に行われる各処理の説明です。
Conclusion
アーキテクチャ依存のコードを struct prog
や struct boot_state
などのラッパーで隠すことによって、どのアーキテクチャでも大まかには同じ流れを辿れるようになっています。全体として、うまく構造化されて綺麗にまとまっているなという感想を持ちました。しかしまだC言語レベルでのざっくりとした理解しかできていないので、アセンブリでの処理も引き続き読み進めていきたいです。
感想、疑問等あればお気軽に@d0iasmまで。
GitHub へのリンク先はあくまでも2019年06月時点で正しい位置なので、今後変更する可能性があるのでご注意ください。