Qemu + virt(RISC-V)のカーネルエントリポイントを確認した

Writing an OS in 1,000 Lines写経した。 これはエナガ本の副読本でRISC-Vに向けたOpenSBI依存のOSを書くというオンラインテキスト。写経ではコピペをしないようにすることとわからなかった仕様は確認することを意識してやった。

エナガ本では説明用の自作OSとしてHinaOSが使われている。1000LとHinaOSは同一ターゲット(qemu-system-riscv32 (RV32I) の virt 仮想マシン)に向けた実装だが開始アドレスが異なる。 HinaOSの開始アドレスは0x800000001000Lでは0x80200000になっていた。このちがいについて調べた。

結論

HinaOSはQemuで実行する際に-bios=noneを渡しており、Zeroth Stage Boot Loader(ZSBL)から呼ばれる。 そのため virt マシンのメモリマップの VIRT_DRAMの先頭がエントリーポイントとなる。

一方で 1000L-OS は OpenSBIに依存しており VIRT_DRAM 先頭には OpenSBI が置かれる。そのためカーネルの開始アドレスは OpenSBI と virt マシンの2つが協力して決定する。そのため異なるアドレスになる。 具体的にどのように決まるかは後述する。

詳細

OpenSBIはファームウェアでRISC-V SBI v2.0のリファレンス実装。 qemu-system-riscv32 のデフォルトBIOSとして使われる。

RISC-V SBI v2.0は S-modeやVS-modeで動くブートローダ, OS, ハイパーバイザに向けたインタフェースでシステムコール風にハードウェアへのアクセスなどを提供する。実装はSupervisor Execution Environment(SEE)と呼ばれる。wiki.riscv.orgに置かれている。

OpenSBIはファームウェアビルドを提供していて、ビルドオプションで処理後の次のステージにどう繋げるか3方式の中から選択できる。

  • FW_DYNAMIC
    • Next Address をマシンに渡してもらう方式
  • FW_JUMP
    • Next Address をビルド時に指定する方式
  • FW_PAYLOAD
    • 次のステージ用プログラムをOpenSBIのバイナリに含める方式
      • ボードがFDTファイルを提供しない
      • ボードが複数バイナリをロードしてくれない

qemu-system-riscv32 のデフォルトBIOSは FW_DYNAMIC としてビルドされたもの。

virt でのbootの流れ

  1. リセットベクタにジャンプする
  2. ZSBL が動く
    1. a2レジスタに struct fw_dynamic_info へのポインタを格納する: OpenSBIの要求(仕様箇所を見つけられてない)
    2. DRAMの先頭にジャンプしてOpenSBIが動く
    3. OpenSBIがカーネルに飛ばす

実際に流れを確認する。まず QemuのデバッグコンソールでROM情報をみる。0x1000 にリセットベクタが0x1028mrom.finfo が置かれている。

1
2
3
4
5
6
7
8
(qemu) info roms
addr=0000000000001000 size=0x000028 mem=rom name="mrom.reset"
addr=0000000000001028 size=0x000018 mem=rom name="mrom.finfo"
addr=0000000080000000 size=0x01e0c0 mem=ram name="opensbi-riscv32-generic-fw_dynamic.bin"
addr=0000000080200000 size=0x001876 mem=ram name="kernel.elf ELF program header segment 0"
addr=0000000080201878 size=0x00034b mem=ram name="kernel.elf ELF program header segment 1"
addr=0000000080201bc4 size=0x02189c mem=ram name="kernel.elf ELF program header segment 2"
addr=0000000087000000 size=0x100000 mem=ram name="fdt"

次にリセットベクタの処理を確認すると下のように a2 レジスタに 0x1028 (0x1000 + 40) が格納されたのちになんかジャンプ(jr)してる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
(qemu) xp /10i 0x1000
0x00001000:  00000297          auipc           t0,0            # 0x1000
0x00001004:  02828613          addi            a2,t0,40
0x00001008:  f1402573          csrrs           a0,mhartid,zero
0x0000100c:  0202a583          lw              a1,32(t0)
0x00001010:  0182a283          lw              t0,24(t0)
0x00001014:  00028067          jr              t0
0x00001018:  0000              illegal
0x0000101a:  8000              illegal
0x0000101c:  0000              illegal
0x0000101e:  0000              illegal

QEMUのRISC-Vのブートを読むと当たり前だけど一致している

ここで0x1028 をみると下のようになっている。あやしい 0x80200000 がみえる。

1
2
3
(qemu) xp /10x 0x1028
0000000000001028: 0x4942534f 0x00000002 0x80200000 0x00000001
0000000000001038: 0x00000000 0x00000000 0x00000000 0x00000000

そこでstruct fw_dynamic_infoの定義を確認すると3要素目(+8byte)にunsigned long next_addrがいることがわかる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct fw_dynamic_info {
	/** Info magic */
	unsigned long magic;
	/** Info version */
	unsigned long version;
	/** Next booting stage address */
	unsigned long next_addr;
	/** Next booting stage mode */
	unsigned long next_mode;
// ....

何かの仕様すくなくともOpenSBIの要求に従って次に読み込むアドレスがa2レジスタ経由で渡されることがわかった。

次に Qemu + virt 環境で実際の値がどこからきてるか確認する。

mrom.finforiscv/boot.c#riscv_rom_copy_firmware_info()で読み込んでいた。そして直前に cpu_to_le32(kernel_entry)next_addrに格納している。

この関数はriscv_setup_rom_reset_vec()で呼ばれていて、 そしてvirt_machine_done()に呼ばれている。 virt_machine_done()の中では下のようkernel_entryが決定されている。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    pflash_blk0 = pflash_cfi01_get_blk(s->flash[0]);
    if (pflash_blk0) {
        if (machine->firmware && !strcmp(machine->firmware, "none") &&
            !kvm_enabled()) {
            /*
             * Pflash was supplied but bios is none and not KVM guest,
             * let's overwrite the address we jump to after reset to
             * the base of the flash.
             */
            start_addr = virt_memmap[VIRT_FLASH].base;
        } else {
            /*
             * Pflash was supplied but either KVM guest or bios is not none.
             * In this case, base of the flash would contain S-mode payload.
             */
            riscv_setup_firmware_boot(machine);
            kernel_entry = virt_memmap[VIRT_FLASH].base;
        }
    }
    if (machine->kernel_filename && !kernel_entry) {
        kernel_start_addr = riscv_calc_kernel_start_addr(&s->soc[0],
                                                         firmware_end_addr);

        kernel_entry = riscv_load_kernel(machine, &s->soc[0],
                                         kernel_start_addr, true, NULL);
    }

BIOS設定、PFlash、カーネルファイルの指定が判断材料に使われている。 PFlashが設定されてない場合、riscv_load_kernel()の中で ELFファイルを読み込むときに設定される値と一致する。 一方で、PFlashが設定されていると VIRT_FLASH 先頭が利用される。

というわけで QEMU + virt + デフォルトBIOS(OpenSBI w/ FW_DYNAMIC) では、カーネル定義を ELF ファイルで渡すと OpenSBI が初期化処理後にカーネルのエントリポイントにジャンプしてくれることがわかった。

参考資料

comments powered by Disqus