原創 C++應用程序在Windows下的編譯、連接:第三部分 靜態連接(一)

    你們好,下面開始靜態連接部分的工做原理分析,因爲這部份內容太多了,我計劃分2個部分發出,先看下這部分的大綱:程序員

3靜態連接

3.1概述

編譯器的任務是將每個包含C++代碼的源文件編譯成包含二進制機器碼的目標文件。因爲在一個源文件中可能會調用到其它文件中的代碼或數據,這些代碼或者數據可能來自於靜態庫中,也可能來自於動態連接庫中,也可能來自於其餘的源文件中。在編譯階段,編譯器只專一於對單個源文件的處理,對於這些外部符號,編譯器沒法解析。對於調用到外部符號的地方,編譯器留出位置,並用一些假數據填充。所以,編譯器輸出的目標文件是不完整的,是須要修正的。windows

連接器的任務是修正目標文件中不完整的地方,解析在編譯階段沒法解析的外部符號,而且將這些目標文件合併到一塊兒,輸出可執行文件。這些外部符號能夠在連接階段解析,能夠在可執行程序加載到內存的階段解析,甚至推遲到可執行程序執行的階段。在連接階段解析外部符號的工做被稱爲靜態連接,在加載階段解析外部符號的工做被稱爲隱式動態連接,在運行階段解析外部符號的工做被稱爲顯式動態連接。數據結構

在靜態連接階段,因爲被引用的外部符號可能來自於不一樣的地方,如:其餘目標文件中,靜態連接庫中,動態連接庫中,因此靜態連接又能夠分爲三種狀況:框架

  • 目標文件之間的靜態連接。
  • 目標文件與靜態連接庫之間的靜態連接。
  • 目標文件與導入庫之間的靜態連接。

靜態連接的整體框架以下圖所示:函數

輸入的文件包括:目標文件,靜態連接庫文件,資源文件,動態連接庫的導入庫文件,以及與連接相關的定義文件(如:def文件)。在執行靜態連接的時候,被輸入的目標文件爲一個到多個,每個目標文件對應一個C++源代碼文件;因爲C++程序是運行在C++運行庫之上的,而C++運行庫又是以靜態連接庫和動態連接庫兩種方式提供。所以在執行靜態連接的時候,輸入文件可能會包括靜態連接庫,好比:libcmt.lib。輸入文件也多是動態連接庫,好比:msvcp90.dll。可是動態連接庫文件不直接參與靜態連接,參與靜態連接的是與該靜態連接庫相對應的導入庫文件(該文件的擴展名也是.lib)。工具

連接器在執行靜態連接的時候分爲兩個階段,每一個階段都包含一次對輸入文件的掃描,在掃面的基礎上執行一些處理操做,而後輸出一些文件。this

在第一遍掃描的過程當中,連接器主要生成了全局符號表,段表,以及導出符號表。在創建全局符號表的時候,每一個目標文件中的全局符號都會被讀入到該表中,而後以鏈表的形式將模塊中定義或者引用了該全局符號的位置存儲起來。當全局符號表創建完畢之後,在該表中,對於每個符號都會有一個定義,0到多個引用。在連接器掃描各個目標文件信息的時候,段信息也會被記錄,包括:各段的大小,位置,屬性等,這些信息被放入到段表中。段表爲後續的段合併提供了信息支持。若是全局符號中包含導出符號(通常爲生成動態連接庫的狀況),連接器會將這些導出符號寫入到.edata段中,而後將.edata段輸出到擴展名爲.exp問臨時文件中,該文件的格式爲COFF格式。spa

在第二遍掃描的過程當中,連接器主要作的工做是:肯定各個段的地址,以及段內符號的地址;執行屬性相同段的合併工做;符號解析和重定位;創建重定位段以及符號表信息;寫入頭部信息;加入少許的代碼和數據,這些代碼包括:樁代碼(一些jump指令)和啓動代碼。操作系統

當靜態連接執行完畢之後,連接器主要輸出了可執行文件或者動態連接庫文件,以及一些輔助性文件,如:符號文件(pdb),導入庫文件(lib),導出表文件(exp)等。debug

3.2符號地址的演化

連接的目標是要處理好符號的虛擬內存地址。下面將要介紹在各個階段內,符號的地址演化狀況。

從C/C++源代碼的編寫階段,通過編譯,連接,程序加載到內存,一直到程序的運行,各個符號的地址的演化流程以下圖所示:

在代碼編寫階段,使用變量名稱,或者函數名稱來表示一個符號。好比:變量的定義,int nVar = 10;,定義一個整形變量初始化爲數值10。使用名稱nVar來表示這個變量符號。

     在執行編譯後的目標文件中,使用文件偏移量來表示一個符號的地址,這個文件偏移量能夠是相對於COFF文件的首位置的絕對偏移。如各個段的位置,重定位表和符號表的位置;也能夠是相對與段首位置的相對偏移。如:數據段內定義的符號相對於數據段首位置的偏移。示例以下:

SECTION HEADER #5   //代碼段的基本信息

   .text name

       0 physical address //物理地址

       0 virtual address  //虛擬地址,該地址均爲零,由於編譯階段沒有分配虛擬內存地址

      39 size of raw data //代碼段大小

    1561 file pointer to raw data (00001561 to 00001599)   //絕對偏移,代碼段相對於文件首位置的偏移

       0 file pointer to relocation table //重定位表的位置。零表示沒有重定位信息

       0 file pointer to line numbers

       0 number of relocations

       0 number of line numbers

60501020 flags

         Code

         COMDAT; sym= "public: class DemoMath & __thiscall DemoMath::operator=(class DemoMath const &)" (??4DemoMath@@QAEAAV0@ABV0@@Z)

         16 byte align

         Execute Read

 

RAW DATA #5   //代碼段的二進制數據內容。這些內容以字節爲單位列出。每一個字節都有一個地址,這些地址是相對於代碼段的偏移量。從下面的內容能夠看出,這些字節從零開始編址,直到地址爲30的位置。

這是相對偏移。若是要更改爲絕對偏移來表示的話,絕對位置= 段相對文件首的位置+各字節相對段的偏移

  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 8B 45 08 8B 08 8B 55 F8 89 0A 8B 45 F8  .M?.E....U?...E?

  00000030: 5F 5E 5B 8B E5 5D C2 04 00                       _^[.?]?..

   在上面示例的註釋中,描述了絕對偏移和相對偏移的狀況。

   在執行連接後的PE文件中,使用虛擬內存地址表示各個符號的位置。這些虛擬內存地址是基於默認加載位置的虛擬內存地址。在32位的操做系統中,可執行文件(exe)的默認加載位置是:0x00400000,動態連接庫(DLL)的默認加載位置是:0x10000000。

符號的虛擬內存地址的計算方式爲:符號的虛擬內存地址 = 默認加載地址 + 段偏移 +段內偏移。在下面的示例中,變量nGlobalData的虛擬地址爲:(0x00400000(默認加載地址)+0x00019000(段偏移)+0x00000004(段內偏移)=0x00419004)示例以下:

//DemoExe.exe數據段導出的內容

SECTION HEADER #4     //數據段的基本信息

   .data name

     5B4 virtual size   //數據段的大小

   19000 virtual address (00419000 to 004195B3)//數據段相對於默認加載位置的偏移。數據段的虛擬內存地址=默認加載位置(0x00400000)+ 0x00019000

     200 size of raw data //數據段的大小

    7800 file pointer to raw data (00007800 to 000079FF)//在PE文件中,數據段相對於文件首位置的絕對偏移。

       0 file pointer to relocation table  //零表示沒有重定位段。必須爲零,已經重定位完成了。

       0 file pointer to line numbers

       0 number of relocations

       0 number of line numbers

C0000040 flags

         Initialized Data

         Read Write

 

RAW DATA #4  //數據段的二進制內容。從下面的內容能夠看出,對於每個字節,都有一個虛擬內存地址。該虛擬內存地址是基於默認加載位置的虛擬內存地址。下面紅色的數據爲變量nGlobalData的值。從地址0x00419004到0x0041907。該數據使用小尾方式排列,應該倒過來看,即:00 00 00 05。

  00419000: 3C 77 41 00 05 00 00 00 00 00 00 00 4E E6 40 BB  <wA.........N?@?

  00419010: B1 19 BF 44 00 00 00 00 00 00 00 00 00 00 00 00  ±.?D............

  00419020: 01 00 00 00 01 00 00 00 01 00 00 00 01 00 00 00  ................

  00419030: 01 00 00 00 00 00 00 00 FE FF FF FF 01 00 00 00  ........t???....

  00419040: FF FF FF FF FF FF FF FF 00 00 00 00 44 82 41 00  ????????....D.A.

  00419050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................

   在應用程序加載到內容的時候,並非每次都能加載到默認的內存位置。若是該內存位置被佔用,那麼必須執行基址重定位工做,即:從新選定模塊要加載的內存基地址。這時候,該符號的虛擬內存地址的計算方式爲:虛擬內存地址=當前基地址+段偏移+段內偏移。其中段偏移和段內偏移在連接階段已經肯定,惟一變化的是當前基地址。

運行DemoExe應用程序,在Visual Studio中查看DemoExe當前的加載位置,具體狀況以下圖所示:

在上圖中,DemoExe被加載到的內存位置是:0x00110000。這個值在每一次程序運行的過程當中均可能不同。

在運行時,變量nGlobalData的地址分配狀況以下圖所示:

變量nGlobalData的當前虛擬內存地址爲:

0x00129004 = 0x00110000+0x00019000+0x00000004。符合前面公式所描述的規則。

靜態連接的過程當中,在生成的PE文件內,符號的虛擬內存地址是基於默認加載位置的。符號解析和地址重定位工做都是在該規則下進行的。

3.3轉移指令

能夠修改IP寄存器的內容,或者同時修改CS寄存器和IP寄存器的內容的指令統稱爲轉譯指令。IP寄存器中保存了當前被執行指令的下一條指令的地址;CS寄存器保存了當前內存段的地址(或者選擇子)。

按照轉移的距離來分,轉移指令分爲三種,分別是:

  • 短轉移指令。只能在256字節範圍了轉移;
  • 近轉移指令。能夠在一個段範圍內轉移;
  • 遠轉移指令。能夠在段間轉移。

經常使用的轉移指令包括:Jump指令,Call指令等。其中,Call指令沒有短轉移功能,只能實現近轉移和遠轉移。在短轉移指令和近轉移指令中,其所包含的操做數都是相對於(E)IP的偏移,而遠轉移指令的操做數包含的是目標的絕對地址。所以,在短轉移指令和近轉移指令中,對於跳轉同一目標地址的狀況下,其操做數是不一樣的,並且應該不一樣,由於是相對的;而遠轉移指令包含的操做數是絕對地址,所以跳轉到同一地址的機器碼指令是相同的。

因爲運行在32位windows下的應用程序是基於平坦內存管理模式的,也就是說,整個進程的虛擬地址空間被劃分紅一個段,該段的基地址是0x00000000H,大小是4GB。在這種狀況下,全部的轉移都是在一個段內進行的,因此無需考慮遠轉移指令。

使用Dumpbin工具將PE文件的內容導出爲彙編格式,在該彙編格式的文件中,涉及到的轉移指令,包括Jump指令和Call指令,均爲近轉移指令。即:段內轉移。

轉移指令的格式爲:

call 操做數

Jump 操做數

操做數的計算公式爲:

操做數 = 符號虛擬內存地址 – IP寄存器的內容

符號的虛擬內存地址爲:被調用函數或其餘被定義的符號在虛擬內存中的絕對地址;IP寄存器的內容爲:當前被執行指令的下一條指令的虛擬內存地址。在32位環境中,指令佔一個字節,操做數(即符號地址)佔4個字節,一共5個字節。所以,IP寄存器內容的計算公式爲:

IP寄存器的內容 = 轉移指令的當前地址 - 5

具體狀況以下圖所示:

3.4目標文件之間的靜態連接

使用Visual Studio創建C++項目之後,在該項目中可能會包含多個源文件,在編譯階段,每個源文件都被編譯成目標文件。在某一個目標文件中,可能引用了定義在其餘目標文件中的符號,所以在靜態連接階段須要對這些外部符號進行解析。在這一節中,目標文件都是由程序員編寫的C++代碼編譯生成的,而不是來自於某個靜態連接庫或者動態連接庫。

在靜態連接的時候,連接器的工做分兩步進行,每步執行一次掃描,具體的操做流程以下圖所示:

3.4.1創建全局符號表

Step1:掃描各個符號表。在執行該階段的任務,掃描目標文件的時候,各個目標文件中所包含的符號表也一同被掃描。將這些屬於各個目標文件的符號表合併到一塊兒,造成一張全局符號表。

Step2:在目標文件所屬的符號表中,因爲各個符號尚未被分配虛擬內存地址,因此符號的值是中還沒有包含符號的虛擬內存地址。這裏所說的符號主要是指變量或者函數。當目標文件中各個符號的地址被肯定之後,須要將各個符號的值更改爲該符號被分配的虛擬內存地址。

Step3:合併同名符號的記錄。在目標文件A中引用了定義在目標文件B中的符號C。那麼在目標文件A中,符號表就會包含這樣一條記錄,該記錄的符號名爲C,符號的「StorageClass」屬性爲:External(全局符號),符號的「SectionNumber」屬性爲:UNDEF(未定義);符號的值不定;在目標文件B中,符號表也會包含一條名稱爲C的符號記錄,該記錄的「StorageClass」屬性爲:External(全局符號),「SectionNumber」屬性爲:SECTn(表示符號位於某各段內),符號的值爲符號的虛擬內存地址。在執行連接的時候,須要將這兩條記錄合併爲一條記錄,並肯定新記錄在符號表中的索引。而後使用新記錄的符號表索引去修正相關重定位表。由於重定位表引用了符號表的索引。

Step4:創建全局符號表。在全局符號表中,全部的符號都擁有正確的虛擬內存地址。全部的重定位表都引用了正確的符號表索引。在創建全局符號表的時候,每一個目標文件中的全局符號都會被讀入到該表中,而後以鏈表的形式將模塊中定義或者引用了該全局符號的位置存儲起來。當全局符號表創建完畢之後,在該表中,對於每個符號都會有一個定義,0到多個引用

3.4.2創建段表

Step1:掃描各段信息。掃描全部參與連接的目標文件,肯定各個段的大小,屬性和位置。在每一個目標文件的段表中,字段「VirtualSize」記錄了該段被加載到內存之後所須要的內存空間的大小,段的大小是虛擬內存空間分配的依據;字段「Characteristics」記錄了該段的屬性。如:可讀,可寫,可執行,是代碼段,仍是數據段等。段的屬性是段合併的依據。

     Step2:創建段表。在內存中爲段表分配內存空間,而後將第一步得到的信息寫入到內存中,造成段表,後續的段合併中將使用到段表。

3.4.3段合併

     Step1:掃描各目標文件。從新掃描各個目標文件,根據段表的信息,提取各段的內容。

     Step2:肯定各段地址。根據段表中的信息,爲提取到的各段分配虛擬內存地址,以及肯定各段佔用的內存空間大小。即:肯定每一個段的段首在內存中的可能加載位置(固然,這個位置在加載時可能會變)。

     Step3:肯定段內地址。在目標文件中,各個段內的符號沒有虛擬內存地址,只有相對於各個段首的文件偏移量。在連接階段,當肯定了各個段段首的虛擬內存地址之後,就能夠根據符號的文件偏移量,計算出各段內符號的虛擬內存地址。符號的虛擬內存地址=段首虛擬內存地址+文件偏移量。

Step4:合併段並輸出。將各個目標文件中的全部屬性相同的段合併到一塊兒,造成一個新段,並輸出到一個新的文件中。這個文件將做爲連接後的輸出物,根據設定,能夠是可執行文件,也能夠是動態連接庫等。在這裏,合併的原則是屬性相同,而不是邏輯相同。例如:全部的代碼段被合併到一塊兒,全部的數據段被合併到一塊兒,全部的bss段被合併到一塊兒。

    完成該階段工做之後,全部目標文件中的內容都被合併到了一塊兒,而且肯定了符號的虛擬內存地址。若是該段擁有重定位表,那麼重定位表的屬性「VirtualAddress」的值也會被修正,使其指向正確的重定位位置。由於段的合併致使了段內符號的相對偏移量的變化,因此該值可能被修正。

3.4.5符號解析

Step1:掃描各段重定位表。通過前面的處理,全部的目標文件都已經被合併,而且將合併後的內容輸出到一個新文件中,該文件將以PE格式存儲。各個段的重定位表和新創建的全局符號表也存在於該文件中。連接器開始掃描重定位表,用來提供重定位信息。

Step2:肯定重定位的位置。經過對重定位表的掃描,取得了重定位表中字段VirtualAddress的值。該值是一個內存地址,在該內存地址所指向的內存處存儲了一個指令的操做數。該操做數通常爲一個變量或函數的內存地址。表示這個指令要使用這個變量的值,或者執行函數調用。在編譯階段,因爲這個操做數所表明的變量或函數被定義其餘目標文件中,因此沒法立刻肯定該操做數的正確值。在連接階段,這個操做數是須要被修正的,該操做數所在的位置即爲重定位的位置。在32位操做系統中,重定位的位置爲4個字節。

Step3:取得重定位符號的地址類型。在重定位表中,須要被修正的函數或變量的地址有兩種類型,即:相對地址和絕對地址。在重定位表中,使用字段Type存儲該類型。在地址重定位的時候,對這兩種類型的地址的處理方式是不一樣的。

Step4:處理相對地址。函數的虛擬內存地址的類型爲相對地址,在進行符號解析和重定位的時候,須要在重定位的位置上填寫4個字節的相對地址。相對地址的計算公式爲:

相對地址 = 符號虛擬內存地址 – 指令虛擬內存地址 – 5

//該計算公式在32位模式下有效,具體解釋見3.3節

編譯C++源代碼的時候,在debug模式中,採用了增量連接的方式,而在release模式中,採用了非增量連接的方式。在執行增量連接的狀況下,在重定位的位置上,被填寫的相對地址是相對於增量連接表中某個表項的相對地址,而不是被調用函數的相對地址;在非增量連接的狀況下,在重定位的位置上,被填寫的相對地址是相對於被調用函數的相對地址。將在3.7節詳細介紹增量連接的概念。

Step5:處理絕對地址。變量的虛擬內存地址的類型爲絕對地址,在進行符號解析和重定位的時候,須要在重定位的位置上填寫4個字節的變量的虛擬內存地址。該地址值爲變量的真實的虛擬內存地址。

關於地址計算部分,參見3.8的示例。

3.4.6其餘工做

    其餘部分的工做包括:向PE文件中寫入頭部信息。包括:DOS頭,PE頭等信息;向PE文件中寫入一些代碼,包括樁代碼和庫的啓動代碼等,主要用於動態連接庫;另外,根據連接的配置,還可能要進行一些文件的輸出,好比:map文件,符號表文件等。

3.5目標文件與導入庫之間的靜態連接

3.5.1概述

該階段的工做是執行動態連接的準備工做,動態連接是相對於靜態連接而言的。所謂靜態連接是指把要調用的函數或者過程連接到可執行文件中,成爲可執行文件的一部分。換句話說,函數和過程的代碼就在程序的exe文件中,該文件包含了運行時所需的所有代碼。當多個程序都調用相同函數時,內存中就會存在這個函數的多個拷貝,這樣就浪費了寶貴的內存資源。

在動態連接中,被調用的函數代碼沒有被拷貝到應用程序的可執行文件中,而僅僅是在其中加入了所調用函數的描述信息(每每是一些重定位信息)。當應用程序被裝入內存開始運行的時候,在Windows的管理下,創建起了應用程序與相應動態連接庫之間的關係。當要執行所調用的DLL中的函數時,根據連接產生的重定位信息,Windows才轉去執行DLL中相應的函數代碼。通常狀況下,若是在一個應用程序中使用了動態連接庫,那麼Win32系統保證內存中只有DLL的一份複製品。

動態連接的整個過程可分爲兩步:編譯時的靜態連接,以及加載運行時的動態連接。這部分靜態連接的工做是爲後續的動態連接所作的準備。即:在靜態連接過程當中生成的數據結構,如導入表,導出表等,都將被加載器用來執行動態連接。整個過程的詳細狀況以下圖所示:

在靜態連接階段,連接器除了要執行如3.4節所描述的目標文件之間的靜態連接的工做外,爲了處理應用程序對動態連接庫中符號的引用,在進行兩遍掃描的時候,連接器還須要作其餘的額外工做,這些工做主要包括:

  • 在動態連接庫所屬的導入庫的支持下,生成可執行文件的導入表;
  • 根據生成導入表的內容和符號表的內容,解析外部符號。這些符號在可執行程序中使用,而在動態連接庫中定義。

在靜態連接階段,動態連接庫文件自己並不參與連接,參與連接的是與動態連接庫相對應的導入庫文件。導入庫文件伴隨動態連接庫文件的生成而生成。

要使用動態連接庫,首先涉及到的是動態連接庫的建立,而後纔會涉及到對動態連接庫的使用。整個動態連接庫的建立工做是在編譯與靜態連接下完成的;而對動態連接庫的使用則涉及到兩個過程:靜態連接下的數據準備工做,以及加載時外部符號的解析工做。關於動態連接的過程將在「動態連接」相關的章節講述,這裏主要描述靜態連接。

相關文章
相關標籤/搜索