g++ 交响曲

正如传统的交响曲,一个 C++ 程序从源文件到可执行文件的过程分为四个乐章,分别是预处理、编译、汇编和链接。当你在终端上输入 g++ hello.cpp 并按下回车键,演出便开始了。

第一乐章:预处理(Preprocessing)

预处理过程由预处理器(preprocessor)完成,主要处理源代码文件中以「#」开始的预编译指令。

所有 # 开头的代码都属于预处理器处理的范畴:

  • 展开所有 #define 定义的宏,如果遇到 #undef 语句取消了某个宏的定义,则在该语句之后不再展开对应的宏。

  • 处理所有条件预编译指令,比如 #if#ifdef#elif#endif

  • 处理 #include 预编译指令,将被包含的的文件插入到该预编译指令的位置。这个过程可递归进行,即被包含文件中可能还包含其它文件。

    • #include <>:在搜索时直接从编译器指定的路径处(在 Linux 中一般是 /usr/include)进行搜索,如果找不到被引入的文件,则程序直接报错。系统提供的头文件一般用这种方式引入。

    • include "":首先在程序所在目录中(或者用 g++ -I 指定搜索路径)进行搜索,如果搜索失败则再从编译器指定的路径处搜索,如果仍然搜索失败,则程序报错,因此用户自定义的头文件必须用这种方式引入。

  • 处理其它宏指令,包括 #error#warning#line#pragma

预处理器除了处理 # 开头的代码行外,还做了一些其它的工作:

  • 处理注释:过滤所有注释(///**/)中的内容(用一个空格代替连续的注释)。

  • 处理预定义的宏,例如 __DATE____FILE__

  • 添加行号和文件名标识符,以便于编译时让编译器产生调式用的行号信息以及用于编译时产生编译错误或警告时能够显示行号。

  • assert 宏中检测条件表达式,如果表达式为假,表示检测失败,程序会向标准错误流 stderr 中输出一条错误信息,然后调用 abort 函数终止程序的执行。

第二乐章:编译(Compilation)

在编译阶段,编译器对预处理完的代码进行规范性检查和语法检查,然后将符合规则的程序转换成相应的目标代码。此过程一般分为 6 步:

  1. 词法分析

    扫描器对输入的源代码程序进行词法分析,运用一种类似于有限状态机的算法将源代码的字符序列分割成一系列的记号。词法分析产生的记号一般可分为如下几类:关键字、标识符、字面量(包括数字和字符串)和特殊记号(如加号、等号)。在识别记号的同时,扫描器也完成了其他工作。比如将标识符放到符号表中,将数字、字符串常量存放到文字表等,以备后续的步骤使用。

  2. 语法分析

    语法分析器对扫描器产生的记号进行语法分析,并产生语法树(以表达式为节点的树)。整个分析过程采用了上下文无关文法的分析方法。如果在语法分析的过程中出现了表达式不合法的情况(比如括号不匹配、表达式中缺少操作符),编译器就会报告语法分析阶段的相关错误。

  3. 语义分析

    上一步的语法分析仅仅停留在表达式的语法层面,但是它不了解语句是否真正有意义(比如两个指针相乘是符合语法的,但是并没有意义)。此时就需要语义分析器来对表达式进行语义分析。注意,编译器所能分析的语义只是静态语义。

    静态语义:在编译期间可以确定的语义,通常包括声明和类型的匹配以及类型的转换等。比如将一个浮点型的表达式赋值给一个指针,语义分析器就会发现这个类型不匹配,编译器将会报错。

    动态语义:在运行期间才能确定的语义。比如将 0 作为除数就是一个运行期语义错误。

    经过语义分析阶段后,整个语法树的表达式都被标识了类型,如果有些类型需要做隐式转化,语义分析器会在语法书中插入相应的转换节点。此外,语义分析器还对符号表里的符号类型做了更新。

  4. 源代码优化

    源代码优化器会在源代码级别进行优化,比如说 (1 + 1) 这个表达式就可以被优化掉,因为它的值可在编译期间确定。由于直接在语法树上进行这类优化比较困难,所以源代码优化器往往将整个优化树转换成中间代码,它是语法树的顺序表示。

  5. 目标代码生成

    在这一阶段,代码生成器将中间代码转换成目标机器代码(低级语言代码),生成的代码依赖于目标机器的硬件体系结构和机器指令的含义。

    目标代码的形式可以是以下三种:

    • 绝对指令代码

    • 可重定位的指令代码

    • 汇编指令代码。

    如果目标代码是绝对指令代码,则这种目标代码可立即执行。如果目标代码是汇编指令代码,则需要汇编器进行汇编。而现代的编译器所产生的目标代码都是一种可重定位的指令代码,也就是说,编译器将源代码文件编译成一个未链接的目标文件。

  6. 目标代码优化

    目标代码优化器对生成的目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法等。

第三乐章:汇编(Assembly)

如果编译过程生成的目标代码是汇编指令代码,则汇编器会将编译器生成的汇编代码翻译成计算机可以识别的机器指令,并生成目标文件(二进制文本形式)。之所以不将源程序直接生成机器指令是因为在不同的阶段可以应用不同的优化技术,并且这些优化技术都已经十分成熟,可以保证在每个阶段分别进行优化后最终可以生成更为高效的机器指令。

第四乐章:链接(Linking)

将每个源代码模块独立地编译,然后按照要求将它们「组装」起来,这个组装模块的过程就是链接。链接的主要内容就是把各个模块之间相互引用的部分都处理好,使得各个模块之间能够正确地衔接。但从原理上讲,链接的工作无非就是把一些指令对其他符号地址的引用加以修正(这个地址修正的过程也叫作重定位,每个要被修正的地方叫一个重定位入口)。总的来说,链接过程主要包括了地址和空间分配、符号决议和重定位等这些步骤。

链接分为静态链接和动态链接:

  • 静态链接:对函数库的链接放在编译时期完成。所有相关的目标文件与牵涉到的函数库被链接形成一个可执行文件。程序在运行时,与函数库再无瓜葛,因为所有需要的函数已经复制到相关位置。这些函数库被称为静态库,通常文件名为「libxxx.a」的形式。

  • 动态链接:把对一些库函数的链接载入推迟到程序运行时期,这就是动态链接库(dynamic link libray)技术。这些函数库被称为动态库,通常文件名为「libxxx.so」。

库其实就是一组目标文件的包,也就是一些最常用的代码编译成目标文件后打包存放。最常见的库是运行时库,它是支持程序运行的基本函数集合。

链接器将所有用到的目标程序链接到一起,无论采用静态链接还是动态链接,最终都会生成一个可以在机器上直接运行的可执行程序。

Updated: