自作CコンパイラをRISC-Vに対応した

2023-12-22

RISC-VというCPUはライセンスフリーとのことでどんなもんか興味あって、そんなこともあり自作のCコンパイラをRISC-Vに対応したいと思っていた。 そのために動かせる環境を用意したくて、最初はQEMUなどのエミュレータでLinuxを動かして…と思っていたが、spikeというシミュレータを使うことで実現した。

環境構築編

以前XV6のRISCV版を動かす際に開発環境をセットアップしたがPCを買い替えてしまって環境は失われてしまっていて、またゼロから環境構築する必要があった。

RISC-V Linux on QEMU(失敗)

当初はQEMUを使ってLinuxを動かしたいと思っていたんだけど、どうにもうまくいかなかった。 公式のRunning 64- and 32-bit RISC-V Linux on QEMU — RISC-V - Getting Started Guideの手順に沿ってやってみたが、qemu実行の際にbiosを指定しろ、とエラーが出てしまい進めなかった。 わけも分からず-bios /usr/lib/riscv64-linux-gnu/opensbi/generic/fw_jump.binというオプションを追加してみても起動しなかった。

あれこれ自分でビルドせずに、出来合いのイメージバイナリを用意してくれていてダウンロードしてサクッと動かせないもんなんだろうか…。

シミュレータ環境の構築 (spike, pk)

行き詰まっていたところ、エミュレーション手順の説明ページを見つけた: RISC-V向けの実行ファイルをmacOSとUbuntu22.04それぞれでエミュレーション実行する方法 #homebrew - Qiita spikeがシミュレータで、pkがプロキシカーネルとブートローダー、ということらしい。

pkのビルド(RISC-V64用)

macOSの場合、brew install riscv-isa-simでspikeは入るらしく簡単なんだけど、 自分の環境ではpkのビルドがそのままではいかずmake時にエラーが出てしまった。 ../configure ...でMakefileが生成されるが、その中のコンパイラ指定が

CC := clang

となってしまい、クロスコンパイルにならなくてエラーになっていた。 これは自分が環境変数 CC を設定してしまっていたからのようで、 CC= ../configure ... と指定する必要があった。

以上を踏まえて、自分の全手順:

export RISCV=$HOME/rv64  # .zshrcにも追加

git clone https://github.com/riscv-software-src/riscv-pk.git
cd riscv-pk
mkdir build
cd build
CC= ../configure --prefix=$RISCV --host=riscv64-unknown-elf --with-arch=rv64gc
make -j$(nproc)
make install # 自分のホーム以下にしているのでsudoは必要ない

RISC-V用の実行ファイル作成

シミュレータの動作確認もかねて、クロスコンパイラでRISC-V64用のバイナリを作成して動かしてみる。 hello worldなどの適当なCのコードを用意して、

$ riscv64-unknown-elf-gcc -o hello hello.c

とコンパイルできる。

  • 出力形式はELFファイル
  • その他、binutilsのツールも使える:
    • 出力結果を riscv64-unknown-elf-objdump -S hello などとして逆アセンブルを見たりすることもできる

実行方法

spike pkを使って、

$ spike $RISCV/riscv64-unknown-elf/bin/pk hello
bbl loader
Hello, world!

実行すると bbl loader とかいう不要なメッセージが出力されてしまうが、オプションで抑制するやり方がわからないので、スクリプトを用意してやる:

#!/bin/bash
spike $RISCV/riscv64-unknown-elf/bin/pk $@ | tail -n +2
exit ${PIPESTATUS[0]}

コンパイラ作成編

RISC-V用のバイナリを動かせる環境ができたので、自作のCコンパイラからの出力に挑戦してみる。 これまではx86-64とaarch64のアセンブリを吐けるようにしていたが、バックエンドを追加して出力できるようにする。 バックエンドで中間表現から各アーキテクチャ用のアセンブリを出力するようにしているので、そこでRISC-Vの実際の命令を出力することで対応する。

CPUアーキテクチャの概要

RISC-Vの仕様をくまなく調べたわけではなく、大雑把に理解したところによると:

  • 固定長な命令:各4バイト(コンパクト命令で2バイトというのもある)
  • 汎用レジスタ: x0x31の32個で、用途ごとに別名もついている
    • ゼロレジスタがある: zero == x0
  • 汎用レジスタで扱えるのは64ビットだけ?で、メモリへのストアやロード時に何バイトにするかで処理する?
  • アドレッシングモード:レジスタ間接+オフセットのみ?
  • フラグレジスタがない
    • 条件分岐はレジスタ同士の比較によって行う

汎用レジスタ

レジスタ 別名 用途 保存
x0 zero 常にゼロ
x1 ra callの戻り先 Caller
x2 sp スタックポインタ Callee
x3 gp グローバルポインタ
x4 tp スレッドポインタ
x5x7 t0~t2 テンポラリ Caller
x8 s0/fp フレームポインタ Callee
x9 s1 保存 Callee
x10x17 a0a7 関数パラメータ用 Caller
x18x27 s2s11 保存 Callee
x28x31 t3t6 テンポラリ Caller

zero, ra, sp, gp, tp の5個は用途が決められているので、残りの27個をレジスタ割付の対象にする (fpも用途は決まっているが関数がスタックフレームを使用しない場合は保存・復帰してやれば割付が可能)。

命令セット

主に利用する命令たち:

命令 内容
mv レジスタ間コピー
li 直値ロード(疑似命令)
add, addi レジスタ間または直値との加算
sub 減算(直値は扱えない)
mul, div, rem 乗除、余り
lb, lh, lw, ld メモリからのロード(1, 2, 4, 8バイト)
sb, sh, sw, sd メモリへのストア(同上)
j 無条件ジャンプ
beq など 条件ジャンプ
call, ret コール、リターン

実際には疑似命令も多数あって、直値のロードはlui命令で上位20ビット+addiで下位12ビットをロード(それ以上の場合シフトも組み合わせる)、無条件ジャンプはjal zero, labelなど、アセンブラで実際の命令に変換される。 また自動的にコンパクト命令を使ってくれる。

  • 疑似命令あるんだったらsubiも対応してくれてもいいのに…

浮動小数点数

RISC-Vでは基本命令セットを小さく抑えていて、浮動小数点数演算やレジスタはオプショナルになっている。 ハードウェア的にサポートされない場合にはソフトウェア的に処理する必要があるが、それは大変なのでハードウェア的にサポートしてるものとする。 そうした場合、浮動小数点レジスタ(FPレジスタ)や命令セットがある:

  • FPレジスタ:f0f31の32個(同じく別名あり)

主な命令:

命令 内容
fmv.d レジスタ間コピー
fadd.d, fsub.d, fmul.d, fdiv.d 加減乗除
fld, fsd ロード、ストア
fcvt 値の変換(double, float, int
feq など 比較結果を汎用レジスタに取り出す
  • .dの代わりに.sfloatになる(レジスタ名は変わらず)

以下、コンパイラから出力する際のトピック:

コンパイラをクロスコンパイルに対応させる

今まではターゲット環境が開発環境と同じだったので、コンパイラの定義済みラベル__x86_64__やら__aarch64__やらでターゲット環境を選択していた。 これを明示的に選べるようにコンパイルオプションの-Dでターゲットとなるアーキテクチャを指定できるようにして、クロスコンパイルできるようにする。

Cコンパイラの出力結果をアセンブルやリンクする実行ファイルもプレフィクスをつけて選択できる様にしてやる:riscv64-unknown-elf-as, riscv64-unknown-elf-ldなど。

PUSH/POP

pushpopのようなプリデクリメントやポストインクリメントのアドレッシングがないので、自分でスタックポインタを操作+レジスタ間接ロード・ストアする。

スタックポインタは16バイトアラインされている必要がある。

条件分岐

x86-64やaarch64で条件分岐を行うにはcmpで比較して、フラグレジスタの条件によってブランチしていた。 がRISC-Vではフラグレジスタがなく、beqなどの命令がオペランドとしてレジスタを2つ取り、その比較によって行う。 また比較の条件がeq, ne, lt, geしかないので、legtはオペランドの順を入れ替えてやる。

FPレジスタはオペランドに与えられないので、feqなどのFPレジスタ間の比較命令でGPレジスタに1または0が得られ、それを使って分岐する。 FP比較命令はnot equalがないので、feqののちに条件を反転する必要がある。

標準入出力

自作コンパイラがちょっと厄介な作りになっていて、ライブラリのヘッダファイルは独自のものを使っているが、x86-64以外ではリンクはシステムのライブラリを利用している。 なので自作のヘッダでの定義がライブラリと合うようにする必要がある。

x86-64やaarch64ではstdin, stdout, stderrは単純にextern FILE*としてやればよかったが、RISC-Vではどうも違うっぽい。 単にprintfを使う分には問題ないが、fprintf(stderr, ...)などとするとリンクで未定義エラーが出てしまう。

プリプロセスした結果からRISC-Vツールチェインのヘッダを見てみると、

// /opt/homebrew/Cellar/riscv-gnu-toolchain/main/riscv64-unknown-elf/include/stdio.h
#define stderr (_REENT->_stderr)

// /opt/homebrew/Cellar/riscv-gnu-toolchain/main/riscv64-unknown-elf/include/sys/reent.h
extern struct _reent *_impure_ptr __ATTRIBUTE_IMPURE_PTR__;
# define _REENT _impure_ptr

struct _reent
{
int _errno; /* local copy of errno */

/* FILE is a big struct and may change over time. To try to achieve binary
compatibility with future versions, put stdin,stdout,stderr here.
These are pointers into member __sf defined below. */
__FILE *_stdin, *_stdout, *_stderr;
...
__FILE __sf[3]; /* first three file descriptors */
...

となっていて、リエントラントに対応するためのコードになってるっぽい。

  • これらのコードはnewlibと同じなので、RISC-Vツールチェインはnewlibを使っているっぽい

呼出規約

関数の呼出規約はx86-64やaarch64と同様にintdoubleなどのスカラー値はレジスタ渡しで、8個を超えた分や構造体はスタック渡し。 レジスタによって呼出元保存や呼出先保存となっているのでそれに従う (tnレジスタがテンポラリ=破壊される=Caller Save、snは保存される=Callee Save)。

任意長引数

riscv64-unknown-elf-gccの出力結果を見ると、呼び出す関数が任意長引数の場合はdoubleの値でもFPレジスタではなく汎用レジスタで渡すっぽい。 なんでそうなってるのか推測だけどRISC-Vは構成によってはハードウェアでの浮動小数点演算なしとかもあり得るので、そういうケースを考慮してのことかもしれない。 FPレジスタからの取り出しはfmv.x.d命令でバイナリ表現そのままGPレジスタに持ってきてやる。

またx86-64などではGPとFPそれぞれ別に管理が必要なためva_listの実体は構造体が使われているが、 RISC-Vでは受け渡しが汎用レジスタのみということもあってか古き良き実装でありがちな単なるポインタになっている。

ただ8個まではレジスタ渡しされるので、スタック渡しの引数と連続になるように関数プロローグで一番最初にスタックに積んで残りのスタック引数と連続させる必要がある。 call命令でリターンアドレスがraレジスタに格納されるので、先に任意長引数の可能性のあるレジスタをスタックに積んで、その後リターンアドレスやフレームポインタを積んだりスタックフレームの確保を行う。

  • 構造体引数と混ぜられたら対処できなそうな気がする…
  • ヘッダのパス: /opt/homebrew/Cellar/riscv-gnu-toolchain/main/lib/gcc/riscv64-unknown-elf/12.2.0/include/stdarg.h

浮動小数点数の丸めモード

C言語でdoubleからintへのキャストをすると小数点以下は切り捨てになる。 これを単にfcvt.w.d命令を使うと四捨五入になってしまうので、アセンブリで丸めモードを指定する必要がある:

fcvt.w.d a0, fa0, rtz
  • rtz (Round towards Zero)
  • fcvtだけじゃなく、すべてのFP命令でrmの丸めモードを指定できるらしい

レジスタ割付

レジスタ割付は、レジスタ構成がaarch64と大差ないので特に問題はなかった。

あとがき

ひとまずRISC-Vのアセンブリを出力して、コンパイル結果をspikeシミュレータ上で動作させることができるようになった。

  • 作ったとはいえまだ命令セットをよく理解してないので、アセンブラやリンカーも対応したい
  • できればQEMU/Linuxや、実際のハードウェアでも動かしたい

リンク