今回は、CISC風に言うと 無条件JMP、CALL、RETといった制御転送系の命令です。しかし例のごとくで、RISC-Vには CALL に相当する JALとJALRしかありません。「ジャル」一つでJMPもRETも皆やってしまう。清々しいというのはこういうものを言うような気がします。書き方は結構なんでもあり、なんだけれども。
※「ぐだぐだ低レベルプログラミング」投稿順indexはこちら
まずは毎回の参照資料へのリンクの再掲載です。
RISC-VでJMP, CALL, RETに相当する命令群
さて、無条件JMP、CALL、RETに相当する命令にはJALとJALRしかないと書きました(JALといって偉大な?飛行機会社様ではありませんよ。)
-
- JAL=Jump And Link
- JALR=Jump And Link Register
JALで「飛べる先」はPC相対で21ビットのオフセット範囲です(命令は2バイト境界アライメントなのでLSB1ビットは不要。)現在のPCの前後1Mバイトの範囲まで飛べるのは、大抵のアプリ(特に組み込み用)のオブジェクトであれば十分かとも思います。
JALRの方はレジスタ間接ジャンプです。RV32Iの場合、32ビットのアドレス空間全てにジャンプすることができます。なお、12ビットのオフセットがレジスタに加えられるので、1回レジスタにアドレスを積めば複数の変数で共用するといった使い方も可能。また、前々回やりましたがPC相対的なアドレスをレジスタにロードする疑似命令(複数命令に展開される)もあります。それを使えばレジスタ間接といいつつPC相対的に動作させることも可能、便利でないかい。
なお、JAL、JALRという「正式名称」でコードすることもできますが、以下の疑似命令などもこれらに落とし込まれます。
-
- j 単なる無条件ジャンプ、PC相対
- jr 単なる無条件ジャンプ、レジスタ間接
- ret 単なる(?)リターン
さらに、これまた例によって RV32C 短縮命令拡張も使えます。不遇?な命令もある中で、JAL、JALR系は使用頻度が高いためか優遇されている感じです。オフセットの値が小さく(RV32Cは16ビット幅なので21ビット幅のJALなど入りませぬ)、特定のレジスタを対象とする典型的なケースでは短縮形にアセンブルされるものが多いです。
もひとつおまけに、JAL、JALRでは、レジスタ名を省略する記法も許されています。これは x0とx1 レジスタ(ABI的にはzeroとra)を使用するJAL、JALRが多いためだと思います。
さて、このx0とx1をオペランドにとることが多い理由が、
全てのJAL、JALRは戻り番地をレジスタにストアできる
というRISC-Vの仕組みにあります。ABI的にはx1レジスタが戻り番地を保持するためのレジスタ(リンクレジスタ)として「お約束」されています。CALL命令的にサブルーチンを呼び出す場合では、x1(ABI的には ra という別名)レジスタを引数にとるのが一般的じゃないかと思います。
しかし、戻る気のないJMPではレジスタを1個無駄にするのは考えものです。その場合、RISC-Vは、戻り番地を捨ててしまえ、というのです。x0(zero)レジスタは常に定数0が読み出せるレジスタですが、書き込む場合はブラックホール的に全てを飲み込むレジスタです。戻り番地の格納先としてx0を指定すれば単なるJMPとして機能します。
そしてJALR命令はレジスタ間接です。そのとび先をリンクレジスタ(通常はx1)に向ければ、戻り番地に飛ぶことになります。RETの正体です。なお、JALRでも当然戻り先はレジスタに格納されます(飛び先を決定してからの格納なので同一のレジスタ、例えば x1 を指定しても問題ない。勿論 x0 に「捨てて」も良い。)
実験用のアセンブラ関数
以下に実験で使用したアセンブラ関数のコードを示します。グローバルシンボルとして、外部から見えるようにしたのは、以下の2つだけです。
-
- callret1
- jp1
残りのラベルは内部でJAL、JALRで飛ぶためのもの。なお、アセンブラのコード生成の「塩梅」を観察するために、同じ意味なんだけれども別な書き方などもワザと使ってみています。
callret1: addi sp,sp,-16 sw ra, 12(sp) jal x1, calltarget1 jal calltarget2 lw ra, 12(sp) addi sp,sp,16 ret calltarget1: add a0, a0, a1 ret calltarget2: add a0, a0, a2 jalr x1, 0(x1) jp1: j jp1_lbl0 j jp1 nop jp1_lbl0: la t0, jp1_lbl1 jr t0 j jp1_lbl0 nop jp1_lbl1: ret
アセンブラ関数の呼び出し側ソース
上記のアセンブラ関数をC言語から呼び出すためにヘッダファイルの中に用意した関数プロトタイプは以下です。
uint32_t callret1(uint32_t a0, uint32_t a1, uint32_t a2); void jp1(void);
上記の関数をC言語のmain()関数内で呼び出してテストするコードは以下です。
uint32_t a0=1; uint32_t a1=2; uint32_t a2=3; uint32_t result = callret1(a0, a1, a2); printf("callret1 = 0x%08x\n", result); jp1();
アセンブル結果のオブジェクトコード確認
アセンブルした結果のオブジェクトを逆アセンブルしてコード生成の様子を眺めてみたのが以下です。同等の意味のコードでもRV32Cの短縮形になったり、ならなかったり、いろいろでした。ただ、
疑似命令が定義されているようなケースでは疑似命令使う
方が塩梅が良い感じがします。変に「正式」表記にこだわらない方が良いみたいな。。。もしかすると Nucleiのツールチェーンのクセかもしれないですが、私は本当のところを知りません。
上記の逆アセンブルリストにはアドレスも書かれています。以下のトレースで、raに突っ込まれている戻り番地がどこなのかは、お手数ですが上記をご参照ください。
ステップ実行で動作確認
まずはC言語からのアセンブラ関数呼び出し行でブレーク。こんな感じ。
アセンブラ関数にステップイン。流石にリンクレジスタを破壊するとCに戻れなくなるので、今回はスタックフレームを確保して、まずCへの戻り番地を保存するところから始めています。
スタックフレームの操作後の状態、最初のサブルーチンの呼び出し。JAL命令使用。ここのraレジスタの値を覚えておいて、JAL実行後の状態と比べてくだされ。
JALで「飛んだ」後です。raに新な戻り番地が入っています。スタックフレームに退避させておかなかったらCに戻れなくなるところでしたぜ。
サブルーチンのお仕事実行後です。a0レジスタに1+2の結果が入っています。
さて、最初のサブルーチンからRET(その実体はJALR)で戻ったところ。
RETでなく、JALRをアカラサマに使って戻ってみました。こんな感じ。
後はスタックフレームから退避してあったC言語部分への戻り番地を取り出してRET(JALR)します。するとC言語ソースに後からセットした第2のブレークポイントにつかまるという段取り。
ブレークポイントで止まったところで、標準出力に接続してある仮想端末を眺めてみると、上のコードの実行結果が画面に報告されています。以下のように戻り値6。アセンブラ関数内でa0に書き込んだ値が戻っております。
さて、次に、単純、無条件ジャンプのテスト。万が一「飛ばなかった場合」が発生しても分かるように「通過しない筈の念のため」ジャンプ命令も仕込んであります。
まず、jp1_lbl0へ無条件ジャンプ。飛び先には前々回にやった la 疑似命令が。これでシンボルのアドレスをPC相対的な表現で t0 レジスタにロードできる筈。
t0レジスタにシンボルアドレスが入っているので、そこに向けてジャンプ。
簡単な動作なんだけれども、説明するとメンドイですな。