GCC 基本使用教程 - part 2
posted on 11 Nov 2020 under category tutorial
GCC 在预处理阶段会使用 cpp 预处理器进行宏扩展. 在 c 源码中可以使用 #define
自定义宏, 也可以通过在编译的时候通过 gcc -D
定义宏. 假设我们的源码 main.c
测试 TEST
是否定义, 是的话就开启测试模式:
#ifdef TEST
// ...开启测试模式
#endif
那么在编译时通过 gcc -DTEST
即可开启测试模式:
$ gcc -Wall -DTEST=1 main.c
我们也可以省略 =1
, 因为如果没有指定值, 默认为 1.
gcc 有一些预定义的宏, 使用 cpp -dM /dev/null
可以查看这些宏:
$ cpp -dM /dev/null
#define __i386__ 1
#define __i386 1
#define i386 1
...
我们也可以使用 gcc -E
让 gcc 只是对源文件进行宏扩展, 而不进行编译
$ gcc -E main.c > main.i
注意 -E
选项默认只会把结果输出到屏幕上, 所以我们使用重定向把结果保存到 main.i
. 也可以传入 --save-temps
选项, 这样的话还会额外产生 .s
汇编文件和 .o
目标文件.
使用 gdb 可以方便的对 c 程序进行调试, 跟踪程序的执行, 检查每执行一步变量的值的变化等.
但是一般来说, gcc 编译出来的可执行文件只包含机器代码, 没有对源码的引用, 所以 gdb 不能追踪到每条指令对应的源码位置, 这样调试起来并不方便.
但是我们可以通过使用 -g
选项, 让 gcc 在编译出来的目标文件和可执行文件内额外存储符号表, 符号表内则存储了函数名变量名对应的源码行号等信息:
$ gcc -Wall -g main.c -o hello
对于带符号表的可执行文件, 可以允许调试器:
当程序崩溃的时候, 系统一般会进行核心转储 (core dump). 所谓核心转储, 就是把程序崩溃时它在内存中的状态, 写入到一个默认为 “core” 的文件. 而配合带符号表的可执行文件, 则可以把转储文件加载到 gdb 中, 通过查看崩溃时的状态进行调试.
假设我们的程序 hello 运行崩溃:
$ ./hello
Segmentation fault (core dumped)
这里显示的 (core dumped)
意味着系统在当前目录生成了 core 转储文件. 有些系统可能默认不会自动核心转储, 此时则需要使用下面的命令进行设置才行 (各种系统用的命令可能不一样):
$ ulimit -c unlimited
接下来我们就可以加载转储文件到 gdb 来看看发生了什么了:
$ gdb hello core
Core was generated by './hello'.
Program terminated with signal 11, Segmentation fault.
...
你可以使用 gdb 的 print
或者 backtrace
等命令查看崩溃时的变量值, 调用栈等等. 关于 gdb 的基本使用, 可以参考 gdb 基本使用教程 @todo
从时间和空间维度讲, 可以把编译器优化分为下面三类
比如源码中的:
x = cos(v)*(1+sin(u/2)) + sin(w)*(1-sin(u/2))
通过重用 sin(u/2)
的计算结果:
t = sin(u/2)
x = cos(v)*(1+t) + sin(w)*(1-t)
这种对表达式的重写就是所谓的 CSE.
为了调用一个函数, 内核需要做一系列工作. 比如
这种调用开销, 可以通过 “内联” 消除掉. “内联” 指的就是把对一个函数的调用改为直接执行这个函数的函数体.
如果函数只有一处被调用的地址, 或者函数本身比较小 (比如简单的 accessor 函数), 小到移动函数体所用的指令数比调用它使用的指令数还少, 这种情况下使用内联效果会很好.
编译器一般通过启发式的规则来决定是否使用这项技术, 比如 “如果函数够小的话”. 而我们在程序中, 也可以通过使用 inline
关键字显示的告知编译器 “这个函数应该内联”.
循环展开是个很简单的概念. 比如下面的循环:
for (i = 0; i < 8; i++) {
y[i] = i;
}
可以展开为:
y[0] = 0;
y[1] = 1;
y[2] = 2;
y[3] = 3;
y[4] = 4;
y[5] = 5;
y[6] = 6;
y[7] = 7;
展开之后, 因为每个赋值都是独立的, 也能让充分利用 CPU 的并行处理能力. 但是也会增加程序的大小.
很多 CPU 都允许一条指令在其他指令执行完之前就开始执行, 也允许几条指令流水执行 (同时执行各自不同的阶段). 编译器通过决定每条指令的最优执行顺序, 以期利用 CPU 的这些特性. 这就是所谓的 “指令调度”.
编译时, 可以通过指定 -OLEVEL
选项 (LEVEL 从 0 到 3) 指定 gcc 的优化级别.
Oo
或不指定 -O
选项 (默认): 不进行优化, 最合适用来调试O1
或 -O
: 执行那些不会影响程序大小的优化. 优化后的程序在变快的同时不会增加大小O2
: 在 O1 基础上加上指令调度优化. 程序不会变大, 但是需要耗费更多编译时间和内存. 适用于部署, 也是 GNU 包发布时的默认级别O3
: 执行比如内联等耗时优化. 程序可能变大, 在某些其实并不适合这些优化技术的情况下, 甚至会变慢其他优化相关选项
funroll-loops
: 执行循环展开优化Os
: 只执行那些能够减小程序大小的优化. 这个选项就是为了得到一个尽可能小的程序, 适合在为内存或磁盘空间比较小的目标机编译时使用使用编译优化除了能让程序更快, 也能为其他高级的编译特性提供支持, 比如数据流分析等. 但是得记住, 使用编译优化并不总是能得到预期的结果. 而且优化后的程序调试起来更难, 在编译时也更耗费时间和内存.
一般建议是: 为了调试的话, 使用 -O0
, 开发或部署的话, 使用 -O2
.
为了测试优化效果, 可以使用 time 工具:
$ time ./a.out
real 0m6.742s # 总运行时间 (包括实际被其他程序抢占运行的时间)
user 0m6.730s # 实际程序运行时间
sys 0m0.000s # 系统运行时间 (程序调度等耗费的时间)
以下仅列出一些相关工具, 我会在后面有关 “静态或共享库的开发” @todo 的文章具体讲.
这篇文章本身就是我看 An Introduction to GCC 的总结. 这本书比较薄也很容易读, 我觉得入门这本已经够了, 它把常用的 GCC 特性和选项都讲到了.
其他的可以直接在 gcc 官方参考中查: https://gcc.gnu.org/onlinedocs.
另外一本比较深入的是 The Definitive Guide to GCC, 目前没看到国内的译本.