home ホーム search 検索 -  login ログイン  | reload edit datainfo version cmd icon diff delete  | help ヘルプ

抄訳メモ/unixwiz.net/Intel x86 Function-call Conventions - Assembly View

抄訳メモ/unixwiz.net/Intel x86 Function-call Conventions - Assembly View

抄訳メモ / unixwiz.net / Intel x86 Function-call Conventions - Assembly View
id: 580 所有者: msakamoto-sf    作成日: 2010-02-07 13:59:40
カテゴリ: Assembler C言語 

原文:

コンパイルされたCコードでの全般的な問題の一つに、関数の呼び出し規約(calling conventions)がある。呼び出し規約とは、関数を呼ぶ側での引数群の渡し方・関数の戻り値の取得方法・呼ばれる関数側でのスタックの使い方についての取り決めである。
呼び出し規約に関わるスタックのレイアウトは「スタックフレーム(stack frame)」とも呼ばれる。

Cと現在のCPU設計の慣習では、スタックフレームとはプログラムの実行時にスタック上に確保されるメモリ領域の塊である。スタックフレームは関数が呼ばれるたびに作成され、関数が使う自動変数を保存する。ネストした関数呼び出し、あるいは同じ関数を再帰的に呼び出す場合は、それぞれの呼び出し毎に独立したスタックフレームが作成される。
物理的にはSP(スタックポインタ)と、BP(フレームポインタ, intel表記ではベースポインタ)の指すアドレス範囲が関数のスタックフレームになる。関数が値をスタックにpushすると、SPが移動する為、スタックフレームも伸びていく。

この記事の内容はアセンブラレベルの話になる。C/C++プログラマから見た呼び出し規約については下記記事を参照。

スタックの解説では Microsoft Visual C コンパイラでの表記を使う。

__cdecl
C言語で必要な機能をサポートする、最も良く使われる呼び出し規約。C言語はprintf()のような可変長引数を使う関数をサポートしているが、これは関数を呼ぶ側(caller)が関数呼び出し後のスタッククリーンアップを行うことを意味する。なぜなら呼ばれる関数側(callee)ではスタックにpushされた引数の数を原理的には判断出来ず、従ってスタッククリーンアップも出来ないからである。
__stdcall
__pascal呼び出し規約としても知られており、可変長引数は使えない。引数の数が固定されている為、呼ばれる関数側(callee)でスタッククリーンアップを行う。スタッククリーンアップの為のコードが関数側1箇所にまとめられる為、__cdeclと比較して若干サイズが小さくなる。Win32APIは__stdcallを使う。

これらはあくまでも規約であり、調整用のコードが周辺で使われることもある。他の呼び出し規約(例:引数をレジスタで渡す)が使われる場合は動きは違ってくるし、最適化により調整されてしまう場合もある。
ここでは呼び出し規約についての概要(overview)を解説しており、信頼に足る定義を行っているわけではない点に注意して欲しい。

スタックフレームで使われるレジスタについて

__cdecl__stdcall呼び出し規約では、次の3つのレジスタを使っている。

ESP - Stack Pointer
PUSH, POP, CALL, RET などのCPU命令で自動的に更新される32ビットレジスタ。スタック上での最後の要素のアドレスを指す(未使用領域の先頭アドレスではない)。「スタックの先頭(Top of the stack)」というのはスタックの使用領域のことで、x86アーキテクチャではアドレスの小さい方を指す。x86ではスタックはアドレスの小さい方へ伸びていく。
EBP - Base Pointer
スタックフレーム上で、関数の引数やローカル変数を参照する時に使う基準点となるアドレスを格納する32ビットレジスタ。。ESPレジスタと違い、EBPレジスタは手動で変更する。「フレームポインタ(Frame Pointer)」とも呼ばれる。
EIP - Instruction Pointer
次に実行されるCPU命令のアドレスを指す。CALL命令実行時に、スタック上に保存される。JUMP系の命令はEIPレジスタを直接書き換える。

アセンブラの表記について

IntelのアセンブラではIntelの表記が使われ、GCCの世界では"AT&T"表記が後方互換性の為に使われている。困ったものではあるが、現実として受け入れるほか無い。
細かい表記の違いは幾つかあるが、最も苛立たしいのはAT&TとIntel表記ではソースオペランドとディスティネーションオペランドが逆さまになっていることだ。整数の即値"4"をEAXレジスタに移動するコードを見てみる:

mov $4, %eax  ; AT&T表記
mov eax, 4    ; Intel表記

最近のGCCではIntel表記で生成する方法もあるようだが、GNUのアセンブラの方がそれを受け付けるのかは不明である。今回の記事ではIntel表記で統一している。

"__cdecl" 関数の呼び出し

スタックの構造を理解するには、__cdecl呼び出し規約の関数呼び出しのステップを追うとよい。以下に示す各ステップはコンパイラにより自動的に生成され実行されるが、全てのステップが常に使われる訳ではない。引数が無い関数や、戻り値が無い関数、レジスタをバックアップする必要のない関数の場合は幾つかのステップが省略されるだろう。

引数を右から左にスタックにpush
引数は右から左にスタックにpushされていく。評価順序とは関係ない。また、pushされる順序は言語で規定されている訳ではなくコンパイラの作者の好みに左右される為、push順序に依存するコードは(特に移植性を考慮する場合は)決して書いてはならない。呼び出す側はpushしたバイト数を覚えておき、後でクリーンアップする必要がある。
関数の呼び出し
CALL命令によりEIPレジスタがスタックに積まれる。この時のEIPはCALL命令の次、つまり関数呼び出しから復帰した直後に実行されるべき命令を指している。CALL命令が完了すると、制御は呼び出された関数側に移る。この時点ではEBPレジスタは変更されていない。
EBPの保存と更新

この時点で関数内に突入しており、新しいEBPが必要となる。そのため次の2ステップを実行する。

  1. 現時点でのEBP = 関数を呼んだ側のスタックフレームのフレームポインタ をスタックにpushして退避
  2. ESPレジスタの値をEBPにコピー
push ebp
mov ebp, esp // ebp << esp

EBPレジスタが更新されれば、「現在の」関数に渡された引数を8(%ebp), 12(%ebp)などのEBPに対するオフセット値により参照できる。0(%ebp)は直前の関数のフレームポインタで、4(%ebp)は現在の関数から復帰した直後に実行される命令のアドレスになる。

一時的に使うCPUレジスタの退避
関数内で使用するCPUレジスタについては、実際に値を変更される前にスタック上に退避する。コンパイラは、関数が復帰する時に退避したレジスタも復元するコードを生成する。
ローカル変数の配置
関数内でローカル変数を用いる場合は、4バイト単位でESPを伸ばすことでスタック上に領域を確保する*1。ローカル変数を参照する場合は、-4(%ebp)のように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の直接操作は絶対に避ける事。直接操作してしまうと呼び出し元に戻る際のレジスタ復元・ローカル変数解放・引数解放などがずれてしまい、プログラムがクラッシュする場合がある。

ローカル変数の解放
ローカル変数の確保ではESPを直接減算して確保するが、解放する時はPOP命令を連続して実行し、確保時に減算した領域と同じサイズ分だけESPを縮める = ESPを加算する。
退避したレジスタの復帰
レジスタを退避した時とは逆の順序で、スタック上からレジスタの値を復帰させていく。*2
直前のベースポインタの復元
この時点でスタック上のローカル変数、レジスタ退避領域は解放され、ESPポインタは呼び出し元関数のEBPを保存した領域まで巻戻っている。EBPを復元する為、スタックから値をPOPしEBPレジスタに格納する。
関数から戻る
RET命令により、EIPの値をスタックからEIPレジスタに戻し、その地点から処理を続行する。
pushされた引数のクリーンアップ
__cdecl呼び出し規約では、関数を呼ぶ側(callee)がスタックにpushした引数群を解放する。適当な使わないレジスタにPOPする場合もあれば、ESPレジスタの値を直接PUSHしたバイト数分加算する = スタックを縮める場合もある。

__cdecl vs __stdcall

__stdcall呼び出し規約はWindows APIで主に使われ、__cdeclより少しだけコンパクトになる。主な違いは引数の数が固定されており、Cの可変長引数のように変更することが出来ない。
引数の数が固定される為、コンパイル時に引数領域のサイズを決定出来る。これによりスタック上の引数領域のクリーンアップ処理は関数側(calee)に移動し、次のような影響がある。

  1. クリーンアップ処理が、複数の関数呼び出しポイントから関数本体1箇所に集約されるため、コードサイズが若干小さくなる。何十箇所で呼ばれる可能性がある場合はコードサイズ、そして実行速度にも若干改善効果が期待出来る。
  2. 引数の数を間違えるとスタックが破壊される。*3
  3. 上記危険性を回避する為、Microsoft Visual C コンパイラは __stdcall 呼び出し規約の関数名については、シンボル名に変換する時に引数の数をくっつけることでリンカレベルで安全を確保している。例えば引数が2つの関数なら、4byte x 2で8バイトの領域を使うので "_関数名@8" がリンカ段階のシンボル名になる。従って引数の数が違うとシンボル名を解決出来ないので警告を表示する。 __cdeclの場合は引数の数がシンボル名に反映されない為、引数の数が違っていても正常にリンク出来る。

ENTER命令

x86アーキテクチャではフレーム管理をサポートする組み込み機構がいくつか提供されている。しかし、Cコンパイラではそれらの機能はあまり使われていないようだ。
面白い例としてENTER命令を挙げる。これは典型的な関数のprologコードと同等の処理を実行する。

ENTER 10, 0    =    PUSH EBP
                    MOV EBP, ESP
                    SUB ESP, 10

機能としては確かに同等で、80386プロセッサのリファレンスによるとサイズもコンパクトになる。ENTER命令は6バイト、同等コードの場合は9バイトになる。しかし必要とするクロック数が、ENTER命令は15クロックに対して同等コードなら6クロックで済む。最新のプロセッサの場合にどうなるかは不明だが、やはりENTER命令が遅い、と書かれているのをどこかで目にしたことがある。


*1: 順序的には先にESPを調整してローカル領域を確保した「後に」レジスタの退避が行われるのではないか?本記事だけでなくその他の参考リソースを見た限りでも、メモリ上のレイアウトでは「引数群→EIP→EBP→ローカル領域→レジスタ退避領域」の順で描かれている。
*2: これも「ローカル変数の解放」の前に行われるべきではないか?でないとPOP処理の順序がメモリレイアウトの図と矛盾してしまう。
*3: K&R-Cの関数宣言では引数の数が無視されるので、引数の数が違っていてもチェックされず、__stdcallの場合はスタックの破壊を引き起こす。ANSI-Cのプロトタイプ宣言では引数の数もチェックされるようになるので、より安全になった。

プレーンテキスト形式でダウンロード
現在のバージョン : 1
更新者: msakamoto-sf
更新日: 2010-02-07 14:09:36
md5:4131ba114c991db11dfb20b833b26432
sha1:d76ae7f5854a06b3fd4ac88767550163cac84742
コメント
コメントを投稿するにはログインして下さい。