関数から別の関数をさらに呼び出すようなプログラムを書く際には、スタックフレームの確保が必要となる。アセンブリ演習の山場と言える箇所なので、頑張って取り組んでほしい。
関数呼び出しは分岐命令によって実現されており、その基本形は次の形で書く事ができる。
func0:
...
# func1 を呼び出す
jal func1
...
func1:
# 呼び出し元に復帰
ret
この場合、func0から分岐命令jalを用いてfunc1へと分岐し、func1の中で無条件分岐命令retを用いてfunc0へと復帰している。jalとretの役割を説明する前に、まずリターンアドレスを保持するレジスタであるraを説明する。raは整数レジスタと同様64bitレジスタである。
raは関数呼び出しの際のリターンアドレスを保存しておくためのレジスタであり、retはraの指すアドレスに無条件分岐する命令、jalはraに次の行の命令のアドレスを保存する命令である。つまり、retによって呼び出し元から復帰してきた際は、jalの次の行から命令が実行される。(jalはjump and link rejisterの略である。)
これだけであれば話はそれほど難しくないのだが、関数呼び出しが2段階行われる場合には話が変わってくる。
...
jal func1
...
func1:
jal func2
ret
func2:
ret
上のコードでの動作は以下のようになると考えられる (まず自分で考えてみるとよい)。
jal func1が実行される。raにはjal func1の直後のアドレスが入る。func1に分岐する。
func1 の jal func2 が実行される。raには func2 のretのアドレス(7行目)が入る。 func2 に分岐する。
func2 の ret が実行される。 raの内容に基づいて、 func1の ret の位置に分岐する。
func1 の ret が実行される。 raの内容に基づいて、 func1 の ret の位置に分岐する。
以下、繰り返し
このように、無限ループになる。問題はjal func1の直後のアドレスが保存されておらず、func1の中でraが上書きされてしまい、jal func1の直後のアドレスが失われてしまった事である。一般に関数呼び出しを行う関数は、ra の内容を保存をする必要がある。そして、ra の保存をするためにスタックフレームを作る必要がある。
データ構造の分野では先入れ後出しのデータ構造のことをスタックと呼ぶが、ここでは特に関数呼び出しのデータを保存するために使うコールスタックのことをスタックと呼ぶことにする。スタックは、仮想メモリの連続した領域上にある。本演習で用いるRISC-Vの標準的ABIでは、アドレスの小さいほうがスタックの先頭で、アドレスの大きいほうがスタックの末尾である。一度スタックが作成されたら、スタックの末尾は動かない。スタックの先頭は関数呼び出しのたびに変化し、これを表すのがレジスタspである。
リターンアドレスなどを退避させるためには、まず退避させるためのスタックフレームを確保しなければならない。それは、スタックの先頭を移動させる事によって行われる。たとえば、16バイトスタックフレームを確保したい場合は、次のようになる。
# 現在のスタックポインタの位置から、16バイト分スタックポインタを移動する。
# アドレスが小さい方がスタックの先頭なので、符号が-となっている。
addi sp, sp, -16
なお、スタックフレームの大きさ(バイト)は16の倍数でなければならず、確保すべきスタックフレームの大きさはどれだけ変数を退避させるかに依存する。スタックフレームを確保した後は、リターンアドレスを退避させる。
# リターンアドレスをスタックに退避する
sd ra, 8(sp)
このようにリターンアドレスをメモリに保存しておけば、関数呼び出しでraの中身を更新しても、必要な時に再度メモリからロードすれば、無限ループに陥らずに済む。実際にリターンアドレスをロードする際には、次のように書ける。
# リターンアドレスをロードする。
ld ra, 8(sp)
最後に関数を終了するときは、確保していたスタックを解放し、スタックポインタの位置を巻き戻す必要がある。
# スタックフレームのスペース割り当てを解放する。
addi, sp, sp, 16
ここまでをまとめると、関数呼び出しを行う関数の開始時と終了時のスタックフレームの処理は以下のようになる。(今回は、128バイトのスタックフレームを確保したいものとする。)
func:
# 関数プロローグ。
addi sp, sp, -128
sd ra, 120(sp)
# ここに関数呼び出しなどを含めた関数の中身を書く
# 関数エピローグ。
ld ra, 120(sp)
addi sp, sp, 128
ret
スタックフレームのサイズを変更する場合は、必ず16の倍数にしなければいけない事に気をつける事。当然、確保したスタックフレームを超えてレジスタ等を退避させてはならない(1つ前のスタックの内容を破壊することになる)ため、どの程度確保すべきなのかは実装に応じてよく計算すること。またローカル変数を使う場合、アラインメントにも注意が必要である。たとえば int型の変数はスタック上の4の倍数の位置、 long型の変数ならスタック上の8の倍数の位置に配置する必要がある。また、ここまで説明した関数呼び出しの仕組みを使う事で、再帰関数呼び出しを実装する事も出来る。
揮発性レジスタとは、関数呼び出しの前後で内容が変わっていても良いレジスタの事であり、ra、a0~a7、t1~t6がこれに該当する。そのため、コードを書く際には、関数呼び出しの前後で内容が変化している可能性を考慮してコードを書かなければならない。関数呼び出しの間で保存したい内容は、スタックフレームや非揮発性レジスタに避難させておく必要がある。
非揮発性レジスタとは、関数呼び出しの前後で内容が変わっていてはいけないレジスタのことであり、sp、s0~s11がこれに該当する。揮発性レジスタの内容を関数呼び出しの間保存したい場合には、スタックフレームではなく非揮発性レジスタに格納しても良い。メモリであるスタックとは異なり、非揮発性レジスタはレジスタなので高速な演算が可能である。ただし、非揮発性レジスタの内容は関数開始時と終了時に一致していなければならないので、関数開始時の非揮発性レジスタの内容をスタックに格納し、関数終了時に非揮発性レジスタの内容をスタックからロードしておかなければならない。