以 GNU Toolchain 為探討對象,簡述編譯器如何運作,以及如何實現最佳化 Copyright (慣C) 2015, 2016 宅色夫
[ Page 5 ]
先想想以下程式的執行: (hello.c)
int main() { return 1; }
當你在 GNU/Linux 下編譯和執行後 (gcc -o hello hello.c ; ./hello
),可用 echo $?
來取得程式返回值,也就是 1
,可是這個返回值總有機制來處理吧?所以你需要一套小程式來作,這就是 C runtime (簡稱 crt)。此外,還有 atexit
一類的函式,也需要 C runtime 的介入才能實現。
C 語言和一般的程式語言有個很重要的差異,就是 C 語言設計來滿足系統程式的需求,首先是作業系統核心,再來是一系列的工具程式,像是 ls, find, grep 等等,而我們如果忽略指標操作這類幾乎可以直接對應於組合語言的指令的特色,C 語言之所以需要 runtime,有以下幾個地方:
int main() { return 1; }
也就是 main()
結束後,要將 exit code 傳遞給作業系統的程式,這部份會放在 crt0
setjmp
和 longjmp
函式來存取,這需要額外的函式庫 (如 libgcc) 來協助處理 stackC 語言裡面有沒有例外處理?
在 kernel 裡的「驗證執行檔」步驟,是要驗證什麼?
UNIX 的「執行檔」有很多種可能,一個是依據特定格式保存的機械碼,也可能是透過額外程式去解析的 shell script,作業系統核心必須得事先解析並確認這個合法的執行檔,才能著手去執行
近來還有對執行檔進行簽章的機制,請見: https://lwn.net/Articles/532710/
[ Page 16 ]
[ Page 31 ]
下圖說明如果沒有 IR 這樣的中間表示法,每個前端 (source files; human readable programming language) 直接對應到後端 (machine code) 的話,有太多種組合,勢必會讓開發量爆增。但如果先讓前端輸出 IR,然後 IR 再接到後端,這樣整體的彈性就大多了
[ Page 36 - 38 ]
void foo(int *i1, int *i2, int *res)
{
for (int i = 0; i < 10; i++) {
*res += *i1 + *i2;
}
}
void foo(int *i1, int *i2, int *res)
{
int tmp = *i1 + *i2;
for (int i = 0; i < 10; i++) {
*res += tmp;
}
}
foo(&var, &var, &var)
來呼叫 foo()
,那很明顯就會發生問題了,*res 值的改變影響 *i1
、*i2
。所以 Compiler 不會這樣最佳化,因為不知道 caller 會怎麼傳遞數值。[ Page 40 ]
ret3 = ? (ret1, ret2)
去判斷從何而來GCC 的 SSA 說明
" The SSA form is based on the premise that program variables are assigned in exactly one location in the program. Multiple assignments to the same variable create new versions of that variable. "
SSA 最佳化實例:
constant propagation
用法:
i1 =Φ(i0, i2)
return 0
)延伸閱讀: Static Single Assignment Form, Kenneth Zadeck (GCC Summit 2004)
[ Page 43 ] GCC 可輸出包含 Basic Block 的CFG
gcc -c -fdump-tree-cfg=out test.c
[ Page 62 ] GCC 後端: Register Transfer Language (RTL)
LISP-Style Representation (令人喜愛的 S-Expression :) )
生成 RTL 的方式
gcc -fdump-rtl-expand xxx.c
RTL Uses Virtual Register
GCC Built-in Operation and Arch-defined Operation
Instruction scheduling (Pipeline scheduling)
Peephole optimizations
[ Page 67 ]
假設我們有兩個有號整數: <stdint.h>
int32_t a, b;
然後原本涉及到分支的陳述:
if (b < 0) a++;
可更換為沒有分支的版本:
a -= b >> 31;
[ Page 69 ]
static
的用意![ Page 71 ]
gcc 會把特定的 printf()
悄悄換成 puts()
,這有什麼好處?
printf()
本身就是個解譯器,要處理一堆格式,執行時間和字串長度並未完全相符合。但 puts()
就不一樣,只跟何時找到 null terminator 有關,行為明確多了,當然整體執行時間也更短。
為何 GCC 算是個 Compiler Driver ? 在使用上,我們要進行 link 也是會另外使用 ld , gcc 也可以當 linker 嗎?
ld 是真正的 linker,而 gcc 作為 compiler driver,自然也可以呼叫到 ld 作 linking[name=jserv]
在此範例中.最佳化CFG.為何是將bb0最後加一個goto bb2,而bb2搬移到bb3下方. 如果原本尚未搬移前多一個goto bb2的動作.這樣最佳化的目的是?
減少 compare-n-branch 的總次數 右邊的是接近組合語言的寫法[name=jserv]
[ Page 65 ] GCC後端 管線排程
Instruction scheduling 透過將 instruction 重排的方式以減少 pipeline hazard,已達到 ILP(instruction-level parallelism) 的目的
[ Page 73 ]
LTO 帶來的提昇,可參考 gcc-5 的釋出說明 :(和 Firefox 有關)
During link-time optimization of Firefox, this pass unifies about 31000 functions, that is 14% overall.
About 50% of virtual calls in Firefox are now speculatively devirtualized during link-time optimization.
GCC 前端:
GCC中端:
GCC後端:
介紹完編譯工具後,來講點大概編譯的流程還有格式
先來看這張圖:
.c 和 .s 比較常見先略過,所以就解釋一下 .coff 和 .elf 是什麼:
GAS syntax (AT&T)
.file “test.s”
.text
.global main
.type main, %function
main:
MOV R0, #100
ADD R0, R0, R0
SWI #11
.end
由一個簡短的 code 來介紹,在程式的 section 會看到 .
,是定義一些 control information,如 .file
, .global
等
%function
: 是一種 type 的表示方式 .type
後面可以放 function 或者是 object 來定義之SWI #11
: 透過 software interrupt 去中斷現有程式的執行,通常會存取作業系統核心的服務 (system call).end
: 表示是 the end of the program注意,在後來的 ARM 中,一律以 "SVC" (Supervisor Call) 取代 "SWI",但指令編碼完全一致
==> ARM Instruction Set Quick Reference Card
以下簡介 ELF 中個別 section 的意義: (注意: ELF section 的命名都由 .
開頭)
.bss
: 表示未初始化的 data,依照定義,系統會賦予這些未初始化的值 0.data
: 表示有初始過的 data.dynamic
: 表示 dynamic linking information.text
: 表示 "text" 或者是 executable instructions來複習一下名詞
==> "argument" 和 "parameter" 在中文翻譯一般寫「參數」或「引數」,常常混淆
==> "argument" 的重點是「傳遞給函式的形式」,所以在 C 語言程式寫 int main(int argc, char *argv[])
時,我們稱 argc 是 argument count,而 argv 是 argument vector
==> "parameter" 的重點是「接受到的數值」,比方說 C++ 有 parameterized type,就是說某個型態可以當作另外一個型態的「參數」,換個角度說,「型態」變成像是數值一樣的參數了。
==>https://en.wikipedia.org/wiki/Parameter_(computer_programming)
在撰寫程式常常會使用呼叫 ( call ),在上圖中高階語言直接將參數傳入即可,那麼在組語的時候是如何實作的呢?是透過暫存器? Stack ? memory ? In what order ?我們必須要有個 protocol 來規範
ARM Procedure Call Standard (AAPCS)
以下測試一個參數數量為 4 和 5 的程式:
int add4(int a, int b, int c , int d){
return a + b + c + d;
}
int add5(int a, int b, int c , int d, int e){
return a + b + c + d + e;
}
程式編譯後,用 objdump 組譯會得到類似以下:
紅框標注的是比左邊多出的程式碼,從這裡可以看到參數 1-4 是存在 R0-R3,而第 5個參數存在原本 sp + 4 的位置,隨著程式碼進行 R0-R3 存在 stack 中,圖下為 stack 恢復前的樣子:
因此若寫到需要輸入 5 個或以上的參數時,就必須存取外部記憶體,這也導致效能的損失。
==> xorg xserver 的最佳化案例
下圖為 ARM C program 標準配置記憶體空間的概念圖:
通常 procedure 存取 operands 透過以下幾種方式:
用幾張圖來表現出存取 operands 時,stack 的變化:
圖下為 passed on a register:
圖下為存取 local variables:
在使用 Cross Compiler 時,gcc 前面總有一串術語,例如:
這樣 arm-none-linux-gnueabi-
稱為 target tripe,通常規則如下:
<target>[<endian>][-<vender>]-<os>[-<extra-info>]
先以常見 x86 的平臺為例子:
gcc 下 -v 可以看到以下:
Target: x86_64-redhat-linux
<target>-<vendor>-<os>
的形式
Android Toolchain:
Target: arm-linux-androideabi
<target>-<os>-<extra-info>
androideabi : 雖然 Android 本身是 Linux 但其 ABI 細節跟一般 linux 不太一樣
Linaro ELF toolchain:
Target: arm-none-eabi
<target>-<vender>-<extra-info>
vender = none
extra-info = eabi
Linaro Linux toolchain:
Target: arm-linux-gnueabihf
<target><endian>-<os>-<extra-info>
extra-info:
eabi: EABI
hf : Hard float, 預設有 FPU
Linaro big-endian Linux toolchain:
Target: armeb-linux-gnueabihf
<target><endian>-<vender>-<extra-info>
endian = be = big-endian
Buildroot 2015-02
Target: arm-buildroot-linux-uclibcgnueabi
<target>-<vender>-<os>-<extra-info>
extra-info:
uclibc 用 uclibc (通常預設是 glibc, uclibc 有些細節與 glibc 不同)
gnu : 無意義
eabi : 採用 EABI
NDS32 Toolchain:
Target: nds32le-elf
<target><endian>-<os>
NDS32 為 Andes Technology 公司特有的 CPU 架構
由以上眾多 pattern 大概可以歸納出一些事情:
vender 欄位變化很大,os 欄位可不填。若不填的話,通常代表 Non-OS (Bare-metal)
source: http://kitoslab.blogspot.tw/2015/08/target-triple.html
為何我們要理解編譯器?這是電腦科學最早的思想體系
[ Interpreter, Compiler, JIT from scratch ]
[ Virtual Machine Constructions for Dummies ]
[ Page 14 ]
getc()
返回型態是 int
,而非 char
呢?因為要處理 EOF
(詳情自行 man getc
)EOF
在 Braif*ck 的 I/O 操作沒有具體定義TODO:
[ Q & A ]
LISP
對於 hello.c
的 printf
,在 armcc 是如何處理的呢?