RISC-VにおけるLinuxブートについて

Linuxカーネルに処理が引き渡されてから、アーキテクチャ固有の処理が終わるまでを追いました。

⚠️初心者が書いているので間違った認識があるかもしれません。もし誤りがあった場合はご教授いただけると幸いです。

Image header

RISC-Vアーキテクチャにおいて、Linuxカーネルの処理は/arch/riscv/kernel/head.Sにある記述から始まるようです。__HEAD ENTRY(START)からの部分は、Linuxカーネルイメージのヘッダ情報を表しています。フォーマットは以下の通りです。

u32 code0;                /* 機械語コード */
u32 code1;                /* 機械語コード */
u64 text_offset;          /* イメージまでのオフセット (little endian) */
u64 image_size;           /* カーネルイメージのサイズ (little endian) */
u64 flags;                /* フラグ (little endian) */
u32 version;              /* ヘッダバージョン, 現在は0.2 */
u32 res1 = 0;             /* 予約済み領域 */
u64 res2 = 0;             /* 予約済み領域 */
u64 magic = 0x5643534952; /* "RISCV"を意味する値, 非推奨 (little endian) */
u32 magic2 = 0x05435352;  /* "RSC\x05"を意味する値, ARMの形式に合わせるため存在 (little endian) */
u32 res3;                 /* 予約済み, PE/COFFヘッダオフセットに使用 */

このヘッダはEFIブートのためにも利用できるようになっており、その際はcode0MZで置き換え、res3にPE/COFFヘッダまでのオフセットを指定します。

_start_kernel

ヘッダの機械語部分には、j _start_kernelとあり、_start_kernelという地点までジャンプします。

_start_kernelでは最初に、miemipという、2つのControl/Status Register(以後CSR)に0を書き込みます。これらのレジスタはMachine Interrupt Registersと呼ばれ、もっとも強い特権を持つM-Modeにおいて存在する、割り込み関連のレジスタです。0を書き込むことは、割り込みを全て無効化する、x86のcliのようなことのようです。

次に、fence.iで命令キャッシュを空にし、raとa0、a1以外の全レジスタを0で埋めます。

Physical Memory Protection

RISC-VにはPhysical Memory Protection(以後PMP)と言うメモリ保護機構が存在します。このPMPを設定して、全メモリ領域へのアクセスが許可されるように変更します。

なお、全てのプロセッサがPMPを実装しているわけではないので、簡単な例外処理機構も用意してあります。例外ハンドラのアドレスを示すCSRのmtvecに少し先のアドレスを指定することで、PMPがない場合も何もなかったように処理を続けられます。

PMPがあった場合には、pmpaddr0に-1を指定し、pmpcfg0PMP_A_NAPOT | PMP_R | PMP_W | PMP_Xを指定します。(もちろん実際は具体的な値に置換されます)

PMP_A_NAPOTはメモリ領域の指定に、8バイト以上の2の累乗の値を用いることを示し、PMP_*の部分はその領域のRead Write eXecuteを許可させることを意味しています。

このような設定をpmpcfg0に指定した場合、0からpmpaddr0の値までの間に存在するアドレスが、設定の適用対象になります。-1は2の補数として考えると、全ビットが立っている状態なので、最初の目的通り、全アドレスまでを対象にすることができています。

PMP関連の処理が終わったら、mhartidからスレッドを動かしているコアを表すhart IDを取得しておきます。

また、gpレジスタに__global_pointer$を代入します。__global_pointer$には、グローバル領域に置かれた変数のメモリアドレスが入っており、ここでgpレジスタに代入することで、グローバル変数のアドレスを求められるようになり、アクセス可能になります。

その後、mstatusに、元の値とSR_FSの1の補数の論理積を書き込み、FPUを無効化します。mstatus自体はさまざまな情報を含むCSRですが、論理積なので浮動小数点数拡張についてのみ設定を変更できます。ここで小数処理を無効化したことで、カーネル空間で小数演算を行なっている時に例外として検出できるようになります。

Symmetric Multiprocessing

Symmetric Multiprocessing(以後SMP)はいわゆるマルチコアのことを指します。

Linuxでは使用する最大コア数がCONFIG_NR_CPUSで規定されているので、今使っているhartのIDがCONFIG_NR_CPUSより小さかった場合のみ処理を継続し、そうでない場合は.Lsecondary_parkに処理を飛ばし、コアをアイドル状態にしながら無限ループに突入します。

運よく動作し続けて良いことになったコアは、.Lgood_coresにジャンプし、メインブートシーケンスを処理する最もラッキーなコアの選出を行います。

選出は、hart_lotteryというグローバル変数に対して、アトミックに1を足すことで行います。ここで用いるamoadd.w命令は、加算を行う前の値をレジスタに書き出すようになっています。そのため、加算前のhart_lotteryから読み出した値が0であるかを見ることによって、最初に加算を行なった、ただ一つのコアを選び出すことができるのです。

残念ながら選ばれなかったコアは.Lsecondary_startにジャンプし、残った唯一のコアは、起動処理を続行します。

.Lsecondary_start

くじを外したコアは処理をここにうつします。

まずは、例外処理のジャンプ先を.Lsecondary_parkに変更します。

その後、hart IDを、32bitなら2ビット、64bitなら3ビット左にずらし、__cpu_up_stack_pointer__cpu_up_task_pointerのアドレスに足しておきます。

加算後のそれぞれのアドレスから値を読み出し、sp(stack pointer)とtp(thread pointer)の各レジスタに格納します。この時、spかtp、少なくともどちらかが0であった場合は、アドレスからの値読み出しからやり直します。

どちらも0で無かった場合は、fenceでキャッシュをクリアした上で、secondary_start_commonに飛びます。

secondary_start_common

ここで、くじに当たったコアと同様に仮想メモリを有効化して移行し、例外ハンドラも設定します。

その後、/arch/riscv/kernel/smpboot.cのsmp_callin関数に飛んで、カーネルに自コアを登録するなど、いろいろな初期化の総仕上げを行なっているようです。疲れたのでこの辺はざっと見ただけです。

くじに外れたコアの話はここまでです。

A winning hart

たくさんのコアの中から抽選を行った結果、当選したコアの話に移ります。くじに当たったコアは、アーキテクチャ依存に依存する処理を終わらせます。

まず、イメージ中のbssセクションの初期化を行います。__bss_start__bss_stopを比較して、値が等しい場合は処理をスキップします。そうでない場合は、for文を回すように、ポインタのバイト数ずつメモリ領域に0を書き込んでいきます。

次に、以前取得したhart IDとDevice Tree Blob(以後DTB)の物理アドレスを別のレジスタに退避させ、hart IDはboot_cpu_hartidと言うグローバル変数にも保存します。DTB物理アドレスはa1レジスタに格納されているようですが、どこから出てきた値なのかがさっぱりわかりません。

スタックポインタにinit_thread_unionTHREAD_SIZEの和を設定します。init_thread_unionはプロセス0が使うスレッド情報の構造体とカーネルモードスタックを合わせたもので、静的に割り当てられています。THREAD_SIZEは、ページサイズ(2^12バイト)の2倍、64bitの場合は4倍と定められています。

そうすると、/arch/riscv/mm/init.cに記述されたsetup_vm関数を呼び出します。実引数はDTBのアドレスです。setup_vm関数の処理は追いきれていませんが、ページテーブルの構築を行なっているようです。

構築したら、relocate関数(/arch/riscv/kernel/head.S)を用いて、仮想メモリに移行します。ここでは、カーネルのページテーブルなどの計算を行なった上で、割り込みを止めて、アドレス空間を切り替えているようです。

その後、setup_trap_vector:にサブルーチンジャンプし、例外ハンドラをhandle_exception(/arch/riscv/kernel/entry.S)に設定し、現在カーネルが処理している例外の数を示すmscratchには0を代入します。

仮想メモリへの移行を行なったので、C言語のプログラムを動かせるようにレジスタの値を復元します。スレッドポインタ、スタックポインタをそれぞれ、init_taskinit_thread_union + THREAD_SIZEのアドレスにし、スレッドポインタ経由でthread_info.cpuに0を書き込みます。CPUに割り振るユニークなIDが0と言う意味のようです。

KernelAddressSanitizer(KASan)というメモリ破壊を検出する機構の設定も行います。実際の処理はkasan_early_init関数(/arch/riscv/mm/kasan_init.c)で行なっています。

そのあとはsoc_early_init関数(/arch/riscv/kernel/soc.c)を呼びます。早い段階で初期化したいデバイスの処理を全部やるという意図のようですが、デバイス構成にかなり依存する雰囲気がします。ところで、80字で行を折り返すとかいうルールに意味あるんですかね?

さて、長かったアーキテクチャ依存コードもとりあえずここまでで完結です。あとは、/init/main.cのstart_kernel関数に飛んでおしまいです。長文に付き合っていただきありがとうございました。