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

本文是GCC(GNU Compiler Collection)的入门教程,涵盖了C程序的编译过程,包括从源文件生成对象文件、链接、编译选项、预编译器的使用、调试、编译优化等。详细解释了编译选项如头文件搜索路径、共享库的处理,以及编译C++程序的注意事项。同时,文章还介绍了如何使用相关工具如ar、gprof和gcov进行静态库创建、性能分析和覆盖率测试。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

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
条目解释
ELFExecutable 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
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值