gcc 編譯過程和編譯優化
從原始碼(xxx.cpp)生成可執行檔案(a.out)一共分為四個階段:
1、預編譯階段:
此時編譯器會處理原始碼中所有的預編譯指令。預編譯指定非常有特點,全部以“#”開頭。
想想,以“#”開頭的命令有哪些?
不同的命令有不同的處理方法,#include命令的處理方法就是赤裸裸的複製貼上。將#include後面的檔案的內容赤裸裸地複製貼上到#include命令所在的位置。#define命令分為帶參巨集和不帶參巨集。#define命令的處理方法,學名叫巨集展開。其實不帶參巨集的處理方法就是赤裸裸的字串替換。之後會產生一篇完全不包含預編譯指令的程式碼。
使用gcc的-E選項可以檢視預編譯結果:
g++ -E xxx.cpp
但是這個命令不會把處理結果儲存在檔案中,而是放在標準輸出中。你可以自己-o定義檔案輸出
2、彙編階段:
此時編譯器會將預處理過的程式碼進行彙編。
使用gcc的-S選項可以檢視彙編結果:
g++ -S xxx.cpp
之後會在當前目錄下產生一個xxx.s的檔案,裡面儲存的是彙編程式碼。
3、編譯階段:
此時編譯器會將彙編程式碼編譯成目標檔案。
使用gcc的-c選項可以生成目標檔案:
g++ -c xxx.s
也可以從原始碼直接生成目標檔案:
g++ -c xxx.cpp
gcc會通過副檔名自動判斷處理的是彙編程式碼還是C++程式碼。
到此,編譯器已經完成它的全部工作。
4、連結階段:
此時已經沒有編譯器的事情了。連結工作交由連結器來處理。連結器會將多個目標檔案連結成可執行檔案。
我們可以通過gcc來進行連結,但是實際上,gcc還是呼叫ld命令來完成連結工作的。
g++ xx1.o xx2.o
編譯期優化選項: -pipe
上文講到,從原始碼生成最終的可執行檔案需要四個步驟,並且還會產生中間檔案。
可是我們在對一個原始檔編譯的時候,直接執行g++ xxx.cpp能夠得到可執行檔案a.out,但是並沒有中間檔案啊!中間檔案在哪裡?
答案是,在/tmp目錄下。想看嗎?跟著我做。
1、在終端中執行g++ xxx.cpp。
2、在另外一個終端中執行ls /tmp/cc* 2>/dev/null。
看見什麼了?什麼也沒有啊!說明你太慢了。
你需要在第一個命令完成前,執行第二個命令,否則什麼也看不見。你大概只有不到0.1秒的時間。
寫一個指令碼來看吧。
#!/bin/bash
g++ main.cpp &
sleep 0.05
ls --color=auto /tmp/cc*
在我的電腦上,時間是0.05的時候可以看到如下結果:
/tmp/cc9CD8ah.o /tmp/ccj9uXNd.s
可以看到,有.s彙編檔案,.o目錄檔案。
所以,實際上gcc將中間檔案放在了/tmp目錄下,並且在編譯完成後會將其刪除。
可是這樣有一個問題,讀寫檔案都是IO操作,效率上會不會很慢?
我們需要將上一步的結果交給下一步處理,有沒有什麼比較快的方法?
如果您瞭解linux的話,會立即想到一個牛X閃閃的東西:管道。
將上一步編譯的結果通過管道傳遞給下一步,這不需要IO操作,全部在記憶體中完成,效率上會有非常大的提高。
gcc提供了這個功能,方法是使用-pipe選項。
g++ -pipe main.cpp
下面是gcc的man手冊中關於-pipe選項的解釋:
-pipe
Use pipes rather than temporary files for communication between the
various stages of compilation. This fails to work on some systems
where the assembler is unable to read from a pipe; but the GNU
assembler has no trouble.
編譯期優化選項: ——O (大寫O)
一段程式碼例子
int createNum();
void putNum(int a);
int sum(int a,int b)
{
return a+b;
}
int main()
{
int x=createNum();
int y=createNum();
int z=sum(x,y);
putNum( z );
return 0;
}
我們來檢視一下它的彙編程式碼:
g++ -s main.cpp
得到一個main.s。開啟這個檔案,擷取其中main函式的一小段,加上一些註釋,如下:
call _Z9createNumv ;呼叫createNum()函式
movl %eax, -4(%rbp) ;將返回值壓棧
call _Z9createNumv ;再呼叫createNum()函式
movl %eax, -8(%rbp) ;將返回值壓棧
movl -8(%rbp), %edx ;將棧頂資料放在暫存器edx中
movl -4(%rbp), %eax ;同上,放在暫存器eax中
movl %edx, %esi ;將暫存器edx中的資料作為sum()函式的第一個引數
movl %eax, %edi ;將暫存器eax中的資料作為sum()函式的第二個引數
call _Z3sumii ;呼叫sum()函式
movl %eax, -12(%rbp) ;將返回值壓棧
movl -12(%rbp), %eax ;將棧頂資料放在暫存器eax中
movl %eax, %edi ;將暫存器eax中的資料作為putNum()函式的第一個引數
call _Z6putNumi ;呼叫putNum()函式
大家覺得,是不是很麻煩?
每次呼叫一個函式之後,先壓棧,然後又轉到暫存器中,這很浪費時間。
gcc會這麼笨嗎?當然不會。gcc的-O選項(注意,是大寫。回想一下,小寫-o選項是幹什麼的?前面講過)就是用來處理編譯期優化的。我們重新產生一下彙編程式碼,但是使用-O選項。
g++ -O -s main.cpp -o main.O1.s
現在開啟main.O1.s檔案,看,裡面函式的返回值沒有經過入棧和出棧的過程,直接傳入下一個函式的引數。這樣減少了六條彙編程式碼。
可是細想想,sum()函式有點多餘。它實際上只是做了一個加法,但是我們仍然需要呼叫這個函式來完成它的功能。我們知道,呼叫一個函式就需要幾條到十幾條彙編程式碼,這是很浪費時間的。gcc會這麼笨嗎?當然不會。-O選項還可以加數字,表示優化的級別。沒有數字預設是1,最大可以加到3。優化級別越高,產生的程式碼的執行效率就越高。我們用級別2試一下:
g++ -O2 -s main.cpp -o main.O2.s
現在開啟main.O2.s,大家可以看到,呼叫sum()函式的程式碼都不見了,取而代之的是一條加法指令來完成兩個整數的相加。
-O3的效果我就不試了。而且,如果不加-O選項,優化級別就是0。
既然-O後面的的數字越大,產生的程式碼越優化,那麼為什麼不直接用-O3?原因是,優化的級別越高,雖然最後生成的程式碼的執行效率就會越高,但是編譯的過程花費的時間就會越長。如果你曾經編譯過大的軟體(比如下載KDE原始碼,然後編譯安裝),你就會知道,相比於JAVA等語言,C++的編譯效率是非常低的。同樣都是100萬行程式碼,C++編譯它需要四個小時,JAVA可能只需要十分鐘。所以,在執行效率和編譯時間之間,需要做出一個權衡。gcc沒有擅自做這個決定,而是把決定的權力留給了使用者。
在linux的世界裡,有這樣一個觀點:讓軟體儘可能少的代替使用者做出決定,讓使用者能夠儘可能多的做自己想要的效果。所以linux的很多軟體都有很多選項
下面的內容有些無聊,只是把O選項相關的文件翻譯出來。想了解的可以瞭解下,想深入瞭解的可以去看gcc的man手冊。
括號裡面的是我自己的想法,剩下的是gcc的man手冊中關於O選項的翻譯。
-O
-O1 優化。優化編譯將多花費一些時間,還會在編譯大函式的時候消耗更多的記憶體。
加上-O選項以後,編譯器試圖減少生成可執行檔案的大小和執行時間。相比於不加優化將花費大量的編譯時間。
-O選項啟用以下優化器:
-fauto-inc-dec -fcprop-registers -fdce -fdefer-pop -fdelayed-branch
-fdse -fguess-branch-probability -fif-conversion2 -fif-conversion
-fipa-pure-const -fipa-reference -fmerge-constants -fshrink-wrap
-fsplit-wide-types -ftree-builtin-call-dce -ftree-ccp -ftree-ch
-ftree-copyrename -ftree-dce -ftree-dominator-opts -ftree-dse
-ftree-forwprop -ftree-fre -ftree-phiprop -ftree-sra -ftree-pta
-ftree-ter -funit-at-a-time
在那些不會影響除錯的裝置上,-O選項也會啟動-fomit-frame-pointer優化器。
-O2 更多的優化。GCC將在不需要用空間換取時間的條件下,啟用幾乎所有支援的優化器。與-O選項比較,這個選項雖然增加了編譯的時間,但生成的程式碼更加高效了。
-O2選項除了啟用-O選項的所有優化器外,還將啟用以下優化器:
-fthread-jumps
-falign-functions -falign-jumps -falign-loops -falign-labels
-fcaller-saves -fcrossjumping -fcse-follow-jumps -fcse-skip-blocks
-fdelete-null-pointer-checks -fexpensive-optimizations -fgcse
-fgcse-lm -finline-small-functions -findirect-inlining -fipa-sra
-foptimize-sibling-calls -fpeephole2 -fregmove -freorder-blocks
-freorder-functions -frerun-cse-after-loop -fsched-interblock
-fsched-spec -fschedule-insns -fschedule-insns2 -fstrict-aliasing
-fstrict-overflow -ftree-if-to-switch-conversion
-ftree-switch-conversion -ftree-pre -ftree-vrp
-O3 最多的優化。-O3選項啟用-O2的全部優化器,還將啟用以下優化器:
-finline-functions, -funswitch-loops,
-fpredictive-commoning, -fgcse-after-reload, -ftree-vectorize and
-fipa-cp-clone options.
-O0 減少編譯時間並讓除錯程式得到期望的結果。這個是預設值。
-Os 空間優化。-Os啟用-O2選項的所有不會增加生成可執行檔案大小的優化器外,還會為減少生成可執行檔案的大小做更多的優化。
-Os禁用以下優化器:-falign-functions
-falign-jumps -falign-loops -falign-labels -freorder-blocks
-freorder-blocks-and-partition -fprefetch-loop-arrays
-ftree-vect-loop-version
如果您同時啟用多個-O選項,無論有沒有級別數字,只有最後一個選項有效。
編譯期優化選項: -W
優秀的程式設計師不應該忽略任何的warning。
優秀的程式設計師寫的程式碼不但沒有error,還沒有warning。
看一段程式碼
int fun(){
}
int main(){
fun();
}
很簡單,對吧?
有錯誤嗎?事實上是沒有的。
編譯一下:g++ return-type.cpp。也沒有任何問題。
可是事實上,fun函式沒有return語句,那麼它可能會返回一個隨機的值,這種忽略可能會造成嚴重的錯誤。
我們希望,gcc在遇見這類問題的時候,能夠給我們一個提示。
還好,gcc提供了一個-W選項。
我們使用這樣的命令來編譯:
g++ -Wreturn-type return-type.cpp
它仍然能夠正常編譯,生成可執行檔案,但是,它會輸出一句warning:
return-type.cpp: In function ‘int fun()’:
return-type.cpp:3:1: warning: no return statement in function returning non-void
不錯吧?
解釋一下,-W是開啟警告輸出,後面接的是警告的種類。gcc將警告分為好多種(將近一百種)。return-type只是檢查返回值型別。
再看一段程式碼:
int fun(){
int a;
return a;
}
int main(){
fun();
}
按照正常方式編譯:g++ uninitialized.cpp。沒有任何問題。
我們開啟uninitialized種類的警告,這樣編譯:
g++ -Wuninitialized uninitialized.cpp
它輸出的warning是這樣的:
uninitialized.cpp: In function ‘int fun()’:
uninitialized.cpp:4:12: warning: ‘a’ is used uninitialized in this function
但是,種類那麼多,一個一個加會不會很麻煩?
哈哈!gcc的-W選項有個種類叫all。猜是什麼意思?開啟所有種類的警告。很方便吧?