編譯器是一個神奇的東西,它能夠将我們所編寫的進階語言源代碼翻譯成機器可識别的語言(二進制代碼),并讓程式按照我們的意圖按步執行。那麼,從編寫源檔案代碼到可執行檔案,到底分為幾步呢?這個過程可以總結為以下5步:
1、編寫源代碼
2、編譯
3、連結
4、裝載
5、執行
今天主要說明的過程是編譯和連結是怎麼進行的。
首先是編譯,那麼什麼是編譯?從廣義上講,編譯就是将某種程式設計語言編寫的代碼轉換成另一種程式設計語言描述的代碼,嚴格一點兒來說,編譯其實就是将進階語言編寫的源代碼翻譯成低級語言(通常是彙編語言,甚至是機器代碼)描述的代碼的過程。這個過程由編譯器完成,是以,我們可以把編譯器看成這樣的一種機器,它的輸入是多個編譯單元(編譯代碼是一個源代碼文本檔案),輸出的是和多個編譯單元一一對應的目标檔案。
為了簡化說明,我們使用如下代碼來示範這個過程。
function.h
1 //function.h
2 #ifndef FIRST_OPTION
3 #define FIRST_OPTION
4 #define MULTIPLIER (3.0)
5 #endif
6
7 float add_and_multiply(float x,float y);
function.c
1 #include "function.h"
2 int ncompletionstatus=0;
3 float add(float x,float y){
4 float z=x+y;
5 return z;
6 }
7 float add_and_multiply(float x,float y){
8 float z=add(x,y);
9 z*=MULTIPLIER;
10 return z;
11 }
main.c
1 #include "function.h"
2 extern int ncompletionstatus;
3 int main(){
4 float x=1.0;
5 float y=5.0;
6 float z;
7 z=add_and_multiply(x,y);
8 ncompletionstatus=1;
9 return 0;
10 }
編譯器要完成編譯的功能,需要一系列的步驟。粗略的講,編譯的過程可分為預處理階段、語言分析階段、彙編階段、優化階段和代碼生成階段。
預處理階段:
(1)、将#include關鍵字表示的含有定義的檔案包含到源代碼檔案中
(2)、處理#define,在代碼中調用宏的位置将宏轉化為代碼
(3)、根據#ifndef ,#ifdef,#elif和#endif指定的位置包含或者排除特定部分的代碼
對于上面的function.c檔案,我們可以使用gcc指令--gcc -E function.c -o function.i對它隻進行預處理而不進行相應的後續處理。生成的i檔案如下所示。
# 1 "function.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "function.c"
# 1 "function.h" 1
float add_and_multiply(float x,float y);
# 2 "function.c" 2
int ncompletionstatus=0;
float add(float x,float y){
float z=x+y;
return z;
}
float add_and_multiply(float x,float y){
float z=add(x,y);
z*=(3.0);
return z;
}
可以看到,宏定義被替換成了(3.0)。
語言分析階段:
(1)、詞法分析階段:将源代碼分割成不可分割的單詞
(2)、文法分析階段:将提取出來的單詞連接配接成單詞序列,并根據程式設計語言規則驗證其順序是否合理
(3)、語義分析階段:目的是發現符合文法規則的語句是否具有實際意義,比如講兩個整數相加并将結果指派給一個對象的語句,雖然能通過文法規則的檢查,但是可能無法通過語義的檢查,例如這個對象的類沒有重載指派操作符
彙編階段:當源代碼經過校驗,其中不包含任何的文法錯誤時,編譯器才會執行彙編階段。在這個階段中,編譯器會将标準的語言集合轉換成特定的CPU指令集的語言集合,不同的CPU會包含不同的指令集、寄存器和中斷,是以不同的處理器要有不同的編譯器對其支援。gcc編譯器支援将輸入的檔案源代碼轉換成對應的ASCII編碼的文本檔案,其中包含了對應的彙編指令的代碼行,彙編指令的格式包括AT&T和Intel兩種,在Centos6.4上也是。
我們對function.c檔案運作gcc -S -masm=att function.c -o function.s指令,可以得到function.c檔案的彙編檔案,如下所示。
1 .file "function.c"
2 .globl ncompletionstatus
3 .bss
4 .align 4
5 .type ncompletionstatus, @object
6 .size ncompletionstatus, 4
7 ncompletionstatus:
8 .zero 4
9 .text
10 .globl add
11 .type add, @function
12 add:
13 pushl %ebp
14 movl %esp, %ebp
15 subl $20, %esp
16 flds 8(%ebp)
17 fadds 12(%ebp)
18 fstps -4(%ebp)
19 movl -4(%ebp), %eax
20 movl %eax, -20(%ebp)
21 flds -20(%ebp)
22 leave
23 ret
24 .size add, .-add
25 .globl add_and_multiply
26 .type add_and_multiply, @function
27 add_and_multiply:
28 pushl %ebp
29 movl %esp, %ebp
30 subl $28, %esp
31 movl 12(%ebp), %eax
32 movl %eax, 4(%esp)
33 movl 8(%ebp), %eax
34 movl %eax, (%esp)
35 call add
36 fstps -4(%ebp)
37 flds -4(%ebp)
38 flds .LC1
39 fmulp %st, %st(1)
40 fstps -4(%ebp)
41 movl -4(%ebp), %eax
42 movl %eax, -20(%ebp)
43 flds -20(%ebp)
44 leave
45 ret
46 .size add_and_multiply, .-add_and_multiply
47 .section .rodata
48 .align 4
49 .LC1:
50 .long 1077936128
51 .ident "GCC: (GNU) 4.4.7 20120313 (Red Hat 4.4.7-3)"
52 .section .note.GNU-stack,"",@progbits
代碼優化階段:由源代碼檔案生成的最初版本的彙編代碼之後,優化就開始了,優化的隻要功能是将程式的寄存器使用率最小化,此外,通過分析能夠預測出來實際上不需要執行的部分代碼并删除
代碼生成階段:優化完成的彙編代碼會在這個階段轉換成對應的機器指令的二進制值,并寫入目标檔案的特定位置,每一個源檔案都對應一個目标檔案,每一個目标檔案都将包含所有相關的節資訊(也就是.text/.code/.bss),同時也會包含部分的描述資訊,我們可以使用gcc -c function.c -o function.o對function.c檔案隻進行編譯處理,生成的檔案是function.o檔案。
對于.o檔案,不能用vi直接打開,打開也是一對亂碼。我們可以使用objdump的工具來檢視.o檔案的反彙編代碼(我的Centos6.4上有這個軟體,是以你的電腦上如果沒有,可以裝一個),使用objdump -D function.o即可在終端上列印出.o檔案的反彙編代碼了,代碼如下所示。
1 [song@localhost Desktop]$ objdump -D function.o
2
3 function.o: file format elf32-i386
4
5
6 Disassembly of section .text:
7
8 00000000 <add>:
9 0: 55 push %ebp
10 1: 89 e5 mov %esp,%ebp
11 3: 83 ec 14 sub $0x14,%esp
12 6: d9 45 08 flds 0x8(%ebp)
13 9: d8 45 0c fadds 0xc(%ebp)
14 c: d9 5d fc fstps -0x4(%ebp)
15 f: 8b 45 fc mov -0x4(%ebp),%eax
16 12: 89 45 ec mov %eax,-0x14(%ebp)
17 15: d9 45 ec flds -0x14(%ebp)
18 18: c9 leave
19 19: c3 ret
20
21 0000001a <add_and_multiply>:
22 1a: 55 push %ebp
23 1b: 89 e5 mov %esp,%ebp
24 1d: 83 ec 1c sub $0x1c,%esp
25 20: 8b 45 0c mov 0xc(%ebp),%eax
26 23: 89 44 24 04 mov %eax,0x4(%esp)
27 27: 8b 45 08 mov 0x8(%ebp),%eax
28 2a: 89 04 24 mov %eax,(%esp)
29 2d: e8 fc ff ff ff call 2e <add_and_multiply+0x14>
30 32: d9 5d fc fstps -0x4(%ebp)
31 35: d9 45 fc flds -0x4(%ebp)
32 38: d9 05 00 00 00 00 flds 0x0
33 3e: de c9 fmulp %st,%st(1)
34 40: d9 5d fc fstps -0x4(%ebp)
35 43: 8b 45 fc mov -0x4(%ebp),%eax
36 46: 89 45 ec mov %eax,-0x14(%ebp)
37 49: d9 45 ec flds -0x14(%ebp)
38 4c: c9 leave
39 4d: c3 ret
40
41 Disassembly of section .bss:
42
43 00000000 <ncompletionstatus>:
44 0: 00 00 add %al,(%eax)
45 ...
46
47 Disassembly of section .rodata:
48
49 00000000 <.rodata>:
50 0: 00 00 add %al,(%eax)
51 2: 40 inc %eax
52 3: 40 inc %eax
53
54 Disassembly of section .comment:
55
56 00000000 <.comment>:
57 0: 00 47 43 add %al,0x43(%edi)
58 3: 43 inc %ebx
59 4: 3a 20 cmp (%eax),%ah
60 6: 28 47 4e sub %al,0x4e(%edi)
61 9: 55 push %ebp
62 a: 29 20 sub %esp,(%eax)
63 c: 34 2e xor $0x2e,%al
64 e: 34 2e xor $0x2e,%al
65 10: 37 aaa
66 11: 20 32 and %dh,(%edx)
67 13: 30 31 xor %dh,(%ecx)
68 15: 32 30 xor (%eax),%dh
69 17: 33 31 xor (%ecx),%esi
70 19: 33 20 xor (%eax),%esp
71 1b: 28 52 65 sub %dl,0x65(%edx)
72 1e: 64 20 48 61 and %cl,%fs:0x61(%eax)
73 22: 74 20 je 44 <add_and_multiply+0x2a>
74 24: 34 2e xor $0x2e,%al
75 26: 34 2e xor $0x2e,%al
76 28: 37 aaa
77 29: 2d .byte 0x2d
78 2a: 33 29 xor (%ecx),%ebp
79 ...
可以看到,裡面包含了.tex/.bss/.data節的内容。以上就是所有編譯階段所完成的任務,我們現在得到的是一個個的目标檔案。
當編譯完成後,下一步就是将編譯出來的各個目标檔案連結成一個可執行的檔案,這個過程就是連結。
最終生成的二進制檔案中包含了多個相同類型的節(.text/.data/.bss),而這些節是從每一個獨立的目标檔案中摘取下來的,也就是說,如果我們把一個個的目标檔案看成一塊簡單的拼貼,程序的記憶體映射看做是一副巨幅鑲嵌的畫,連結的過程就是将拼貼組合在一起,放置在鑲嵌畫的恰當的位置。連結的過程由連結器執行,它的最終任務是将獨立的節組合成最終的程式記憶體映射節,與此同時解析所有的引用。
連結階段主要包括重定位和解析引用兩個階段。
重定位:連結過程的第一個階段僅僅進行拼接,其過程是将分散在單獨目标檔案中不同類型的節拼接到程式的記憶體映射節中,在每一個目标檔案中,代碼的位址範圍都是從0開始的,但是在程式的記憶體映射中,位址範圍并不都是從0開始的,是以我們要将目标檔案中的位址範圍轉換成最終程式記憶體映射中更具體的位址範圍。
解析引用:在重定位結束後,就開始了解析引用。所謂解析引用,就是在位于不同部分的代碼之間建立關聯,使得程式變成一個緊湊的整體。引發連結問題的根本原因是--代碼片段在不同的編譯單元内,它們之間嘗試互相引用,但是将目标檔案拼接成程式記憶體映射之前,又不知道要引用對象的位址。,比如我們引用了其他源檔案中的函數,怎麼知道該函數的入口點呢,這就是連結階段解析引用所解決的問題。我們使用在本文開頭所使用的示例代碼來說明這個問題。
1、在function.c檔案中,add_and_multiply函數調用了函數add,這兩個函數在同一個源檔案中,在這種情況下,函數add的記憶體映射位址值是一個已知量,是以這個調用是沒有問題的;
2、在main函數中,調用了add_and_multiply函數,并且引用了外部變量ncompletestatus,這時就會出現問題,我們不知道該函數和該外部變量的記憶體映射位址,實際上,編譯器會假設這些符号未來會在程序的記憶體映射中存在,但是,直到生成完整的記憶體映射之前,這兩項引用會一直被當成為解析引用。
為了完成解析引用的任務,連結器需要完成:
(1)、檢查拼接到記憶體映射的節
(2)、找出哪些部分代碼産生了外部調用
(3)、計算該引用在程式記憶體映射中的具體位置
(4)、最後,将機器指令中的僞位址替換成程式記憶體映射的實際位址,進而完成解析引用。
為了展示示例程式的連結過程,我們需要先編譯main.c和function.c
運作指令gcc -c function.c main.c和gcc function.o main.o -o demoapp生成可執行的檔案demoapp
利用objdump檢視main.o中的反彙編代碼
1 Disassembly of section .text:
2
3 00000000 <main>:
4 0: 55 push %ebp
5 1: 89 e5 mov %esp,%ebp
6 3: 83 e4 f0 and $0xfffffff0,%esp
7 6: 83 ec 20 sub $0x20,%esp
8 9: b8 00 00 80 3f mov $0x3f800000,%eax
9 e: 89 44 24 14 mov %eax,0x14(%esp)
10 12: b8 00 00 a0 40 mov $0x40a00000,%eax
11 17: 89 44 24 18 mov %eax,0x18(%esp)
12 1b: 8b 44 24 18 mov 0x18(%esp),%eax
13 1f: 89 44 24 04 mov %eax,0x4(%esp)
14 23: 8b 44 24 14 mov 0x14(%esp),%eax
15 27: 89 04 24 mov %eax,(%esp)
16 2a: e8 fc ff ff ff call 2b <main+0x2b> //注意這裡
17 2f: d9 5c 24 1c fstps 0x1c(%esp)
18 33: c7 05 00 00 00 00 01 movl $0x1,0x0 //注意這裡
19 3a: 00 00 00
20 3d: b8 00 00 00 00 mov $0x0,%eax
21 42: c9 leave
22 43: c3 ret
上述代碼中,在第16行和18中,main函數分别調用了自己和通路了位址0的值,這都是不應該出現的情況(其實我不懂彙編......囧),然後我們再來檢視demoapp的反彙編代碼,看一下和main函數的節
1 080483e4 <main>:
2 80483e4: 55 push %ebp
3 80483e5: 89 e5 mov %esp,%ebp
4 80483e7: 83 e4 f0 and $0xfffffff0,%esp
5 80483ea: 83 ec 20 sub $0x20,%esp
6 80483ed: b8 00 00 80 3f mov $0x3f800000,%eax
7 80483f2: 89 44 24 14 mov %eax,0x14(%esp)
8 80483f6: b8 00 00 a0 40 mov $0x40a00000,%eax
9 80483fb: 89 44 24 18 mov %eax,0x18(%esp)
10 80483ff: 8b 44 24 18 mov 0x18(%esp),%eax
11 8048403: 89 44 24 04 mov %eax,0x4(%esp)
12 8048407: 8b 44 24 14 mov 0x14(%esp),%eax
13 804840b: 89 04 24 mov %eax,(%esp)
14 804840e: e8 9b ff ff ff call 80483ae <add_and_multiply> //注意這裡
15 8048413: d9 5c 24 1c fstps 0x1c(%esp)
16 8048417: c7 05 98 96 04 08 01 movl $0x1,0x8049698 //注意這裡
17 804841e: 00 00 00
18 8048421: b8 00 00 00 00 mov $0x0,%eax
19 8048426: c9 leave
20 8048427: c3 ret
21 8048428: 90 nop
22 8048429: 90 nop
23 804842a: 90 nop
24 804842b: 90 nop
25 804842c: 90 nop
26 804842d: 90 nop
27 804842e: 90 nop
28 804842f: 90 nop
在main.o中,main起始的位置是0,而在demoapp中main起始位址變為0x080483e4,這就是重定位現象,另外,與上述main.o對應,第14行調用了函數add_and_multiply而不是調用了main自己,是以連結器完成了函數引用解析的功能,同時,在main.o中的第18行的0x0被修改為0x8049698,我們可以通過objdump來檢視0x8049698位址中到底放了什麼資料。
執行objdump -x -j .bss demoapp,可以看到
1 SYMBOL TABLE:
2 08049690 l d .bss 00000000 .bss
3 08049690 l O .bss 00000001 completed.5974
4 08049694 l O .bss 00000004 dtor_idx.5976
5 08049698 g O .bss 00000004 ncompletionstatus //注意這裡
在.bss段,位址0x08049698中放置着外部變量ncompletionstatus,于是,我們可以看到,連結器成功的完成了重定位和解析引用的功能。(但是我有一個疑問,ncompletionstatus在function.c中已經被初始化為0,為什麼不是在.data段存放,而是在.bss中存放?請路過的大神解釋一下)
以上,就是程式編譯和連結的全部過程,經過連結後的檔案是一個可被執行的檔案,可執行的檔案總是會包含.data, .bss, .text節和其他的一些特殊的節,這些節通過拼接單獨的目标檔案中的節得到。
需要注意的一點是,main不是程式執行時首先執行的代碼,啟動程式是整個程式首先執行的代碼,而且啟動程式時在連結之後才添加到程式的記憶體映射當中的,也就是說,可執行的檔案并不完全是通過編譯項目源代碼檔案生成的。啟動代碼有兩種不同的形式:
crt0:它是純粹的入口點,這是程式代碼的第一部分,在核心的控制下執行;
crt1:它是更現代化的啟動例程,可以在main函數執行前和程式終止後完成一些任務。
這部分啟動代碼是OS自動添加給應用程式的,這也是可執行檔案和動态庫的唯一差別,動态庫沒有啟動程式代碼。
參考書籍:《進階C/C++編譯技術》