Share via


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

https://blogs.msdn.com/ntdebugging/archive/2009/01/09/challenges-of-debugging-optimized-x64-code.aspx