x64 のデバッグについて
こんにちは、プラットフォーム サポートの近藤です。
今回は、最近良く見る x64 コードのデバッグについてお話します。
- x64 呼出規約について
x64 上では、全ての呼び出しが従来の FASTCALL に似た呼出規約が用いられています。ここでは呼出規約の詳細は説明しませんが (興味ある方は記事末尾の参考資料をご覧ください)、x64 アーキテクチャで増えたレジスタを多く使用するようになっています。引数に関しましては、基本的に 4 つ目の引数までが順に rcx、rdx、r8、r9 レジスタで渡され、残りはスタックに保存されます。このように、多くの場合引数がレジスタで渡されているため、デバッグ時には注意が必要です。
では、実際の動きを見てみましょう。
今回は下記テスト プログラムをデバッグしながら進めていきます。
=================================
__declspec(noinline)
void func6(DWORD64 a, DWORD64 b, DWORD64 c, DWORD64 d, DWORD64 e, DWORD64 f)
{
printf("Func6: 0x%x 0x%x 0x%x 0x%x 0x%x 0x%x\n", a, b, c, d, e, f);
return;
}
__declspec(noinline)
void func3(DWORD64 a, DWORD64 b, DWORD64 c)
{
DWORD64 d = 0x40;
DWORD64 e = 0x50;
DWORD64 f = rand();
printf("Func3: 0x%x 0x%x 0x%x\n", a, b, c);
func6(0x10,0x20,0x30,d,e,f);
return;
}
int _tmain(int argc, _TCHAR* argv[])
{
DWORD64 a = 1;
DWORD64 b = 2;
DWORD64 c = rand();
printf("Calling Func3\n");
func3(a, b, c);
return 0;
}
==================================
コンパイラによる最適化を抑えるため、__declspec(noinline) を使用し、引数にもランダムな数字を渡しています。
まずは func3 が呼ばれた瞬間です。
0:000> kb
RetAddr : Args to Child : Call Site
00000001`3f7c10cc : 00000001`3f7c21f0 00000000`00000000 000007ff`fffde000 00000000`000000fe : testProgram!func3
00000001`3f7c1292 : 00000000`00000000 000000dc`b1cca995 00000000`00000000 00000000`00000000 : testProgram!wmain+0x2c
00000000`7789f56d : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : testProgram!__tmainCRTStartup+0x11a
00000000`779d3281 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : kernel32!BaseThreadInitThunk+0xd
00000000`00000000 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x1d
func3 には、"0x1" "0x2" "0x29"、の 3 つの引数が渡されていますが、ご覧の通り、'kb' コマンドを実行しても、引数は正常に表示されません。これは引数がスタックではなく、レジスタに保存されているためです。
0:000> r
rax=000000000000000e rbx=0000000000000029 rcx=0000000000000001
rdx=0000000000000002 rsi=0000000000000000 rdi=0000000000000001
rip=000000013f7c1040 rsp=000000000031f898 rbp=0000000000000000
r8=0000000000000029 r9=00000000001771ae r10=0000000000000000
r11=0000000000000246 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl nz na po nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206
testProgram!func3:
00000001`3f7c1040 48895c2408 mov qword ptr [rsp+8],rbx ss:00000000`0031f8a0={testProgram!`string' (00000001`3f7c21f0)}
次に、func6 を呼んだ瞬間です。
0:000> kb
RetAddr : Args to Child : Call Site
00000001`3f7c1092 : 00000001`3f7c21d8 00000000`00000001 00000000`00000002 00000000`00000029 : testProgram!func6
00000001`3f7c10cc : 00000000`00000029 00000000`00000000 000007ff`fffde000 00000000`000000fe : testProgram!func3+0x52
00000001`3f7c1292 : 00000000`00000000 000000dc`b1cca995 00000000`00000000 00000000`00000000 : testProgram!wmain+0x2c
00000000`7789f56d : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : testProgram!__tmainCRTStartup+0x11a
00000000`779d3281 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : kernel32!BaseThreadInitThunk+0xd
00000000`00000000 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x1d
引数として、"0x10" "0x20" "0x30" "0x40" "0x50" "0x4823" を渡しています。レジスタとスタックを見ますと、第 5 と 第 6 引数のみスタックに保存されていることが確認できます。
0:000> r
rax=0000000000000014 rbx=0000000000004823 rcx=0000000000000010
rdx=0000000000000020 rsi=0000000000000000 rdi=0000000000000029
rip=000000013f7c1000 rsp=000000000031f858 rbp=0000000000000000
r8=0000000000000030 r9=0000000000000040 r10=0000000000000000
r11=0000000000000246 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl nz na po nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206
testProgram!func6:
00000001`3f7c1000 4883ec48 sub rsp,48h
0:000> dps @rsp La
00000000`0031f858 00000001`3f7c1092 testProgram!func3+0x52
00000000`0031f860 00000001`3f7c21d8 testProgram!`string'
00000000`0031f868 00000000`00000001
00000000`0031f870 00000000`00000002
00000000`0031f878 00000000`00000029
00000000`0031f880 00000000`00000050 << 第 5 引数
00000000`0031f888 00000000`00004823 << 第 6 引数
00000000`0031f890 00000000`00000001
00000000`0031f898 00000001`3f7c10cc testProgram!wmain+0x2c
00000000`0031f8a0 00000000`00000029
さて、ここでスタック上の第 5 引数の前に、0x20 バイトの領域があることに気づくと思います。00000000`0031f860 ~ 00000000`0031f880 の領域です。
00000000`0031f858 00000001`3f7c1092 << func3 への戻り値
00000000`0031f860 00000001`3f7c21d8
00000000`0031f868 00000000`00000001
00000000`0031f870 00000000`00000002
00000000`0031f878 00000000`00000029
00000000`0031f880 00000000`00000050 << 第 5 引数
ここは "home space" と呼ばれる空間であり、第 1 から第 4 引数を入れることのできる領域です。この領域は、呼び出した関数が引数を取らなくても用意されます。尚、x64 の場合、'kb' コマンドで表示されるのは関数への引数ではなく、このホームスペースの値となります。
ホーム スペースは呼び出し側が必ず用意しますが、残念ながら、ここの使用は呼び出し先次第であり、必ずしも引数が保存されるわけではありません。デバッグビルドなどの場合は、プロローグコードの最初に渡された引数をこのホーム スペースにコピーしますが、今回の例ではリリース ビルドのため、func3 内にあった printf 関数の引数が残っています。注意していただきたいのは、このコピーを呼び出し先が行うことです。呼び出し元は、必ずレジスタを使用して引数を渡します。
// リリース ビルドの場合
0:000> u testprogram!func6
testProgram!func6:
00000001`3f7c1000 4883ec48 sub rsp,48h
00000001`3f7c1004 488b442478 mov rax,qword ptr [rsp+78h]
00000001`3f7c1009 ba10000000 mov edx,10h
00000001`3f7c100e 488d0d9b110000 lea rcx,[testProgram!`string' (00000001`3f0f21b0)]
00000001`3f7c1015 4889442430 mov qword ptr [rsp+30h],rax
00000001`3f7c101a 448d4a20 lea r9d,[rdx+20h]
00000001`3f7c101e 448d4210 lea r8d,[rdx+10h]
00000001`3f7c1022 48c744242850000000 mov qword ptr [rsp+28h],50h
// デバッグ ビルドの場合
0:000> u testProgram!func6
testProgram!func6:
00000001`3fd31030 4c894c2420 mov qword ptr [rsp+20h],r9
00000001`3fd31035 4c89442418 mov qword ptr [rsp+18h],r8
00000001`3fd3103a 4889542410 mov qword ptr [rsp+10h],rdx
00000001`3fd3103f 48894c2408 mov qword ptr [rsp+8],rcx
00000001`3fd31044 57 push rdi
00000001`3fd31045 4883ec40 sub rsp,40h
00000001`3fd31049 488bfc mov rdi,rsp
00000001`3fd3104c 48b91000000000000000 mov rcx,10h
- 引数の探し方
さて、この時点で、例えば func3 への引数を調べたかったとしましょう。今回はホーム スペースに残っていますが、これはたまたまですので、なかったこととしてデバッグしてみます。
0:000> k
Child-SP RetAddr Call Site
00000000`0031f810 00000001`3f7c1092 testProgram!func6+0x34
00000000`0031f860 00000001`3f7c10cc testProgram!func3+0x52
00000000`0031f8a0 00000001`3f7c1292 testProgram!wmain+0x2c
00000000`0031f8d0 00000000`7789f56d testProgram!__tmainCRTStartup+0x11a
00000000`0031f900 00000000`779d3281 kernel32!BaseThreadInitThunk+0xd
00000000`0031f930 00000000`00000000 ntdll!RtlUserThreadStart+0x1d
まずは func3 が呼ばれる直前のアセンブリを確認します。
0:000> ub 00000001`3f7c10cc
testProgram!wmain+0x6:
00000001`3f7c10a6 ff157c100000 call qword ptr [testProgram!_imp_rand (00000001`3f7c2128)]
00000001`3f7c10ac 488d0d3d110000 lea rcx,[testProgram!`string' (00000001`3f7c21f0)]
00000001`3f7c10b3 4863d8 movsxd rbx,eax
00000001`3f7c10b6 ff157c100000 call qword ptr [testProgram!_imp_printf (00000001`3f7c2138)]
00000001`3f7c10bc ba02000000 mov edx,2
00000001`3f7c10c1 8d4aff lea ecx,[rdx-1]
00000001`3f7c10c4 4c8bc3 mov r8,rbx
00000001`3f7c10c7 e874ffffff call testProgram!func3 (00000001`3f7c1040)
第 1、第 2 引数は "1" と "2" を指定しただけですので、値を直接 ecx と edx に入れていますね。しかし、第 3 引数は rand() の結果を一度 rbx に入れ、それを r8 に入れていることが分かります。
では、func3 の頭をアセンブリを確認します。
0:000> u 00000001`3f7c1040
testProgram!func3:
00000001`3f7c1040 48895c2408 mov qword ptr [rsp+8],rbx
00000001`3f7c1045 57 push rdi
00000001`3f7c1046 4883ec30 sub rsp,30h
00000001`3f7c104a 498bf8 mov rdi,r8
00000001`3f7c104d ff15d5100000 call qword ptr [testProgram!_imp_rand (00000001`3f7c2128)]
00000001`3f7c1053 ba01000000 mov edx,1
00000001`3f7c1058 488d0d79110000 lea rcx,[testProgram!`string' (00000001`3f7c21d8)]
00000001`3f7c105f 448d4201 lea r8d,[rdx+1]
最初に rbx の値を rsp+8 に保存しています。ということは、この時点でのスタックポインタの値が分かれば、rbx に保存されていた第 3 引数の値も分かります。
では、スタック ポインタの値を計算しましょう。まず、x64 呼出では、一つの関数のプロローグ コードとエピローグ コードの間、rsp の値は変わりません。関数開始時に実行されるプロローグコードで、ローカル変数や、関数内から呼ぶ関数の引数用のスタックスペースなど、必要なスタック領域を全て事前に確保するようになっています。アセンブリを見ると、rbx レジスタをスタックに保存した後、push コマンドを実行し、sub コマンドで rsp をずらしていますね。この sub コマンドが、確保処理です。つまり、これ以降、スタックポインタは func3 内では変わりません。
では、func3 実行時のスタック ポインタを確認しましょう。この値は 'k' コマンドで確認することができます。
0:000> k
Child-SP RetAddr Call Site
00000000`0031f810 00000001`3f7c1092 testProgram!func6+0x34
00000000`0031f860 00000001`3f7c10cc testProgram!func3+0x52
00000000`0031f8a0 00000001`3f7c1292 testProgram!wmain+0x2c
00000000`0031f8d0 00000000`7789f56d testProgram!__tmainCRTStartup+0x11a
00000000`0031f900 00000000`779d3281 kernel32!BaseThreadInitThunk+0xd
00000000`0031f930 00000000`00000000 ntdll!RtlUserThreadStart+0x1d
0x00000000`0031f860 ですね。後は、ここからプロローグコードで行われた処理を逆計算すれば、引数の値を確認できます。
プロローグ コードでは 0x30 を引き、さらに push もあったので、rsp の値は +0x38 のはずです。
0:000> ? 00000000`0031f860+30+8
Evaluate expression: 3274904 = 00000000`0031f898
ここから、rsp+8 に保存していたので…
0:000> dps 00000000`0031f898+8 L1
00000000`0031f8a0 00000000`00000029
一致しますね。無事、引数の値を確認することができました。
- 裏技紹介
このように、x64 では引数一つ調べるだけでかなりの工数がかかってしまいます。場合によって、引数を調べるためだけに関数を二つ、三つと追っていく必要があることもあり、大変です。そこで、この作業時間を減らすコマンドを紹介します!
.frame /r < フレーム番号>
このコマンドでは、指定したスタックフレーム時のレジスタの中身を表示します。尚、全てのレジスタ値が表示されますが、信用できる値は非揮発的なレジスタのみです。x64 では rbx、rbp、rdi、rsi、そして r12 ~ r15 ですね。
さて、上記例では、func3 に渡された第 3 引数は、wmain 関数の rbx に保存されていました。そこで、このコマンドの出番です。
まずは wmain のフレーム番号を確認します。
0:000> kn
# Child-SP RetAddr Call Site
00 00000000`0031f810 00000001`3f7c1092 testProgram!func6+0x34
01 00000000`0031f860 00000001`3f7c10cc testProgram!func3+0x52
02 00000000`0031f8a0 00000001`3f7c1292 testProgram!wmain+0x2c
03 00000000`0031f8d0 00000000`7789f56d testProgram!__tmainCRTStartup+0x11a
04 00000000`0031f900 00000000`779d3281 kernel32!BaseThreadInitThunk+0xd
05 00000000`0031f930 00000000`00000000 ntdll!RtlUserThreadStart+0x1d
フレーム 2 ですね。では、フレーム 2 のレジスタの内容を確認しましょう。
0:000> .frame /r 2
02 00000000`0031f8a0 00000001`3f7c1292 testProgram!wmain+0x2c
rax=0000000000004823 rbx=0000000000000029 rcx=000000013f7c21b0
rdx=0000000000000010 rsi=0000000000000000 rdi=0000000000000001
rip=000000013f7c10cc rsp=000000000031f8a0 rbp=0000000000000000
r8=0000000000000020 r9=0000000000000030 r10=0000000000000000
r11=0000000000000246 r12=0000000000000000 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl nz na pe nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000202
testProgram!wmain+0x2c:
00000001`3f7c10cc 33c0 xor eax,eax
rbx には引数である 0x29 が表示されていますね。簡単に引数を調べることができました。
x64 環境のデバッグを行う際には役立つコマンドですので、是非活用してください。
- 参考資料
Overview of x64 Calling Conventions
https://msdn.microsoft.com/en-us/library/ms235286.aspx
x64 Software Conventions
https://msdn.microsoft.com/en-us/library/7kcdt6fy.aspx
Challenges of Debugging Optimized x64 Code