C语言标准把从源文件到可执行文件的处理过程分为多个阶段,称为翻译阶段。
翻译阶段是个概念模型,编译器实现可能进行优化,或多个阶段同时进行,只要保证最终结果与依次执行翻译阶段一致即可。
阶段1
把源文件中的所有字符映射为源字符集 中的对应字符,并且把行尾指示符 转换为换行符
LF
。
源字符集是C语言标准发明的一个概念,它没有说明到底是什么字符集,而是说它必须包含基本字符集 。基本字符集也是C语言标准发明的一个概念,它包括52个英文大小写字母、10个数字、29个英文标点符号和5个空白字符,也就是C语言代码必须的最小字符集。基本字符集里的所有字符都必须编码为1个字节。
其实关于字符集和编码,C语言标准也没讲明白,甚至左右脑互搏。一边定义源文件使用的字符集就是源字符集,一边又要从源文件映射到源字符集。我之后会专门写一篇文章讲C语言的字符集和编码。目前我们不用纠结这个概念,关注编译器是怎么处理的就好。
gcc 会根据locale 确定源文件的编码,如果无法从locale确定,则默认编码是UTF-8 ,可以通过参数-finput-charset
指定源文件编码。
gcc的源字符集就是UTF-8编码。如果经过上述步骤确定的源文件编码不是UTF-8,则会将源文件从该编码转换为UTF-8编码。
这个阶段还有一个操作是替换三字符序列 ,不过该特性的使用场景极其罕见并且已经在C23被移除,所以就不介绍了。
阶段2
如果某一行以反斜杠
\
结尾,则删除反斜杠和换行符,从而把两行合并成一行。
该操作可以通过两两合并把多行合并成一行,如下所示:
c
a\
b\
c
处理后:
c
abc
该操作不会重复扫描,如下所示:
c
a\\
b
只有第二个\
会被处理,尽管处理之后第一个\
成为了该行最后一个字符,也不会折返回来处理第一个\
。
这个阶段还要求处理后的源文件必须以换行符结尾,但几乎所有编译器实现都不做此限制。
阶段3
源文件被分解为预处理标记 、空白字符序列 和注释。
每条注释都被替换为1个空格。
所有换行符都如数保留,其他空白字符序列由实现决定是否合并成1个空格。
源文件不能以不完全的预处理标记或注释结尾,比如:
c
/* comment
缺少*/
,编译会报错。
gcc会保留缩进空白字符,删除所有尾随空白字符,并且合并非空白字符之间的空白字符,如下所示:
c
a b
c d
处理后:
c
a b
c d
阶段4
执行预处理。
所有通过
#include
指令包含进来的文件都递归执行阶段1-4。删除所有预处理指令和预处理操作符。
预处理是个非常复杂的过程,我会专门写一篇文章来讲。
阶段5
没有编码前缀的字符常量 和字符串字面量 中的字符和转义序列从源字符集 转换为执行字符集。
执行字符集也是C语言标准发明的一个概念,它也包含一个基本字符集,要求跟源字符集的基本字符集一样,额外多了几个控制字符。
编码前缀指的是u8
、u
、U
、L
,这些可以加在字符常量和字符串字面量之前,为其指定运行时编码。这些字符在阶段7进行处理。
只有通用转义序列和Unicode转义序列才会转换为执行字符集,比如\n
和\u4e00
。八进制和十六进制转义序列会转换为其对应的单字节数据,比如\33
。
执行字符集决定字符常量和字符串字面量在程序运行时的编码,从而影响程序输出的编码。因此该阶段只转换这两者而不是整个源文件,因为这两者会作为数据在程序执行时使用,而其他部分只用于编译。
只有当执行字符集与终端的编码相同时,源代码中的字符数据才能被正确显示。然而执行字符集必须在编译时确定,此时不可能知道运行时的终端编码,所以依赖执行字符集的C语言程序只能在特定的单一编码环境中正确运行。
C语言的国际化一直非常糟糕,即使后面推出了宽字符和Unicode支持也没有改善太多。
gcc 的默认执行字符集是UTF-8 ,可以通过参数-fexec-charset
指定执行字符集。
gcc的执行字符集不受C语言标准的限制。比如UTF-16不存在单字节编码的字符,不符合基本字符集的要求,但gcc仍然可以把它作为执行字符集。不过这会导致一些问题。在阶段7字符串字面量会被转换为数组,不管执行字符集是什么,没有编码前缀的字符串字面量都是在末尾加一个单字节空字符,由于它不是个合法的宽字符串,用宽字符函数处理会导致内存越界。而且执行字符集把任何值为0的字节视为空字符,不管这个字节在编码中的位置,所以用普通函数处理会导致提前结束。
阶段6
合并相邻的字符串字面量。
c
char *s = "abc"
"de";
等价于:
c
char *s = "abcde";
阶段7
空白字符被忽略。所有预处理标记作为一个翻译单元进行语法和语义分析,翻译成目标文件。
这一步就是我们常说的编译。
阶段8
解析外部对象和函数引用,链接库以满足外部引用。所有目标文件整合成单个可执行文件。
这一步就是我们常说的链接。
C语言不具备自动查找必要库的能力,编译器会默认链接某些库,而其他库都需要通过编译器参数手动链接。
翻译阶段的意义
翻译阶段明确了广义的编译过程中每种操作在逻辑上的先后顺序,从而尽量避免歧义。同时也从语法无法描述的角度对代码进行规范。
比如下面的代码:
c
#define SWAPINT(A, B) ((A)^=(B),\
(B)^=(A),\
(A)^=(B))
因为处理行尾\
在预处理之前,所以就实现了语法上不允许的多行预处理指令。
而下面的代码:
c
// this is a comment\
printf("hello, world\n");
同样因为处理行尾\
在处理注释之前,所以一个意外的\
字符就能让行注释影响到代码。
再看下面的代码:
c
char *s = "abc" // first line
"de";
因为处理注释在合并字符串字面量之前,所以这两个字符串字面量依然是相邻的,可以合并。