利用GCC開發C程序
原創我們知道,程序可能是由一個源文件編譯而來的,也可能是通過編譯多個源文件得到的,并且有時候還要用到系統程序庫和頭文件。這里所謂構建或編譯,就是把用程序設計語言(例如C或者C++編程語言)編寫的文本式的源代碼轉換成用來控制中央處理器的機器代碼,這些機器代碼不是文本,而是一連串的1和0。之后,這些機器代碼被存放到一個文件中,該文件就是通常所說的可執行文件,有時候也叫做二進制文件。
一、編譯C程序
對于C語言來說,最經典的示例代碼莫過于著名的Hello World了,下面是它的源代碼:
#include <stdio.h> int main (void) { printf ("Hello, world!\n"); return 0; } |
我們這里假設上述源代碼存儲在一個稱為“hello.c”的文件中。若要借助gcc編譯這個“hello.c”文件的話,可以使用下列命令:
$ gcc -Wall hello.c -o hello
上述命令會把“hello.c”文件中的源代碼編譯成機器代碼,并將其放到一個稱為“hello”的可執行文件中。其中選項“-o”告訴gcc輸出一個包含機器代碼的文件,該選項通常作為命令行的最后一個參數;如果省略了該選項,那么編譯輸出將寫到一個名為“a.out”的缺省文件中。
請注意,如果當前目錄中的文件與生成的可執行文件同名的話,原來的文件將被覆蓋掉。
選項“-Wall”的作用是打開編譯程序所有最常用的警告,一般建議總是使用該選項。雖然還有其它的警告選項,但是“-Wall”選項是最重要的一個。GCC不會生成任何警告,除非您啟用了相應的選項。當利用C和C++進行程序設計的時候,編譯程序的警告信息對于檢測程序的問題來說是非常重要的。
本例中,即使使用了“-Wall”選項編譯程序也不會生成任何警告,因為這個程序是完全正確的。如果源代碼沒有導致任何警告,則說明編譯很順利。若要運行該程序,可以鍵入該可執行文件的路徑名,如下所示:
$ ./hello
Hello, world!
上述命令將可執行文件裝入內存,并啟動CPU執行這段內存中的指令。這里的路徑./表示當前目錄,所以 ./hello表示加載并且運行位于當前目錄中的可執行文件“hello”。
二、 查找程序的錯誤
如前所述,當使用C和C++進行編程的時候,編譯程序警告對編程有著莫大的幫助。 為例說明這一點,我們在下面的程序代碼中故意放進了一個細微的錯誤:它不正確地使用了printf函數的時候,因為它使用浮點格式來輸出一個整數值。
#include <stdio.h> int main (void) { printf ("Two plus two is %f\n", 4); return 0; } |
乍一看,很難發現這個錯誤,但是如果在編譯的時候使用了“-Wall”選項的話,編譯程序就很容易發現這個問題。在利用警告選項“-Wall”編譯上面的“bad.c”這個程序的時候,會收到下列消息:
$ gcc -Wall bad.c -o bad bad.c: In function ‘main’: bad.c:6: warning: double format, different type arg (arg 2) |
該消息指出,在“bad.c”文件內的第6行錯誤地使用了一個格式串。實際上,GCC生成的消息有一個固定的格式,即行號:消息。編譯程序對致使編譯失敗的錯誤信息和警告信息進行區別對待,警告信息只是指出可能的問題,但是不會停止程序的編譯。本例中,正確的格式說明符應該是“%d”,關于格式說明符的用法,讀者可以參考有關C語言手冊。
如果不使用警告選項“-Wall”的話,程序在編譯的時候毫無異常,但是在執行的時候卻會得到錯誤的結果:
$ gcc bad.c -o bad $ ./bad Two plus two is 2.585495 (呵呵,結果是不是有點出人意料呀?!) |
我們看到,錯誤的格式說明符導致了錯誤的結果輸出,因為我們傳遞給printf函數的是一個整數而非浮點數。在內存中,整數和浮點數是以不同的形式存放的,并且所占用的字節數通常也不同,所以最終導致了一個不合邏輯的結果。當然,在您實際運行上述程序的時候,得到的結果可能跟這里顯示的不盡相同,這要取決于您所使用的具體硬件平臺以及操作系統。
很明顯,在開發程序的時候如果不使用編譯程序的警告進行檢驗將是非常危險的。因為即使程序中的函數使用不當沒有導致程序崩潰的話,也會導致錯誤的結果,而后者的危害往往更大。所以一定記得打開編譯程序的警告選項“-Wall”,這會為您捕捉到C語言編程時最常見的錯誤。
#p#
三、編制多個源文件
很多時候,一個程序會分解成多個文件分別編寫,特別是對于大型程序,這會不僅使得它更易于編輯和理解,還允許我們對個別部分進行單獨的編譯。在下面的例子中,我們會把Hello World程序分解到三個文件中,即“main.c”、“hello_fn.c”以及頭文件“hello.h”。下面是主程序“main.c”的代碼:
#include "hello.h" int main (void) { hello ("world"); return 0; } |
在前面的“hello.c”程序中,我們是對系統函數printf進行了調用;而這里沒有調用系統函數printf,而是調用了一個新的外部函數hello,這個外部函數定義在一個單獨的“hello_fn.c”文件中。
主程序還包含進了頭文件“hello.h”,這個頭文件存放有hello函數的聲明。聲明用來保證函數調用和函數定義時的參數和返回值類型能夠正確匹配。在“main.c”中,我們不必包含系統的“stdio.h”頭文件來對printf函數進行聲明,之所以這樣是因為“main.c”文件并沒有直接調用printf函數。實際上,在“hello.h”中只有一行聲明,用以說明hello函數的原型:
void hello (const char * name);
Hello函數本身的定義位于“hello_fn.c”文件中:
#include <stdio.h> #include "hello.h" void hello (const char * name) { printf ("Hello, %s!\n", name); } |
這個函數顯示消息“Hello,name!”,當然這里的name實際會被參數name所指的字符串所替代。對于#include "FILE.h" 和#include
$ gcc -Wall main.c hello_fn.c -o newhello
本例中,我們使用“-o”選項為可執行代碼指定輸出文件:“newhello”。 需要注意的是,我們這里沒有在命令行的文件列表中指定頭文件“hello.h”,因為在源文件中的偽指令#include "hello.h" 已經通知編譯程序在適當的時候自動包含該文件。若要運行該程序,鍵入這個可執行文件的路徑名即可,如下所示:
$ ./newhello
Hello, world!
現在,程序的所有部分已被編譯成單個可執行文件,這個文件的執行結果跟前面用單個源文件編譯得到的可執行文件是一致的。
四、文件的單獨編譯
如果程序存儲在一個單一的文件中的話,只要改變其中的任何一個函數,整個程序就得重新編譯,以生成一個新的可執行文件。如果源文件個頭很大的話,這時非常費時間的。
如果程序存儲在不同的獨立的源文件中的話,哪些源代碼改變了,只是重新編譯相應的文件即可。通過這種兩步走的方式,對修改后的源文件單獨編譯之后,再將它們連接起來就行了。
在第一步中,文件編譯后得到的并非一個可執行文件,而是一個目標文件,使用GCC時其擴展名通常為“.o”。
在第二階段,通過一個稱為鏈接器的獨立程序將這些目標文件合并起來。最后,鏈接器把所有目標文件組織成一個單獨的可執行文件。目標文件中存放的是機器代碼,但是對于所有引用的在其他文件中函數或者變量的內存地址都保持未定義狀態。這樣一來,就允許編譯源文件而不會彼此直接引用。當鏈接器生成可執行文件的時候,它才會填上這些“遺漏”的地址。
從源文件創建目標文件
命令行選項“-c”用來將一個源文件編譯成一個目標文件,例如,以下命令將源文件編譯為一個目標文件:
$ gcc -Wall -c main.c
這會生成一個名為“main.o”的目標文件,其中存放的是main函數的機器代碼。此外,它還包含一個對外部函數hello的引用,不過在目前階段相應的內存地址保持為未定義狀態,等到后面的鏈接階段才會填上這些內存地址。可以使用下列命令來編譯“hello_fn.c”源文件中的hello函數 :
$ gcc -Wall -c hello_fn.c
上述命令將生成一個目標文件,名為“hello_fn.o”。 注意,在本例中我們沒有使用“-o”選項來為輸出的文件指定名稱。在使用“-c”選項的時候,編譯程序會自動創建一個跟源文件同名的目標文件,并用“.o”代替原先的擴展名。同時,我們也不必在命令行中放上“hello.h”頭文件,因為“main.c”和“hello_fn.c”文件中的#include語句會自動包含這個頭文件。
從目標文件創建可執行文件
在創建一個可執行文件的時候,最后一步就是使用gcc將各個目標文件鏈接到一起,并填上外部函數的內存地址。為了把各個目標文件連接在一起,可以使用下列命令:
$ gcc main.o hello_fn.o -o hello
由于各個單獨的源文件已經成功地編譯成了目標代碼,所以這里就不必使用“-Wall”警告選項了。源文件一旦編譯好,鏈接就成為一個無歧義的過程,它要么成功,要么失敗——并且,只有在目標文件中存在無法解析的引用的情況下才會發生。
在鏈接階段,gcc使用的工具是鏈接器ld,這是一個獨立的程序。在GNU系統中使用的鏈接器是GNU ld。在其他系統上,GCC可能使用GNU 鏈接器,也可能使用的是它們自己的鏈接器。通過運行鏈接器,gcc從目標文件創建一個可執行文件。現在,我們可以試著運行剛生成的可執行文件了,命令如下所示:
$ ./hello
Hello, world!
我們看到,這個程序的輸出結果跟前面由單個源文件編譯得到的程序的結果是一樣的。
目標文件的鏈接順序
在類UNIX系統上,編譯器和鏈接器的傳統做法是將命令行指定的目標文件按照從左到右的順序進行搜索。這意味著,那些含有函數定義的目標文件應當放在所有調用這些函數的文件之后。本例中,包含有hello函數的“hello_fn.o”文件應該位于“main.o”文件之后,因為main函數將調用hello函數:
$ gcc main.o hello_fn.o -o hello (正確的順序)
對于一些編譯器或者鏈接器來說,如果上述順序弄反了的話,就會出錯:
$ cc hello_fn.o main.o -o hello (不正確的順序) main.o: In function ‘main’: main.o(.text+0xf): undefined reference to ‘hello’ |
因為“main.o”文件后面沒有包含hello函數定義的目標文件,所以編譯出錯。目前大部分編譯器和鏈接器通常會搜索所有的目標文件,而不管它們的順序如何,但是并非所有的編譯器和鏈接器都是這樣的,所以最好還是按照從左至右的順序來給目標文件排個隊為妙。
所以,如果您不想碰到煩人的未定義的引用這類問題的話,最好把所有必需的文件都羅列到命令行中。
#p#
五、重新編譯和重新鏈接
為了說明如何單獨編譯某些源文件,下面我們修改一下“main.c”主程序,讓它向“所有人”而非“世界”問好,如下所示:
#include "hello.h" int main (void) { hello ("everyone"); /* changed from "world" */ return 0; } |
更新“main.c”文件后,我們使用以下命令來重新編譯這個源文件:
$ gcc -Wall -c main.c
這將生成一個新的目標文件:“main.o”。 這里不必為“hello_fn.c”新建一個目標文件,因為這個文件以及依賴于該文件的文件如頭文件等都沒有發生任何改變。這個新的目標文件跟hello函數重新鏈接后,會生成一個新的可執行文件:
$ gcc main.o hello_fn.o -o hello
如今,這個新的可執行文件將使用新的main函數來產生輸出:
$ ./hello
Hello, everyone!
需要注意的是,我們只是重新編譯“main.c”文件,并重新鏈接原有的目標文件的hello函數。如果修改的是“hello_fn.c”,則可以重新編譯“hello_fn.c”來創建一個新的“hello_fn.o”目標文件,并用它跟現有的“main.o”文件相鏈接就行了。如果修改了一個函數的原型,則必須修改所有涉及該函數其他源文件,并全部重新編譯、鏈接。總的來說,在具有許多源文件的大型項目中,鏈接要比編譯快多了,所以只是重新編譯已修改過的源程序能夠節約許多時間。此外,只重新編譯項目中經過修改的文件的過程還可以利用GNU Make 自動處理。
六、鏈接外部程序庫
程序庫是一組可以鏈接到程序中的預編譯的目標文件。程序庫最常見的用法就是提供系統函數,例如C語言數學程序庫中的平方根函數sqrt等。程序庫通常存儲在一些擴展名為“.a”的專用存檔文件中,這些就是通常所說的靜態庫。這些文件是由一個單獨的工具即GNU歸檔程序ar從目標文件生成的,鏈接器在編譯時會用它們來解析對函數的引用。為簡單起見,這里只介紹靜態庫,至于在在運行時動態鏈接的共享庫將在后續文章中加以介紹。
標準的系統程序庫通常位于目錄“/usr/lib”和“/lib”下面。在同時支持64和32位可執行文件的系統上,程序庫的64位版本經常存放在“/usr/lib64”和“/lib64”目錄中,而32位版本則存放在“/usr/lib”和“/lib”目錄。例如,C的數學庫通常存放在類UNIX系統的“/usr/lib/libm.a”文件中,這個程序庫的函數的原型聲明則位于“/usr/include/math.h”頭文件內。 C的標準程序庫則位于“/usr/lib/libc.a”,該庫中具有ANSI/ISO C 標準所規定的各種函數,如printf等。默認時,所有C程序都會鏈接這個程序庫。下面是一個調用libm.a數學程序庫中的外部函數sqrt的示例程序:
#include <math.h> #include <stdio.h> int main (void) { double x = sqrt (2.0); printf ("The square root of 2.0 is %f\n", x); return 0; } |
當我們用這個單獨的源文件創建一個可執行文件的時候,會在編譯階段出錯:
$ gcc -Wall calc.c -o calc /tmp/ccbR6Ojm.o: In function ‘main’: /tmp/ccbR6Ojm.o(.text+0x19): undefined reference to ‘sqrt’ |
這是由于缺少外部的數學程序庫libm.a,所以無法正確解析對sqrt函數的引用所致。函數sqrt的定義不在程序或者默認程序庫“libc.a”中,而編譯程序也沒有鏈接“libm.a”文件,因為我們沒有顯式的選取這個庫。錯誤信息中提到的“/tmp/ccbR60jm.o”文件是一個臨時的目標文件,它是由“calc.c”生成的,用以處理鏈接過程。
要想讓編譯程序把sqrt函數鏈接到主程序“calc.c”上,我們必須為編譯程序提供“libm.a”程序庫。為做到這一點,最簡單的方法就是在命令行中顯式的規定這個程序庫,如下所示:
$ gcc -Wall calc.c /usr/lib/libm.a -o calc
程序庫“libm.a”由一些存放各種數學函數的目標文件組成,其中包括sin、cos、exp、log以及sqrt函數等等。為了找到包含sqrt函數的目標文件,鏈接器會把“libm.a”程序庫的各個目標文件仔細搜查一遍。一旦找到sqrt函數所在的目標文件,主程序就可以鏈接這個目標文件從而生成一個完整的可執行文件:
$ ./calc
The square root of 2.0 is 1.414214
這個可執行文件不僅包含主函數生成的機器代碼,同時還有從“libm.a”程序庫的相應目標文件中復制過來的sqrt函數的機器代碼。為了免去在命令行中指定冗長的路徑的麻煩,編譯程序提供了一個“-l”選項來簡化鏈接程序庫的工作,下面是一個示例:
$ gcc -Wall calc.c -lm -o calc
這個命令等價于上面使用完整庫名/usr/lib/libm.a的那條命令。一般而言,編譯程序選項“-lNAME”將嘗試用標準程序庫目錄中名為“libNAME.a”的庫文件鏈接到我們的目標文件。當然,我們可以通過命令行選項和環境變量來指定更多的目錄,這一點下面將會談到。 對于一個大型程序,在鏈接諸如數學程序庫、圖像程序庫以及網絡程序庫等程序庫的時候通常會使用許多“-l”選項。
下面我們探討一下程序庫的鏈接順序問題。程序庫的搜索順序與目標文件的搜索順序一樣,都是按照它們在在命令行中的排列從左到右依次搜索——存放函數定義的程序庫應該出現在所有使用它的源文件或者目標文件的后面。這條規則同樣適用于“-l”選項指定的那些程序庫,如下所示:
$ gcc -Wall calc.c -lm -o calc (正確順序)
對于某些編譯器來說,如果上面的順序弄反了,比如將“-lm”放在了使用它的文件的前面,這時就會出錯:
$ cc -Wall -lm calc.c -o calc (錯誤的順序) main.o: In function ‘main’: main.o(.text+0xf): undefined reference to ‘sqrt’ |
出錯的原因是“calc.c”之后根本就找不到包含sqrt的程序庫或者目標文件。選項“-lm”應該放到“calc.c”文件之后。對于程序庫之間的排列順序也應遵循這個規則,即當一個程序庫調用定義在另一個程序庫中的外部函數的時候,這個庫必須放在包含該函數的程序庫的前面。例如,一個程序“data.c”使用了線性規劃程序庫“libglpk.a”,之后又用到了數學程序庫“libm.a”,那么編譯這個程序的命令就應該像下面這樣:
$ gcc -Wall data.c -lglpk -lm
之所以這樣安排,是因為“libglpk.a”中的目標文件要用到定義在“libm.a”中的函數。 就像目標文件那樣,目前大部分編譯器和鏈接器通常會搜索所有的目標文件,而不管它們的順序如何,但是并非所有的編譯器和鏈接器都是這樣的,所以最好還是按照從左至右的順序來給目標文件排個隊為妙。
#p#
七、使用程序庫的頭文件
當我們使用程序庫的時候,為了聲明函數參數和返回值的類型,必須包含進相應的頭文件。如果不進行聲明的話,可能會向函數的參數傳遞錯誤的類型,從而導致錯誤的結果。下面的例子展示了另一個在其函數中調用C數學程序庫的程序,本例中,pow函數用來計算2的立方。
#include <stdio.h> int main (void) { double x = pow (2.0, 3.0); printf ("Two cubed is %f\n", x); return 0; |
然而,此程序中有一個錯誤,即忘記了用#include語句包含“math.h”,所以編譯程序也就不知道pow函數的原型為double pow (double x, double y)。編制此程序時如果沒有使用任何警告選項的話,將得到一個產生錯誤結果的可執行文件:
$ gcc badpow.c -lm $ ./a.out Two cubed is 2.851120 (結果有誤,應該是8才對) |
這里的結果是不正確的,因為調用pow時使用的參數和返回值類型不對。注意,該結果會隨著硬件平臺和操作系統的不同而有所區別。如果編譯時打開“-Wall”警告選項的話,將會出現下面的提示:
$ gcc -Wall badpow.c -lm badpow.c: In function ‘main’: badpow.c:6: warning: implicit declaration of function ‘pow’ |
這個例子再次說明使用警告選項“-Wall”檢測各種有可能被忽視的問題的重要性。
八、結束語
本文講述利用gcc開發C程序的詳細過程,即如何通過GCC來構建或者說是編譯C程序。我們知道,程序可能是由一個源文件編譯而來的,也可能是通過編譯多個源文件得到的,并且有時候還要用到系統程序庫和頭文件。本文詳細介紹了如何從單個與多個源文件來生成可執行文件,同時還介紹了用于檢查程序錯誤的有該選項以及鏈接庫程序的方法,希望本文對讀者能夠有所幫助。