1. 程式人生 > >計算機科學基礎知識(三)靜態庫和靜態鏈接

計算機科學基礎知識(三)靜態庫和靜態鏈接

很多 靜態鏈接 hello def 位置 每一個 需求 缺省 屬性

三、將relocatable object file靜態鏈接成可執行文件

將relocatable object file鏈接成可執行文件分成兩步,第一步是符號分析(symbol resolution),第二步是符號重新定位(Relocation)。本章主要描述這兩個過程,為了完整性,靜態庫的概念也會在本章提及。

1、為什麽會提出靜態庫的概念?

程序邏輯有共同的需求,例如數學庫、字符串庫等,如果每個程序員在撰寫這些代碼邏輯的時候都需要自己重新寫那麽該是多麽麻煩的事情,而且容易出錯,如果有現成的,何必自己制作輪子呢?因此,靜態庫的概念被提出來。靜態庫有下面的幾種方案:

(1)發行的編譯器帶一些標準函數的source code
(2)發行的編譯器帶一個大的.o文件
(3)發行的編譯器在某個目錄下放置若幹.o文件
(4)使用靜態庫

如果可以閱讀到標準函數的source code,對於那些喜歡刨根問底的程序員而言是一種幸福,不過這種方案的缺陷是標準c的庫函數太多,這樣整起來compiler太復雜,而且庫和compiler捆綁的太緊了,每次修正一個庫的issue都需要重新release一個新的編譯器。把所有的庫函數變成一個大的.o文件可以解除庫和compiler耦合太緊的問題,程序員總是可以把自己的程序和這個.o文件進行鏈接,從而屏蔽了庫函數的底層細節。順便說一下,使用linker可以將若幹的.o文件合並成一個.o文件,命令如下:

arm-linux-ld –r -o result.o a.o b.o c.o……

命令行中的r表示鏈接的結果是一個relocatable file而不是一個executable file。當然,這種方法的缺陷是生成的目標文件太大(目前考慮靜態鏈接),編譯的的可執行文件中包括了.o中的所有內容,無論用到或者用不到。此外,由於所有的庫函數都在一個.o文件中,任何一點改動都需要重新編譯生成那個大的.o文件。針對這樣的缺陷,我們可以考慮把一個大的.o文件分散成一個一個小的.o文件,並保存在一個特定的目錄下。這樣就解決了浪費磁盤空間的issue,只不過每次的編譯都比較復雜,程序員需要知道自己調用了哪些庫函數,涉及哪些.o的文件,並把這些.o文件作為輸入文件傳遞給gcc。這樣的使用對程序員而言是非常不方便的。最終的解決方案就是靜態庫,我們可以通過下面的命令來生成靜態庫:

arm-linux-ar rcs libtest.a a.o b.o c.o……

2、Linker解析符號的順序

在生成可執行文件的時候,傳遞給linker的參數大概如下:

arm-linux-ld [all kinds of parameter] -o result a.o b.o c.o……aa.a bb.a cc.a……

也就是說,linker可以把一個個的.o文件(靜態庫本身也是.o文件的集合,只不過linker可以根據符號引用情況,智能的從.a文件中抽取它需要的.o文件)組合形成一個可執行文件。Linker掃描.o文件的順序是從左到右,Scan的過程中維護三個集合
(1)集合E:E中包含了所有要merge成可執行文件的.o文件
(2)集合U:U中包含了所有的未定義的符號
(3)集合D:D中包含了已經定義的符號
符號解析(Symbol resolution)過程是這樣的:初始態:E U D都是空集,掃描過程中,如果是.o文件,那麽就將該.o文件加入E,並分析該.o文件的符號表,把該.o定義的符號信息假如到集合D中,該.o文件中未定義的符號會在集合D中scan,看看是否已經定義,如果以及定義,那麽OK,符號已經解析了,如果在目前的集合D中沒有定義該符號,那麽就把該符號假如到集合U中。如果是庫文件,那麽linker會試圖為當前的U集合中未定義的每一個符號找到歸宿。對於U中的每一個符號,linker會遍歷庫文件中的各個.o文件,找到對應的,定義了集合U中符號的那個.o文件,如果找到,那麽就將該.o文件加入E,並分析該.o文件的符號表,更新U和D。這個過程會反復叠代執行,直到U和D固定下來。這樣的動作可以是的那些包含在.a文件中的、未涉及的.o文件被丟棄,不會加到集合E中。當掃描到最後一個.o的文件的時候,如果集合U中仍然是非空集合,那麽linker就會報undefined reference的錯誤。

了解linker這些行為有助於解決鏈接過程的issue。假設命令行參數如下:

arm-linux-ld -static -o result libaa.a a.o

如果libaa庫中定義了一個ahead的函數,並且在a.o中引用,看起來符號是定義了,但是由於linker的掃描順序,實際上上面的linker會undefined reference to ahead的錯誤,當然,修正也很簡單,把libaa放到a.o的後面就OK了,因此,在實際中,我們總是把.o文件放到前面,把庫文件放到最後。

3、編譯hello world

我們回到最原始的hello world程序,看看靜態編譯的過程。源代碼我就不上了,大家自行想像。為了更清楚的看到鏈接過程,我們在命令行參數中增加-v的參數,如下:

arm-linux-gcc -v -static -o hello main.c

在控制臺屏幕上跳動的是整個編譯、鏈接的詳細過程,當然,我們這裏只關註link的過程,因此重點看看gcc是如何調用linker的,是傳遞了什麽樣的參數給linker:

/opt/arm-compiler/bin/../libexec/gcc/arm-none-linux-gnueabi/4.2.0/collect2 --sysroot=/opt/arm-compiler/bin/../arm-none-linux-gnueabi/libc -Bstatic -dynamic-linker /lib/ld-linux.so.3 -X -m armelf_linux_eabi -o hello /opt/arm-compiler/bin/../arm-none-linux-gnueabi/libc/usr/lib/crt1.o /opt/arm-compiler/bin/../arm-none-linux-gnueabi/libc/usr/lib/crti.o /opt/arm-compiler/bin/../lib/gcc/arm-none-linux-gnueabi/4.2.0/crtbeginT.o -L/opt/arm-compiler/bin/../lib/gcc/arm-none-linux-gnueabi/4.2.0 -L/opt/arm-compiler/bin/../lib/gcc -L/opt/arm-compiler/bin/../lib/gcc/arm-none-linux-gnueabi/4.2.0/../../../../arm-none-linux-gnueabi/lib -L/opt/arm-compiler/bin/../arm-none-linux-gnueabi/libc/lib -L/opt/arm-compiler/bin/../arm-none-linux-gnueabi/libc/usr/lib /var/tmp/ccg9aX7V.o --start-group -lgcc -lgcc_eh -lc --end-group /opt/arm-compiler/bin/../lib/gcc/arm-none-linux-gnueabi/4.2.0/crtend.o /opt/arm-compiler/bin/../arm-none-linux-gnueabi/libc/usr/lib/crtn.o

實際調用的不是ld,而是collect2,不過沒有關系,它只不過是ld的馬甲,都素一樣一樣D。--sysroot用來指定系統目錄,這裏替代了configure-time的缺省設置。-Bstatic表示創建靜態鏈接的可執行文件,不要和動態庫發生糾葛。-dynamic-linker就象其名字說明的,用來指明該可執行程序使用哪一個dynamic linker,當然,在我們這個場景下,設定dynamic linker沒有意義。-X表示刪除編譯器生成的一些臨時符號(有.L前綴)。-m用來指明linker處於哪一種emulation mode。我使用的linker支持armelf_linux_eabi和armelfb_linux_eabi兩種mode,分別用來支持little endian的ARM和big endian的ARM。不同的emulation mode使用不同的鏈接腳本(可以參考arm-none-linux-gnueabi/lib/ldscripts目錄下的內容),linker會根據gcc傳遞來的參數(靜態編譯還是動態編譯、編譯可執行文件還是編譯動態庫)以及emulation mode來選擇合適的鏈接腳本(如果沒有顯示的使用-T來指定鏈接腳本的話)。當然,emulation mode還有一些其他的應用場景,我們這裏淺嘗輒止吧。

--start-group archives --end-group的參數是和linker如何掃描靜態庫相關,所有用--start-group和--end-group包含的靜態庫(-lgcc -lgcc_eh -lc)都需要特別對待。一般而言,linker對每個靜態庫都只是掃描一遍,不過如果用--start-group和--end-group包圍起來的庫則是重復性的掃描,直到沒有新的未定義的符號被創建。c庫是在意料之中的庫,gcc庫和gcc_eh庫是什麽東東呢?libgcc庫提供了一些底層的runtime庫函數,可以使用ar命令和nm命令觀察靜態庫文件:

arm-linux-ar –t libgcc.a -----該命令可以看看libgcc.a中有多少個.o文件

arm-linux-nm –a libgcc.a -----該命令可以看看libgcc.a中定義的符號情況

libgcc庫中多半是一些數學運算相關的函數,而這些函數在目標處理器上不能直接執行(沒有對應的匯編指令),因此,在程序編譯的時候,gcc編譯器可以直接使用libgcc庫中的代碼來完成這些數學運算。例如:ARM處理器不支持除法,如果你的c代碼中出現了整數的除法,實際上,compiler是無法使用div這樣的指令來翻譯c代碼的,這種情況下,就需要libgcc庫了,你可以自己寫一段包括整數的除法的c代碼,然後用objdump反匯編看看,在實際的匯編指令中,除法實際上是使用了__aeabi_idiv這個函數(位於libgcc庫中的_divsi3.o模塊中)。我們再來看libgcc_eh.a這個庫,eh的含義是exception handler(我猜的),應該是和異常處理相關的。這裏我也了解不多,暫且略過。

除了真正的mian.c對應的/var/tmp/ccg9aX7V.o文件,其他還有幾個有趣的.o文件:crt1.o crti.o crtbeginT.o crtend.o crtn.o(crt就是c runtime的意思)。有些系統使用crt0.o,有些使用crt1.o,當然也有使用更高number的系統,類似crt2.o什麽的。一般來說,程序員接觸到的程序入口都是main函數(有些使用更高級工具的程序員可能連main函數都看不到),但是實際上在main函數之前還有一段程序setup環境的過程,而且這段bootstrap的代碼是所有的程序共用的,因此被整理成了crt*.o的文件,在鏈接的時候,和所有的具體程序相關的object文件link在一起,形成最後的image。如果不這樣做,勢必每一個程序員寫程序都需要處理一些相同的內容,這不符合軟件工程師不應該制作新輪子的原則。當然,雖然我們不制作新輪子,但是一定要理解輪子的機制。我們先反匯編看看crt1.o的內容:

00000000 <_start>:
0: e59fc024 ldr ip, [pc, #36] ; 2c <.text+0x2c>
4: e3a0b000 mov fp, #0 ; 0x0 --------最外層函數,清除frame pointer
8: e49d1004 ldr r1, [sp], #4 ----------r1 = argc, sp=sp+4,sp指向了argv[]
c: e1a0200d mov r2, sp -----------r2指向了argv[]
10: e52d2004 str r2, [sp, #-4]! --------這時候r2就是棧底,將stack end參數壓入棧
14: e52d0004 str r0, [sp, #-4]! -------將內核傳遞的r0參數壓入_start的棧
18: e59f0010 ldr r0, [pc, #16] ; 30 <.text+0x30> ---r0保存了main函數指針
1c: e59f3010 ldr r3, [pc, #16] ; 34 <.text+0x34> ---r3保存了__libc_csu_init
20: e52dc004 str ip, [sp, #-4]! -------將__libc_csu_init壓入棧
24: ebfffffe bl 0 <__libc_start_main> ----將控制權交給c lib
28: ebfffffe bl 0

2C .word __libc_csu_fini 註:原始的dump文件不是這樣的,我稍加修改
30 .word main
34 .word __libc_csu_init

首先映入眼簾的就是_start函數,沒有錯,_start函數才是這個靜態編譯程序的入口,然後歷經千山萬水,最後會調用main函數。站在應用程序的大門口,進入一個新的世界之前,有一個問題很關鍵:我是如何來到這裏的?或者說內核做了些什麽事情才讓cpu跳轉到_start執行?在那一點上,CPU的寄存器為何?這時候該程序的用戶棧的狀態為何?……太多太多的問題,只有讓內核代碼來回答了。當我們在terminal中執行hello這個靜態鏈接的可執行程序的時候,shell進程會首先fork一個進程,然後調用exec系統調用進入內核,將新建進程的映像替換成hello。對於elf文件,內核會進入load_elf_binary函數,而具體和該進程用戶棧上內容相關的是create_elf_tables函數,具體代碼我們就不過了,但是這時候的用戶棧的狀態如下:

技術分享圖片

如果認為_start也是一個函數,那麽它其實是一個特殊的函數,首先它是用戶空間最外層的一個函數,因此需要將fp清零,在棧的回溯的過程中,當遇到fp等於0的時候,debugger就知道已經到了最外層的函數了。內核傳遞給_start函數的參數是通過某些寄存器傳遞的(例如X86平臺,edx指向DT_FINI函數),對於ARM平臺,規範規定通過r0來傳遞一個handler 函數,在atexit的時候執行該handler了,在3.14版本的內核中並沒有這麽做,這時候的r0等於NULL,具體參考ELF_PLAT_INIT這個內核宏定義。sp寄存器必須被內核正確的設定,並且在sp指向的用戶棧上面保存argc、argv等數據。因此,內核轉到userspace的關卡上,PC,SP,R0這三個寄存器被特別設定,其他的寄存器沒有特別的規定。

OK,了解這些內核知識後,看_start的反匯編代碼就比較輕松了,代碼註釋已經提供了,這裏不再贅述,其本質就是設定傳遞給__libc_start_main函數的參數,我們可以認為該函數的調用c代碼如下:

int __libc_start_main( int (*main) (int, char **, char ** ), ----通過r0傳遞
int argc, char **argv,----------通過r1 r2傳遞
__typeof (main) init,-----又一個函數指針,類型和main一樣,用r3傳遞
void (*fini) (void),
void (*rtld_fini) (void),
void *stack_end )------上面三個參數通過stack傳遞

在這個主題上,我不想再深入下去了,讀者有興趣的話可以自行反匯編其他的.o文件,每個都有自己特定的用途,但無論如何,進入main函數之前,內核、編譯器和c庫已經完美的準備好了一切。

4、鏈接腳本

和compiler相比,linker的工作還是比較簡單的。就是按照鏈接腳本進行各個.o文件的各個section的同類項合並,具體如何合並就要看link script file怎麽規定了。相信有些同學會問:我編譯hello world這樣程序也沒有使用鏈接腳本啊。雖然你可以直接使用簡單的gcc命令來編譯hello world程序,不過除了源代碼之外,還有一個default的link script是幕後英雄。使用下面的命令可以看到這個link script:

arm-linux-ld --verbose

鏈接腳本是一個很復雜的東西,你可以在linker的user manual中找到詳細的解釋,這裏我們只給出一些基本概念性的東西,讓大家有個了解就OK了:

OUTPUT_FORMAT("elf32-littlearm", "elf32-bigarm", "elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)
SEARCH_DIR("=/usr/local/lib"); SEARCH_DIR("=/lib"); SEARCH_DIR("=/usr/lib");

缺省鏈接腳本的開始有一些內容如上,OUTPUT_FORMAT定義了linker輸出文件的格式。OUTPUT_FORMAT的語法是:OUTPUT_FORMAT(default, big, little),如果沒有command line中的-EL或者-EB參數傳遞給linker,那麽linker就選擇default的格式,如果command line傳入-EL參數,那麽選擇little,如果傳入-EB,那麽就選擇big。在上面的鏈接命令行中沒有傳遞-EL或者-EB參數,因此,linker輸出的是default,也就是elf32-littlearm的輸出文件格式。OUTPUT_ARCH指明了輸出的文件是for哪一個平臺的。ENTRY指明了程序入口點的,和我們上面分析的一樣,_start就是程序的入口點,這個鏈接腳本命令和命令行中的-e參數含義是一樣的。SEARCH_DIR命令就像它的名字說明的一樣,當linker搜索lib的時候,可以在SEARCH_DIR定義的路徑中尋找,這個鏈接腳本命令和命令行中的-L path參數含義是一樣的。根據鏈接腳本的定義,實際上linker總是會在/usr/local/lib,/usr/lib和/lib目錄下尋找庫文件。很明顯,從這裏就可以看出GNU/linux系統對用戶態的lib是有進行grouping的:那些重要的、關鍵的操作系統相關的lib被放到/lib目錄下(例如c庫)。一旦有一個usr的前綴,其重要性就比較低了,至少不是系統級別,應該是和普通應用程序相關的庫。普通的應用程序也有兩種,一種是大家(所有登錄用戶)都使用的,另外一種是特定用戶使用的。因此,GNU/linux將那些和多個用戶相關的library放入/usr/lib中。如果你自己編寫了一個lib,最優雅的方式是放入到/usr/local/lib目錄下,這樣才不會打攪到別人。

占據link script file大部分內容的是SECTIONS這個command,這個命令的作用就是告訴linker如何把輸入的.o文件中的section映射到輸出文件的section中,並且是如何和把這些section放到memory中的,我們摘取一個片段:

SECTIONS
{
PROVIDE (__executable_start = 0x00008000); . = 0x00008000 + SIZEOF_HEADERS;
.interp : { *(.interp) }
.hash : { *(.hash) }
……
.text :
{
*(.text .stub .text.* .gnu.linkonce.t.*)
KEEP (*(.text.*personality*))
*(.gnu.warning)
*(.glue_7t) *(.glue_7) *(.vfp11_veneer)
} =0
……
}

在鏈接腳本裏面可以定義符號,並且該符號被放入到了符號表中(註意:link script中定義的符號是global的),可以被c代碼訪問。因此,上面腳本中的__executable_start = 0x00008000其實就是在符號表中定義了一個符號。PROVIDE的意思是:如果.o文件中定義了同名的符號,那麽該符號的定義將被取代,如果.o文件中沒有定義該符號,那麽就使用鏈接腳本中的定義。在c代碼中訪問鏈接腳本中定義的符號沒有那麽直觀,我們給出一個例子:

#include

extern int __executable_start;

int main(int argc, char ** argv)
{

printf("__executable_start=%p\n", &__executable_start);

return 0;
}

編譯之後該程序執行的結果可以看到__executable_start等於0x00008000。如果在X86的PC上運行,__executable_start等於0x08048000。在c代碼中,打印__executable_start的地址(&__executable_start)才可以輸出正確的0x00008000信息。為什麽呢?在c代碼中,我們定義一個符號int xyz = 0x1234,實際上是做了兩件事:

(1)分配一個memory的空間來保存xyz的值

(2)在符號表中保存了xyz這個符號的地址

當在程序中修改xyz這個符號的值的時候,需要通過符號表獲取到xyz這個符號的地址:address_of_xyz,通過符號表中address_of_xyz可以訪問xyz變量並修改其值。c代碼中如此,但是鏈接腳本中定義一個符號並非如此,鏈接腳本中的符號不會分配memory,而僅僅是在符號表中有一個entry,該entry表示有一個符號是__executable_start,其地址是0x00008000。因此,在c程序中,直接訪問__executable_start得到的是0x00008000地址上memory中的value,訪問&__executable_start可以獲取該符號的地址,也就是0x00008000了。

‘.’是一個特殊的linker變量,它總是保存在當前output的memory address。. = 0x00008000 + SIZEOF_HEADERS就是把當前的輸出文件的section的地址調整為0x00008000加上ELF文件header size的一個位置上去。由此可見,輸出文件的.interp section的首地址就是0x00008000 + SIZEOF_HEADERS。‘:’前面是輸出文件的section,之後用{}包圍起來的是輸入文件section描述。輸入文件section描述部分的格式是:

object file ( section a section b section c ……)

可以使用通配符*(.text)表示所有.o文件中的.text section。具體細節這裏不再描述,通過鏈接腳本中的section命令可以將輸入的若幹個relocatable object file的各個section輸出到目標文件(可能是動態庫,也可能是executable file)指定的section,並分配適合的runtime address。

5、符號解析

通俗的講,符號解析就是確定引用符號和定義符號的對應關系的過程。我們知道,各個.o文件中都會定義一些符號,也會引用一些符號,那麽將若幹個.o link成一個可執行文件的時候,我們需要把各個.o文件中引用的符號找到一個確定的位置,.o文件中的引用符號最好可以找到唯一一個.o文件中定義的符號,一一對應比較好處理,如果沒有定義也簡單,linker報錯就可以了,當一個引用的符號有多個.o文件定義的時候會怎麽樣?這是本節主要的內容。

計算機科學是一門實踐的科學,我們還是需要動手寫一些非常簡單的程序來理解符號,具體如下:

main.c hello.c

int foo = 0x1234;
int main(int argc, char ** argv)
{
hello_world();
return 0;
}

#include
int foo;
int hello_world()
{
printf("foo is:0x%x\n", foo);
return 0;
}

雖然定義了同樣的符號,不過使用gcc main.c hello.c進行編譯是不會報錯的,hello.c中的foo符號放入common block而main.c文件中的foo放入data section,linker采納了main.c中的定義的那個符號來對應hello_world中的對foo的引用。當然,如果你試圖用gcc –fno-common main.c hello.c進行編譯,linker會報multiple definition of `foo‘的link error。如果在hello.c和main.c中都不初始化foo,那麽linker也不會報錯,隨便選擇一個就OK了。

還有一種比較容易引起錯誤的符號解析是函數符號。我們可以編譯相同名字的函數在多個動態庫中,假設這個函數名是foo,當然,你自己的文件中也可以定義同名的函數符號foo,這時候,當程序調用foo函數的時候,linker到底選擇哪一個函數呢?這個留給大家自己做實驗吧。

6、了解靜態鏈接後的可執行文件

本節我們將深入觀察靜態編譯鏈接後的hello world程序。首先看看源代碼:

#include
int main(int argc, char ** argv)
{
printf("hello, world!\n");
return 0;
}

編譯後(arm-linux-gcc -static -o hello main.c)使用bin utilities來觀察結果。我們首先看看ELF Header:

ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2‘s complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)---類型是可執行文件
Machine: ARM
Version: 0x1
Entry point address: 0x8130--------入口地址
Start of program headers: 52 (bytes into file)
Start of section headers: 482924 (bytes into file)
Flags: 0x4000002, has entry point, Version4 EABI
Size of this header: 52 (bytes)
Size of program headers: 32 (bytes)
Number of program headers: 6----------有六個program header
Size of section headers: 40 (bytes)
Number of section headers: 28
Section header string table index: 25

和.o文件不同的是程序入口地址有了具體的賦值,0x8130是什麽呢?我們可以從dump的結果看到:這個地址就是_start符號(還記得cr1.o嗎?)。另外,可執行文件比.o文件多了program header的內容。program header是用來告知OS如何創建進程映像的。既然牽扯到了進程映像,那麽program header一定要提供進程地址空間的信息,用內核的語言描述這個需求就是:把ELF文件,從某個文件偏移處(offset)開始的指定大小(file size)映射到進程地址空間(virtual address或者physical address)開始的指定大小(memory size)去,當然還要包括type flag 對齊屬性什麽的,這些信息基本就勾勒了一個program header的data structure,具體可以參考內核中Elf32_Phdr和Elf64_Phdr的定義。我們來看看hello world的program header的組成:

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
EXIDX 0x06e600 0x00076600 0x00076600 0x006d0 0x006d0 R 0x4
LOAD 0x000000 0x00008000 0x00008000 0x6ed50 0x6ed50 R E 0x8000
LOAD 0x06f000 0x0007f000 0x0007f000 0x007ac 0x01ffc RW 0x8000
NOTE 0x0000f4 0x000080f4 0x000080f4 0x00020 0x00020 R 0x4
TLS 0x06f000 0x0007f000 0x0007f000 0x00010 0x00028 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4

Type中的LOAD表示該program header描述了一個loadable segment。segment是程序加載相關的術語,加載其實就是讓一個保存在磁盤的程序變成process image,用通俗的語言就是將磁盤文件中的一個個的loadable segemnt copy到內存,然後執行(當然實際中linux不是這樣做的,linux內核如何做超出本文的主題)。我們先看第一個loadable segment(後文稱之code segment,當然,並不是說該segment全部是可執行代碼,只是一種代稱),從文件偏移0處,將0x6ed50大小的內容mapping到虛擬地址從0x00008000開始的地址,memory中的size和文件size一樣。後面的R(readonly)和E(executable)這兩個flag已經基本出賣了這個segment,相信因該是和程序代碼相關的。第二個loadable segment(後文稱之data segment)是從文件偏移0x06f000開始,將0x007ac大小的內容mapping到虛擬地址從0x0007f000開始的地址,內存中的segment size是0x01ffc。當memory size大於file size的時候,"多余"那部分的memory被初始化成0。這個segment是可讀寫的,因此應該是程序需要訪問的數據區域。

根據上面的信息,我們可以大概描述這兩個loadable segment,code segment如下:

技術分享圖片

kernel在加載這個segment的時候很簡單,就是把executable file映射到進程地址空間的0x8000開始,長度是0x6ed50的虛擬地址空間段就OK了。這個segment除了ELF header和program header之外,還包括.note.ABI-tag .init .text __libc_freeres_fn .fini .rodata __libc_subfreeres __libc_atexit .ARM.extab .ARM.exidx .eh_frame這些section。當然,這些section無論是在可執行文件中還是虛擬地址空間中都是連續的。

.note.ABI-tag section描述了操作系統信息,該section dump的結果如下:

000080f4 <.note.ABI-tag>:
80f4: 00000004 .word 0x00000004 ------vendor name的長度
80f8: 00000010 .word 0x00000010 ------data的長度
80fc: 00000001 .word 0x00000001 ------note type
8100: 00554e47 .word 0x00554e47------vendor name,包含GNU 這三個字符
8104: 00000000 .word 0x00000000------0表示是linux下的可執行程序
8108: 00000002 .word 0x00000002------下面三個字節是linux的版本2.6.14
810c: 00000006 .word 0x00000006
8110: 0000000e .word 0x0000000e

對於有些編程語言,編譯器需要提供constructor,所謂constructor就是一些特殊的初始化函數,這些函數在進入main函數之前已經調用完成。例如C++中,class的構建函數就是這樣的函數。.init section就是為了解決這種需求的。對於c程序,.init比較簡單,如下:

00008114 <_init>:
8114: e52de004 str lr, [sp, #-4]!-------將返回地址壓棧
8118: e24dd004 sub sp, sp, #4 ; 0x4
811c: eb000011 bl 8168 ---enable profiling
8120: e28dd004 add sp, sp, #4 ; 0x4
8124: e8bd8000 ldmia sp!, {pc}-------恢復調用現場

剩下的section我們就不一一介紹了,大家可以自己看看反匯編分析。我們這裏討論一個有意思的問題:為何ARM target上的程序的mapping到了0x8000的地址上?為何loadable segment的對齊是0x8000呢?不把入口地址mapping到0地址是可以理解的,因為我們的程序需要捕獲NULL指針的訪問異常,因此對於用戶進程的地址空間,必須把0開始的那個page保留下來,不建立頁表。這樣,當用戶程序由於錯誤導致空指針的訪問的時候,硬件可以產生異常,kernel可以發送信號給這個進程,你就可以看到segment fault的錯誤了。那麽起始地址為何在0x8000呢?也就是說前面空閑了32kB的虛擬地址空間。一般而言,我們在前面空出一個page就OK了,因此用戶程序的起始地址也就是和硬件相關起來,因為需要了解該CPU的MMU的硬件特性,其支持的虛擬地址到物理地址的映射的size限制。對於ARM,我們可以以4k為單位進行mapping,當然也可以用更大的單位,我們假設最大的page映射單位是MAXPAGESIZE,那麽起始地址就是和MAXPAGESIZE這個最大的page映射單位的宏定義相關了。同樣的,loadable segement的對齊size也是和page size相關的,有興趣的話可以看一看鏈接腳本。對於linker,MAXPAGESIZE值可以build in(我估計對於我用的編譯器,這個宏定義被設定為32K,因此首地址是0x8000)。當然,你也可以通過-z max-page-size=value來修改。我們交叉編譯器不支持這個關鍵字,不過可以使用x86上的gcc來觀察結果,通過這個選項,你可以修改起始地址和loadable segement的對齊size。

OK,根據上面的知識,我們可以計算code segment的起始地址。0x8000 + 0x6ed50 = 0x76d50,如果align到0x8000上,那麽code segment起始地址應該是0x78000,不過有點遺憾,實際上是0x7f000,為何?我們需要看看鏈接腳本:

……
. = ALIGN (0x8000) - ((0x8000 - .) & (0x8000 - 1)); . = DATA_SEGMENT_ALIGN (0x8000, 0x1000);
……

. = DATA_SEGMENT_END (.);

在DATA_SEGMENT_ALIGN(maxpagesize, commonpagesize)和DATA_SEGMENT_END (.)之間就是data segment。DATA_SEGMENT_ALIGN 可以定義data segment的對齊方式。maxpagesize是該CPU支持的最大的page size,而commonpagesize則是一般情況下使用的那個page size。為何有max page size,又有common page size呢?為何要搞這麽復雜?難道就是為了讓廣大人民群眾望而卻步嗎?當然不是,一般而言,處理器的MMU不會設定一種page size,這主要和應用場景相關,對於大段的code segment,使用小一些的page size會比較浪費內存(要建立很多頁表),這時候,系統多半使用較大的page size(甚至是max page size)。而對於data segment,其size沒有那麽大,使用max page size反而會浪費內存:雖然頁表項少些,但是一個1k的data segment需要一個32k(假設max page size是0x8000)的物理內存與之對應,寶貴的內存資源豈能如此浪費。對於我使用的交叉編譯器平臺,這裏max page size是0x8000,即32KB,common page size是0x1000,就是大家都比較習慣的4KB了。

在data segment使用common page size進行映射的時候(當data segment非常大的時候,也可以考慮使用max page size),DATA_SEGMENT_ALIGN(maxpagesize, commonpagesize)計算如下:

(ALIGN(maxpagesize) + (. & (maxpagesize - commonpagesize)))

上面的運算把當前的location counter設定為0x78000 + 0x6000 = 0x7f000。data segment的示意圖如下:

技術分享圖片

kernel在加載這個segment的時候采用和code segment類似的手段,就是把executable file從0x6f000偏移開始的文件內容映射到進程地址空間的0x7f000開始,長度是0x01ffc的虛擬地址空間段就OK了。和code segment不同的是memory中的data segment要大一些,這些區域由於沒有executable file中的內容與之對應,因此會被os設定為0。這個data segment包括.tdata .init_array .fini_array .jcr .data.rel.ro .got .data .bss __libc_freeres_ptrs這些section。當然,這些section無論是在可執行文件中還是虛擬地址空間中都是連續的。

.data和.bss相信大家都非常的熟悉了,這裏不再贅述,我們挑選一兩個data segment中的典型section來描述。我們來看看.tdata這個section。要想講清楚這個section需要首先搞清楚什麽是thread local storage(TLS)。在進行多線程編程的時候,我們知道,臨時變量都是thread-specific的,而全局變量都是所有thread共享的,對其訪問要有適合的鎖的機制,以便控制multi thread的並發。但是這樣的數據模型不能總是滿足用戶需求,有的時候,程序需要這樣的一種數據模型,該數據是全局的(或者是static的),但是這種數據又不在多個線程中共享,每個thread訪問的都是自己特定的副本,這種thread-specific的數據方法我們稱之TLS。

對於linux環境,thread-local類型的數據並不是放入大家耳熟能詳的.data或者.bss section,而是放入了.tdata和.tbss section。和.data或者.bss不同,執行中的程序不能直接訪問.tdata和.tbss,這段section中數據更像是一個initial image,每個線程在創建的時候都會以.tdata或者.tbss為藍本,創建自己thread-specific的數據,後續變量的訪問都是針對自己thread local的數據區域。

了解了這些基礎知識後,問題來了:我就寫一個簡單的hello world,又沒有創建什麽thread local的數據,為何還有.tdata和.tbss section呢?實際上雖然你的程序沒有訪問,但是c庫中有訪問,例如errno。在沒有multi thread programming之前,errno是一個全局變量,然而,進入多線程編程環境之後,errno必須是thread local的才能不影響其接口形態。

計算機科學基礎知識(三)靜態庫和靜態鏈接