Dev Study
OSと低レイヤのしくみ コース

2. RISC-Vのレジスタ — 32本をどう使い分けるか

RISC-Vの32本の整数レジスタが、呼び出し規約(ABI)の上でどう役割分担しているのかを図で追っていきましょう。

①32本のレジスタとABI名

x0〜x31 とABI名の対応
x0zero
常に0
x1ra
戻りアドレス
x2sp
スタックポインタ
x3 / x4gp / tp
割り当て対象外
続いて一時・保存・引数
x5〜x7t0〜t2
一時
x8 / x9s0 / s1
保存
x10〜x17a0〜a7
引数・戻り値
残り
x18〜x27s2〜s11
保存
x28〜x31t3〜t6
一時
  • ・ハードウェアが持つのは x0〜x31 という番号だけで、ra や sp といった名前はABIが後から与えた約束です
  • ・フレームポインタの使用は任意ですが、仕様では「フレームポインタが存在する場合は x8(s0)に置かなければならない」と定められています
  • ・gp(x3)と tp(x4)は仕様の表で割り当て対象外(unallocatable)とされ、シグナルハンドラがその値に依存しうるため手続き中で変更してはならないとされています

32本のうちコンパイラが自由に割り当てられるのは実質27本ほどです。番号ではなくABI名で考えるのが実務上の入口になります。

②x0 — 読むと0、書くと捨てられる

x0 の特殊な振る舞い
x0 を読むadd t0, x0, x0
常に
0 が返る全ビットが0に固定
一方で
x0 へ書くadd x0, t1, t2
結果
書き込みは捨てられるx0 の値は変わらない
  • ・仕様上 x0 は全ビットが0に固定(hardwired)されており、書き込みは破棄されます
  • ・書き込み先を x0 にしたロード命令でも、例外の送出やその他の副作用は通常どおり発生すると仕様に明記されています
  • ・戻り命令 ret は rd=x0, rs1=x1, imm=0 の JALR として符号化され、書き込み先を x0 にすることで「リンクを残さないジャンプ」を表現しています

x0 は「値を捨てる先」としても使えます。結果が不要な命令の書き込み先に指定するのが定番の使い方です。

③引数と戻り値 — a0〜a7

a0〜a7 が担う引数と戻り値
呼び出し側引数を並べる
a0 から順に詰める
a0第1引数
a1第2引数
a2〜a7第3〜第8引数
8本で足りなければ
スタックへ配置sp からの連続領域に置く
復路
a0戻り値
a12語目の戻り値
  • ・仕様は「基本整数呼び出し規約は a0〜a7 の8本の引数レジスタを提供する」と定めており、戻り値は同じ型の第1引数と同じ方法で返されるため a0・a1 が使われます
  • ・レジスタに載りきらない引数は、関数入口の sp のオフセット0を先頭として、後続の引数ほど高いアドレスへ順に配置されます
  • ・スタックポインタは手続き入口の時点で128ビット境界に整列していることが求められ、手続きの実行中も整列を保つ必要があります

引数は a0 から順に詰めていきます。復路では同じ a0・a1 が戻り値の置き場に変わるのが特徴です。

④caller-saved と callee-saved の境界

退避責任がどちらにあるか
呼び出しをまたいで保存されない呼び出す側が守る
ra / a0〜a7 / t0〜t6
呼び出しをまたいで保存される呼ばれた側が守る
sp / s0〜s11
それぞれの意味
壊されて当然必要なら呼ぶ前に自分で退避する
必ず元に戻る呼び出しをまたいで値が残る
契約として読むと
「誰が退避するか」の取り決めどちらかが必ず責任を持つので二重退避が起きない
  • ・仕様の表は各レジスタについて「呼び出しをまたいで保存されるか」を示しており、s0〜s11 と sp は保存される側に分類されています
  • ・一時レジスタ t0〜t6 と引数レジスタ a0〜a7、そして ra は保存されない側に分類され、呼ばれた側は元の値を戻す義務を負いません
  • ・caller-saved / callee-saved は性能上の性質ではなく、呼び出す側と呼ばれた側が守るべき約束事です

この境界は「値が壊れるか」ではなく「壊れたとき誰が責任を持つか」を決めた契約です。だから二重に退避せずに済みます。

⑤関数呼び出しをまたぐと何が残るか

call の前後でのレジスタの生死
call の直前t0=10, s1=20, a0=30
jal で関数へ
呼ばれた関数内部で自由にレジスタを使う
戻ってきた直後
t0不定
値の保証なし
s120 のまま
保証あり
a0戻り値に置き換わる
元の30は消える
  • ・呼び出しをまたいで値を持ち越したい変数は s0〜s11 に置くのが基本方針です
  • ・t0〜t6 の値を呼び出し後も使いたい場合は、呼び出す側がスタックへ退避しておく必要があります
  • ・sp は呼び出しをまたいで保存される側なので、戻ってきたときには呼び出し前と同じ値に戻っています

呼び出しをまたいで生き残るのは s系 と sp だけです。t系 と a系 は壊れている前提でコードを書きます。

⑥ra は保存されない側なのに退避されるのはなぜか

葉関数と非葉関数での ra の扱い
葉関数他の関数を呼ばない
非葉関数内部でさらに関数を呼ぶ
ra をどう扱うか
ra を触らないそのまま ret すればよい
自分の jal が ra を上書きする入口で ra をスタックへ退避
出口で
retrd=x0, rs1=x1, imm=0 の JALR
ra を復元してから ret呼び出し元へ正しく戻れる
  • ・ra は psABI の表で「呼び出しをまたいで保存されない」に分類されており、呼ばれた側が元の値を戻す義務はありません
  • ・それでも非葉関数が ra を退避するのは規約上の義務ではなく、自分自身の jal が ra を壊すと呼び出し元へ戻れなくなるためです
  • ・分類上の「誰が守るか」と、実際に退避コードを書く関数が一致しないことがある点に注意が必要です

ra は分類上は保存されない側ですが、非葉関数は自衛のために ra を退避します。分類と実装が食い違って見える代表例です。

⑦なぜこの割り当てなのか

番号の並びに込められた設計意図
x8〜x9 と x18〜x27 が s系保存レジスタが2箇所に分かれている
理由の一つ
圧縮命令 C拡張x8〜x15 を3ビットで指し示せる
その範囲に何が入るか
x8, x9s0, s1
x10〜x15a0〜a5
結果として
よく使う保存・引数レジスタが短い命令で表せるコード量の削減につながる
  • ・圧縮命令の CIW・CL・CS・CA・CB 形式はレジスタ番号を3ビットで符号化するため、x8〜x15 の8本しか指定できません
  • ・ISAマニュアルの解説によれば、この8本が最も出現頻度の高いレジスタになるようABI側の割り当てが変更されました
  • ・番号が連続して自然に整列していることは復号器の簡素化にもつながり、s2〜s11 が離れた x18〜x27 にあるのはこの8本の枠を優先した結果として理解できます

レジスタ番号の並びは恣意的ではなく、圧縮命令で短く表せる範囲に頻出レジスタを寄せた結果です。

公式ドキュメントで詳しく ↗