動態連接庫的建立流程以下圖所示:程序員
在系統設計階段,主要的設計內容包括:類結構的設計以及功能類之間的關係,動態連接庫的接口。在動態連接庫中,包含兩類函數:一類是內部函數,一類是外部函數。內部函數只能在動態連接庫的內部使用,不能被動態連接庫之外的模塊調用;外部函數是該動態連接庫的接口,能夠被外部模塊調用。windows
爲了使外部函數可以被系統外的模塊調用,在進行C++代碼編寫的時候,必須對外部函數執行導出。導出的級別有兩種:函數級別的導出和類級別的導出。在函數級別的導出中,只將該函數導出;在類級別的導出中,將這個類所屬的函數和數據導出。在進行導出的時候,使用關鍵字「_declspec(dllexport)」。數組
若是外部模塊要調用動態連接庫中的函數,那麼必須對該函數執行導入。導入的級別有兩種:函數級別的導入和類級別的導入。在函數級別的導入中,只能將該函數導入;在類級別的導入中,能夠將整個類所屬的函數和數據導入,在進行導入的時候,使用關鍵字「_declspec(dllimport)」。安全
在使用Visual Studio創建動態連接庫的時候,首先是建立工程項目,而且選擇項目類型爲動態連接庫類型,即:Application type的DLL選項。Static Library表示建立靜態連接庫,Windows application表示建立到窗口的可執行程序,Console application表示建立帶命令行的可執行程序。具體狀況以下圖所示:數據結構
創建完畢工程項目之後,向工程項目中添加各個類的頭文件,以及源文件,開始各個功能類的編寫。在執行函數級別的導出的時候,具體的C++代碼樣式以下:app
#ifndef _DemoMath_H函數 #define _DemoMatn_H工具
class DemoOutPut;佈局 class DemoDLL_Export DemoMathui { public: DemoMath(); ~DemoMath(); _declspec(dllexport) void AddData(double a,double b); //成員函數AddData被導出 _declspec(dllexport) void SubData(double a,double b);//成員函數SubData被導出 void MulData(double a,double b); //成員函數沒有被導出,不能被該dll以外的函數調用 void DivData(double a,double b); void Area(double r); private: DemoOutPut * m_pOutPut; }; #endif |
執行類級別導出的時候,具體的C++代碼的樣式以下:
#ifndef _DemoMath_H #define _DemoMatn_H
class DemoOutPut; class _declspec(dllexport) DemoMath //將整個類導出。類中全部的函數都可被外部模塊調用 { public: DemoMath(); ~DemoMath(); void AddData(double a,double b); void SubData(double a,double b); void MulData(double a,double b); void DivData(double a,double b); void Area(double r); private: DemoOutPut * m_pOutPut; }; #endif |
爲了方便對導入,導出的管理,通常會將導入,導出的信息定義到一個頭文件中,在須要進行導入,導出的時候,將這個頭文件引入便可。
這個頭文件包含了導入和導出兩方面的功能,在使用該頭文件以前定義宏DeMODLL_EXPORTS,則執行導出功能;若是沒有定義該宏,則執行導入功能。具體的定義內容以下:
#ifndef _DemoDef_H #define _DemoDef_H //定義函數的導入,導出 #ifdef DEMODLL_EXPORTS #define DemoDLL_Export _declspec(dllexport) #else #define DemoDLL_Export _declspec(dllimport) #endif #endif |
在類級別的導出中,該頭文件的使用方式描述以下:
#ifndef _DemoMath_H #define _DemoMatn_H #include "DemoDef.h" //引入頭文件 class DemoOutPut; class DemoDLL_Export DemoMath //類級別導出,使用該標記前,必須定義宏:DEMODLL_EXPORTS { public: DemoMath(); ~DemoMath(); void AddData(double a,double b); void SubData(double a,double b); void MulData(double a,double b); void DivData(double a,double b); void Area(double r); private: DemoOutPut * m_pOutPut; }; #endif |
在執行導出的時候,在使用頭文件DemoDef.h以前,必須定義DemoDLL_EEPORTS。有兩種方式定義該宏,一種方式是直接手工填寫代碼,如:
#ifndef _DemoMath_H #define _DemoMatn_H
#define DEMODLL_EXPORTS //手工定義宏,必須位於include 「DemoDef.h」 以前 #include 「DemoDef.h」 Class ... #endif |
另一種方式是在項目屬性中定義該宏,以下圖所示:
打開工程項目屬性窗口,在C/C++標籤的Preprocessor Definitions項目中,添加宏定義便可。
當動態連接庫的代碼編寫完畢之後,就能夠經過編譯、連接來生成該動態連接庫,在執行編譯、連接的時候,具體的輸入,輸出狀況以下圖所示:
若是在建立該動態連接庫的時候引用了第三方靜態連接庫中的函數,那麼在連接的時候,須要將該靜態連接庫中相關的函數合併到輸出的PE文件中;若是在建立該動態連接庫的時候引用了另外的動態連接庫,那麼在執行連接的時候,被引用動態連接庫的導入庫文件須要參與連接;目標文件中包含的內容是整個動態連接庫的核心內容,被引入的第三方庫,不管是靜態連接庫,仍是動態連接庫,都是爲目標文件中的功能代碼提供支持的;另外,在連接的時候,也能夠提供連接空間文件Def,在該文件中定義了連接選項的各個方面。
編譯、連接執行完畢之後,在默認狀況下,連接器除了輸出PE文件之外,還會輸出.exp文件,.lib導入庫文件,以及符號文件.pdb。經過連接配置,還能夠輸出其餘功能的文件,如:.map文件。
被輸出的PE文件有兩種類型,可執行文件和動態連接庫文件。輸出的類型能夠在建立項目的時候,在application type欄選擇。
對於一個動態連接庫來講,至少要存在一個導出函數。通常狀況下,在一個動態連接庫中,會存在導出若干個函數。這些被導出函數的信息被存放到導出表中。當編譯、連接完成之後,導出表中的信息被保存到PE文件中。
導出表由一個被數據目錄所指向的數據結構IMAGE_EXPORT_DIRECTORY開始,並關聯到三個數組,這三個數組分別存儲被導出函數的地址,被導出函數的名稱,以及被導出函數名稱與序號之間的關係。在程序加載的時候,導出表中的信息被加載器用來執行動態連接。
連接器生成導出表的過程以下圖所示:
連接器在執行第一遍掃描的時候,會以各個目標文件爲輸入。在掃描的過程當中,連接器收集導出函數的信息,而後將這些信息寫入到一個臨時文件中。該臨時文件以.exp爲擴展名,但實際上它是一個COFF格式的文件,該文件格式與目標文件是一致的。在.exp文件中,連接器建立了導出表「.edata」。
連接器在進行第二次掃描的時候,會將各個目標文件和第一掃描生成的.exp文件連接到一塊兒。將.exp文件中的導出表的信息提取出來,並寫入到PE文件中。在PE文件中,導出表不會單獨存在,它通常會被合併到.rdata節,該節存儲只讀數據。
當連接執行完畢之後,.exp文件的使命結束,在後續的工做中,通常不會使用到它。
在編譯、連接動態連接庫的時候,另外一個重要的輸出物就是導入庫,導入庫的擴展名是.lib。該擴展名與靜態連接庫的擴展名一致。可是,導入庫與靜態連接庫是不一樣的。
靜態連接庫是一系列目標文件的集合,這些目標文件以特定的格式打包、壓縮在靜態連接庫中。當執行靜態連接的時候,靜態連接庫中的相關代碼和數據須要被複制到要生成的PE文件中。程序運行的時候不須要靜態連接庫參與。
導入庫中存儲的不是動態連接庫的代碼和數據,而是動態連接庫中被導出函數的描述信息,每個動態連接庫都會對應一個導入庫。在連接階段,導入庫參與連接;在程序運行階段,動態連接庫參與運行。
當在一個目標文件中引用了一個動態連接庫中函數的時候,在編譯階段,就須要將該目標文件和被引用的動態連接庫的導入庫一塊兒連接。在導入庫的支持下,將會生成PE文件的導入表。實際上,PE文件的導入表就是由多個導入庫中的信息合併到一塊兒而生成的。
在導入庫中,主要包含以下內容:
在導入庫中,主要包含了三部份內容,分別是:樁代碼,啓動代碼,以及導出函數的符號。在執行連接的時候,啓動代碼,樁代碼要被合併到輸出的PE文件中;而被導出的函數的符號則以兩種形式提供,用於支持在PE文件中導入表的創建,以及對外部符號的解析工做。
樁代碼包含了一系列jump指令,每條jump指令都會跳轉到導出函數的地址處,用於支持函數調用。樁代碼,導出函數的符號(兩種形式),以及導出函數的地址之間的關係以下圖所示:
在導入庫中,被導出的函數名稱以兩種方式提供,方式一:_imp_函數名 + 修飾;方式二:函數名 + 修飾。例如上圖中的_imp_Fun1和Fun1形式。
使用工具dumpbin導出DemoDlld.lib的內容,在該內容中,一個符號導出了兩種形式,具體狀況以下:
Dump of file demodlld.lib
File Type: LIBRARY
Archive member name at 8: / 51C17BC5 time/date Wed Jun 19 17:37:09 2013 uid gid 0 mode 2A2 size correct header end
21 public symbols
5CE __IMPORT_DESCRIPTOR_DemoDLLd 7FC __NULL_IMPORT_DESCRIPTOR 934 DemoDLLd_NULL_THUNK_DATA B6C ??4DemoMath@@QAEAAV0@ABV0@@Z B6C __imp_??4DemoMath@@QAEAAV0@ABV0@@Z D50 ?GetOperTimes@@YAHXZ D50 __imp_?GetOperTimes@@YAHXZ A88 ??0DemoMath@@QAE@XZ A88 __imp_??0DemoMath@@QAE@XZ AFA ??1DemoMath@@QAE@XZ AFA __imp_??1DemoMath@@QAE@XZ BE6 ?AddData@DemoMath@@QAEXNN@Z BE6 __imp_?AddData@DemoMath@@QAEXNN@Z E3C ?SubData@DemoMath@@QAEXNN@Z E3C __imp_?SubData@DemoMath@@QAEXNN@Z DC2 ?MulData@DemoMath@@QAEXNN@Z DC2 __imp_?MulData@DemoMath@@QAEXNN@Z CD6 ?DivData@DemoMath@@QAEXNN@Z CD6 __imp_?DivData@DemoMath@@QAEXNN@Z C60 ?Area@DemoMath@@QAEXN@Z C60 __imp_?Area@DemoMath@@QAEXN@Z |
在方式二中,每個函數名稱都會對應一個jump指令,該函數名稱的地址是jump指令的入口。經過jump指令,能夠跳轉到方式一形式的函數名稱處。
在方式一形式的函數名稱處,每個函數名稱都會對應到動態連接庫中的一個函數的地址。
在使用該動態連接庫的時候,能夠經過兩種方式來調用該動態連接庫中的函數,具體狀況以下:
方式一: Call _imp_Fun //高效方式,須要關鍵字_declspec(dllimport)支持。
方式二: Call Fun //低效方式,執行了額外的jump指令跳轉
Fun: Jump _imp_Fun |
在發佈動態連接庫的時候,要包含以下文件:
在發佈動態連接庫的時候,動態連接庫的發佈者須要同時發佈該動態連接庫的頭文件。在編碼階段,當須要調用動態連接庫中的方法的時候,程序員須要使用#include命令將該動態連接庫的頭文件引入。
在使用動態連接庫中的函數的時候,能夠有兩種方式。一種方式是:使用關鍵字_declspec(dllimport)將須要的函數顯式地導入,這些被導入的函數必須位於動態連接庫被導出函數的集合中;另一種方式是:直接使用動態連接庫中被導出的函數,不作任何顯式地導入。
使用_declspec(dllimport)導入動態連接庫中的函數的時候,將會使用高效地函數調用方式,而不使用_declspec(dllimport)導入動態連接庫中的函數的時候,將會使用低效地函數調用方式。
當使用關鍵字_declspec(dllimport)修飾被調用函數名稱的時候,在編譯階段,函數的名稱將被處理成「_imp_函數名稱 + 修飾」的形式。當執行靜態連接的時候,Call指令後面的操做數被解析成被調用函數的地址。所以,經過一次對Call指令的執行,就能夠完成對動態連接庫中被調用函數的調用。
若是不使用關鍵字_declspec(dllimport)修飾被調用函數的名稱,那麼在編譯階段,函數的名稱將被處理成「函數名稱 + 修飾」的形式。當執行靜態連接的時候,導入庫中的樁代碼被合併到PE文件中。而Call指令後面的操做數被解析成一段樁代碼的地址,在這段樁代碼中,經過jump指令才能跳轉到被調用函數的地址處。具體狀況可參見3.5.2.4節的描述。所以,經過執行兩步指令才能完成對動態連接庫中函數的調用。
爲了實習那高效地函數調用,在使用動態連接庫中的導出函數的時候,須要明確地將這些函數導入。如3.5.2.1節描述的那樣,首先將導入、導出的信息定義到一個頭文件中,在實現動態連接庫的時候,將這個包含定義信息的頭文件引入。在實現動態連接庫,在執行函數導出的時候,須要特別定一個宏標記;在使用被髮布的動態連接庫的時候,須要明確地執行函數的導入,這時候,只須要引入隨該動態連接庫一塊兒發佈的頭文件便可,不須要作任何宏定義。
在靜態連接階段,連接器引入動態連接庫的導入庫,並執行連接的過程以下圖所示:
從上圖能夠看出,在執行靜態連接的時候,輸入的數據爲多個目標文件和多個導入庫(若是該模塊調用了多個動態連接庫中的函數),連接器通過處理之後,會輸出PE格式的文件(能夠是可執行文件或者動態連接庫),同時還可能會輸出一些其餘用途的文件。
連接器在執行靜態連接的時候,經歷了兩次掃描的過程。除了要處理目標文件之間的連接問題外,若是目標文件引用了其餘動態連接庫中的函數,那麼連接器還須要進行額外的處理工做,這些工做主要是:導入表的創建,代碼的加入,以及外部符號解析。這些被連接器額外加入到PE文件中的代碼來自於導入庫中,主要是樁代碼,以及動態連接庫啓動的代碼。
導入表的創建過程以下圖所示:
PE文件的導入表是一個數組,數組元素的類型是IMAGE_IMPORT_DESCRIPTOR類型的數據結構。該數據結構有兩個指針,分別指向導入地址表(IAT)和導入名稱表(INT)這兩個數組。在連接階段,這兩個數組中存儲的都是符號的名稱信息。
對於導入表數組中,每個數組元素都會對應一個動態連接庫的信息。具體的狀況是,在掃描的時候,用導入庫中的信息去填寫IMAGE_IMPORT_DESCRIPTOR結構體中的字段。如:動態連接庫的名稱,建立時間等。
在導入庫中,包含一些命名爲.idata$4,.idata$5形式的節,這些節中包含的信息就是動態連接庫中被導出的函數的信息,如名稱,地址等。在生成IAT,以及INT數組的時候,這些節中的數據要被合併到IAT或者INT數組中來。
在當前模塊中被引用,而定義在動態連接庫中的符號,相對與當前模塊來講,它是外部符號。外部符號的解析過程以下圖所示:
在上圖中,經過重定位表和全局符號表能夠查找到外部符號,以及外部符號出如今代碼中的位置。存在於這些位置上的指令的格式爲:
Call dword ptr[xxxx] //xxxx是內存中的某個地址的值。 dword ptr[xxxx]表示取內存中某個地址開始處的內容,大小4字節。該地址由「xxxx」這個操做數指定。 |
在編譯階段,因爲是外部符號,在目標文件中,佔位於「xxxx」的位置上的操做數的值未知;在靜態連接階段,須要修正xxxx的值,使之與動態連接庫中被調用的函數創建關係即:使Call指令直接地或者間接地指向IAT數組中的某各位置。在程序加載到內存的過程當中,加載器會將IAT數組中的函數名稱更改爲動態連接庫中相關函數的地址,這時候才真正完成了動態連接。
若是函數的名稱是「函數名+修飾」的形式(該函數沒有使用_declspec(dllimport)導入),那麼在靜態連接的時候,xxxx被解析成樁代碼中某個jump指令的地址。如:Fun1的地址,或者Fun2的地址等;若是函數的名稱是「_imp_函數名+修飾」的形式(該函數使用了_declspec(dllimport)導入),那麼在靜態連接的時候,xxxx被解析成IAT數組中某個數組元素的地址。在當前階段,該數組元素中存儲的是對應函數的函數名稱。
通過這些處理,Call指令後面的地址被修正正確,它直接地或者間接地指向了IAT數組中某各數組元素的位置,該數組元素存儲的是動態連接庫中被調用函數的信息。在靜態連接完成之後,這裏存儲的仍是函數的名稱,所以在執行程序的時候,是沒法正確跳轉到動態連接庫中被調用函數的位置。這個問題將在程序加載時,由動態連接來解決。具體狀況,參見第四章。
目標文件與靜態連接庫之間的靜態連接過程與目標文件之間的靜態連接過程相似。靜態連接庫也是由一系列的目標文件組成的,在執行靜態連接的時候,靜態連接庫中的相關目標文件會被拷貝到輸出的PE文件中。
通常狀況下,程序員在調試C++程序的時候,其操做流程以下圖所示:
由上圖能夠看出,代碼調試的過程是一個不斷循環,迭代的過程。試想一下,若是當前被調試的C++程序是一個很龐大的C++程序,它包含至關多的源文件,好比:上千個源文件。那麼執行第二步和第三步的時候,將會耗費至關多的時間,而程序員所關心的是第六步和第八步。爲了解決這個問題,在Visual Studio中引入了「Edit and Continue」功能。
在使用「Edit and Continue」功能的時候,程序員的調試流程有所更改,具體的流程過程以下圖所示:
在該流程中,初次啓動調試的時候,其操做過程與非「Edit and Continue」模式下是一致的,都須要設定斷點,啓動調試(若是須要編譯,則執行之),以及程序的加載和運行。當程序運行的設定的斷點後,程序員開始單步執行程序代碼,並跟蹤各個變量的狀態直到發現代碼錯誤。
在非「Edit and Continue」模式下,程序員須要關閉調試,而後修改代碼錯誤,從新編譯源代碼,而後再次啓動調試。在代碼量小,程序加載和運行所須要的時間少的時候,是能夠這麼作的;
在「Edit and Continue」模式下,當程序員發現代碼錯誤的時候,不須要關閉當前調試。在開啓調試的狀況下,程序員直接更改錯誤代碼,更改完畢之後,設定新的斷點,而後按F10鍵繼續執行程序。在上圖中,藍色部分描述了「Edit and Continue」模式下程序員的調試流程。在程序員修改錯誤代碼的時候,該錯誤代碼必須位於當前運行點之下。具體狀況以下圖所示:
在上圖中,當前運行點是代碼「nOpertimes++」所在的位置,新修改的代碼必須位於該行代碼之下。
爲了使用「Edit and Continue」功能,必須在項目的屬性窗口中進行設定,具體狀況以下圖所示:
在「C/C++」分組的「General」標籤中,須要將「Debug Information Format」選項的值設定爲:「Program Database for Edit & Continue(/ZI)」。
當程序員按下F10鍵之後,爲了支持「Edit and Continue」功能,編譯器開始執行增量連接。爲了使用增量連接功能,必須在項目的屬性窗口中進行設定,具體狀況以下圖所示:
在「Linker」分組的「General」標籤中,須要將「Enable Incremental Linking」的值設定爲:Yes(/INCREMENTAL)。設定該值後,在執行編譯的時候,編譯器將開啓增量連接功能。
在C++代碼中,咱們實現了兩個函數:Fun1和Fun2。在將C++源文件編譯成PE文件之後,咱們假設這兩個函數被緊挨着放到了一塊兒。Fun1被放到了地址:0x40002000處,Fun1的大小爲0x100。那麼Fun2的地址就應該是:0x40002000 + 0x100 = 0x40002100。
在程序員調試C++代碼的時候,須要不斷地修改代碼,並執行編譯,連接。當程序員將Fun1函數的內容修改之後,函數的大小發生了變化,好比變化爲0x200。在這種狀況下,因爲兩個函數被牢牢地放在了一塊兒,那麼Fun2的地址就要被被向後推移0x100,變成了0x400022000。在這種狀況下,通常的解決思路是:從新洗牌,將現有的編譯好的exe刪除,而後從新編譯源文件,執行連接。在這個過程當中,須要從新佈局全部的函數,從新生成全局符號表,從新執行符號解析和重定位工做…。對於大型的軟件項目來講,這個過程是漫長而痛苦的。在極端狀況下,程序員由於修改了一小段代碼,就必需要等待一個漫長的編譯、連接過程。
爲了解決這個問題,visual Stuio在debug模式下,使用了增量連接的功能。在使用了該功能之後,咱們得到了更快的編譯速度,由此帶來的反作用是:被編譯出來的PE文件更加龐大,該PE文件的運行效率更加低下。因爲debug模式下編譯出來的程序是供程序員調試使用的,而不是提供給最終用戶的,因此咱們能夠忽略這些反作用。
在Release模式下,因爲沒有使用增量連接,因此在被編譯出來的PE中,其內容更加緊湊,其運行效率更加高效。
當一個函數被修改之後,因爲其大小發生了變化,因此可能會引發其餘函數入口地址的更改。在增量連接模式下,爲了不因爲一個函數大小的變化而影響到其餘函數的入口地址的問題。採用了兩種解決方式:在函數間以及段間填充二進制數據「0xcc」,以及使用.textbss段。
在函數間以及段間填充二進制數據「0xcc」方式
該方式主要是爲了加快編譯速度。在該方式下,連接器不會將各個函數緊湊地存放在一塊兒,而是在各個函數之間填充必定數量的二進制數據「0xcc」。在各個段之間,好比:.text段和.data段之間,填充大量的二進制數據「0xcc」。該二進制數據表明彙編指令:INT 3。該windows下,執行該指令會致使異常,從而中斷程序的運行。這是出於安全方面的考慮,因爲一些緣由,使程序執行到了填充空間中,因爲INT 3指令的存在,程序被終止運行。
在修改函數的時候,當函數的大小發生較小的變化的時候,將會在函數間被填充的空間之中,爲更改後的函數分配新空間。所以,各個函數的入口地址都不會發生變化,只是被填充的空間的大小發生了變化。在編譯的時候,只須要編譯發生變化的源文件,因爲函數地址均未發生變化,因此也不須要從新執行地址解析和重定位工做。所以加快了編譯速度。
在修改函數的時候,當函數的大小發生較大變化的時候,即:函數間被填充的空間不足以支持被更改函數的大小。在這種狀況下,使用段間填充的空間爲新函數分配空間。這時候,新函數的入口地址放生變化。將會使用增量連接表處理函數入口地址發生變化的問題。
使用dumpbin工具解析debug模式下編譯出來的PE文件,其部份內容以下:
10011490: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 100114A0: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 100114B0: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 100114C0: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 100114D0: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 100114E0: 55 8B EC 81 EC CC 00 00 00 53 56 57 51 8D BD 34 U.ì.ìì...SVWQ.?4 100114F0: FF FF FF B9 33 00 00 00 B8 CC CC CC CC F3 AB 59 ???13...?ììììó?Y 10011500: 89 4D F8 8B 45 08 8B 08 8B 55 F8 89 0A 8B 45 F8 .M?.E....U?...E? 10011510: 5F 5E 5B 8B E5 5D C2 04 00 CC CC CC CC CC CC CC _^[.?]?..ììììììì 10011520: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 10011530: 55 8B EC 81 EC C0 00 00 00 53 56 57 8D BD 40 FF U.ì.ìà...SVW.?@? 10011540: FF FF B9 30 00 00 00 B8 CC CC CC CC F3 AB A1 40 ??10...?ììììó??@ 10011550: 91 01 10 5F 5E 5B 8B E5 5D C3 CC CC CC CC CC CC ..._^[.?]?ìììììì 10011560: CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC CC ìììììììììììììììì 10011570: 55 8B EC 6A FF 68 AE 56 01 10 64 A1 00 00 00 00 U.ìj?h?V..d?.... 10011580: 50 81 EC E8 00 00 00 53 56 57 51 8D BD 0C FF FF P.ìè...SVWQ.?.?? 10011590: FF B9 3A 00 00 00 B8 CC CC CC CC F3 AB 59 A1 04 ?1:...?ììììó?Y?. 100115A0: 90 01 10 33 C5 50 8D 45 F4 64 A3 00 00 00 00 89 ...3?P.E?d£..... 100115B0: 4D EC 6A 01 E8 4A FC FF FF 83 C4 04 89 85 20 FF Mìj.èJü??.?... ? 100115C0: FF FF C7 45 FC 00 00 00 00 83 BD 20 FF FF FF 00 ???Eü.....? ???. 100115D0: 74 13 8B 8D 20 FF FF FF E8 C7 FB FF FF 89 85 0C t... ???è????... 100115E0: FF FF FF EB 0A C7 85 0C FF FF FF 00 00 00 00 8B ????.?..???..... 100115F0: 85 0C FF FF FF 89 85 14 FF FF FF C7 45 FC FF FF ..???...????Eü?? 10011600: FF FF 8B 4D EC 8B 95 14 FF FF FF 89 11 8B 45 EC ??.Mì...???...Eì 10011610: 8B 4D F4 64 89 0D 00 00 00 00 59 5F 5E 5B 81 C4 .M?d......Y_^[.? 10011620: F4 00 00 00 3B EC E8 92 FB FF FF 8B E5 5D C3 CC ?...;ìè.???.?]?ì |
在上面的代碼示例中,紅色的部分表示的是被填充的二進制數據。
使用.textbss段方式
該方式是爲了實現「Edit and Continue」功能。.textbss段只存在於debug模式下生成的PE文件中,用於存放程序代碼的二進制數據。在PE文件中,該段只是一個邏輯概念,它不佔用文件中的存儲空間;在程序加載運行的時候,在進程的虛擬地址空間中,須要爲該段分配地址空間。
在調試階段,「Edit and Continue」模式下,當程序員修改了某個函數並按F10鍵繼續執行該程序之後,編譯器開始執行增量連接。在該狀況下,連接器會將更改後的函數的二進制代碼存放到內存中爲.textbss段分配的地址空間中。所以該函數的入口地址放生了變化。將會使用增量連接表處理函數入口地址發生變化的問題。這些過程都是在程序運行階段發生的。
不管是使用段間填充的方式,仍是使用.textbss段的方式,函數的入口地址都發生了變化。所以,在執行連接的時候,對於每一處調用該函數的地方,都須要修正該函數的地址值。這必然會影響程序的編譯速度,爲了解決這個問題,引入了增量連接表的概念。
增量連接表存在於debug模式下生成的PE文件中,位於.text段。該表中存儲一系列jump指令。每一個指令後面都會跟隨一個函數的相對地址。使用dumpbin工具解析debug模式下生成的PE文件之後,增量連接表的部份內容以下所示:
10011000: CC CC CC CC CC E9 76 08 00 00 E9 9B 36 00 00 E9 ......v.....6... @ILT+11(_DebugBreak@0): 10011010: EA 36 00 00 E9 A7 1E 00 00 E9 08 2C 00 00 E9 BD .6.........,.... @ILT+27(??4DemoMath@@QAEAAV0@ABV0@@Z): 10011020: 04 00 00 E9 68 15 00 00 E9 C5 36 00 00 E9 3E 13 ....h.....6...>. @ILT+43(??Bsentry@?$basic_ostream@DU?$char_traits@D@std@@@std@@QBE_NXZ): 10011030: 00 00 E9 B9 2B 00 00 E9 C8 36 00 00 E9 BF 2F 00 ....+....6..../. @ILT+59(_DllMain@12): 10011040: 00 E9 1A 16 00 00 E9 35 1F 00 00 E9 60 1E 00 00 .......5....`... |
在上面的代碼中,如:「E9 B9 2B 00 00」,表示執行近跳轉,要跳轉的地址是:00002bb9,這是一個相對地址;E9是彙編指令jump的機器碼。
在未引入增量連接表以前,在Call指令中是直接調用函數的。函數調用的指令格式描述以下:
Call foo。//Foo爲被調用函數的相對地址。 |
在引入了增量連接表以後,經過增量連接表間接調用函數。函數調用的指令格式描述以下:
Call foo_stub //foo_stub爲增量連接表中某一個表項的相對地址 Foo_stub: Jump foo //foo爲被調用函數的相對地址。 |
由此能夠看出,在引入了增量連接表之後,Call指令會調用增量連接表中的某一個表項,在增量連接表的表項中,再經過jump指令跳轉到該函數的真正位置。在引入了增量連接表之後,當函數的入口地址放生變化時,只須要修改jump指令後面的地址數據,而不是修改每個函數調用處的地址數據。經過這種方式,將須要修改n處代碼位置的狀況,簡化爲只須要修改一處代碼位置。
在「Edit and Continue」模式下,執行增量連接的流程以下圖所示:
首先須要編譯被更改過的源文件,而後將被更改後的函數的二進制代碼存儲到.textbss段所在的虛擬內存的地址空間中,最後還須要修改增量連接表中的某個表項的數據。
在「Edit and Continue」模式下,因爲被調試程序還處於運行狀態。所以,當修改完畢增量連接表的表項之後,還須要檢查全部線程的TIB,若是該線程的EIP還指向老的函數的地址(函數被修改前該函數的地址),就須要將該地址修正爲新函數的地址。
以上全部的操做均是對被調試程序內存的操做,包括對內存數據的讀取和修改。而完成這項工做的,是Visual Studio調試器。
在1.2.1C++源代碼示例中,類DemoMath中的成員函數AddData調用了全局變量nOpertimes,以及類DemoOutPut中的成員函數OutPutInfo。具體代碼格式以下:
void DemoMath::AddData(double a, double b) { nOperTimes++; m_pOutPut->OutPutInfo(a + b); } |
在執行編譯,連接的時候,在DemoMath.cpp生成的目標文件DemoMath.obj中,全局變量nOpertimes的地址須要執行重定位操做;相對於目標文件DemoMath.obj,目標文件DemoOutPut.obj中定義的符號OutPutInfo是外部符號,它也須要被解析和重定位。
在地址重定位的時候,變量的地址是絕對類型,函數的地址是相對類型。
在編譯階段,編譯器將C++源文件編譯成目標文件。使用工具dumpbin將目標文件DemoMath.obj中關於函數AddData所在的代碼段的內容導出,該內容包括:摘要信息,二進制代碼,以及符號表,具體內容以下:
//這是代碼段關於AddData函數部分的摘要信息 SECTION HEADER #14 //段名稱 .text name 0 physical address //物理地址,還沒有分配,應該爲零 0 virtual address //虛擬地址,還沒有分配,應該爲零 5C size of raw data //該段二進制數據的大小 1EB2 file pointer to raw data (00001EB2 to 00001F0D) //該段距離文件首位置的偏移 1F0E file pointer to relocation table //重定位表距離文件首位置的偏移 0 file pointer to line numbers //行號表的位置,爲零表示沒有行號表 4 number of relocations //重定位表中元素的個數 0 number of line numbers 60501020 flags //標記 Code //表示該段爲代碼 COMDAT; sym= "public: void __thiscall DemoMath::AddData(double,double)" (?AddData@DemoMath@@QAEXNN@Z) 16 byte align //16字節對齊 Execute Read //可執行,可讀 //如下內容爲代碼段關於AddData函數的二進制內容 RAW DATA #14 相對於代碼段偏移量 二進制內容 00000000: 55 8B EC 81 EC CC 00 00 00 53 56 57 51 8D BD 34 U.ì.ìì...SVWQ.?4 00000010: FF FF FF B9 33 00 00 00 B8 CC CC CC CC F3 AB 59 ???13...?ììììó?Y 00000020: 89 4D F8 A1 00 00 00 00 83 C0 01 A3 00 00 00 00 .M??.....à.£.... 00000030: DD 45 08 DC 45 10 83 EC 08 DD 1C 24 8B 45 F8 8B YE.üE..ì.Y.$.E?. 00000040: 08 E8 00 00 00 00 5F 5E 5B 81 C4 CC 00 00 00 3B .è...._^[.?ì...; 00000050: EC E8 00 00 00 00 8B E5 5D C2 10 00 ìè.....?]?..
//如下爲重定位表的內容。從左到右,各字段的含義是: //offset 須要重定位的位置。這些位置位於「RAW DATA #14」所描述的二進制代碼中,紅色顯示部分。 //Type 重定位的類型。Dir32表示絕對定位;Rel32表示相對定位 //Applied To 未知 //Symbol Index 須要重定位的符號在符號表中的索引。經過此字段,將符號表和重定位表關聯 //Symbol Name 符號名稱。 RELOCATIONS #14 Symbol Symbol Offset Type Applied To Index Name -------- ---------------- ----------------- -------- ------ 00000024 DIR32 00000000 F ?nOperTimes@@3HA (int nOperTimes) 0000002C DIR32 00000000 F ?nOperTimes@@3HA (int nOperTimes) 00000042 REL32 00000000 59 ?OutPutInfo@DemoOutPut@@QAEXN@Z (public: void __thiscall DemoOutPut::OutPutInfo(double)) 00000052 REL32 00000000 3F __RTC_CheckEsp |
(表一)
在上面被導出的內容中,二進制代碼不容易閱讀,因此將其內容轉換爲彙編格式,具體內容以下:
?AddData@DemoMath@@QAEXNN@Z (public: void __thiscall DemoMath::AddData(double,double)): 00000000: 55 push ebp 00000001: 8B EC mov ebp,esp 00000003: 81 EC CC 00 00 00 sub esp,0CCh 00000009: 53 push ebx 0000000A: 56 push esi 0000000B: 57 push edi 0000000C: 51 push ecx 0000000D: 8D BD 34 FF FF FF lea edi,[ebp-0CCh] 00000013: B9 33 00 00 00 mov ecx,33h 00000018: B8 CC CC CC CC mov eax,0CCCCCCCCh 0000001D: F3 AB rep stos dword ptr es:[edi] 0000001F: 59 pop ecx 00000020: 89 4D F8 mov dword ptr [ebp-8],ecx //此處引用了全局變量nOperTimes。該符號還沒有解析,地址暫時用零填充 00000023: A1 00 00 00 00 mov eax,dword ptr [?nOperTimes@@3HA] 00000028: 83 C0 01 add eax,1 0000002B: A3 00 00 00 00 mov dword ptr [?nOperTimes@@3HA],eax 00000030: DD 45 08 fld qword ptr [ebp+8] 00000033: DC 45 10 fadd qword ptr [ebp+10h] 00000036: 83 EC 08 sub esp,8 00000039: DD 1C 24 fstp qword ptr [esp] 0000003C: 8B 45 F8 mov eax,dword ptr [ebp-8] 0000003F: 8B 08 mov ecx,dword ptr [eax] //該地址爲函數OutPutInfo的地址,該地址還沒有解析,暫時用零填充 00000041: E8 00 00 00 00 call ?OutPutInfo@DemoOutPut@@QAEXN@Z 00000046: 5F pop edi 00000047: 5E pop esi 00000048: 5B pop ebx 00000049: 81 C4 CC 00 00 00 add esp,0CCh 0000004F: 3B EC cmp ebp,esp 00000051: E8 00 00 00 00 call __RTC_CheckEsp 00000056: 8B E5 mov esp,ebp 00000058: 5D pop ebp 00000059: C2 10 00 ret 10h |
(表二)
在上面的代碼中,紅色部分爲符號nOperTimes的虛擬內存地址,因爲該符號還沒有解析,因此該地址未知,暫時用零代替。藍色部分爲符號OutPutInfo的虛擬內存地址,因爲該符號還沒有解析,因此該地址未知,暫時用零代替。須要重定位的位置與重定位表的描述吻合。
目標文件DemoMath.obj的符號表的部份內容以下:
//符號nOpertimes在符號表中的內容 //00F表示符號在符號表中的索引。 //SECT4表示該符號位於第四個段中,也就是說,該符號位於當前目標文件中 //notype表示該符號爲變量 //External表示該符號爲全局符號,未見外部可見。Static表示該符號只文件內可見 //?nOperTimes@@3HA (int nOperTimes)是符號名稱 00F 00000000 SECT4 notype External | ?nOperTimes@@3HA (int nOperTimes)
//符號OutPutInfo在目標文件DemoMath.obj所屬符號表的內容 //UNDEF表示該符號未定義,該符號的定義位於其餘目標文件中。該符號須要解析 034 00000000 UNDEF notype () External | ??0DemoOutPut@@QAE@XZ
|
(表三)
在目標文件中,彙編內容顯示了編譯之後,連接以前各個須要重定位符號的信息狀況;結合重定位表中的信息,能夠很容易地找到須要重定位的位置;重定位表與經過索引字段與符號表關聯,符號表中各個符號的值,就是符號的地址。在該階段,這些地址還沒有使用虛擬地址表示。在連接階段,將會根據各個目標文件中的符號表生成全局符號表。在全局符號表中,各個符號的值使用虛擬地址表示。
開啓增量連接模式,在連接階段,將目標文件連接在一塊兒,輸出PE文件。使用工具dumpbin將DemoDlld.dll的內容解析爲彙編格式,其中函數AddData的彙編代碼的內容以下:
?AddData@DemoMath@@QAEXNN@Z: 10011780: 55 push ebp 10011781: 8B EC mov ebp,esp 10011783: 81 EC CC 00 00 00 sub esp,0CCh 10011789: 53 push ebx 1001178A: 56 push esi 1001178B: 57 push edi 1001178C: 51 push ecx 1001178D: 8D BD 34 FF FF FF lea edi,[ebp-0CCh] 10011793: B9 33 00 00 00 mov ecx,33h 10011798: B8 CC CC CC CC mov eax,0CCCCCCCCh 1001179D: F3 AB rep stos dword ptr es:[edi] 1001179F: 59 pop ecx 100117A0: 89 4D F8 mov dword ptr [ebp-8],ecx //符號nOperTimes已經被解析,地址爲0x10019140。該地址爲絕對地址 100117A3: A1 40 91 01 10 mov eax,dword ptr [?nOperTimes@@3HA] 100117A8: 83 C0 01 add eax,1 100117AB: A3 40 91 01 10 mov dword ptr [?nOperTimes@@3HA],eax 100117B0: DD 45 08 fld qword ptr [ebp+8] 100117B3: DC 45 10 fadd qword ptr [ebp+10h] 100117B6: 83 EC 08 sub esp,8 100117B9: DD 1C 24 fstp qword ptr [esp] 100117BC: 8B 45 F8 mov eax,dword ptr [ebp-8] 100117BF: 8B 08 mov ecx,dword ptr [eax] //符號OutPutInfo已經被解析,地址爲0xFFFFF9CA。該地址爲相對地址 100117C1: E8 CA F9 FF FF call @ILT+395(?OutPutInfo@DemoOutPut@@QAEXN@Z) 100117C6: 5F pop edi 100117C7: 5E pop esi 100117C8: 5B pop ebx 100117C9: 81 C4 CC 00 00 00 add esp,0CCh 100117CF: 3B EC cmp ebp,esp 100117D1: E8 E7 F9 FF FF call @ILT+443(__RTC_CheckEsp) 100117D6: 8B E5 mov esp,ebp 100117D8: 5D pop ebp 100117D9: C2 10 00 ret 10h |
(表四)
在增量連接模式下,編譯器會生成增量連接表,其部份內容以下所示:
@ILT+395(?OutPutInfo@DemoOutPut@@QAEXN@Z): 10011190: E9 0B 09 00 00 E9 40 35 00 00 E9 FF 34 00 00 E9 //紅色部分的彙編代碼爲:jump 0B 09 00 00 @ILT+411(_QueryPerformanceCounter@4): 100111A0: 7E 35 00 00 E9 77 08 00 00 E9 84 34 00 00 E9 81 |
(表五)
在增量連接模式下,全局符號表的相關內容爲:
符號類型 |
符號地址 |
長度 |
符號名稱 |
Function |
11AA0 |
126 |
DemoOutPut::OutPutInfo |
Data |
19140 |
4 |
nOperTimes |
(表六)
在符號表中,符號地址爲相對於默認加載位置的相對地址。真正的符號地址應該是0x10000000 + 符號地址。
關閉增量連接模式,從新編譯文件,將輸出的PE文件內容導出。函數AddData的彙編內容描述以下:
?AddData@DemoMath@@QAEXNN@Z: 10001220: 55 push ebp 10001221: 8B EC mov ebp,esp 10001223: 81 EC CC 00 00 00 sub esp,0CCh 10001229: 53 push ebx 1000122A: 56 push esi 1000122B: 57 push edi 1000122C: 51 push ecx 1000122D: 8D BD 34 FF FF FF lea edi,[ebp-0CCh] 10001233: B9 33 00 00 00 mov ecx,33h 10001238: B8 CC CC CC CC mov eax,0CCCCCCCCh 1000123D: F3 AB rep stos dword ptr es:[edi] 1000123F: 59 pop ecx 10001240: 89 4D F8 mov dword ptr [ebp-8],ecx //變量nOpertimes的地址已經被解析。該地址爲絕對地址0x10006030 10001243: A1 30 60 00 10 mov eax,dword ptr [?nOperTimes@@3HA] 10001248: 83 C0 01 add eax,1 1000124B: A3 30 60 00 10 mov dword ptr [?nOperTimes@@3HA],eax 10001250: DD 45 08 fld qword ptr [ebp+8] 10001253: DC 45 10 fadd qword ptr [ebp+10h] 10001256: 83 EC 08 sub esp,8 10001259: DD 1C 24 fstp qword ptr [esp] 1000125C: 8B 45 F8 mov eax,dword ptr [ebp-8] 1000125F: 8B 08 mov ecx,dword ptr [eax] //函數OutPut的地址已經被解析。該地址爲相對地址00 00 02 2A 10001261: E8 2A 02 00 00 call ?OutPutInfo@DemoOutPut@@QAEXN@Z 10001266: 5F pop edi 10001267: 5E pop esi 10001268: 5B pop ebx 10001269: 81 C4 CC 00 00 00 add esp,0CCh 1000126F: 3B EC cmp ebp,esp 10001271: E8 0A 0B 00 00 call __RTC_CheckEsp 10001276: 8B E5 mov esp,ebp 10001278: 5D pop ebp 10001279: C2 10 00 ret 10h 1000127C: CC CC CC CC |
(表七)
在非增量連接模式下,全局符號表的部份內容爲:
符號類型 |
符號地址 |
長度 |
符號名稱 |
Function |
1490 |
126 |
DemoOutPut::OutPutInfo |
Data |
6030 |
4 |
nOperTimes |
(表八)
在符號表中,符號地址爲相對於默認加載位置的相對地址。真正的符號地址應該是0x10000000 + 符號地址。
在表一中,根據重定位表的信息能夠得知,位於00000024位置的符號nOperTimes須要被重定位。在目標文件中,它的內容爲:00000020: 89 4D F8 A1 00 00 00 00。
執行靜態連接之後,輸出PE格式的Dll文件,其內容被導出到表四中。符號nOperTimes已經完成了重定位,其內容爲:100117A3: A1 40 91 01 10。符號nOperTimes的地址被解析成:0x10019140。
在全局符號表(表五)中,符號nOperTimes的地址是:19140。該地址爲基於默認加載位置的相對地址,加上0x10000000即爲符號的虛擬地址0x10019140。
在變量類型的符號的解析和重定位的時候,用全局符號表中的符號值(即變量地址:0x10019140)去重寫目標文件中須要重定位的位置(00000020: 89 4D F8 A1 00 00 00 00),獲得PE文件中重定位後的結果(100117A3: A1 40 91 01 10)。
變量的地址類型爲絕對地址,在重定位的時候,使用全局符號表中符號的虛擬地址直接重寫須要重定位的位置便可。
在增量連接模式下,函數調用的過程以下圖所示:
在增量連接的模式下,通過兩級調用才能完成對被調用函數的調用。首先在函數調用點,以call指令方式執行一次函數調用。通過對Call指令的調用,控制流程轉到了增量連接表中jump指令的位置。而後開始執行jump指令,通過對jump指令的調用,控制流程才真正到達被調用函數處。
在表四中,符號(函數)OutPutInfo已經被解析和重定位,該函數調用點處的機器碼爲:100117C1: E8 CA F9 FF FF。E8是Call指令的二進制機器碼,其後是四個字節的操做數,表示增量連接表中jump指令的相對位置。Call指令後的操做數的計算公式爲:操做數 = jump指令地址 – IP寄存器內容。當前IP寄存器內容爲:Call指令的地址 + 5,即:100117C1 + 5 = 100117C6。經表五的內容(10011190: E9 0B 09 00 00)得知,jump指令的地址爲:0x10011190。那麼操做數 = 0x10011190 - 100117C6 = FF FF F9 CA。與預期結果符合。
在表五中,jump指令的內容爲:10011190: E9 0B 09 00 00。通過jump指令,調用流程才真正轉到被調用函數處。Jump指令的操做數的計算公式爲:操做數 = 被調用函數的地址 – IP寄存器的內容。當前IP寄存器的內容爲:jump指令的地址 + 5,即:10011190 + 5 = 10011195。經過表六得知,被調用函數的地址爲:0x10011AA0。所以,jump指令的操做數 = 10011AA0 – 10011195 = 00 00 090B。該結果與預期符合。
在非增量連接模式下,通過一次Call指令的調用,便可完成從主調函數到被調函數控制流程的轉換。經過表七得知,函數調用點處的機器指令碼爲:10001261: E8 2A 02 00 00。E8爲Call指令的二進制碼,其後爲四個字節的操做數,該操做數是被調用函數OutPutInfo的相對地址。
Call指令操做數的計算公式爲:操做數 = 符號的地址 – IP寄存器的內容。當前IP寄存器的內容爲:Call指令的地址 + 5,即:0x10001261 + 0x5 = 0x10001266。經過表八得知,函數OutPutInfo的地址爲:0x10001490。所以,操做數 = 0x10001490 – 0x10001266 = 00 00 02 2A。該值預期結果符合。