讀《程序員的自我修養》的自我梳理和解惑

 

基本術語

VMS——進程虛擬地址空間,PMS——物理內存空間,DSO——動態共享對象

  • 程序與文件的關係

平時我們所說的“一個程序由多個文件構成”兩種視角:程序開發階段的多個文件(源代碼文件或者庫文件),程序最終運行時狀態所需的多個文件。在此之後談到“程序由多個文件構成”表述時,均是從程序最終運行時狀態的角度來分析的

構成一個程序的文件又稱爲“模塊”,一個程序要運行必須由一個可執行文件(可執行ELF文件類型爲ET_EXEC)啓動,至於除此之外程序運行時是否需要其他模塊,需要哪些模塊,則根據不同的情況有不同的要求,只有在這個程序真正被OS進程化之後才能知道。直觀上怎麼知道呢?——OS負責將程序運行需要的模塊加載到進程虛擬地址空間(VMS),通過 cat /proc/進程ID/maps 命令可以看到當前哪些載模塊被加載,以及各個模塊在VMS的分佈視圖。

現代的程序規模不斷擴大,導致開發程序時的多模塊化分工,即不同的開發人員開發相對獨立的模塊,但是對開發出來的不同模塊如何進行組織(例如,靜態鏈接和動態鏈接),導致了程序最終的運行時狀態卻不一致

在靜態鏈接機制下,程序最終的運行時狀態就是由單獨一個ELF可執行文件構成的,通過 cat /proc/進程ID/maps 命令看到的進程實時VMS視圖中也只有可執行文件這一個磁盤上的實體文件被加載;

但是對於動態鏈接卻不一樣了。在動態鏈接機制下,程序最終的運行時狀態是由多個文件/模塊構成的。這些文件/模塊中,除了仍然必須的一個ELF可執行文件,還可能有多個動態共享對象DSO文件(即Windows下的dll動態鏈接庫文件),其ELF類型爲ET_DYN。這也可以通過cat /proc/進程ID/maps 命令在VMS實時視圖中看見。

  • ELF文件的“執行視圖”和模塊加載的誤區

首先再次明晰“模塊加載”的概念。在支持虛擬內存管理的OS平臺下,任何時候提到模塊加載均是指OS將磁盤中的文件/模塊加載到某個進程的虛擬地址空間/虛擬內存,而不用去考慮虛擬空間和物理內存空間的頁交換細節,那個工作由OS存儲管理模塊和MMU去完成的。之所以可執行文件又被稱爲“映像文件”,也是因爲它實際上是被加載到虛擬地址空間,磁盤上的該文件是其在VMS中的映像。

當僅僅談及構成程序的文件時,無論是靜態鏈接得到的可執行文件,還是動態鏈接生成的可執行文件和動態共享對象文件,都只是靜態的磁盤文件概念而已,只要程序沒有被OS進程化,這些文件都不涉及動態的加載過程,更談不上程序的執行。

而所謂ELF文件的“執行視圖”則是和ELF文件的“鏈接視圖”相對應的概念,它們都只是如何看待ELF文件邏輯劃分的方式而已,並不表示這個ELF文件已經被加載和開始執行了。今天很多時候思考問題時的一個誤區就是將ELF文件的“執行視圖”概念與模塊已經被加載混淆了。

/*注意:只有ELF可執行文件和ELF動態共享對象文件纔有segmentProgram Header的概念,也纔可以通過 readelf看到“執行視圖”,ELF可重定位目標文件是沒有“執行視圖”而只有鏈接視圖。*/

ELF可執行文件可以通過 readelf -l 文件名 命令看到其“執行視圖”,如下示例:

似乎ELF文件的各個segment都已經被分配了在VMS中的起始地址(第三列),但這個地址實際上僅僅是該文件模塊假設的被加載後的VMS地址,至於程序被進程化運行後,該模塊究竟被OS加載到VMS中的哪個區域,則是完全由OS在模塊真正被加載的時候來決定的,不同的操作系統會有不同的決定,並且模塊中其實也只有屬性爲 PHT_LOAD 的segment(圖中的前兩行)纔會被OS加載成爲VMS中的VMA。

另外,任何一個ELF可執行文件的“執行視圖”中都是沒有堆、棧的segment的,這也是區分文件和程序,區分ELF文件可執行視圖與進程地址空間的重要線索,因爲只有一個程序纔會有heap和stack的概念,一個進程的VMS中才會有heap VMA和stack VMA,而一個文件和文件的執行視圖中是沒有堆棧概念和堆segment、棧segment的。

  • 動態鏈接的基本思想

靜態鏈接機制下,一個程序最終的運行時形態就是一個可執行文件,靜態鏈接器將衆多的目標文件和諸如libc.a這樣的靜態庫文件鏈接到一起,成爲一個不可分割的整體,運行時由OS將可執行文件中的LOAD類型segment加載到VMS中。這種程序構成和運行模式的缺點主要有兩個方面:

  1. C語言靜態庫爲代表的靜態庫文件在幾乎所有的程序中都要使用,根據靜態鏈接規則,在鏈接生成一個可執行文件時,所有目標文件中的各類section都要被合成可執行文件中對應的新section,這也就意味着任何一個程序的可執行文件都完全地包含了這些靜態庫的所有內容。因爲可執行文件都是要保存在磁盤中的,對這些靜態庫的重複包含將嚴重浪費磁盤空間;同時在多進程併發執行環境下,不同進程虛擬地址空間的各個VMA都要參與虛擬空間與物理空間的頁交換,這樣就必然導致相同靜態庫的代碼和數據內容會被多次交換到物理內存空間的不同區域中,這將造成物理內存的嚴重浪費

  2. 靜態鏈接工作方式下,如果某個模塊開發人員發佈了該模塊的更新版本(以可重定位目標文件或靜態庫文件的形式),則整個程序都需要進行重新鏈接得到新的可執行文件。對於程序的用戶而言,他們手中是沒有全部目標文件和庫文件的,不可能只更新某個*.o*.a文件而自己去鏈接生成新版可執行文件,他們必須從網絡上下載程序發佈商放出來的整個可執行文件。網絡下載的速度影響將導致用戶更新的不便。

動態鏈接可以解決這些問題,其基本思想是:將程序開發得到的各個模塊,不再靜態鏈接成一個不可分割的整體磁盤文件,而是將編譯得到的各模塊目標文件單獨存放於磁盤中,然後一個或多個目標文件生成一個包含完整符號信息的DSODLL,可執行文件生成時,根據這些DSODLL提供的信息不再對未定義符號進行重定位。當程序真正需要運行的時候,才由OS將所需要的各個模塊分別從磁盤空間加載到VMS中,然後由本身也是一個程序模塊的動態連接器完成鏈接。這就是所謂的“運行時鏈接”。當其他的程序併發運行時也需要相同的某些DSO/DLL模塊,則可以與之前的進程在物理內存中實現共享。

  • 動態共享對象爲什麼加載時不能固定虛擬地址

一般地,程序運行時OS加載的第一個模塊就是ELF可執行文件,並且VMS中只會加載一個可執行文件,所以OS往往選擇VMS中一個固定的位置加載可執行文件,這也正是之前提到的ELF可執行文件“執行視圖”中各個segment的虛擬地址可以事先假定的原因。對於Linux系統,可執行文件一般都是從0x08040000開始加載,而對於Windows系統,這個地址則是0x00040000,相應的,LinuxWindows下的編譯器也會根據這一特性在可執行文件的“執行視圖”中假定虛擬地址。

但是對於DSO卻沒有這樣的待遇了。一個程序只用一個可執行文件,但是卻可能使用多個DSO,所以每個DSO都使用相同的虛擬地址是不可能的。那麼能否爲每個DSO都固定一個不同的VMS地址呢?這也是不可能的,因爲DSO不計其數,除非將所有的DSO全部不重疊地規劃到VMS中,否則不同程序在使用DSO的過程中必然會發生地址衝突。例如素不相識的DSO-1DSO-2開發者均將自己開發的DSO的加載地址固定爲0x100,那麼任意兩個程序只要同時使用DSO-1DSO-2,則VMS內就會發生DSO地址衝突。更何況DSO數目是不斷增加的,不可能在Linux3GB程序可用VMS中全部規劃在內。

所以DSO模塊的“執行視圖”中各個segment的地址都使用的不是絕對地址,而是從零開始的相對偏移,以便DSO能夠在程序運行時被OS加載到任意位置。如下圖:


  • 裝載時重定位的缺陷

要得到程序的可執行文件,需要對其中未定義符號進行重定位,如果可執行目標文件引用了DSO中定義的符號,就需要對目標文件中這樣的符號標記爲動態鏈接符號,將符號重定位工作推遲到程序開始運行,DSO被裝載到VMS後再進行——這就是所謂的“裝載時重定位”,採用裝載時重定位技術可以使得每個DSO都可以被動態加載到VMS

但是因爲DSO源程序在進行編譯時,內部代碼對本模塊和外模塊定義的符號的引用,會使得DSO模塊的 .text.bss中含有對符號(絕對地址)的引用,當某一個DSO被裝載後進行符號重定位的時候,其代碼段和數據段引用的絕對地址都要進行重定位修改——這裏尤其重要的是“DSO的代碼段也需要對絕對地址的引用進行修改動態鏈接的最大目的和優勢之一就是實現多進程的代碼指令共享,可是如果DSO-1的代碼段也需要因爲重定位而修改,並且修改值隨DSO-1和相關DSO被加載地址而定,將導致使用DSO-1的每個進程中DSO-1的代碼段被修改後都不一樣了,自然也就無法在物理內存中被多進程共享了。

在用gcc編譯得到一個共享對象的時候,如果只使用 -shared 參數,得到的DSO就會採用裝載時重定位的方法。DSO代碼段中對於未定義符號的引用一律使用的是絕對地址——即使在被裝載前可能只是假地址。

不過由於數據不需要所有的進程共享,所以對於DSO的數據段可以採用裝載時重定位,從而每個使用該DSO的進程在物理內存中都擁有可能各不相同的DSO數據段副本。

  • GCC-fPIC 參數與地址無關代碼

要實現上述的DSO指令共享,必須將DSO指令segment中使用絕對地址的指令進行修改,使之成爲“地址無關代碼(PIC)”,gcc支持PIC技術,在編譯源程序的時候使用 -fPIC 參數就可以得到這樣的地址無關代碼。

DSO的指令segment中使用絕對地址的情況包括四類:對模塊內定義的函數的調用,對模塊內定義的全局變量和靜態變量的訪問,對外模塊定義的函數的調用,對外模塊定義的全局變量的訪問。因此PIC技術也就是對這四種情況的指令進行修正。

  1. 對於第一類:因爲源代碼在被編譯完成之後,各個函數的相對位置就已經固定下來,並且在彙編語言級函數調用所用的jmp或call指令都使用的是相對於當前PC寄存器值的相對跳轉,故而即使是DSO被加載的VMS位置不確定,DSO代碼段中的這種調用情況對應的指令也是不用修改的。

  2. 對於第二類:需要分成兩種情況,對於本模塊定義的靜態變量(無論是局部的還是全局的),因爲同一個模塊源代碼編譯得到的DSO文件中,代碼segment和數據segment的相對位置也是固定的,相應的代碼VMA和數據VMA在VMS中的相對位置也是固定的——只不過由於跨segment時存在VMA的二次映射,計算相對偏移量時需要加上一個虛擬頁的長度,因爲彙編指令集在進行數據訪問時,沒有類似於call這樣的相對於當前PC寄存器值的相對跳轉指令,所以ELF文件中使用了特殊的
    call <__i686.get_pc_thunk.cx> 方式,其中__i686.get_pc_thunk.cx作爲一個編譯器內建的函數,其在代碼段中的相對位置自然也是固定的。因爲在調用call指令之前,下一條指令的地址將會被入棧,而棧頂則是由%esp寄存器保存的,所以這個函數中只做一件事情,就是將esp寄存器的值保存到%ecx寄存器中,這樣下一條指令的地址就保存在%ecx寄存器中,然後隨後利用%ecx通過寄存器相對尋址的方法就可以訪問同模塊內的靜態變量了。

對於本模塊內定義的非靜態全局變量(例如global)則有所不同了,因爲該變量可能會被其他模塊所引用,至於引用改變量的模塊究竟是可執行文件,還是其他的DSO是無法事先得知的。如果global是被其他的DSO所引用比較簡單,按照下述的第四類情況進行跨模塊數據訪問處理即可;但是如果global是被可執行模塊所引用則必須考慮額外的情況了。

可執行文件的代碼段可能並沒有使用PIC技術而是直接使用絕對地址,這時global的地址必須在鏈接時就確定,所以鏈接器將會在.bss段中創建一個global副本,這樣當原本定義global的DSO被加載之後,DSO數據段中也會有一個global的副本——這是不允許的!解決辦法之有一個,那就是DSO在編譯時,中對global進行訪問的所有指令,均採用下述第三類“對外模塊中定義的全局變量進行訪問”的方式。

  1. 對於第三類:ELF文件的做法是,gcc編譯器在生成一個DSO時,額外生成一個GOT表(Global Offset Table),實際就是一個指針數組,每個指針指向一個外部模塊中定義的全局變量。在ELF鏈接視圖中,GOT是以一個 .got section 形式存在;而在ELF執行視圖中,GOT存在於數據segment中。對於每一個使用該DSO的進程而言,共享DSO的代碼segment而各自擁有DSO的數據segment副本,所以GOT的內容是在DSO被加載後來確定的。使用objdump -d DSO文件名 命令可見 .got section 中內容全部是0。

與前述第二類“本模塊靜態變量與代碼段相對地址保持固定”類似,GOT也與代碼段的相對地址是固定的,所以同樣可以通過 call <__i686.get_pc_thunk.cx> 方式先找到GOT的位置,然後在GOT中找到需要的全局變量的地址。

  1. 對於第四類:類似於第三類,使用GOT來間接訪問。

/*注意:由上述第二類中關於DSO中定義的非靜態全局變量的基本原理,如果一個共享對象lib.so中定義全局變量global,而進程A和進程B分別都使用了lib.so,因爲所有進程共享lib.so的代碼段,獨立擁有lib.so的數據段副本,所以即使AB都修改了global的值,它們也是互不影響的,這樣說來global與進程AB可執行模塊內定義的全局變量沒有什麼區別;

然而當AB是同一進程的兩個線程時,情況就不同了,因爲線程AB共享同一個VMS,自然也就共享VMS中包含global的那個DSO數據段副本,任何一個線程修改global,對另外一方都是可見的。

當然在實際應用中,存在“多個進程共享一個全局變量”,以及“多個線程訪問全局變量的不同副本”的需求。*/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章