関数呼び出しの仕組み

C言語で関数を呼び出すとき、コンパイラはどのようなアセンブラ命令を生成しているのか。このページでは関数呼び出しに関わる一連の仕組みを順に説明します。

目次

  1. 「呼び出し」で解決しなければならない問題
  2. 引数の渡し方(呼び出し規約)
  3. 戻り先アドレスの保存
  4. スタックフレーム
  5. 関数戻り値の返し方
  6. 全体の流れ

1. 「呼び出し」で解決しなければならない問題

次の C コードを例に考えます。

int add(int a, int b) {
    return a + b;
}

int main(void) {
    int result = add(3, 5);
    return 0;
}

mainadd(3, 5) を呼び出すとき、CPU は以下の3つの問題を解決しなければなりません。

  1. 引数をどう渡すか35add に届ける
  2. どこに戻ってくるかadd が終わったら main の続きに戻れる
  3. 戻り値をどう受け取るかadd の計算結果を main で使える

これらの取り決めを 呼び出し規約(Calling Convention / ABI) と呼びます。アーキテクチャごとに標準が決まっており、コンパイラはこの規約に従ってコードを生成します。

2. 引数の渡し方(呼び出し規約)

引数は レジスタ を使って渡します。スタックへの書き込みではなくレジスタを使うのは、メモリアクセスより高速だからです。

ARM Cortex-M(AAPCS)

第1〜第4引数を r0r3 に格納する。
第5引数以降はスタックに積む。

mov r0, #3   ; 第1引数 a = 3
mov r1, #5   ; 第2引数 b = 5
bl  add

x86-64(System V AMD64 ABI)

第1〜第6引数を rdi rsi rdx rcx r8 r9 の順に格納する。
第7引数以降はスタックに積む。

mov edi, 3   ; 第1引数 a = 3
mov esi, 5   ; 第2引数 b = 5
call add
eax / edi のような 32bit 名が出てくる理由:
引数が int(32bit)の場合、GCC は rdi(64bit)ではなく edi(32bit)を使います。 CPU の仕様上、edi への書き込みは rdi の上位32bitを自動的にゼロクリアするため、動作は同じです。
AsmWalker での確認:
サンプル「関数呼び出し」をコンパイルし、call(x86)または bl(ARM)命令のステップまで進めると、 引数レジスタ(x86: rdi/rsi、ARM: r0/r1)に値がセットされた状態がレジスタパネルにバッジ付きで表示されます。

3. 戻り先アドレスの保存

関数が呼び出される前の main の「続き」のアドレスを 戻り先アドレス(Return Address) と呼びます。

CPU がプログラムのどこを実行しているかは プログラムカウンタ(PC / RIP) に記録されています。callbl 命令はこのカウンタを呼び出し先に書き換えます。そのとき、戻り先アドレスをどこかに保存しておかないと、関数終了後に元の場所に戻れません。

ARM:リンクレジスタ(LR / r14)に保存

BL 命令が実行されると、戻り先アドレスが自動的に LR に書き込まれます。

bl add      ; LR ← 戻り先アドレス
            ; PC ← add のアドレス

; add 内での return
bx lr       ; PC ← LR(main に戻る)

x86-64:スタックに保存

call 命令が実行されると、戻り先アドレスがスタックに push されます。

call add    ; [rsp] ← 戻り先アドレス
            ; rsp  -= 8
            ; RIP  ← add のアドレス

; add 内での return
ret         ; RIP ← [rsp](main に戻る)
            ; rsp += 8
ARM と x86 の根本的な違い:
ARM はレジスタ1本(LR)に戻り先アドレスを保存するため、関数の中でさらに関数を呼ぶと LR が上書きされてしまいます。 そのため、ARM の関数は最初に push {lr} でスタックに待避し、戻る直前に pop {pc} で復元します。
x86 は call の時点でスタックに積むので、この問題が自動的に解決されています。
AsmWalker での確認:
call(x86)命令のステップの命令詳細パネルに、戻り先アドレスが [rsp] ← 0x401038 のような形式で表示されます。 その値がスタックパネルにも積まれているので、両方を見比べると戻り先アドレスの保存場所がつかめます。

4. スタックフレーム

関数が実行される間、ローカル変数や一時データを置くためにスタック上に確保した領域を スタックフレーム と呼びます。

スタックは高アドレス側から低アドレス側に向かって成長します。

「解放」とはスタックポインタを動かすだけ

スタックフレームは関数が終わると 解放 されます。ただし、これはメモリ上の値を消去しているわけではありません。

スタックの確保も解放も、スタックポインタ(SP / RSP)を動かすだけで実現されます。

確保: sub rsp, 16    ; SP を 16 下げる → その領域が「使用中」になる
解放: add rsp, 16    ; SP を 16 上げる → その領域が「空き」になる
                     ; ※ メモリ上の値は残ったまま(次に使う関数が上書きするまで)
「解放後も値が残る」落とし穴:
スタックに置いたローカル変数のアドレスをポインタとして外部に渡すと、 関数が返った後に SP が戻されてもメモリ上の値はしばらく残ります。 しかし次の関数呼び出しで上書きされるため、そのポインタ経由でアクセスすると 不定な値を読むことになります。これが C 言語でよく起きる「スタック変数のアドレス返却バグ」の原因です。

フレームの作り方(プロローグ)

ARM

push {r7, lr}     ; 呼び出し元の r7(FP)と LR を待避
sub  r7, sp, #0   ; r7(FP)← 現在の SP
sub  sp, sp, #8   ; ローカル変数分のスタック確保

x86-64

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(最適化なし)でコンパイルするため、 フレームポインタを使う形式で出力されます。
AsmWalker での確認:
push rbp(x86)または push {r7, lr}(ARM)のステップでスタックパネルを見ると、 フレームポインタが新しく積まれる様子が確認できます。 ステップを進めるごとにスタックが下方向(低アドレス側)に伸びていく動きが視覚的にわかります。

フレームの解放(エピローグ)

ARM

pop {r7, pc}   ; r7 復元 + PC ← LR(return)

x86-64

pop rbp        ; rbp 復元
ret            ; RIP ← [rsp](return)、rsp += 8
; ※ GCC は leave 命令で pop rbp の代わりに使うことがある

スタックの状態(x86-64 の例)

高アドレス
┌──────────────────────────────┐
│  ...(main のフレーム)       │
│  戻り先アドレス(call が積む)  │ ← call add 実行直後
│  保存された rbp               │ ← push rbp で積む
│  ローカル変数 a = 3           │ ← [rbp-4]
│  ローカル変数 b = 5           │ ← [rbp-8]
│  ...(空き)                  │ ← rsp が指す位置
低アドレス

  ↑ pop rbp / ret が実行されると rsp が上に戻り、
    add のフレーム領域は「空き」扱いになる(値は消えない)

5. 関数戻り値の返し方

戻り値(return value)は決まったレジスタに格納して返します。

アーキテクチャ整数・ポインタの戻り値64bit 戻り値
ARM Cortex-Mr0r0:r1(上位 r1)
x86-64rax(32bit 結果なら eaxrax
; 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 に結果が残っている
AsmWalker での確認:
ret(x86)または bx lr / pop {pc}(ARM)のステップでレジスタパネルを見ると、 eax(x86)または r0(ARM)に「戻り値」バッジが表示されます。 その値が呼び出し元 main の変数 result に対応します。

6. 全体の流れ

main から add(3, 5) を呼び出して戻るまでの一連の流れです。

1
引数をレジスタにセット
mov edi, 3 / mov esi, 5(x86)または mov r0, #3 / mov r1, #5(ARM)
2
call / bl 命令で関数へジャンプ
戻り先アドレスをスタック(x86)またはLR(ARM)に保存し、PCを add の先頭に書き換える。
3
プロローグ:スタックフレームを作る
前のフレームポインタを待避し、新しいフレームポインタをセット。ローカル変数の領域を確保する。
4
関数本体の処理
引数(レジスタ)を読み取り、計算を行い、戻り値を eax(x86)または r0(ARM)にセット。
5
エピローグ:スタックフレームを解放
フレームポインタを復元し、スタックを巻き戻す。
6
ret / bx lr 命令で main に戻る
スタック(x86)またはLR(ARM)から戻り先アドレスを取り出し、PCにセット。
7
main が戻り値を使う
eax(x86)または r0(ARM)に結果が入っている。
AsmWalker での確認:
サンプル「関数呼び出し」を選んでコンパイルすると、上記の流れをステップ実行で1命令ずつ確認できます。 call 命令のステップでは引数レジスタの値がバッジ表示され、ret 命令のステップでは戻り値レジスタがハイライトされます。