Gcc/G++ compiler options
所谓程序的重定向,其实这里的程序并不是指C语言的源码程序,而是经过gcc编译器编译后的可执行文件.我们比较常用的gcc编译命令为
1 | gcc prog.c -o exec |
这里的prog.c表示C语言的源码文件,而-o exec表示将编译后的可执行文件命名为exec.如果我们不去指定-o选项,那么gcc编译器会默认将编译后的可执行文件命名为a.out.
虽然我们只用了一条命令就完成了程序的编译,但实际上编译过程其实是分为四个阶段进行的,即预处理(Preprocessing),编译(Compilation),汇编(Assembly)和链接(Linking).
预处理(Preprocessing)
预处理阶段的主要任务如下:
- 头文件展开: 处理#include指令,把所需要的头文件内容插入到当前位置.
- 宏定义替换: 替换所有宏定义(#define)为其定义的内容.
- 处理条件编译: 根据#if,#ifdef,#ifndef,#else,#elif和#endif指令,来决定程序中哪些代码需要保留;这些编译条件可以用来控制程序运行平台的差异以及调试程序功能.
- 删除注释: 所有注释会被移除,便于后续编译.
- 插入行号信息: 插入#line指令,用于记录原始文件位置,便于后续错误定位.
预处理阶段的shell命令如下所示,
1 | gcc -E prog.c |
gcc并不保留预处理后的文件,但是我们可以通过-E选项来让程序在预处理阶段完成以后停止编译,并且保留预处理信息.如果我们只使用-E选项,那么gcc会将预处理后的结果输出到标准输出上(也就是显示器),但是他会报一个错误,when writing output to : No such file or directory.这是因为gcc默认会将预处理的结果输出到一个文件里面,但我们并没有指定输出文件.故我们可以在-E之后再加上-o选项,来指定输出文件的名称,这样gcc就会将预处理的结果输出到指定的文件,而非标准输出.值得注意的是,预处理的结果文件后缀名没有强制标准,但为了便于区分,一般我们会使用.i作为后缀名.
编译(Compilation)
gcc编译器的编译阶段指的是将源代码编译成汇编代码的过程,但这个阶段并不会进行汇编或链接操作.编译阶段的主要任务如下:
- 语法分析: 将源代码转换为语法树(AST),检查语法是否正确.
- 语义分析: 检查程序的变量类型,作用域和函数调用是否合理,同时在汇编代码中嵌入符号的声明和引用信息.
- 中间代码生成: 将语法树转换为中间代码.
- 优化: 对中间代码进行优化,提高执行效率.
- 目标架构相关指令生成: 将中间代码翻译成对应硬件的目标架构指令集,从而生成汇编代码.
编译阶段的shell命令如下所示,
1 | gcc -S prog.c |
我们发现gcc的-S选项既可以从源代码文件开始编译,也可以从预处理后的文件开始编译.不同于预处理阶段的不会保存预处理结果,编译阶段的结果在默认情况下会保存在同名的.s文件.当然我们也可以用-o选项来指定输出文件的名称,这样gcc就会将编译后的结果输出到指定的文件中.
编译阶段的优化是一个非常重要的环节,因为它可以显著提高程序的执行效率.编译器会根据不同的优化级别进行不同程度的优化.在gcc中,我们可以通过-O选项来指定优化级别,具体如下:
-O0(不优化): 不进行优化,编译速度最快,一般是默认的级别;其生成的代码直接对应源代码,方便调试;代码体积较大,运行速度较慢.适用于开发调试阶段(确保代码逻辑正确);需要精确匹配源码和汇编代码的情况.
1
gcc -S -O0 prog.c
-O1(基本优化): 平衡优化和编译速度,进行一些简单的优化:删除未使用的代码块;合并常量;简单的循环优化(如减少临时变量存储);内联代码量小的函数.适用于需要一定优化但不想牺牲太多调试体验的情况.
1
gcc -S -O0 prog.c
-O2(推荐优化级别): 相比起-O1,他的优化策略更为激进,适用于发布版本:循环优化;指令调度;函数内联;更多的常量传播和死代码删除.代码的运行速度显著提升,但是其所需的编译时间会更长.调试的信息可能有所丢失,因为某些变量会被优化.适用于生产环境发布版本,需要较高的运行效率.值得注意的是,并不是更为激进的优化策略就能保证代码文件的大小更小,有时候-O2的优化会导致代码体积变大,因为编译器可能会将一些函数内联到调用处,从而增加代码的重复量.
1
gcc -S -O2 prog.c
-O3(激进优化): 他是gcc提供的最激进的优化基本,会可能导致代码的体积进一步变大:自动向量化(如使用SIMD指令集);更激进的内联和循环优化;可能改变浮点运算行为(影响精度).运行速度可能比-O2更快,但也可能引入问题(如代码膨胀或浮点误差).调试的信息可能会被改动的比较大,因此调试更为困难.适用于高性能计算(HPC),数值计算等对速度要求极高的场景.
1
gcc -S -O3 prog.c
-Os(优化代码大小): 其是在-O2的基础上优先减少代码体积;禁用可能增加代码大小的优化(如循环展开).适用于嵌入式系统, 内存受限的环境(如微控制器)
1
gcc -S -Os prog.c
可以用如下命令来查看gcc默认启用了哪些优化选项,
1 | gcc -Q --help=optimizers | grep enabled |
汇编(Assembly)
汇编阶段是在编译阶段得到的汇编代码的基础之上,用汇编器解析汇编指令,将其转换为CPU可直接执行的二进制指令(机器码)
1 | gcc -c prog.c -o test.o |
这里虽然我们只给出了从源代码文件开始编译的指令,但实际上可以从任意的中间文件(.i/.s)开始编译.同样,与编译阶段类似,gcc -c会默认生成一个同名的后缀为.o的机器码文件;需值得注意的是,这个同名文件指的是与操作的文件同名,不一定会与源文件同名.
其主要任务如下:
- 符号与重定位信息处理: 为变量,函数等符号创建表,记录其位置与类型;生成重定位信息,以支持连接器在最终链接时解析地址.
- 节区分布: 将代码和数据分配到不同的节区(如.text节区存放代码,.data节区存放初始化数据,.bss节区存放未初始化数据),以便于链接器处理.
- 生成目标文件: 将处理后的机器码和符号表信息写入目标文件(.o文件),以供链接器使用.
链接(Linking)
链接阶段的主要任务是将多个目标文件(.o文件)和库文件(.a/.so)合并成一个可执行文件或共享库.
1 | gcc prog.o -o exec |
这里调用的是gnu的ld连接器,完成链接操作.
其主要任务如下:
符号解析: 检查所有.o文件中使用的函数和变量名;找出它们的定义位置并建立正确的引用关系. 如:在main.o中用到了foo函数,但foo定义在foo.o中,链接器会将foo.o中的foo函数地址填入main.o的引用位置.
重定位: 每个.o文件内部的地址是从零开始的虚拟地址;连接器需要把这些地址重新计算成全局地址,也就是程序运行时的实际地址;修改目标文件中相关位置(代码和数据段)来修正地址引用.
合并节区: 将所有.o文件的相同节区合并成一个节区,如将所有的.text节区合并成一个大的.text节区,以便于最终生成可执行文件.
符号表和重定位信息清理: 可执行文件通常不再保留完整的符号表(除非带调试信息);重定位表信息也会被清除,因为地址已固定.
链接库文件: 如果使用了静态库(.a)或动态库(.so),链接器会将所需的库文件合并到最终的可执行文件中;对于动态库,链接器会记录库的路径和符号信息,以便运行时加载.
静态链接(static linking)
静态链接就是以一组可以重定位目标文件和命令行参数作为输入,生成一个完全链接的可以加载和运行的可执行目标文件作为输出.这其实是因为在大型程序下,我们会出现多个目标文件之间存在相互调用的情况,为了满足这种互相调用的关系,我们需要将这些源文件产生的目标文件做一个链接,从而形成一个可以执行的程序.由很多目标文件进行链接形成的是静态库,换言之,静态库也可以简单看成是一组目标文件的集合,也就是很多目标文件经过压缩打包后形成的一个文件.
其主要的缺点:
浪费内存空间,因为每个可执行程序中对所有需要的目标文件都要有一个副本,所以如果有多个程序使用同一个库,每个程序都会包含这个库的副本,导致内存浪费.
更新不便,如果库文件有更新,需要重新编译所有依赖这个库的程序,这在大型项目中可能非常麻烦.
其的优点在于由于可执行文件已经具备了所有执行程序所需的任何东西,因此其在运行时速度会更快.
动态链接(dynamic linking)
动态链接是为了解决静态链接所提及的浪费内存和更新不便的问题而提出的.动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序.其流程如下:
假设现在有两个程序prog1.o和prog2.o,这两者共用同一个库lib.o,假设首先运行程序prog1,系统首先加载prog1.o,当系统发现prog1.o中用到了lib.o,即prog1.o依赖于lib.o,那么系统接着加载lib.o,如果prog1.o和lib.o还依赖于其他目标文件,则依次全部加载到内存中.当prog2运行时,同样的加载prog2.o,然后发现program2.o依赖于lib.o,但是此时lib.o已经存在于内存中,这个时候就不再进行重新加载,而是将内存中已经存在的lib.o映射到prog2的虚拟地址空间中,从而进行链接(这个链接过程和静态链接类似)形成可执行程序.
其主要的优点:
- 节省内存空间,因为多个程序可以共享同一个库文件的内存映射,避免了重复加载相同的库.
- 更新方便,如果库文件有更新,只需要替换库文件即可,不需要重新编译所有依赖这个库的程序,只要保证接口不变即可.
其的缺点在于由于程序在运行时才进行链接,因此会增加程序的启动时间和运行时的开销,因为需要在运行时解析符号和地址.