前回の続きです。
それでは実際にRISC-Vなコードにコンパイルして試してみましょう。
こちらにある「RISC-V GNU Compiler Toolchain」を使います。
Windowsでは動かないので(WSLとか使えば行けるはずですが)、
Ubuntuな環境でやってみます。
ちなみに自分が使っているのは Kubuntu 20.04LTS です。
RISC-V GNU Compiler Toolchain を導入
まずは、
作業フォルダを作ったりするなどして、
そこで以下のようにやって、必要となるファイルを導入します。
(※gitが未導入の場合は、$ sudo apt-get install git
してください)
$ git clone --recursive https://github.com/riscv/riscv-gnu-toolchain
--recursive
が必要でしたが、今は必要ないようです。 思った以上に時間を要する感じ。
自分のPCは Ryzen 3600 + NVMeなストレージなので、
そこそこ速いはずですが、数十分くらい掛かったような?
というか通信速度にも依りそうですね。
なお、説明によると上記の作業で8GBほどのディスク容量が必要になるようですが、
makeすると最終的には20GBほど消費するような感じでした、確か。
なので、空き容量には一応注意しておいた方がよいかと思います。
ビルドするのに必要なパッケージを導入
続いて、
ビルドするのに必要なパッケージを導入します。
以下のようにやります。これは、それほど時間を要しない感じでした。
$ sudo apt-get install autoconf automake autotools-dev curl python3 libmpc-dev libmpfr-dev libgmp-dev gawk build-essential bison flex texinfo gperf libtool patchutils bc zlib1g-dev libexpat-dev
事前設定を行ってビルドする
そして、
Newlibなやつでmakeします。たぶん、Newlibでやるのが無難?
こちらに書かれているようにやれば、
/opt/riscvフォルダにRV64GCな命令セット対応で作られます。
ですが、自分の環境は少々空き容量に余裕が無いのと、
64bitより32bit版の方が早くmakeが終わるかもしれないし、
クロスコンパイルの動作確認する程度なので、
今回はあえてRV32GCでmakeします。
ということで、以下のようにやります。
$ cd riscv-gnu-toolchain
$ ./configure --prefix=/opt/riscv --with-arch=rv32gc --with-abi=ilp32d
$ sudo make
makeが終わるまでに約40分ほど掛かりました(^_^;)
気長に待ちましょう。
なお、
しばらく経った後に、最新版のソースをpullしてmakeし直すということが無いようならば、
make時に作られた一時的なファイルは必要ないので、$ sudo make clean
で削除できます。
というか、riscv-gnu-toolchain フォルダごと処分してしまって構わないかも(^_^;)
コマンド実行のための準備
makeの結果 /opt/riscv/bin/ に以下のコマンドが作成されます。
riscv32-unknown-elf-addr2line
riscv32-unknown-elf-ar
riscv32-unknown-elf-as
riscv32-unknown-elf-c++
riscv32-unknown-elf-c++filt
riscv32-unknown-elf-cpp
riscv32-unknown-elf-elfedit
riscv32-unknown-elf-g++
riscv32-unknown-elf-gcc
riscv32-unknown-elf-gcc-10.2.0
riscv32-unknown-elf-gcc-ar
riscv32-unknown-elf-gcc-nm
riscv32-unknown-elf-gcc-ranlib
riscv32-unknown-elf-gcov
riscv32-unknown-elf-gcov-dump
riscv32-unknown-elf-gcov-tool
riscv32-unknown-elf-gdb
riscv32-unknown-elf-gdb-add-index
riscv32-unknown-elf-gprof
riscv32-unknown-elf-ld
riscv32-unknown-elf-ld.bfd
riscv32-unknown-elf-lto-dump
riscv32-unknown-elf-nm
riscv32-unknown-elf-objcopy
riscv32-unknown-elf-objdump
riscv32-unknown-elf-ranlib
riscv32-unknown-elf-readelf
riscv32-unknown-elf-run
riscv32-unknown-elf-size
riscv32-unknown-elf-strings
riscv32-unknown-elf-strip
絶対パスでいちいち入力するのは面倒なので、パスを通しておきます。
nanoなどのエディタで編集します。
$ nano ~/.bashrc
# ↓この一行を追加。
export PATH=${PATH}:/opt/riscv/bin
$ source ~/.bashrc
クロスコンパイルして出力コードを確認してみる
ということで、
ようやくクロスコンパイルする準備が整いました。
定番の hello.c でやってみます。
#include <stdio.h>
int main(int argc, char *args[])
{
printf("Hello, world!\n");
return 0;
}
一応、最適化レベル2を指定してやってみます。
$ riscv32-unknown-elf-gcc hello.c -o hello -O2
$ riscv32-unknown-elf-run hello
すると、無事に「Hello, world!」が出力されました(^_^)
では、気になるアセンブラなコードを出力して見てみましょう。
$ riscv32-unknown-elf-gcc hello.c -S -O2
.file "hello.c"
.option nopic
.attribute arch, "rv32i2p0_m2p0_a2p0_f2p0_d2p0_c2p0"
.attribute unaligned_access, 0
.attribute stack_align, 16
.text
.section .rodata.str1.4,"aMS",@progbits,1
.align 2
.LC0:
.string "Hello, world!"
.section .text.startup,"ax",@progbits
.align 1
.globl main
.type main, @function
main:
lui a0,%hi(.LC0)
addi sp,sp,-16
addi a0,a0,%lo(.LC0)
sw ra,12(sp)
call puts
lw ra,12(sp)
li a0,0
addi sp,sp,16
jr ra
.size main, .-main
.ident "GCC: (GNU) 10.2.0"
おお、
確かにRISC-Vなコードになっているようです。
.attribute arch, "rv32i2p0_m2p0_a2p0_f2p0_d2p0_c2p0"
となっているので、
命令セットのバージョンが32ビット版で基本のI、拡張のM,A,F,D,Cのいずれもが 2.0 準拠となっているのが分かります。
でも、これだと実際のバイナリコードが分かりませんね。
また、16bit縮小命令がちゃんと使われているのかを確認できません。
なので、objdumpしてみましょう。
$ riscv32-unknown-elf-objdump -d hello
長いので冒頭の一部分だけ載せます。
セクション .text の逆アセンブル:
00010074 <main>:
10074: 6549 lui a0,0x12
10076: 1141 addi sp,sp,-16
10078: 4f450513 addi a0,a0,1268 # 124f4 <__errno+0x6>
1007c: c606 sw ra,12(sp)
1007e: 2489 jal 102c0 <puts>
10080: 40b2 lw ra,12(sp)
10082: 4501 li a0,0
10084: 0141 addi sp,sp,16
10086: 8082 ret
00010088 <register_fini>:
10088: 00000793 li a5,0
1008c: c789 beqz a5,10096 <register_fini+0xe>
1008e: 6541 lui a0,0x10
10090: 67450513 addi a0,a0,1652 # 10674 <__libc_fini_array>
10094: ae91 j 103e8 <atexit>
10096: 8082 ret
00010098 <_start>:
10098: 00004197 auipc gp,0x4
1009c: c8018193 addi gp,gp,-896 # 13d18 <__global_pointer$>
100a0: 04418513 addi a0,gp,68 # 13d5c <__malloc_max_total_mem>
100a4: 09c18613 addi a2,gp,156 # 13db4 <__BSS_END__>
100a8: 8e09 sub a2,a2,a0
100aa: 4581 li a1,0
100ac: 28d5 jal 101a0 <memset>
100ae: 00000517 auipc a0,0x0
100b2: 33a50513 addi a0,a0,826 # 103e8 <atexit>
100b6: c511 beqz a0,100c2 <_start+0x2a>
100b8: 00000517 auipc a0,0x0
100bc: 5bc50513 addi a0,a0,1468 # 10674 <__libc_fini_array>
100c0: 2625 jal 103e8 <atexit>
100c2: 2895 jal 10136 <__libc_init_array>
100c4: 4502 lw a0,0(sp)
100c6: 004c addi a1,sp,4
100c8: 4601 li a2,0
100ca: 376d jal 10074 <main>
100cc: a0b9 j 1011a <exit>
︙
ふむふむ、
16bitな縮小命令が適宜使われて、
バイナリコードがよりコンパクトになっているようです。
なかなか良い感じですね。
表記上は、lui a0,0x12
となってますが、
実際には c.lui x10,0x12
になるのですね。
<main>:
において、raレジスタをスタックに保存退避していますが、
4バイトで済むはずなのに、16バイト確保しているのが気になりました。
.attribute stack_align, 16
とあるので、
スタックは16バイト単位に確保するようになっているようです。
理由はよくわからないけど、キャッシュラインのサイズに適応させるためかもしれません。
X86_64のコードと比較してみる
比較のために、
X86_64のコードも見てみましょう。
$ gcc hello.c -o hello -O2
$ objdump -d hello
こちらも長いので、冒頭の一部分だけ載せます。
セクション .text の逆アセンブル:
0000000000001060 <main>:
1060: f3 0f 1e fa endbr64
1064: 48 83 ec 08 sub $0x8,%rsp
1068: 48 8d 3d 95 0f 00 00 lea 0xf95(%rip),%rdi # 2004 <_IO_stdin_used+0x4>
106f: e8 dc ff ff ff callq 1050 <puts@plt>
1074: 31 c0 xor %eax,%eax
1076: 48 83 c4 08 add $0x8,%rsp
107a: c3 retq
107b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
0000000000001080 <_start>:
1080: f3 0f 1e fa endbr64
1084: 31 ed xor %ebp,%ebp
1086: 49 89 d1 mov %rdx,%r9
1089: 5e pop %rsi
108a: 48 89 e2 mov %rsp,%rdx
108d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
1091: 50 push %rax
1092: 54 push %rsp
1093: 4c 8d 05 46 01 00 00 lea 0x146(%rip),%r8 # 11e0 <__libc_csu_fini>
109a: 48 8d 0d cf 00 00 00 lea 0xcf(%rip),%rcx # 1170 <__libc_csu_init>
10a1: 48 8d 3d b8 ff ff ff lea -0x48(%rip),%rdi # 1060 <main>
10a8: ff 15 32 2f 00 00 callq *0x2f32(%rip) # 3fe0 <__libc_start_main@GLIBC_2.2.5>
10ae: f4 hlt
10af: 90 nop
︙
まるで、ウィンチェスター・ミステリー・ハウスの如く、
X86は長年に渡って、増築の上に増築して来たような所があるので、
命令毎のバイト長がカオスな感じになっているのが否めませんね(^_^;)
ARMのように64bit化の際に、
バッサリとコード体系を切り替えるやり方もあったはずなんだけど・・・。
あと、
endbr64
という謎な命令が気になりますね。
調べてみた所、
主にプロセッサ・パイプラインが制御フローの違反を検出するためのマーカー命令として使用されます。
ということらしい。よくわからないけどセキュリティにも関係しているのかな?
いずれにしろ、endbr64
自体は何もしないコードのようだ。
う~む。
それから、
nop
や nopl
とかが使われているのも気になりました。
調べてみた所、
CPU命令キャッシュ(L1と呼ばれる)は、プロセッサデコーダに一度に16バイトをロードすることで動作します。そのため、16バイトの境界にコードを合わせることができれば、うまく行きます。
だそうだ。
要するに、16バイト単位に読み込むためにnopで穴埋めしているということ。
確かに各関数のアドレスはちょうど16バイトの整数倍になっているのが分かる。
う~む・・・なんかコード効率がイマイチですな(^_^;)
でも考えてみると、
X86のコードはもはや中間コードみたいなもので、
実際にはCPUの内部命令に変換された後に実行されていたりします。
なので、こういうやり方が理にかなっているのかもしれませんね。
そのためのオーバーヘッドやコストがどのくらいになるのか気になるけど・・・。
感想など
さて、
こうしてコードを比較してみると、
RISC-Vがより洗練されたコードを目指していることが分かります。
一方、X86のようなカオスなコードであっても、
長年の互換性から使われ続けているのも事実で、
技術の進歩でしっかり性能も出してきているわけです。
またARMはv8以降、世の中のニーズに応じた命令を追加して進化し続けています。
あのスーパーコンピューター「富岳」でも使われています。
企業が開発したCPUには長年のノウハウが詰まっており、
マーケティングによって必要とされる進化をしています。
さて、オープンソースなRISC-Vはそんな状況にどこまで食い込めるのでしょうか?
個人的には期待をもって今後も追ってみたいと思っています。