原文:
コンパイルされたCコードでの全般的な問題の一つに、関数の呼び出し規約(calling conventions)がある。呼び出し規約とは、関数を呼ぶ側での引数群の渡し方・関数の戻り値の取得方法・呼ばれる関数側でのスタックの使い方についての取り決めである。
呼び出し規約に関わるスタックのレイアウトは「スタックフレーム(stack frame)」とも呼ばれる。
Cと現在のCPU設計の慣習では、スタックフレームとはプログラムの実行時にスタック上に確保されるメモリ領域の塊である。スタックフレームは関数が呼ばれるたびに作成され、関数が使う自動変数を保存する。ネストした関数呼び出し、あるいは同じ関数を再帰的に呼び出す場合は、それぞれの呼び出し毎に独立したスタックフレームが作成される。
物理的にはSP(スタックポインタ)と、BP(フレームポインタ, intel表記ではベースポインタ)の指すアドレス範囲が関数のスタックフレームになる。関数が値をスタックにpushすると、SPが移動する為、スタックフレームも伸びていく。
この記事の内容はアセンブラレベルの話になる。C/C++プログラマから見た呼び出し規約については下記記事を参照。
スタックの解説では Microsoft Visual C コンパイラでの表記を使う。
これらはあくまでも規約であり、調整用のコードが周辺で使われることもある。他の呼び出し規約(例:引数をレジスタで渡す)が使われる場合は動きは違ってくるし、最適化により調整されてしまう場合もある。
ここでは呼び出し規約についての概要(overview)を解説しており、信頼に足る定義を行っているわけではない点に注意して欲しい。
__cdeclと__stdcall呼び出し規約では、次の3つのレジスタを使っている。
IntelのアセンブラではIntelの表記が使われ、GCCの世界では"AT&T"表記が後方互換性の為に使われている。困ったものではあるが、現実として受け入れるほか無い。
細かい表記の違いは幾つかあるが、最も苛立たしいのはAT&TとIntel表記ではソースオペランドとディスティネーションオペランドが逆さまになっていることだ。整数の即値"4"をEAXレジスタに移動するコードを見てみる:
mov $4, %eax ; AT&T表記 mov eax, 4 ; Intel表記
最近のGCCではIntel表記で生成する方法もあるようだが、GNUのアセンブラの方がそれを受け付けるのかは不明である。今回の記事ではIntel表記で統一している。
スタックの構造を理解するには、__cdecl呼び出し規約の関数呼び出しのステップを追うとよい。以下に示す各ステップはコンパイラにより自動的に生成され実行されるが、全てのステップが常に使われる訳ではない。引数が無い関数や、戻り値が無い関数、レジスタをバックアップする必要のない関数の場合は幾つかのステップが省略されるだろう。
この時点で関数内に突入しており、新しいEBPが必要となる。そのため次の2ステップを実行する。
push ebp mov ebp, esp // ebp << esp
EBPレジスタが更新されれば、「現在の」関数に渡された引数を8(%ebp), 12(%ebp)などのEBPに対するオフセット値により参照できる。0(%ebp)は直前の関数のフレームポインタで、4(%ebp)は現在の関数から復帰した直後に実行される命令のアドレスになる。
以上で関数のスタックフレームが構築され、引数やローカル変数には以下に示すようにEBPへのオフセットで参照出来るようになった。
16(%ebp) | 引数(3番目) |
12(%ebp) | 引数(2番目) |
8(%ebp) | 引数(1番目) |
4(%ebp) | 復帰用EIP |
0(%ebp) | 呼び出し元のEBP |
-4(%ebp) | ローカル変数その1 |
-8(%ebp) | ローカル変数その2 |
-12(%ebp) | ローカル変数その3 |
メモリレイアウトとしては次のようになっている。
HIGHER MEMORY ADDRESS | | | (...) | |--------------| | old %EIP | |--------------| +-->| old %EBP | | |--------------| | | (...) | | |--------------| | | (function | | | params) | | |--------------| | | param #2 |<--- 12(%ebp) | |--------------| | | param #1 |<--- 8(%ebp) | |--------------| | | old %EIP | | |--------------| +---| old %EBP |<--- %EBP (current) |--------------| | local var #1 |<--- -4(%ebp) |--------------| | local var #2 |<--- -8(%ebp) |--------------| | (...) | |--------------| | saved %reg | |--------------| | saved %reg |<--- %ESP (current) |--------------| | (...) | | | LOWER MEMORY ADDRESS
これにより関数は本体コードを実行し、退避したレジスタを使用することが出来るようになった。ただし、ESPの直接操作は絶対に避ける事。直接操作してしまうと呼び出し元に戻る際のレジスタ復元・ローカル変数解放・引数解放などがずれてしまい、プログラムがクラッシュする場合がある。
__stdcall呼び出し規約はWindows APIで主に使われ、__cdeclより少しだけコンパクトになる。主な違いは引数の数が固定されており、Cの可変長引数のように変更することが出来ない。
引数の数が固定される為、コンパイル時に引数領域のサイズを決定出来る。これによりスタック上の引数領域のクリーンアップ処理は関数側(calee)に移動し、次のような影響がある。
x86アーキテクチャではフレーム管理をサポートする組み込み機構がいくつか提供されている。しかし、Cコンパイラではそれらの機能はあまり使われていないようだ。
面白い例としてENTER命令を挙げる。これは典型的な関数のprologコードと同等の処理を実行する。
ENTER 10, 0 = PUSH EBP MOV EBP, ESP SUB ESP, 10
機能としては確かに同等で、80386プロセッサのリファレンスによるとサイズもコンパクトになる。ENTER命令は6バイト、同等コードの場合は9バイトになる。しかし必要とするクロック数が、ENTER命令は15クロックに対して同等コードなら6クロックで済む。最新のプロセッサの場合にどうなるかは不明だが、やはりENTER命令が遅い、と書かれているのをどこかで目にしたことがある。