以前C言語で、業務とはまったく関係のないお遊びプログラムを作っている時に、不可解な現象に悩まされました。 とある関数の内部で、呼び出し元の情報を書き換えて、関数を抜け出す時に本来の戻り先とは別の戻り先に制御を移すという、ちょっと変な処理を行うものです。 この時は何か目的があってプログラミングをしていたわけではなく、あまり深く考えもせずに作っていたので、最初はプログラムがバグってるのかな?と思っていました。 しかし、何をやってもコンパイル時に最適化オプションを付けた時だけ発生するので、気になり調査しました。
ソースコード
問題のプログラムの処理フローは次のようになっています。関数は通常、呼び出した場所にreturnで戻るのですが、このプログラムはreturnをすると次の関数に制御が移るようになっています。
ソースは、以下のコードになります。
- 環境によってはうまく動かない場合もありますが、ご了承ください
- 多少いい加減なコードになっていますが、気にしないでください
環境
OS:Windows Vista SP2(32bit)
CPU:Intel Core2 Duo 2GHz
コンパイラ:Microsoft Visual C++ 9.0 Express
sample.c
#include <stdio.h>
// デバッグメッセージに使用するマクロ
#define DBG_p(s) printf(s)
int func1(int ret);
int func2(void);
// 書き換えた後に、本来のアドレスに戻る時に使用
unsigned int return_address;
// プログラムエントリーポイント
int main(void){
int ret = 0;
DBG_p("in main\n");
ret = func1(1000);
printf("function result = %d\n",ret);
DBG_p("out main\n");
return 0;
}
// mainから通常の呼び出され方で呼び出される
// この関数は引数の値をそのままreturnする
int func1(int ret)
{
unsigned int *ebp_addr;
__asm {
// スタック取得
mov ebp_addr, ebp
}
DBG_p(" in func1\n");
// リターンアドレスを取得
return_address = ebp_addr[1];
// 指定した関数に書き換える
ebp_addr[1] = (unsigned int)func2;
DBG_p(" out func1\n");
// 適当な戻り値を返す
return ret;
}
// func1のreturnの直後に呼び出される
// func1で返した値に1000を足す
int func2(void)
{
unsigned int add_val = 1000;
unsigned int ret_val = 0;
__asm {
// func1の戻り値を加算する
add eax, add_val
mov ret_val, eax
}
DBG_p(" in func2\n");
DBG_p(" out func2\n");
__asm {
mov eax, ret_val
// 元々の戻り値(func1のリターンアドレス)にジャンプする
mov esp, ebp
pop ebp
jmp return_address
}
}
プログラムの詳細は以下のようになっています。
- main関数からfunc1(1000)を呼び出す
- func1で関数の戻り先をfunc2に書き換え、元々の戻り先を保持する
- func1で引数をそのままreturnすると、func2が呼び出される
- func2でfunc1の戻り値に1000を足す
- func2から保持した戻り先(main)に戻る
- mainで結果を表示して終了
まずは、最適化をしない場合の結果です。以下のコマンドでコンパイルします。
cl sample.c
最適化なし結果
次に、最適化オプションを指定した場合の結果です。以下のコマンドでコンパイルします。
cl /Ox sample.c
結果を見ると、最適化を行わない場合はfunc1が終わった後にfunc2が呼び出されます。これは想定どおりです。 しかし、最適化を行うとfunc1が終わった後ではなく、mainが終わった後にfunc2が呼び出されます。 実行タイミングがまったく違うので、結果もまったく変わってしまいます。何故こんなことがおきるのでしょうか?
アセンブリ言語のソースを見てみよう
どんな最適化をしたのか、その結果を見てみます。 コンパイルオプションに、アセンブル後のコードを出力させるオプションを追加し、再度コンパイルして見比べてみます。
- 関係ない部分は削ってありますので、そのままでは動きません
- 文字列の出力は最後のprintf以外は消してあります
最適化なしアセンブリコード
_DATA SEGMENT COMM _return_address:DWORD $SG2463 DB 'function result = %d', 0aH, 00H _DATA ENDS PUBLIC _func1 PUBLIC _main EXTRN _printf:PROC _TEXT SEGMENT _ret$ = -4 ; size = 4 _main PROC ; Line 13 push ebp mov ebp, esp push ecx ; Line 14 mov DWORD PTR _ret$[ebp], 0 ; Line 18 push 1000 ; 000003e8H call _func1 add esp, 4 mov DWORD PTR _ret$[ebp], eax ; Line 20 mov eax, DWORD PTR _ret$[ebp] push eax push OFFSET $SG2463 call _printf add esp, 8 ; Line 23 xor eax, eax ; Line 24 mov esp, ebp pop ebp ret 0 _main ENDP _TEXT ENDS PUBLIC _func2 _TEXT SEGMENT _ebp_addr$ = -4 ; size = 4 _ret$ = 8 ; size = 4 _func1 PROC ; Line 29 push ebp mov ebp, esp push ecx ; Line 33 mov DWORD PTR _ebp_addr$[ebp], ebp ; Line 37 mov eax, DWORD PTR _ebp_addr$[ebp] mov ecx, DWORD PTR [eax+4] mov DWORD PTR _return_address, ecx ; Line 39 mov edx, DWORD PTR _ebp_addr$[ebp] mov DWORD PTR [edx+4], OFFSET _func2 ; Line 43 mov eax, DWORD PTR _ret$[ebp] ; Line 44 mov esp, ebp pop ebp ret 0 _func1 ENDP _ret_val$ = -8 ; size = 4 _add_val$ = -4 ; size = 4 _func2 PROC ; Line 49 push ebp mov ebp, esp sub esp, 8 ; Line 50 mov DWORD PTR _add_val$[ebp], 1000 ; 000003e8H ; Line 51 mov DWORD PTR _ret_val$[ebp], 0 ; Line 55 add eax, DWORD PTR _add_val$[ebp] ; Line 56 mov DWORD PTR _ret_val$[ebp], eax ; Line 61 mov eax, DWORD PTR _ret_val$[ebp] ; Line 63 mov esp, ebp ; Line 64 pop ebp ; Line 65 jmp DWORD PTR _return_address ; Line 67 mov esp, ebp pop ebp ret 0 _func2 ENDP _TEXT ENDS END
最適化ありアセンブリコード
_DATA SEGMENT COMM _return_address:DWORD $SG2509 DB 'function result = %d', 0aH, 00H _DATA ENDS PUBLIC _func2 _TEXT SEGMENT _ret_val$ = -8 ; size = 4 _add_val$ = -4 ; size = 4 _func2 PROC ; Line 49 push ebp mov ebp, esp sub esp, 8 ; Line 50 mov DWORD PTR _add_val$[ebp], 1000 ; 000003e8H ; Line 51 mov DWORD PTR _ret_val$[ebp], 0 ; Line 55 add eax, DWORD PTR _add_val$[ebp] ; Line 56 mov DWORD PTR _ret_val$[ebp], eax ; Line 61 mov eax, DWORD PTR _ret_val$[ebp] ; Line 63 mov esp, ebp ; Line 64 pop ebp ; Line 65 jmp DWORD PTR _return_address ; Line 67 mov esp, ebp pop ebp ret 0 _func2 ENDP _TEXT ENDS PUBLIC _func1 _TEXT SEGMENT _ebp_addr$ = -4 ; size = 4 _ret$ = 8 ; size = 4 _func1 PROC ; Line 29 push ebp mov ebp, esp push ecx ; Line 33 mov DWORD PTR _ebp_addr$[ebp], ebp ; Line 37 mov eax, DWORD PTR _ebp_addr$[ebp] mov ecx, DWORD PTR [eax+4] mov DWORD PTR _return_address, ecx ; Line 39 mov DWORD PTR [eax+4], OFFSET _func2 ; Line 43 mov eax, DWORD PTR _ret$[ebp] ; Line 44 mov esp, ebp pop ebp ret 0 _func1 ENDP _TEXT ENDS PUBLIC _main EXTRN _printf:PROC _TEXT SEGMENT _ebp_addr$2531 = -4 ; size = 4 _main PROC ; Line 13 push ebp mov ebp, esp push ecx ; Line 18 mov DWORD PTR _ebp_addr$2531[ebp], ebp mov eax, DWORD PTR _ebp_addr$2531[ebp] mov ecx, DWORD PTR [eax+4] ; Line 20 push 1000 ; 000003e8H mov DWORD PTR _return_address, ecx push OFFSET $SG2509 mov DWORD PTR [eax+4], OFFSET _func2 call _printf add esp, 8 ; Line 23 mov eax, 9000 ; 00002328H ; Line 24 mov esp, ebp pop ebp ret 0 _main ENDP _TEXT ENDS END
ごちゃごちゃしてて見難いのですが、注目すべき点は、main関数のコードです。その中でも『; Line 18』と書いてある部分が決定的な違いの部分です。 見やすくする為にmain関数を抜粋しました。
最適化なしmain関数アセンブリコード
_TEXT SEGMENT _ret$ = -4 ; size = 4 _main PROC ; Line 13 push ebp mov ebp, esp push ecx ; Line 14 mov DWORD PTR _ret$[ebp], 0 ; Line 18 push 1000 ; 000003e8H call _func1 add esp, 4 mov DWORD PTR _ret$[ebp], eax ; Line 20 mov eax, DWORD PTR _ret$[ebp] push eax push OFFSET $SG2463 call _printf add esp, 8 ; Line 23 xor eax, eax ; Line 24 mov esp, ebp pop ebp ret 0 _main ENDP _TEXT ENDS
最適化ありmain関数アセンブリコード
PUBLIC _main _TEXT SEGMENT _ebp_addr$2532 = -4 ; size = 4 _main PROC ; Line 13 push ebp mov ebp, esp push ecx ; Line 18 mov DWORD PTR _ebp_addr$2532[ebp], ebp mov eax, DWORD PTR _ebp_addr$2532[ebp] mov ecx, DWORD PTR [eax+4] ; Line 20 push 1000 ; 000003e8H mov DWORD PTR _return_address, ecx push OFFSET $SG2509 mov DWORD PTR [eax+4], OFFSET _func2 call _printf add esp, 8 ; Line 23 mov eax, 9000 ; 00002328H ; Line 24 mov esp, ebp pop ebp ret 0 _main ENDP _TEXT ENDS
『; Line 18』見てみると、最適化なしの場合は、引数をスタックに積んでfunc1を呼び出しています。 しかし最適化ありの場合は、ベースポインタを参照してリターンアドレスを取得して・・・っと、まるでfunc1と同じ、と言うよりもfunc1のコードがその場に展開されてます。 そうです。原因は、最適化による関数自体のインライン展開によって、動作が変わってしまっていたのでした。 インライン展開によって処理フローは以下のように変わってしまいました。
インライン展開されることによって、まず取得しているリターンアドレスが変わります。 次にfunc2を呼び出すタイミングが変わるので、func1の戻り値に1000を足しているつもりが、実際にはmainの戻り値に1000を足してしまいます。 結果、全体的に全く想定外の動きになってしまっています。
インライン展開をさせたくない場合は?
コンパイルオプションで、最適化を行わなくする以外に、このプログラムをコンパイルする時に使用したコンパイラMicrosoft Visual C++ 9.0 Expressではコンパイラに対する指示(プラグマ)を用いることで、インライン展開を部分的に抑止することができるようです。 このソースコードの場合は、以下のようなコードに変更します。
#pragma auto_inline( off )
// mainから通常の呼び出され方で呼び出される
// この関数は引数の値をそのままreturnする
int func1(int ret)
{
unsigned int *ebp_addr;
__asm {
// スタック取得
mov ebp_addr, ebp
}
DBG_p(" in func1\n");
// リターンアドレスを取得
return_address = ebp_addr[1];
// 指定した関数に書き換える
ebp_addr[1] = (unsigned int)func2;
DBG_p(" out func1\n");
// 適当な戻り値を返す
return ret;
}
#pragma auto_inline( on )
上記のように、auto_inlineプラグマを指示することで、#pragma auto_inline( off )から、次に#pragma auto_inline( on )が来るまでの間の関数のインライン展開を抑止することができます。 特定の関数のリターンアドレスを意図的に取得したりする場合は、これは必須ですね。
まとめ
コンパイラの最適化の1つにインライン展開という機能があるのは知っていましたが、普段意識していなかったために、今回はアセンブリ言語のソースを見ないと原因がわかりませんでした。 インラインアセンブラでインライン展開してはまずいコードを書いても、コンパイラはそこまで深く判断できずに、展開可能と判断してしまうんですね。
今回の事で、インライン展開の最適化が行われると問題になるケースがあるということを改めて認識し、それを制御する方法があることがわかりました。 ただし、プラグマはコンパイラに対する指示なので、使用するコンパイラによっては使えない場合があります。 使う場合はそれぞれのコンパイラのマニュアルを参照してください。
普段やらないことをやると、当たり前のような事につまずいてしまいます。しかし、何でだろ?と悩んで、調べて一歩一歩解決していくのもプログラミングの醍醐味かなと思います。 こういう普段仕事では組まないような、お遊びプログラムを作るのは楽しいですね。























この記事にはまだコメントがついていません。
■ コメントをどうぞ