优化的x64代码的调试挑战

原文地址: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调用惯例的所有微妙之处。


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值