天天看點

調試優化x64代碼的挑戰

如果到目前為止您還沒有機會調試優化的x64代碼,請不要再等待太久,也不要落後于時代!由于類似x64 fastcall的調用約定加上大量的通用寄存器,在調用堆棧中的任意點查找變量值确實非常困難。

在本文中,我想詳細介紹一些我最喜歡的調試優化x64代碼的技術。但是在深入研究這些技術之前,讓我們先對x64調用約定有一個快速的概述。

x64調用約定

熟悉x86平台上fastcall調用約定的人将認識到與x64調用約定的相似之處。通常,您必須了解x86平台上的多個調用約定,而在x64平台上,目前隻有一個。在這種情況下,通過__declspec(naked)調用(當然不包括直接調用)可以實作編碼

我不會詳細介紹x64呼叫約定的所有細微差别,是以我建議您檢視以下連結(http://msdn.microsoft.com/en-us/library/ms794533.aspx). 但是通常,函數的前四個參數是通過寄存器rcx、rdx、r8和r9傳遞的。如果函數接受四個以上的參數,則這些參數将傳遞到堆棧上。(熟悉x86 fastcall調用約定的人,其中前兩個參數是在ecx和edx中傳遞的,熟悉這種約定的人會認識到它們的相似之處)。

為了幫助說明x64調用約定是如何工作的,我建立了一些簡單的示例代碼。雖然代碼是人為設計的,與真實世界中的代碼相去甚遠,但它示範了在實際世界中可能遇到的一些場景。代碼如下所示。

#include <stdlib.h>

#include <stdio.h>

#include <windows.h>

__declspec(noinline)

void

FunctionWith4Params( int param1, int param2, int param3,

                     int param4 )

{

    size_t lotsOfLocalVariables1 = rand();

    size_t lotsOfLocalVariables2 = rand();

    size_t lotsOfLocalVariables3 = rand();

    size_t lotsOfLocalVariables4 = rand();

    size_t lotsOfLocalVariables5 = rand();

    size_t lotsOfLocalVariables6 = 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_t someLocalVariable1 = rand();

    size_t someLocalVariable2 = 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檔案中(例如示例.cpp). 我使用Windows SDK(具體是Windows SDK CMD shell)使用以下指令行将此代碼編譯為C++代碼:

cl /EHa /Zi /Od /favor:INTEL64 example.cpp /link /debug      

注意/Od開關。這将禁用所有優化。稍後,我将啟用最大化優化,這就是樂趣開始的時候!

一旦建構了可執行子產品(我的示例.exe),然後可以在調試器中按如下方式啟動它:

windbg -Q -c "bu example!main;g;" example.exe      

上面的指令将在windbg中啟動應用程式,在main()例程上設定一個斷點,然後轉到該斷點。

現在,讓我們看一下調用FunctionWith6Params()時堆棧的樣子。下圖說明了指令指針位于FunctionWith6Params()的代碼開頭但在prolog代碼執行之前的堆棧:

調試優化x64代碼的挑戰

請注意,在本例中,調用者(在本例中為main())在堆棧上為所有六個參數配置設定了足夠的空間,以便使用FunctionWith6Params()函數,即使前四個參數是通過寄存器傳入的。堆棧上的額外空間通常被稱為寄存器參數的“主空間”。在前面的圖中,我已經顯示了那些填充了xxxxxxxx的插槽,以表明其中的值在此時實際上是随機的。這是因為調用程式main()沒有初始化這些插槽。被調用函數可自行決定将前四個參數存儲在該空間中以便于安全儲存。這正是在未優化的建構中發生的情況,并且是一個巨大的調試友善,因為如果需要,可以很容易地找到堆棧上前四個參數的内容。另外,windbg堆棧指令,如kb和kv,顯示了前幾個參數,将報告真實的結果。

綜上所述,下面是FunctionWith6Params()中的prolog代碼執行後堆棧的樣子:

調試優化x64代碼的挑戰

FunctionWith6Params()的prolog程式集代碼如下所示:

0:000> uf .

example!FunctionWith6Params [c:\temp\blog_entry\sample_code\example.cpp @ 28]:

   41 00000001`40015900 mov dword ptr [rsp+20h],r9d
41 00000001`40015905 mov dword ptr [rsp+18h],r8d
41 00000001`4001590a mov dword ptr [rsp+10h],edx
41 00000001`4001590e mov dword ptr [rsp+8],ecx
41 00000001`40015912 push rbx
41 00000001`40015913 push rsi
41 00000001`40015914 push rdi
41 00000001`40015915 sub rsp,50h      

您可以看到,前四條指令将堆棧上的前四個參數儲存在main()配置設定的主空間中。然後,prolog代碼儲存6params()計劃在執行期間使用的所有非易失性寄存器。儲存的寄存器的狀态在傳回到調用方之前在函數epilog代碼中恢複。最後,prolog代碼在堆棧上保留一些空間,在本例中,為0x50位元組。

棧頂的這個空間是用來做什麼的?首先,為任何局部變量建立空間。在本例中,FunctionWith6Params()有兩個。但是,這兩個局部變量隻占0x10位元組。在堆棧頂部建立的其餘空間是怎麼處理的?

在x64平台上,當代碼為調用另一個函數準備堆棧時,它不會像x86代碼那樣使用push指令将參數放在堆棧上。相反,對于特定函數,堆棧指針通常保持固定不變。編譯器檢查目前函數中代碼調用的所有函數,找到參數數最多的函數,然後在堆棧上建立足夠的空間來容納這些參數。在本例中,FunctionWith6Params()調用printf(),傳遞8個參數。因為這是參數數最多的被調用函數,編譯器在堆棧上建立8個插槽。堆棧上的前四個插槽将是任何函數FunctionWith6Params()調用所使用的主空間。

x64調用約定的一個友善的副作用是,一旦您位于函數的prolog和epilog的括号内,當指令指針位于該函數中時,堆棧指針不會改變。這樣就不需要基本指針了,這在x86調用約定中很常見。當FunctionWith6Params()中的代碼準備調用子函數時,它隻需将前四個參數放入所需的寄存器中,如果參數超過4個,則使用mov指令将其餘參數放入配置設定的堆棧空間,但要確定跳過堆棧上的前四個參數槽。

調試優化的x64代碼(噩夢開始)

為什麼調試x64優化代碼如此棘手?好吧,還記得調用者在堆棧上為被調用者建立的主空間來儲存前四個參數嗎?原來,調用約定不要求被調用方使用該空間!而且,您可以肯定地打賭,優化後的x64代碼将不會使用該空間,除非它對于優化目的是必要的和友善的。此外,當優化的代碼确實使用主空間時,它可以使用它來存儲非易失性寄存器,而不是函數的前四個參數。

繼續使用以下指令行重新編譯示例代碼:

cl /EHa /Zi /Ox /favor:INTEL64 example.cpp /link /debug      

注意/Ox開關的用法。這将啟用最大優化。調試符号仍處于打開狀态,是以我們可以輕松調試優化的代碼。始終在啟用調試資訊的情況下生成釋出産品,以便可以調試釋出版本!

讓我們看看FunctionWith6Params()的prolog程式集代碼是如何變化的:

41 00000001`400158e0 mov qword ptr [rsp+8],rbx
41 00000001`400158e5 mov qword ptr [rsp+10h],rbp
41 00000001`400158ea mov qword ptr [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`400158f8 mov ebx,r9d
41 00000001`400158fb mov edi,r8d
41 00000001`400158fe mov esi,edx
41 00000001`40015900 mov r12d,ecx      

優化後的代碼明顯不同!讓我們逐項列出以下更改:

·函數使用堆棧上的home空間,但是,它不在那裡存儲前四個參數。相反,它使用空間來存儲一些非易失性寄存器,它必須稍後在epilog代碼中恢複。這個經過優化的代碼将使用更多的處理器寄存器,是以它必須儲存更多的非易失性寄存器。

·它仍然将三個非易失性寄存器與存儲在主空間中的其他三個寄存器一起推送到堆棧中以安全儲存。

·然後在堆棧上建立空間。但是,它的空間比未優化代碼中的空間小,并且隻有0x40位元組。這是因為優化的代碼使用寄存器來表示局部變量someLocalVariable1和someLocalVariable2。是以,它隻需要為調用函數所需的8個插槽建立空間,printf()是最大數量的參數。

·然後,它将前四個參數存儲到非易失性寄存器中,而不是存儲在主空間中。(不要指望這種行為。優化後的函數不能複制rcx、rdx、r8和r9的内容。這完全取決于代碼的結構)

現在,在第一次printf()調用之後,單步執行FunctionWith6Params()到源行。我的機器上printf()調用生成的輸出如下:

輸入6個參數的函數(29、4823、18BE、6784、4AE1、3D6C)

windbg中stack指令的一個常見版本是kb,它還顯示幀中每個函數的前幾個參數。實際上,它顯示堆棧的前幾個位置。指令的輸出如下所示:

0:000> kb
RetAddr : Args to Child : Call Site
00000001`4001593b : 00000000`00004ae1 00000000`00004823 00000000`000018be 00000000`007e3570 : example!FunctionWith6Params+0x6a [c:\temp\blog_entry\sample_code\example.cpp @ 37]
00000001`40001667 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000001 : example!main+0x5b [c:\temp\blog_entry\sample_code\example.cpp @ 57]
00000000`76d7495d : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : example!__tmainCRTStartup+0x15b
00000000`76f78791 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : kernel32!BaseThreadInitThunk+0xd
00000000`00000000 : 00000000`00000000 00000000`00000000 00000000`00000000 00000000`00000000 : ntdll!RtlUserThreadStart+0x1d      

請注意,FunctionWith6Params()的前四個參數并非都與kb指令所顯示的完全比對!當然,這是優化的副作用。在優化的代碼中,您根本不能相信kb和kv顯示的輸出。這就是為什麼優化的x64代碼如此難以調試的最大原因。請相信我,上面kb輸出中的第二個和第三個插槽與FunctionWith6Params()的實際參數值比對,這純屬運氣。這是因為FunctionWith6Params()将非易失性寄存器存儲在這些插槽中,而main()恰好在調用FunctionWith6Params()之前将這些值放入這些非易失性寄存器中。

參數偵查——技術1(Down the Call Graph)

現在,讓我們來看看在運作x64代碼時,在調用堆棧中為函數查找難以捉摸的函數參數的一些技術。我在Functionwith4params()中放置了一個DebugBreak()調用來說明。繼續,讓代碼在windbg中運作,直到它到達這個斷點。現在,假設您看到的不是一個實時調試場景,而是一個來自您的客戶的轉儲檔案,而這正是您的應用程式崩潰的地方。是以,你看一下堆棧,它看起來像下面這樣:

0:000> kL
Child-SP RetAddr Call Site
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!FunctionWith6Params example!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`40015972 call example!ILT+5(?FunctionWith5ParamsYAXHHHHHZ) (00000001`4000100a)      

FunctionWith6Params()将ecx複制到r12d中以備以後使用,因為内容必須傳遞給FunctionWith6Params()主體中的多個函數。請注意,在調用FunctionWith5Params()時,ecx的内容已經複制到r12d和r9d中,但是r9d是易失性的,是以我們必須小心,因為當FunctionWith5Params()調用FunctionWith4Params()時,它可能會在下一次函數調用之前被覆寫。有了這些資訊,讓我們深入研究迄今為止已執行的FunctionWith5Params()的彙編代碼:

0:000> u example!FunctionWith5Params example!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`4001589b call example!ILT+10(?FunctionWith4ParamsYAXHHHHZ) (00000001`4000100f)      

在調用FunctionWith4Params()時,我們所追求的值現在在eax、edx和r12d中。同樣,請注意eax和edx,因為它們是易失性的。但是,由于FunctionWith5Params()沒有接觸到r12d,是以我們所追求的參數的内容仍然在r12d中

現在,讓我們看看函數with4params()中迄今為止執行的代碼:

0:000> u example!FunctionWith4Params example!FunctionWith4Params+0x66
example!FunctionWith4Params [c:\temp\blog_entry\sample_code\example.cpp @ 9]:
00000001`400157b0 48895c2408 mov qword ptr [rsp+8],rbx
00000001`400157b5 48896c2410 mov qword ptr [rsp+10h],rbp
00000001`400157ba 4889742418 mov qword ptr [rsp+18h],rsi
00000001`400157bf 57 push rdi
00000001`400157c0 4154 push r12
00000001`400157c2 4155 push r13
00000001`400157c4 4156 push r14
00000001`400157c6 4157 push r15
00000001`400157c8 4883ec50 sub rsp,50h
00000001`400157cc 458be1 mov r12d,r9d
00000001`400157cf 458be8 mov r13d,r8d
00000001`400157d2 448bf2 mov r14d,edx
00000001`400157d5 448bf9 mov r15d,ecx
00000001`400157d8 e8afbcfeff call example!rand (00000001`4000148c)
00000001`400157dd 4898 cdqe
00000001`400157df 4889442448 mov qword ptr [rsp+48h],rax
00000001`400157e4 e8a3bcfeff call example!rand (00000001`4000148c)
00000001`400157e9 4898 cdqe
00000001`400157eb 4889442440 mov qword ptr [rsp+40h],rax
00000001`400157f0 e897bcfeff call example!rand (00000001`4000148c)
00000001`400157f5 4863e8 movsxd rbp,eax
00000001`400157f8 e88fbcfeff call example!rand (00000001`4000148c)
00000001`400157fd 4863f0 movsxd rsi,eax
00000001`40015800 e887bcfeff call example!rand (00000001`4000148c)
00000001`40015805 4863f8 movsxd rdi,eax
00000001`40015808 e87fbcfeff call example!rand (00000001`4000148c)
00000001`4001580d 4863d8 movsxd rbx,eax
00000001`40015810 ff15a24b0100 call qword ptr [example!_imp_DebugBreak (00000001`4002a3b8)]      

我們剛找到我們要找的東西!紅色突出顯示的行顯示r12儲存在堆棧上,因為FunctionWith4Params()希望重用r12。由于r12是一個非易失性寄存器,它必須将内容儲存在某個地方,以便在函數退出之前恢複内容。我們要做的就是找到堆棧上的那個插槽,假設堆棧沒有被破壞,我們就可以得到我們的獎品了。

查找插槽的一種技術是從前面所示的堆棧轉儲中與FunctionWith4Params()架構相關聯的子SP值開始,在我的建構中是00000000`0012fd0。使用該值,讓我們使用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`40002f9e example!_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()顯示的prolog代碼,我們可以找到存放獎品的插槽。我在上面用綠色突出顯示了它,您可以看到我機器上的值是0x29,它與發送到控制台的printf()值相比對。此外,我在FunctionWith4Params()的彙編代碼中用綠色突出顯示了r14d,以訓示edx(第二個參數)的内容被複制到了哪裡。由于FunctionWith4Params()實際上是堆棧中的頂級函數(由于DebugBreak()不帶參數),是以r14d還應該包含我們所追求的值。傾倒r14的内容物證明如下:

0:000> r r14
r14=0000000000000029      

總而言之,當您通過調用圖向下跟蹤寄存器傳遞的參數值時,請查找将值複制到的位置。具體地說,如果将值複制到非易失性寄存器中,這可能是件好事。如果一個非易失性的函數需要先儲存它的内容,那麼它必須首先在非易失性堆棧上執行。如果你沒有那麼幸運,你也許可以跟蹤一個寄存器,它被複制到一個在斷點處沒有被改變。上述兩種情況均已顯示。

參數偵查——技術2(Up the Call Graph)

我要示範的第二種技術與第一種技術非常相似,隻是我們像以前一樣以相反的方向周遊堆棧/調用圖,也就是說,向上周遊調用圖。不幸的是,這些技術沒有一個是可靠的,并保證會取得成果。是以,有多種技術可以使用是很好的,即使所有的技術都有可能被淘汰。

我們知道,當函數With6Params()被調用時,ecx包含我們所追求的值。是以,如果我們檢視main()的代碼,也許可以找到在函數調用之前填充ecx寄存器的源代碼。讓我們看看main()中的彙編代碼:

0:000> u example!main example!main+0x5b
example!main [c:\temp\blog_entry\sample_code\example.cpp @ 58]:
00000001`400159b0 48895c2408 mov qword ptr [rsp+8],rbx
00000001`400159b5 48896c2410 mov qword ptr [rsp+10h],rbp
00000001`400159ba 4889742418 mov qword ptr [rsp+18h],rsi
00000001`400159bf 48897c2420 mov qword ptr [rsp+20h],rdi
00000001`400159c4 4154 push r12
00000001`400159c6 4883ec30 sub rsp,30h
00000001`400159ca e8bdbafeff call example!rand (00000001`4000148c)
00000001`400159cf 448be0 mov r12d,eax
00000001`400159d2 e8b5bafeff call example!rand (00000001`4000148c)
00000001`400159d7 8be8 mov ebp,eax
00000001`400159d9 e8aebafeff call example!rand (00000001`4000148c)
00000001`400159de 8bf0 mov esi,eax
00000001`400159e0 e8a7bafeff call example!rand (00000001`4000148c)
00000001`400159e5 8bf8 mov edi,eax
00000001`400159e7 e8a0bafeff call example!rand (00000001`4000148c)
00000001`400159ec 8bd8 mov ebx,eax
00000001`400159ee e899bafeff call example!rand (00000001`4000148c)
00000001`400159f3 448bcf mov r9d,edi
00000001`400159f6 89442428 mov dword ptr [rsp+28h],eax
00000001`400159fa 448bc6 mov r8d,esi
00000001`400159fd 8bd5 mov edx,ebp
00000001`400159ff 418bcc mov ecx,r12d
00000001`40015a02 895c2420 mov dword ptr [rsp+20h],ebx
00000001`40015a06 e8fab5feff call example!ILT+0(?FunctionWith6ParamsYAXHHHHHHZ) (00000001`40001005)      

我們看到ecx是從r12d的内容中複制的,這很有幫助,因為r12d是一個非易失性寄存器,如果它被調用堆棧下一級的函數重用,那麼它必須被保留,而保留通常意味着在堆棧上放一個副本。如果用堆棧中的值填充ecx會很好,此時我們實際上已經完成了。但在這種情況下,我們隻需要重新開始向下的旅程。

我們不用看太遠。讓我們再來看看函數with6params()的prolog代碼:

example!FunctionWith6Params [c:\temp\blog_entry\sample_code\example.cpp @ 41]:
41 00000001`400158e0 mov qword ptr [rsp+8],rbx
41 00000001`400158e5 mov qword ptr [rsp+10h],rbp
41 00000001`400158ea mov qword ptr [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`400158f8 mov ebx,r9d
41 00000001`400158fb mov edi,r8d
41 00000001`400158fe mov esi,edx
41 00000001`40015900 mov r12d,ecx      

r12在函數with6params()中被重用,這意味着我們的獎品将在堆棧上。首先,讓我們使用dps指令檢視位于00000000`0012fe80的此幀的子SP:

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(Inspecting Dead Space)

我要示範的最後一個技巧涉及到更多的技巧,包括檢視堆棧上“死”的或以前使用過的、目前函數調用未使用的插槽。為了示範,假設在DebugBreak()被命中後,我們需要知道傳遞給FunctionWith6Params()的param4的内容。讓我們再看一下為函數with6params()執行的程式集,這次,讓我們遵循r9d,第四個參數:

0:000> u example!FunctionWith6Params example!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`40015972 call example!ILT+5(?FunctionWith5ParamsYAXHHHHHZ) (00000001`4000100a)      

注意,r9d首先被移到ebx中。但是,請注意,它将内容複制到堆棧上rsp+0x28的插槽中。這個插槽是什麼?它是以下printf()調用的第六個參數。請記住,編譯器會檢視代碼調用的所有函數,并找到具有最大參數數的函數,然後為該函數配置設定足夠的空間。當代碼準備調用printf()時,它會将我們所追求的值移動到保留堆棧空間中的第六個參數槽中。但是這些資訊有什麼用呢?

如果檢查FunctionWith6params(),就會發現printf()之後調用的每個函數都不到六個參數。具體地說,對FunctionWith5Params()的調用隻使用其中的5個插槽,剩下的3個插槽中隻剩下垃圾。這些垃圾其實是我們的寶貝!通過檢查代碼,可以保證沒有人重寫rsp+28表示的插槽。

要找到這個插槽,讓我們再次從擷取我們正在讨論的幀的子SP值開始,如下所示:

0:000> kL
Child-SP RetAddr Call Site
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      

正如預期的那樣,堆棧上的“dead”槽包含我們要查找的值。您可以将該值與控制台上顯示的輸出進行比較以進行驗證。

非易失性寄存器快捷方式

現在我已經向你們展示了在寄存器中找到這些難以捉摸的值背後的理論,讓我給你們展示一條捷徑,讓生活變得輕松一點。快捷方式依賴于.frame指令的/r選項。使用.frame/r時,調試器具有跟蹤非易失性寄存器的智能。但與任何技術一樣,口袋裡總是有多個工具,以防需要使用所有工具來驗證結果。

為了示範,讓我們考慮前面描述的技術2,在這裡我們查找調用圖,我們想知道r12在main()調用Functionwith6params()之前是什麼。繼續,在windbg中重新啟動應用程式,并讓它運作,直到達到DebugBreak()。現在,讓我們看看包含幀編号的堆棧:

0:000> knL
# Child-SP RetAddr Call Site
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`4000168b example!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()的第一個參數在調用FunctionWith6Params()之前也存儲在main()中的非易失性寄存器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=0000000000001649 r10=0000000000000000
r11=0000000000000246 r12=0000000000000029 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
example!main+0x5b:
00000001`40015a0b 488b5c2440 mov rbx,qword ptr [rsp+40h] ss:00000000`0012ff20=0000000000000000      

如您所見,.frame/r顯示了在調用FunctionWith6Params()之前在main()中的寄存器内容。當心!使用此指令時,隻能信任非易失性寄存器!請務必檢視以下連結,以檢視哪些寄存器被認為是不穩定的:x64 64位的寄存器使用情況。

.frame/r可以節省您在堆棧上手動查找儲存的易失性寄存器的時間。在我的實驗中,.frame/r甚至可以在沒有符号資訊的情況下工作。但是,如果遇到.frame/r崩潰的情況,知道如何手動執行也不會有壞處。

結論

x64調用約定和處理器中豐富的通用寄存器帶來了許多優化的機會。然而,當所有這些優化都發揮作用時,它們肯定會使調試變得困難。在簡要概述了x64調用約定之後,我示範了三種可以用來查找調用堆棧中各種函數的參數值的技術。我還向您展示了一個快捷方式,可以用來檢視調用堆棧中特定幀的非易失性寄存器。我希望您在調試過程中發現這些技術很有用。此外,我敦促您更加熟悉x64呼叫約定的所有細微差别。

調試優化x64代碼的挑戰