《程式設計師的自我修養》筆記(二)——裝載與動態連結

吳尼瑪發表於2018-03-26

裝載與動態連結

可執行檔案的裝載與程式

  • 每個程式都擁有自己獨立的虛擬地址空間,這個空間大小由計算機硬體平臺決定(理論上的最大上限)。比如,32位硬體平臺的虛擬地址空間的地址為0到232-1,即0x00000000~0xFFFFFFFF,總共大概4G;而64位硬體平臺的虛擬地址空間地址為0到264-1,即0x0000000000000000~0xFFFFFFFFFFFFFFFF,大概有17179869184G。在32位平臺上,Linux作業系統中4G的虛擬地址空間會被劃分為兩個部分,從0xC0000000到0xFFFFFFFF共1G的地址空間被分配給了作業系統,剩下的從0x00000000到0xBFFFFFFF共3G的地址空間是留給程式的。從原則上講,我們程式最多能使用3G的虛擬地址空間。對於Windows作業系統來說,它的程式虛擬地址空間劃分是作業系統佔用2G,程式只剩下2G。對於一些程式來說2G虛擬空間太小,所以Windows有個啟動引數可以將作業系統佔用的虛擬地址空間減少到1G。方法如下:修改Windows系統盤根目錄下的boot.ini,加上“/3G”引數。

《程式設計師的自我修養》筆記(二)——裝載與動態連結

  • 動態裝載的兩種典型方法是覆蓋裝入和頁對映,覆蓋裝入在沒有發明虛擬儲存之前使用比較廣泛,現在已經幾乎被淘汰了。頁對映簡單的說就是作業系統將程式需要使用的頁按一定的演算法動態對映到實體記憶體中執行。

  • 從作業系統的角度來看,一個程式最關鍵的特徵是它擁有獨立的虛擬地址空間,這使得它有別於其他程式。一個程式的建立有三步:

    • 首先是建立虛擬地址空間。
    • 讀取可執行檔案頭,並且建立虛擬空間與可執行檔案的對映關係。(可執行檔案裝載中最重要的一步,也是傳統意義上的“裝載”)
    • 將CPU指令暫存器設定成可執行檔案入口,啟動執行。
  • 我們知道,當程式執行發生頁錯誤時,作業系統將從實體記憶體中分配一個物理頁,然後將該“缺頁”從磁碟中讀取到記憶體中,再設定缺頁的虛擬頁和物理頁的對映關係,這樣程式才得以正常執行。但是很明顯的一點是,當作業系統捕獲到缺頁錯誤時,它應知道程式當前所需要的頁在可執行檔案中的哪一個位置。這就是虛擬空間與可執行檔案之間的對映關係。

《程式設計師的自我修養》筆記(二)——裝載與動態連結

  • ELF檔案被對映時,是以系統的頁長度作為單位的。為避免記憶體浪費,作業系統在裝載可執行檔案時主要關心的只是檔案中段的許可權(可讀、可寫、可執行)。對於相同許可權的段,把它們合併到一起當作一個段進行對映。Linux中將程式虛擬空間中的一個段叫做虛擬記憶體區域(VMA),在Windows中將這個叫做虛擬段(Virtual Section)。很多情況下,一個程式中的堆和棧分別都有一個對應的VMA。作業系統在程式啟動前會將系統的環境變數和程式的執行引數提前儲存到程式的虛擬空間的棧中(也就是VMA中的stack VMA)。

  • PE檔案的裝載和ELF有所不同,在PE檔案中,所有段的起始地址都是頁的倍數,段的長度如果不是頁的整數倍,那麼在對映時向上補齊到頁的整數倍。由於這個特點,PE檔案的對映過程比ELF簡單得多,因為它無需考慮如ELF裡面諸多段地址對齊之類的問題,雖然這樣會浪費一些磁碟和記憶體空間。

  • PE檔案中,連結器在生產可執行檔案時,往往將所有的段儘可能地合併,所以一般只有程式碼段、資料段、只讀資料段和BSS等為數不多的幾個段。

  • 每個PE檔案在裝載時都會有一個裝載目標地址,這個地址就是基地址,基地址不是固定的,每次裝載時都可能會變化。所以PE檔案中有一個常見術語叫相對虛擬地址(RVA),它是相對於PE檔案的裝載基地址的一個偏移地址。這樣無論基地址怎麼變化,PE檔案中的各個RVA都保持一致。

  • WIndows PE檔案的裝載過程:

    • 先讀取檔案的第一個頁(包含DOS頭,PE檔案頭和段表)。
    • 檢查程式地址空間中,目標地址是否可用,如果不可用,則另外選一個裝載地址。(主要針對DLL裝載)
    • 使用段表中提供的資訊,將PE檔案中所有的段一一對映到地址空間中相應的位置。
    • 如果裝載地址不是目標地址,則進行Rebasing
    • 裝載所有PE檔案所需要的DLL檔案。
    • 對PE檔案中的所有匯入符號進行解析。
    • 根據PE頭中指定的引數,建立初始化堆和棧。
    • 建立主執行緒並且啟動程式。

動態連結

  • 為什麼要動態連結?

    • 靜態連結的方式對於計算機記憶體和磁碟的空間浪費非常嚴重。
    • 靜態連結對於程式的更新、部署和釋出也會帶來很多麻煩。
  • 在Linux系統中,ELF動態連結檔案被成為動態共享物件(DSO),簡稱共享物件,它們一般都是以“.so”為副檔名的一些檔案;而在Windows系統中,動態連結檔案被成為動態連結庫(DLL),它們通常是以“.dll”為副檔名的檔案。

  • 靜態連結的重定位叫連結時重定位(Link Time Relocation),而動態連結的重定位為裝載時重定位(Load Time Relocation),在Windows中,這種裝載時重定位又被叫做基址重置(Rebasing)。在Linux和GCC中只要使用“-shared”引數,輸出的共享物件就是使用的裝載時重定位。

  • 把指令中那些需要修改的部分分離出來,跟資料部分放在一起,這樣指令部分就可以保持不變,而資料部分可以在每個程式中擁有一個副本,這種方案就是地址無關程式碼(PIC)技術。在Linux共享物件中要生成地址無關程式碼只用在編譯是帶上引數-fPIC。

《程式設計師的自我修養》筆記(二)——裝載與動態連結

  • 上面的情況並沒有包括定義在共享模組內部的全域性變數。ELF共享庫在編譯時,預設都把定義在模組內部的全域性變數當做定義在其他模組的全域性變數,也就是說當做上圖中的型別(4),通過GOT來實現變數的訪問。當共享模組被裝載時,如果某個全域性變數在可執行檔案中擁有副本,那麼動態連結器就會把GOT中的相應地址指向該副本,這樣該變數在執行時實際上最終就只有一個例項。如果變數在共享模組中被初始化,那麼動態連結器還需要將該初始化值複製到主模組中的變數副本;如果該全域性變數在程式主模組中沒有副本,那麼GOT中的相應地址就指向模組內部的該變數副本。

  • 對於共享物件來說,如果資料段中有絕對地址引用,那麼編譯器和連結器就會產生一個重定位表,這個重定位表裡麵包含了“R_386_RELATIVE”型別的重定位入口。當動態連結器裝載共享物件時,如果發現該共享物件有這樣的重定位入口,那麼動態連結器就會對該共享物件進行重定位。

  • 我們在編譯共享物件時如果使用“-fPIC”引數,就表示要產生地址無關的程式碼段。GCC編譯動態連結的可執行檔案會預設帶上該引數的。如果不使用該引數就會產生一個裝載時重定位的共享物件,它的程式碼段就不是地址無關的,也就不能被多個程式之間共享,於是就失去了節省記憶體的優點。但是裝載時重定位的共享物件的執行速度要比使用地址無關程式碼的共享物件快,因為它省去了地址無關程式碼中每次訪問全域性資料和函式時需要做一次計算當前地址以及間接地址定址的過程。

  • 動態連結比靜態連結慢的主要原因是動態連結下對於全域性和靜態的資料訪問都要進行復雜的GOT定位,然後間接定址;對於模組間的呼叫也要先定位GOT,然後再進行間接跳轉,這可能會導致程式啟動或者執行速度減慢,所以我們需要優化動態連結效能。

  • ELF採用延遲繫結來優化動態連結效能,基本思想是當函式第一次被用到時才進行繫結(符號查詢、重定位等)。具體方法是使用了PLT(Procedure Linkage Table)。PLT為GOT間接跳轉又增加了一箇中間層,在呼叫某個外部模組的函式時,並不直接通過GOT跳轉,而是通過一個叫作PLT項的結構來進行跳轉。每個外部函式在PLT中都有一個相應的項。(彙編指令實現)

《程式設計師的自我修養》筆記(二)——裝載與動態連結

  • 實際的PLT基本結構程式碼如下:
PLT0:
push *(GOT +4)
jump *(GOT+8)

...

bar@plt:
jmp *(bar@GOT)
push n
jump PLT0
複製程式碼
  • 在動態連結情況下,作業系統在裝載完可執行檔案之後會先啟動一個動態連結器,之後就將控制權交給動態連結器的入口地址。當動態連結器得到控制權之後,它開始執行一系列自身的初始化操作,然後根據當前的環境引數,開始對可執行檔案進行動態連結工作。當所有動態連結工作完成以後,動態連結器會將控制權交到可執行檔案的入口地址,程式開始正式執行。

  • 動態連結相關結構

    • “.interp”段:裡面儲存的就是一個字串,這個字串就是可執行檔案所需要的動態連結器的路徑。
    • “.dynamic”段:ELF檔案中最重要的結構,儲存了依賴於哪些共享物件、動態連結符號表的位置、動態連結重定位表的位置、共享物件初始化程式碼的地址等資訊。
    • “.dynsym”段:動態符號表,表示動態連結模組之間的符號匯入匯出關係。
    • “.rel.dyn”段:資料引用重定位,修正“.got”以及資料段。
    • “.rel.plt”段:函式引用重定位,修正“.got.plt”。
  • 動態連結基本上分為3步:先是啟動動態連結器本身(自舉,bootstrap),然後裝載所有需要的共享物件,最後是重定位和初始化。(跳轉)

  • 完成基本自舉以後,動態連結器將可執行檔案和連結器本身的符號表都合併到一個全域性符號表中。在Linux中,當一個符號需要被加入全域性符號表時,如果相同的符號名已經存在,則後加入的符號被忽略(全域性符號介入問題)。

  • 當上面的步驟完成之後,連結器開始重新遍歷可執行檔案和每個共享物件的重定位表,將它們的GOT/PLT中的每個需要重定位的位置進行修正。

Windows下的動態連結

  • 在ELF中,由於程式碼段是地址無關的,所以它可以實現多個程式之間共享一份程式碼,但是DLL的程式碼卻並不是地址無關的,所以它只是在某些情況下可以被多個程式間共享。

  • PE檔案裡有兩個常用的概念就是基地址(Base Address)相對地址(RVA,Relative Virtual Address)。基地址就是PE標頭檔案中的Image Base,是PE檔案被裝載進程式地址空間中的起始地址, 對於EXE檔案來說,其值一般是0x400000,對於DLL檔案來說,其值一般是0x10000000。而相對地址就是一個地址相對於基地址的偏移。

  • ELF預設匯出所有的全域性符號。但是在DLL中,我們需要顯式地“告訴”編譯器我們需要匯出某個符號,否則編譯器預設所有符號都不匯出。在VC++中,我們使用“__declspec(dllexport)”表示DLL匯出符號,使用“__declspec(dllimport)”表示DLL匯入符號。除了使用匯出匯入符號外,我們也可以使用“.def”檔案中的IMPORT或者EXPORTS段來宣告匯入匯出符號。這個方法不僅對C/C++有效,對其他語言也有效。

  • 使用.def檔案來描述DLL檔案匯出屬性的優點有兩個,一是可以控制匯出符號的符號名,而是可以控制一些連結的過程。

  • Windows提供3個API來支援DLL的執行時連結,分別是LoadLibrary(LoadLibraryEx):裝載DLL,GetProcAddress:獲取某個符號的地址,FreeLibrary:解除安裝DLL。

  • 在Windows PE中,所有匯出的符號被集中存放在**匯出表(Export Table)**的結構中。從最簡單的結構上來看,它提供了一個符號名與符號地址的對映關係。匯出表是一個IMAGE_EXPORT_DIRECTORY結構體,定義在“Winnt.h”中:

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;
    WORD    MajorVersion;
    WORD    MinorVersion;
    DWORD   Name;
    DWORD   Base;
    DWORD   NumberOfFunctions;
    DWORD   NumberOfNames;
    DWORD   AddressOfFunctions;     // RVA from base of image
    DWORD   AddressOfNames;         // RVA from base of image
    DWORD   AddressOfNameOrdinals;  // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
複製程式碼

《程式設計師的自我修養》筆記(二)——裝載與動態連結

  • 匯出表結構中,最後3個成員執行3個陣列,分別是匯出地址表(EAT,Export Address Table)符號名錶(Name Table)名字序號對應表(Name-Ordinal Table)

  • 匯出地址表中存放的是各個匯出函式的RVA,符號名錶中存放的是匯出函式的名字。序號表實際是早期16位windows為了應對記憶體小而使用的機制。使用序號匯入匯出的好處就是省去了函式名查詢過程,函式名錶也不需要儲存到記憶體中。但是它最大的問題就是一個函式的序號可能會變化。這就需要程式設計師手工指定每個匯出函式的序號。由於目前硬體效能的提升,這種記憶體空間的節省和查詢速度的提升效果就不明顯了。所以現在這種方式基本就不採用了,但是為了保持向後相容,它還是被保留了下來。

  • 動態連結器如何查詢函式RVA呢?假設模組A匯入了Math.dll中的Add函式,那麼A的匯入表中就儲存了“Add”這個函式名。當進行動態連結時,動態連結器在Math.dll的函式名錶中進行二分查詢,找到“Add”函式,然後在名字序號對應表中找到“Add”所對應的序號,即1,減去Math.dll的Base值1,結果為0,然後在EAT中找到下標0的元素,即“Add”的RVA為0x1000。

  • 在ELF中,“.rel.dyn”和“.rel.plt”兩個段中分別儲存了該模組所需要匯入的變數和函式的符號以及所在的模組等資訊,而“.got”和“.got.plt”則儲存著這些變數和函式的真正地址。Windows中也有類似機制,叫做匯入表(Import Table)。當某個PE檔案被載入時,Windows載入器的其中一個任務就是將所有需要匯入的函式地址確定並且將匯入表中的元素調整到正確的地址,以實現動態連結的過程。

  • 匯入表是一個IMAGE_IMPORT_DESCRIPTOR結構體陣列,每一個IMAGE_IMPORT_DESCRIPTOR結構對應一個被匯入的DLL。它也被定義在“Winnt.h”中:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;            // 0 for terminating null import descriptor
        DWORD   OriginalFirstThunk;         // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
    } DUMMYUNIONNAME;
    DWORD   TimeDateStamp;                  // 0 if not bound,
                                            // -1 if bound, and real date\time stamp
                                            //     in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
                                            // O.W. date/time stamp of DLL bound to (Old BIND)

    DWORD   ForwarderChain;                 // -1 if no forwarders
    DWORD   Name;
    DWORD   FirstThunk;                     // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;
複製程式碼
  • 結構體中的FirstThunk指向一個匯入地址陣列(IAT,Import Address Table),IAT中每個元素對應一個被匯入的符號,元素的值在不同的情況下有不同的含義。在動態連結器剛完成對映還沒有開始重定位和符號解析時,IAT中的元素值表示相對應的匯入符號的序號或者是符號名;當Windows的動態連結器在完成該模組的連結時,元素值會被動態連結器改寫成該符號的真正地址,從這一點看,匯入地址陣列與ELF中的GOT非常類似。(INT)

  • 為了使得編譯器能夠區分函式是從外部匯入的還是模組內部定義的,MSVC引入了“__declspec(dllimport)”的擴充套件屬性,一旦一個函式被宣告為“__declspec(dllimport)”,那麼編譯器就知道它是外部匯入的,以便產生相應的指令形式。比如:CALL DWORD PTR [0x0040D11C]。這裡面的IAT表元素地址0x0040D11C也是絕對地址,這也是需要後面修正的。所以可以看到PE結構中,DLL的程式碼段並非地址無關的,所以Windows系統就是大氣,根本不像Linux那麼在意程式碼段指令的重複利用。

  • 因為PE沒有類似ELF的全域性符號介入問題,所以對於模組內部的全域性函式呼叫,編譯器產生的都是直接呼叫指令CALL XXXXXXXX(不是相對地址偏移,是直接地址呼叫。這是因為Windows PE下,任何一個PE檔案在編譯時都會給出自己的一個優先裝載位置,然後根據此位置產生一系列的定位,當然這個絕對地址是需要在實際裝載執行時再重新修正的,採用了一種重定基地址的方法)。

DLL優化

  • DLL的程式碼段和資料段本身並不是地址無關的,也就是說它預設需要被裝載到由ImageBase指定的目標地址中。如果目標地址被佔用,那麼就需要裝載到其他地址,便會引起整個DLL的Rebase。這對於擁有大量DLL的程式來說,頻繁的Rebase也會造成程式啟動緩慢。這是影響DLL效能的一個原因

  • 動態連結過程中,匯入函式的符號在執行時需要被逐個解析。在這個解析過程中,免不了涉及到符號字串的比較和查詢過程,這個查詢過程中,動態連結器會在目標DLL的匯出表中進行符號字串的二分查詢。即使是使用了二分查詢法,對於擁有DLL數量很多,並且有大量匯入匯出符號的程式來說,這個過程仍然是非常耗時的。這是影響DLL效能的另一個原因

  • Windows PE採用了裝載時重定位來解決共享物件的地址衝突問題。這個重定位過程有些特殊,因為所有這些需要重定位的地方只需要加上一個固定的差值,也就是說加上一個目標裝載地址與實際裝載地址的差值。這主要得益於DLL內部的地址都是基於基地址的,或者似乎相對於基地址的RVA。所以這種重定位過程比一般的重定位要簡單,速度更快一些。PE裡把這種特殊的重定位過程叫做重定基地址(Rebasing)

  • MSVC的連結器提供了指定輸出檔案的基地址的功能。可以在連結時使用link命令中的“/BASE”引數來指定基地址。比如:link /BASE:0x100100000, 0x10000 /DLL bar.obj

  • Windows系統本身自帶很多系統的DLL,基本上Windows的應用程式執行時都要用到。Windows系統就在程式空間中專門劃出一塊0x70000000~0x80000000區域,用於對映這些常用的系統DLL。Windows在安裝時就把這塊地址分配給這些DLL,調整這些DLL的基地址使得它們互相之間不衝突,從而在裝載時就不需要進行重定基址了。

  • 每一次一個程式執行時,所有被依賴的DLL都會被裝載,並且一系列的匯入匯出符號依賴關係都會被重新解析。在大多數情況下,這些DLL都會以同樣的順序被裝載到相同的記憶體地址,所以它們的匯出符號的地址應該都是不變的,既然這些符號的地址不變,那程式主模組的匯入表應該還是和上次程式執行時相同,故而可以保留下來,這樣就可以省去每次啟動時符號解析的過程。這種方法稱為DLL繫結。

  • 在PE的匯入表中有一個和IAT一樣的陣列叫做INT就是用來儲存繫結符號的地址的。一旦檢測到INT裡面有資訊,則不需要再次進行符號重定位了,如果遇到問題(如依賴的DLL更新,DLL裝載順序打亂了和此前裝載位置不一致),導致INT中繫結符號資訊失效,則也可以依靠IAT的資訊再重來一次重定位。Windows系統中很多系統自帶程式便採用DLL繫結用以加速程式啟動。

參考文章

Windows下動態連結之二:DLL優化加速

如何理解DLL不是地址無關的?DLL與ELF的對比分析

相關文章