ELF文件與鏈接過程
一. ELF文件
1. ELF文件種類
ELF文件標准里面把系統中采用ELF格式的文件分為4類,如下所示. 使用linux下的file
命令可以查看一個文件的類型.
- 可執行文件
- 可重定位文件: 包含了代碼和數據的 .o文件, 靜態鏈接庫也歸為它.
- 共享目標文件: 包含了代碼和數據, 可能被動態鏈接的.so文件.
- 核心轉儲文件: 當進程意外終止時,系統可以將該進程的地址空間的內容及終止的一些其它信息保存為該文件類型.
2. 文件內部組成
總體來說,程序源代碼被編譯之后主要分成兩種段,程序指令和程序數據. 另外還有一些其它的必要信息,用於對程序指令與程序數據進行處理或程序在內存加載之類的.
- ELF頭
ELF頭(或者說文件頭)描述了整個文件的的基本信息, 它位於文件的最開始部分, 大小一般為64個字節. 里面包含ELF文件類型,目標機器型號, 程序入口等. 使用命令 readelf -h 文件名
可以查看ELF頭.
可重定位文件的文件頭信息如下所示:
yin@yin-Aspire-V5-471G:~/test$ readelf -h main.o
ELF 頭:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
類別: ELF64
數據: 2 補碼,小端序 (little endian)
版本: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
類型: REL (可重定位文件)
系統架構: Advanced Micro Devices X86-64
版本: 0x1
入口點地址: 0x0
程序頭起點: 0 (bytes into file)
Start of section headers: 960 (bytes into file)
標志: 0x0
本頭的大小: 64 (字節)
程序頭大小: 0 (字節)
Number of program headers: 0
節頭大小: 64 (字節)
節頭數量: 12
字符串表索引節頭: 11
可執行文件的文件頭信息如下所示:
yin@yin-Aspire-V5-471G:~/test$ readelf -h a.out
ELF 頭:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
類別: ELF64
數據: 2 補碼,小端序 (little endian)
版本: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
類型: DYN (共享目標文件)
系統架構: Advanced Micro Devices X86-64
版本: 0x1
入口點地址: 0x4f0
程序頭起點: 64 (bytes into file)
Start of section headers: 6680 (bytes into file)
標志: 0x0
本頭的大小: 64 (字節)
程序頭大小: 56 (字節)
Number of program headers: 9
節頭大小: 64 (字節)
節頭數量: 28
字符串表索引節頭: 27
ELF文件頭結構信息的數據結構定義在/usr/include/elf.h
文件中, 例如64位版本的如下:
#define EI_NIDENT (16)
typedef struct
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf64_Half e_type; /* Object file type */
Elf64_Half e_machine; /* Architecture */
Elf64_Word e_version; /* Object file version */
Elf64_Addr e_entry; /* Entry point virtual address */
Elf64_Off e_phoff; /* Program header table file offset */
Elf64_Off e_shoff; /* Section header table file offset */
Elf64_Word e_flags; /* Processor-specific flags */
Elf64_Half e_ehsize; /* ELF header size in bytes */
Elf64_Half e_phentsize; /* Program header table entry size */
Elf64_Half e_phnum; /* Program header table entry count */
Elf64_Half e_shentsize; /* Section header table entry size */
Elf64_Half e_shnum; /* Section header table entry count */
Elf64_Half e_shstrndx; /* Section header string table index */
} Elf64_Ehdr;
- 程序頭表
程序頭表存在於可執行文件中, 位於ELF文件頭后面, 在程序加載的時候會使用到程序頭表. 對於ELF文件,可以從鏈接角度和加載角度兩方面來看, 對應了鏈接視圖與加載視圖. 加載視圖關心的是程序頭表, 鏈接視圖關心節頭部表. 在鏈接生成可執行文件時, 會把多個節(section)合並對應一個段(segment). 所以節頭部表中的數目少於程序頭表的數目的. 引入程序頭的原因是這樣的: 方便程序的加載, 你想想啊,多個節可能具有相同的讀寫屬性, 把相同屬性的節合並成一個段,一次性加載入內存中, 並且節約內存,方便管理(從虛擬內存的分頁機制與對齊方面考慮).
對於可重定位文件, 讀取它的程序頭表時, 顯示不存在的.
yin@yin-Aspire-V5-471G:~/test$ readelf -l main.o
本文件中沒有程序頭。
對於可執行文件, 讀取它的程序頭表時,是這樣的, 並且還輸出了不同的section 到對應segment的映射關系.
yin@yin-Aspire-V5-471G:~/test$ readelf -l -W a.out
Elf 文件類型為 DYN (共享目標文件)
Entry point 0x4f0
There are 9 program headers, starting at offset 64
程序頭:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0001f8 0x0001f8 R 0x8
INTERP 0x000238 0x0000000000000238 0x0000000000000238 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0007d8 0x0007d8 R E 0x200000
LOAD 0x000df0 0x0000000000200df0 0x0000000000200df0 0x00022c 0x000248 RW 0x200000
DYNAMIC 0x000e00 0x0000000000200e00 0x0000000000200e00 0x0001c0 0x0001c0 RW 0x8
NOTE 0x000254 0x0000000000000254 0x0000000000000254 0x000044 0x000044 R 0x4
GNU_EH_FRAME 0x000694 0x0000000000000694 0x0000000000000694 0x00003c 0x00003c R 0x4
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
GNU_RELRO 0x000df0 0x0000000000200df0 0x0000000000200df0 0x000210 0x000210 R 0x1
Section to Segment mapping:
段節...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .init .plt .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame
03 .init_array .fini_array .dynamic .got .data .bss
04 .dynamic
05 .note.ABI-tag .note.gnu.build-id
06 .eh_frame_hdr
07
08 .init_array .fini_array .dynamic .got
- 節頭部表
section header table, 它保存了文件中所有節的信息, 包含下面要講到的.data/.text/.bss/.dynamic等. 節頭部表就是一個數組,第一個元素都是一個描述節信息的數據結構, 使用objdump -h
或 readelf -S
命令可以查看到. 節頭部表位於文件的后半部分, 在所有section的后面, 為什么放到后面呢, 我也不知道啊. 在ELF文件頭信息中給出來節節頭部表在文件中的偏移位置(Start of section headers)
節頭部表很有用的!!!!鏈接的時候要使用到它,在可可重定位文件中, 信息都是按節保存的.
使用objdump工具顯示的重定位文件中的節頭部表的信息:
yin@yin-Aspire-V5-471G:~/test$ objdump -h main.o
main.o: 文件格式 elf64-x86-64
節:
Idx Name Size VMA LMA File off Algn
0 .text 0000000b 0000000000000000 0000000000000000 00000040 2^0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 0000000c 0000000000000000 0000000000000000 0000004c 2^2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000014 0000000000000000 0000000000000000 00000058 2^2
ALLOC
3 .rodata 0000000c 0000000000000000 0000000000000000 00000058 2^2
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002a 0000000000000000 0000000000000000 00000064 2^0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 0000008e 2^0
CONTENTS, READONLY
6 .eh_frame 00000038 0000000000000000 0000000000000000 00000090 2^3
使用readelf工具顯示的重定位文件中的節頭部表信息如下所示,好像更全一點吧.
yin@yin-Aspire-V5-471G:~/test$ readelf -S main.o
There are 12 section headers, starting at offset 0x3c0:
節頭:
[號] 名稱 類型 地址 偏移量
大小 全體大小 旗標 鏈接 信息 對齊
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
000000000000000b 0000000000000000 AX 0 0 1
[ 2] .data PROGBITS 0000000000000000 0000004c
000000000000000c 0000000000000000 WA 0 0 4
[ 3] .bss NOBITS 0000000000000000 00000058
0000000000000014 0000000000000000 WA 0 0 4
[ 4] .rodata PROGBITS 0000000000000000 00000058
000000000000000c 0000000000000000 A 0 0 4
[ 5] .comment PROGBITS 0000000000000000 00000064
000000000000002a 0000000000000001 MS 0 0 1
[ 6] .note.GNU-stack PROGBITS 0000000000000000 0000008e
0000000000000000 0000000000000000 0 0 1
[ 7] .eh_frame PROGBITS 0000000000000000 00000090
0000000000000038 0000000000000000 A 0 0 8
[ 8] .rela.eh_frame RELA 0000000000000000 00000348
0000000000000018 0000000000000018 I 9 7 8
[ 9] .symtab SYMTAB 0000000000000000 000000c8
0000000000000210 0000000000000018 10 17 8
[10] .strtab STRTAB 0000000000000000 000002d8
000000000000006c 0000000000000000 0 0 1
[11] .shstrtab STRTAB 0000000000000000 00000360
000000000000005c 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
- .data節
該節就是用來保存可變數據的, 包含: 全局變量, 全局靜態變量, 局部靜態變量.
- .rodata節
該節是用來保存只讀數據的,包含: 只讀的全局變量, 只讀的全局靜態變量, 只讀的局部靜態變量.
- .text節
該節保存的是就是程序指令. 使用objdump -d
命令可以反匯編代碼信息. 例如:
yin@yin-Aspire-V5-471G:~/test$ size main.o
text data bss dec hex filename
79 12 20 111 6f main.o
yin@yin-Aspire-V5-471G:~/test$ size a.out
text data bss dec hex filename
1415 556 28 1999 7cf a.out
使用 size
命令可以查看ELF文件的代碼段/數據段/BSS段的長度.
- .bss節
在鏈接后的可執行文件中, 該節保存: 未初始化的全局變量, 初始化為0的全局變量, 未初始化的全局靜態變量, 初始化為0的全局靜態變量, 未初始化的局部靜態變量, 初始化為0的局部靜態變量. 總之吧, 只要它的初始值為0, 都保存在該節內. 無論全局變量還是靜態變量,你不初始化它,它們默認就是0值.
在可重定位文件中, 與可執行文件中有一處不同: 未初始化的全局變量保存在.common節中. 原因是這樣的: 未初始化的全局變量是弱符號, 鏈接器允許多個同名的弱符號存在, 並且鏈接的時候決定使用哪一個弱符號. 而在編譯階段,編譯器在編譯成可重寫位文件時, 不能確定未初始化的全局變量是否會在鏈接成可執行文件時使用, 因為可能其它可重位文件中也存在同名的弱符號,所以呢, 就把所有未初始化的全局變量都放到.common節中,讓鏈接器決定使用哪一個弱符號. 當鏈接器確定了之后, 鏈接成可執行文件時,未初始化的全局變量又最終還是放到了.bss節中.
未初始化的全局靜態變量, 因為它們的作用域只在當前文件內, 所以不可能用其它文件的未初始化的全局靜態變量沖突, 所以它們保存在.bss節就可以了.
.bss節在文件中不占空間,真的是不占一點點的內存空間, 關於它的信息保存在 節頭部表中. 當被加載到內存中時,操作系統會為它分配一塊內存, 並且分配這塊內存初始化為0值. 它的這種特性也就決定了它保存的變量的類型.
為什么叫bss呢, 引用自<<程序員的自我修養>>一書的說明.
bss, block started by symbol, 這個詞最初是UA-SAP匯編器的一個偽指令, 用於為符號預留一塊內存空間. 后來BSS這個詞被作為關鍵字引入一了FAP匯編器中,用於定義符號並且為該符號預留給定數量的未初始化空間.
- .rel.text 和 .rel.data
重定位表, 保存對目標文件的重定位信息, 也就是對代碼段和數據段的絕對地址引用的位置描述. 在進行重定位時, 鏈接器會讀取定位表來決定對給定符號在什么哪里進行重定位.
- .symtab節
符號表中保存了本文件中定義的所有符號信息, 符號是鏈接的接口. 在一個文件中即可能定義了一些符號,也可能引用了其它文件的符號, 它們的信息都會保存到符號表中. 函數是符號,變量名是符號.
全局符號: 包含非靜態函數, 全局變星, 對於鏈接過程,它只關心全局符號, 全局符號是對文件外可見的,它們會被重定位.
局部符號:, 包含靜態函數, 全局靜態變量, 局部靜態變量, 這類符號只在文件內部可見. 我就想局部符號有什么用處呢??查了資料這么說: 調試器可以使用這些局部符號來分析程序或崩潰時的核心轉儲文件, 這些符號對鏈接過程沒有作用, 鏈接器會忽略它們.
extern c 關鍵字
C++為了與C兼容, 在符號管理上,為了不按照C++的符號簽名的方式對符號進行擴展(C++的名稱修飾機制), C++編譯器會將在"extern C"的大括號內的代碼當作C語言來處理.
強符號與弱符號
強符號與弱符號的概念一般都是對全局符號才有用, 因為全局符號是對文件外可見的, 多個文件之間相同的符號名有可能沖突. 局部符號對文件外不可見, 只在文件內部可見, 鏈接的時候不可能沖突, 萬一局部符號定義重復了,編譯的時候就會報錯了.
對於C/C++語言來說,編譯器默認函數名與初始化的全局變量為強符號, 未初始化的全局變量是弱符號. 也可以通過GCC的 "attribute((weak))"來定義弱符號. 鏈接器按以下規則處理全局符號:
1. 不允許強符號被以多次定義.
2. 如果一個符號在某個目標文件中是強符號, 在其它文件中是弱符號,那么鏈接器選擇弱符號.
3. 如果一個弱號在所有目標文件中都是弱符號,選擇其中占用空間最大的那個符號.
強引用與弱引用
強引用: 當沒有在其它文件中找到對應符號的定義時,鏈接器報符號未定義的錯誤.
弱引用: 在處理弱引用時, 如果該符號未定義, 鏈接器不會對該引用報錯,而是默認值為0. 在GCC中, 通過" atrribute((weakref))"擴展關鍵字來聲明對一個符號的弱引用.
- .strtab節
字符串表, 保存ELF文件中所有的字符串. 是這樣的: ELF文件中用到了很多字符串, 比如段名/變量名/字符串變量等. 因為字符串的長度往往不定,怎么保存呢? 一種常見的做法是把字符串集中起來存放到一個表中, 然后使用字符串在表中的偏移來引用字符串.
二. 靜態鏈接
靜態鏈接的過程相對簡單, 也就大概說一下過程啊. 鏈接過程是指由可重定位文件鏈接生成可執行文件. 在可重定位文件中,程序的地址都是從0x0000開始的, 生成可執行文件時, 程序的地址對應了程序加載時的真實虛擬地址,在linux下是從0x400000的之后的某個位置開始的, 靜態鏈接過程大致包含兩個大的過程:空間與地址分配, 符號解析與重定位.
1. 空間與地址分配
一句話,鏈接靜態器把所有的可重定位文件的內容合成到一個文件內, 采用的策略就是相似的section 合並. 合並之后,為程序代碼段與數據段分配虛擬地址空間. 虛擬地址空間分配之后, 所有的全局符號的絕對地址信息也就確定了, 為接下來的符號解析與重定位作好了准備.
2. 符號解析與重定位
在一個文件中可能定義了一個全局符號,也可能引用了其它文件定義的全局符號. 符號解析過程就是找到引用的外部符號的在哪里, 去哪里查找呢? 那就要用的之前說過了符號表了, 所有符號的信息都在符號表里保存着. OK, 找到符號之后,也就能知道該符號對應的虛擬地址空間了. 接下來就進行重定位了.
問題來了,我怎么知道對哪個位置進行重定位呢? 這就用於了之前說的重定位表了, 該表里保存了所有的需要進行重定位的信息. 知道了重定位的位置,也知道了重定位到的引用符號所有的虛擬地址空間, 這個工作就可以搞定了.
真實情況是符號解析過程是伴隨着重定位過程進行的, 在重定位過程中,需要使用到哪一個符號,才對那個符號進行解析.
重定位的方式有兩種: 絕對地址尋址和相對地址尋址. 反正吧, 關於如何重定位的所有信息都在重定位表中保存着呢, 都可以搞定,不是問題.細節先不寫了. 沒有大多必要細說吧,了解就OK了, 咱們很少人會寫鏈接器的.
COMMON塊: 之前說過在可重定位文件中,全局未初始化的變量作為弱符號處理, 保存在COMMON塊中. 因為可能多個相同弱符號的存在, 編譯器並不能確定該弱符號最終占的空間大小,也就沒有辦法在BSS段中分配空間. 但是鏈接器在鏈接過程中可以確定符號的大小(因為它拿到了所有同名的弱符號,它決定了要使用哪一個弱符號),所以鏈接器在生成可執行文件時又把弱符號放到了BSS段中,為它分配空間.
3. 靜態庫及鏈接過程
使用gcc -c
命令可以生成重定位文件, 然后進行靜態鏈接過程, 例如:
yin@yin-Aspire-V5-471G:~/test$ gcc -c main.c -o relo.o
yin@yin-Aspire-V5-471G:~/test$ gcc relo.o -o a.out
yin@yin-Aspire-V5-471G:~/test$ ls
a.out main.c relo.o
靜態庫就是把多個可重定位文件打包放一起了, 使用ar壓縮工具把目標文件壓縮在一起,生成靜態庫, 通常以.a后綴結尾. 舉個例子哈, 生成一個新的靜態庫libnew:
yin@yin-Aspire-V5-471G:~/test$ cat a.c
int add(int a, int b)
{
return a + b;
}
yin@yin-Aspire-V5-471G:~/test$ cat b.c
int sub(int a, int b)
{
return a - b;
}
yin@yin-Aspire-V5-471G:~/test$ gcc -c a.c b.c
yin@yin-Aspire-V5-471G:~/test$ ar -qs libnew.a a.o b.o
yin@yin-Aspire-V5-471G:~/test$ ls
a.c a.o b.c b.o libnew.a
使用ar -t
命令可以顯示給定的靜態庫包含了哪些目標文件:
yin@yin-Aspire-V5-471G:~/test$ ar -t libnew.a
a.o
b.o
具體鏈接器如何使用靜態庫來解析引用的, 參考一下<<深入理解計算機系統>>一書, 不想寫了,沒有啥意思.
其它
對於C++代碼, 鏈接器還會做:
1. 重復代碼的消除工作, 因為內聯函數/虛函數表/模板等機制都有可能導致在不同的編譯單元中生成相同的代碼. 消除的方法之一是把它們放到一個對應特性名稱的單獨段中, 鏈接器進行過濾.
2. 全局構造與析構. 在linux下,一般程序的入口為start, 這個函數是glibc的一部分, 它會完成一系列的初始化過程,然后調用main函數, main函數執行完成之后,對回到初始的部分,做一些清理工作. c++的全局對象的構造與構造函數是典型的例子. 在ELF文件中是這樣實現的, 它定義了兩個特殊的段: .init和.fini段.里面保存了main函數執行前與執行后的相關代碼信息.