当社では新人向けにコンピュータの基礎知識や、組み込み制御などの教育を行っています。
前回はスタック上に変数がどの様に確保されるかについて説明しました。
今回は関数呼び出し時にスタックがどの様に使われるかについて説明していきます。
C言語プログラムとバイナリコード
以下のプログラム①をコンパイルし、アセンブラコードに変換すると②になります。
① C言語プログラム
int func(int c, int d )
{
int a = 100
c = a + d;
return c;
}
int main()
int a = 10;
int b = 20;
int rtn;
rtn = func(a,b);
:
② アセンブラコード
main:
push ebp
mov ebp, esp
sub esp, 12
mov DWORD PTR [ebp-4], 10
mov DWORD PTR [ebp-8], 20
push DWORD PTR [ebp-8]
push DWORD PTR [ebp-4]
call func
add esp, 8
mov DWORD PTR [ebp-12], eax
mov esp, ebp
pop ebp
ret
func:
push ebp
mov ebp, esp
sub esp, 4
mov DWORD PTR [ebp-4], 100
mov eax, DWORD PTR [ebp-4]
add eax, DWORD PTR [ebp+12]
mov DWORD PTR [ebp+8], eax
mov eax, DWORD PTR [ebp+8]
mov esp, ebp
pop ebp
ret
命令の説明
- call : 関数呼び出しを行う。その際、現在のPC(プログラムカウンタ)をスタックへpushにて保存する。
- ret : 関数から呼び出し元へ戻る。call時にスタックに保存されたPCをpopすることで呼び出し前の箇所へ戻ることができる。
※ 前回の説明にあった命令については省略。
アセンブラコードの動作説明
この②のアセンブラコードがどの様に動作するかを以下に記載する。
main:
push ebp
mov ebp, esp
sub esp, 12
mainからプログラムが開始される。
まず現在のベースポインタをスタックへ保存し、新しいスタックフレームを構築する。
続いてスタック上に a, b, rtn の3個の変数領域(合計12バイト)を確保する。

mov DWORD PTR [ebp-4], 10
mov DWORD PTR [ebp-8], 20
確保したローカル変数領域に初期値を設定する。
変数a([ebp-4]) 10、変数b([ebp-8]) に 20 を格納する。
push DWORD PTR [ebp-8]
push DWORD PTR [ebp-4]
変数a 及び変数b の値をスタックへ push する。
これらは関数呼び出し時の引数となる。
スタックは後入れ先出し構造であるため、結果として関数側では正しい順序で引数を参照できる。

call func
関数 func を呼び出す。
call 命令は、次に実行すべき命令のアドレス(戻りアドレス)をスタックへ push し、その後 func へ制御を移す。
この仕組みにより、関数終了後に元の位置へ確実に復帰できる。

以下、関数内の処理である。
func:
push ebp
mov ebp, esp
sub esp, 4
mov DWORD PTR [ebp-4], 100
新たに関数用のスタックフレームを構築する。
main のスタックフレームとは独立した領域を使用する。
変数 a 用に4バイト確保し、100 を代入する。
この変数aと同名の変数がmainにもあるが、同名であっても異なるスタック領域に存在する別の変数である。

mov eax, DWORD PTR [ebp-4]
add eax, DWORD PTR [ebp+12]
mov DWORD PTR [ebp+8], eax
c = a + d; の処理を実行する。
まずローカル変数 a([ebp-4])を eax に読み込む。
次に引数 d([ebp+12])を加算する。
計算結果を引数 c([ebp+8])へ書き戻す。
演算は汎用レジスタ eax を用いて実行される。
mov eax, DWORD PTR [ebp+8]
引数 c の値を eax に格納する。
x86 では戻り値は eax に格納する規約であるため、この値が関数の戻り値となる。
mov esp, ebp
pop ebp
スタックポインタとベースポインタを関数呼び出し直後の状態へ復元する。
これにより、関数内で確保したローカル変数領域は破棄される。

ret
スタックに保存されている戻りアドレスを取り出し、そのアドレスへ制御を戻す。

再び main 側の処理である。
add esp, 8
push した引数2個分(8バイト)を破棄し、スタックポインタを元の位置へ戻す。
関数内で引数 c の値を書き換えているが、それは使われずに破棄される。
C言語で引数に値を返すことができないのはこの為である。

mov DWORD PTR [ebp-12], eax
eax に格納されている戻り値を変数 rtn に保存する。
mov esp, ebp
pop ebp
ret
main のスタックフレームを解放し、プログラムを終了する。
ここでは、初期のx86系CPUである Intel 8086 を例に関数呼び出しの動作を説明した。
8086 は汎用レジスタ数が少ないため、引数はスタック経由で受け渡す方式を採用している。
その後の Intel 80386 以降のCPUではレジスタ数が拡張され、さらに現代の64ビット環境ではレジスタ渡しが主流となっている。
以上が本アセンブラコードの動作である。
