前言 對於我們平時寫代碼運行,我們很少去關註編譯和鏈接的過程,因為現在的開發環境都是集成(IDE)的,這些IDE一般都會將編譯和鏈接的過程一步搞定,這一過程又被稱為構建。但若經常寫代碼,經常會有很多莫名其妙的錯誤讓我們不知所措,對於這些錯誤若我們能知其原因,那是再好不過了。因此本系列就是帶你瞭解 ...
前言
對於我們平時寫代碼運行,我們很少去關註編譯和鏈接的過程,因為現在的開發環境都是集成(IDE)的,這些IDE一般都會將編譯和鏈接的過程一步搞定,這一過程又被稱為構建。但若經常寫代碼,經常會有很多莫名其妙的錯誤讓我們不知所措,對於這些錯誤若我們能知其原因,那是再好不過了。因此本系列就是帶你瞭解這些編譯器和鏈接器在背後的工作
夢開始的地方
讓我們先來看一個最最最經典的例子
//hello.c
#include <stdio.h>
int main()
{
printf("hello world");
return 0;
}
事實上,運行以上過程,可以被分解為四步:預處理、編譯、彙編、鏈接
預編譯
預處理器在預編譯階段會將源代碼文件.c和相關的頭文件編譯為一個.i文件,其中主要處理“#”開頭的預編譯指令
預編譯過程主要處理規則:
- 刪除所有註釋 "//" 和 "/**/"
- 處理所有條件預編譯指令 "#if"、"#ifdef"等
- 刪除所有 "#define",且展開所有巨集定義
- 處理預編譯指令"#include",將被包含的文件插入到相應的預編譯指令位置。當然可能插入的文件還包含其他文件
- 添加行號和文件名標識,以便於編譯器在編譯時產生調試所用的行號信息和產生編譯錯誤或警告時可以顯示行號
- 保留所有#pragma指令,便於編譯器使用
編譯
所謂編譯,就是將上一個預處理階段處理完的文件進行詞法分析、語法分析、語義分析和優化後生成的彙編代碼文件。其過程最為關鍵且複雜。但是現在版本的GCC已經把預編譯和編譯合併為一個步驟,使用cc1來完成
現有如下片段:
array[index] = (index + 4) * ( 2 + 6 )
詞法分析
首先,源代碼程式被輸入到掃描器,運用類似有限狀態機的演算法將源代碼的字元序列分割為一系列記號(token)
記號 | 類型 |
---|---|
array | 標識符 |
[ | 左方括弧 |
index | 標識符 |
] | 右方括弧 |
= | 賦值 |
( | 左圓括弧 |
index | 標識符 |
+ | 加號 |
4 | 數字 |
) | 右圓括弧 |
* | 乘號 |
( | 左圓括弧 |
2 | 數字 |
+ | 加號 |
6 | 數字 |
) | 右圓括弧 |
- 詞法分析的記號可分為:關鍵字、標識符、字面量(數字、字元串等)、特殊符號(加號等)
- 識別記號時,掃描器也會將標識符放入符號表,將字面量常量放入文字表,以備往後的步驟使用
語法分析
接下來時語法分析。語法分析器對掃描器產生的記號進行語法分析,再產生語法樹,語法樹時以表達式為節點的樹。語法分析過程會採用上下文無關語法
- 語法分析過程,會確定運算符號的優先順序和含義。此時若出現表達式不合法,編譯器則會報告語法分析階段的錯誤
語義分析
語法分析會對完成了表達式的語法層面的分析,但其並不知道這個語句是否有意義,因此需要語義分析器進行語義分析,從而對整個語法樹的表達式標識類型;若有類型需要做隱式轉換,會在語法樹中插入相應的轉換節點
語義分析分為兩類,靜態語義是編譯器再編譯器可以確定的語義;動態語義則是在運行期才能確定的語義
- 靜態語義包括聲明、類型的匹配和類型的轉換。比如浮點類型賦值給整型,需要進行類型轉換
生成中間語言
編譯器有很多不同的優化,其中一種便是在源代碼級別用源碼級優化器進行優化。直接在語法樹上優化較為困難,因此源代碼優化器將語法樹轉換為中間代碼
-
雖然中間代碼看上去已經十分接近目標代碼了,但中間代碼和機器以及運行時環境無關
-
中間代碼類型:三地址碼,P-代碼
//三地址碼 //表示將y z進行op操作後賦值給x x = y op z t1 = 2 + 6 t2 = index + 4 t3 = t2 * t1 array[index] = t3; //優化 //優化程式會計算2 + 6 t2 = index + 4 t2 = t2 * 8 array[index] = t2;
-
中間代碼可以將編譯器分為前後端。前端由編譯器產生機器無關的中間代碼;而後端由編譯器將中間代碼轉換為目標機器代碼。前端關註的是正確反映代碼含義的靜態結構,而後端關註讓代碼良好運行的動態結構。好處是對於跨平臺的編譯器,它們可以針對不同平臺使用同一個前端和針對不同機器的數個後端
目標代碼生成於優化
編譯器後端包括代碼生成器和目標代碼優化器。代碼生成器將中間代碼轉換為目標機器代碼,目標代碼優化器會將目標代碼進行優化
革命尚未結束
也許你覺得到這裡我們已經萬事俱備,已經形成可執行文件。但其實之前的步驟只是將源代碼文件編譯為目標文件,但在目標文件中我們還未確定index和array的地址,若index和array的地址在另一個程式模塊,便沒法確定地址,我們還需其他手段
這個問題由鏈接解決。事實上,定義在其他模塊的全局變數和函數最終運行時的絕對地址都需要在鏈接時才能確定。編譯器將一個源代碼文件編譯為一個未鏈接的目標文件,隨後由鏈接器最終將目標文件鏈接未可執行文件。編譯器只是暫時擱置調用地址的指令,最後等到鏈接時由鏈接器去修正地址
彙編
彙編器將彙編代碼轉變成機器指令(機器可以執行的指令)
- 由於每個彙編語句幾乎都對應一條機器指令,因此彙編過程較為簡單,沒有複雜語法、語義、指令優化,只需根據彙編指令和機器指令的對照表一一翻譯便好
深挖中間目標文件
中間目標文件又簡稱目標文件(object文件):編譯器編譯源代碼後生成的文件。從結構上來說,目標文件是已經編譯後的可執行文件格式,只是沒有經過鏈接,其中某些符號和地址還沒有調整,但其本身是按照可執行文件格式存儲的
往後我們將深入分析目標文件格式,介紹ELF文件的重要段及文件頭、段表、重定位表、字元串表、符號表、調試信息等相關結構;我們會瞭解到可執行文件、目標文件、庫都是以段為基礎的文件,不僅是數據和代碼存放在相應段中,編譯器也會將一些輔助信息按照表的方式存儲
目標文件格式
現如今的pc平臺流行的可執行文件格式為windows的PE和Linux的ELF,都是COFF格式的變種
目標文件格式和編譯器和操作系統有關,不同平臺下的格式各有不同
ELF格式的文件類型分為四類:
ELF文件類型 | 說明 | 實例 |
---|---|---|
可重定位文件(relocatable file) | 包含數據和代碼,可被用來鏈接成可執行文件或共用目標文件,靜態鏈接庫也屬於此類 | Linux .o windows .obj |
可執行文件(executable file) | 包含可直接執行的程式,一般沒有擴展名 | /bin/bash文件 windows .exe |
共用目標文件(shared object file) | 包含數據和代碼,可在以下兩種情況使用。一是鏈接器可以使用這種文件跟其他的可重定位文件和共用目標文件鏈接,生成新的目標文件;二是動態鏈接器可以將幾個這樣的共用目標文件與可執行文件結合,作為進程映像的一部分運行 | Linux .so,如/lib/glibc-2.5.so windows的DLL |
核心轉儲文件(core dump file) | 當進程意外終止時,系統可將該進程的地址空間的內容及終止時的其他信息轉儲到核心轉儲文件 | Lindex的core dump |
目標文件的重要段
目標文件包含機器指令代碼、數據、鏈接時所需要的信息(符號表、調試信息、字元串);以"字"和"段"進行存儲,都表示一定長度的區域,基本不加以區別,以後的都統一為"段"
機器指令被放於代碼段,常見的名稱有".code"或".text";
全局變數和局部靜態變數數據放於數據段,常見的名稱有".data";
未初始化的全局變數和局部變數放於".bss"段
文件頭(file header)描述整個文件的屬性,其中包含文件是否可執行、靜態鏈接還是動態鏈接及入口地址、目標硬體、目標操作系統等信息,ELF文件還包括一個段表(描述文件中各個段的數組),其描述了文件中各個段在文件中的偏移量、段名、長度、讀寫許可權等
ELF文件佈局會隨著討論不斷深入而擴大
總的來說,源代碼編譯後主要分為兩種段:指令和數據
那麼,我們為什麼要對這些指令和數據進行分類呢?這有什麼好處呢?
- 程式被裝載後,數據和指令分別被映射到兩個虛擬存儲區域。數據區域對進程來說是可讀寫,而指令區域是只讀的。如此可以防止指令被惡意改寫
- cpu在當下擁有十分強大的緩存,因此程式需要想盡一切辦法提高緩存的命中率;而指令和數據進行分類後有利於提高程式的局部性,就可以提高命中率了
- 當系統中運行多個同一個程式的副本且它們的指令也是相同的,記憶體中只需要保存一份此程式的指令部分,當然其他只讀數據也是同樣的道理;不過數據區域是進程私有,因此每個副本進程的數據是不同的
紙上談來終覺淺
如果只是對目標文件瞭解概念上的知識,而不深入其具體細節,我認為這並不可能真正瞭解他,因此接下來我將以一個具有代表性的例子撩開這層神秘的面紗
現有一個instance.c程式:
int globalInitVar = 1;
int globalUninitVar;
int printf( const char* format, ... );
void func1( int i )
{
printf( "%d\n", i );
}
int main()
{
static int staticVar = 2;
static int staticVar2;
int a = 1;
int b;
func1( staticVar + staticVar2 + a + b );
return a;
}
用gcc編譯(-c)此文件,再通過binutils的工具objdump(-h將基本信息列印出來,-x信息更多)查看object內部結構:
我們先來看幾個重要的段屬性,Size表示是段的長度;File off表示段的偏移量,也就是所在的位置;CONTENTS表示該段在文件中存在。我們可以看到.bss並沒有CONTENTS,說明它在ELF中沒有內容
- size 查看ELF文件的代碼段、數據段、BSS的長度和(dec十進位,hex十六進位)
接下來,我們將細探這幾個段所包含的內容
用objdump的-s將所有段的內容以十六進位方式列印出來,-d將所有包含指令的段反彙編
- 首先是代碼段:
contents of section.text是.text的數據用十六進位列印出來的。其中最左邊的是offset偏移量;中間四列是十六進位內容,也就是段長度,這裡是0x5b和之前看到的是一致的;最後一列是當前段的ASCII碼形式。下麵的是反彙編結果,很顯然兩個函數func1和main正是本常式序里的
- 其次是數據段和只讀數據段:
.rodata段存放只讀數據,如字元串常量和const修飾的。在這裡調用printf時用到的字元串常量"%d\n"就是只讀數據,因此將其放在.rodata段。但有些編譯器會把字元串常量放到.data段
設立.rodata段的好處:
- 支持c++關鍵字
- 操作系統在載入時可以將.rodata段屬性映射為只讀,保證安全性
objdump -x -s -d instance.o查看data情況:
contents of section .data的中間部分前四個位元組為0x01、0x00、0x00、0x00這個值也就是globalInitVar.後四個位元組為0x02、0x00、0x00、0x00,這個值也就是staticInitVar
在這裡你可能會有疑惑,為什麼globalInitVar的次序不是0x00、0x00、0x00、0x01呢?這與CPU的位元組序有關,現在有請我們的嘉賓大端和小端登場!
在不同的電腦結構中,對於數據的存儲和傳輸機制有所不同,這導致了一個問題——通信雙方的信息單元應該以怎樣的順序傳送?目前電腦體系中最常用的位元組存儲機制有兩種:大端和小端
在瞭解大端和小端前,我們還需瞭解兩個概念——MSB(most significant bit/byte)和LSB(least significant bit/byte)。MSB表示在一個bit序列或一個byte序列中對整個序列取值影響最大的那個bit/byte;LSB表示在在一個bit序列或一個byte序列中對整個序列取值影響最小的那個bit/byte
比如0x32857233,0x32則是MSB,0x33時LSB
大端規定了存儲時MSB放在低地址,傳輸時MSB放在流開始處,存儲時LSB放在高地址,傳輸時LSB放在流的末尾處;小端與大端相反
pc的CPU的相容機中經常用小端,而mac機器以及TCP/IP、java虛擬機用大端.至於是大端好還是小端好,這個問題已經爭論很久了但也沒有結論
- 然後是.bss段:
.bss段為globalUninitVar和staticVar2預留了空間,但很奇怪的是這個段的長度只有四個位元組,而globalUninitVar和staticVar2的大小總和是8。通過符號表查找,原來只有static_var2存放在.bss段,而globalUninitVar卻沒有存放在任何段,其表現形式是一個未定義的COM符號。關於這個現象與編譯器實現有關,有些編譯器會將全局靜態未初始化的變數存放在.bss段,只是預留未定義的全局變數符號,等到最終鏈接成可執行文件時再為.bss分配空間
當然除了.text、.data、.bss這三個常用的段外,ELF文件也很有可能包含其他種類的段
常用段名 | 說明 |
---|---|
.rodata1 | 只讀數據 |
.comment | 編譯器版本信息 |
.debug | 調試信息 |
.dynamic | 動態鏈接信息 |
.hash | 符號哈希表 |
.line | 調試時的行號表,也就是源代碼行號和編譯後指令的對應表 |
.note | 額外的編譯器信息,如程式的公司名、發佈版本 |
.strtab | 字元串表,存儲ELF文件中用到的字元串 |
.symtab | 符號表 |
.shstrtab | 段名錶 |
.plt .got |
動態鏈接的跳轉表和全局入口表 |
.init .fini |
程式初始化和終結代碼段 |
- 以上這些段都是系統保留的,我們也可以使用一些非系統保留的名字作為自定義段的名字
ELF文件總體結構
ELF文件將要瞭解的重要結構:
ELF文件頭
文件頭作為ELF目標文件的最前部包含描述整個文件的屬性,如ELF文件版本、目標機器型號、程式入口地址、包含段描述符的段表等等
我們通過readelf -h instance.o來查看ELF文件內容:
可以看到ELF文件中包含了ELF魔數(Magic)、機器位元組長度(Class)、數據存儲方式(Data)、版本(Version)、操作系統運行平臺(OS/ABI)、ABI版本、ELF文件類型(Type)、硬體平臺(Machine)、硬體平臺版本(Version)、入口地址(Entry point address)、程式和段表的起始位置(Start of program headers、Start of section headers)、當前ELF文件頭大小(Size of this header)、程式頭大小(Size of program headers)、程式頭數量(Number of program headers)、段表描述符大小(Size of section headers)、段表描述符數量(Size of section headers)、段表字元串表所在段在段表中的下標(section header string table index)
ELF文件有32位和64位版本,文件頭亦是如此,分別是"Elf32_Ehdr"和"Elf64_Ehdr",定義在"/usr/include/elf.h"兩個版本文件頭內容都一樣,只是部分成員大小不同。以下是文件頭"elf.h"使用typredef定義的變數體系
自定義類型 | 描述 | 原始類型 | 長度(位元組) |
---|---|---|---|
Elf32_Addr | 32位版本程式地址 | uint32_t | 4 |
Elf32_Half | 32位版本的無符號短整型 | uint16_t | 2 |
Elf32_Off | 32位版本的偏移地址 | uint32_t | 4 |
Elf32_Sword | 32位版本有符號整型 | int32_t | 4 |
Elf32_Word | 32位版本無符號整型 | uint32_t | 4 |
Elf64_Addr | 64位版本程式地址 | uint64_t | 8 |
Elf64_Half | 64位版本的無符號短整型 | uint16_t | 2 |
Elf64_Off | 64位版本的偏移地址 | uint64_t | 8 |
Elf64_Sword | 64位版本有符號整型 | int32_t | 4 |
Elf64_Word | 64位版本無符號整型 | uint32_t | 4 |
舉例Elf32_Ehdr:
typedef struct
{
//ELF魔數(Magic)、機器位元組長度(Class)、數據存儲方式(Data)、版本(Version)、操作系統運行平臺(OS/ABI)、ABI版本
unsigned char e_ident[16];
//ELF文件類型
Elf32_Half e_type;
//ELF文件的CPU屬性
Elf32_Half e_machine;
//ELF版本號
Elf32_Word e_version;
//入口地址
Elf32_Addr e_entry;
//start of program headers
Elf32_Off e_phoff;
//start of section headers 段表起始位置
Elf32_Off e_shoff;
//ELF標誌位,標識ELF相關屬性
Elf32_Word e_flags;
//size of this header ELF文件頭大小
Elf32_Half e_ehsize;
//將在後面的系列動態鏈接講解
Elf32_Half e_phentsize;
//將在後面的系列動態鏈接講解
Elf32_Half e_phnum;
//段表描述符大小
Elf32_Half e_shentsize;
//段表描述符數量
Elf32_Half e_shnum;
//段表字元串表在所在段表的下標
Elf32_Half e_shstrndx;
}Elf32_Ehdr;
Magic 從前面readelf輸出的內容可以得知,Magic後面16個位元組對應於e_ident,用來標識ELF文件的平臺屬性,如ELF字長是32位還是64位下的、位元組序是大端還是小端、ELF文件版本;其中前四個位元組也就是這裡所說的ELF魔數,這四個位元組的魔數用來確認文件類型。操作系統在載入可執行文件時會確認魔數是否正確,若不對則拒絕載入;
上面這個例子,前面四個位元組是標識碼,所有ELF文件都相同,也就是ELF魔數,這四個位元組的魔數用來確認文件類型。操作系統在載入可執行文件時會確認魔數是否正確,若不對則拒絕載入;第一個位元組0x7f對應ASCII字元里的DEL控制符,後面三個位元組0x45、0x4c、0x46是ELF這三個字母的ASCII標識碼;第五個位元組表示系統位數,也就是Class,64位是0x02,32位是0x01;第六個位元組表示位元組序,也就是Data,0x01是小端,0x02是大端;第七個位元組表示ELF文件的主版本號,也就是Version,一般為1,因為ELF標準在1.2往後就沒更新了;最後九位位元組沒有定義,填0。但有些平臺會使用這九個位元組作為擴展標誌
Type e_type表示ELF文件類型,在之前我們提到過4種ELF文件類型,其中三種文件類型對應一個常量,系統通過這個常量來判斷ELF文件類型,而不是文件擴展名
常量 | 值 | 含義 |
---|---|---|
ET_REL | 1 | 可重定位文件,.o |
ET_EXEC | 2 | 可執行文件 |
ET_DYN | 3 | 共用目標文件,.so |
Machine ELF文件格式在不同平臺可以使用,但這並不代表一個ELF文件可以在不同的平臺使用,只是不同平臺遵守同一套ELF文件標準
e_machine表示該ELF文件的平臺屬性
常量 | 值 | 定義 |
---|---|---|
EM_M32 | 1 | AT&T WE 32100 |
EM_SPARC | 2 | SPARC |
EM_386 | 3 | Intel x86 |
EM_68k | 4 | Motorola 680000 |
EM_88K | 5 | Motorola 880000 |
EM_860 | 6 | Intel 80860 |
段表
我們使用段表來包含各式各樣的段,它描述各個段的信息,比如名字、長度等;編譯器、鏈接器和裝載器都是依靠段表來定位訪問各個段
在ELF文件中,段表的位置由ELF文件頭的"e_shoff"成員記錄
段表是一個以"Elf32_Shdr"結構體為元素的數組,每個元素對應一個段,也可以稱這個結構體為段描述符.Elf32_Shdr定義在"/usr/include/elf.h"
我們用readelf -S instance.o來查看完整的段表信息
可以看到,段表數組中的第一個元素是個無效的段描述符,類型為NULL,除此以外都是有效的
在這裡我們解釋Elf32_Shar各個成員的含義
sh_name | Section name 段名 一個字元串,位於一個叫做".shstrtab"的字元串表 |
---|---|
sh_type | section type 段的類型 |
sh_flags | section flag 段的標誌位 |
sh_addr | section address 段的虛擬地址 |
sh_offset | section offset 段偏移 |
sh_size | section size 段長 |
sh_link 和 sh_info | section link and section information 段鏈接信息 |
sh_addralign | section address alignment 段地址對齊 |
sd_entsize | section entry size 項長 包含一些固定大小的項,如符號表,其包含的每個符號大小相同 |
- sh_type sh_flag 段名只在編譯鏈接過程有意義,但它並不真正表示段的類型;而對於編譯器來說,主要決定段屬性的是段類型和段標誌位.其中段標誌位表示該段在進程虛擬地址中的屬性,如是否可寫,是否可執行等
sh_type相關常量
常量 | 值 | 含義 |
---|---|---|
SHT_NULL | 0 | 無效段 |
SHT_PROGBITS | 1 | 程式段 |
SHT_SYMTAB | 2 | 表示該段內容為符號表 |
SHT_STRTAB | 3 | 表示該段內容為字元串表 |
SHT_RELA | 4 | 重定位表。包含重定位信息 |
SHT_HASH | 5 | 符號表的哈希表 |
SHT_DYNAMIC | 6 | 動態鏈接信息 |
SHT_NOTE | 7 | 提示信息 |
SHT_NOBITS | 8 | 表示該在文件中無內容 |
SHT_REL | 9 | 包含重定位信息 |
SHT_SHLIB | 10 | 保留 |
SHT_DNYSYM | 11 | 動態鏈接的符號表 |
sh_flag相關常量
常量 | 值 | 含義 |
---|---|---|
SHF_WRITE | 1 | 表示該段在進程空間可寫 |
SHF_ALLOC | 2 | 表示該段在進程空間中需要分配空間 |
SHF_EXECINSTR | 4 | 表示該段在進程空間中可被執行 |
- 段鏈接信息
sh_link 和 sh_info所包含的意義
sh_type | sh_link | sh_info |
---|---|---|
SHT_DYNAMEIC | 該段所使用的字元串表在段表中的下標 | 0 |
SHT_HASH | 該段所使用的符號表在段表中的下標 | 0 |
SHT_REL SHT_RELA |
該段所使用的相應符號表在段表中的下標 | 該重定位表所作用的段在段表中的下標 |
SHT_SYMTAB SHT_DYNSYM |
與操作系統有關 | 與操作系統有關 |
other | SHN_UNDEF | 0 |
重定位表
以上例子,在段表中有一個稱為".rela.text"的段,其類型為"SHT_REL",就是重定位表,也就是說重定位表也是一個段;該表是針對.text段的重定位表,其"sh_link"表示符號表下標,"sh_info"表示其作用於哪個段。比如當前這個例子,".rela.text"作用於".text"段,因為".text"段下標為1,"sh_info"就為1
字元串表
ELF文件中會經常用到字元串,比如段名、變數名之類的;但因為字元串的長度大多數情況是不確定的,所以用固定大小的結構去表示它行不通。一種常用的方法是將字元串存放在一個表中,用字元串的偏移量來引用字元串,也就是字元串表;這樣引用字元串只需一個數字下標即可搞定而不用考慮長度的問題
如以下字元串表
字元串表在ELF文件中同樣以段的形式存儲。如"strtab"為字元串表,保存普通的字元串;"shstrtab"為段表字元串表,保存段表中用到的字元串
"e_shstrndx"是Elf32_Ehdr中的成員,表示"shstrtab"在段表中的下標,也就是段表字元串表在段表中的下標
符號表
鏈接(具體請查看2.5鏈接)的本質就是其實就是將一個複雜的系統逐步分割成小系統,把每個源代碼模塊獨立編譯,再按需將它們組裝。其原理便是將指令對其他符號地址的引用加以修正。而鏈接過程非常關鍵的一部分就是對符號的管理,也就是符號表;每個目標文件都含有一個符號表,其中包含了當前目標文件所用的所有符號
每個符號都有值,對於變數函數來說,這個值就是它們的地址,被稱為符號值
下麵,我們對符號分一下類:
- 在目標文件內定義的全局符號,可以被其他目標文件引用。如,之前的例子中的"glovalInitVar"、"func1"
- 在目標文件內引用其他目標文件的全局符號,並沒有定義在當前目標文件,稱為外部符號。如,"printf"
- 局部符號。只在編譯單元內可見,對鏈接無用,因此鏈接器會忽視這類符號。作用是調試器使用這類符號分析核心轉儲文件
- 段名
- 行號
使用nm instance.o查看符號表;readelf -s instance.o查看符號表中的符號
符號表結構 ELF文件中符號表往往是一個段,叫做".symtab";結構上是一個包含Elf32_Sym結構體的數組,其中Elf32_Sym對應一個符號。從上表我們可以得知,符號表的第一個元素是未定義符號,也就是無效的
以下是Elf32_Sym的結構體定義
typedef struct
{
Elf32_Word st_name; /* Symbol name (string tbl index) 符號名。這個成員是該符號名的下標,因為我們是通過偏移量得出符號名 */
Elf32_Addr st_value; /* Symbol value 符號值 */
Elf32_Word st_size; /* Symbol size 符號類型的大小 */
unsigned char st_info; /* Symbol type and binding 符號類型和綁定信息 */
unsigned char st_other; /* Symbol visibility 目前為0,暫時沒用 */
Elf32_Section st_shndx; /* Section index 符號所在的段 */
} Elf32_Sym;
st_info符號類型和綁定信息 該類型低4位標識符號類型,高28位標識符號綁定信息
符號綁定信息
巨集定義名 | 值 | 說明 |
---|---|---|
STB_LOCAL | 0 | 局部符號 |
STB_GLOBAL | 1 | 全局符號 |
STB_WEAK | 2 | 弱引用 |
符號類型
巨集定義名 | 值 | 說明 |
---|---|---|
STT_NOTYPE | 0 | 未知類型符號 |
STT_OBJECT | 1 | 數據對象,如變數 |
STT_FUNC | 2 | 函數或其他可執行代碼 |
STT_SECTION | 3 | 段,此類符號必須為STB_LOCAL |
STT_FILE | 4 | 文件名,一般為目標文件的源文件名,必須是STB_LOCAL,其st_shndx必須為SHB_ABS |
st_shndx符號所在段 若定義在本目標文件內,表示符號所在段在段表中的下標;若不是,則較為特殊
巨集定義名 | 值 | 說明 |
---|---|---|
SHN_ABS | 0xfff1 | 表示一個絕對的值,如意為文件名的符號 |
SHN_COMMON | 0xfff2 | 表示一個"COMMON塊"類型,一般來說是未初始化的全局符號 |
SHN_UNDEF | 0 | 表示未定義。在本文件引用,定義在其他目標文件 |
st_value符號值 按之前說的符號值是函數或變數的地址,但需分情況
- 目標文件內,若表示的是符號的定義且不為"COMMON"(即st_shndx不為SHN_COMMON),則st_value表示該符號在段內偏移量
- 目標文件內,若符號位"COMMON",則st_value表示符號的對齊屬性
- 可執行文件內,st_value表示符號的虛擬地址
下麵我們來分析實際的符號表內容
Num對應st_name表示數組下標;Value對應st_value表示符號值;Size對應st_size表示符號大小;Type和Bind對應st_info表示符號類型和綁定信息;Vis對應st_other在c/c++內並未使用,忽略。
我們可以看到符號類型Type存在SECTION類型的,這些類型表示下標為Ndx的段的段名,它們並沒有顯示符號名,因為段名就是它們的符號名.如Num為2的Ndx為1,說明它是".text"段的段名
特殊符號 鏈接器進行鏈接產生可執行文件時,會定義許多特殊符號,這些符號並不是在我們的程式中定義,但我們可以引用它。如:
- _executable_start,表示程式最開始的地址
- _end或end,程式結束地址
- __etext或_etext或etext,表示代碼為結束地址
- _edata或edata,表示數據段結束地址
符號修飾 在以前,編譯後產生的目標文件,符號名與對應的變數函數的名字是一樣的,沒有變化;後來演化出相當多由彙編編寫的庫和目標文件,這時就產生了一個問題,程式若要使用這些庫,就不能使用庫中定義的變數函數的名字作為符號名,否則會發生衝突。為了防止符號名發生衝突,會對原本定義的名字加一些符號,如"_",也就是所謂的符號修飾,這並沒有從根本上解決問題,因此後來c++推出了名稱空間來解決這類問題
函數簽名 函數簽名包含一個函數的函數名、參數類型、所處類、名稱空間及其他信息。函數簽名用於識別不同函數,在編譯器及鏈接器處理符號時,會使用某種名稱修飾方法,使得每個函數簽名對應一個修飾後的名稱;這種方法不僅對函數有效,全局變數和靜態變數也在使用
弱符號和強符號 若我們在多個目標文件中定義了相同名字的全局符號,鏈接時將會出現名稱重覆的錯誤
編譯器預設函數和已初始化的全局變數為強符號;未初始化的全局變數為弱符號。當然,也可以通過"__attribute__((weak))"定義任何一個強符號為弱符號
判斷為強化號還是弱符號,是通過定義,而非引用
下麵我們來分析一個例子
extern int exa;
int strong = 2;
int weak;
__attribute__((weak)) weak2 = 3;
int main()
{
//...
}
上述片段中,強符號有strong、main,弱符號有weak、weak2,exa都不是
鏈接器會按如下規則處理和選擇被多次定義的全局符號:
- 不允許強符號被多次定義,否則鏈接器報符號重覆定義錯誤
- 若一個符號在一個目標文件內為強符號,在其他文件中都為弱符號,則選擇強符號
- 若一個符號在所用目標文件內都為弱符號,則選擇占用空間最大的一個為強符號
儘量不要使用多個不同類型的弱符號,容易導致難以發現的錯誤
弱符號的優點:庫中定義的弱符號可以被用戶定義的強符號覆蓋,使得程式可以使用定義的庫函數
調試信息
目標文件內可能包含調試信息,且調試信息占用很大的空間,比代碼和數據還大幾倍
在gcc編譯時用"-g"參數,編譯器就會在目標文件裡加上調試信息
鏈接
也許你會疑惑已經轉變成機器指令了,為什麼還需要鏈接?為什麼不直接輸出可執行文件反而輸出目標文件?鏈接過程到底包含了什麼?
在電腦最早的時候,編寫並不像現在如此快捷輕鬆,當時的程式員使用機器語言在紙上寫好程式,當程式要被執行時,便手動地將程式寫入存儲設備紙帶(在紙帶上會打相應的孔)。若一條指令需要執行的內容是跳轉到目的地的絕對地址;此時會面臨一個問題,程式並不是一層不變,大概率會經常被修改;若目的地的絕對地址發生變動,程式員又需要手動修改之前執行跳轉的指令,這種來來回回修改會使得開發效率十分低下。這個時候彙編語言如同救世主一般降臨,彙編語言因其使用接近人類的各種符號和標記幫助記憶,開發效率提高了很多。但問題到這裡並沒有戛然而止,隨著軟體規模的日漸龐大,代碼里也快速地膨脹,這導致我們需要考慮將不同功能的代碼以一定方式組織起來,使得更加容易閱讀理解,對於以後維護十分有用。於是,逐漸地人們開始將代碼按功能或性質進行劃分,形成不同的功能模塊,不同模塊間按照層次結構組織;這些模塊相互依賴且相對獨立。問題接踵而至,當一個程式被分割為多個模塊後,這些模塊間如何形成一個單一程式?這就很像模塊間如何通信?
模塊間通信有兩種,都需要知道地址,也就是模塊間符號的引用:
- 模塊間函數調用
- 模塊間變數訪問
綜上,我們便可得出鏈接其實就是將一個複雜的系統逐步分割成小系統,把每個源代碼模塊獨立編譯,再按需將它們組裝。其原理便是將指令對其他符號地址的引用加以修正
鏈接器所要做的便是將各個模塊之間相互作用的部分處理好,使得各個模塊間可以正確銜接。使用鏈接器可以直接引用其他模塊的函數和全局變數而無需知其地址,因為鏈接時鏈接器會根據引用的符號去自動查找符號的地址,再將引用符號地址的指令進行修正
鏈接的過程主要包括:地址空間分配、符號決議、重定位等
以下便是最基本的靜態鏈接過程:每個模塊的源代碼文件經編譯器編譯為目標文件,目標文件和庫一起鏈接形成最終的可執行文件
靜態鏈接
在這之前我們瞭解了基本的預編譯、編譯、彙編、鏈接的過程,並分析了ELF文件輪廓和內容,接下來我們將進入本文的核心內容靜態鏈接
既然我們知道鏈接會將目標文件合併為一個可執行文件,那麼這個過程要經歷哪些步驟?
現在有這樣兩個源文件,main.c文件會引用sub.c內的swap函數和變數,接下來我們要做的是將它們鏈接形成一個可執行文件
//m.c
extern int shared;
void swap( int*, int* );
int main()
{
int x = 100;
swap( &shared, &x );
return 0;
}
//sub.c
int shared = 1;
void swap( int* a, int* b )
{
*a ^= *b ^= *a ^= *b;
}
空間與地址分配
我們知道,可執行文件的代碼段和數據段是通過合併多個目標文件得來的,那麼對於這些目標文件,鏈接器是怎樣重新分配空間給這些目標文件呢?
按序疊加 將各個目標文件按照順序疊加
這種方法非常簡單,但會造成空間上的浪費,因為對每個段都有地址和空間上的對齊要求,這會導致存在許多零散的段,合併的目標文件越多,問題愈加明顯
註:這裡的地址和空間有兩個含義。對於".text"這種有實際數據的段,在文件中和虛擬地址中都要分配空間;而".bss"段裝載前並不占用文件空間,因此對他只是分配虛擬地址空間
- 輸出的可執行文件中的空間
- 裝在後的虛擬地址中的虛擬地址空間
相似段合併 將相同性質的段合併在一起。這種方法是現在鏈接器採用的
相似段合併鏈接分為兩步:
- 空間與地址分配 獲得所有目標文件各自的各個段的長度、屬性、位置,計算出合併後的長度、位置並建立映射關係,將他們的符號表中所有的符號定義和符號引用統一放在一個全局符號表中
- 符號解析與重定位 獲得信息後再進行符號解析、重定位、調整代碼中的地址等
下麵,我們先編譯 m.c、sub.c,再用ld鏈接器進行鏈接
其中, 編譯源碼到目標文件時,若沒有加“-fno-stack-protector”,預設會調用函數“__stack_chk_fail”進行棧相關檢查,且若是手動ld去鏈接,沒有鏈接“__stack_chk_fail”所在庫文件,鏈接時必然會報此項錯誤,因此在編譯時加上“-fno-stack-protector”,強制gcc不做棧檢查。-e main表示將main函數作為程式入口,ld預設入口為_start;-o result表示輸出文件名,預設為a.out
在用objdump查看鏈接前後地址的分配情況
m
sub
result
其中,VMA(virtual memory address)是虛擬地址,LMA(load memory address)是載入地址;大部分情況兩個值相同,但在某些嵌入式系統中可能不同。可以看到,鏈接前目標文件中的所有VMA都為0,因此此時虛擬空間還沒有被分配,等到鏈接後,各個段都會被分配相應的虛擬地址;並且可以看到生成的可執行代碼result中的".text"Size大小是目標文件m.c和sub.c的".text"之和。聰明的你也許發現了,可執行文件中".text"段被分配到了0x4000e8,"data"段被分配到了0x400160,為什麼虛擬地址不是從0開始呢?這與操作系統的進程虛擬地址空間分配規則有關,要將可執行文件中的".text"和".data"段載入到一個新創建的進程中,Linux載入器會分配虛擬頁的一個連續的片(chunk),對於32位系統來說,地址是從0x08048000處開始的;對於64位系統來說,地址是從0x400000處開始的。然後把這些虛擬頁標記為無效的,也就是未被緩存的,將頁表條目指向目標文件中適當的位置(會詳細的內容會在以後的裝載部分討論)
確定符號地址 當完成前面段的虛擬地址確定後,鏈接器就會開始計算各個符號的虛擬地址,因為符號在段內的相對位置是固定的,不會發生變化,因此我們只需給符號加上基礎地址,也就是它所處的段的虛擬地址
符號解析和重定位
空間和地址分配完成後,鏈接器將進行符號解析和重定位,在進入這個主題前,我們先來看看鏈接前對兩個外部符號進行了什麼操作,也就是說編譯m.c時,如何訪問外部符號
我們用objdump -d m.o查看反彙編結果
我們知道程式里代碼都用的虛擬地址,在未進行空間分配前,目標文件代碼段的起始地址為0x00000000.最左邊那一列表示每條指令的偏移量,每一行表示一條指令,其中1d那一條指令表示引用"swap"的位置,第一個位元組表示操作碼,後四節表示被調用函數相對於調用指令的下一條指令的偏移量,相對偏移量也就是0,所以這條callq指令的實際調用地址時mov地址 + 0,也就是0x22;而16那一條指令lea表示對shared的引用,將shared地址賦值給rdi寄存器,前三個位元組為指令碼,後四個位元組時shared地址;其實兩個對象都是臨時地址,在編譯時並不知道外部符號的真正地址,等到鏈接時將真正的地址計算工作交給鏈接器
通過先前的學習,我們知道地址和空間分配後就可確定所有符號的虛擬地址,隨後鏈接器就可根據符號的地址對每個需要重定位的指令進行地址修正
重定位 鏈接器如何知道哪些指令是要被調整的呢?指令的哪些部分需要調整?如何調整?事實上是由重定位表告訴他,重定位表保存了重定位相關的信息 ,描述如何修改相應的段的內容,每個要被重定位的段都有一個對應的重定位表
通過objdump -r m.o查看目標文件的重定位表
每個需要被重定位的地方叫做重定位入口,這裡有四個;重定位入口的偏移量表示該入口在要被重定位的段中的位置
重定位表的結構
typedef struct
{
Elf32_Addr r_offset; /* Address 重定位入口的偏移.對於可重定位文件來說,此值表示該重定位入口所要修正的位置的第一個位元組相對於段起始的偏移量;對於可執行文件或共用對象文件來說,此值表示該重定位入口所要修正的位置的第一個位元組的虛擬地址 */
Elf32_Word r_info; /* Relocation type and symbol index 重定位入口的類型和符號.低八位表示重定位入口的類型,高24位標識重定位入口的符號在符號表中的下標.各處理器的指令格式不同,因此重定位所修正的指令地址格式也不一樣*/
} Elf32_Rel;
typedef struct
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
} Elf64_Rel;
符號解析 重定位時,每個重定位入口都是對一個符號的引用,那麼當鏈接器要對某符號的引用進行重定位時,他需要確定這個符號的地址,因此鏈接器就去全局符號表查找相應的符號,再進行重定位
readelf -s m.o 查看符號表
意料之中,我們可以看到shared和swap為UND類型,也就是未定義類型.在鏈接器掃描完所有目標文件後,必須能從全局符號表中查找到UND類型的符號,否則會報符號未定義的錯誤
靜態庫鏈接
我們知道,在我們平常使用的編程語言中有輸入輸出方式,這些輸入輸出肯定調用了系統提供的API,否則我們不可能僅憑一行代碼就能實現輸入輸出,而這些語言都會將API包裝成一個語言庫。例如,c++的cout需要調用<iostream>語言庫。當然,語言庫中有些函數並不會調用操作系統的API,如strlen()
在一個語言如c++的運行庫中,包含許多和系統功能有關的代碼,如輸入輸出、時間等,這其中包含了許許多多的目標文件,若是將這些文件直接給程式員使用,那恐怕程式員得禿頂了;因此會將這些目標文件打包壓縮在一起,且標註編號和索引,便於查找和檢索,這也就是我們常說的靜態庫
根據上面的內容,我們可以定義靜態庫就是一組目標文件的集合,很多目標文件經過壓縮打包後形成的一個文件
那靜態庫鏈接是什麼呢?顯而易見答案已經呼之欲出了,將我們寫的源文件編譯成目標文件和需要的靜態庫里的目標文件鏈接;當然很有可能,靜態庫里的目標文件里的符號也依賴於其他目標文件,相當於會遞歸很多很多次;很顯然,我們鏈接時不可能將一個完整的目標文件全部包含,由於之前說的會遞歸很多次,這樣會造成運行庫中有許多許多的符號,會造成極大的空間浪費,因此只會包含目標文件中需要的符號
BFD庫
現在的硬體和軟體平臺種類五花八門,這導致了不同平臺都有它自己獨特的目標文件格式,這些差異導致編譯器和鏈接器很難處理不同平臺間的目標文件,因此對於這種問題,我們需要一種統一的介面處理不同平臺格式間的差異。BFD庫就做到了這一點
BFD庫將目標文件抽象為一個統一的模型,這個模型會抽象目標文件的文件頭、段、符號表等等,這樣使得BFD庫的程式只需使用這個抽象的模型就可以操作所有BFD支持的目標文件格式
GCC這種可跨平臺的工具就是使用BFD庫處理目標文件,而不是直接使用目標文件。好處是編譯器和鏈接器處理的目標文件分隔開來,一旦需要支持一種新的目標文件格式,只須在BFD庫中添加對應的格式即可,無需修改編譯器和鏈接器
總結
在本章中,我們講到編譯器和鏈接器為我們服務時究竟做了哪些幕後工作。我們瞭解到從源文件到最終可執行文件的預編譯、編譯、彙編、鏈接整個過程是怎樣的,分析每個步驟的作用和前後間的關係;我們還深入分析了各種目標文件格式及其包含的內容,主要介紹有代碼段、數據段、BSS段、文件頭、段表、重定位表、字元串表、符號表等,發現原來可執行文件、目標文件、庫也不過如此,都是基於段的文件的集合;最後我們介紹了靜態鏈接這類型的奧秘,目標文件在唄鏈接成可執行文件時,目標文件中的段是如何合併的,鏈接器是如何為他們分配地址和空間的,最終地址確定後,鏈接器會將各個目標文件中對外部符號的引用進行解析,將段中重定位指令和數據進行指正,使他們指向正確的位置
請慢慢地咀嚼消化這些知識,接下來我們將進入裝載的世界,學習關於可執行文件裝載到記憶體的知識以及這一過程究竟是怎樣的,它的本質是什麼