GCC 基本使用教程 - part 2

  • gcc

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 调试

使用 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

为优化而编译

从时间和空间维度讲, 可以把编译器优化分为下面三类

  • 源码层级优化. 在提高程序速度的同时还能减少程序大小. 包括 CSE 以及内联函数等
  • 空间换时间. 比如 “循环展开” 就是拿空间换时间
  • 指令重新调度. 这是最低层级的优化, 编译器尝试重新排列指令以期利用 CPU 流水等并发特性. 这不会增加程序 大小, 但是因为比较复杂, 会增加编译时的内存和时间消耗

常见子表达式消除 (Common Subexpression Elimination, CSE)

比如源码中的:

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.

函数内联 (Function Inlining)

为了调用一个函数, 内核需要做一系列工作. 比如

  • 指示 CPU 存储函数参数到相关寄存器和内存位置
  • 跳到函数开始位置 (把合适的虚拟内存页加载到物理内存或者 CPU 高速缓存), 开始执行函数
  • 执行完毕后还需要恢复到执行前的指令位置和状态

这种调用开销, 可以通过 “内联” 消除掉. “内联” 指的就是把对一个函数的调用改为直接执行这个函数的函数体.

如果函数只有一处被调用的地址, 或者函数本身比较小 (比如简单的 accessor 函数), 小到移动函数体所用的指令数比调用它使用的指令数还少, 这种情况下使用内联效果会很好.

编译器一般通过启发式的规则来决定是否使用这项技术, 比如 “如果函数够小的话”. 而我们在程序中, 也可以通过使用 inline 关键字显示的告知编译器 “这个函数应该内联”.

循环展开 (Loop Unrolling)

循环展开是个很简单的概念. 比如下面的循环:

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 的并行处理能力. 但是也会增加程序的大小.

指令调度 (Scheduling)

很多 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 的文章具体讲.

  • ar: 管理库文件 (归档文件)
  • gprof: 剖析器. 可用于剖析程序函数的调用次数, 每次调用时间等…
  • gcov: 覆盖测试工具
  • nm: 查看可执行文件的符号表
  • strip: 去除符号表
  • ldd: 列出程序所依赖的共享库

参考

这篇文章本身就是我看 An Introduction to GCC 的总结. 这本书比较薄也很容易读, 我觉得入门这本已经够了, 它把常用的 GCC 特性和选项都讲到了.

其他的可以直接在 gcc 官方参考中查: https://gcc.gnu.org/onlinedocs.

另外一本比较深入的是 The Definitive Guide to GCC, 目前没看到国内的译本.