調試實戰 —— dll 加載失敗之全局變量初始化篇

前言

最近項目里老是遇到 dll 加載不上的問題,緣由各類各樣。今天先總結一個雖然不是項目中實際遇到的問題,可是卻很是經典的問題。其它幾種問題,後續慢慢總結。html

示例代碼包含一個 exe 工程,兩個 dll 工程。 exe 會加載兩個 dll 並調用它們的導出函數(GetCallCount),結果只有一個 dll 的導出函數被成功調用。會是什麼緣由呢?node

現象

運行效果以下圖:bash

run_result

經過 dumpbin 已經確認兩個 dll 都有名爲 GetCallCount 的函數。可是隻有一個調用成功了,另一個卻調用失敗。函數

dumpbin-exports

使用 process explorer 觀察 dll 加載狀況,發現只加載了一個 dll,沒發現另一個 dll測試

loaded_dll

對於這個問題,若是咱們使用 process monitor 觀察整個加載過程,看到的都是 Success。以下圖:ui

procmon-trace

說明,加載正常,在本地找到了這個文件,並正確的映射到內存空間中了。但爲何在進程中觀察不到這個 dll 呢?是時候上調試器了。this

上調試器

直接在 vs 中按 F5 啓動,果真中斷到 vs 中了。spa

exception-and-call-stack

從上圖右側部分,咱們能夠看到完整的調用棧。.net

這裏簡單介紹下相關代碼。在 GlobalVariableInitializeOrder.cpp 的第 15 行調用了 HMODULE hDll2 = LoadLibraryA("GlobalVariableInitializeOrderDll2.dll"); 加載對應的模塊。debug

Common\Test2.cpp 的第 10 行定義了全局變量 CTest2 g_t2;(在 dll 中),問題就出在這個全局變量的初始化代碼中。

從上圖左側部分,咱們能夠得知錯誤代碼是 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 tree  return (this->_Parent(this->_Myhead));}複製代碼

咱們能夠發現 _Root() 內部簡單的調用了 _Parent() 函數,並把 this->_Myhead 看成參數傳遞過去了。再查看下 _Parent() 函數的源碼,以下:

static _Nodepref _Parent(_Nodeptr _Pnode){	// return reference to parent pointer in node  return ((_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: 0x8  char _Color;        // offset: 0xC  char _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 處的值固然會掛掉了。

至此,咱們知道了崩潰的直接緣由——訪問非法地址。可是根本緣由是什麼呢?爲何 _Myhead0 呢? 我猜想是由於 map 尚未初始化。可是該如何證明這個猜想呢?

繼續深刻

CTest2 的構造函數裏調用的是 CTest1::GetMap()GetMap() 內部會返回 CTest1 的靜態變量 static std::map<std::string, std::string> s_manager; 的引用。

若是能證實在 CTest2::g_t2 初始化時,CTest1::s_manager 還沒初始化,那麼咱們就證明了咱們的猜想。

我想到兩個辦法:

  1. map 的構造函數中輸出一條日誌。在調用 g_t2 的構造函數時,查看是否有咱們在 map 中新加的日誌。
  2. 明確每一個全局變量的初始化順序。

第一種方法比較簡單,直接修改 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 = 0x001f6000pfend = 0x001f6250。而後咱們就能夠用強悍的 dps 來查看pfbeginpfend 之間的內容了。在命令窗口執行,dps 0x001f6000 0x001f6250。由於有不少空項,這裏只截取中間部分。

dps-0x001f6000-0x001f6250

咱們能夠很明顯的看到,g_t2的構造函數在前,s_manager 的構造函數在後。

至此,已經證明了咱們以前的猜測。

對比強化

由於工程 GlobalVariableInitializeOrderDll1 和工程 GlobalVariableInitializeOrderDll2 代碼如出一轍,只有一點點的不一樣,就是這一點不一樣致使了一個 dll 能夠正常使用,另一個卻不能正常使用。

咱們能夠用相同的手法觀察 GlobalVariableInitializeOrderDll1.dll 的初始化過程。

在命令窗口輸入 bm GlobalVariableInitializeOrderDll1!_CRT_INIT;g ,埋伏好斷點後運行起來。再次中斷後,使用相同的辦法進入_initterm() 函數,經過 dv 命令獲得 pfbegin = 0x10026000pfend = 0x10026250 的值,而後執行 dps 0x10026000 0x10026250,以下圖(一樣有不少空項,只截取了中間部分):

dps-0x001f6000-0x001f6250

咱們發現,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_t2s_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

完整的測試工程下載連接:

百度雲 連接: pan.baidu.com/s/1gW1dZsNY… 提取碼: 7irh

CSDN 連接:download.csdn.net/download/xi…

總結

  • 永遠不要讓一個全局變量依賴另一個全局變量。

  • 全局變量是在 DllMain 或者 main 函數執行前進行初始化的。

  • 32 位程序中,通常使用 eax 保存函數的返回值。

  • dps 命令能夠按地址遍歷給定範圍的內容。

  • dv 命令能夠查看局部變量和參數。

參考資料

若是有小夥伴兒對全局變量初始化感興趣,能夠參考如下幾篇文檔:

docs.microsoft.com/en-us/cpp/c…

www.cppblog.com/xlshcn/arch…

bytepointer.com/resources/p…

相關文章
相關標籤/搜索