源碼要運行,,必須先轉(zhuǎn)成二進制的機器碼,。這是編譯器的任務。
比如,,下面這段源碼(假定文件名叫做test.c),。
#include <stdio.h>
int main(void)
{
fputs("Hello, world!\n", stdout);
return 0;
}
要先用編譯器處理一下,才能運行,。
$ gcc test.c
$ ./a.out
Hello, world!
對于復雜的項目,,編譯過程還必須分成三步。
$ ./configure
$ make
$ make install
這些命令到底在干什么,?大多數(shù)的書籍和資料,,都語焉不詳,,只說這樣就可以編譯了,沒有進一步的解釋,。
本文將介紹編譯器的工作過程,,也就是上面這三個命令各自的任務。我主要參考了Alex Smith的文章《Building C Projects》,。需要聲明的是,,本文主要針對gcc編譯器,也就是針對C和C++,,不一定適用于其他語言的編譯,。
第一步 配置(configure)
編譯器在開始工作之前,需要知道當前的系統(tǒng)環(huán)境,,比如標準庫在哪里,、軟件的安裝位置在哪里、需要安裝哪些組件等等,。這是因為不同計算機的系統(tǒng)環(huán)境不一樣,通過指定編譯參數(shù),,編譯器就可以靈活適應環(huán)境,,編譯出各種環(huán)境都能運行的機器碼。這個確定編譯參數(shù)的步驟,,就叫做"配置"(configure),。
這些配置信息保存在一個配置文件之中,約定俗成是一個叫做configure的腳本文件,。通常它是由autoconf工具生成的,。編譯器通過運行這個腳本,獲知編譯參數(shù),。
configure腳本已經(jīng)盡量考慮到不同系統(tǒng)的差異,,并且對各種編譯參數(shù)給出了默認值。如果用戶的系統(tǒng)環(huán)境比較特別,,或者有一些特定的需求,,就需要手動向configure腳本提供編譯參數(shù)。
$ ./configure --prefix=/www --with-mysql
上面代碼是php源碼的一種編譯配置,,用戶指定安裝后的文件保存在www目錄,,并且編譯時加入mysql模塊的支持。
第二步 確定標準庫和頭文件的位置
源碼肯定會用到標準庫函數(shù)(standard library)和頭文件(header),。它們可以存放在系統(tǒng)的任意目錄中,,編譯器實際上沒辦法自動檢測它們的位置,只有通過配置文件才能知道,。
編譯的第二步,,就是從配置文件中知道標準庫和頭文件的位置,。一般來說,配置文件會給出一個清單,,列出幾個具體的目錄,。等到編譯時,編譯器就按順序到這幾個目錄中,,尋找目標,。
第三步 確定依賴關系
對于大型項目來說,源碼文件之間往往存在依賴關系,,編譯器需要確定編譯的先后順序,。假定A文件依賴于B文件,編譯器應該保證做到下面兩點,。
?。?)只有在B文件編譯完成后,才開始編譯A文件,。
?。?)當B文件發(fā)生變化時,A文件會被重新編譯,。
編譯順序保存在一個叫做makefile的文件中,,里面列出哪個文件先編譯,哪個文件后編譯,。而makefile文件由configure腳本運行生成,,這就是為什么編譯時configure必須首先運行的原因。
在確定依賴關系的同時,,編譯器也確定了,,編譯時會用到哪些頭文件。
第四步 頭文件的預編譯(precompilation)
不同的源碼文件,,可能引用同一個頭文件(比如stdio.h),。編譯的時候,頭文件也必須一起編譯,。為了節(jié)省時間,,編譯器會在編譯源碼之前,先編譯頭文件,。這保證了頭文件只需編譯一次,,不必每次用到的時候,都重新編譯了,。
不過,,并不是頭文件的所有內(nèi)容,都會被預編譯,。用來聲明宏的#define命令,,就不會被預編譯,。
第五步 預處理(Preprocessing)
預編譯完成后,編譯器就開始替換掉源碼中bash的頭文件和宏,。以本文開頭的那段源碼為例,,它包含頭文件stdio.h,替換后的樣子如下,。
extern int fputs(const char *, FILE *);
extern FILE *stdout;
int main(void)
{
fputs("Hello, world!\n", stdout);
return 0;
}
為了便于閱讀,,上面代碼只截取了頭文件中與源碼相關的那部分,即fputs和FILE的聲明,,省略了stdio.h的其他部分(因為它們非常長),。另外,上面代碼的頭文件沒有經(jīng)過預編譯,,而實際上,,插入源碼的是預編譯后的結果。編譯器在這一步還會移除注釋,。
這一步稱為"預處理"(Preprocessing),,因為完成之后,就要開始真正的處理了,。
第六步 編譯(Compilation)
預處理之后,,編譯器就開始生成機器碼。對于某些編譯器來說,,還存在一個中間步驟,會先把源碼轉(zhuǎn)為匯編碼(assembly),,然后再把匯編碼轉(zhuǎn)為機器碼,。
下面是本文開頭的那段源碼轉(zhuǎn)成的匯編碼。
.file "test.c"
.section .rodata
.LC0:
.string "Hello, world!\n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movq stdout(%rip), %rax
movq %rax, %rcx
movl $14, %edx
movl $1, %esi
movl $.LC0, %edi
call fwrite
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Debian 4.9.1-19) 4.9.1"
.section .note.GNU-stack,"",@progbits
這種轉(zhuǎn)碼后的文件稱為對象文件(object file),。
第七步 連接(Linking)
對象文件還不能運行,,必須進一步轉(zhuǎn)成可執(zhí)行文件。如果你仔細看上一步的轉(zhuǎn)碼結果,,會發(fā)現(xiàn)其中引用了stdout函數(shù)和fwrite函數(shù),。也就是說,程序要正常運行,,除了上面的代碼以外,,還必須有stdout和fwrite這兩個函數(shù)的代碼,它們是由C語言的標準庫提供的,。
編譯器的下一步工作,,就是把外部函數(shù)的代碼(通常是后綴名為.lib和.a的文件),添加到可執(zhí)行文件中,。這就叫做連接(linking),。這種通過拷貝,,將外部函數(shù)庫添加到可執(zhí)行文件的方式,叫做靜態(tài)連接(static linking),,后文會提到還有動態(tài)連接(dynamic linking),。
make命令的作用,就是從第四步頭文件預編譯開始,,一直到做完這一步,。
第八步 安裝(Installation)
上一步的連接是在內(nèi)存中進行的,即編譯器在內(nèi)存中生成了可執(zhí)行文件,。下一步,,必須將可執(zhí)行文件保存到用戶事先指定的安裝目錄。
表面上,,這一步很簡單,,就是將可執(zhí)行文件(連帶相關的數(shù)據(jù)文件)拷貝過去就行了。但是實際上,,這一步還必須完成創(chuàng)建目錄,、保存文件、設置權限等步驟,。這整個的保存過程就稱為"安裝"(Installation),。
第九步 操作系統(tǒng)連接
可執(zhí)行文件安裝后,必須以某種方式通知操作系統(tǒng),,讓其知道可以使用這個程序了,。比如,我們安裝了一個文本閱讀程序,,往往希望雙擊txt文件,,該程序就會自動運行。
這就要求在操作系統(tǒng)中,,登記這個程序的元數(shù)據(jù):文件名,、文件描述、關聯(lián)后綴名等等,。Linux系統(tǒng)中,,這些信息通常保存在/usr/share/applications目錄下的.desktop文件中。另外,,在Windows操作系統(tǒng)中,,還需要在Start啟動菜單中,建立一個快捷方式,。
這些事情就叫做"操作系統(tǒng)連接",。make install命令,就用來完成"安裝"和"操作系統(tǒng)連接"這兩步,。
第十步 生成安裝包
寫到這里,,源碼編譯的整個過程就基本完成了,。但是只有很少一部分用戶,愿意耐著性子,,從頭到尾做一遍這個過程,。事實上,如果你只有源碼可以交給用戶,,他們會認定你是一個不友好的家伙,。大部分用戶要的是一個二進制的可執(zhí)行程序,立刻就能運行,。這就要求開發(fā)者,,將上一步生成的可執(zhí)行文件,做成可以分發(fā)的安裝包,。
所以,,編譯器還必須有生成安裝包的功能。通常是將可執(zhí)行文件(連帶相關的數(shù)據(jù)文件),,以某種目錄結構,,保存成壓縮文件包,交給用戶,。
第十一步 動態(tài)連接(Dynamic linking)
正常情況下,,到這一步,程序已經(jīng)可以運行了,。至于運行期間(runtime)發(fā)生的事情,,與編譯器一概無關。但是,,開發(fā)者可以在編譯階段選擇可執(zhí)行文件連接外部函數(shù)庫的方式,,到底是靜態(tài)連接(編譯時連接),還是動態(tài)連接(運行時連接),。所以,,最后還要提一下,,什么叫做動態(tài)連接,。
前面已經(jīng)說過,靜態(tài)連接就是把外部函數(shù)庫,,拷貝到可執(zhí)行文件中,。這樣做的好處是,適用范圍比較廣,,不用擔心用戶機器缺少某個庫文件,;缺點是安裝包會比較大,而且多個應用程序之間,,無法共享庫文件,。動態(tài)連接的做法正好相反,,外部函數(shù)庫不進入安裝包,只在運行時動態(tài)引用,。好處是安裝包會比較小,,多個應用程序可以共享庫文件;缺點是用戶必須事先安裝好庫文件,,而且版本和安裝位置都必須符合要求,,否則就不能正常運行。
現(xiàn)實中,,大部分軟件采用動態(tài)連接,,共享庫文件。這種動態(tài)共享的庫文件,,Linux平臺是后綴名為.so的文件,,Windows平臺是.dll文件,Mac平臺是.dylib文件,。