ELF檔案格式與程式的編譯連結
說明:本文的討論基於一個執行linux的x86系統環境,使用標準ELF檔案格式。討論集中在32位程式碼,在x86-64系統上用gcc -m32產生32位程式碼。若編譯時發生/usr/include/features.h:364:25: fatal error: sys/cdefs.h: 沒有那個檔案或目錄,則使用命令sudo apt-get install libc6-dev-i386解決。 ELF檔案格式與相關命令詳見http://blog.csdn.net/li_xiang_li/article/details/50528307
一. 由原始碼到可執行程式經歷了什麼
原始碼: //main.c int add(int a,int b); static int si;//.bss extern int buf[]; int *copy = &buf[0];//.rel.data int main() { int a = 3; int b = 5; int c = add(a,b);//.rel.text char *s = "hello c";//.rodata static int si;//.bss return 0; } //add.c int buf[2]; int add(int a,int b) { return (a+b); } //makefile(為了簡化討論,makefile檔案中沒有新增-g選項) all:main main:main.o add.o gcc -o main main.o add.o -m32 main.o:main.c gcc -c main.c -m32 add.o:add.c gcc -c add.c -m32 clean: rm -rf *.o main
從原始碼到可執行程式所要經歷的過程概述:
原始碼(.c .cpp .h)經過c前處理器(cpp)後生成.i檔案,編譯器(cc1、cc1plus)編譯.i檔案後生成.s檔案,彙編器(as)彙編.s檔案後生成.o檔案,連結器(ld)連結.o檔案生成可執行檔案。gcc是對cpp、cc1(cc1plus)、as、ld這些後臺程式的包裝,它會根據不同的引數要求去呼叫後臺程式。以helloworld程式為例,使用gcc -o hello hello.c時加上-v選項可觀察到詳細的步驟。也可使用gcc分別進行以上四步驟,預編譯gcc -E hello.c -o hello.i,編譯gcc -S hello.i -o hello.s,彙編gcc -c hello.s -o hello.o,連結gcc -o hello hello.o
有興趣的話可以嘗試驗證以下預處理、編譯、彙編、連結的詳細過程及結果。使用cpp對c檔案進行預處理用vim檢視.i檔案,使用cc1編譯.i檔案用vim檢視.s檔案,使用as彙編.s檔案,最終使用ld手動連結相關檔案生成可執行檔案。(過程較為複雜,可參考-v選項後的結果)
二、 由ELF檔案看程式的編譯連結
程式的編譯涉及詞法分析、語法分析、語義分析等過程,詳情參見《編譯原理》。
1.以下分析一下編譯完成之後的ELF檔案:
readelf -S add.o檢視section header table
由section header table可推測add.c檔案程式碼中並沒有實際資料(.data的size大小為0)。add.o中共包含了11個節描述符,每個節描述符大小為40位元組(sizeof(Elf32_Shdr)),共440位元組,因此在這11個節之後還有440位元組的section header table。
readelf -S main.o檢視section header table
alloc表示該section在程序地址空間中要分配空間(.text .data .bss都會有這個標誌)
main.o中共有14個節描述符,每個節的大小為40位元組(sizeof(Elf32_Shdr)),因此這14個節之後還有560位元組的section header table。
以上的原始碼和關於add.o和main.o的四張圖可以看出,.bss確實不佔磁碟空間,只是起到佔位的作用(兩個section table中.bss的size都是0,但原始碼中確實定義了相關的變數)。.comment編譯器版本資訊的size為0x2e不變,ELF Header的大小為0x34位元組不變。
2.關於符號表的一些描述:
-
- 符號表的資料結構
-
st_info低4位表示符號的型別,高28位表示符號繫結資訊
符號型別:LOCAL(區域性符號)GLOBAL(全域性符號)WEAK(弱引用)
全域性符號的兩種情況:1、非static的c函式和非static的全域性變2、又稱為外部符號,定義在其他模組的函式和變數
區域性符號:static的c函式和全域性變數,目標檔案中節和相應的原始檔的名字。
符號繫結資訊:NOTYPE(未知符號型別) OBJECT(資料物件,例如變數、陣列) FUNC(函式) SECTION(段) FILE(檔名)
st_shndx符號所在段
若符號定義在本目標檔案,則該成員表示符號所在的段在段表中的下標
若符號未定義在本檔案中,或者有些特殊的符號,有以下含義:ABS該符號包含了一個絕對的值,例如檔名。 COMMON未初始化的全域性符號。 UNDEF該符號在本目標檔案被引用,但定義在其他目標檔案中。
st_value符號值
每個符號都有一個對應的值,目標檔案中若是非COMMOM型別的符號,則該符號對應的函式或者變數位於由st_shndx指定的段,偏移st_value的位置(常見如全域性變數);目標檔案中若是COMMON型別的符號,則st_value表示該符號的對齊屬性;可執行檔案中st_value表示符號的虛擬地址。
問題:未初始化的全域性變數到底存放在.bss中還是存放在符號表的COMMON中?
實際上跟不同的語言和不同的編譯器實現有關,有些編譯器會將全域性未初始化變數存放在.bss段,有些則不存放,只是預留一個未定義的全域性符號,等到最終連結成可執行檔案的時候再在.bss段分配空間。
-
- 詳細檢視符號表
-
readelf -s add.o檢視符號表
可以從本圖中反推程式碼,add.c型別為FILE,符號所在段為ABS,它是檔名;buf型別為OBJECT,符號所在段在COMMOM中暫時存放,繫結資訊為GLOBAL,size為8表示該資料物件大小為8位元組,value為4表示該資料物件按照4位元組對齊,則程式碼中buf是一個未初始化的全域性變數或陣列;add型別為FUNC,符號所在段為.text,繫結資訊為GLOBAL,size為13表示該函式指令佔13位元組,value為0表示該函式相對於段起始位置的偏移量為0,它是函式名。其他符號所在段為1,2,3,5,6,4分別表示對應的section table中的段名,分別為.text .data .bss .note.GNU-stack .eh_frame .comment
readelf -s main.o檢視符號表
由本圖可以反推原始碼,main.c的型別為FILE,符號所在段為ABS,它是檔名;copy的型別是OBJECT,繫結資訊為GLOBAL,size大小為4位元組,所在段為3對應section table中為.data段,綜合來看copy是一個已經初始化的大小為4位元組的全域性變數或陣列;buf型別為NOTYPE,符號所在段為UND,繫結資訊為GLOBAL,可見buf是本檔案引用的一個外部檔案中的符號;main型別為FUNC,符號所在段為1對應在section table中為.text段,繫結資訊為GLOBAL,則該符號是一個函式名;add的型別是NOTYPE,符號所在段為UND,繫結資訊為GLOBAL,可見add是本檔案引用的一個外部檔案中的符號。si型別為OBJECT,符號所在段為5對應section table為.bss,size為4,則si是一個大小為4位元組的靜態變數,同樣si.1490也是一個大小為四位元組的靜態變數,都存放在.bss中。其他符號所在段為1,3,5,6,7,8,9的段自行對應到section table中。
關於si和si.1490的說明:
由於原始碼定義了同名的全域性靜態變數si和區域性的靜態變數si,一時無法判斷變數和符號的對應關係,但經過其他測試程式碼發現,只有區域性的靜態變數才會在變數名後加數字,因此可判斷原始碼中的全域性的static int si對應符號表中的si,區域性的static int si對應符號表中的si.1490
關於未初始化的全域性變數存放在.bss中還是存放在COMMON中的細緻說明:
未初始化的全域性非static變數,編譯後存放在.o檔案中符號表的COMMON, 連結後存放在可執行檔案的.bss中。
未初始化的全域性static變數無論在編譯階段還是連結階段始終存放在.bss中
三、連結過程簡要分析(靜態連結)
1、 連結的兩步過程概述
第一步:空間分配,將所有可重定位檔案(.o)中的同名section合併為一個segment,並按頁對齊。調整各個section的偏移量和section本身大小。彙總所有可重定位檔案(.o)的符號到全域性符號表中。
第二步:符號解析與重定位(符號解析指將每個符號引用剛好和一個符號定義聯絡起來。重定位指連結器把每個符號定義與一個儲存位置聯絡起來,然後修改所有對這些符號的引用,使得它們指向這個儲存位置,從而重定位這些節。)
2、空間分配詳解
檢視main可執行程式的section table
readelf -S main
首先需要明確的是連結前後程式中所使用的地址已經是程式在程序中的虛擬地址。由add.o和main.o的section table中可以看出它們的Addr列的值都是0x0,因為虛擬空間還沒有被分配,所以預設為0。同時這也說明之所以可重定位檔案無法執行,是因為虛擬空間沒有分配,無法從0x0地址對映到相應的實體地址。
從main的segment table中可以看出,最終.text被分配到地址0x080482f0,大小為0x1b2;.data被分配到地址0x0804a014,大小為0xc;.bss被分配到地址0x0804a020,大小為0x14;.rodata被分配到0x080484b8,大小為0x10。最終可執行檔案main中每個segment的size值遠遠大於可重定位檔案add.o和main.o中對應名字的section的size之和,是因為gcc呼叫的連結器ld隱式的連結了一些和啟動有關的其他檔案(比如crt1.o、crti.o、crtn.o、crtbegin.o、crtend.o等)。我們可以手動連結指定連結哪些檔案,ld -m elf_i386 -e main -o main main.o add.o最終觀察可執行檔案中的segment的size值恰好是add.o和main.o中對應section的size的和,印證了關於連結時空間的分配。
3、符號解析與重定位詳解
3.1、符號解析
3.1.1、概念:把程式碼中的每個符號引用和符號定義聯絡起來
連結器解析符號引用的方法是將每個引用與它輸入的可重定位目標檔案的符號表中一個確定的符號定義聯絡起來。
3.1.2、原始碼main.c中extern int buf[];int *copy = &buf[0];int c = add(a,b);
程式碼中引用了符號buf和add
原始碼add.c中int buf[2];int add(int a,int b){}
原始碼main.c中static int si;int *copy = buf[0];
程式碼中定義了符號buf,add,si,copy
3.1.3、關於符號解析的細節需要參考可重定位檔案的符號表(.symtab)
符號解析的根本還是將符號的引用和符號的定義關聯起來,main.c中copy和si都是本地(LOCAL)符號,連結器解析起來很容易,因為符號的引用和符號定義都在一個模組之中。若符號不是本模組中的,例如buf和add這樣的全域性(GLOBAL)符號,則連結器稱這些符號為外部符號,假設存在於其他模組中。連結器對這些外部符號的處理(符號決議)有其他的規則,在後面討論。
3.2、重定位
3.2.1、相關概念:
重定位
指的是根據各目標檔案中重定位表(例如.rel.text和.rel.data)中記錄的資訊來調整.symtab中所記錄的符號的地址。
重定位表的資料結構
- 重定位表中資料成員的解釋
r_offset重定位符號的偏移
對於可重定位檔案,此值是該重定位符號所要修正的位置的第一個位元組相對於段起始的偏移
對於可執行檔案或共享檔案,此值是該重定位符號所要修正的位置的第一個位元組的虛擬地址
r_info重定位的型別(低8位)和符號(高24位表示重定位的符號在符號表中的下標,標識被修改的引用應該指向的符號)
重定位型別(告訴連結器如何修改新的引用)
R_386_PC32,相對定址,CPU使用當前指令中的32位地址加上PC(程式計數器)的值來得到最終的有效值。
R_386_32,絕對定址,CPU直接使用當前指令中的32位地址作為有效地址
3.2.2、重定位符號引用
彙編器將本地沒有定義的符號寫入可重定位目標檔案的.symtab表,讓連結器到其他可重定位目標檔案中查詢。同理,彙編器對遇到儲存位置未知的符號引用時,它也將會將這些符號的資訊存於.rel.text和.rel.data表中,告訴連結器將可重定位目標檔案合併成可執行檔案時如何修改引用。
可使用命令readelf -r main.o檢視重定位表
重定位表中指出符號add的地址按照.text段偏移0x29位元組處的4位元組地址加上當前PC的值作為最終有效值替換到該位置。符號buf按照.data段偏移0位元組處的地址為該符號的最終有效地址。
3.3、關於符號解析過程中連結器對於全域性符號的處理(符號決議)
3.3.1、相關概念
強符號:函式和已初始化的全域性變數
弱符號:未初始化的全域性變數
3.3.2、連結規則
1、多個同名強符號存在,連結失敗報錯,無法選擇符號
2、存在同名的一個強符號和多個弱符號,選擇強符號
3、 存在同名的多個弱符號,選擇佔空間大的一個
3.3.3、例項解析
//add.c //sub.c //main.c
int fun(int a,int b) int fun(int a,int b) int main()
{ { {
return a+b; return a-b; int a = fun(5,3);
return 0;
} }
gcc -o main main.c add.c sub.c -m32
圖中可以看出,是ld連結器返回一個錯誤,編譯過程是完全不會報錯的,正因為兩個強符號無法選擇,只能報錯退出。
//main.c //test.c
int sign; int sign = 5;
int main()
{
printf("%d\n",sign);
return 0;
}
gcc -o main main.c test.c -m32
結果列印 5
強弱符號選強符號
//main.c //test.c
void fun();
signed char n; int n;
int main() void fun()
{ {
n = 255; n = 255;
fun(); }
printf("%d\n",n);
return 0;
}
gcc -o main main.c test.c -m32
最終列印255,兩個弱符號選擇佔空間大的一個
參考:
深入理解計算機系統
程式設計師的自我修養——連結、裝載與庫