调试实战 —— dll 加载失败之全局变量初始化篇
前言
最近項目里總是遇到 dll 加載不上的問題,原因各種各樣。今天先總結一個雖然不是項目中實際遇到的問題,但是卻非常經典的問題。其它幾種問題,后續慢慢總結。
示例代碼包含一個 exe 工程,兩個 dll 工程。exe 會加載兩個 dll 并調用它們的導出函數(GetCallCount),結果只有一個 dll 的導出函數被成功調用。會是什么原因呢?
現象
運行效果如下圖:
run_result通過 dumpbin 已經確認兩個 dll 都有名為 GetCallCount 的函數。但是只有一個調用成功了,另外一個卻調用失敗。
dumpbin-exports使用 process explorer 觀察 dll 加載情況,發現只加載了一個 dll,沒發現另外一個 dll。
loaded_dll對于這個問題,如果我們使用 procmon 觀察整個加載過程,看到的都是 Success。如下圖:
procmon-trace說明,加載正常,在本地找到了這個文件,并正確的映射到內存空間中了。但為什么在進程中觀察不到這個 dll 呢?是時候上調試器了。
上調試器
直接在 vs 中按 F5 啟動,果然中斷到 vs 中了。
exception-and-call-stack從上圖右側部分,我們可以看到完整的調用棧。
這里簡單介紹下相關代碼。在 GlobalVariableInitializeOrder.cpp 的第 15 行調用了 HMODULE hDll2 = LoadLibraryA("GlobalVariableInitializeOrderDll2.dll"); 加載對應的模塊。
Common\Test2.cpp 的第 10 行定義了全局變量 CTest2 g_t2;,問題就出在這個全局變量的初始化代碼中。
從上圖左側部分,我們可以得知錯誤代碼是 0xc0000005,內存訪問異常。訪問的地址是 0x00000004,對應的指令位置是 0x001EA6DB。
invalid-eax從上圖中的反匯編看,確實是掛在了 001EA6DB mov eax,dword ptr [eax]。因為 eax 的值是 4,我們需要查明 eax 為什么的值是 4。相信很多小伙伴都知道,eax 用來保存函數調用的返回值。我們可以把注意力集中到 0x001EA6D6 處的 call 指令了,調用的是成員函數 _Root() 。
查看 vs 提供的源碼,如下:
_Nodeptr&?_Root()?const {?//?return?root?of?nonmutable?treereturn?(this->_Parent(this->_Myhead)); }我們可以發現 _Root() 內部簡單的調用了 _Parent() 函數,并把 this->_Myhead 當作參數傳遞過去了。再查看下 _Parent() 函數的源碼,如下:
static?_Nodepref?_Parent(_Nodeptr?_Pnode) {?//?return?reference?to?parent?pointer?in?nodereturn?((_Nodepref)_Pnode->_Parent); }務必注意: _Parent() 的返回值類型是 _Nodepref,返回的是引用(最后三個字母 ref 已經說明了一切)!相當于返回的是 _Pnode->_Parent 的地址!我們可以查看 _Nodepref 的定義:typedef _Nodeptr& _Nodepref;。
所以 _Root() 函數相當于 &(this->_Myhead->_Parent)。我們來觀察下 this 的值。
watch-this-value可以看到 _Myhead 的值是 0,類型是 std::_Tree_node<...>。
我們再看下 _Tree_node 的定義:
template<class?_Value_type,?class?_Voidptr> struct?_Tree_node {_Voidptr?_Left;?????//?offset:?0x0_Voidptr?_Parent;???//?offset:?0x4_Voidptr?_Right;????//?offset:?0x8char?_Color;????????//?offset:?0xCchar?_Isnil;????????//?offset:?0xD_Value_type?_Myval;?//?offset:?0x10private:_Tree_node&?operator=(const?_Tree_node&); };從 _Tree_node 的定義可知, _Parent 的偏移是 4 (因為是 32 位的程序,如果是 64 位,那么是 8)。
綜上,地址 001EA6D6 處的 call 指令反回了 4。接下來的兩條指令是把返回值賦給局部變量 _Nodeptr _Pnode。但是在執行第一條匯編指令 ?mov eax,dword ptr [eax] 時就掛了,因為 eax 的值是 4,正常情況下訪問 0x00000004 處的值當然會掛掉了。
至此,我們知道了崩潰的直接原因——訪問非法地址。但是根本原因是什么呢?為什么 _Myhead 是 0 呢?我猜測是因為 map 還沒有初始化。但是該如何證實這個猜測呢?
繼續深入
CTest2 的構造函數里調用的是 CTest1::GetMap(),GetMap() 內部會返回 CTest1 的靜態變量 static std::map<std::string, std::string> s_manager; 的引用。
如果能證明在 CTest2::g_t2 初始化時,CTest1::s_manager 還沒初始化,那么我們就證實了我們的猜測。
我想到兩個辦法:
在 ?map 的構造函數中輸出一條日志。在調用 g_t2 的構造函數時,查看是否有我們在 map 中新加的日志。
明確每個全局變量的初始化順序。
第一種方法比較簡單,直接修改 vs 提供的源碼即可,注意修改只讀屬性。本文以第 2 種方法為例展開。
全局變量初始化簡介
本小節根據上面的調用棧簡單的介紹全局變量的初始化過程(只介紹我們關心的部分)。
不知道各位小伙伴兒是否記得上面的調用棧。切換到 8 號棧幀,如下圖:
__DllMainCRTStartup-call-_CRT_INIT可以發現,在 __DllMainCRTStartup() 函數中,當 dwReason == DLL_PROCESS_ATTACH 或者 dwReason == DLL_THREAD_ATTACH 的時候,會調用 _CRT_INIT() 函數。_CRT_INIT() 會執行運行時庫的初始化相關功能,比如,初始化全局變量。然后才會調用用戶提供的 DllMain() 函數。
繼續切換到 7 號棧幀,如下圖:
crt_init通過注釋可知,_initterm() 是在調用 C++ constructors。
我們繼續切換到 6 號棧幀,如下圖:
_initterm根據注釋猜測,應該是在依次調用每個全局變量的初始化函數。pfbegin 指向了保存全局變量初始化函數的表格的起始位置,pfend 指向最后一個有效位置的下一個位置,跟標準庫中的容器多么相似啊。如果 *pfbegin 的值不為 0,說明表格對應的位置有有效的初始化函數,需要調用,否則就跳過。
在 vs 中,我們想遍歷出這個表格的內容有些費勁。是時候請 windbg 出場了。
windbg 出場
在使用 windbg 之前一定要設置好符號路徑,否則很多內容看不到。
使用 windbg 打開要運行的程序,在命令窗口輸入 bm GlobalVariableInitializeOrderDll2!_CRT_INIT ,埋伏好斷點后執行 g 命令繼續運行。
set-breakpoint-by-bm很快,就中斷到我們設置好的斷點處了。在調用 _initterm() 的地方設置好斷點,執行 g 命令(也可以和 vs 一樣按 F5),斷下來后,單步進入 _initterm() 函數,執行 dv 查看局部變量。
single-step-to-initterm從輸出結果可知,pfbegin = 0x001f6000,pfend = 0x001f6250。然后我們就可以用強悍的 dps 來查看pfbegin 和 pfend 之間的內容了。在命令窗口執行,dps 0x001f6000 0x001f6250。因為有很多空項,這里只截取中間部分。
dps-0x001f6000-0x001f6250我們可以很明顯的看到,g_t2的構造函數在前,s_manager 的構造函數在后。
至此,已經證實了我們之前的猜想。
對比強化
因為工程 GlobalVariableInitializeOrderDll1 和工程 GlobalVariableInitializeOrderDll2 代碼一模一樣,只有一點點的不同,就是這一點不同導致了一個 dll 可以正常使用,另外一個卻不能正常使用。
我們可以用相同的手法觀察 GlobalVariableInitializeOrderDll1.dll 的初始化過程。
在命令窗口輸入 bm GlobalVariableInitializeOrderDll1!_CRT_INIT;g ,埋伏好斷點后運行起來。再次中斷后,使用相同的辦法進入_initterm() 函數,通過 dv 命令得到 pfbegin = 0x10026000 和 pfend = 0x10026250 的值,然后執行 dps 0x10026000 0x10026250,如下圖(同樣有很多空項,只截取了中間部分):
我們發現,s_manager 的構造函數在前,g_t2的構造函數在后。
修復
我們應該從根本上消除對全局變量的依賴,只需要把 s_manager 放到 GetMap() 中就可以了。
static?std::map<std::string,?std::string>&?GetMap() {static?std::map<std::string,?std::string>?s_manager;return?s_manager; }但有時候,由于各種各樣的原因,我們不能消除這種依賴。我們還可以調整全局變量的初始化順序。只要有辦法讓 g_t2 在 s_manager 之后再初始化就可以了。對比兩個 dll 工程文件,我們發現有一處關鍵的不同點。
difference-of-project1-2在能正常加載的 dll 對應的工程中, Test1.cpp, Test2.cpp 出現的順序是 Test1.cpp, Test2.cpp,在不能正常加載的 dll 對應的工程中,出現的順序是 Test2.cpp, Test1.cpp。調整 dll2.vcxproj 中的文件順序和 dll1.vcxproj 一樣,再次編譯運行,一切順利。
success動手實戰
強烈建議你也動手實戰一番,畢竟紙上來的終覺淺。如果你也想動手實戰,可以下載完整的工程文件,使用 vs2013 編譯運行即可。如果沒裝 vs2013,也可以手動改成其它版本的 vs。
完整的測試工程下載鏈接:
百度云 鏈接: https://pan.baidu.com/s/1gW1dZsNYZoo0s_rfaO2Jzg 提取碼: 7irh
CSDN 鏈接:https://download.csdn.net/download/xiaoyanilw/12405380
總結
永遠不要讓一個全局變量依賴另外一個全局變量。
全局變量是在 DllMain 或者 main 函數執行前進行初始化的。
在?32?位程序中,一般使用?eax?保存函數的返回值。
dps 命令可以按地址遍歷給定范圍的內容。
dv 命令可以查看局部變量和參數。
參考資料
如果有小伙伴兒對全局變量初始化感興趣,可以參考以下幾篇文檔:
https://docs.microsoft.com/en-us/cpp/c-runtime-library/crt-initialization?redirectedfrom=MSDN&view=vs-2019
http://www.cppblog.com/xlshcn/archive/2007/12/07/37088.html
http://bytepointer.com/resources/pietrek_libctiny_2001.htm
需要你的
總結
以上是生活随笔為你收集整理的调试实战 —— dll 加载失败之全局变量初始化篇的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: [开源] .Net orm FreeSq
- 下一篇: Sql Server之旅——第七站 复合