C言語で関数を呼び出すとき、コンパイラはどのようなアセンブラ命令を生成しているのか。このページでは関数呼び出しに関わる一連の仕組みを順に説明します。
次の C コードを例に考えます。
int add(int a, int b) {
return a + b;
}
int main(void) {
int result = add(3, 5);
return 0;
}
main が add(3, 5) を呼び出すとき、CPU は以下の3つの問題を解決しなければなりません。
3 と 5 を add に届けるadd が終わったら main の続きに戻れるadd の計算結果を main で使えるこれらの取り決めを 呼び出し規約(Calling Convention / ABI) と呼びます。アーキテクチャごとに標準が決まっており、コンパイラはこの規約に従ってコードを生成します。
引数は レジスタ を使って渡します。スタックへの書き込みではなくレジスタを使うのは、メモリアクセスより高速だからです。
第1〜第4引数を r0〜r3 に格納する。
第5引数以降はスタックに積む。
mov r0, #3 ; 第1引数 a = 3
mov r1, #5 ; 第2引数 b = 5
bl add
第1〜第6引数を rdi rsi rdx rcx r8 r9 の順に格納する。
第7引数以降はスタックに積む。
mov edi, 3 ; 第1引数 a = 3
mov esi, 5 ; 第2引数 b = 5
call add
int(32bit)の場合、GCC は rdi(64bit)ではなく edi(32bit)を使います。
CPU の仕様上、edi への書き込みは rdi の上位32bitを自動的にゼロクリアするため、動作は同じです。
call(x86)または bl(ARM)命令のステップまで進めると、
引数レジスタ(x86: rdi/rsi、ARM: r0/r1)に値がセットされた状態がレジスタパネルにバッジ付きで表示されます。
関数が呼び出される前の main の「続き」のアドレスを 戻り先アドレス(Return Address) と呼びます。
CPU がプログラムのどこを実行しているかは プログラムカウンタ(PC / RIP) に記録されています。call や bl 命令はこのカウンタを呼び出し先に書き換えます。そのとき、戻り先アドレスをどこかに保存しておかないと、関数終了後に元の場所に戻れません。
BL 命令が実行されると、戻り先アドレスが自動的に LR に書き込まれます。
bl add ; LR ← 戻り先アドレス
; PC ← add のアドレス
; add 内での return
bx lr ; PC ← LR(main に戻る)
call 命令が実行されると、戻り先アドレスがスタックに push されます。
call add ; [rsp] ← 戻り先アドレス
; rsp -= 8
; RIP ← add のアドレス
; add 内での return
ret ; RIP ← [rsp](main に戻る)
; rsp += 8
push {lr} でスタックに待避し、戻る直前に pop {pc} で復元します。call の時点でスタックに積むので、この問題が自動的に解決されています。
call(x86)命令のステップの命令詳細パネルに、戻り先アドレスが
[rsp] ← 0x401038 のような形式で表示されます。
その値がスタックパネルにも積まれているので、両方を見比べると戻り先アドレスの保存場所がつかめます。
関数が実行される間、ローカル変数や一時データを置くためにスタック上に確保した領域を スタックフレーム と呼びます。
スタックは高アドレス側から低アドレス側に向かって成長します。
スタックフレームは関数が終わると 解放 されます。ただし、これはメモリ上の値を消去しているわけではありません。
スタックの確保も解放も、スタックポインタ(SP / RSP)を動かすだけで実現されます。
確保: sub rsp, 16 ; SP を 16 下げる → その領域が「使用中」になる
解放: add rsp, 16 ; SP を 16 上げる → その領域が「空き」になる
; ※ メモリ上の値は残ったまま(次に使う関数が上書きするまで)
push {r7, lr} ; 呼び出し元の r7(FP)と LR を待避
sub r7, sp, #0 ; r7(FP)← 現在の SP
sub sp, sp, #8 ; ローカル変数分のスタック確保
push rbp ; 呼び出し元の rbp を待避
mov rbp, rsp ; rbp ← 現在の rsp(フレーム基点)
sub rsp, 16 ; ローカル変数分のスタック確保
r7(ARM)・rbp(x86)は フレームポインタ(FP / Base Pointer) と呼ばれ、フレームの基点アドレスを保持します。
ローカル変数へのアクセスは「フレームポインタ ± オフセット」で行います。
; x86-64 の例:ローカル変数 a を [rbp-4] に配置
mov DWORD PTR [rbp-4], edi ; a = 第1引数(スタックに保存)
mov eax, DWORD PTR [rbp-4] ; eax = a(読み出し)
-O1 以上)や ARM Thumb モード(-mthumb)では、
コンパイラがフレームポインタを省略し、スタックポインタからのオフセットで
ローカル変数にアクセスすることがあります。
mov DWORD PTR [rsp+4], edi(フレームポインタなし)rbp(または r7)は汎用レジスタとして別用途に使われます。
AsmWalker のサンプルは -O0(最適化なし)でコンパイルするため、
フレームポインタを使う形式で出力されます。
push rbp(x86)または push {r7, lr}(ARM)のステップでスタックパネルを見ると、
フレームポインタが新しく積まれる様子が確認できます。
ステップを進めるごとにスタックが下方向(低アドレス側)に伸びていく動きが視覚的にわかります。
pop {r7, pc} ; r7 復元 + PC ← LR(return)
pop rbp ; rbp 復元
ret ; RIP ← [rsp](return)、rsp += 8
; ※ GCC は leave 命令で pop rbp の代わりに使うことがある
高アドレス
┌──────────────────────────────┐
│ ...(main のフレーム) │
│ 戻り先アドレス(call が積む) │ ← call add 実行直後
│ 保存された rbp │ ← push rbp で積む
│ ローカル変数 a = 3 │ ← [rbp-4]
│ ローカル変数 b = 5 │ ← [rbp-8]
│ ...(空き) │ ← rsp が指す位置
低アドレス
↑ pop rbp / ret が実行されると rsp が上に戻り、
add のフレーム領域は「空き」扱いになる(値は消えない)
戻り値(return value)は決まったレジスタに格納して返します。
| アーキテクチャ | 整数・ポインタの戻り値 | 64bit 戻り値 |
|---|---|---|
| ARM Cortex-M | r0 | r0:r1(上位 r1) |
| x86-64 | rax(32bit 結果なら eax) | rax |
; x86-64 の add 関数の末尾
mov edx, DWORD PTR [rbp-4] ; edx = a
mov eax, DWORD PTR [rbp-8] ; eax = b
add eax, edx ; eax = a + b(関数戻り値)
pop rbp
ret ; main に戻る。eax に結果が残っている
ret(x86)または bx lr / pop {pc}(ARM)のステップでレジスタパネルを見ると、
eax(x86)または r0(ARM)に「戻り値」バッジが表示されます。
その値が呼び出し元 main の変数 result に対応します。
main から add(3, 5) を呼び出して戻るまでの一連の流れです。
mov edi, 3 / mov esi, 5(x86)または mov r0, #3 / mov r1, #5(ARM)
eax(x86)または r0(ARM)にセット。
eax(x86)または r0(ARM)に結果が入っている。
call 命令のステップでは引数レジスタの値がバッジ表示され、ret 命令のステップでは戻り値レジスタがハイライトされます。