RISC-Vのコードモデルについていろいろ調べる機会があったのでまとめておく。
参考にしたのは、
- RISC-V Large Code Model Software Workaround
最初の疑問:LUI
命令とAUIPC
命令の違いは?
RISC-Vの仕様書を眺めていると、LUI
命令とAUIPC
命令というものが定義されている。それぞれビットフィールドを眺めているとそう違いはない。挙動が少し異なるだけだ。
挙動の違いは何かというと、LUI
命令はrd
レジスタの上位31-20ビットに即値を書き込むが、AUIPC
命令はrd
レジスタに、現在のPCの値+即値<<20
を書き込む。つまり、現在のPCからの相対値を書き込むというのが挙動として異なる。
- 筆者の自作RISC-Vシミュレータでの
LUI
命令およびAUIPC
命令の挙動の違い。
void InstEnv::RISCV_INST_C_LUI (InstWord_t inst_hex) { RegAddr_t rd_addr = ExtractBitField (inst_hex, 11, 7); UDWord_t imm = ExtendSign ((ExtractBitField (inst_hex, 12,12) << 17) | (ExtractBitField (inst_hex, 6, 2) << 12), 17); m_pe_thread->WriteGReg<UDWord_t> (rd_addr, imm); } void InstEnv::RISCV_INST_AUIPC (InstWord_t inst_hex) { RegAddr_t rd_addr = ExtractRDField (inst_hex); DWord_t imm = ExtendSign (ExtractBitField (inst_hex, 31, 12), 19); DWord_t mask = ~0xfff; UDWord_t res = ((imm << 12) & mask) + m_pe_thread->GetPC (); m_pe_thread->WriteGReg<UDWord_t> (rd_addr, m_pe_thread->SExtXlen(res)); }
これを逆に捉えると、AUIPC
命令は「現在のPCに対する相対値を書き込む」命令であるのに対し、LUI
命令は「0に対する相対値をrd
レジスタに書き込む」と考えることができる。
この2種類の命令は、どちらも2つの命令を組み合わせて使用されることを想定している。例を見てみる。
int global_v; int access_global() { global_v = global_v + 1; return global_v; }
$ riscv64-unknown-elf-gcc -O3 global_variable.c -c $ riscv64-unknown-elf-objdump -D global_variable.o
Disassembly of section .text: 0000000000000000 <access_global>: 0: 000007b7 lui a5,0x0 4: 0007a503 lw a0,0(a5) # 0 <access_global> 8: 2505 addiw a0,a0,1 a: 00a7a023 sw a0,0(a5) e: 8082 ret
グローバル変数global_v
にアクセスするために、まずはLUI
グローバル変数の配置されているアドレスの上位ビットを作り、次にlw
命令で下位ビットのオフセットを調整して所望の場所にアクセスする。
しかし上記のプログラム、何かがおかしい。それは、global_v
の場所が決まっていないのになぜlui
命令とlw
命令が生成されているのか。そして両方とも即値の部分が0だと、結果的にアクセスは常に0x0000_0000に向かってしまい、上手くアクセスできるはずがない。
この謎を解くためには、リンカスクリプトによるリロケーションという概念を知る必要がある。リロケーションについて理解するために、もう少し情報を出してみよう。riscv64-unknown-elf-objdump
にもう少しオプションを追加する。
$ riscv64-unknown-elf-objdump -D -r global_variable.o
Disassembly of section .text: 0000000000000000 <access_global>: 0: 000007b7 lui a5,0x0 0: R_RISCV_HI20 global_v 0: R_RISCV_RELAX *ABS* 4: 0007a503 lw a0,0(a5) # 0 <access_global> 4: R_RISCV_LO12_I global_v 4: R_RISCV_RELAX *ABS* 8: 2505 addiw a0,a0,1 a: 00a7a023 sw a0,0(a5) a: R_RISCV_LO12_S global_v a: R_RISCV_RELAX *ABS* e: 8082 ret
なにやら余分な情報がたくさん出てきた。
LUI
命令の後に、R_RISCV_HI20
とR_RISCV_RELAX
というラベルが付加されている。LW
命令の後に、R_RISCV_LO12_I
とR_RISCV_RELAX
というラベルが付加されている。SW
命令の後に、R_RISCV_LO12_S
とR_RISCV_RERAX
というラベルが付加されている。
これらの情報は「リロケーション情報」呼ばれ生成されたオブジェクトファイルに付加されている。オブジェクトファイルを結合して最終的な実行ファイルを作成するリンカは、これらのリロケーション情報を読みとって最終的なアドレスマップを元にアドレスを計算し、最終的な命令とアドレスを埋め込む。
ではさらにmain()
関数を埋め込んで最終的なバイナリを作ってみる。
main.c
extern int access_global(); int main() { access_global(); return 0; }
$ riscv64-unknown-elf-gcc -O3 main.c -c $ riscv64-unknown-elf-ld main.o global_variable.o -o global_variable.riscv $ riscv64-unknown-elf-objdump -D -r global_variable.riscv
... 00000000000100c0 <access_global>: 100c0: 67c5 lui a5,0x11 100c2: 0d07a503 lw a0,208(a5) # 110d0 <global_v> 100c6: 2505 addiw a0,a0,1 100c8: 0ca7a823 sw a0,208(a5) 100cc: 8082 ret Disassembly of section .bss: 00000000000110d0 <global_v>:
最終的なアドレスが埋め込まれた。LUI
命令は0x11
をオペランドに取りa5
レジスタに0x11000
を書き込み、次にlw
命令がa5 + 208
= a5 + 0xd0
に対してメモリアクセスを発行することで0x0000110d0
に配置してあるglobal_v
変数にアクセスができるようになっている。これがリンカの役割だ。
このように、グローバル変数へのアクセスに関して、コンパイル時にその場所を決定せず、リンク時にアドレスを決定する。という訳でコンパイラはリンク時に変数がどのような場所にあっても命令が生成できるようにしておく必要がある。
では、話を戻して次に同じglobal_variable.c
を以下のオプションを付けてコンパイルしてみる。
$ riscv64-unknown-elf-gcc -O3 -mcmodel=medany -mexplicit-relocs global_variable.c -c $ riscv64-unknown-elf-objdump -D -r global_variable.o
Disassembly of section .text: 0000000000000000 <access_global>: 0: 00000797 auipc a5,0x0 0: R_RISCV_PCREL_HI20 global_v 0: R_RISCV_RELAX *ABS* 4: 0007a503 lw a0,0(a5) # 0 <access_global> 4: R_RISCV_PCREL_LO12_I .LA0 4: R_RISCV_RELAX *ABS* 0000000000000008 <.LA1>: 8: 00000797 auipc a5,0x0 8: R_RISCV_PCREL_HI20 global_v 8: R_RISCV_RELAX *ABS* c: 2505 addiw a0,a0,1 e: 00a7a023 sw a0,0(a5) # 8 <.LA1> e: R_RISCV_PCREL_LO12_S .LA1 e: R_RISCV_RELAX *ABS* 12: 8082 ret
今度はLUI
命令の代わりにAUIPC
命令が使用されていることに注目しよう。
AUIPC
命令の後に、R_RISCV_PCREL_HI20
とR_RISCV_RELAX
というラベルが付加されている。LW
命令の後に、R_RISCV_PCREL_LO12_I
とR_RISCV_RELAX
というラベルが付加されている。SW
命令の後に、R_RISCV_PCREL_LO12_S
とR_RISCV_RERAX
というラベルが付加されている。
生成された命令が変わった原因は-mcmodel=medany
というオプションが理由だ。このmcmodel
というオプションがRISC-Vの「コードモデル」を指定している。
コードモデルとABI、混同しがちだが異なる概念である。例えば、異なるABI同士のオブジェクトファイルはリンクすることができないが、オブジェクトファイル間でコードモデルが異なっていても問題なくリンクできる。コードモデルはソフトウェアのアドレッシングを決めるためのルールであり、関数呼び出しのABIのように互いに関連するようなモデルではないからだ。コードモデルは生成されるプログラムのアドレッシングモードを規定しており、最終的にリンカによってどのようにリンクされるのかについてを規定している。
そういう意味では、一番最初の例であるLUI
命令を使った命令の生成例では、異なるコードモデルを使っている。実は下記のようにオプションを指定したことに等しい。
$ riscv64-unknown-elf-objdump -D -r global_variable.o -mcmodel=medlow
最後にLUI
命令と同様にAUIPC
を使ったmcmodel=medany
のコードモデルで生成したオブジェクトファイルをリンクしてどのような命令が生成されているかを確認しよう。
$ riscv64-unknown-elf-gcc -O3 main.c -c $ riscv64-unknown-elf-ld main.o global_variable.o -o global_variable.riscv $ riscv64-unknown-elf-objdump -D -r global_variable.riscv
Disassembly of section .text: ... 00000000000100c0 <access_global>: 100c0: 00001797 auipc a5,0x1 100c4: 0147a503 lw a0,20(a5) # 110d4 <global_v> 100c8: 00001797 auipc a5,0x1 100cc: 2505 addiw a0,a0,1 100ce: 00a7a623 sw a0,12(a5) # 110d4 <global_v> 100d2: 8082 ret Disassembly of section .bss: 00000000000110d4 <global_v>: 110d4: 0000 unimp
AUIPC
命令はPCとの相対なので、a5
への書き込みはPC + 0x1 << 12 = 0x100c0 + 0x1000 = 0x110c0
となり、lw
命令により0x110c0 + 20 = 0x110d4
へのlw命令によるメモリアクセスが発生するようになっている。
一方でsw
命令の場合はPC + 0x1 << 12 = 0x100c8 + 0x1000 = 0x110c8
となり、sw命令により0x110c8 + 12 = 0x110d4
へのメモリアクセスが発生するようになっている。
さて、ここまでGCCのオプションによって生成される命令が異なることが分かったが、どのように使い分ければいいのか。重要なのはこの2つのアドレッシングモードが、アドレスとして指定することのできる範囲が異なるということである。それぞれのコードモデルにおいてアクセス可能な最小のアドレスと最大のアドレスを計算してみる。
medlow
コードモデル- 最小アドレス:
LUI x1, 0x80000
により0x8000_0000
となる。つまり、現在のPCから-2GBまでの距離。 - 最大アドレス:
LUI x1, 0x7ffff
によりx1に0x8000_0000
を格納しLW -1(x1)
にアクセスすることで+2GB-1Bの場所までアクセスすることができる。
- 最小アドレス:
medany
コードモデル- 最小アドレス:
AUIPC x1, 0x80000
によりPC+0x8000_0000
となる。つまり、現在のPCから-2GBまでの距離。 - 最大アドレス:
AUIPC x1, 0x7ffff
によりPC+0x7ffff_f000
を格納し、LW -1(x1)
にアクセスすることで現在のPCから最大で+2GB-1Bの距離までアクセスできる。
- 最小アドレス:
このように、コードモデルに応じてアクセスできる距離が異なる。medany
では、現在のコードからの距離に応じてアクセスできる範囲が異なる、というのがミソだ。