torutkのブログ

ソフトウェア・エンジニアのブログ

VC++ 64bit版の関数呼び出し時はレジスタが多用されている

はじめに

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レジスタです。

Parameter Passing

引数の位置 整数*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

なお、UNIX系の64bitプログラムでは、6個までの引数がレジスタ渡しとなるようです。

その他メモ

Visual Studio 2015 Community Editionのライセンスの更新

Visual Studio 2015 Community Editionを久々に起動しました。すると、「ライセンスの更新が必要」と出て使用することができませんでした。
マイクロソフトアカウントを入力して更新すると、使えるようになりましたので、どうやらインストールしてからは一定期間毎にマイクロソフトアカウントを入力しないとダメなようです。インターネット接続環境が必須のようですね。

Windows OS標準ライブラリのシンボル情報

インターネットに接続がマスト、インターネット接続ができない環境では、いったんインターネット接続できるマシンでシンボル情報を取得し、取得したファイル群を持っていく方法となります。

ただし、DLLのバージョンが一致しているマシンでないとずれるので、同じマシンを用意するのが大変です。

  • シンボル情報を取得するマシンをインターネットに接続したとたん、Windowsアップデートがかかってバージョンがずれてしまうとか…

*1:ポインター、64bit以下の構造体を含む