天天看點

再談FPO

在調試程式的過程中,可能遇到過一兩次“FPO”這個詞。FPO是指在x86上處理編譯器如何通路本地變量和基于堆棧的參數的編譯器優化的一個特定類型。對于使用局部變量(和/或基于堆棧的參數)的函數,編譯器需要一種機制來引用堆棧上的這些值。通常,這是通過以下兩種方式之一完成的:

  • 直接從堆棧指針(esp)通路局部變量。這是啟用FPO優化時的行為。雖然這不需要單獨的寄存器來跟蹤局部變量和參數的位置,但如果禁用了FPO優化,這會使生成的代碼稍微複雜一些。特别是,由于函數調用或修改堆棧的其他指令等原因,esp中局部變量和參數的位移實際上會随着函數的執行而改變。是以,編譯器必須在引用基于堆棧的值的函數中的每個位置跟蹤目前esp值的實際位移。對于編譯器來說,這通常不是什麼大問題,但是在手工編寫的彙程式設計式中,這可能會變得有點棘手。
  • 指定一個寄存器指向堆棧上相對于局部變量和基于堆棧的參數的固定位置,并使用此寄存器通路局部變量和參數。這是禁用FPO優化時的行為。約定是使用ebp寄存器通路局部變量和堆棧參數。Ebp通常設定為第一個堆棧參數可以在[Ebp+08]中找到,而局部變量通常位于Ebp的負位移處。

禁用FPO優化的函數的典型情況可能如下所示:

push   ebp               ; save away old ebp (nonvolatile)     mov    ebp, esp          ; load ebp with the stack pointer     sub    esp, sizeoflocals ; reserve space for locals     ...                      ; rest of function      

主要的概念是禁用FPO優化,一個函數将立即儲存ebp(作為第一個接觸堆棧的操作),然後用目前堆棧指針加載ebp。這時的堆棧布局:

[ebp-01]   Last byte of the last local variable     [ebp+00]   Old ebp value     [ebp+04]   Return address     [ebp+08]   First argument...      

此後,函數将始終使用ebp通路局部變量和基于堆棧的參數。(函數的彙編序列可能會有一些變化,特别是使用變化的函數設定初始SEH幀時,但相對于ebp,堆棧布局的最終結果始終相同。)

這确實(如前所述)使得ebp寄存器不可用于其他用途。但是,相對于打開FPO優化後編譯的函數,此性能影響通常不足以成為一個大問題。此外,有許多情況下要求函數使用幀指針:

  • 任何使用SEH的函數都必須使用幀指針,因為當發生異常時,無法從異常分派時的esp值(堆棧指針)中知道局部變量的位移(異常可能發生在任何地方,而諸如進行函數調用或為函數調用設定堆棧參數之類的操作會修改esp的值。
  • 任何使用析構函數的C++對象都必須使用SEH來編譯解壓縮支援。這意味着大多數C++函數最終都被禁用了FPO優化。(可以改變編譯器關于SEH異常和C++解卷的假設,但是預設的(和推薦的設定)是在出現SEH異常時取消對象。)
  •  任何使用alloca在堆棧上動态配置設定記憶體的函數都必須使用一個幀指針(是以禁用了FPO優化),因為esp對局部變量和參數的位移可以在運作時更改,編譯器在生成代碼時不知道。

由于這些限制,您可能正在編寫的許多函數将已經禁用FPO優化,而沒有顯式地将其關閉。但是,仍有可能許多不符合上述條件的函數啟用了FPO優化,是以不使用ebp引用局部變量和堆棧參數。

既然您已經大緻了解了FPO優化的功能,那麼我将在本系列的下半部分介紹為什麼在調試某些類問題時全局關閉FPO優化對您有利。(事實上,大多數微軟系統代碼也會關閉FPO,是以您可以放心,已經在FPO和非FPO優化代碼之間進行了真正的成本效益分析,在一般情況下禁用FPO優化總體上更好。)

考慮下面的示例程式,其中有幾個不做任何事情的函數,這些函數将堆棧參數亂放并互相調用。(在本文中,我禁用了全局優化和函數内聯。)

__declspec(noinline)     void     f3(        int* c,        char* b,        int a        )     {        *c = a * 3 + (int)strlen(b);        __debugbreak();     }     __declspec(noinline)     int     f2(        char* b,        int a        )     {        int c;        f3(           &c,           b + 1,           a - 3);        return c;     }     __declspec(noinline)     int     f1(        int a,        char* b        )     {        int c;        c = f2(           b,           a + 10);        c ^= (int)rand();        return c + 2 * a;     }     int     __cdecl     wmain(        int ac,        wchar_t** av        )     {        int c;        c = f1(           (int)rand(),           "test");        printf("%d\\n",           c);        return 0;     }      

如果我們運作程式将在寫死斷點處中斷,并加載符号,則一切都如預期的那樣:

0:000> k     ChildEBP RetAddr       0012ff3c 010015ef TestApp!f3+0x19     0012ff4c 010015fe TestApp!f2+0x15     0012ff54 0100161b TestApp!f1+0x9     0012ff5c 01001896 TestApp!wmain+0xe     0012ffa0 77573833 TestApp!__tmainCRTStartup+0x10f     0012ffac 7740a9bd kernel32!BaseThreadInitThunk+0xe     0012ffec 00000000 ntdll!_RtlUserThreadStart+0x23      

不管FPO優化是打開還是關閉,由于我們加載了符号,無論哪種方式,我們都會得到一個合理的調用堆棧。不過,如果我們沒有加載符号,情況就不同了。在同一個程式中,如果啟用了FPO優化,并且沒有加載符号,那麼如果我們請求調用堆棧,就會有些混亂:

0:000> k     ChildEBP RetAddr       WARNING: Stack unwind information not available.     Following frames may be wrong.     0012ff4c 010015fe TestApp+0x15d8     0012ffa0 77573833 TestApp+0x15fe     0012ffac 7740a9bd kernel32!BaseThreadInitThunk+0xe     0012ffec 00000000 ntdll!_RtlUserThreadStart+0x23      

比較這兩個調用堆棧,我們在輸出中完全丢失了三個調用幀。我們得到稍微合理的結果的唯一原因是WinDbg的堆棧跟蹤機制有一些智能的啟發式方法來猜測調用幀在使用幀指針的堆棧中的位置。如果我們回顧一下如何使用幀指針設定調用堆棧,那麼程式在x86上周遊堆棧而不使用符号的方式就是将堆棧視為一種連結的調用幀清單。回想一下,當使用幀指針時,我提到了堆棧的布局:

[ebp-01]   Last byte of the last local variable     [ebp+00]   Old ebp value     [ebp+04]   Return address     [ebp+08]   First argument...      

這意味着,如果我們嘗試執行沒有符号的堆棧周遊,那麼方法是假設ebp指向類似于以下内容的“結構”:

typedef struct _CALL_FRAME     {        struct _CALL_FRAME* Next;        void*               ReturnAddress;     } CALL_FRAME, * PCALL_FRAME;      

注意這是如何對應于我上面描述的相對于ebp的堆棧布局的。一個非常簡單的堆棧周遊函數,設計用于周遊使用幀指針編譯的幀,可能看起來是這樣的(使用_addressorreturnaddress内在函數查找“ebp”,假設舊ebp在傳回位址的位址之前4個位元組):

LONG     StackwalkExceptionHandler(        PEXCEPTION_POINTERS ExceptionPointers        )     {        if (ExceptionPointers->ExceptionRecord->ExceptionCode           == EXCEPTION_ACCESS_VIOLATION)           return EXCEPTION_EXECUTE_HANDLER;        return EXCEPTION_CONTINUE_SEARCH;     }     void     stackwalk(        void* ebp        )     {        PCALL_FRAME frame = (PCALL_FRAME)ebp;        printf("Trying ebp %p\\n",           ebp);        __try        {           for (unsigned i = 0;               i < 100;               i++)           {              if ((ULONG_PTR)frame & 0x3)              {                 printf("Misaligned frame\\n");                 break;              }              printf("#%02lu %p  [@ %p]\\n",                 i,                 frame,                 frame->ReturnAddress);              frame = frame->Next;           }        }        __except(StackwalkExceptionHandler(           GetExceptionInformation()))        {           printf("Caught exception\\n");        }     }     #pragma optimize("y", off)     __declspec(noinline)     void printstack(        )     {        void* ebp = (ULONG*)_AddressOfReturnAddress()          - 1;        stackwalk(           ebp);     }     #pragma optimize("", on)      

如果我們重新編譯程式,禁用FPO優化,并在f3函數中插入對printstack的調用,控制台輸出如下:

Trying ebp 0012FEB0     #00 0012FEB0  [@ 0100185C]     #01 0012FED0  [@ 010018B4]     #02 0012FEF8  [@ 0100190B]     #03 0012FF2C  [@ 01001965]     #04 0012FF5C  [@ 01001E5D]     #05 0012FFA0  [@ 77573833]     #06 0012FFAC  [@ 7740A9BD]     #07 0012FFEC  [@ 00000000]     Caught exception      

換句話說,在不使用任何符号的情況下,我們在x86上成功地執行了堆棧周遊。但是,當調用堆棧中的某個函數不使用幀指針(即在啟用了FPO優化的情況下編譯)時,這一切都會崩潰。在這種情況下,認為ebp總是指向一個CALL_FRAME結構的假設不再有效,并且調用堆棧要麼被截短,要麼完全錯誤(特别是當有問題的函數将ebp重新用作除幀指針之外的其他用途時)。盡管可以使用啟發式方法來嘗試猜測結構上真正的調用/傳回位址記錄,但這實際上不過是一個有根據的猜測,而且往往至少有一點錯誤(通常完全丢失一個或多個幀)。

現在,您可能想知道為什麼您可能關心不帶符号的堆棧周遊操作。畢竟,您的程式将要調用的Microsoft二進制檔案的符号(如kernel32)可從Microsoft symbol伺服器獲得,并且您(可能)有與您自己的程式對應的私有符号,以便在調試問題時使用。

好吧,答案是,在正常的調試過程中,您将需要在沒有符号的情況下記錄堆棧跟蹤,以解決各種各樣的問題。原因是NTDLL(和NTOSKRNL)中提供了大量支援,以幫助調試一類特别隐蔽的問題:處理洩漏(以及在某些地方關閉錯誤的句柄值并需要找出原因的其他問題)、記憶體洩漏和堆損壞。

這些(非常有用!)調試功能提供了一些選項,允許您将系統配置為在每次堆配置設定、堆空閑或每次打開或關閉句柄時記錄堆棧跟蹤。現在,這些特性的工作方式是,當堆操作或句柄操作發生時,它們将實時捕獲堆棧跟蹤,而不是試圖闖入調試器以顯示此輸出的結果(由于許多原因,這是不可取的),它們将目前堆棧跟蹤的副本儲存在記憶體中,然後繼續正常執行。要顯示這些儲存的堆棧跟蹤,請!哦,比賽!希普,還有!avrf指令具有在記憶體中定位這些儲存的跟蹤并将其列印到調試器供您檢查的功能。

但是,NTDLL/NTOSKRNL首先需要一種方法來建立這些堆棧跟蹤,以便它可以儲存它們以供以後檢查。這裡有幾個要求:

  • 捕獲堆棧跟蹤的功能不能依賴于NTDLL或NTOSKRNL之上的任何内容。這已經意味着,任何像通過DbgHelp下載下傳和加載符号這樣複雜的事情都會立即消失,因為這些函數的層次遠遠高于NTDLL/NTOSKRNL(事實上,它們必須調用将堆棧跟蹤記錄在案的同一個函數才能找到符号)。
  • 當調用堆棧上所有内容的符号都不可用于本地計算機時,該功能必須起作用。例如,這些功能必須部署在客戶計算機上,而不以某種方式讓該計算機通路您的私有符号。是以,即使有一個很好的方法來定位正在捕獲堆棧跟蹤的符号(實際上沒有),如果願意的話,您甚至找不到這些符号。
  • 該功能必須在核心模式下工作(用于儲存句柄跟蹤),因為句柄跟蹤部分由核心本身管理,而不僅僅是NTDLL。
  • 該功能必須使用最少的記憶體來存儲每個堆棧跟蹤,因為在程序的整個生命周期中,堆配置設定、堆釋放、句柄建立和句柄關閉等操作都是非常頻繁的操作。是以,不能使用僅儲存整個線程堆棧以供以後在符号可用時檢查的選項,因為對于每個儲存的堆棧跟蹤來說,這将非常昂貴。

考慮到所有這些限制,負責儲存堆棧跟蹤的代碼需要在沒有符号的情況下運作,而且它還必須能夠以非常簡潔的方式儲存堆棧跟蹤(不需要為每個跟蹤使用大量記憶體)。

是以,在x86上,NTDLL和NTOSKRNL中的堆棧跟蹤儲存代碼假定調用幀中的所有函數都使用幀指針。這是在沒有符号的x86上儲存堆棧跟蹤的唯一實際選項,因為沒有足夠的資訊烘焙到每個單獨編譯的二進制檔案中,無法可靠地執行堆棧跟蹤,而不假設在每個調用站點使用幀指針。(Windows支援的64位平台通過使用大量的展開中繼資料解決了這個問題,正如我在過去的一些文章中所述。)

是以,pageheap的堆棧跟蹤日志記錄和句柄跟蹤所公開的功能是,當您試圖調試問題時,沒有符号的堆棧跟蹤最終會對您(開發人員)很重要,因為您的所有二進制檔案都有符号。如果確定對所有代碼禁用FPO優化,則可以使用pageheap的堆棧跟蹤堆操作、UMDH(使用者模式堆調試器)和handle跟蹤等工具來跟蹤堆相關問題和處理相關問題。這些功能中最棒的部分是,您甚至可以将它們部署到客戶站點上,而無需安裝完整的調試器(或在調試器下運作程式),隻需稍後在實驗室中對您的程序進行小型轉儲即可進行檢查。不過,所有這些功能都依賴于禁用的FPO優化(至少在x86上是這樣的),是以,請記住在您的釋出版本上關閉FPO優化,以提高這些難以發現的現場問題的可調試性。