常見場景
你是在工作中遇到如下問題或者疑問:
- undefined reference to “function”。連結過程中出現未定義引用。
- .a和.so檔案分別是什麼?什麼情況下使用?
- extern "C"有什麼作用?
等等...
編譯過程
我們平時編譯,如果沒有加任何編譯引數將預設執行預處理,編譯,彙編,連結等步驟。
ELF檔案格式
每一個cpp檔案會生成一個.o檔案。.o檔案裡面有什麼資訊?多個.o檔案如何合併成一個可執行檔案。可執行檔案的檔案裡有都有什麼資訊?
看下下面的例子:
int global_init_var = 84;
int global_uninit_var;
void func1(int i) {
printf("%d\n", i);
}
int main() {
static int static_var = 85;
static int static_var2;
int a = 1;
int b;
func1(static_var + static_var2 + a + b);
return 0;
}
為了探究.o檔案內容,只編譯不連結gcc -c whats_in_elf.c -o whats_in_elf.o
ELF可以用objdump,readelf等工具檢視內容。這裡用readelf -S whats_in_elf.o檢視section headers:
# daihaonan link_load $ readelf -S whats_in_elf.o
There are 11 section headers, starting at offset 0x114:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00000000 000034 000051 00 AX 0 0 4
[ 2] .rel.text REL 00000000 000424 000028 08 9 1 4
[ 3] .data PROGBITS 00000000 000088 000008 00 WA 0 0 4
[ 4] .bss NOBITS 00000000 000090 000004 00 WA 0 0 4
[ 5] .rodata PROGBITS 00000000 000090 000004 00 A 0 0 1
[ 6] .comment PROGBITS 00000000 000094 00002d 01 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 00000000 0000c1 000000 00 0 0 1
[ 8] .shstrtab STRTAB 00000000 0000c1 000051 00 0 0 1
[ 9] .symtab SYMTAB 00000000 0002cc 0000f0 10 10 10 4
[10] .strtab STRTAB 00000000 0003bc 000065 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
可以看到.o檔案由很多section組成,每個section都有size, file off等描述其在檔案內位置的屬性。元資訊記錄在File header中,其中有e_shoff欄位指向Section Header Table,Section Header Table是個陣列結構儲存每個Section資訊。
檢視Header:
.o檔案總體格式如下:
當然還有很多其它section,.text,.data,.rodata,.symtab,.rel.text段是最主要的段,分別儲存程式碼資訊,全域性資料,全域性只讀資料,符號表,程式碼段重定位表。
.text Section
將程式碼反彙編objdump -s -d whats_in_elf.o
# daihaonan link_load $ objdump -d whats_in_elf.o
whats_in_elf.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <func1>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: 89 7d fc mov %edi,-0x4(%rbp)
b: 8b 45 fc mov -0x4(%rbp),%eax
e: 89 c6 mov %eax,%esi
10: bf 00 00 00 00 mov $0x0,%edi
15: b8 00 00 00 00 mov $0x0,%eax
1a: e8 00 00 00 00 callq 1f <func1+0x1f>
1f: c9 leaveq
20: c3 retq
0000000000000021 <main>:
21: 55 push %rbp
22: 48 89 e5 mov %rsp,%rbp
25: 48 83 ec 10 sub $0x10,%rsp
29: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%rbp)
30: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 36 <main+0x15>
36: 8b 05 00 00 00 00 mov 0x0(%rip),%eax # 3c <main+0x1b>
3c: 8d 04 02 lea (%rdx,%rax,1),%eax
3f: 03 45 f8 add -0x8(%rbp),%eax
42: 03 45 fc add -0x4(%rbp),%eax
45: 89 c7 mov %eax,%edi
47: e8 00 00 00 00 callq 4c <main+0x2b>
4c: b8 00 00 00 00 mov $0x0,%eax
51: c9 leaveq
52: c3 retq
可以看到func1和main兩個函式的反彙編程式碼。
順便可以瞭解下gcc函式呼叫約定。
規則如下:
- 執行call指令前,函式呼叫者將引數入棧,按照函式列表從右到左的順序入棧。
- call指令會自動將當前eip入棧,ret指令將自動從棧中彈出該值到eip暫存器。
- 被呼叫函式負責:將ebp入棧,esp的值賦給ebp。所以反彙編一個函式會發現開頭兩個指令都是
push %ebp, mov %esp,%ebp
一個例子:
.data和.rodat Section
Contents of section .data:
0000 54000000 55000000 T...U...
Contents of section .rodata:
0000 25640a00 %d..
可以看到.data Section有8個位元組,分別是0x54和0x55對應全域性變數static_var和global_init_var。
.rodata Section只有4個位元組儲存%d\n三個字元。
從這裡可以直觀看到全域性有初值的變數是會在ELF檔案中分配空間的,而a,b這種棧上分配的變數不會ELF檔案中分配空間,只會在執行到該函式的是在棧上動態分配。
.symtab Section
可以用readelf -s whats_in_elf.o
檢視符號表
# daihaonan link_load $ readelf -s whats_in_elf.o
Symbol table '.symtab' contains 16 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS whats_in_elf.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 static_var.1600
7: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 static_var2.1601
8: 0000000000000000 0 SECTION LOCAL DEFAULT 7
9: 0000000000000000 0 SECTION LOCAL DEFAULT 8
10: 0000000000000000 0 SECTION LOCAL DEFAULT 6
11: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_init_var
12: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM global_uninit_var
13: 0000000000000000 33 FUNC GLOBAL DEFAULT 1 func1
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
15: 0000000000000021 50 FUNC GLOBAL DEFAULT 1 main
從上面可以得到如下資訊:
- 該.o檔案中有static_var.1600,static_var2.1601,global_init_var,global_uninit_var,func1,printf,main等符號
- 每個符號在.o檔案中的位置,比如func1,Ndx是1,對應.text Section,Value為0,Size為33,說明func1從.text Section起始位元組開始,佔了33個位元組。
- printf這個符號在.o檔案中並沒有定義,所以它的Ndx是UND
用g++ whats_in_elf.c -o whats_in_elf2.o
重新編譯,會發現
# daihaonan link_load $ readelf -s whats_in_elf2.o
Symbol table '.symtab' contains 17 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS whats_in_elf.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 _ZZ4mainE10static_var
7: 0000000000000004 4 OBJECT LOCAL DEFAULT 4 _ZZ4mainE11static_var2
8: 0000000000000000 0 SECTION LOCAL DEFAULT 7
9: 0000000000000000 0 SECTION LOCAL DEFAULT 8
10: 0000000000000000 0 SECTION LOCAL DEFAULT 6
11: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_init_var
12: 0000000000000000 4 OBJECT GLOBAL DEFAULT 4 global_uninit_var
13: 0000000000000000 33 FUNC GLOBAL DEFAULT 1 _Z5func1i
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND __gxx_personality_v0
15: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _Z6printfPKcz
16: 0000000000000021 50 FUNC GLOBAL DEFAULT 1 main
原來的func1變成了_Z5func1i,為了防止符號衝突,C++引入了符號修飾的概念。
所以在C++裡如果希望動態庫中某個函式能被正確載入,需要加上extern "C"
方式符號被修飾,比如:
extern "C"
{
ProcessorBase* create_processor(const std::string& processor_name)
{
...
}
}
載入該符號的地方才能正確找到create_processor這個符號。(PFUNC_CREATE_PROCESSOR_CALL)dlsym(handle,"create_processor");
.rel.text Section
對於可重定位的ELF檔案,必須包含重定位Section,一個ELF檔案中可能有多個重定位Section,比如.text有需要重定位的地方,那麼會有一個.rel.text表,詳細見下文。
靜態連結
為什麼需要連結?
考慮如下程式:
a.c
extern int shared;
int main() {
int a = 100;
swap(&a, &shared);
}
b.c
int shared = 1;
void swap(int* a, int* b) {
*a ^= *b ^= *a ^= *b;
}
分別將a.c和b.c進行編譯,然後檢視程式碼段反彙編。
# daihaonan link_load $ objdump -d a.o
a.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 83 ec 10 sub $0x10,%rsp
8: c7 45 fc 64 00 00 00 movl $0x64,-0x4(%rbp)
f: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 15 <main+0x15>
15: 8b 45 fc mov -0x4(%rbp),%eax
18: 89 d6 mov %edx,%esi
1a: 89 c7 mov %eax,%edi
1c: b8 00 00 00 00 mov $0x0,%eax
21: e8 00 00 00 00 callq 26 <main+0x26>
26: c9 leaveq
27: c3 retq
main中會引用全域性變數shared,呼叫swap函式,但是shared和swap都不是定義在a.o中的,而是定義在b.o中。所以a.o中對shared的引用為0x0(%rip),%rip暫存器中儲存的是當前執行指令的地址,對swap呼叫為e8 00 00 00 00,這是一條近址相對位移呼叫指令,e8是指令碼,00 00 00 00是運算元,也就是被呼叫函式相對於呼叫指令的下一條指令的偏移量。這裡因為不知道swap函式在哪,所以暫時用00 00 00 00來代替。
所以我們可以得出連結的一個主要作用是對一些全域性變數,函式引用指令進行修正。
連結後達到什麼效果?
將a.o和b.o連結在一起。ld a.o b.o -e main -o ab
然後再來看下ab中main的反彙編程式碼
# daihaonan link_load $ objdump -S ab
ab: file format elf64-x86-64
Disassembly of section .text:
00000000004000e8 <main>:
4000e8: 55 push %rbp
4000e9: 48 89 e5 mov %rsp,%rbp
4000ec: 48 83 ec 10 sub $0x10,%rsp
4000f0: c7 45 fc 64 00 00 00 movl $0x64,-0x4(%rbp)
4000f7: 8b 15 bb 00 20 00 mov 0x2000bb(%rip),%edx # 6001b8 <shared>
4000fd: 8b 45 fc mov -0x4(%rbp),%eax
400100: 89 d6 mov %edx,%esi
400102: 89 c7 mov %eax,%edi
400104: b8 00 00 00 00 mov $0x0,%eax
400109: e8 02 00 00 00 callq 400110 <swap>
40010e: c9 leaveq
40010f: c3 retq
連結後再來看main函式的反彙編程式碼。有三個地方變動了mov 0x0(%rip),%edx
變成了mov 0x2000bb(%rip),%edx
,e8 00 00 00 00
變成了e8 02 00 00 00
。最左側的地址變成了全域性的虛擬地址,這說明連結還會分配虛擬地址空間,連結結束,每個函式,每個全域性變數在虛擬地址空間內的地址就確定了。
callq下一條指令地址為0x40010e再加上0x02,等於0x400110。所以swap函式程式碼起始地址應該是0x400110。用objdump -S ab來驗證下。
0000000000400110 <swap>:
400110: 55 push %rbp
400111: 48 89 e5 mov %rsp,%rbp
400114: 53 push %rbx
400115: 48 89 7d f0 mov %rdi,-0x10(%rbp)
400119: 48 89 75 e8 mov %rsi,-0x18(%rbp)
40011d: 48 8b 45 f0 mov -0x10(%rbp),%rax
400121: 8b 10 mov (%rax),%edx
400123: 48 8b 45 e8 mov -0x18(%rbp),%rax
400127: 8b 08 mov (%rax),%ecx
400129: 48 8b 45 f0 mov -0x10(%rbp),%rax
40012d: 8b 18 mov (%rax),%ebx
40012f: 48 8b 45 e8 mov -0x18(%rbp),%rax
400133: 8b 00 mov (%rax),%eax
400135: 31 c3 xor %eax,%ebx
400137: 48 8b 45 f0 mov -0x10(%rbp),%rax
40013b: 89 18 mov %ebx,(%rax)
40013d: 48 8b 45 f0 mov -0x10(%rbp),%rax
400141: 8b 00 mov (%rax),%eax
400143: 31 c1 xor %eax,%ecx
400145: 48 8b 45 e8 mov -0x18(%rbp),%rax
400149: 89 08 mov %ecx,(%rax)
40014b: 48 8b 45 e8 mov -0x18(%rbp),%rax
40014f: 8b 00 mov (%rax),%eax
400151: 31 c2 xor %eax,%edx
400153: 48 8b 45 f0 mov -0x10(%rbp),%rax
400157: 89 10 mov %edx,(%rax)
400159: 5b pop %rbx
40015a: c9 leaveq
40015b: c3 retq
果然swap起始地址是0x400110。
a.o+b.o到ab的過程大致如下圖:
第一步對a.o和b.o相同的Section進行併合。
第二步將ab對映到進行虛擬地址空間,並確定各符號在進行虛擬地址空間中的地址。
第三步修正各符號引用,使其指向符號最終的地址。
怎麼連結?
連結一般分為兩步:
- 空間和地址分配。掃碼所有輸入目標檔案,蒐集符號定義和引用,放到全域性符號表,並對Section進行併合。
- 符號解析和重定位。
符號重定位依賴重定位表+符號表
# daihaonan link_load $ objdump -r a.o
a.o: file format elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000011 R_X86_64_PC32 shared-0x0000000000000004
0000000000000022 R_X86_64_PC32 swap-0x0000000000000004
重定位表中記錄了哪些地方需要修正,這裡可以看到.text的0x11偏移處引用了shared變數,所以需要修正,.text的0x22偏移處引用了swap函式,也需要修正,
而.symtab Section記錄了符號所在的位置。
# daihaonan link_load $ readelf -s b.o
Symbol table '.symtab' contains 10 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS b.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 2
4: 0000000000000000 0 SECTION LOCAL DEFAULT 3
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000000 0 SECTION LOCAL DEFAULT 6
7: 0000000000000000 0 SECTION LOCAL DEFAULT 4
8: 0000000000000000 4 OBJECT GLOBAL DEFAULT 2 shared
9: 0000000000000000 76 FUNC GLOBAL DEFAULT 1 swap
連結器有了這倆資訊,可以輕而易舉完成符號重定位。
動態連結
靜態連結VS動態連結
動態連結優點:
- 方便釋出。模組A依賴模組B,如果模組B實現發生了改變,在靜態連結的情況下,模組A需要重新編譯。
- 記憶體佔用。模組A和模組B都依賴模組C中的某個函式func,在果靜態連結的情況下,模組A/B同時執行時,func需要在記憶體中存在兩份。
動態連結缺點:
- 執行效率不如靜態連結高。
動態連結效果
靜態共享庫
如圖假設A.so又依賴B.so中的a變數和foo函式,當呼叫foo的時候,動態連結器會將B.so載入到記憶體load_address處,foo在B.so內是固定的y位元組偏移出。所以foo在程式內的虛擬地址就是load_address+y。然後動態連結器修改A.so中call foo指令出程式碼,將foo地址修改為load_address+y。至此動態連結完成。和靜態連結的區別在於動態連結將地址重定位推遲到了執行時。
動態共享庫
上面這種靜態共享庫有個問題,就是指令部分沒法在多個程式之間共享,從而失去了節省記憶體的優點。
假設有兩個程式,做的事情都是A.so中呼叫B.so中的foo函式和引用a變數。
程式1A.so被載入到a0虛擬地址,程式2中A.so被動態載入到a1虛擬地址,靜態共享庫的虛擬記憶體分佈如下:
A.so中的程式碼會被重定位,並且重定位值不一樣,程式1中a變數在虛擬地址load_address1+x處,而在程式2中a變數在虛擬地址load_address2+x處。所以A.so的程式碼在記憶體中需要儲存多份。
如果我們把需要重定位的地方單獨抽出來放到資料區,這樣a變數被載入到哪個地址,程式碼部分都不需要變動,那麼兩個程式可以只在實體記憶體中載入一份程式碼。使用這種機制的共享庫叫做動態共享庫。
相同的動態共享庫的虛擬記憶體分佈如下:
這種模式下,程式碼中需要被重定位的地方被放到了GOT中,動態載入重定位的時候只需要修改GOT就可以了,程式碼部分不需要被修改。缺點也很明顯就是多了一層索引。
這就是-fPIC連結選項的作用。該連結選項指定生成的動態庫為動態共享庫。