この記事はC言語で継続の取得と呼び出し(前編)の続きです.
通常のx86-64のプログラムでは関数が呼び出されると,メモリのスタック領域にリターンアドレスやローカル変数の値を配置しています.
よって,main()
関数のスタックフレームから継続を取得したい処理を実行している関数のスタックフレームまでの領域を保存しておき,継続を呼び出すタイミングでスタック領域の値を書き戻すことで,継続の呼び出しの実装が可能です.
また,setjmp()
/longjmp()
でCPUのレジスタの値を復元しています.保存されるレジスタには次に実行される命令のアドレスも含まれています.
C言語ではsetjmp()
/longjmp()
関数を用いて関数の呼び出しを越えたgotoが可能です.
これはJavascriptやPythonなどに実装されているtry-catchのような動作をします.
下記がサンプルコードです.
#include <stdio.h>
#include <setjmp.h>
jmp_buf buf;
void func2() {
puts("func2 called");
longjmp(buf, 1);
puts("this line is not called");
}
void func1() {
puts("func1 called");
func2();
puts("this line is not called");
}
int main() {
puts("main()");
if (setjmp(buf) == 0) {
puts("context saved");
func1();
} else {
puts("longjmp was called");
};
}
main
関数はsetjmp
をグローバル変数bufとともに呼び出します.setjmp
はコンテキストを保存した際は0を返り値として返します.func1
はfunc2
を呼び出しますが,func2
がlongjmp
を呼び出すのでこれらの関数がreturnすることはありません.longjmp
の第二引数はsetjmp
の返り値となるので,処理が戻ってきたことを検知できます.(0の場合は0でない別の数になります)
この二つの関数はCPUの汎用レジスタなどの実行コンテキストを保存/復元して以上の動作を実現します.
機械語にコンパイルされたC言語のコードが呼び出されるとき,CPUとメモリではなにが起こるのかを整理します.
int add(int a, int b) {
puts("add");
return a + b;
}
このような関数を最適化オプションなしでコンパイルしたものが下記のコードです.(x86-64 gcc 14.1.1)
.LC0:
.string "add"
add:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl $.LC0, %edi
call puts
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
leave
ret
関数の呼び出しの様子を把握するのに重要なのが%rbpと%rspレジスタの値の変化です.
関数は呼び出されるとスタックフレームを生成します.このフレームには関数を読んだ命令の次のアドレスや%rbpの値,ローカル変数などの情報が記録されています.
関数が呼び出されたとき,%rbpレジスタの値は呼び出し元(ここではcallerと呼びます)の関数の%rbpのままになっています.
この値は保存しないとcallerが値を復元できなくなってしまうため,push命令で%rbpをスタックに保存します.
関数のローカル変数などの領域は%rspの値を減じて(スタックはメモリアドレスが減る方向に伸びます)確保します.
(x-86-64では%rspは16byte単位で伸ばす必要があるため,上記の例ではint(=4byte)x2個の領域を確保する)
GCCでは関数を呼び出さない関数の場合%rspの値を変更しませんが,今回のプログラムの場合は問題とならないため考慮しません.
つまり,一つの関数の持つフレームのアドレスは%rsp~%rbpということになります.複数の関数の際も同様で,継続を取得する関数の%rsp~最も上位の関数の%rbpの範囲を保持すれば良いことになります.
継続を呼び出すcall_continuation
関数はスタックのコピーを書き戻す際にallocaを呼び出しています.
これは書き換えているスタックのメモリ領域と実行中の関数のスタックフレームの位置が被らないようにするためです.
volatile void *q = 0;
do {
q=alloca(8);
} while (q > c->rsp);
_cc(c, val);
このライブラリはいくつか問題点があります.
ライブラリはx86-64のレジスタを直接読み出しています.また,スタックがメモリアドレスの高い方から低い方向に伸びることを仮定しています.また,C言語の標準では未定義の動作を使用しているため,移植性があまり高くありません.
get_continuation()
関数はINIT_CONTINUATION()
マクロを実行したところから%rspまでのスタックの全てをコピーします.
例えば下記のような例ではほとんど同じ内容のメモリ領域が2つ生成されます.
int func() {
if (get_continuation(c1) == 0)
puts("get_continuation1");
else
puts("resume1");
if (get_continuation(c2) == 0)
puts("get_continuation2");
else
puts("resume2");
}
また,継続を呼び出す際もスタックの領域を全て書き換えます.非常に多くのメモリコピーが発生するため,実行時間は長くなります.
移植性のあるCの継続ライブラリ 多田 好克
https://ipsj.ixsq.nii.ac.jp/ej/?action=repository_uri&item_id=30459&file_id=1&file_no=1
継続 Wikipedia
https://ja.wikipedia.org/wiki/%E7%B6%99%E7%B6%9A
なんでも継続 Kawai Shiro
http://practical-scheme.net/docs/cont-j.html