gcc 精彩之旅

在爲Linux開發應用程序時,絕大多數情況下使用的都是C語言,因此幾乎每一位Linux程序員面臨的首要問題都是如何靈活運用C編譯器。目前 Linux下最常用的C語言編譯器是GCC(GNU Compiler Collection),它是GNU項目中符合ANSI C標準的編譯系統,能夠編譯用C、C++和Object C等語言編寫的程序。GCC不僅功能非常強大,結構也異常靈活。最值得稱道的一點就是它可以通過不同的前端模塊來支持各種語言,如Java、 Fortran、Pascal、Modula-3和Ada等。

開放、自由和靈活是Linux的魅力所在,而這一點在GCC上的體現就是程序員通過它能夠更好地控制整個編譯過程。在使用GCC編譯程序時,編譯過程可以被細分爲四個階段:

◆ 預處理(Pre-Processing)

◆ 編譯(Compiling)

◆ 彙編(Assembling)

◆ 鏈接(Linking)

Linux 程序員可以根據自己的需要讓GCC在編譯的任何階段結束,以便檢查或使用編譯器在該階段的輸出信息,或者對最後生成的二進制文件進行控制,以便通過加入不 同數量和種類的調試代碼來爲今後的調試做好準備。和其它常用的編譯器一樣,GCC也提供了靈活而強大的代碼優化功能,利用它可以生成執行效率更高的代碼。

GCC提供了30多條警告信息和三個警告級別,使用它們有助於增強程序的穩定性和可移植性。此外,GCC還對標準的C和C++語言進行了大量的擴展,提高程序的執行效率,有助於編譯器進行代碼優化,能夠減輕編程的工作量。

GCC起步

在學習使用GCC之前,下面的這個例子能夠幫助用戶迅速理解GCC的工作原理,並將其立即運用到實際的項目開發中去。首先用熟悉的編輯器輸入清單1所示的代碼:

清單1:hello.c

#include <stdio.h>
int main(void)
{
printf ("Hello world, Linux programming!n");
return 0;
}

然後執行下面的命令編譯和運行這段程序:

# gcc hello.c -o hello
# ./hello
Hello world, Linux programming!

從程序員的角度看,只需簡單地執行一條GCC命令就可以了,但從編譯器的角度來看,卻需要完成一系列非常繁雜的工作。首先,GCC需要調用預處理程 序 cpp,由它負責展開在源文件中定義的宏,並向其中插入“#include”語句所包含的內容;接着,GCC會調用ccl和as將處理後的源代碼編譯成目 標代碼;最後,GCC會調用鏈接程序ld,把生成的目標代碼鏈接成一個可執行程序。

爲了更好地理解GCC的工作過程,可以把上述編譯過程分成幾個步驟單獨進行,並觀察每步的運行結果。第一步是進行預編譯,使用-E參數可以讓GCC在預處理結束後停止編譯過程:

#  gcc -E hello.c -o hello.i

此時若查看hello.cpp文件中的內容,會發現stdio.h的內容確實都插到文件裏去了,而其它應當被預處理的宏定義也都做了相應的處理。下一步是將hello.i編譯爲目標代碼,這可以通過使用-c參數來完成:

#  gcc -c hello.i -o hello.o

GCC默認將.i文件看成是預處理後的C語言源代碼,因此上述命令將自動跳過預處理步驟而開始執行編譯過程,也可以使用-x參數讓GCC從指定的步驟開始編譯。最後一步是將生成的目標文件鏈接成可執行文件:

#  gcc hello.o -o hello

在採用模塊化的設計思想進行軟件開發時,通常整個程序是由多個源文件組成的,相應地也就形成了多個編譯單元,使用GCC能夠很好地管理這些編譯單 元。假設有一個由foo1.c和foo2.c兩個源文件組成的程序,爲了對它們進行編譯,並最終生成可執行程序foo,可以使用下面這條命令:

#  gcc foo1.c foo2.c -o foo

如果同時處理的文件不止一個,GCC仍然會按照預處理、編譯和鏈接的過程依次進行。如果深究起來,上面這條命令大致相當於依次執行如下三條命令:

# gcc -c foo1.c -o foo1.o
# gcc -c foo2.c -o foo2.o
# gcc foo1.o foo2.o -o foo

在編譯一個包含許多源文件的工程時,若只用一條GCC命令來完成編譯是非常浪費時間的。假設項目中有100個源文件需要編譯,並且每個源文件中都包 含 10000行代碼,如果像上面那樣僅用一條GCC命令來完成編譯工作,那麼GCC需要將每個源文件都重新編譯一遍,然後再全部連接起來。很顯然,這樣浪費 的時間相當多,尤其是當用戶只是修改了其中某一個文件的時候,完全沒有必要將每個文件都重新編譯一遍,因爲很多已經生成的目標文件是不會改變的。要解決這 個問題,關鍵是要靈活運用GCC,同時還要藉助像Make這樣的工具。

警告提示功能

GCC包含完整的出錯檢查和警告提示功能,它們可以幫助Linux程序員寫出更加專業和優美的代碼。先來讀讀清單2所示的程序,這段代碼寫得很糟糕,仔細檢查一下不難挑出很多毛病:

◆main函數的返回值被聲明爲void,但實際上應該是int;

◆使用了GNU語法擴展,即使用long long來聲明64位整數,不符合ANSI/ISO C語言標準;

◆main函數在終止前沒有調用return語句。

清單2:illcode.c

#include <stdio.h>
void main(void)
{
  long long int var = 1;
  printf("It is not standard C code!n");
}

下面來看看GCC是如何幫助程序員來發現這些錯誤的。當GCC在編譯不符合ANSI/ISO C語言標準的源代碼時,如果加上了-pedantic選項,那麼使用了擴展語法的地方將產生相應的警告信息:

# gcc -pedantic illcode.c -o illcode
illcode.c: In function `main':
illcode.c:9: ISO C89 does not support `long long'
illcode.c:8: return type of `main' is not `int'

需要注意的是,-pedantic編譯選項並不能保證被編譯程序與ANSI/ISO C標準的完全兼容,它僅僅只能用來幫助Linux程序員離這個目標越來越近。或者換句話說,-pedantic選項能夠幫助程序員發現一些不符合 ANSI/ISO C標準的代碼,但不是全部,事實上只有ANSI/ISO C語言標準中要求進行編譯器診斷的那些情況,纔有可能被GCC發現並提出警告。

除了-pedantic之外,GCC還有一些其它編譯選項也能夠產生有用的警告信息。這些選項大多以-W開頭,其中最有價值的當數-Wall了,使用它能夠使GCC產生儘可能多的警告信息:

# gcc -Wall illcode.c -o illcode
illcode.c:8: warning: return type of `main' is not `int'
illcode.c: In function `main':
illcode.c:9: warning: unused variable `var'

GCC給出的警告信息雖然從嚴格意義上說不能算作是錯誤,但卻很可能成爲錯誤的棲身之所。一個優秀的Linux程序員應該儘量避免產生警告信息,使自己的代碼始終保持簡潔、優美和健壯的特性。

在處理警告方面,另一個常用的編譯選項是-Werror,它要求GCC將所有的警告當成錯誤進行處理,這在使用自動編譯工具(如Make等)時非常 有用。如果編譯時帶上-Werror選項,那麼GCC會在所有產生警告的地方停止編譯,迫使程序員對自己的代碼進行修改。只有當相應的警告信息消除時,才 可能將編譯過程繼續朝前推進。執行情況如下:

# gcc -Wall -Werror illcode.c -o illcode
cc1: warnings being treated as errors
illcode.c:8: warning: return type of `main' is not `int'
illcode.c: In function `main':
illcode.c:9: warning: unused variable `var'

對Linux程序員來講,GCC給出的警告信息是很有價值的,它們不僅可以幫助程序員寫出更加健壯的程序,而且還是跟蹤和調試程序的有力工具。建議在用GCC編譯源代碼時始終帶上-Wall選項,並把它逐漸培養成爲一種習慣,這對找出常見的隱式編程錯誤很有幫助。

在Linux 下開發軟件時,完全不使用第三方函數庫的情況是比較少見的,通常來講都需要藉助一個或多個函數庫的支持才能夠完成相應的功能。從程序員的角度看,函數庫實 際上就是一些頭文件(.h)和庫文件(.so或者.a)的集合。雖然Linux下的大多數函數都默認將頭文件放到/usr/include/目錄下,而庫 文件則放到/usr/lib/目錄下,但並不是所有的情況都是這樣。正因如此,GCC在編譯時必須有自己的辦法來查找所需要的頭文件和庫文件。

GCC採用搜索目錄的辦法來查找所需要的文件,-I選項可以向GCC的頭文件搜索路徑中添加新的目錄。例如,如果在/home/xiaowp/include/目錄下有編譯時所需要的頭文件,爲了讓GCC能夠順利地找到它們,就可以使用-I選項:

# gcc foo.c -I /home/xiaowp/include -o foo

同樣,如果使用了不在標準位置的庫文件,那麼可以通過-L選項向GCC的庫文件搜索路徑中添加新的目錄。例如,如果在/home/xiaowp/lib/目錄下有鏈接時所需要的庫文件libfoo.so,爲了讓GCC能夠順利地找到它,可以使用下面的命令:

# gcc foo.c -L /home/xiaowp/lib -lfoo -o foo

值得好好解釋一下的是-l選項,它指示GCC去連接庫文件libfoo.so。Linux下的庫文件在命名時有一個約定,那就是應該以lib三個字 母開頭,由於所有的庫文件都遵循了同樣的規範,因此在用-l選項指定鏈接的庫文件名時可以省去lib三個字母,也就是說GCC在對-lfoo進行處理時, 會自動去鏈接名爲libfoo.so的文件。

Linux下的庫文件分爲兩大類分別是動態鏈接庫(通常以.so結尾)和靜態鏈接庫(通常以.a 結尾),兩者的差別僅在程序執行時所需的代碼是在運行時動態加載的,還是在編譯時靜態加載的。默認情況下,GCC在鏈接時優先使用動態鏈接庫,只有當動態 鏈接庫不存在時才考慮使用靜態鏈接庫,如果需要的話可以在編譯時加上-static選項,強制使用靜態鏈接庫。例如,如果在 /home/xiaowp/lib/目錄下有鏈接時所需要的庫文件libfoo.so和libfoo.a,爲了讓GCC在鏈接時只用到靜態鏈接庫,可以使 用下面的命令:

# gcc foo.c -L /home/xiaowp/lib -static -lfoo -o foo
代碼優化

代碼優化指的是編譯器通過分析源代碼,找出其中尚未達到最優的部分,然後對其重新進行組合,目的是改善程序的執行性能。GCC提供的代碼優化功能非 常強大,它通過編譯選項-On來控制優化代碼的生成,其中n是一個代表優化級別的整數。對於不同版本的GCC來講,n的取值範圍及其對應的優化效果可能並 不完全相同,比較典型的範圍是從0變化到2或3。

編譯時使用選項-O可以告訴GCC同時減小代碼的長度和執行時間,其效果等價於-O1。在這一級別上能夠進行的優化類型雖然取決於目標處理器,但一 般都會包括線程跳轉(Thread Jump)和延遲退棧(Deferred Stack Pops)兩種優化。選項-O2告訴GCC除了完成所有-O1級別的優化之外,同時還要進行一些額外的調整工作,如處理器指令調度等。選項-O3則除了完 成所有-O2級別的優化之外,還包括循環展開和其它一些與處理器特性相關的優化工作。通常來說,數字越大優化的等級越高,同時也就意味着程序的運行速度越 快。許多Linux程序員都喜歡使用-O2選項,因爲它在優化長度、編譯時間和代碼大小之間,取得了一個比較理想的平衡點。

下面通過具體實例來感受一下GCC的代碼優化功能,所用程序如清單3所示。

清單3:optimize.c

#include <stdio.h>

int main(void)

{

 double counter; 

double result; 

double temp; 

for (counter = 0;    counter < 2000.0 * 2000.0 * 2000.0  / 20.0 + 2020;    counter += (5 - 1) / 4)

{    temp = counter / 1979;    result  = counter;      }

 printf("Result is %lfn", result);

 return 0;

}

首先不加任何優化選項進行編譯:

# gcc -Wall optimize.c -o optimize

藉助Linux提供的time命令,可以大致統計出該程序在運行時所需要的時間:

# time ./optimizeResult is 400002019.000000real    0m14.942suser    0m14.940ssys     0m0.000s

接下去使用優化選項來對代碼進行優化處理:

# gcc -Wall -O optimize.c -o optimize

在同樣的條件下再次測試一下運行時間:

# time ./optimizeResult is 400002019.000000real    0m3.256suser    0m3.240ssys     0m0.000s

對比兩次執行的輸出結果不難看出,程序的性能的確得到了很大幅度的改善,由原來的14秒縮短到了3秒。這個例子是專門針對GCC的優化功能而設計 的,因此優化前後程序的執行速度發生了很大的改變。儘管GCC的代碼優化功能非常強大,但作爲一名優秀的Linux程序員,首先還是要力求能夠手工編寫出 高質量的代碼。如果編寫的代碼簡短,並且邏輯性強,編譯器就不會做更多的工作,甚至根本用不着優化。


 優化雖然能夠給程序帶來更好的執行性能,但在如下一些場合中應該避免優化代碼:

◆ 程序開發的時候 優化等級越高,消耗在編譯上的時間就越長,因此在開發的時候最好不要使用優化選項,只有到軟件發行或開發結束的時候,才考慮對最終生成的代碼進行優化。

◆ 資源受限的時候 一些優化選項會增加可執行代碼的體積,如果程序在運行時能夠申請到的內存資源非常緊張(如一些實時嵌入式設備),那就不要對代碼進行優化,因爲由這帶來的負面影響可能會產生非常嚴重的後果。

◆ 跟蹤調試的時候 在對代碼進行優化的時候,某些代碼可能會被刪除或改寫,或者爲了取得更佳的性能而進行重組,從而使跟蹤和調試變得異常困難。

調試

一個功能強大的調試器不僅爲程序員提供了跟蹤程序執行的手段,而且還可以幫助程序員找到解決問題的方法。對於Linux程序員來講,GDB(GNU Debugger)通過與GCC的配合使用,爲基於Linux的軟件開發提供了一個完善的調試環境。

默認情況下,GCC在編譯時不會將調試符號插入到生成的二進制代碼中,因爲這樣會增加可執行文件的大小。如果需要在編譯時生成調試符號信息,可以使 用GCC 的-g或者-ggdb選項。GCC在產生調試符號時,同樣採用了分級的思路,開發人員可以通過在-g選項後附加數字1、2或3來指定在代碼中加入調試信息 的多少。默認的級別是2(-g2),此時產生的調試信息包括擴展的符號表、行號、局部或外部變量信息。級別3(-g3)包含級別2中的所有調試信息,以及 源代碼中定義的宏。級別1(-g1)不包含局部變量和與行號有關的調試信息,因此只能夠用於回溯跟蹤和堆棧轉儲之用。回溯跟蹤指的是監視程序在運行過程中 的函數調用歷史,堆棧轉儲則是一種以原始的十六進制格式保存程序執行環境的方法,兩者都是經常用到的調試手段。

GCC產生的調試符號具有普遍的適應性,可以被許多調試器加以利用,但如果使用的是GDB,那麼還可以通過-ggdb選項在生成的二進制代碼中包含 GDB專用的調試信息。這種做法的優點是可以方便GDB的調試工作,但缺點是可能導致其它調試器(如DBX)無法進行正常的調試。選項-ggdb能夠接受 的調試級別和-g是完全一樣的,它們對輸出的調試符號有着相同的影響。

需要注意的是,使用任何一個調試選項都會使最終生成的二進制文件的大小急劇增加,同時增加程序在執行時的開銷,因此調試選項通常僅在軟件的開發和調試階段使用。調試選項對生成代碼大小的影響從下面的對比過程中可以看出來:

# gcc optimize.c -o optimize

# ls optimize -l-rwxrwxr-x  1 xiaowp   xiaowp  11649 Nov 20 08:53 optimize  (未加調試選項)

# gcc -g optimize.c -o optimize

# ls optimize -l-rwxrwxr-x  1 xiaowp   xiaowp  15889 Nov 20 08:54 optimize  (加入調試選項)

雖然調試選項會增加文件的大小,但事實上Linux中的許多軟件在測試版本甚至最終發行版本中仍然使用了調試選項來進行編譯,這樣做的目的是鼓勵用戶在發現問題時自己動手解決,是Linux的一個顯著特色。

下面還是通過一個具體的實例說明如何利用調試符號來分析錯誤,所用程序見清單4所示。

清單4:crash.c

#include <stdio.h>

 int main(void)

int input =0; 

printf("Input an integer:"); 

scanf("%d", input); 

printf("The integer you input is %dn", input);

 return 0;

}

編譯並運行上述代碼,會產生一個嚴重的段錯誤(Segmentation fault)如下:

# gcc -g crash.c -o crash

# ./crashInput

an integer:10Segmentation fault

爲了更快速地發現錯誤所在,可以使用GDB進行跟蹤調試,方法如下:

# gdb crashGNU gdb Red Hat Linux (5.3post-0.20021129.18rh)……(gdb)

當GDB提示符出現的時候,表明GDB已經做好準備進行調試了,現在可以通過run命令讓程序開始在GDB的監控下運行:

(gdb) runStarting program: /home/xiaowp/thesis/gcc/code/crashInput an integer:10Program received signal SIGSEGV, Segmentation fault.0x4008576b in _IO_vfscanf_internal () from /lib/libc.so.6

仔細分析一下GDB給出的輸出結果不難看出,程序是由於段錯誤而導致異常中止的,說明內存操作出了問題,具體發生問題的地方是在調用 _IO_vfscanf_internal ( )的時候。爲了得到更加有價值的信息,可以使用GDB提供的回溯跟蹤命令backtrace,執行結果如下:

(gdb) backtrace

#0  0x4008576b in _IO_vfscanf_internal () from /lib/libc.so.6

#1  0xbffff0c0 in ?? ()

#2  0x4008e0ba in scanf () from /lib/libc.so.6

#3  0x08048393 in main () at crash.c:11

#4  0x40042917 in __libc_start_main () from /lib/libc.so.6

跳過輸出結果中的前面三行,從輸出結果的第四行中不難看出,GDB已經將錯誤定位到crash.c中的第11行了。現在仔細檢查一下:

(gdb) frame 3

#3  0x08048393 in main () at crash.c:1111       scanf("%d", input);

使用GDB提供的frame命令可以定位到發生錯誤的代碼段,該命令後面跟着的數值可以在backtrace命令輸出結果中的行首找到。現在已經發現錯誤所在了,應該將

scanf("%d", input);改爲scanf("%d", &input);

完成後就可以退出GDB了,命令如下:

(gdb) quit

GDB的功能遠遠不止如此,它還可以單步跟蹤程序、檢查內存變量和設置斷點等。

調試時可能會需要用到編譯器產生的中間結果,這時可以使用-save-temps選項,讓GCC將預處理代碼、彙編代碼和目標代碼都作爲文件保存起 來。如果想檢查生成的代碼是否能夠通過手工調整的辦法來提高執行性能,在編譯過程中生成的中間文件將會很有幫助,具體情況如下:

# gcc -save-temps foo.c -o foo

# ls foo*foo  foo.c  foo.i  foo.s

GCC 支持的其它調試選項還包括-p和-pg,它們會將剖析(Profiling)信息加入到最終生成的二進制代碼中。剖析信息對於找出程序的性能瓶頸很有幫 助,是協助Linux程序員開發出高性能程序的有力工具。在編譯時加入-p選項會在生成的代碼中加入通用剖析工具(Prof)能夠識別的統計信息,而- pg選項則生成只有GNU剖析工具(Gprof)才能識別的統計信息。

最後提醒一點,雖然GCC允許在優化的同時加入調試符號信息,但優化後的代碼對於調試本身而言將是一個很大的挑戰。代碼在經過優化之後,在源程序中 聲明和使用的變量很可能不再使用,控制流也可能會突然跳轉到意外的地方,循環語句有可能因爲循環展開而變得到處都有,所有這些對調試來講都將是一場噩夢。 建議在調試的時候最好不使用任何優化選項,只有當程序在最終發行的時候才考慮對其進行優化。
上次的培訓園地中介紹了GCC的編譯過程、警告提示功能、庫依賴、代碼優化和程序調試六個方面的內容。這期是最後的一部分內容。
在將源代碼變成可執行文件的過程中,需要經過許多中間步驟,包含預處理、編譯、彙編和連接。這些過程實際上是由不同的程序負責完成的。大多數情況下GCC可以爲Linux程序員完成所有的後臺工作,自動調用相應程序進行處理。

這樣做有一個很明顯的缺點,就是GCC在處理每一個源文件時,最終都需要生成好幾個臨時文件才能完成相應的工作,從而無形中導致處理速度變慢。例 如,GCC 在處理一個源文件時,可能需要一個臨時文件來保存預處理的輸出、一個臨時文件來保存編譯器的輸出、一個臨時文件來保存彙編器的輸出,而讀寫這些臨時文件顯 然需要耗費一定的時間。當軟件項目變得非常龐大的時候,花費在這上面的代價可能會變得很沉重。

解決的辦法是,使用Linux提供的一種更加高效的通信方式—管道。它可以用來同時連接兩個程序,其中一個程序的輸出將被直接作爲另一個程序的輸入,這樣就可以避免使用臨時文件,但編譯時卻需要消耗更多的內存。

在編譯過程中使用管道是由GCC的-pipe選項決定的。下面的這條命令就是藉助GCC的管道功能來提高編譯速度的:

# gcc -pipe foo.c -o foo

在編譯小型工程時使用管道,編譯時間上的差異可能還不是很明顯,但在源代碼非常多的大型工程中,差異將變得非常明顯。

文件擴展名

在使用GCC的過程中,用戶對一些常用的擴展名一定要熟悉,並知道其含義。爲了方便大家學習使用GCC,在此將這些擴展名羅列如下:

.c C原始程序;

.C C++原始程序;

.cc C++原始程序;

.cxx C++原始程序;

.m Objective-C原始程序;

.i 已經過預處理的C原始程序;

.ii 已經過預處理之C++原始程序;

.s 組合語言原始程序;

.S 組合語言原始程序;

.h 預處理文件(標頭文件);

.o 目標文件;

.a 存檔文件。

GCC常用選項

GCC作爲Linux下C/C++重要的編譯環境,功能強大,編譯選項繁多。爲了方便大家日後編譯方便,在此將常用的選項及說明羅列出來如下:

-c 通知GCC取消鏈接步驟,即編譯源碼並在最後生成目標文件;

-Dmacro 定義指定的宏,使它能夠通過源碼中的#ifdef進行檢驗;

-E 不經過編譯預處理程序的輸出而輸送至標準輸出;

-g3 獲得有關調試程序的詳細信息,它不能與-o選項聯合使用;

-Idirectory 在包含文件搜索路徑的起點處添加指定目錄;

-llibrary 提示鏈接程序在創建最終可執行文件時包含指定的庫;

-O、-O2、-O3 將優化狀態打開,該選項不能與-g選項聯合使用;

-S 要求編譯程序生成來自源代碼的彙編程序輸出;

-v 啓動所有警報;

-Wall 在發生警報時取消編譯操作,即將警報看作是錯誤;

-Werror 在發生警報時取消編譯操作,即把報警當作是錯誤;

-w 禁止所有的報警。

小結

GCC 是在Linux下開發程序時必須掌握的工具之一。本文對GCC做了一個簡要的介紹,主要講述瞭如何使用GCC編譯程序、產生警告信息、調試程序和加快 GCC的編譯速度。對所有希望早日跨入Linux開發者行列的人來說,GCC就是成爲一名優秀的Linux程序員的起跑線。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章