関数とスタック
関数とは、何度も使える名前付きの命令のまとまりです。そこへジャンプすると処理を実行し、呼び出し元に制御を戻します。これにより、同じロジックを一度書くだけで、プログラム全体で同じ行を繰り返す代わりに多くの場所から使えます。
を使って関数を呼び出します。これはラベルへジャンプし、戻り先を ra に保存します。関数は で終わり、ra のアドレスへ戻ります。 と は疑似命令です。つまり、アセンブラが実際の命令 と に展開する便利な名前です。
引数は a0、a1、およびそれに続くレジスタに置かれます。戻り値は a0 に置かれます。この取り決めが呼び出し規約であり、別々のコード片が連携できるようにする共通の合意です。
li a0, 6 # 引数call square # 呼び出し、そしてraは戻ってくる場所を覚えているli a7, 1 # squareは結果をa0に残すecallli a7, 10ecallsquare:mul a0, a0, a0 # a0 = a0 * a0ret # raへ戻る
呼び出しを追ってみましょう。 は引数 6 を a0 に置きます。 は square ラベルへジャンプし、戻りアドレスを ra に保存します。関数内では、 が 6 * 6 = 36 を計算し、結果を戻り値に使うレジスタである a0 に残します。 は呼び出しの直後の命令へ戻り、そこで と が 36 を表示し、その後プログラムは終了します。
呼び出し元がAを呼び出そうとしています。
caller が を実行すると、戻り先が ra レジスタ内の caller に自動的に保存されます。A が を実行する前に問題が発生します。 命令が ra を新しい戻りアドレスで上書きしようとしており、A が家に帰るために必要なアドレスが破壊されてしまいます。これを修正するために、A はスタックに場所を確保し()、元の ra をそこに保存します()。これで安全に を実行できます。B が実行され、通常の で戻り、A の中に着地します。最後に、A はスタックから元の ra を復元し()、スタックポインタを片付け()、自身の がその復元された値を使って caller へ無事にジャンプします。もし A がこの手順を省いていたら、 命令が ra を永久に上書きしてしまい、A の最後の は家に戻る代わりに間違った場所へジャンプしていたでしょう。スタック
レジスタの数はごくわずかであるため、他の関数を呼び出す関数には一時的な領域が必要です。スタックとは、スタックポインタ sp を通じて借りるメモリのことです。スペースを確保するには sp から引き算を行い、保持する必要がある値はそこに格納し、 の前に元の状態に戻します。
スタックは後入れ先出し(LIFO)構造です。最後に保存した値が最初に取り出されます。RISC-V では、スタックはメモリ上で下向きに成長します。sp から引くことで、安全に使える新しい領域を確保します。値を取り出すときは、保存したときと逆の順序で行わなければなりません。そうしないと、プログラムの実行の流れが壊れます。
addi sp, sp, -4 # 1ワード分の場所を確保するsw ra, 0(sp) # 戻りアドレスを保存する# ... ここで他の関数を呼び出す ...lw ra, 0(sp) # それを復元するaddi sp, sp, 4 # その場所を解放するret
すべての / は ra を上書きするため、関数が別の関数を呼び出すと、内側の呼び出しが戻りアドレスを破壊し、自身の が誤った場所へジャンプします。これを防ぐには、 でスタックに 4 バイト確保し、 で ra をそこに保存します。内側の呼び出しが終わったら、 で戻りアドレスを復元し、 で の前に領域を返します。
ra を復元し、常に sp を開始時の値に戻してください。開始状態と一致しないスタックは、アセンブリプログラムでクラッシュが起きる通常の原因です。