原文地址:https://blogs.msdn.microsoft.com/ntdebugging/2009/01/09/challenges-of-debugging-optimized-x64-code/
如果你还没有调试过优化的x64代码的奢华体验,不要等太久而落后于时代!因为x64类似于fastcall的调用惯例与丰富的通用寄存器,查找在调用栈的任意点的变量值确实非常棘手。
在本文,我想给出我最喜欢的调试优化x64代码的技术的细节。不过在深入这些技术之前,让我们先快速浏览一下x64的调用惯例。
x64调用惯例
你们中间熟悉x86平台fastcall调用惯例的人将发现x64调用惯例的相似性。通常你必须掌握x86平台上多种调用惯例,但在x64平台上目前只有一个。(当然,我排除了没有调用惯例的情形,你可以通过__declspec(naked)或直接用汇编编程来实现)。我不走入x64调用惯例所有的各种细微差别,因此我建议你调查一下链接(http://msdn.microsoft.com/en-us/library/ms794533.aspx)。不过通常,函数的前4个参数通过寄存器rcx,rdx,r8,r9传递。如果函数接受的参数超过4个,剩下的参数在栈上传递。(你们中间熟悉前2个参数在ecx与edx中传递的x86fastcall调用惯例的那部分人会发现其中的相似性)。
为了帮助展示x64调用惯例如何工作,我创建了一些简单的例子代码。虽然代码是人为的,远离现实生活中的代码,但它展现了某些很可能在现实世界中遭遇的场景。代码显示如下。
#include <stdlib.h>
#include <stdio.h>
#include <windows.h>
__declspec(noinline)
void
FunctionWith4Params( int param1, int param2, int param3,
int param4 )
{
size_tlotsOfLocalVariables1 = rand();
size_tlotsOfLocalVariables2 = rand();
size_tlotsOfLocalVariables3 = rand();
size_tlotsOfLocalVariables4 = rand();
size_tlotsOfLocalVariables5 = rand();
size_tlotsOfLocalVariables6 = rand();
DebugBreak();
printf( "Entering FunctionWith4Params( %X, %X, %X, %X )\n",
param1,param2, param3, param4 );
printf( "Local variables: %X, %X, %X, %X, %X, %X \n",
lotsOfLocalVariables1,lotsOfLocalVariables2,
lotsOfLocalVariables3,lotsOfLocalVariables4,
lotsOfLocalVariables5,lotsOfLocalVariables6 );
}
__declspec(noinline)
void
FunctionWith5Params( int param1, int param2, int param3,
int param4, int param5 )
{
FunctionWith4Params(param5, param4, param3, param2 );
FunctionWith4Params(rand(), rand(), rand(), rand() );
}
__declspec(noinline)
void
FunctionWith6Params( int param1, int param2, int param3,
int param4, int param5, int param6 )
{
size_tsomeLocalVariable1 = rand();
size_tsomeLocalVariable2 = rand();
printf( "Entering %s( %X, %X, %X, %X, %X, %X )\n",
"FunctionWith6Params",
param1,param2, param3, param4, param5, param6 );
FunctionWith5Params(rand(), rand(), rand(),
param1,rand() );
printf( "someLocalVariable1 = %X, someLocalVariable2 = %X\n",
someLocalVariable1,someLocalVariable2 );
}
int
main( int /*argc*/, TCHAR** /*argv*/ )
{
// I use the rand() function throughout this code to keep
// the compiler from optimizing too much. If I had used
// constant values, the compiler would have optimized all
// of these away.
int params[] = {rand(), rand(), rand(),
rand(),rand(), rand() };
FunctionWith6Params(params[0], params[1], params[2],
params[3],params[4], params[5] );
return 0;
}
把这个代码剪切、拷贝到一个cpp文件(比如example.cpp)。我使用WindowsSDK(确切地,Windows SDK CMD Shell)通过以下的命令行来编译这个C++代码:
cl/EHa /Zi /Od /favor:INTEL64 example.cpp /link /debug
注意选项/Od。这禁止了所有的优化。随后,我将启用最大限度的优化,这正是好玩的开始!
一旦你构建出了可执行模块(我的被命名为example.exe),你可以这样在调试器中启动它:
windbg-Q -c "bu example!main;g;" example.exe
上面的命令将在windbg中启动这个应用程序,在main()上设置一个断点,然后跑到该断点。
现在,让我们看一下在调用FunctionWith6Params()时栈的一个图。下面这个图展示了当指令指针在FunctionWith6Params()代码开头,但在执行prologue代码之前的栈:
注意调用者,在这里是main(),在栈上为FunctionWith6Params()的所有6个参数分配了足够的空间,尽管前4个参数通过寄存器传递。在栈上这额外的空间通常被称为寄存器参数“主空间(home space)”。在上面的图中,我把这些槽显示为xxxxxxxx以表示目前这里面的值是完全随机的。那是因为调用者,main(),没有初始化这些槽。出于安全保护,被调用函数可以自行决定把前4个参数保存在这个空间。这正是非优化构建中所发生的,这是一个巨大的调试便利性,因为如果你需要,你很容易地在栈上找出前4个参数的内容。另外,windbg的栈命令,比如显示头几个这些参数的kb与kv,将报告真实的结果。
说了这么多,下面是执行FunctionWith6Params()的prologue代码后,栈的样子:
FunctionWith6Params()的prologue的汇编代码显示如下:
0:000> uf .
example!FunctionWith6Params[c:\temp\blog_entry\sample_code\example.cpp @ 28]:
41 00000001`40015900mov dword ptr [rsp+20h],r9d
41 00000001`40015905 mov dwordptr [rsp+18h],r8d
41 00000001`4001590a mov dwordptr [rsp+10h],edx
41 00000001`4001590e mov dwordptr [rsp+8],ecx
41 00000001`40015912 push rbx
41 00000001`40015913 push rsi
41 00000001`40015914 push rdi
41 00000001`40015915 sub rsp,50h
你可以看到前4条指令将前4个参数保存到栈上由main()分配的主空间。然后,prologue代码保存所有FunctionWith6Params()在执行期间计划使用的非易变(non-volatile)寄存器。在返回调用者前,函数的epilogue代码恢复被保存寄存器的状态。最后,prologue代码在栈上保留一些空间,在这个例子中,0x50个字节。
在栈顶保留的这个空间做什么用?首先,这个空间是为局部变量创建的。在这个例子中,FunctionWith6Params()有两个。不过,这两个局部变量仅占0x10个字节。这个创建在栈顶的空间的余下部分又有什么作用呢?
在x64平台上,当代码为调用另一个函数准备栈时,它不使用push指令把参数放到栈上,就像x86代码通常做的那样。相反,对特定的一个函数,栈指针通常保持不变。编译器查看在当前函数代码中所有调用的函数,它找出参数最多的那个,然后在栈上构建足够的空间以容纳这些参数。在这个例子里,FunctionWith6Params()传递8个参数调用printf()。因为那是带有最多参数的被调用函数,编译器在栈上创建了8个槽。FunctionWith6Params()调用的函数将栈顶的4个槽用作主空间。
X64调用惯例的一个便利的副作用是,一旦你在一个函数的prologue与epilogue的范围内,当指令指针在这个函数里,栈指针不会改变。这消除了在x86调用惯例中常见的基地址指针。在FunctionWith6Params()的代码准备调用子函数时,它只是将前4个参数放入要求的寄存器,如果有多于4个参数,它使用mov指令将余下参数放入分配的栈空间,但要确保跳过栈上前4个参数的槽。
调试优化的x64代码(噩梦的开始)
为什么调试x64优化代码如此棘手?嗯,记得调用者在栈上为被调用者保存前4个参数创建的主空间吗?结果是调用惯例不要求被调用者使用这个空间!你可以确定地打赌优化的x64代码将不会使用这个空间,除非对于其优化目标必要且方便。另外,在优化代码使用主空间时,它可以它来保存非易变寄存器,而不是函数的前4个参数。
走,使用以下命令行重新编译例子代码:
cl /EHa /Zi /Ox /favor:INTEL64 example.cpp /link/debug
注意选项/Ox的使用。这打开了最大的优化。调试符号仍然打开,因此我们可以容易地调试优化代码。总是打开调试信息来构建你的release发布,使得你可以调试它!
让我们看一下FunctionWith6Params()的prologue汇编代码变成什么样子:
41 00000001`400158e0mov qword ptr [rsp+8],rbx
41 00000001`400158e5 mov qwordptr [rsp+10h],rbp
41 00000001`400158ea mov qwordptr [rsp+18h],rsi
41 00000001`400158ef push rdi
41 00000001`400158f0 push r12
41 00000001`400158f2 push r13
41 00000001`400158f4 sub rsp,40h
41 00000001`400158f8mov ebx,r9d
41 00000001`400158fbmov edi,r8d
41 00000001`400158femov esi,edx
41 00000001`40015900mov r12d,ecx
优化代码显著不同了!让我们逐条列举这些变化:
· 函数使用了栈上的主空间,不过它不在那里保存前4个参数。相反,它用来保存它后面在epilogue代码中必须恢复的一些非易变寄存器。这个优化代码准备使用更多的处理器寄存器,因此它必须保存更多的非易变寄存器。
· 出于安全保护,它仍然把3个非易变寄存器压入栈,另外3个它保存在主空间。
· 然后它在栈上创建空间。不过,这个空间比非优化代码的小,仅0x40个字节。这是因为优化代码使用寄存器来表示局部变量someLocalVariable1与someLocalVariable2。因此,它仅需为调用带有最多参数函数,printf(),所需的8个槽创建空间。
· 它接着将前4个参数保存在非易变寄存器中,而不是主空间。(不要依赖这个行为。一个优化后的函数可能没有rcx,rdx,r8及r9内容的拷贝。它完全依赖代码的结构)
现在单步FunctionWith6Params()到第一个printf()调用后的源代码行。在我机器上来自printf()调用的生成输出如下:
Entering FunctionWith6Params( 29, 4823, 18BE, 6784,4AE1, 3D6C )
在windbg中栈命令的一个常用版本是kb,它也显示了在帧里每个函数的前几个参数。在现实中,它显示了栈的前几个位置。Kb命令的输出如下:
0:000> kb
RetAddr : ArgstoChild :Call Site
00000001`4001593b : 00000000`00004ae100000000`00004823 00000000`000018be 00000000`007e3570 : example!FunctionWith6Params+0x6a [c:\temp\blog_entry\sample_code\example.cpp@ 37]
00000001`40001667 : 00000000`00000000 00000000`00000000 00000000`0000000000000000`00000001 : example!main+0x5b[c:\temp\blog_entry\sample_code\example.cpp @ 57]
00000000`76d7495d : 00000000`00000000 00000000`00000000 00000000`0000000000000000`00000000 : example!__tmainCRTStartup+0x15b
00000000`76f78791 : 00000000`00000000 00000000`00000000 00000000`0000000000000000`00000000 : kernel32!BaseThreadInitThunk+0xd
00000000`00000000 : 00000000`00000000 00000000`00000000 00000000`0000000000000000`00000000 : ntdll!RtlUserThreadStart+0x1d
注意不是所有FunctionWith6Params()前4个参数都匹配kb命令的显示!当然,这是优化的一个副作用。你不能简单地相信kb与kv在优化代码中显示的输出。这是为什么优化的x64代码如此难以调试的最大的原因。相信我上面kb输出中第二、第三槽匹配FunctionWith6Params()的实际参数值纯粹是运气。这是因为FunctionWith6Params()在这些槽中保存非易变寄存器,正好main()在调用FunctionWith6Params()之前将这些值放入这些非易变寄存器。
参数侦查——技术1(沿着调用图往下)
现在,让我们看一下在运行x64代码时查找函数难以捉摸的参数的一些技术。出于展示,我在FunctionWith4Params()中放置了一个DebugBreak()。开始,让代码在windbg中运行直到命中这个断点。现在,想象你正在看到的实际上不是一个活动的调试现场,而是来自你客户的一个dump文件,这是你应用程序崩溃的地方。因此,你看了一下栈,它看起来像这样:
0:000> kL
Child-SP RetAddr CallSite
00000000`0012fdc8 00000001`40015816 ntdll!DbgBreakPoint
00000000`0012fdd0 00000001`400158a0 example!FunctionWith4Params+0x66
00000000`0012fe50 00000001`40015977 example!FunctionWith5Params+0x20
00000000`0012fe80 00000001`40015a0b example!FunctionWith6Params+0x97
00000000`0012fee0 00000001`4000168b example!main+0x5b
00000000`0012ff20 00000000`7733495d example!__tmainCRTStartup+0x15b
00000000`0012ff60 00000000`77538791 kernel32!BaseThreadInitThunk+0xd
00000000`0012ff90 00000000`00000000 ntdll!RtlUserThreadStart+0x1d
现在,要找出怎么错了,你需要知道FunctionWith6Params()的第一个参数。假定你没有在控制台输出中看到第一个参数。不要作弊!
我想展示的第一个技术涉及在调用图中向下挖掘,找出在进入FunctionWith6Params()以后,rcx(第一个参数)的内容发生了什么变化。在这个情形里,因为参数是32位整数,我们将尝试跟踪ecx的内容,它是rcx的下半部。
让我们从查看FunctionWith6Params()里从开始到调用FunctionWith5Params()的汇编代码:
0:000> u example!FunctionWith6Paramsexample!FunctionWith6Params+0x97
example!FunctionWith6Params [c:\temp\blog_entry\sample_code\example.cpp @41]:
00000001`400158e0 mov qword ptr [rsp+8],rbx
00000001`400158e5 mov qword ptr[rsp+10h],rbp
00000001`400158ea mov qword ptr[rsp+18h],rsi
00000001`400158ef push rdi
00000001`400158f0 push r12
00000001`400158f2 push r13
00000001`400158f4 sub rsp,40h
00000001`400158f8 mov ebx,r9d
00000001`400158fb mov edi,r8d
00000001`400158fe mov esi,edx
00000001`40015900 mov r12d,ecx
00000001`40015903 call example!rand(00000001`4000148c)
00000001`40015908 movsxd r13,eax
00000001`4001590b call example!rand(00000001`4000148c)
00000001`40015910 lea rdx,[example!`string’+0x68(00000001`40020d40)]
00000001`40015917 movsxd rbp,eax
00000001`4001591a mov eax,dword ptr[rsp+88h]
00000001`40015921 lea rcx,[example!`string’+0x80 (00000001`40020d58)]
00000001`40015928 mov dword ptr[rsp+38h],eax
00000001`4001592c mov eax,dword ptr[rsp+80h]
00000001`40015933 mov r9d,esi
00000001`40015936 mov dword ptr[rsp+30h],eax
00000001`4001593a mov r8d,r12d
00000001`4001593d mov dword ptr[rsp+28h],ebx
00000001`40015941 mov dword ptr[rsp+20h],edi
00000001`40015945 call example!printf(00000001`400012bc)
00000001`4001594a call example!rand(00000001`4000148c)
00000001`4001594f mov edi,eax
00000001`40015951 call example!rand(00000001`4000148c)
00000001`40015956 mov esi,eax
00000001`40015958 call example!rand(00000001`4000148c)
00000001`4001595d mov ebx,eax
00000001`4001595f call example!rand(00000001`4000148c)
00000001`40015964 mov r9d,r12d
00000001`40015967 mov r8d,esi
00000001`4001596a mov edx,ebx
00000001`4001596c mov ecx,eax
00000001`4001596e mov dword ptr[rsp+20h],edi
00000001`40015972call example!ILT+5(?FunctionWith5ParamsYAXHHHHHZ) (00000001`4000100a)
FunctionWith6Params()将ecx拷贝到r12d以保留它为后面的使用,因为在FunctionWith6Params()函数体内,这些内容必须传递给多个函数。注意调用FunctionWith5Params()的点,ecx的内容已经被拷贝到r12d与r9d,不过,r9d是易变的,因此我们必须小心使用,因为在下一个函数调用前,即FunctionWith5Params()调用FunctionWith4Params()时,它可以被改写。有了这些信息,让我们深入FunctionWith5Params()执行到这一点的汇编代码:
0:000> u example!FunctionWith5Paramsexample!FunctionWith5Params+0x20
example!FunctionWith5Params [c:\temp\blog_entry\sample_code\example.cpp @32]:
00000001`40015880 mov qword ptr [rsp+8],rbx
00000001`40015885 mov qword ptr[rsp+10h],rsi
00000001`4001588a push rdi
00000001`4001588b sub rsp,20h
00000001`4001588f mov ecx,dword ptr[rsp+50h]
00000001`40015893 mov eax,r9d
00000001`40015896 mov r9d,edx
00000001`40015899 mov edx,eax
00000001`4001589bcall example!ILT+10(?FunctionWith4ParamsYAXHHHHZ)(00000001`4000100f)
在调用FunctionWith4Params()的地方,我们追踪的值现在在eax,edx及r12d里。再次的,小心eax与edx,因为它们是易变的。不过,因为FunctionWith5Params()不触及r12d,参数的值仍然在r12d里。
现在让我们看一下FunctionWith4Params()目前为止执行的代码:
0:000> u example!FunctionWith4Paramsexample!FunctionWith4Params+0x66
example!FunctionWith4Params [c:\temp\blog_entry\sample_code\example.cpp @9]:
00000001`400157b048895c2408 mov qwordptr [rsp+8],rbx
00000001`400157b548896c2410 mov qwordptr [rsp+10h],rbp
00000001`400157ba4889742418 mov qwordptr [rsp+18h],rsi
00000001`400157bf57 push rdi
00000001`400157c04154 push r12
00000001`400157c24155 push r13
00000001`400157c4 4156 push r14
00000001`400157c64157 push r15
00000001`400157c84883ec50 sub rsp,50h
00000001`400157cc458be1 mov r12d,r9d
00000001`400157cf458be8 mov r13d,r8d
00000001`400157d2448bf2 mov r14d,edx
00000001`400157d5448bf9 mov r15d,ecx
00000001`400157d8e8afbcfeff call example!rand(00000001`4000148c)
00000001`400157dd4898 cdqe
00000001`400157df4889442448 mov qwordptr [rsp+48h],rax
00000001`400157e4e8a3bcfeff call example!rand(00000001`4000148c)
00000001`400157e94898 cdqe
00000001`400157eb4889442440 mov qwordptr [rsp+40h],rax
00000001`400157f0e897bcfeff call example!rand(00000001`4000148c)
00000001`400157f54863e8 movsxd rbp,eax
00000001`400157f8e88fbcfeff call example!rand(00000001`4000148c)
00000001`400157fd4863f0 movsxd rsi,eax
00000001`40015800e887bcfeff call example!rand(00000001`4000148c)
00000001`400158054863f8 movsxd rdi,eax
00000001`40015808e87fbcfeff call example!rand(00000001`4000148c)
00000001`4001580d4863d8 movsxd rbx,eax
00000001`40015810ff15a24b0100 call qword ptr[example!_imp_DebugBreak (00000001`4002a3b8)]
我们找到了所寻找的东西!红色高亮行显示r12被保存在栈上,因为FunctionWith4Params()想重用r12。因为r12不是易变寄存器,它必须将内容保存在一个地方,这样在函数退出前可以恢复。我们所要做的就是在栈上找出这个槽,假设栈没有被污染,我们将中大奖。
找出这个槽的一个技术是从与之前显示的栈转储中的FunctionWith4Params()帧相关的Child-SP值开始,在我的程序是00000000`0012fdd0。使用这个值,让我们使用dps命令转储栈的内容:
0:000> dps 00000000`0012fdd0 L10
00000000`0012fdd0 00000001`00000001
00000000`0012fdd8 00000001`40024040 example!_iob+0x30
00000000`0012fde0 00000000`00000000
00000000`0012fde8 00000001`40002f9eexample!_getptd_noexit+0x76
00000000`0012fdf0 00000000`00261310
00000000`0012fdf8 00000001`40001a92 example!_unlock_file2+0x16
00000000`0012fe00 00000000`00000001
00000000`0012fe08 00000000`00004823
00000000`0012fe10 00000000`000041bb
00000000`0012fe18 00000000`00005af1
00000000`0012fe20 00000000`00000000
00000000`0012fe28 00000000`00000000
00000000`0012fe30 00000000`00002cd6
00000000`0012fe38 00000000`00000029
00000000`0012fe40 00000000`00006952
00000000`0012fe48 00000001`400158a0 example!FunctionWith5Params+0x20 [c:\temp\blog_entry\sample_code\example.cpp@ 34]
我把进入FunctionWith4Params()时rsp指向的位置高亮为红色。基于上面FunctionWith4Params()的prologue代码,我们可以找到大奖所在的槽。我在上面已经把它高亮为绿色,你可以看到在我的机器上这个值是0x29,匹配printf()发送给控制台的值。另外,在FunctionWith4Params()的汇编代码里,我把r14d高亮为绿色,显示edx(第二个参数)内容的拷贝。因为FunctionWith4Params()事实上是栈顶函数(归咎于DebugBreak()不接受任何参数这个事实),那么r14d还应该包含我们追踪的值。倾印r14的内容证实如下:
0:000> r r14
r14=0000000000000029
汇总起来,当你在一个调用图里追踪寄存器传递参数值时,找出这些值拷贝到的地方。特别地,如果这个值拷贝到一个非易变寄存器,这是个好事情。如果一个下游函数希望重用非易变寄存器,它必须首先保存这些内容(通常在栈上),因此在它用完时可以恢复它。如果你不那么幸运,你可能能够追踪一个在断点还没改变的保存拷贝的寄存器。上面展示这两种情况。
参数侦查——技术2(沿着调用图往上)
我想展示的第二个技术非常类似于第一个技术,除了我们朝反方向遍历栈及调用图,即沿着调用图往上。不幸的是,这些技术都不是万无一失,保证能有成果。因此,有多个技术可用是很好的,即使它们都可能失灵。
我们知道在调用FunctionWith6Params()时,ecx包含我们追踪的值。因此,如果我们查看main()的代码,可能我们可以找出在函数调用前ecx寄存器中值的来源。让我们看一下main()的汇编代码:
0:000> u example!main example!main+0x5b
example!main [c:\temp\blog_entry\sample_code\example.cpp @ 58]:
00000001`400159b048895c2408 mov qwordptr [rsp+8],rbx
00000001`400159b548896c2410 mov qwordptr [rsp+10h],rbp
00000001`400159ba 4889742418 mov qwordptr [rsp+18h],rsi
00000001`400159bf48897c2420 mov qwordptr [rsp+20h],rdi
00000001`400159c44154 push r12
00000001`400159c64883ec30 sub rsp,30h
00000001`400159cae8bdbafeff call example!rand(00000001`4000148c)
00000001`400159cf448be0 mov r12d,eax
00000001`400159d2e8b5bafeff call example!rand(00000001`4000148c)
00000001`400159d78be8 mov ebp,eax
00000001`400159d9e8aebafeff call example!rand(00000001`4000148c)
00000001`400159de8bf0 mov esi,eax
00000001`400159e0e8a7bafeff call example!rand(00000001`4000148c)
00000001`400159e58bf8 mov edi,eax
00000001`400159e7e8a0bafeff call example!rand(00000001`4000148c)
00000001`400159ec8bd8 mov ebx,eax
00000001`400159eee899bafeff call example!rand(00000001`4000148c)
00000001`400159f3448bcf mov r9d,edi
00000001`400159f689442428 mov dwordptr [rsp+28h],eax
00000001`400159fa448bc6 mov r8d,esi
00000001`400159fd8bd5 mov edx,ebp
00000001`400159ff418bcc mov ecx,r12d
00000001`40015a02895c2420 mov dwordptr [rsp+20h],ebx
00000001`40015a06e8fab5feff call example!ILT+0(?FunctionWith6ParamsYAXHHHHHHZ)(00000001`40001005)
看到ecx拷贝了r12d的内容。这是有帮助的,因为r12d是一个非易变寄存器,如果它被调用栈下方的函数重用,它必须被保存,而通常这个保存意味着将拷贝放置在栈上。如果ecx以栈上的一个值填充,这很好,这时我们实际上做完了。不过在这个案例里,我们只需要再次向下开始我们的旅程。我们不需要看太远。让我们另外看一下FunctionWith6Params()的prologue代码:
example!FunctionWith6Params[c:\temp\blog_entry\sample_code\example.cpp @ 41]:
41 00000001`400158e0 mov qwordptr [rsp+8],rbx
41 00000001`400158e5 mov qwordptr [rsp+10h],rbp
41 00000001`400158ea mov qwordptr [rsp+18h],rsi
41 00000001`400158ef push rdi
41 00000001`400158f0 push r12
41 00000001`400158f2 push r13
41 00000001`400158f4sub rsp,40h
41 00000001`400158f8mov ebx,r9d
41 00000001`400158fb mov edi,r8d
41 00000001`400158femov esi,edx
41 00000001`40015900mov r12d,ecx
r12在FunctionWith6Params()中重用,这意味着我们的大奖将在栈上。让我们从通过使用dps命令查看这个帧的Child-SP开始,它在00000000`0012fe80:
0:000> dps 00000000`0012fe80 L10
00000000`0012fe80 00000000`00001649
00000000`0012fe88 00000000`00005f90
00000000`0012fe90 00000000`00000029
00000000`0012fe98 00000000`00004823
00000000`0012fea0 00000000`00006952
00000000`0012fea8 00000001`00006784
00000000`0012feb0 00000000`00004ae1
00000000`0012feb8 00000001`00003d6c
00000000`0012fec0 00000000`00000000
00000000`0012fec8 00000000`00000029
00000000`0012fed0 00000000`00006784
00000000`0012fed8 00000001`4000128b example!main+0x5b[c:\temp\blog_entry\sample_code\example.cpp @ 72]
我把在进入FunctionWith6Params()时rsp指向的槽高亮为红色。这时,遍历汇编代码,找出这个值保存在哪个槽是小菜一碟。在上面我已经把它高亮为绿色。
参数侦查——技术3(检查死空间)
最后我想展示的技术技巧性更多一些,涉及查看栈上“死的”或之前使用但当前函数不使用的槽。为了展示,假设在命中DebugBreak()之后,我们需要知道传递给FunctionWith6Params()的param4的内容是什么。让我们再看一下FunctionWith6Params()已经执行的汇编,这次跟踪r9d,第四个参数:
0:000> u example!FunctionWith6Paramsexample!FunctionWith6Params+0x97
example!FunctionWith6Params [c:\temp\blog_entry\sample_code\example.cpp @41]:
00000001`400158e0 mov qword ptr [rsp+8],rbx
00000001`400158e5 mov qword ptr[rsp+10h],rbp
00000001`400158ea mov qword ptr[rsp+18h],rsi
00000001`400158ef push rdi
00000001`400158f0 push r12
00000001`400158f2 push r13
00000001`400158f4 sub rsp,40h
00000001`400158f8 mov ebx,r9d
00000001`400158fb mov edi,r8d
00000001`400158fe mov esi,edx
00000001`40015900 mov r12d,ecx
00000001`40015903 call example!rand(00000001`4000148c)
00000001`40015908 movsxd r13,eax
00000001`4001590b call example!rand(00000001`4000148c)
00000001`40015910 lea rdx,[example!`string’+0x68(00000001`40020d40)]
00000001`40015917 movsxd rbp,eax
00000001`4001591a mov eax,dword ptr[rsp+88h]
00000001`40015921 lea rcx,[example!`string’+0x80(00000001`40020d58)]
00000001`40015928 mov dword ptr[rsp+38h],eax
00000001`4001592c mov eax,dword ptr[rsp+80h]
00000001`40015933 mov r9d,esi
00000001`40015936 mov dword ptr[rsp+30h],eax
00000001`4001593a mov r8d,r12d
00000001`4001593d mov dword ptr [rsp+28h],ebx
00000001`40015941 mov dword ptr [rsp+20h],edi
00000001`40015945 call example!printf(00000001`400012bc)
00000001`4001594a call example!rand(00000001`4000148c)
00000001`4001594f mov edi,eax
00000001`40015951 call example!rand(00000001`4000148c)
00000001`40015956 mov esi,eax
00000001`40015958 call example!rand(00000001`4000148c)
00000001`4001595d mov ebx,eax
00000001`4001595f call example!rand(00000001`4000148c)
00000001`40015964 mov r9d,r12d
00000001`40015967 mov r8d,esi
00000001`4001596a mov edx,ebx
00000001`4001596c mov ecx,eax
00000001`4001596e mov dword ptr[rsp+20h],edi
00000001`40015972call example!ILT+5(?FunctionWith5ParamsYAXHHHHHZ)(00000001`4000100a)
注意r9d首先移入ebx。但还要注意它把这些内容拷贝到栈在rsp+0x28处的槽。这个槽是什么?它是后面printf()调用的第六个参数。记住编译器查看所有代码调用的函数,并找出带有最多参数的函数,然后为这个函数分配足够的空间。因为代码为调用printf()准备,它把我们跟踪的值移入保留栈空间的第六个参数槽。但这个信息有什么用?
如果检查FunctionWith6Params(),你会看到printf()之后调用的函数的参数都少于六个。特别地,
FunctionWith5Params()调用仅使用五个槽,留下那个填满了垃圾(译注:原文为3个)。这个垃圾实际上是我们的宝藏!从余下的代码,可以确保没有人改写由rsp+0x28代表的槽。
要找出这个槽,我们再次从获取我们正在谈论的帧的Child-SP值开始:
0:000> kL
Child-SP RetAddr CallSite
00000000`0012fdc8 00000001`40015816 ntdll!DbgBreakPoint
00000000`0012fdd0 00000001`400158a0 example!FunctionWith4Params+0x66
00000000`0012fe50 00000001`40015977 example!FunctionWith5Params+0x20
00000000`0012fe80 00000001`40015a0b example!FunctionWith6Params+0x97
00000000`0012fee0 00000001`4000168b example!main+0x5b
00000000`0012ff20 00000000`7733495d example!__tmainCRTStartup+0x15b
00000000`0012ff60 00000000`77538791 kernel32!BaseThreadInitThunk+0xd
00000000`0012ff90 00000000`00000000 ntdll!RtlUserThreadStart+0x1d
然后我们可以获取上面高亮的值,使用与代码中相同的偏移来找出我们的值:
0:000> dd 000000000012fe80+28 L1
00000000`0012fea8 00006784
如预期,栈上“死的”槽包含了我们跟踪的值。你可以将这个值与控制台显示的输出进行比较、验证。
一个非易变寄存器捷径
我已经向你展示了找出这些在寄存器中到处传递、难以捉摸的值背后的技术,现在让我告诉你一个使生活可以轻松一点的捷径。这个捷径依赖.frame命令的/r选项。在使用.frame /r时,调试器有追踪非易变寄存器的智力。但就像任何技术,总是要在口袋中放几种工具,以防你需要使用它们来验证一个结果。为了展示,让我们考虑前面描述的技术2,其中我们沿调用图向上查找,我们想知道在main()调用FunctionWith6Params()之前r12是什么。使用windbg重新启动应用程序,运行直接命中DebugBreak()。现在,让我们看一下包括帧号的栈:
0:000> knL
#Child-SP RetAddr CallSite
00 00000000`0012fdc8 00000001`40015816 ntdll!DbgBreakPoint
01 00000000`0012fdd0 00000001`400158a0 example!FunctionWith4Params+0x66
02 00000000`0012fe50 00000001`40015977 example!FunctionWith5Params+0x20
03 00000000`0012fe80 00000001`40015a0b example!FunctionWith6Params+0x97
04 00000000`0012fee0 00000001`4000168bexample!main+0x5b
05 00000000`0012ff20 00000000`7748495d example!__tmainCRTStartup+0x15b
06 00000000`0012ff60 00000000`775b8791 kernel32!BaseThreadInitThunk+0xd
07 00000000`0012ff90 00000000`00000000 ntdll!RtlUserThreadStart+0x1d
基于我们之前对main()汇编代码的分析,我们知道FunctionWith6Params()的第一个参数,在main()调用FunctionWith6Params()之前,还被保存在非易变寄存器r12。现在,在我们使用./frame /r命令把当前帧设为4时,检查一下我们得到什么。
0:000> .frame /r 4
04 00000000`0012fee0 00000001`4000168b example!main+0x5b[c:\temp\blog_entry\sample_code\example.cpp @ 70]
rax=0000000000002ea6 rbx=0000000000004ae1 rcx=0000000000002ea6
rdx=0000000000145460 rsi=00000000000018be rdi=0000000000006784
rip=0000000140015a0b rsp=000000000012fee0 rbp=0000000000004823
r8=000007fffffdc000 r9=0000000000001649r10=0000000000000000
r11=0000000000000246 r12=0000000000000029 r13=0000000000000000
r14=0000000000000000 r15=0000000000000000
iopl=0 nv up ei pl nz nape nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000202
example!main+0x5b:
00000001`40015a0b488b5c2440 mov rbx,qwordptr [rsp+40h] ss:00000000`0012ff20=0000000000000000
正如你看到的,.frame /r显示了调用FunctionWith6Params()前main()里寄存器的内容。小心!在使用这个命令时你仅能信赖非易变寄存器!确保检查以下链接,了解哪些寄存器被视为易变: Register Usagefor x64 64-Bit。
.frame /r可以节约你花在手工挖掘栈,查找被保存的易变寄存器的时间。以我的经验,.frame/r在没有符号信息时也可工作。不过,在面对.frame/r失灵时,知道如何手动操作总是没有坏处的。
结论
X64调用惯例以及处理器上丰富的通用寄存器带来了许多优化机会。不过,在所有这些优化都起作用时。它们肯定会使得调试变困难。在给出x64调用惯例的一个概览后,我展示了可以用于在调用栈中查找各个函数参数值的三个技术。我还展示了在调用栈上查看一个特定帧的非易变寄存器的一个捷径。我希望这些技术在你的调试历险中有用。另外,我敦请你要更熟悉x64调用惯例的所有微妙之处。