はじめに
Windows OS上で、とあるアプリケーションソフトウェアがしばしばクラッシュ(異常終了)します。利用者の立場なので、デバッガーで…という環境はありません。開発元で対処されるのを待つのみなのですが、運用環境でしか再現しないときなどは対処が難しくなります。
運用環境でできる対処として、UNIX系OSでは、アプリケーションソフトウェアがクラッシュするときの問題解決アプローチとして、コアダンプを生成する設定をして、生成されたコアダンプをデバッガ(gdb等)で解析するという方法があります。
そこで、Windows OSで同様なアプローチがあるかを調べると、クラッシュダンプを生成し、そのダンプファイルをデバッガ(WinDbg)で解析するという方法がありました。簡単なメモを次のWikiに書いています。
Windowsでアプリケーション異常終了の原因を追及する - ソフトウェアエンジニアリング - Torutk
クラッシュダンプをデバッガで解析すると、クラッシュした箇所のアセンブリ(ニーモニック)が得られます。そのあたりを調べていると、64bit版のプログラム(AMD64)では関数の引数がレジスタを介して渡されるということを知りました。
32bit版までの理解では、引数をスタックに積んで受け渡し、スタックに積む順番にいくつか種類があり、代表的なものにstdcall, cdeclがある(その他にはfastcall, thiscallなど)といったところです。
そこで、64bitコードでの関数呼び出しを少しかじってみることにします。
Windows 64bitコードでの関数呼び出し
Microsoftが定めるWindows OS上の64bitコードが使用する関数の呼び出し方法(ABI:Application Binary Interface)では、関数の引数のうち最初の4個がレジスタに割当てられます。5個目以降はスタックに割当てられます。戻り値はRAXレジスタです。
引数の位置 | 整数*1 | 浮動小数点数 |
---|---|---|
第1引数 | RCX | XMM0 |
第2引数 | RDX | XMM1 |
第3引数 | R8 | XMM2 |
第4引数 | R9 | XMM3 |
実際にどのようなコードが生成されるかを、Visual Studio 2015 Community EditionのVC++で試してみました。
サンプルコードの記述およびコンパイルにおけるメモ
- C++コードではシンボル(関数名)がマングリングされるので、extern "C"で関数を宣言します。
- Releaseビルドをします。
- ごく単純なサンプルコードは最適化でインライン展開され、また無効コードとして削除されるといった影響があるので、コンパイルオプションに/Ob1(コンパイラ判断ではインライン展開しない)を追加します。
- 引数を無理やりにでも使うコードを記述します。
- プロジェクトのプロパティで[C/C++] > [出力ファイル]でアセンブリの出力を指定します(アセンブリコード、コンピューター語コード、ソースコード(/FAcs))。
引数が1個(整数)の関数
int oneArg(int a) { int value = a + 1; return value; }
ビルドすると、コンパイルされたオブジェクトファイル(*.obj)と同じディレクトリに、アセンブリコードのファイル(*.cod)が生成されます。
第1引数は整数なのでRCXレジスタが、戻り値はRAXレジスタが使われるコードが生成されると期待して生成されたアセンブリコードを見てみましょう。
; COMDAT oneArg _TEXT SEGMENT a$ = 8 oneArg PROC ; COMDAT ; 5 : int value = a + 1; 00000 8d 41 01 lea eax, DWORD PTR [rcx+1] ; 6 : return value; ; 7 : } 00003 c3 ret 0 oneArg ENDP _TEXT ENDS
EAXレジスタは、戻り値を入れるRAXレジスタの下位32bitなので、戻り値型であるint型整数(32bit)が格納される場所で間違いなさそうです。
RCXレジスタは、第1引数を入れるレジスタなのでこれも間違いなさそうです。
ソースコードでは、第1引数の値に1を加算していますが、[rcs+1]で表現されているように見えます。
さて、ここでLEA命令が生成されています。LEA命令を調べると、アドレス計算の命令ですが、転じて整数の計算に使われています。ここでのコードは、RCXレジスタの値に1を加算した値をEAXレジスタに入れるものです。
引数が1個の関数を呼び出すコード
int arg = std::stoi(str); int ret1 = oneArg(arg);
生成されたアセンブリコードは次です。
; 13 : int ret1 = oneArg(arg); 000b7 8b cf mov ecx, edi 000b9 e8 00 00 00 00 call oneArg 000be 44 8b d0 mov r10d, eax
なお、EDIレジスタには前段の処理で、oneArg関数の引数として渡す値が入っています。
第1引数RCXレジスタの下位32bitであるECXレジスタに値を入れたあと関数呼び出しを行います。
戻り値であるEAXレジスタの値をR10レジスタの下位32bitに入れています。
push, popがなくすっきりしたコードになっています。
引数が4個(整数)の関数
int fourArgs(int a, int b, int c, int d) { int alfa = a + b; int bravo = c * d; int ret = alfa + bravo; return ret; }||< 第1引数aがRCX、第2引数bがRDX、第3引数cがR8、第4引数dがR9の各レジスタに入れて渡され、戻り値はRAXレジスタが使われるコードが生成されると期待されます。生成されたアセンブリコードを見てみましょう。 >|asm| fourArgs PROC ; COMDAT ; 5 : int alfa = a + b; ; 6 : int bravo = c * d; 00000 45 0f af c1 imul r8d, r9d ; 7 : int ret = alfa + bravo; 00004 42 8d 04 01 lea eax, DWORD PTR [rcx+r8] 00008 03 c2 add eax, edx ; 8 : return ret; ; 9 : } 0000a c3 ret 0 fourArgs ENDP
最初に第3引数cと第4引数dの掛け算のコードが生成されています。レジスタR8DはR8レジスタの下位32bitで、R9DはR9レジスタの下位32bitです。
imul命令でオペランドのレジスタ2つの掛け算が実行され、結果はR8レジスタに上書きされます。
lea命令で、第1引数aと、imul命令結果(c * d)が加算され、EAXレジスタに格納されます。
続くadd命令で、EAXレジスタ( a + c * d)と第2引数bが加算され、EAXレジスタに格納され、関数からリターンします。
引数が4個の関数を呼び出すコード
int ret2 = fourArgs(ret1, ret1 * 2, ret1 * 3, ret1 * 4);
生成されたアセンブリコードは次です。
; 14 : int ret2 = fourArgs(ret1, ret1 * 2, ret1 * 3, ret1 * 4); 000c1 44 8d 0c 85 00 00 00 00 lea r9d, DWORD PTR [rax*4] 000c9 44 8d 04 40 lea r8d, DWORD PTR [rax+rax*2] 000cd 8d 14 00 lea edx, DWORD PTR [rax+rax] 000d0 8b c8 mov ecx, eax 000d2 e8 00 00 00 00 call fourArgs 000d7 8b d8 mov ebx, eax
fourArgs関数の引数1〜4に渡すの値を、lea命令で計算しています。計算結果をそれぞれ引数2(RDXの下位32bitであるEDXレジスタ)、引数3(R8の下位32bitであるR8Dレジスタ)、引数4(R9の下位32bitであるR9Dレジスタ)、引数1(RCXの下位32bitであるECXレジスタ)に入れて、callで関数を呼び出しています。
ここまで関数の引数には、スタックは使われずレジスタで受け渡しされています。
引数が5個(整数、浮動小数点数混在)の関数
int fiveArgs(int a, double b, char* c, int d, int e) { int v1 = a + d + e; double v2 = v1 / b; double v3 = v2 + *c; int ret = oneArg((int)v3); return ret; }
第1引数aがRCX、第2引数bがXMM1、第3引数cがR8、第4引数dがR9の各レジスタに入れて渡され、第5引数eはスタックに積まれて渡され、戻り値はRAXレジスタが使われるコードが生成されると期待されます。生成されたアセンブリコードを見てみましょう。
fiveArgs PROC ; COMDAT ; 5 : int v1 = a + d + e; 00000 42 8d 04 09 lea eax, DWORD PTR [rcx+r9] 00004 03 44 24 28 add eax, DWORD PTR e$[rsp] 00008 66 0f 6e d0 movd xmm2, eax ; 6 : double v2 = v1 / b; ; 7 : double v3 = v2 + *c; ; 8 : int ret = oneArg((int)v3); ; 9 : return ret; 0000c 41 0f be 00 movsx eax, BYTE PTR [r8] 00010 f3 0f e6 d2 cvtdq2pd xmm2, xmm2 00014 66 0f 6e c0 movd xmm0, eax 00018 f2 0f 5e d1 divsd xmm2, xmm1 0001c f3 0f e6 c0 cvtdq2pd xmm0, xmm0 00020 f2 0f 58 d0 addsd xmm2, xmm0 00024 f2 0f 2c ca cvttsd2si ecx, xmm2 00028 e9 00 00 00 00 jmp oneArg fiveArgs ENDP
ちょっと複雑になってきました。
最初の計算では、第1引数aがRCX、第4引数dがR9、第5引数はスタックにあるのですが、pop命令ではなく、スタックポインタRSPにオフセットe$を加えた場所から値を取ってきて加算しています。
5個目の引数がスタックから取られていることが分かりました。
以降は省略します。
引数が4個の関数を呼び出すコード
15 : int ret3 = fiveArgs(argc, arg, (char*)argv, arg, arg); 000dd f3 0f e6 c9 cvtdq2pd xmm1, xmm1 000e1 89 74 24 20 mov DWORD PTR [rsp+32], esi 000e5 44 8b ce mov r9d, esi 000e8 4c 8b c7 mov r8, rdi 000eb 8b cd mov ecx, ebp 000ed e8 00 00 00 00 call fiveArgs
第1引数にRCX(ECX)、第2引数にXMM1、第3引数にR8、第4引数にR9(R9D)、第5引数はスタックに値を入れています。
スタックには、pushではなく、スタックポインタ(RSP)からのオフセットにmov命令で値を入れています。
ということで
アプリケーションの利用者という立場で、アプリケーションがクラッシュしたときにできることはクラッシュダンプを生成し、ダンプファイルを読み込んでヒントを得るといった程度しかできません。
また、アプリケーションプログラムのシンボル情報が得られない場合、クラッシュダンプを解析してもアセンブリコードレベルでしか調査ができません。
そこで、Windowsの64bitプログラムのアセンブリコードレベルでの仕組みを少しだけ触ってみました。
インテルの64bit IA-32命令のリファレンスガイドは次から入手できます(現時点では、日本語版は提供されていません)
Intel® 64 and IA-32 Architectures Software Developer Manuals | Intel® Software
その他メモ
Visual Studio 2015 Community Editionのライセンスの更新
Visual Studio 2015 Community Editionを久々に起動しました。すると、「ライセンスの更新が必要」と出て使用することができませんでした。
マイクロソフトアカウントを入力して更新すると、使えるようになりましたので、どうやらインストールしてからは一定期間毎にマイクロソフトアカウントを入力しないとダメなようです。インターネット接続環境が必須のようですね。