An Introduction To GCC-for the GNU Compilers gcc and g++(GCC 簡介)

Author: Brian Gough
Foreword by Richard M. Stallman

1. 簡介

GCC: GNU Compiler Collection, GNU編譯器集合,脫胎於Richard M. Stallman的GNU(GNU‘s Not Unix)計劃。

2. 編譯C程序

2.1 例子:hello

$ gcc –Wall hello.c –o hello
  • -o:可以指定存儲機器碼的輸出文件,該選項通常是命令行上的最後一個參數。如果省略它,輸出將被寫到默認文件a.out中。
  • -Wall:打開所有最常用到的編譯警告----推薦你總是使用該選項!

GCC輸出的信息總是如 file:line-number:message這種形式。

編譯器區分錯誤信息和警告信息,不能成功編譯的是錯誤信息,指示可能的錯誤的是警告信息(但並不停止程序的編譯)。

2.2 #include “FILE.h”和“#include < FILE.h>”

#include "FILE.h"是先在當前目錄搜索FILE.h,然後再查看包含系統頭文件的目錄。

#include <FILE.h>(注意<>中沒有空格,題目是被Markdown編輯方式逼的)這種include聲明是搜索系統目錄的頭文件,默認情況下不會在當前目錄下查找頭文件。

2.3 從源文件生成對象文件

命令行選項-c用於把源碼文件編譯成對象文件。例如,下面的命令將把源文件“main.c”編譯成一個對象文件:

$ gcc -Wall -c main.c

這會生成一個包含main函數機器碼的對象文件main.o。它包含一個對外部函數hello的引用,但在這個階段該對象文件中的對應的內存地址留着沒有被解析(它將在後面鏈接時被填寫)。

編譯源文件hello_fn.c中的hello函數的相應命令如下:

$ gcc -Wall -c hello_fn.c

這會生成對象文件hello_fn.o

注意,在這裏可以不需要用-o選項來指定輸出文件的文件名。當用-c來編譯時,編譯器會自動生成與源文件同名,但用.o來代替原來的擴展名的對象文件。由於main.chello_fn.c中的#include聲明,hello.h會自動被包括進來,所以在命令行上不需要指定該頭文件。

2.4 從對象文件生成可執行文件

$ gcc main.o hello_fn.o -o hello

一旦源文件被編譯,鏈接是一個要麼成功要麼失敗的明確的過程(只有在有引用不能解析的情況下才會鏈接失敗),所以這裏是少數的無需使用-Wall選項的地方之一。

gcc使用鏈接器ld來施行鏈接,它是一個單獨的程序。

2.5 對象文件的鏈接次序

在類Unix系統上,傳統上編譯器和鏈接器搜索外部函數的次序是在命令行上指定的對象文件中從左到右的查找。這意味着包含函數定義的對象文件應當出現在調用這些函數的任何文件之後。

由於是main調用hello,在這種情況下,包含hello函數的文件hello_fn.o應該被放在main.o之後:

$ gcc main.o hello_fn.o -o hello # (correct order)

2.6 重新編譯和重新鏈接

只重新編譯修改過的文件,然後重新鏈接

2.7 與外部庫文件鏈接

庫是已經編譯好並能被鏈接入程序的對象文件的集合;庫通常被存儲在擴展名爲.a的特殊歸檔文件中,被稱爲靜態庫,它們用一個單獨的工具,GNU歸檔器ar,從對象文件生成;標準的系統庫通常能在/usr/lib/lib目錄下找到;

默認只會鏈接libc.a庫文件,其它的庫文件需要手動指定,如

$ gcc -Wall calc.c /usr/lib/libm.a -o calc

指定libm.a,其中包含數學庫。也可以通過選項-l指定

$ gcc -Wall calc.c -lm -o calc # correct order

通常,-lNAME,將會試圖鏈接庫文件libNAME.a;庫文件鏈接順序也和普通文件一樣,從左到右,即被引用文件應該出現在引用文件之後。

3. 編譯選項

3.1 設置頭文件的搜尋路徑

默認情況下,gcc搜索下列目錄來查找頭文件(即include路徑)

  • /usr/local/include/
  • /usr/include/

gcc搜索下列目錄來查找二進制文件(即library搜索路徑或link路徑)

  • /usr/local/lib/
  • /usr/lib/
    默認搜索路徑也可能包括其它依賴於系統或指定站點的目錄,以及GCC安裝目錄。例如在64位機器上,默認的lib64也會被搜索。

【注意】關於上述目錄的優先級是從大到小的,即一旦查到所需要的文件,就不再查下面的目錄了。

3.2 指定編譯位置以及鏈接位置

$ gcc -Wall -I/opt/gdbm-1.8.3/include -L/opt/gdbm-1.8.3/lib dbmain.c -lgdbm
  • -I:指定include path
  • -L:指定llink path

爲了移植性,不要在源碼中使用絕對路徑

添加查找路徑

$ C_INCLUDE_PATH=/opt/gdbm-1.8.3/include
$ CPLUS_INCLUDE_PATH=/opt/gdbm-1.8.3/include
$ export C_INCLUDE_PATH
$ LIBRARY_PATH=/opt/gdbm-1.8.3/lib
$ export LIBRARY_PATH

擴展查找路徑

$ C_INCLUDE_PATH=.:/opt/gdbm-1.8.3/include:/net/include
$ LIBRARY_PATH=.:/opt/gdbm-1.8.3/lib:/net/lib

一個點.用於指定當前目錄

-I-L可以重複:

$ gcc -I. -I/opt/gdbm-1.8.3/include -I/net/include -L. -L/opt/gdbm-1.8.3/lib -L/net/lib .....

此時搜索順序:

  1. 命令行中的-I-L的選項,從左到右;
  2. 環境變量(C_INCLUDE_PATHLIBRAYR_PATH指定的目錄;
  3. 系統默認目錄。

3.3 共享庫

這種類型的庫需要特別對待——必須在程序執行前從磁盤中加載,這種加載稱爲動態鏈接。這種庫使用.so作爲代表共享對象的後綴名,在對庫文件更新時,無需重新編譯使用該庫的文件。

共享庫的優先級比靜態庫的優先級高,所以-l選項會在查找libXxx.a前,先查找是否有一個libXxx.so的動態庫。默認會在/usr/local/lib//usr/lib/目錄中查找共享庫,如果沒有則需要加入加載路徑。設置加載路徑最簡單的方式是使用LD_LIBRARY_PATH環境變量。例如:

LD_LIBRARY_PATH=/opt/gdbm-1.8.3/lib
export LD_LIBRARY_PATH

爲了不使用共享庫,可以使用-static選項。

3.4 C語言標準

-ansi選項關閉了GCC中和ANSI/ISO標準衝突的擴展,就是使用標準的C,而不是GCC中的C的方言。例如asm在GCC中有特殊含義,而在ANSI/ISO C標準中則是合法的:

#include <stdio.h>
int main(void)
{
    const char asm[] = "6502";
    printf("the string asm is '%s'\n", asm);
    return 0;
}

直接使用gcc -Wall ansi.c -o ansi編譯會報錯:

gcc -Wall ansi.c -o ansi
ansi.c: In function ‘main’:
ansi.c:5:16: error: expected identifier or ‘(’ before ‘asm’
     const char asm[] = "6502";
                ^~~
ansi.c:7:40: error: expected expression before ‘asm’
     printf("the string asm is '%s'\n", asm);
                                        ^~~
Makefile:8: recipe for target 'ansi' failed
make: *** [ansi] Error 1

此時,可以使用gcc -Wall -ansi ansi.c -o ansi
類似的還有inlinetypeofunixvax

_GNU_SOURCE是一個宏,使能了所有GNU C庫中的擴展。使用方法:gcc -Wall -ansi -D_GNU_SOURCE pi.c。類似的功能測試宏(feature test macro)還有:POSIX系統的_POSIX_C_SOURCE、BSD系統的_BSD_SOURCE、SVID系統的SVID_SOURCE、XOPEN系統的_XOPEN_SOURCE_GNU_SOURCE使上述所有的宏都生效,發生衝突的時候,POSIX系統的宏優先級最高。

-pedantic選項配合-ansi選項會使得GCC拒絕所有的GNU C擴展,不僅僅是和ANSI/ISO C標準不兼容的部分。

-std可以指定GCC使用的C語言的標準版本。版本還是很多的,可以通過man gcc查看。

3.5 警告選項

-Wall是一個集合,包括(部分,不全,全部的可以從man gcc查看):

選項 釋義
-Wcomment 對嵌套註釋告警,嵌套註釋何理的解決方法是使用#if 0 ... #endif
-Wformat 對諸如printfscanf系列函數中,格式不匹配告警
-Wunused 對定義後未使用的變量告警
-Wimplicit 對未經聲明就使用的函數告警,常常是忘了加入頭文件
-Wreturn-type 對聲明不是void類型的函數沒有返回,或者在聲明爲void類型的函數返回非空告警

除了-Wall還有其它有用的警告選項
-W-Wall類似,實踐中也常常一起連用。
-Wconversion對隱式類型轉換告警。例如unsigned int x = -1;,雖然被允許,但是正確用法應該是unsigned int x = (unsigned int) -1;
-Wshadow對重新聲明變量(即variable shadowing,變量隱藏)告警。
-Wcast-qual對指針進行類型轉換時移除類型限定符的告警,這種類型限定符如const
-Wwrite-strings隱式的給所有字符串常量一個const限定符,試圖修改他們會產生編譯錯誤。
-Wtraditional是對未形成標準之前的C編譯器告警。
-Werror,上述告警都只是告警,但是仍然會生成目標代碼,而本選項會把告警生成錯誤,並停止編譯。

4. 使用預編譯器

GNU C preprocessor(cpp)即GNU C預編譯器,是GCC包的一部分。預編譯器在編譯之前會擴展源文件中的宏。

4.1 定義宏

#ifdef...#endif用於檢測是否定義了宏,用命令行中-DNAME選項來定義宏,或者使用#define NAME來定義宏,NAME即是宏名。通常宏是未定義的,但是也有編譯器定義好的宏,這些宏大多使用了__開頭,這是一種保留的命名空間,可用cpp -dM /dev/null命令列出所有的GCC的預定義宏,其中/dev/null是可用任意的空文件替換的。而不使用__開頭的宏則一般是系統相關的,可用-ansi指定關閉。

4.2 給宏賦值

-DNAME # 默認情況下值是1
-DNAME=4
-DNAME="2+2"
-DNAME="\"Hello, World!\""

4.3 預處理源文件

使用-E選項,就會輸出對源文件的宏擴展,但是不會編譯源文件。而想要保存編譯產生的中間文件,可以使用-save-temps選項。預處理產生的文件的後綴名是.i,中間文件還有彙編文件.s以及目標文件.o

5. debug

GCC使用-g選項在目標文件代碼和可執行文件代碼中存儲額外的調試信息。

程序異常結束時操作系統會把程序終止時的內存狀態寫入當前目錄下的core文件。但是因爲core文件太大且會迅速佔滿所有磁盤空間,有些系統默認不會該文件,可以通過

$ ulimit -c

查看,如果結果是0,就不會產生core文件。此時可以通過

$ ulimit -c unlimited

來對當前shell進行配置,使其可以寫入任何大小的core文件。如果想永久使用,需要在諸如.bash_profile等文件中配置。

6. 編譯優化

6.1 源碼層面的優化

源碼層面優化很多,也基本和機器無關,這裏只討論兩個。

6.1.1 公共子表達式消除

Common Subexpression Elimination(CSE),就是把表達式中的重複使用的子表達式抽出來,用一個變量來替代,但是該變量不會影響真實的變量:

x = cos(v)*(1+sin(u/2)) + sin(w)*(1-sin(u/2));

可以重寫爲下式,但是臨時變量t只是用以說明而已:

t = sin(u/2);
x = cos(v)*(1+t) + sin(w)*(1-t)

這個過程會在編譯器優化打開時自動執行。

6.1.2 函數內聯

Function Inlining,就是函數不按照函數來調用,爲了避免函數調用的開銷。當然函數調用開銷往往不佔用程序很多開銷,但是在函數本身很小即指令較少但是又大量調用的時候,就開銷顯著了。

double sq(double x)
{
    return x * x;
}

for (i=0; i<100000; i++)
	sum += sq(i+0.5);

通過函數內聯可以做到:

for (i=0; i<100000; i++)
{
    double t = i + 0.5; /* temporary variable */
	sum += t * t;
}

6.2 時空權衡

以時間換空間,以及以空間換時間。

循環展開,這是以空間換時間的一種方法。

for (i=0; i<8; ++i)
	a[i] = i;

需要判斷9次,更有效率的做法是直接賦值,此時無需判斷了,而且賦值也是獨立的,可以並行執行。

a[0] = 0;
a[1] = 1;
a[2] = 2;
a[3] = 3;
a[4] = 4;
a[5] = 5;
a[6] = 6;
a[7] = 7;

6.3 調度

編譯器決定每條指令最好的執行順序。指令流水線。

6.4 優化級別

級別從0到3,使用-OLEVEL,其中LEVEL是0-3。

級別 解釋
-O0(默認) 不執行任何優化,直接編譯。默認時,即不加-O這個選項。
-O1-O 不需要時空權衡的常用優化,通常比級別-O0編譯速度快。
-O2 在級別-O1的基礎上,加上了指令調度。只優化了無需時空權衡的部分,不會增加可執行文件的體積,但是相比-O1還是會花費時間。但這一般是最好的選擇,不會增加可執行文件體積,一般也是GNU包release的默認優化選擇。
-O3 這個會使用更多開銷,例如function inlining就包含在這裏,以空間換時間。但是也可能會陷入過度優化,使得程序變慢。
-funroll-loops 打開循環展開(loop unrolling)功能,獨立於其它優化。會增加程序體積。不管是否產生有益結果,都需要逐一檢查(case-by-case)。
-Os 本選項優化項會減小程序體積。通常在系統內存和磁盤空間受限情況下使用。

不管如何,都要權重優化的開銷,包括帶來的調試複雜度,編譯的時間和空間需求。通常,-O0用於調試,-O2用於開發和部署。

6.5 例子

// test.c
#include <stdio.h>

double powern(double d, unsigned n)
{
    double x = 1.0;
    unsigned j;

    for (j=1; j<=n; ++j)
        x *= d;

    return x;
}

int main(void)
{
    double sum = 0.0;
    unsigned i;

    for (i=1; i<=10000000; ++i)
        sum += powern(i, i%5);

    printf("sum = %g\n", sum);

    return 0;
}
# 測試
$ make test0 && time ./a.out
gcc -Wall -O0 test.c -lm
sum = 4e+33
real    0m0.378s
user    0m0.344s
sys     0m0.031s

$ make test1 && time ./a.out
gcc -Wall -O1 test.c -lm
sum = 4e+33
real    0m0.112s
user    0m0.078s
sys     0m0.031s

$ make test2 && time ./a.out
gcc -Wall -O2 test.c -lm
sum = 4e+33
real    0m0.109s
user    0m0.078s
sys     0m0.031s

$ make test3 && time ./a.out
gcc -Wall -O3 test.c -lm
sum = 4e+33
real    0m0.106s
user    0m0.063s
sys     0m0.031s

$ make testfl && time ./a.out
gcc -Wall -O3 -funroll-loops test.c -lm
sum = 4e+33
real    0m0.110s
user    0m0.078s
sys     0m0.031s

比較的是user使用的信息,給出了CPU實際花費於運行的時間。realsys分別記錄了總的運行時間(包括了其它程序使用的時間,即分時使用CPU)以及系統調用的時間。

6.6 優化與調試

GCC允許優化與調試選項共存,而其它編譯器不一定允許這樣子,因爲優化器會使得調試變得麻煩。但是在程序崩潰的時候,有調試信息比沒有調試信息強。所以推薦使用-g-O2,這也是GNU包release的默認選擇。

6.7 優化與編譯器告警

優化會產生很多別的警告,如對未初始化的變量告警,-Wuninitialized(包含在-Wall中)會在優化啓用時才告警,沒有優化就不告警。

7. 編譯C++程序

GNU C++編譯器直接將C++程序編譯爲彙編程序,使用的是g++。gcc選項通用,也有部分適用於指定C++程序的選項。

C++程序的源碼文件後綴是.cc.cpp.cxx.C

如果一個模板文件在程序中多次使用,就會在多個目標文件中存儲。GNU Linker確保最終程序中只有一份。-fno-implicit-templates

8. 平臺相關的選項

-m
-march=CPU-mcpu=CPU

$ gcc -Wall -march=pentium4 hello.c
$ gcc -Wall -march=athlon hello.c

-m32允許32位代碼,默認產生64位代碼。

9. 故障排除

$ gcc --help
$ gcc -v --help
$ gcc -v --help 2>&1 | more # 我更喜歡用less,不用more

-v是Verbose的意思,展示詳細細節。

10. 編譯器相關工具

10.1 創建庫:ar

創建靜態庫文件

$ ar cr libhello.a hello_fn.o bye_fn.o
$ ar t libhello.a
hello_fn.a
bye_fn.a

選項cr代表create and replace,第一次創建,之後替換。libhello.a是靜態庫文件名,後續參數是打包到靜態庫文件中的目標文件。
選項t代表table of contents,列出靜態庫文件中的所有目標文件。
【注意】發佈一個靜態庫文件,務必隨之發佈頭文件,其中包含公共方法和變量。

使用:

$ gcc -Wall main.c libhello.a -o hello
$ gcc -Wall -L. main.c -lhello -o hello # -L. 是把當前目錄加入library查詢目錄

10.2 使用分析工具profiler:gprof

用於測算程序性能,它會記錄程序中每個函數的調用次數以及每次花費的時間。

$ gcc -Wall -pg collatz.c # -pg就是爲了使用分析工具所必須的選項
$ gprof ./a.out

10.3 覆蓋測試:gcov

分析運行中程序中每行代碼執行的次數,這樣可以發現哪塊區域代碼沒使用過,或沒有經過測試。

$ gcc -Wall -fprofile-arcs -ftest-coverage cov.c

-ftest-coverage添加的指令用於統計每行執行的次數,-fprofile-arcs包含程序每個分支的指令代碼。

$ gcov cov.c會產生一個cov.c.gcov文件,裏面標註了每一行代碼執行次數。沒有執行到的行會標記爲#####,可以使用grep '#####' *.gcovgrep -rn '#####' *.gcov來找出來該行。

11. 編譯器是如何工作的

11.1 編譯過程

#include <stdio.h>

int main(void)
{
    printf("Hello world!\n");
    return 0;
}
預處理
編譯
彙編
鏈接
階段 解釋 舉例
預處理 擴展宏 cpp hello.c > hello.i.ifor C,.iifor C++。
編譯 源碼->彙編語言 gcc -Wall -S hello.i,生成文件hello.s
彙編 彙編語言->機器碼 as hello.s -o hello.o,此時目標文件中printf函數還不知曉。
鏈接 最終的可執行文件 gcc hello.o

選項-save-temps會保存所有中間文件。

12. 檢查編譯後的文件

12.1 file:確認文件

file命令用於查看目標文件或可執行文件的內容,或者決定它的狀態,如是靜態鏈接還是動態鏈接。

$ file a.out
a.out: ELF 32-bit LSB executable, Intel 80386,
version 1 (SYSV), dynamically linked (uses shared
libs), not stripped
條目 解釋
ELF Executable and Linking Format,是可執行文件的內部格式,其它老一點的格式如COFF:Common Object File Format
32-bit 字大小,也可能是64-bit
LSB 在Least Significant Byte字序平臺編譯,如Intel和AMD x86處理器平臺,還有MSB,也就是大端字節序和小端字節序
Intel 80386 處理器型號
version 1 (SYSV) 文件內部格式的版本號
dynamicallly linked 可執行文件使用了共享庫(statically linked使用靜態鏈接,如使用了-static選項)
not stripped 可執行文件包含一個符號表,可以通過strip命令刪除

12.2 nm:檢查符號表

$ nm a.out
08048334 t Letext
08049498 ? _DYNAMIC
08049570 ? _GLOBAL_OFFSET_TABLE_
........
080483f0 T main # 程序main函數起始點
08049590 b object.11
0804948c d p.3
U printf@GLIBC_2.0

大部分符號都是編譯器和操作系統所用。
T代表了函數定義在目標文件中;U代表了函數未定義,應該由鏈接程序鏈接其它目標文件定義。

12.3 ldd:尋找動態鏈接庫

$ gcc -Wall hello.c
$ ldd a.out
libc.so.6 => /lib/libc.so.6 (0x40020000)  # C library libc, shared library, version 6
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)  # dynamic loader library ld-linux, shared library, version 2

$ gcc -Wall calc.c -lm -o calc
$ ldd calc
libm.so.6 => /lib/libm.so.6 (0x40020000)  # math library libm, shared library, version 6
libc.so.6 => /lib/libc.so.6 (0x40041000)  # C library
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000)  # dynamic loader library
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章