我们知道,程序可能是由一个源文件编译而来的,也可能是通过编译多个源文件得到的,并且有时候还要用到系统程序库和头文件。这里所谓构建或编译,就是把用程序设计语言(例如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程序。我们知道,程序可能是由一个源文件编译而来的,也可能是通过编译多个源文件得到的,并且有时候还要用到系统程序库和头文件。本文详细介绍了如何从单个与多个源文件来生成可执行文件,同时还介绍了用于检查程序错误的有该选项以及链接库程序的方法,希望本文对读者能够有所帮助。