一舆声、前言
在開發(fā)程序的過程中,編譯和鏈接是一定會經(jīng)歷但很少被重視的步驟柳爽,通常這兩個步驟會被 IDE 封裝媳握,開發(fā)者只需一鍵構(gòu)建即可,但遇到錯誤(尤其是鏈接相關(guān)的錯誤)時磷脯,如果不了解編譯和鏈接的原理蛾找,就很難定位并解決問題。本文則嘗試分析并記錄程序編譯和鏈接的整個過程赵誓。
二打毛、什么是編譯?
編譯的過程其實(shí)是將我們程序的源代碼翻譯成CPU能夠直接運(yùn)行的機(jī)器代碼俩功。
#include <stdio.h>
int add(int a, int b);
int main() {
printf("Hello World!\n");
int result = add(5, 5);
return 0;
}
其中打印一行文本幻枉,并調(diào)用了一個 add()
函數(shù),該函數(shù)被定義在另一個源文件 math.c
中诡蜓,代碼如下:
int add(int a, int b) {
return a + b;
}
2.1 編譯源碼
接下來熬甫,我們可以調(diào)用 gcc -c
來分別編譯這兩個文件,命令如下:
gcc -c main.c
gcc -c math.c
備注:常用的 C/C++ 編譯器除了 gcc 還有 clang万牺、msvc 等等罗珍。
需要注意的是洽腺,編譯永遠(yuǎn)都是以單個源文件為單位的脚粟。在實(shí)際開發(fā)中覆旱,我們通常會將不同功能的代碼分散到不同的源文件,一方面方便代碼的閱讀和維護(hù)核无,同時也提升了軟件構(gòu)建的速度扣唱。
比如,我們修改了其中某一個源文件团南,那么只需要單獨(dú)編譯它這一個文件即可噪沙,不需要浪費(fèi)時間重新編譯整個工程。
2.2 二進(jìn)制的目標(biāo)文件
執(zhí)行上述的 gcc 編譯命令后吐根,可以看到正歼,在編譯之后會生成兩個擴(kuò)展名為 .o
的文件,它們被稱作目標(biāo)文件拷橘。
目標(biāo)文件是一個二進(jìn)制的文件局义,文件的格式是 ELF(Executable and Linkable Format)
,Linux 下所有可執(zhí)行文件的通用格式冗疮。相應(yīng)的 Windows 使用的是另一種格式 PE
萄唇,它們雖然互不兼容,但在結(jié)構(gòu)上非常相似术幔,都是對二進(jìn)制代碼的一種封裝另萤。
我們可以在文件頭部找到可執(zhí)行文件的基本信息,比如支持的操作系統(tǒng)诅挑、計器類型等等四敞,執(zhí)行以下命令查看:
readelf -h main.o
輸出結(jié)果:
ELF 頭:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
類別: ELF64
數(shù)據(jù): 2 補(bǔ)碼,小端序 (little endian)
版本: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
類型: REL (可重定位文件)
系統(tǒng)架構(gòu): Advanced Micro Devices X86-64
版本: 0x1
入口點(diǎn)地址: 0x0
程序頭起點(diǎn): 0 (bytes into file)
Start of section headers: 744 (bytes into file)
標(biāo)志: 0x0
本頭的大邪瓮住: 64 (字節(jié))
程序頭大心垦: 0 (字節(jié))
Number of program headers: 0
節(jié)頭大小: 64 (字節(jié))
節(jié)頭數(shù)量: 13
字符串表索引節(jié)頭: 10
文件后面則是一系列的區(qū)塊毒嫡,里面有我們的計器代碼還有程序的數(shù)據(jù)等等癌蚁,查看區(qū)塊/段(Sections):
readelf -S main.o
輸出結(jié)果:
共有 13 個節(jié)頭,從偏移量 0x2e8 開始:
節(jié)頭:
[號] 名稱 類型 地址 偏移量
大小 全體大小 旗標(biāo) 鏈接 信息 對齊
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000000000 00000040
000000000000002b 0000000000000000 AX 0 0 1
[ 2] .rela.text RELA 0000000000000000 00000220
0000000000000048 0000000000000018 I 11 1 8
[ 3] .data PROGBITS 0000000000000000 0000006b
0000000000000000 0000000000000000 WA 0 0 1
[ 4] .bss NOBITS 0000000000000000 0000006b
0000000000000000 0000000000000000 WA 0 0 1
[ 5] .rodata PROGBITS 0000000000000000 0000006b
000000000000000d 0000000000000000 A 0 0 1
[ 6] .comment PROGBITS 0000000000000000 00000078
0000000000000036 0000000000000001 MS 0 0 1
[ 7] .note.GNU-stack PROGBITS 0000000000000000 000000ae
0000000000000000 0000000000000000 0 0 1
[ 8] .eh_frame PROGBITS 0000000000000000 000000b0
0000000000000038 0000000000000000 A 0 0 8
[ 9] .rela.eh_frame RELA 0000000000000000 00000268
0000000000000018 0000000000000018 I 11 8 8
[10] .shstrtab STRTAB 0000000000000000 00000280
0000000000000061 0000000000000000 0 0 1
[11] .symtab SYMTAB 0000000000000000 000000e8
0000000000000120 0000000000000018 12 9 8
[12] .strtab STRTAB 0000000000000000 00000208
0000000000000016 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), l (large)
I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
在以上信息中兜畸,經(jīng)常提到有:
-
.text
代碼區(qū)努释,里面是之前編譯好的機(jī)器代碼; -
.data
數(shù)據(jù)區(qū)咬摇,里面保存了我們初始化的全局變量伐蒂、局部靜態(tài)變量等等;
需要注意的是肛鹏,目標(biāo)文件雖然包含了編譯之后的機(jī)器代碼逸邦,但它并不能夠直接執(zhí)行恩沛,操作系統(tǒng)也不允許你區(qū)執(zhí)行它。因?yàn)槲覀冊诰幾g的過程中缕减,用到了尚未定義的 add()
函數(shù)雷客。
在主程序中 add()
其實(shí)只是一句聲明而已,它被定義在另一個模塊 math.c
中桥狡,這同樣也包括我們用到的標(biāo)準(zhǔn)庫中的 printf()
函數(shù)搅裙,如果我們?nèi)ゲ榭?stdio.h
頭文件,其中的 printf()
也只是一個函數(shù)聲明而已裹芝。
換句話說部逮,我們在編譯 main.c
時,編譯器完全不知道 printf()
和 add()
函數(shù)的存在嫂易,比如它們位于內(nèi)存的哪個區(qū)塊兄朋、代碼長什么樣,都是不知道的怜械。因此編譯器只能將這個兩個函數(shù)的跳轉(zhuǎn)地址暫時先設(shè)為 0颅和,隨后在鏈接的時候再去修正它。
比如宫盔,我們來看一下 main.o
這個目標(biāo)文件中的內(nèi)容:
objdump -s -d main.o
輸出結(jié)果:
main.o: 文件格式 elf64-x86-64
Contents of section .text:
0000 554889e5 4883ec10 bf000000 00e80000 UH..H...........
0010 0000be05 000000bf 05000000 e8000000 ................
0020 008945fc b8000000 00c9c3 ..E........
Contents of section .rodata:
0000 48656c6c 6f20576f 726c6421 00 Hello World!.
Contents of section .comment:
0000 00474343 3a202855 62756e74 7520352e .GCC: (Ubuntu 5.
0010 342e302d 36756275 6e747531 7e31362e 4.0-6ubuntu1~16.
0020 30342e31 32292035 2e342e30 20323031 04.12) 5.4.0 201
0030 36303630 3900 60609.
Contents of section .eh_frame:
0000 14000000 00000000 017a5200 01781001 .........zR..x..
0010 1b0c0708 90010000 1c000000 1c000000 ................
0020 00000000 2b000000 00410e10 8602430d ....+....A....C.
0030 06660c07 08000000 .f......
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: bf 00 00 00 00 mov $0x0,%edi
d: e8 00 00 00 00 callq 12 <main+0x12>
12: be 05 00 00 00 mov $0x5,%esi
17: bf 05 00 00 00 mov $0x5,%edi
1c: e8 00 00 00 00 callq 21 <main+0x21>
21: 89 45 fc mov %eax,-0x4(%rbp)
24: b8 00 00 00 00 mov $0x0,%eax
29: c9 leaveq
2a: c3 retq
上述的 0000000000000000 <main>
是指編譯后的主函數(shù)代碼融虽,在下面的內(nèi)容中,左邊的是機(jī)器代碼灼芭,右邊是對應(yīng)的反匯編有额,可以看到反匯編中的兩個 call
指令,它們分別對應(yīng)之前調(diào)用的 printf()
和 add()
函數(shù)彼绷,我們發(fā)現(xiàn)巍佑,它們的跳轉(zhuǎn)地址都被設(shè)成了 0,而這里的 0 在后面鏈接的時候會被修正寄悯。
另外萤衰,為了讓鏈接器能夠定位到這些需要被修正的地址,在代碼塊中猜旬,我們還可以找到一個重定位表(Reloction Table)脆栋,命令如下:
objdump -r main.o
輸出結(jié)果:
main.o: 文件格式 elf64-x86-64
RELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000009 R_X86_64_32 .rodata
000000000000000e R_X86_64_PC32 puts-0x0000000000000004
000000000000001d R_X86_64_PC32 add-0x0000000000000004
RELOCATION RECORDS FOR [.eh_frame]:
OFFSET TYPE VALUE
0000000000000020 R_X86_64_PC32 .text
比如在 .text
區(qū)塊中,需要被重定位的兩個函數(shù) printf()
和 add()
洒擦,它們分別位于偏移量 0x0e
和 0x1d
的位置椿争,后面的 R_X86_64_PC32
是地址的類型和長度,這和我們之前看到的機(jī)器代碼是一一對應(yīng)的熟嫩。
備注:這里的
printf()
函數(shù)顯示為puts()
是因?yàn)?main.c
中調(diào)用printf()
函數(shù)時秦踪,傳入的參數(shù)無需解析,即不含類似%s
之類的 format 時,編譯器會將其轉(zhuǎn)換為puts()
函數(shù)椅邓,進(jìn)行簡化柠逞。
三、什么是鏈接景馁?
我們將編譯生成的 main.o
和 math.o
鏈接生成一個獨(dú)立的可執(zhí)行文件板壮,命令如下:
gcc main.o math.o -o demo
執(zhí)行完成后,在終端目錄下裁僧,我們可以找到生成的 demo 文件个束,該文件可以直接運(yùn)行慕购。
./demo
輸出結(jié)果:
Hello World!
鏈接其實(shí)將編譯之后的所有目標(biāo)文件聊疲,連同用到的一些靜態(tài)庫、運(yùn)行時庫(動態(tài)庫)組合拼裝成一個獨(dú)立的可執(zhí)行文件沪悲。其中就包括我們之前提到的地址修正获洲。
在這個時候,鏈接器會根據(jù)目標(biāo)文件或者靜態(tài)庫中的重定位表殿如,找到那些需要被重定位的函數(shù)贡珊、全局變量,從而修正它們的地址涉馁。
但如果我們在鏈接的時候忘記提供必須的目標(biāo)文件门岔,比如這里的 math.o
由于鏈接器找不到 add()
函數(shù)的實(shí)現(xiàn),就會報錯"引用未定義"(undefined reference to 'xxx')烤送,或者有的編譯器也叫"符號未定義"(undefined symbols to 'xxx')寒随,意思就是我們的代碼中用到了 xxx
,但鏈接器卻無法找到它的定義帮坚。
gcc main.o -o demo
輸出結(jié)果:
main.o:在函數(shù)‘main’中:
main.c:(.text+0x1d):對‘a(chǎn)dd’未定義的引用
collect2: error: ld returned 1 exit status
四妻往、構(gòu)建工具
本文到這里,相信大家對編譯和鏈接已經(jīng)有一定了解了试和,但如果我們每次都手動編譯再鏈接顯然不夠高效讯泣,實(shí)際開發(fā)也沒有人會這么做,通常我們都是用各種各樣的 IDE 或者構(gòu)建工具幫我們自動化整個流程阅悍。
我自己工作中常用到的構(gòu)建工具主要有 Makefile 和 SCons好渠,這里用最常見的 Makefile 來舉例。
可能很多人對 Makefile 的印象是很古老节视,但其實(shí) Makefile 除了軟件構(gòu)建之外還有許多其他的奇技淫巧拳锚,比如用它來自動生成文檔等等,像很多現(xiàn)代的項(xiàng)目也都還在用它肴茄,譬如 Android OS 的構(gòu)建等等晌畅。
Makefile 的核心是對"依賴"的管理,比如要構(gòu)建上文中的可以執(zhí)行程序 demo
寡痰,則需要 main.o
和 math.o
文件同時執(zhí)行 gcc 鏈接指令抗楔,構(gòu)建 main.o
又需要 main.c
這個文件同時執(zhí)行 gcc 編譯命令棋凳,依此類推,我們可以發(fā)現(xiàn)连躏,makefile 其實(shí)就是在定義一棵依賴樹剩岳。
我們需要構(gòu)建最右側(cè)的這個目標(biāo)文件就需要提供左邊那些節(jié)點(diǎn)文件,然后這樣層層遞歸下去入热,代碼如下:
all: demo
demo: main.o math.o
gcc main.o math.o -o demo
main.o: main.c
gcc -c main.c
math.o: math.c
gcc -c math.c
clean:
rm demo main.o math.o
有了 makefile 以后我們可以調(diào)用 make 命令拍棕,后面跟上目標(biāo)的名稱 demo
,它會自動根據(jù)我們"依賴樹"遞歸地去構(gòu)建這個可執(zhí)行文件:
make demo
第一次運(yùn)行由于所有葉子節(jié)點(diǎn)都不存在勺良,make 會自動構(gòu)建所有的依賴绰播,包括其中的目標(biāo)文件:
tree
輸出結(jié)果
.
├── main
├── main.c
├── main.o
├── makefile
├── math.c
└── math.o
0 directories, 6 files
如果我們再運(yùn)行一次 make,由于所有的文件都已經(jīng)存在尚困,并且是最新的蠢箩,make 就不會再重復(fù)構(gòu)建了。
make: 'demo' is up to date.
如果后續(xù)我們修改了 main.c
文件事甜,由于 main.c
只會影響 main.o
谬泌,從而影響最后的可執(zhí)行文件 demo
,所以 make 只會去重新生成這兩個相關(guān)的文件逻谦,從而避免了其他不必要的文件編譯掌实。
其實(shí)所有的現(xiàn)代化構(gòu)建工具都用到了相同的原理——對依賴的管理,只不過加入了一些更實(shí)用的功能邦马,比如腳本語言的支持(SCons就是基于Python實(shí)現(xiàn)的贱鼻,因此支持Python語言的功能)、第三方庫的管理等等勇婴。