前言
這個部分主要就講講最后一個部分,也是最復雜的一個部分--- 代碼生成。代碼生成需要涉及到一下幾個方面,分別是匯編代碼生成、鏈接和加載。不過在講代碼生成之前先要講一下和代碼優化,畢竟這個部分單獨先拿出來講不會影響內容的連貫性。
代碼優化
優化的目的一般是為了提升代碼運行的速度,不過也有以減少代碼量為目的的優化。下面我簡要通過一張表來解釋常見的優化案例:
優化方法 | 例子 | 處理 |
---|---|---|
常量折疊 | int max = 2*1024; | 直接在編譯過程中算出結果 |
代數簡化 | int x = y * 1; | 利用數學表達式的性質代替計算的公式 |
降低運算強度 | int y = x * 2; | 乘法變成位移,次數較少的乘法運算變成加法 |
削除共同子表達式 | int x = a * b + c ; int y = a * b+1; | 其中共同的計算部分只需要計算一次就行 |
消除無效語句 | if(false) func(x); | 這個指令在邏輯上基本是運行不到的,可以直接刪除 |
函數內聯 | 這個和c++里面的inline差不多,就是把短的語句插入到其他地方,避免沒有必要的壓棧還有清理過程 |
在Cflat編譯器中,作者使用的是一種“窺孔優化”,即對于一部分機器碼進行匹配,然后做優化,這里就不展開了。
代碼生成
匯編代碼語法
匯編代碼具體的語法可以看到一下《自制編譯器》的第三部分,我認為這個部分寫的非常詳細,可以作為很好的參考手冊。
匯編代碼舉例
import stdio;
int a = 10;
int func(int b) { return b + 1; }
int main(int argc, char ** argv) {
printf("%s\n", "hello,world!");
}
這個例子中聲明了一個全局變量和一個函數,以及在main()
函數中輸出了hello,world
,可以說是一個非常常規的例子了,他的對應的由Cflat編譯器輸出的匯編代碼如下:
# 說明匯編代碼對應的文件的名稱
.file "test.cb"
.data
# 聲明全局變量 對齊方式 類型(@object 表示變量)
.globl a
.align 4
.type a,@object
.size a,4
a:
# 說明a這個變量是4個字節 初始值為10
.long 10
# 聲明字符串這樣的只讀量會放在read only data節(rodata)
.section .rodata
.LC0:
.string "%s\n"
.LC1:
.string "hello,world!"
#改變節 將下面的函數的代碼寫在機器碼對應的 .text節
.text
.globl func
.type func,@function
# 對應的函數代碼
func:
# 函數序言
pushl %ebp
movl %esp, %ebp
#將參數的值通過間接內存訪問放入eax寄存器
movl 8(%ebp), %eax
#加1并且返回 返回值一般都會通過eax返回
addl $1, %eax
jmp .L0
.L0:
# 函數尾聲
movl %ebp, %esp
popl %ebp
ret
# 計算出函數func的大小 “.”符號說明的是函數末尾的地址-main函數的開頭的地址得到函數的大小
.size func,.-func
.globl main
.type main,@function
# 這個標簽其實就是main函數的地址,方便其他函數調用
main:
# 函數序言
pushl %ebp
movl %esp, %ebp
# 把標簽對應的字符串放到eax寄存器
movl $.LC1, %eax
# 參數壓棧 兩個參數 一個格式化參數 一個輸出參數,從右向左壓棧
pushl %eax
movl $.LC0, %eax
pushl %eax
# 表示調用printf(去printf的地址調用printf 但是printf在哪里呢?)
call printf
addl $8, %esp
.L1:
# 函數尾聲
movl %ebp, %esp
popl %ebp
ret
.size main,.-main
如果對于匯編語言理解上有問題的話問題不大,因為注釋已經寫的比較明白了,下面幾節將會闡述這段匯編代碼的幾個問題
- 函數相關的機器棧幀分配問題 (調用者 被調用者 參數壓棧等等)
- 函數序言和函數尾聲
- 對于沒有聲明在
main()
函數中printf()
的地址在哪里?
對于變量分配,全局變量的分配就是使用匯編代碼的范疇,這里不詳細解釋了。
Stop here!
在生成中間代碼ir樹到匯編代碼的步子是不是邁的有點大?事實上從中間代碼到匯編代碼的生成的階段需要完成理解我下面講解的內容才能更好地理解代碼生成的過程。其實匯編代碼的生成過程也是類似上面的從代碼到抽象語法樹這樣的一個翻譯的過程(但是整體的邏輯更復雜一些),這些匯編代碼最后交給了Linux系統的GNU as匯編器生成了目標文件.out 文件(ELF格式),比如
as -o hello.o hello.s
之后再將這個文件和標準庫鏈接才能輸出可執行文件
gcc hello.o -o hello
ir樹到匯編代碼的具體流程先按下不表,后面再詳細說明。
函數調用約定 calling convention
程序的調用約定是指:
根據 CPU和OS決定函數函數調用的具體實現方法的約定。
所以說到了編譯的第四個部分這里因為不同的CPU的指令集上有差異,而且OS提供的一些工具包也不同,在代碼生成的階段是一個對CPU和OS強依賴的階段(Cflat編譯器就是在x86架構的CPU,Linux系統上實現的)。
結合具體的例子分析調用過程
首先要明確一點,機器棧其實上是內存的一塊區域,并不是通過一種編程語言創建的一種棧的數據結構,只是匯編語言通過了類似于先入后出(First in last out)的機制來操作這片內存。
int f(int x, int j){
int i, j;
i = x;
j = i * y;
return j;
}
int main(int argc, char ** argv) {
int i = 77;
i = f(i, 8);
i %= 5;
return i;
}
對于這個代碼,對應的匯編代碼以及內容我通過下圖表示出來了
下面簡單的做一下說明:
下到匯編語言這個層級有幾點需要明確:
-
寄存器的數量非常少
寄存器是CPU中非常關鍵的資源,他非常少,但是由不得不用,試想一下只有很少的寄存器(相當于是編程語言的全局變量)就要完成編程語言到機器語言的轉化,難度是很大的。但是還是可以通過壓入(push)和彈出(pop)機器棧的方式保存的。
- 函數對參數的訪問、序言、尾聲
在函數調用的過程中,函數是通過棧底指針寄存器ebp+偏移量(n(%ebp))的方式來訪問實際參數的,但是函數之間存在著調用和被調用的關系,被調用函數就需要保存調用者的棧底指針寄存器ebp的值。同時調用函數還需要負責給被調用函數開辟出一塊內存空間(這就是函數的序言部分),被調用函數還需要執行在調用完成之后的還原工作,畢竟得把別人棧頂(esp)和棧底(ebp)還原回去,這部分就是函數的尾聲。
雖然書上沒有寫,但是我們也不妨看看f函數的的匯編代碼:
f:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
# 從這里開始就是省略的部分
# 訪問函數的實際參數
movl 8(%ebp), %eax
movl %eax, -4(%ebp)
movl -4(%ebp), %eax
movl 12(%ebp), %ecx
# 在ecx 和eax進行運算操作
imull %ecx, %eax
movl %eax, -8(%ebp)
movl -8(%ebp), %eax
jmp .L0
# 函數尾聲
.L0:
movl %ebp, %esp
popl %ebp
ret
.size f,.-f
上面的代碼中,之所以是ebp 的地址+8 是因為call調用返回地址壓棧之后,返回地址上面的就是被調用函數f的棧底地址,而且main函數在調用f之前就給他的兩個參數壓棧了,所以很容易就尋找到了實際參數的地址。
callee-save和caller-save寄存器
為了減少函數在調用和被調用的過程中對于通用寄存器反復壓棧出棧的過程,寄存器一般被劃分為callee-save(被調用者保存寄存器)caller-save(調用者保存寄存器)
舉個例子ebp是一個callee-save寄存器,因為被調用的函數(callee)會將調用函數的esp作為自己的ebp,所以被調用的函數(callee)在寫入ebp寄存器之前需要保存調用者(caller)的ebp,然后再向esp中寫入內容,因此調用者需要保存自己的esp(caller-save),如果仔細分析一下上面函數調用的流程就可以理解了。
函數的編譯過程
在程序調用約定中還有一條非常重要的要求就是對于函數的棧幀的結構的約定
其中可以看到如果函數被調用的時候局部變量需要放在callee-save寄存器的地址上面 然后再是臨時變量。
CodeGenerator#compileFunctionBody
private void compileFunctionBody(AssemblyCode file, DefinedFunction func) {
StackFrameInfo frame = new StackFrameInfo();
// 確定參數的地址 實際參數的位置在調用者函數的棧中(caller )通過ebp寄存器往下找
locateParameters(func.parameters());
// 確定局部變量的地址
frame.lvarSize = locateLocalVariables(func.lvarScope());
//函數體的編譯過程現編譯完函數體 然后再找出偏移量
AssemblyCode body = optimize(compileStmts(func));
frame.saveRegs = usedCalleeSaveRegisters(body);
frame.tempSize = body.virtualStack.maxSize();
//根據編譯函數中確定的寄存器(callee-save)來確定局部變量的偏移量
fixLocalVariableOffsets(func.lvarScope(), frame.lvarOffset());
fixTempVariableOffsets(body, frame.tempOffset());
if (options.isVerboseAsm())
printStackFrameLayout(file, frame, func.localVariables());
generateFunctionBody(file, body, frame);
}
這個里面存在一個很大的問題,因為如果不先編譯函數的話就沒有辦法確定這個函數在使用過程中使用的callee-save寄存器器個數,因此也就無法確定臨近的局部變量的內存位置。因為Cflat編譯器采取的策略是先進行函數體的編譯,局部變量的位置在加上callee-save寄存器的偏移量。
總結1
中間代碼編譯成匯編代碼的過程比較復雜,這個地方其實只是挑選出來比較重點的部分說了一下,大體的思路比較好把握,就是需要按照對應的指令集和調用約定構造出符合CPU和OS要求的匯編代碼,然后就可以通過GNU as工具 生成目標文件。
生成目標文件
最開始的時候就講過生成了目標代碼之后需要鏈接和加載才可以生成可執行文件,在Linux中常見的用于描述目標文件、可執行文件以及共享庫的所有信息,并且把機器代碼機器對應的元數據以一種放標連接器加載器處理的形式保存的起來的格式被稱作ELF文件。
ELF文件的格式
ELF文件中有部分是給匯編器處理的有部分是鏈接器生成的,Cflat中匯編器處理的只有四個節。所謂節分別是
名稱 | 作用 |
---|---|
.text | 配置機器碼 |
.data | 擁有初始值的全局變量 |
.rodata | 字符串字面量不能更新的數據(只讀) |
.bss | 沒有初始值的全局變量,在ELF文件中沒有大小信息 |
這些節以及指定他們的相關語法還是屬于匯編語言的范疇的,因此還是需要CodeGenerator中生成這個節進行內存的分配等等操作。
CodeGenerator#generate
public AssemblyCode generate(IR ir) {
//確定全局變量 函數 字符串常量在內存空間中的位置
locateSymbols(ir);
return generateAssemblyCode(ir);
}
private AssemblyCode generateAssemblyCode(IR ir) {
AssemblyCode file = newAssemblyCode();
file._file(ir.fileName());
if (ir.isGlobalVariableDefined()) {
generateDataSection(file, ir.definedGlobalVariables());
}
if (ir.isStringLiteralDefined()) {
generateReadOnlyDataSection(file, ir.constantTable());
}
if (ir.isFunctionDefined()) {
generateTextSection(file, ir.definedFunctions());
}
// #@@range/generateAssemblyCode_last{
if (ir.isCommonSymbolDefined()) {
generateCommonSymbols(file, ir.definedCommonSymbols());
}
if (options.isPositionIndependent()) {
PICThunk(file, GOTBaseReg());
}
return file;
}
鏈接
對于兩個C語言文件main.c和f.c
main.c:
#include <stdio.h>
extern int f(int a, int b);
int main(int argc, char ** argv){
printf("%the answer is d\n", f(1,2));
}
f.c:
int f int f(int a, int b) {return a + b;}
對這兩個文件進行分別編譯輸出目標文件,
gcc -c main.c
gcc -c f.c
因為main中的f函數沒有定義,所以需要將兩個目標文件鏈接起來
gcc main.o f.o -o prog
最終輸出了prog這個可執行文件。在Linux中負責鏈接的程序是/usr/bin/ld。鏈接器對于OS是強依賴的,所以鏈接器一般都會和OS由OS提供商提供。
鏈接器處理操作和能夠處理的文件格式
鏈接器在連接時會進行三個處理
-
合并節
將相同種類的節進行合并
合并節 -
重定位
重定位是指根據程序實際加載到內存時的地址,對目標文件中的代碼和數據進行調整。因為各個文件的編譯和匯編的過程是獨立的,并不知道其他的文件的函數的地址,這樣可能會出現在鏈接的時候同一個地址有多個函數的情況發生,因此需要進行偏移量的調整。
重定位過程 符號消解
對于未定義的符號進行消解的過程,比如printf函數需要在lib.c中找。
-
可重定位文件
- 可執行文件
- 共享庫
- 靜態庫
共享庫和靜態庫后面會詳細講到。
動態鏈接和靜態鏈接
動態鏈接和靜態鏈接的區別就在于:靜態鏈接會在build的時候進行目標文件的鏈接,而動態鏈接只是在build的時候檢查共享庫(動態鏈接庫)和符號是否存在,在運行時進行鏈接。
動態鏈接的優缺點
可以通過理解動態鏈接的優缺點來了解靜態連接的優缺點
優點 | 原因 |
---|---|
容易更新 | 對于庫的更新只需要安裝新的庫,對于靜態庫卻需要重新進行鏈接 |
節省磁盤空間 | 因為靜態庫需要在鏈接了靜態庫文件中保留一份靜態庫的副本,比較浪費磁盤空間 |
節省內存 | .text段只讀,mmap映射到內存之后多個進程可以共享這個內存段 |
缺點 | 原因 |
---|---|
性能稍差 | 運行時進行連接操作需要花費一定的時間 |
連接具有不確定性 | 可能用戶在鏈接時替換為自己的庫 |
位置無關代碼
動態鏈接需要加載共享庫,上面簡單的介紹了一下共享庫。共享庫是通過mmap系統調用直接加載到內存空間中,然后再虛擬地址空間內進程就可以共用這個內存頁。在上面的main.c的例子中使用了f.c中定義的f函數和在libc中定義的printf函數,在匯編代碼中,會使用call <f>和call <printf>類似的代碼來表示調用這兩個函數。
在鏈接時,因為f定義在f.c中,通過重定位可以找到f函數;在鏈接時會加載libc庫也可以確定printf的地址了。
直觀地思路是修改call后面的地址,保證call能夠調用真正的地址。
但是這樣的方式是不行的,因為現代操作系統不允許這樣的行為,代碼節是只讀的只能修改數據節。而且重定位一旦發生就會向內存中寫入數據,共享庫的共享也根本無從談起了。
因此就選擇了一種比較折中的方式,就是重定位到數據段,修改數據段的內容總是可行的。因此對于外部定義的函數的訪問過程就可以通過GOT(全局偏移量表,定義在數據段),來保存函數地址,然后鏈接器也生成了用于獲取外部函數地址的一小段代碼,也就是PLT(Procedure Link Table)。
《自制編譯器》中對這個部分我覺得描述比較模糊,我根本看不懂他的邏輯,于是就選取了《深入理解計算機系統》的一張圖來說明這個過程。
延遲綁定 lazy binding
第一次調用
假設現在要獲取共享庫中定義的函數addvec,也就是序號1表示的部分,這是就會訪問過程連接表PLT中的PLT[2]對應的這一小段代碼。這段代碼需要跳轉到GOT[4]對應的地址,其實也就是訪問地址在4005c6
然后執行相應的操作。繼續執行PLT[2]中的代碼,帶了4005cb ,這里跳轉到了4005a0也就是PLT[0]對應的程序,他的目的是調用動態鏈接器,執行PLT[0]中的兩部操作就給動態鏈接器傳遞了所需的參數,于是也就確定了addvec的位置。
后續調用
上面這樣的操作有一個顯而易見的好處,類似于libc.so 這樣有很多很多的函數的庫,調用方只會使用需要鏈接的庫進行鏈接,就可以避免很多沒有必要的重定位,
在上面的圖中,如果如果是第二次或者后續在調用addvec的時候會直接跳轉到GOT[4]的地址,此時因為addvec的地址已經確定,所以可以直接使用addvec
總結
編譯的四個過程就全部都講完了,我們再來梳理一下這個過程
對于任意的cflat代碼,會按照圖中的代碼的調用順序進行調用。在compile()中進行語法分析,語義分析并且聲稱中間代碼,然后就會進行匯編代碼生成,最后Cflat編譯器會調用Linux中的一些必要的函數和工具進行鏈接,
對于Part1中的圖也可以在最后進行補充了: