程式設計師的自我修養-編譯連結

2023-03-26 15:01:53

常見場景

你是在工作中遇到如下問題或者疑問:

  1. undefined reference to 「function」。連結過程中出現未定義參照。
  2. .a和.so檔案分別是什麼?什麼情況下使用?
  3. 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函數呼叫約定。

規則如下:

  1. 執行call指令前,函數呼叫者將引數入棧,按照函數列表從右到左的順序入棧。
  2. call指令會自動將當前eip入棧,ret指令將自動從棧中彈出該值到eip暫存器。
  3. 被呼叫函數負責:將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

從上面可以得到如下資訊:

  1. 該.o檔案中有static_var.1600,static_var2.1601,global_init_var,global_uninit_var,func1,printf,main等符號
  2. 每個符號在.o檔案中的位置,比如func1,Ndx是1,對應.text Section,Value為0,Size為33,說明func1從.text Section起始位元組開始,佔了33個位元組。
  3. 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),%edxe8 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對映到進行虛擬地址空間,並確定各符號在進行虛擬地址空間中的地址。
第三步修正各符號參照,使其指向符號最終的地址。

怎麼連結?

連結一般分為兩步:

  1. 空間和地址分配。掃碼所有輸入目標檔案,蒐集符號定義和參照,放到全域性符號表,並對Section進行併合。
  2. 符號解析和重定位。

符號重定位依賴重定位表+符號表

# 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動態連結

動態連結優點:

  1. 方便釋出。模組A依賴模組B,如果模組B實現發生了改變,在靜態連結的情況下,模組A需要重新編譯。
  2. 記憶體佔用。模組A和模組B都依賴模組C中的某個函數func,在果靜態連結的情況下,模組A/B同時執行時,func需要在記憶體中存在兩份。

動態連結缺點:

  1. 執行效率不如靜態連結高。

動態連結效果

靜態共用庫

如圖假設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連結選項的作用。該連結選項指定生成的動態庫為動態共用庫。