FPGA開発日記

カテゴリ別記事インデックス https://msyksphinz.github.io/github_pages , English Version https://fpgadevdiary.hatenadiary.com/

RISC-Vのコードモデルについて(1. コードモデルとは何なのか)

RISC-Vのコードモデルについていろいろ調べる機会があったのでまとめておく。

参考にしたのは、

www.sifive.com

  • RISC-V Large Code Model Software Workaround

https://sifive.cdn.prismic.io/sifive%2F15d1d90e-f60e-42a2-b6bf-c805c6c73b0d_riscv-large-code-model-workaround.pdf

最初の疑問:LUI命令とAUIPC命令の違いは?

RISC-Vの仕様書を眺めていると、LUI命令とAUIPC命令というものが定義されている。それぞれビットフィールドを眺めているとそう違いはない。挙動が少し異なるだけだ。

f:id:msyksphinz:20200425221550p:plain

挙動の違いは何かというと、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_HI20R_RISCV_RELAXというラベルが付加されている。
  • LW命令の後に、R_RISCV_LO12_IR_RISCV_RELAXというラベルが付加されている。
  • SW命令の後に、R_RISCV_LO12_SR_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_HI20R_RISCV_RELAXというラベルが付加されている。
  • LW命令の後に、R_RISCV_PCREL_LO12_IR_RISCV_RELAXというラベルが付加されている。
  • SW命令の後に、R_RISCV_PCREL_LO12_SR_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では、現在のコードからの距離に応じてアクセスできる範囲が異なる、というのがミソだ。