盤古開天辟地!我們寫了個C語言源文件,那從源文件到可執行程序這中間又發生了什么?編譯,鏈接這些概念又是什么意思?帶着對這些問題的好奇,我查了一些資料。其中,主要參考的是《程序員的自我修養》這本書和一些網上的博客。
在windows
下經常只需要單擊Run
或者Debug
就可以運行一個C語言程序,這種便利隱藏了背后的復雜機制,而我想知道這背后到底發生了什么。
本文所使用的系統是ubuntu
,但這些概念也適用於windows
下。
1. 編譯源文件的四個階段
假如我們寫了一個很簡單的helloworld.c
程序:
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Hello,World!\n");
return 0;
}
我們都知道運行命令
gcc helloworld.c -o helloworld
便可以對這個文件進行編譯,並命名可執行文件為helloworld
。然后運行
./helloworld
Hello,World!
便可以執行該文件,但是這背后又經歷了什么呢?
注意:
本文並不是一篇嚴謹的探討編譯過程的文章,只是我對這個問題了解過程的一個梳理。
1.1 預處理(preprocessing)
在預處理階段,我們可以簡單理解就是處理以"#"開始的那些預處理指令,比如說:
#define,#include,#if,#elif,#else,#endif
預處理器會按照這些指令的意義進行處理,將#define
定義的宏進行替換展開,將#include
包含的文件整體替換進來。
可以運行命令
gcc -E helloworld.c -o helloworld.i
來得到經過預處理后的文件,檢查可以發現預處理確實幫我們把#include
的文件包含進來了,另外在文件中還包含了一些行號信息,以便之后程序出錯提示錯誤所在的位置。
1.2 編譯(compile)
這一步是將上一步得到的*.i
進行編譯,得到匯編代碼,可以運行命令
gcc -S helloworld.i -o helloworld.s
來得到經過匯編后的文件,該文件的其中一部分如下:
main:
...
leaq .LC0(%rip), %rcx
call puts
...
正好對應我們在主程序中調用的函數printf
,於是我們知道在這一步是生成了匯編文件。
1.3 匯編(assembly)
這一步是將上一步的匯編代碼匯編為具體的機器代碼,可以運行命令
gcc -c helloworld.s -o helloworld.o
生成的helloworld.o
可以稱為目標文件,下面我們對目標文件來檢查,幫助理解鏈接
過程。
1.3.1 目標文件的結構
上一步中生成的是目標文件,但這個目標文件還沒有經過鏈接,也就是它其中的一些符號還無法確定,比如說在上面的printf
我們就無法確定在哪里去尋找這個函數的具體定義,通過頭文件stdio.h
我們只是知道了它的定義形式,知道如何去調用它,但是具體執行的時候是需要代碼的,那么去哪里找呢?尋找printf
並將它的地址寫入到我們的程序中就是鏈接的作用。
我們在系統中經常打交道的文件有
- 可執行文件(Executable File),比如
Windows
下的.exe
,或者linux
下/bin/bash
文件 - 共享目標文件(Shared Object File),比如
Windows
下的.dll
,或者linux
下.so
文件 - 可重定位文件(Relocatable File),我們上面生成的文件便是這種文件,可重定位指的是程序中的一些位置的符號(函數名,變量名)的地址還未確定,在之后的鏈接階段需要重新定位
在Linux
下可以使用命令file
來查看文件的具體格式,讓我們運行
$ file helloworld.o
helloworld.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
那么具體來說,目標文件到底包含什么呢?首先一定會包含代碼,其次是數據(定義的變量),除此以外,我們還關心的是文件中包含的符號表,它是我們后續執行鏈接最重要的內容了。
運行命令
$ readelf -S helloworld.o
可以查看我們目標文件的段表,關於段表的詳細介紹請查看《程序員的自我修養》這本書。
There are 13 section headers, starting at offset 0x2d8:
節頭:
[號] 名稱 類型 地址 偏移量
大小 全體大小 旗標 鏈接 信息 對齊
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
0000000000000022 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000228
0000000000000030 0000000000000018 I 10 1 8
[ 3] .data PROGBITS 0000000000000000 00000062
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 00000062
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .rodata PROGBITS 0000000000000000 00000062
000000000000000d 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 0000006f
000000000000002c 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 0000009b
0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 000000a0
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000258
0000000000000018 0000000000000018 I 10 8 8
[10] .symtab SYMTAB 0000000000000000 000000d8
0000000000000120 0000000000000018 11 9 8
[11] .strtab STRTAB 0000000000000000 000001f8
000000000000002e 0000000000000000 0 0 1
[12] .shstrtab STRTAB 0000000000000000 00000270
0000000000000061 0000000000000000 0 0 1
我們關心的是上述段表中的2
號段表:.rela.text
可重定位表。正如我們之前所說的,在鏈接階段要對可重定位文件中的一些符號進行重定位,所以我們必須了解哪些符號需要進行定位,而.rela.text
就是用來記錄相應的符號。
其中,符號表中會包含幾種符號:
- 在本文件中定義的符號,可以被其它目標文件所引用
- 在本文件中引用的符號,但卻沒有在本文件中定義
- ...
我們先運行命令
$ nm helloworld.o
U _GLOBAL_OFFSET_TABLE_
0000000000000000 T main
U puts
來查看我們的目標文件中的符號表,可以看到我們兩個符號main
和puts
。之所以不是printf
可能是編譯中進行了改變。
讓我們運行另外一個命令來詳細查看符號表:
$ readelf -s helloworld.o
Symbol table '.symtab' contains 12 entries:
Num: Value Size Type Bind Vis Ndx Name
......
9: 0000000000000000 34 FUNC GLOBAL DEFAULT 1 main
10: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND _GLOBAL_OFFSET_TABLE_
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND puts
又看到了我們熟悉的兩個符號,由於main
是在本文件中定義的所以它的類型是FUNC
函數,且Ndx=1
可以得知位於代碼段,而puts
由於未定義,所以Ndx=UND(undefine)
,因此通過符號表我們便可以獲得哪些符號是在本文件中定義的,哪些符號是需要進行重定位的。
1.4 鏈接(link)
上面我們知道了符號表的存在,下面我們詳細說明下鏈接的過程。
假設我們有了兩個文件,a.c
和b.c
。例子來自於《程序員的自我修養》。
/* a.c */
extern int shared;
int main(){
int a=100;
swap(&a, &shared);
return 0;
}
/* b.c */
int shared = 1; // default is global variable, can be accessed by external program
void swap(int *a, int *b){
*a ^= *b ^= *a ^= *b; // swap value
}
首先使用gcc
編譯這兩個文件
$ gcc -c a.c b.c
然后我們會得到兩個文件a.o
,b.o
,分別查看這兩個文件的符號表
$ readelf -s a.o
Symbol table '.symtab' contains 13 entries:
Num: Value Size Type Bind Vis Ndx Name
......
8: 0000000000000000 81 FUNC GLOBAL DEFAULT 1 main
9: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND shared
11: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND swap
$ readelf -s b.o
Symbol table '.symtab' contains 10 entries:
Num: Value Size Type Bind Vis Ndx Name
......
8: 0000000000000000 4 OBJECT GLOBAL DEFAULT 2 shared
9: 0000000000000000 75 FUNC GLOBAL DEFAULT 1 swap
於是,我們可以看出,在a.o
中只定義了一個全局符號main
,而shared
和swap
都是未定義,而在b.o
中,shared
和swap
則是定義了的。
我們將采用的鏈接命令為
$ ld a.o b.o -e main -o ab
- -e 表示
main
作為主函數入口 - -o 表示輸出文件名
然后查看分配前后地址的分配情況
$ objdump -h a.o
a.o: 文件格式 elf64-x86-64
節:
Idx Name Size VMA LMA File off Algn
0 .text 00000051 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000000 0000000000000000 0000000000000000 00000091 2**0
CONTENTS, ALLOC, LOAD, DATA
......
$ objdump -h b.o
b.o: 文件格式 elf64-x86-64
節:
Idx Name Size VMA LMA File off Algn
0 .text 0000004b 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .data 00000004 0000000000000000 0000000000000000 0000008c 2**2
CONTENTS, ALLOC, LOAD, DATA
......
我嘗試了好幾遍運行命令
$ ld a.o b.o -e main -o ab
但是都提示一個錯誤
a.o:在函數‘main’中:
a.c:(.text+0x4b):對‘__stack_chk_fail’未定義的引用
不知道為什么,於是我只好使用命令
$ gcc a.o b.o -o ab
但是生成后的文件和作者的就不太一樣了,如下
節:
Idx Name Size VMA LMA File off Algn
......
13 .text 00000222 0000000000000560 0000000000000560 00000560 2**4
......
22 .data 00000014 0000000000201000 0000000000201000 00001000 2**3
CONTENTS, ALLOC, LOAD, DATA
23 .bss 00000004 0000000000201014 0000000000201014 00001014 2**0
ALLOC
24 .comment 0000002b 0000000000000000 0000000000000000 00001014 2**0
CONTENTS, READONLY
但是仍然是可以看出VMA(虛擬內存地址)已經被賦值了,而在之前的a.o
和b.o
中都是沒有賦值的。
到這一步的意思是經過鏈接,我們將兩個目標文件合成到一個文件中了,並且每個函數都有自己的相對地址,這時候我們就可以給每一個符號賦予地址了。
運行命令
$ readelf -s ab
來查看符號表,只列出相關的內容
Symbol table '.symtab' contains 66 entries:
Num: Value Size Type Bind Vis Ndx Name
59: 000000000000066a 81 FUNC GLOBAL DEFAULT 14 main
62: 00000000000006bb 75 FUNC GLOBAL DEFAULT 14 swap
65: 0000000000201010 4 OBJECT GLOBAL DEFAULT 23 shared
我們可以看出相關符號已經被賦予了具體的地址空間,也就是我們完成了鏈接過程。
在完成上述過程后,我們運行命令來反匯編查看
$ objdump -d ab
000000000000066a <main>:
66a: 55 push %rbp
66b: 48 89 e5 mov %rsp,%rbp
66e: 48 83 ec 10 sub $0x10,%rsp
672: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
679: 00 00
67b: 48 89 45 f8 mov %rax,-0x8(%rbp)
67f: 31 c0 xor %eax,%eax
681: c7 45 f4 64 00 00 00 movl $0x64,-0xc(%rbp)
688: 48 8d 45 f4 lea -0xc(%rbp),%rax
68c: 48 8d 35 7d 09 20 00 lea 0x20097d(%rip),%rsi # 201010 <shared>
693: 48 89 c7 mov %rax,%rdi
696: b8 00 00 00 00 mov $0x0,%eax
69b: e8 1b 00 00 00 callq 6bb <swap> # <swap> 6bb
6a0: b8 00 00 00 00 mov $0x0,%eax
6a5: 48 8b 55 f8 mov -0x8(%rbp),%rdx
6a9: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx
6b0: 00 00
6b2: 74 05 je 6b9 <main+0x4f>
6b4: e8 87 fe ff ff callq 540 <__stack_chk_fail@plt>
6b9: c9 leaveq
6ba: c3 retq
注意到swap
以及變量shared
的地址已經被正確地賦值給了程序,作為對比我們查看下在鏈接之前程序的內容
$ objdump -d a.o
a.o: 文件格式 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: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
f: 00 00
11: 48 89 45 f8 mov %rax,-0x8(%rbp)
15: 31 c0 xor %eax,%eax
17: c7 45 f4 64 00 00 00 movl $0x64,-0xc(%rbp)
1e: 48 8d 45 f4 lea -0xc(%rbp),%rax
22: 48 8d 35 00 00 00 00 lea 0x0(%rip),%rsi # 29 <main+0x29>
29: 48 89 c7 mov %rax,%rdi
2c: b8 00 00 00 00 mov $0x0,%eax
31: e8 00 00 00 00 callq 36 <main+0x36>
36: b8 00 00 00 00 mov $0x0,%eax
3b: 48 8b 55 f8 mov -0x8(%rbp),%rdx
3f: 64 48 33 14 25 28 00 xor %fs:0x28,%rdx
46: 00 00
48: 74 05 je 4f <main+0x4f>
4a: e8 00 00 00 00 callq 4f <main+0x4f>
4f: c9 leaveq
50: c3 retq
我們要注意的是偏移22
和偏移31
分別對應着shared
和swap
的調用,而第二列的十六進制代表這條指令,每個指令的后四個字節為地址,可以看出這些地址都是0
,這說明在文件a.o
中,由於無法確定具體的地址,此時編譯器只是將其賦了一個特殊的地址0x0
,然后在最后的鏈接階段再完成正確的地址賦值。
我們還可以運行命令
$ objdump -r a.o
a.o: 文件格式 elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000025 R_X86_64_PC32 shared-0x0000000000000004
0000000000000032 R_X86_64_PLT32 swap-0x0000000000000004
000000000000004b R_X86_64_PLT32 __stack_chk_fail-0x0000000000000004
其中的offset
描述了要重定位的位置。
2. 總結
事實上,在《程序員的自我修養》這本書中作者對於細節的探討很深入,要想完全理解掌握實在太難。
我主要想總結下關於鏈接部分。大概的過程就是:
- 鏈接器接收到輸入文件
- 收集每個輸入文件的段表,合成一個全局符號表,這張表里包含所有定義的符號
- 如果是靜態鏈接,將多個輸入文件合並,進行地址空間的分配,在這一步完成之后所有符號的具體地址就定了
- 然后再對每一個輸入文件中需要重定位的符號重新定位到正確的地址處