関数呼び出しと %esp と %ebp
例えば、こんなコード
/* f1.c */ int add(int a, int b) { return a+b; } int main(int argc, char *argv[]) { int c = add(1, 2); return 0; }
を書いて、
$ gcc -S f1.c -o f1.s
とすると、f1.s は
.text .globl _add _add: pushl %ebp movl %esp, %ebp subl $8, %esp movl 12(%ebp), %eax addl 8(%ebp), %eax leave ret .globl _main _main: pushl %ebp movl %esp, %ebp subl $40, %esp movl $2, 4(%esp) movl $1, (%esp) call _add movl %eax, -12(%ebp) movl $0, %eax leave ret .subsections_via_symbols
となる。
どちらの関数も
pushl %ebp movl %esp, %ebp なんとかかんとか leave ret
って形になってる。どうやらこれはある種のイディオムのようなもので、-fomit-frame-poiter とかしなければ必ずこうなる(と思う)。
やってることは
ベースポインタをスタックに積んで保存 -> 現時点でのスタックポインタの位置をベースポインタに設定 -> なんとかかんとか -> ベースポインタとスタックポインタを元に戻す -> 呼び出し元に帰る
ってなかんじみたい。
何故わざわざベースポインタを用いるのかというと、ローカル変数や引数といったスタックにある値にアクセスする際にスタックポインタだと不便だからだ。
push/pop するたびにスタックポインタは動いていって、同じローカル変数にアクセスするのに、ずらす量が異なってしまい、非常にややこしい。
そこで、push/pop しても同じようにスタックにある値にアクセスできるように「ベース」を設定するわけだ。
ちなみに、-fomit-frame-pointer をつけて gcc にかけると、ebp が使われず esp のみのコードを吐く。
こっちのほうが、単純に命令数が少ないから高速なのかな?
で、関数呼び出しのときにスタックに起こることを詳しく見ていく。
まず関数呼び出しの前に引数をスタックに積んでいく。
movl $2, 4(%eps) movl $1, (%eps)
の部分がそれ。後ろの引数から順にスタックに積んでいく。
pushl $2 pushl $1
と書き直した方が人間にはわかりやすい。
でも、きっと gcc が吐いた
一気にスタックを確保 (subl $40, %esp) -> movl で目的の場所へ入れる
のほうが高速なんだろうな。でも、なんで40も確保するんだろ?
引数2つとローカル変数1つで、12確保すれば十分な気がするのに。
このへんは、また調べてみよう。
引数を積み終わったら、今度は関数がある場所へ移動する。
call _add
がそれ。call では、
関数呼び出しが終わったときに帰るべきアドレスをスタックに積む -> 目的の関数へジャンプ
ということが行われている。だから、関数が呼び出された時にスタックの一番上*1にあるのは、帰るべきアドレスです。
ようやく関数を呼び出せました。
まずやることは、さっき書いたように現在のベースポインタの保存と設定。これが終わった後に
subl $8, %esp
として、スタックを成長させてますが、何故これが必要なのか相変わらず不明。
こうやってスタックが成長しても、ベースポインタの %ebp の指す場所は変わらない。
だから、8(%ebp) で第一引数、12(%ebp) で第二引数にアクセスできるわけだ。
4(%ebp) にはもともとの %ebp の値、(%ebp) には帰るべきアドレスが入っている。
movl 12(%ebp), %eax addl 8(%ebp), %eax
の部分で、実際に a+b を計算し、結果を %eax に入れている。
%eax は破壊してもよい*2レジスタで、関数の戻り値を置くレジスタでもある。
だから、これで戻り値を a+b の結果にちゃんとできたわけだ。
leave
これは、ベースポインタとスタックポインタを元に戻している。したがって、
movl %ebp, %esp popl %ebp
と同じことをしている。
これで帰る準備ができたため、実際に帰る処理に入る。それが
ret
だ。帰るべき場所はスタックの一番上に置いてあるので、
popl %edx jmp *%edx
と同じことをしている。%edx は自由に使っていいレジスタ。
そうして、無事に呼び出し元の関数 (main) に帰ってこれた。
戻り値は %eax に入っているため、
movl %eax, -12(%ebp)
で、ローカル変数 c に戻り値をコピーしている。
ところで、-8(%ebp) や -4(%ebp) には何が入ってるんだろ?よくわからない。
こうやって見ると、C ですら本当に処理を簡潔に記述できるなぁと思ってしまう。
でも、アセンブリコードレベルで考えるってのもなかなか面白い。