C++應用程式在Windows下的編譯、連結:第三部分 靜態連結(一)

yangxi_001發表於2016-11-17

大家好,下面開始靜態連結部分的工作原理分析,由於這部分內容太多了,我計劃分2個部分發出,先看下這部分的大綱:

3靜態連結

3.1概述

編譯器的任務是將每一個包含C++程式碼的原始檔編譯成包含二進位制機器碼的目標檔案。由於在一個原始檔中可能會呼叫到其它檔案中的程式碼或資料,這些程式碼或者資料可能來自於靜態庫中,也可能來自於動態連結庫中,也可能來自於其他的原始檔中。在編譯階段,編譯器只專注於對單個原始檔的處理,對於這些外部符號,編譯器無法解析。對於呼叫到外部符號的地方,編譯器留出位置,並用一些假資料填充。因此,編譯器輸出的目標檔案是不完整的,是需要修正的。

連結器的任務是修正目標檔案中不完整的地方,解析在編譯階段無法解析的外部符號,並且將這些目標檔案合併到一起,輸出可執行檔案。這些外部符號可以在連結階段解析,可以在可執行程式載入到記憶體的階段解析,甚至推遲到可執行程式執行的階段。在連結階段解析外部符號的工作被稱為靜態連結,在載入階段解析外部符號的工作被稱為隱式動態連結,在執行階段解析外部符號的工作被稱為顯式動態連結。

在靜態連結階段,由於被引用的外部符號可能來自於不同的地方,如:其他目標檔案中,靜態連結庫中,動態連結庫中,所以靜態連結又可以分為三種情況:

  • 目標檔案之間的靜態連結。
  • 目標檔案與靜態連結庫之間的靜態連結。
  • 目標檔案與匯入庫之間的靜態連結。

靜態連結的總體框架如下圖所示:

輸入的檔案包括:目標檔案,靜態連結庫檔案,資原始檔,動態連結庫的匯入庫檔案,以及與連結相關的定義檔案(如:def檔案)。在執行靜態連結的時候,被輸入的目標檔案為一個到多個,每一個目標檔案對應一個C++原始碼檔案;由於C++程式是執行在C++執行庫之上的,而C++執行庫又是以靜態連結庫和動態連結庫兩種方式提供。因此在執行靜態連結的時候,輸入檔案可能會包括靜態連結庫,比如:libcmt.lib。輸入檔案也可能是動態連結庫,比如:msvcp90.dll。但是動態連結庫檔案不直接參與靜態連結,參與靜態連結的是與該靜態連結庫相對應的匯入庫檔案(該檔案的副檔名也是.lib)。

連結器在執行靜態連結的時候分為兩個階段,每個階段都包含一次對輸入檔案的掃描,在掃面的基礎上執行一些處理操作,然後輸出一些檔案。

在第一遍掃描的過程中,連結器主要生成了全域性符號表,段表,以及匯出符號表。在建立全域性符號表的時候,每個目標檔案中的全域性符號都會被讀入到該表中,然後以連結串列的形式將模組中定義或者引用了該全域性符號的位置儲存起來。當全域性符號表建立完畢以後,在該表中,對於每一個符號都會有一個定義,0到多個引用。在連結器掃描各個目標檔案資訊的時候,段資訊也會被記錄,包括:各段的大小,位置,屬性等,這些資訊被放入到段表中。段表為後續的段合併提供了資訊支援。如果全域性符號中包含匯出符號(一般為生成動態連結庫的情況),連結器會將這些匯出符號寫入到.edata段中,然後將.edata段輸出到副檔名為.exp問臨時檔案中,該檔案的格式為COFF格式。

在第二遍掃描的過程中,連結器主要做的工作是:確定各個段的地址,以及段內符號的地址;執行屬性相同段的合併工作;符號解析和重定位;建立重定位段以及符號表資訊;寫入頭部資訊;加入少量的程式碼和資料,這些程式碼包括:樁程式碼(一些jump指令)和啟動程式碼。

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

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節所描述的目標檔案之間的靜態連結的工作外,為了處理應用程式對動態連結庫中符號的引用,在進行兩遍掃描的時候,連結器還需要做其他的額外工作,這些工作主要包括:

  • 在動態連結庫所屬的匯入庫的支援下,生成可執行檔案的匯入表;
  • 根據生成匯入表的內容和符號表的內容,解析外部符號。這些符號在可執行程式中使用,而在動態連結庫中定義。

在靜態連結階段,動態連結庫檔案本身並不參與連結,參與連結的是與動態連結庫相對應的匯入庫檔案。匯入庫檔案伴隨動態連結庫檔案的生成而生成。

要使用動態連結庫,首先涉及到的是動態連結庫的建立,然後才會涉及到對動態連結庫的使用。整個動態連結庫的建立工作是在編譯與靜態連結下完成的;而對動態連結庫的使用則涉及到兩個過程:靜態連結下的資料準備工作,以及載入時外部符號的解析工作。關於動態連結的過程將在“動態連結”相關的章節講述,這裡主要描述靜態連結。

相關文章