分离编译与链接
到目前为止,项目基本由一个 9cc.c 和一个测试脚本组成。这个结构适合起步,但随着代码增长,所有内容塞在一个文件里会越来越难维护。接下来把 9cc.c 拆成几个职责更清晰的文件。
9cc.h:头文件main.c:main函数parse.c:语法分析器codegen.c:代码生成器
main 函数本身很短,技术上可以放进任意一个 C 文件。但从职责划分看,它既不是语法分析器,也不是代码生成器,所以单独放在 main.c 更干净。
本章先解释什么是分离编译、为什么大型程序离不开它,再给出当前项目的拆分方法和 Makefile 修改方式。
什么是分离编译
分离编译及其必要性
分离编译,是指把一个程序拆成多个源文件编写,并分别编译。在分离编译中,编译器并不会读取整个程序,而是读取程序的一个片段,并输出与之对应的片段。只包含程序片段、单独无法执行的文件称为“目标文件”(object file,扩展名是 .o)。分离编译的最后一步,是把多个目标文件连接起来,生成一个文件。把目标文件合并成一个可执行文件的程序,称为“链接器”。
我们需要理解为什么必须做分离编译。实际上,从技术上说,并不存在“源代码必须拆开”的必然性。只要一次性把全部源代码交给编译器,理论上编译器也可以不借助链接器,直接输出完整的可执行文件。
不过,如果采用这种做法,编译器就必须真正知道程序所使用的全部代码。例如 printf 这类标准库函数,通常是标准库作者用 C 写出来的函数。为了省略链接步骤,就必须每次也把这些函数的源代码作为编译器输入。反复编译同样的函数,在多数情况下只是浪费时间。因此,标准库通常以已经编译好的目标文件形式分发,不需要在本机每次重新编译。换言之,即使程序本身只有一个源文件,只要它使用标准库,实际上也已经在利用分离编译。
如果不做分离编译,只修改一行代码也必须重新编译整个代码库。几万行代码编译一次就可能要几十秒;大型项目的源代码可能超过 1000 万行,如果把它作为一个编译单元处理,一天也未必能编译完。内存也可能需要 100GiB 这种量级。这样的构建流程并不现实。
另外,把所有函数和变量都写在一个文件里,对人类来说也很难管理。
基于上述原因,分离编译是必要的。
链接器的历史
把多个机器码例程片段连接成一个程序的链接器功能,在计算机黎明期就已经被需要。1947 年,John Mauchly(最早的数字计算机 ENIAC 的项目负责人)就描述过一种程序:它从磁带读入子程序,对其进行重定位,并把它们合并成一个程序。
即使在最早期的计算机上,通用子例程也希望只写一次,然后被各种程序复用。这样一来,就需要一种能把程序片段结合起来、变成可执行程序的链接器。1947 年时汇编器还没有普遍使用,人们还在直接写机器码。实际上,对当时的程序员来说,链接器甚至比汇编器更早成为想要制作的工具。
头文件的必要性及其内容
在分离编译中,编译器只会看到程序的一部分代码。但这并不含义着编译器能编译程序中的任意小片段。请看下面的代码。
void print_bar(struct Foo *obj) {
printf("%d\n", obj->bar);
}
如果知道结构体 Foo 的类型,编译器就能为这段代码输出对应汇编;否则就无法编译这个函数。
进行分离编译时,每个 C 文件中都必须包含足够的信息,使该文件能被单独编译。可是,如果把其他文件里的代码全部写进来,那就不是分离编译了。因此,需要对信息进行取舍。
作为例子,考虑为了输出“调用另一个 C 文件中的函数”的代码,需要放入哪些信息。编译器需要下面这些信息。
- 首先,需要知道某个标识符是函数名。
- 编译器输出函数调用代码时,会按约定顺序把参数设置到寄存器里,然后用
call指令跳转到另一个函数的开头。根据参数类型不同,也可能需要把整数转换为浮点数等操作。参数类型或个数不对时,还需要显示错误消息。因此,函数参数的个数以及各个参数的类型是必要信息。 - 被调用函数内部具体做了什么,对调用方来说并不重要;它最终只会返回。因此,在编译调用方函数时,不需要被调用函数的代码本体。
- 分离编译时还不知道
call要跳转到的实际地址。不过,汇编器可以先输出一条临时跳到地址 0 的call指令,并在目标文件里留下“把目标文件第 X 字节修正为名为 Y 的函数的地址”这样的信息。链接器查看这些信息,在决定可执行文件布局之后,对程序片段做二进制补丁,修正跳转目标地址。这个操作称为“重定位”。因此,分离编译需要函数名,但不需要函数的最终地址。
把上面的要求合在一起看,只要有去掉函数体 { ... } 后的部分,就足以调用这个函数。这种省略函数体的形式称为函数的“声明”(declaration)。声明只把类型和名称告诉编译器,不包含函数代码。例如下面是 strncmp 的声明。
int strncmp(const char *s1, const char *s2, size_t n);
编译器看到上面这一行,就能知道 strncmp 存在以及它的类型。与声明相对,包含函数代码的形式称为“定义”(definition)。
函数声明也可以带上表示声明的关键字 extern,写成下面这样。
extern int strncmp(const char *s1, const char *s2, size_t n);
不过,对于函数来说,因为是否省略函数体已经足以区分声明和定义,所以可以不写 extern。
另外,参数只要类型明确即可,因此声明中可以省略参数名。但为了便于人类阅读,通常即使在声明里也会写上参数名。
再以结构体类型为例。如果两个以上的 C 文件使用同一个结构体,那么这些 C 文件都需要写有同样的结构体声明。如果某个结构体只在一个 C 文件中使用,其他 C 文件就不需要知道它的存在。
在 C 中,这类为了编译其他 C 文件而需要的声明,会集中写到称为头文件的文件中,扩展名为 .h。例如把声明写入 foo.h,然后在需要它的其他 C 文件中写 #include "foo.h",这行 #include 就会被替换为 foo.h 文件的内容。
typedef 等机制同样用于把类型信息告诉编译器。如果这类信息会被多个 C 文件使用,也应该写入头文件。
编译器读到声明时,不会输出任何汇编。声明只是为了使用其他文件中的函数和变量所需的信息,本身并不是在定义函数或变量。
理解到这里,应该也能明白“使用 printf 时要像咒语一样写 #include <stdio.h>”这句话实际在做什么。C 标准库会隐式传给链接器,所以链接器可以把包含 printf 调用的目标文件与标准库链接,生成可执行文件。另一方面,编译器默认并不知道 printf。printf 不是内建函数,也没有自动读取标准库头文件的语言规格;编译器刚启动时,对 printf 一无所知。包含 C 标准库附带的头文件之后,编译器才知道 printf 的存在和类型,从而能编译对 printf 的函数调用。
单遍编译器与前向声明
在 C 中,即使把所有函数都写在一个文件里,也有时需要声明。C 语言规格允许编译器不先读取整个文件,而是从开头开始逐个函数编译。因此,任何函数都必须只依靠它在文件中出现之前已经写下的信息就能编译。如果想调用定义在文件后面的函数,就需要事先写出该函数声明。这类声明称为“前向声明”(forward declaration)。
通过安排函数书写顺序,几乎可以避免大部分前向声明;但如果要写相互递归的函数,前向声明就是必需的。
允许不读取整个文件就编译,是 C 在主存非常小的时代有意义的规格。放到今天,这种规格多少已经过时。如果编译器更聪明一些,同一个文件中的定义本来可以不必提前声明。不过,这个行为已经是语言规格的一部分,所以仍然需要记住。
链接错误
把目标文件最后统一交给链接器时,它们作为整体必须恰好包含构成程序所需的全部信息,既不能缺,也不能多。
如果程序中只有函数 foo 的声明而没有定义,各个 C 文件仍然可以正常编译,包括对 foo 的调用代码。但最后链接器要制作完整程序时,需要把某些位置修正为 foo 的地址;由于找不到 foo,这些位置无法修正,于是会报错。
链接时发生的错误称为链接错误。
多个目标文件中包含同一个函数或变量时,也会发生链接错误。因为对链接器来说,出现重复时并不知道应该选择哪一个。这类重复错误常见于把定义错误地写进头文件的情况。头文件会被多个 C 文件包含;如果头文件中有定义,就等价于在多个 C 文件中重复写了同一个定义。为了解决这种错误,应当只在头文件中写声明,把实体移动到某一个 C 文件里。
重复定义与链接错误
存在重复定义时,链接器也可以选择其中一个定义并忽略其他定义。这样的链接器即使遇到重复定义也不会报错。
实际目标文件中也可以按定义选择是否允许重复。例如内联函数、C++ 模板展开结果等,可以以允许重复的形式包含在目标文件里。目标文件格式和链接器行为比看起来复杂,例外很多;但这些行为终究是例外。默认情况下,重复定义通常会成为错误。
全局变量的声明和定义
我们的编译器还没有支持全局变量,所以还没有出现全局变量对应的汇编示例。不过,从汇编层面看,全局变量和函数几乎属于同一类对象。因此,和函数一样,全局变量也有定义和声明之分。如果变量实体重复存在于多个 C 文件中,通常会导致链接错误。
默认情况下,全局变量会被分配到禁止执行的内存区域,因此跳转到那里会导致程序因段错误崩溃。但除此之外,从本质上说,数据和代码并没有区别。运行时可以像读取全局变量一样把函数当作数据读取;也可以修改内存属性允许执行,然后跳转到数据区域,把数据当作代码运行。
我们用实际代码确认:函数和全局变量本质上都只是位于内存中的数据。下面的代码中,标识符 main 被定义为全局变量。main 的内容是 x86-64 机器码。
char main[] = "\x48\xc7\xc0\x2a\x00\x00\x00\xc3";
把上面的 C 代码保存为 foo.c 并编译,然后用 objdump 查看内容。默认情况下,objdump 只会把全局变量的内容显示为十六进制;如果传入 -D 选项,就能把数据强行当作代码反汇编。
$ cc -c foo.c
$ objdump -D -M intel foo.o
Disassembly of section .data:
0000000000000000 <main>:
0: 48 c7 c0 2a 00 00 00 mov rax,0x2a
7: c3 ret
数据默认会被映射到禁止执行的区域。编译时传入 -Wl,--omagic 选项可以改变这个行为。使用这个选项生成可执行文件。
$ cc -static -Wl,--omagic -o foo foo.o
函数和变量在汇编中都只是标签,并属于同一个命名空间。链接器合并多个目标文件时,并不关心某个标签对应的是函数还是数据。因此,即使 main 在 C 层面被定义为数据,链接器仍然会像它是 main 函数一样成功链接。
运行生成的文件。
$ ./foo
$ echo $?
42
如上所示,程序正确返回了 42。也就是说,名为 main 的全局变量内容被作为代码执行了。
在 C 语法中,对于全局变量,加上 extern 就成为声明。下面是 int 类型全局变量 foo 的声明。
extern int foo;
编写包含 foo 的程序时,会把上面这一行写在头文件中。然后,在某一个 C 文件中定义 foo。下面是 foo 的定义。
int foo;
另外,在 C 中,没有给出初始化式的全局变量会被初始化为 0。因此,这种变量在语义上等同于用 0、{0, 0, ...}、"\0\0\0\0..." 等初始化。
如果写成 int foo = 3 这样带初始化式的形式,那么初始化式只能写在定义中。声明只是为了把变量类型告诉编译器,并不需要具体初始化式。编译器看到全局变量声明时不会输出汇编,因此当场并不需要知道它的内容会如何初始化。
省略初始化式时,全局变量的声明和定义看起来只差一个 extern,外观很接近;但声明和定义是两个不同概念。这里需要明确区分。
Intel CPU 的 F00F 缺陷
1997 年以前的 Intel Pentium 处理器有一个严重缺陷:执行 F0 0F C7 C8 这 4 字节指令时,CPU 会完全挂起。
这 4 字节并没有正式对应的汇编指令。如果硬要写成汇编,大致是 lock cmpxchg8b eax。其中 0F C7 C8 是 cmpxchg8b eax,表示把 8 字节值在寄存器和内存之间原子交换;F0 是 lock 前缀,用来让紧随其后的指令具备原子性。但 cmpxchg8b 本来就是原子的,因此 lock cmpxchg8b eax 是冗余且非法的写法。也就是说,这样的汇编指令在语法上并不存在,F0 0F C7 C8 这样的字节列通常不会出现在普通程序中,Intel 因此没能在量产前发现这个缺陷。
char main[] = "\xf0\x0f\xc7\xc8";
使用“把 main 函数写成数据”这个技巧,重现 F00F 缺陷的代码可以用 C 写成上面这一行。现代 x86 上这段程序是无害的,但在 1997 年当时的 Pentium 上,任何人都可以用这一行程序轻易让整个系统挂起。
如果是个人独占使用的 PC,F00F 缺陷还不算特别严重;但在像今天云计算那样共享 CPU 的使用场景中,这个缺陷是致命的。起初人们以为 F00F 缺陷无法修复,只能召回更换 CPU;后来有人设计出在 OS 内核异常处理器层面绕过该缺陷的巧妙方法,Intel 才得以避免产品更换。
C 标准库与归档文件
步骤 8:拆分文件并修改 Makefile
文件拆分
请按照本章开头给出的结构拆分文件。9cc.h 是头文件。根据程序结构,有时也会为每个 .c 文件准备一个对应的 .h 文件;但多余声明通常不会造成特别大的危害,所以这里不需要管理得那么细。准备一个 9cc.h 文件,并让所有 C 文件都用 #include "9cc.h" 包含它即可。
修改 Makefile
程序已经拆成多个文件,所以 Makefile 也要更新。下面的 Makefile 会编译并链接当前目录中的所有 .c 文件,生成名为 9cc 的可执行文件。这里假设项目只有一个头文件 9cc.h,并且所有 .c 文件都包含这个头文件。
CFLAGS=-std=c11 -g -static
SRCS=$(wildcard *.c)
OBJS=$(SRCS:.c=.o)
9cc: $(OBJS)
$(CC) -o 9cc $(OBJS) $(LDFLAGS)
$(OBJS): 9cc.h
test: 9cc
./test.sh
clean:
rm -f 9cc *.o *~ tmp*
.PHONY: test clean
注意,Makefile 中的缩进必须是制表符。
make 是功能很多的工具,并不一定要完全掌握;但能读懂上面这种程度的 Makefile,在很多场景下都会有帮助。因此,下面说明这个 Makefile。
在 Makefile 中,由冒号分隔的一行,加上 0 行以上用制表符缩进的命令行,构成一条规则。冒号前面的名称称为“目标”。冒号后面的 0 个以上文件名称为依赖文件。
执行 make foo 时,make 会尝试创建名为 foo 的文件。如果指定目标文件已经存在,只有当目标文件比依赖文件更旧时,make 才会重新执行目标规则。这样就能实现“源代码发生变化时才重新生成二进制文件”这类行为。
.PHONY 是表示伪目标的特殊名称。make test 和 make clean 并不是为了创建名为 test 或 clean 的文件而执行;但普通情况下 make 并不知道这一点。如果当前目录里恰好有名为 test 或 clean 的文件,make test 或 make clean 就可能什么也不做。用 .PHONY 指定这类伪目标,就能告诉 make:我们并不是真的要生成同名文件,不管目标文件是否存在,都应该执行规则中的命令。
CFLAGS、SRCS、OBJS 是变量。
CFLAGS 是 make 内建规则会识别的变量,用来写传给 C 编译器的命令行选项。这里传入下面这些标志。
-std=c11:告诉编译器源代码使用 C11 标准。-g:输出调试信息。-static:进行静态链接。
SRCS 右边使用了 make 提供的 wildcard 函数。这个函数会展开为匹配参数的文件名。$(wildcard *.c) 目前会展开为 main.c parse.c codegen.c。
OBJS 右边使用了变量替换规则,把 SRCS 中的 .c 替换为 .o。因为 SRCS 是 main.c parse.c codegen.c,所以 OBJS 会成为 main.o parse.o codegen.o。
在理解这些之后,跟踪一下执行 make 9cc 时会发生什么。make 会尝试生成作为参数指定的目标,所以最终目标是创建 9cc 文件(如果没有给参数,会选择第一条规则;在这个例子中,不指定 9cc 也可以)。为此,make 会沿着依赖关系查找缺失或过期的文件并构建它们。
9cc 的依赖文件,是当前目录中各个 .c 文件对应的 .o 文件。如果上次运行 make 时生成的 .o 文件还在,并且它比对应的 .c 文件时间戳更新,make 就不会重复执行同样的命令。只有当 .o 文件不存在,或者 .c 文件更新时,才会运行编译器生成 .o 文件。
$(OBJS): 9cc.h 这条规则表示所有 .o 文件都依赖 9cc.h。因此,只要修改 9cc.h,所有 .o 文件都会被重新编译。
static 关键字的多种含义
C 的 static 关键字主要有两种用途。
- 给局部变量加
static,使其在函数退出后仍然保存值。 - 给全局变量或函数加
static,使该变量或函数的作用域限制在文件作用域内。
这两种用途并没有特别的共通性,却使用同一个关键字,因此成了学习 C 时容易混乱的点。理想情况下,用途 1 也许应该使用 persistent,用途 2 使用 private 之类的不同关键字。更理想地说,对于用途 2,也许应该默认就是 private,只有全局可见的变量和函数才加 public。
C 复用关键字的原因,是要保持与过去代码资产的兼容。如果向语言中添加 private 这样的新关键字,那么把该词用作变量名或函数名的既有程序就无法编译了。C 为了避免这种情况,没有增加关键字,而是在不同上下文中复用已有关键字。
如果在 1970 年代的某个阶段,C 选择增加新关键字,而不是复用 static,当时需要修改的代码量也许还不大。但如果站在当时的位置上思考,自己会怎么决定,确实是个不容易的问题。