眾所周知,計算機中運行的指令是由二進制編碼的0和1組成,最早的程序員通過在紙帶上打孔來編寫程序,有孔表示1,無孔表示0,經過光電掃描輸入電腦,這種0和1序列我們稱之為機器語言。
0和1看的人頭都大了,人們厭煩這種復雜且易出錯的編碼方式,進而發明了匯編語言,匯編語言只是充當一個助記符的作用,但好歹人們不用寫010101010,而是可以用mov
、add
這種人們一看就知道其含義的符號來書寫程序,久而久之,人們在匯編語言的基礎上又發展了高級語言,也就是我們現在看到的各種語言,如C、C++、Python等,不論是面向對象的還是面向過程的,都可以歸結到高級語言。
人們用高級語言來工作、編程,但機器只識別機器語言,這中間肯定就存在一個轉換的過程。這個過程平時在我們編程序的過程中並不會注意,我們常用的編程環境如VS、dev c++、Delphi等這種IDE(集成開發環境)都為我們封裝好了一切,只要我們點擊運行或構建按鈕,源程序就會變成可以在機器上運行的機器代碼,而這個被忽略的過程就是我們今天的重點。
我們會以C語言的經典程序HelloWorld作為例子,參考《程序員的自我修養——鏈接、裝載和庫》的內容,通過實操為讀者一步步展現這個過程的具體步驟。
在Linux中,當我們使用GCC來編譯該程序時,只需要用最簡單的命令
$ gcc hello.c
$ ./a.out
hello world
事實上,這個過程可以分解為4個步驟,分別是預處理(Preprocess)、編譯(Compilation)、匯編(Assembly)和鏈接(Linking)。順便提一句,轉化為機器代碼后,當我們運行該程序還涉及到將機器代碼加載到內存中執行的過程,這個過程我們稱之為裝載,但我們本文不進行詳細闡述。
預處理
我們在編寫C和C++程序的時候,經常會用到#號開頭的語句,如#include
、#define
、#ifdef
等語句,這些語句在預處理過程中就發揮着重要作用。
源文件hello.c和相關的頭文件被預編譯器cpp預編譯為一個后綴為.i的文件
$ gcc -E hello.c -o hello.i
或者
$ cpp hello.c > hello.i
生成結果如下:
我們可以看到,一個本來不到十行的程序,經過預處理后,已經變成了一個863行的程序,說明預處理器向程序中加了許多的內容,我們原先的幾行代碼也被放在了最后。
預編譯過程主要處理那些源文件中的以"#"開始的預編譯指令,主要處理規則如下:
-
將所有的
#define
刪除,並將所有的宏定義進行展開。程序中我們的RET宏被替換為了0。 -
處理所有的條件預編譯指令,比如
#ifdef
、#elif
等。 -
處理#include預編譯指令,將被包含的文件插入到該預編譯指令的位置。注意,這個過程是遞歸進行的,也就是說被包含的文件可能還包含其他文件。
左側為stdio.h的內容,右側為hello.i的內容,可以看到stdio.h文件的內容經過預處理后直接拷貝到了hello.i文件中。
-
刪除所有的注釋"//"和"/* */"。hello.c程序中//use macro這條注釋在hello.i中已經消失了。
-
添加行號和文件名標識,比如#2 "hello.c" 2,以便編譯時編譯器產生調試用的行號信息以及用於編譯時產生編譯錯誤或警告時可以顯示行號。
-
保留所有的#pragma編譯器指令,因為編譯器需要使用他們。(比如在vs中我們常用的#pragma warning (disable : 4996)來禁止編譯器產生對使用不安全函數的警告)
記得上過的課上又提到過,由於宏的不規范定義會導致一些錯誤,而預處理后的程序所有宏均被替代,因此可以通過查看預處理后的.i文件來判斷宏定義是否正確或頭文件包含是否正確。
編譯
編譯過程就是把預處理完的文件進行一系列詞法分析、語法分析、語義分析及優化后生成相應的匯編文件。
$ gcc -S hello.i -o hello.s
上面的匯編風格為AT&T的,我們可以加些參數將其轉換為Intel風格的,且去掉cfi宏。
$ gcc -S hello.i -o hello.s -masm=intel -fno-asynchronous-unwind-tables
可以看到.string后面跟着字符串"hello world!",值得注意的是,生成的匯編代碼中函數printf被替換成了puts,這是因為當printf只有一個單一參數時,與puts是十分類似的,於是GCC的優化策略就將其替換以提高性能。
下面我們對編譯過程進行詳細介紹
編譯器實現了從源程序到語義上等價的目標程序的映射,這個映射可以分為兩部分:分析部分和綜合部份。
分析(analysis)部分將源程序分解為多個組成要素,並在這些要素上加上語法結構,然后利用這個結構創建該源程序的一個中間表示。分析部分還會收集有關源程序的信息,並把信息存放在一個稱為符號表(symbol table)的數據結構中,符號表將和中間表示形式一起傳送給綜合部份。
綜合(synthesis)部分根據中間表示和符號表中的信息來構造用戶期待的目標程序。
分析部分經常被稱為編譯器的前端(front end),它和目標機器無關;而綜合部份稱為后端(back end),與目標機器有關,前端和后端分離導致我們可以更好地開發編譯器,編譯器開發者便不用為每個CPU架構開發一整套編譯器,而是重新編寫后端即可,也不用為每一種高級語言開發一整套編譯器,只需要更改前端即可。
-
詞法分析(lexical analysis)
讀入組成源程序的字符流,並將它們組織成為有意義的詞素(lexeme)序列,對於每個詞素,詞法分析器產生詞法單元(token)作為輸出。
-
語法分析(syntax analysis)
使用由詞法分析器生成的各個詞法單元token來創建樹形的中間表示,該中間表示給出了詞法分析產生的詞法單元流的語法結構,一個常用的表示是語法樹(syntax tree),樹中的每個內部節點表示一個運算,而該結點的子節點表示該運算的分量。
-
語義分析(semantic analysis)
語法分析僅僅是完成了對表達式語法層面的分析,但是它並不了解這個語句是否真正有意義,比如C語言里面兩個指針作乘法運算是沒有意義的,但這個語句在語法上是合法的。編譯器所能分析的語義是靜態語義(Static Sematic),即編譯期間可以確定的語義,與之對應的動態語義(Dynamic Sematic)就是只有在運行期才能確定的語義。比如將零作為除數是一個運行期語義錯誤。
語義分析使用語法樹和符號表中的信息來檢查源程序是否和語言定義的語義一致。它同時也收集類型信息,並把這些信息存放在語法樹或符號表中,以便在隨后的中間代碼生成過程中使用。語義分析的一個重要部分是類型檢查(type checking),編譯器檢查每個運算符是否具有匹配的運算分量。
-
中間代碼生成
根據語義分析的輸出,生成類機器語言的中間表示,比如三地址碼和P-代碼。三地址碼類似於匯編語言的指令組成(但還不是匯編語言),每個指令具有三個運算分量,每個運算分量都像一個寄存器。這種中間代碼一般跟目標機器和運行時環境無關。
比如
a = b + c * (4 + 2)
的源代碼最后生成的中間代碼模樣大概為:t0 = 2 + 4 t1 = id3 * t0 t2 = id2 + t1 id1 = t3
-
中間代碼優化
改進中間代碼,生成更好的目標代碼,比如上面的中間代碼可以優化為:
t1 = id3 * 6 id1 = id2 + t1
中間代碼使得編譯器可以被分為前端和后端。編譯器前端負責產生機器無關的中間代碼,編譯器后端將中間代碼轉換為目標代碼。這樣對於一些可以跨平台的編譯器而言,可以針對不同的平台使用同一個前端和針對不同機器平台的后端。
-
目標代碼生成
這個過程非常依賴於目標機器,因為不同的機器有着不同的字長、寄存器等,如果目標語言是x86匯編語言,那么上面的中間代碼產生的目標代碼可能為:
mov edx, DWORD PTR [ebp - 8] ;[ebp-8]里面為c的值 mov eax, edx ;eax = c add eax, eax ;eax = 2c add eax, edx ;eax = 3c add eax, eax ;eax = 6c mov edx, eax ;edx = 6c mov eax, DWORD PTR [ebp - 12] ;[ebp - 12]里面為b的值 add eax, edx ;eax = b + 6c
匯編
匯編過程就是將匯編語言轉換為機器語言。由於匯編指令是機器指令的助記符,每一個匯編語句幾乎都對應一條機器指令,所以匯編器的匯編過程相對於編譯器來講比較簡單,沒有復雜的語法,也沒有語義,也不需要做指令優化,只是根據匯編指令和機器指令的對照表一一翻譯就可以。
$ gcc -c hello.s -o hello.o
或者
$ as hello.s -o hello.o
此時的目標文件hello.o是一個可重定位目標文件(Relocatable File),如果使用文本編輯器查看hello.o會看到一堆亂碼,我們需要采用反匯編技術查看hello.o文件內容
$ objdump -sd hello.o -M intel
由於還未進行鏈接,目標文件的符號的虛擬地址無法確定,於是我們看到字符串“hello world!”的地址為0x0000,傳給puts函數的參數(即hello world字符串的地址)也為00000000,而call puts機器語言中的0xfffffffc(小端)為-4,表示相對PC尋址,puts函數的地址為0x1e - 4 = 0x1a,我們可以看到0xfffffffc和之前的0x00000000一樣,存放的並不是puts函數的地址,只是一個臨時的假地址,因為在編譯的時候,編譯器並不知道puts函數的地址,分配地址的事情交給鏈接器來做。
鏈接
鏈接可以分為靜態鏈接和動態鏈接兩種,GCC默認使用動態鏈接,添加編譯選項"-static"即可指定使用靜態鏈接。,這一階段將目標文件及其依賴庫進行鏈接,生成可執行文件。功能主要包括
-
地址和空間分配(Address and Storage Allocation)
-
符號綁定(Symbol Binding)
-
重定位(Relocation)
將每一個符號的定義與一個內存地址進行關聯,然后修改這些符號的引用,使其指向這個內存地址。
$ gcc hello.o -o hello -static
使用objdump反匯編查看hello文件內容
$ objdump -d hello
(使用書中的這個命令一下子顯示出太多內容,於是我自己使用了objdump -d hello | grep '<main>'
查看了main的地址,然后最后用objdump -d hello | grep '80488'
查看了main函數的反匯編代碼。可以看到此時的push和call中的地址都已經修正到了正確的位置。
此時程序也就可以被加載到內存中正常執行了。