5.1 翻译单元
在前面的章节中,我们的注意力大多集中在单个源文件的编译与运行上。然而在真实的工程实践中,几乎没有哪个严肃的 C 语言项目会将成千上万行代码堆砌在一个单独的 .c 文件中。随着工程规模的扩大,将系统按业务领域或功能逻辑拆分成多个源文件,是保证代码可维护性与协作效率的必由之路。
然而,C 语言的编译模型与许多现代语言截然不同:编译器在编译阶段从不以「项目目录」或「工程全局」为单位吞吐代码,而是以一个一个独立的翻译单元(Translation Unit)为基本处理粒度。搞清翻译单元的构成及其生命周期,是理清「声明应该写在何处、定义应当放在哪里」这一多文件组织核心问题的认知基石。
1. 基本定义
翻译单元的定义
预处理完成后得到的源文件称为一个翻译单元 (Translation Unit)。它由外部声明序列组成,例如函数定义、对象定义、typedef 声明和 extern 声明。编译器先分别处理各翻译单元生成目标文件,再由链接器把目标文件与库连接为最终程序。
当编译器启动编译任务时,首先由预处理器负责处理源文件中的所有预处理指令:宏被原样展开、条件编译分支被计算裁剪,所有被 #include 引入的头文件内容被逐字逐句递归粘贴进源码中。这一系列纯文本替换全部落定后,所产生的完整独立文本流便构成了一个翻译单元。
随后的语法分析、中间代码生成与机器码汇编,都完全局限在这个翻译单元内部进行。编译器对该文件外部的现实世界一无所知;直到所有翻译单元各自编译成目标文件(.o 或 .obj)后,链接器才登场将分散的符号引用与实际定义逐一绑定起来。
2. 为什么它是模块边界
在缺少模块(module)概念的经典 C 语言体系中,翻译单元扮演着最重要的模块隔离边界。同名标识符能否和平共处、某个函数或对象能否被其他源文件查阅与调用,完全受制于翻译单元的边界以及符号所附带的链接属性(Linkage)。
理解这个边界有助于建立清晰的组织直觉:
- 边界内的自治性:在同一个翻译单元内,标识符遵循标准的作用域与可见性规则。如果借助
static关键字将符号限定在当前翻译单元内部(内部链接属性),那么该符号的名字在编译成目标文件后便会被隐藏起来,绝不会与外部其他源文件产生命名冲突。 - 跨边界的协作约定:如果需要调用另一个翻译单元中实现的函数,当前翻译单元并不需要包含该函数的完整实现细节,只需要一份形式契约——即函数声明。声明向当前编译器承诺该函数的签名与调用约定,而将寻找函数实体的具体任务推迟到链接阶段完成。
- 边界对接的媒介:头文件正是横跨不同翻译单元的纽带。它负责向各调用者广播公共契约,确保调用者与提供者在各自独立的翻译过程中始终看到完全一致的数据类型与接口约定。
3. 一个最小示例
为了观察翻译单元在工程中的实际运作,我们搭建一个由三个文件构成的最小工程:头文件 math_ext.h 负责对外声明算术接口,实现文件 math_ext.c 给出具体逻辑,而主程序 main.c 负责消费该接口。
关于 #ifndef / #define / #endif
这三行是“头文件包含保护”的标准写法,属于预处理器的条件编译指令,目的是防止头文件被重复包含。第一部分第 17 章已经系统说明预处理与条件编译;这里关注它在头文件边界中的作用。
/* math_ext.h */
#ifndef MATH_EXT_H
#define MATH_EXT_H
int add(int lhs, int rhs);
#endif2
3
4
5
6
7
/* math_ext.c */
#include "math_ext.h"
int add(int lhs, int rhs) {
return lhs + rhs;
}2
3
4
5
6
/* main.c */
#include <stdio.h>
#include "math_ext.h"
int main(void) {
printf("%d\n", add(3, 4));
return 0;
}2
3
4
5
6
7
8
输出:
7
在构建该程序的过程中,math_ext.c 与 main.c 分别经过预处理扩展成两个截然不同的翻译单元。在 main.c 对应的翻译单元里,编译器只看到 add 函数的签名声明,它根据签名生成传参和调用指令,并在生成的目标文件中留下一处未解引用的外部符号标记;而在 math_ext.c 的翻译单元里,编译器生成了 add 的机器指令序列并对外导出该符号。最后,链接器把两者拼接合并,把调用点与定义点牢固锁定在一起。
4. 头文件重复包含
在具备一定规模的真实工程中,头文件之间往往会形成网状的依赖关系。例如模块的基础类型定义在 common.h 中,而业务模块 parser.h 与 emitter.h 均需要引用基础类型,因而都在各自开头包含了 common.h。当顶层的业务逻辑 main.c 同时引入了 parser.h 与 emitter.h 时,common.h 的全文就会被预处理器先后两次原封不动地复制进同一个源文件中。
重复包含破坏翻译单元语义
头文件会被预处理器按文本插入,同一头文件可能被多次包含。如果缺少包含保护,头文件中的结构体定义等内容会在同一翻译单元中重复出现,导致编译错误。包含保护(或 #pragma once)就是用来避免这种重复的。
在 C 语言语法规则中,函数与对象的纯声明(只要类型保持兼容)在同一个作用域内重复书写多次是合法的;但是,结构体(struct)、联合体(union)或枚举(enum)的类型定义,在同一个翻译单元内却被严格禁止出现多次。一旦缺少头文件保护机制,重复展开的类型定义就会直接触发编译器的「类型重定义」(redefinition of struct)致命错误。通过 #ifndef 宏保护或非标准但被广泛支持的 #pragma once,可以确保同一个头文件的实体内容在一个翻译单元内仅被展开一次,从而保证包含操作的幂等性。
5. 声明一致性约束
在多文件协作中,一个最容易引发隐蔽灾难的误区,是调用方为了省事而脱离头文件自行书写 extern 声明。
跨翻译单元声明必须一致
多个翻译单元若都引用同一个外部符号,就必须看到彼此一致的声明。若某个源文件里声明写错了类型,即使能编译通过,也可能在链接后产生难以定位的运行错误。
这种隐蔽性根源于 C 语言链接器的运作机制:传统的 C 链接器主要是基于名字匹配的无类型符号绑定器。在标准 C 中,函数符号通常不会像 C++ 那样在符号名中编码参数与返回值类型(即 Name Mangling)。若实现文件 impl.c 中实现的函数原本返回浮点数 double average(int, int),而调用方 app.c 却凭记忆手工写下了 int average(int, int) 的声明:
app.c在编译期会根据int声明生成从整型通用寄存器读取返回值的机器码指令;impl.c在编译期会按照double逻辑将计算结果放置在浮点寄存器中;- 两者在单文件编译时各自都能毫无警告地通过;
- 链接器在链接期仅按名字
average强行将两处绑定在一起; - 直到程序运行时,调用方读取了完全错误的寄存器数据,导致结果失真甚至内存崩溃。
要彻底杜绝这类风险,唯一的工程实践就是:任何跨翻译单元共享的符号,其声明必须集中在唯一的头文件中;实现文件必须包含该头文件以接受编译期自检,调用文件也必须通过包含该头文件获取权威声明。
6. 内部符号收敛
在拆分模块时,并非所有函数与数据都需要开放给外部世界。模块内部往往包含大量辅助性的计算过程、内部状态缓存以及实现细节。
如果放任所有内部函数与全局对象使用默认的存储说明符,它们在编译后都会被赋予外部链接属性(External Linkage),成为目标文件中暴露在外的全局符号。这不仅会污染全局命名空间,极易与同伴编写的代码或第三方库发生名字冲突(Name Collision),还会破坏模块的封装性,使调用者误调本不该公开的私有逻辑。
因此,所有不需要跨翻译单元访问的函数与具有静态存储期的对象,都应当显式使用 static 关键字加以修饰。这会使符号收敛为内部链接属性(Internal Linkage):
- 在当前翻译单元之外,该符号完全不可见,其他源文件即使定义了同名符号也能和平共处、互不干扰;
- 编译器由于明确知悉该符号不可能被外部目标文件引用,能够大胆施加激进的优化(例如直接将小型辅助函数内联展开,甚至彻底消除未被实际调用的死代码);
- 最终目标文件的全局符号表将保持极度清爽,仅保留真正对外提供公共服务的接口符号。
7. 暂定定义与链接结果
在 C 语言的文件作用域下,对象的声明与定义存在一种特殊的中间过渡形态——暂定定义(Tentative Definition)。
暂定定义与多重定义
文件作用域下形如 int g; 的声明在没有初始化器时可能形成暂定定义。单个翻译单元内可以出现多次同名暂定定义,最终会合并为一个定义;但跨翻译单元若都给出外部定义,就会在链接阶段产生多重定义冲突。
如果在全局作用域书写 int counter; 且不附带初始化表达式(例如 = 0),也不附加 extern 关键字,它在语法上便构成暂定定义。在单个翻译单元内部,标准允许连续出现多行同名的暂定定义,编译器在语法分析完成后,会自动将它们折叠归并为唯一的实际对象定义。
然而,一旦开发者误将这种未带 extern 的对象声明写进了头文件中,灾难便会发生:当该头文件被工程中多个 .c 文件同时包含时,每个翻译单元在编译完成后都会各自生成一个强符号(或公共符号)。现代主流编译器(如 GCC 10 以后默认启用的 -fno-common 规范)在链接阶段执行严格的单一定义规则(One Definition Rule 类似约束),面对多个目标文件中同名的全局对象,会立即报错中断构建并输出 multiple definition of 'counter'。
正确的工程规矩因而非常明确:头文件中永远只能放置附带 extern 说明符的纯声明(extern int counter;),而该对象的唯一定义与初始化实体,必须严格存放在某一个特定的 .c 实现文件中。
8. 习题
8.1 巩固
用正文第 3 节的三文件示例做一遍翻译与链接的观察:
- 建
math_ext.h、math_ext.c、main.c,内容照抄正文。分别执行gcc -std=c17 -E main.c -o main.i和gcc -std=c17 -E math_ext.c -o math_ext.i,用wc -l记录四个文件的行数。 - 在
main.i和math_ext.i里各用grep -c数两样东西:声明int add(int lhs, int rhs);和函数体return lhs + rhs;。把两边的计数记下来。 - 分别用
-c生成main.o与math_ext.o,对两个目标文件运行nm。指出add在哪个文件里是U、在哪个里是T,main和printf各是什么。 - 先只把
main.o交给链接器,记录诊断和它所属的阶段;再把math_ext.o加进去,运行程序确认输出7。
最后回答:main.i 里为什么找不到 add 的函数体;两个目标文件里那些 U 符号是在哪一步、被什么填上的。
包含保护针对的究竟是哪类重复?通过以下对照实验建立清晰边界。
先建一个没有包含保护的头文件:
/* point.h */
struct point {
int x;
int y;
};2
3
4
5
- 写
twice.c,连续两次#include "point.h",然后使用struct point。编译,记录诊断,指出它出现在哪个阶段。用gcc -std=c17 -E twice.c | grep -c "struct point {"数结构体定义出现的次数。 - 给
point.h加上#ifndef POINT_H形式的包含保护,重新数一次,再编译一次,确认通过。换成#pragma once再数一次。 - 把
twice.c拆成one.c和two.c:两个翻译单元各包含一次没有保护的point.h,各自使用struct point,然后链接。记录结果并解释:为什么这次没有报重复定义? - 写一个只含
int add(int lhs, int rhs);的头文件,在同一个源文件里包含两次,不加保护,编译。解释这次为什么也不需要保护。
验收:第 1 步的计数是 2、第 2 步是 1;第 3、4 步都能正常编译链接。
下面的工程包含三个源文件,在语法检查、汇编与链接阶段均能顺利通过,但运行时计算出的数值却完全偏离预期:
/* api.h */
#ifndef API_H
#define API_H
double average(int lhs, int rhs);
#endif2
3
4
5
6
7
/* impl.c */
int average(int lhs, int rhs) {
return (lhs + rhs) / 2;
}2
3
4
/* app.c */
#include <stdio.h>
#include "api.h"
int main(void) {
printf("%f\n", average(3, 4));
return 0;
}2
3
4
5
6
7
8
- 编译运行,记录输出。说明编译器和链接器为什么都没有报错:
impl.c里没有包含api.h,它看不到那份写错的声明,链接器又只按名字绑定符号。 - 在
impl.c开头加上#include "api.h",重新编译,记录完整诊断。指出它来自哪个阶段,诊断里两个类型分别是什么。 - 把声明和定义改成一致的正确版本(返回
double,除数为2.0),确认输出3.500000。 - 回答:如果
impl.c一直不包含自己的头文件,这种不一致还有哪些办法能在构建时暴露?至少说出一种,并实际试一次。
验收:修改前输出 0.000000,修改后输出 3.500000,第 2 步出现 conflicting types 一类错误。
8.2 编程
把一个单文件程序拆成三个翻译单元。原程序如下,保存为 textstat.c 并确认它输出 words: 9 和 longest: 5:
#include <stdio.h>
int is_delimiter(char c) {
return c == ' ' || c == '\t' || c == '\n';
}
int word_count(const char *text) {
int count = 0;
int in_word = 0;
for (const char *p = text; *p != '\0'; p++) {
if (is_delimiter(*p)) {
in_word = 0;
} else if (!in_word) {
in_word = 1;
count++;
}
}
return count;
}
int longest_word(const char *text) {
int longest = 0;
int current = 0;
for (const char *p = text; *p != '\0'; p++) {
if (is_delimiter(*p)) {
current = 0;
} else {
current++;
if (current > longest) {
longest = current;
}
}
}
return longest;
}
int main(void) {
const char *text = "the quick brown fox jumps over the lazy dog";
printf("words: %d\n", word_count(text));
printf("longest: %d\n", longest_word(text));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
要求:
- 拆成
textstat.h、textstat.c、report.c。头文件里只放word_count与longest_word的声明和包含保护;实现文件里把is_delimiter加上static;main移进report.c。 report.c里再写一个同名的static int is_delimiter(char c),只把空格当分隔符。最终程序的标准输出必须仍然只有words:与longest:两行,和拆分前逐字节相同。为了确认两份实现确实各自生效,可以临时加一行打印做对照(对照完删掉):对"a\tb"调用word_count得到 2(textstat.c的那份把制表符当分隔符),而report.c自己的is_delimiter('\t')返回 0。- 分别用
-c编译两个.c,再链接。运行确认输出words: 9和longest: 5。 - 用
nm -g --defined-only textstat.o列出导出符号,确认只有word_count和longest_word;再用nm textstat.o找到is_delimiter,说明它为什么是小写字母。 - 把两个文件里的
static都去掉,重新链接,记录诊断;改回static后确认恢复通过。
验收:nm -g --defined-only textstat.o 只出现两个 T;两个 is_delimiter 同时存在且互不干扰;去掉 static 后链接器报 multiple definition。
下面的三个文件用 static 让两个模块各自维护一份状态:
/* counter_a.c */
static int counter = 0;
static void step(void) {
counter++;
}
void bump_a(void) {
step();
}
int value_a(void) {
return counter;
}2
3
4
5
6
7
8
9
10
11
12
13
14
/* counter_b.c */
static int counter = 0;
static void step(void) {
counter++;
}
void bump_b(void) {
step();
}
int value_b(void) {
return counter;
}2
3
4
5
6
7
8
9
10
11
12
13
14
/* main.c */
#include <stdio.h>
void bump_a(void);
void bump_b(void);
int value_a(void);
int value_b(void);
int main(void) {
bump_a();
bump_a();
bump_b();
printf("%d %d\n", value_a(), value_b());
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
- 编译运行,记录输出。解释为什么两个模块累加的是各自的
counter。 - 对
counter_a.o运行nm与nm -g --defined-only,指出counter、step、bump_a、value_a的符号字母,说明哪些对外可见。 - 只去掉
counter的static(两个文件都去),链接,记录诊断;只去掉step的static再试一次,记录诊断。说明这两次失败的共同原因。 - 把设计改成两个模块共享同一个计数器:新建
counter.h放extern int counter;与四个函数声明,把两个实现合并进counter.c,main.c不变。运行确认输出3 3;用nm -g counter.o确认counter是B,再用nm main.o说明文件里为什么找不到counter这个符号(main只通过函数访问它,没有直接引用)。最后在main的printf里直接读一次counter,重新编译,确认main.o里出现U counter。
验收:原程序输出 2 1,共享版本输出 3 3;第 3 步两次都出现 multiple definition;改完后 counter.o 里是 B、main.o 里是 U。
下面的代码将全局对象的实体定义直接暴露在了头文件中,这是初涉多文件 C 工程时极易踩中的典型链接陷阱:
/* counter.h */
int counter;2
/* counter.c */
#include "counter.h"
void bump(void) {
counter++;
}2
3
4
5
6
/* main.c */
#include <stdio.h>
#include "counter.h"
void bump(void);
int main(void) {
bump();
printf("%d\n", counter);
return 0;
}2
3
4
5
6
7
8
9
10
11
- 按默认选项编译链接,记录诊断。指出
counter在两个翻译单元里各是什么,违反的是哪一条规则。 - 加上
-fcommon再链接一次,运行并记录输出;用nm counter.o看counter的符号字母。说明公共符号是怎样让两个暂定定义合并的。查一下 GCC 10 起-fcommon为什么不再默认开启,用一句话概括。 - 把
counter.h改成int counter = 0;,仍然加-fcommon,重新链接,记录诊断。说明带初始化器的定义与暂定定义在链接行为上有什么区别。 - 改成标准做法:头文件写
extern int counter;,在counter.c里写int counter = 0;。确认程序输出1;对两个目标文件运行nm,指出counter的B与U分别在哪里。 - 另写一个单文件程序,文件作用域下写三次
int g;、再写一次int g = 3;,编译运行,记录输出与nm里的符号。说明这与跨翻译单元的多重定义为什么不冲突。
验收:第 2 步输出 1 且符号为 C;第 3 步仍报 multiple definition;第 4 步符号为 B 与 U 各一处;第 5 步输出 3,符号为 D。
8.3 综合与挑战
下面的多文件工程中预先埋下了三处相互交织的构建隐患,在排查时会按编译器与链接器的执行阶段依次暴露。请先原样直接尝试构建,切勿提前猜测修改:
/* scale.h */
#ifndef COMMON_H
#define COMMON_H
int scale(int x);
#endif2
3
4
5
6
7
/* counter.h */
#ifndef COMMON_H
#define COMMON_H
int counter;
void bump(void);
int get_counter(void);
#endif2
3
4
5
6
7
8
9
10
11
/* scale.c */
#include "scale.h"
int scale(int x) {
return x * 10;
}2
3
4
5
6
/* counter.c */
#include "counter.h"
void bump(void) {
counter++;
}
int get_counter(void) {
return counter;
}2
3
4
5
6
7
8
9
10
/* main.c */
#include <stdio.h>
#include "scale.h"
#include "counter.h"
int scale(int x) {
return x + 1;
}
int main(void) {
bump();
bump();
printf("%d %d %d\n", get_counter(), scale(2), counter);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
要求:
- 用
gcc -std=c17 -Wall -Wextra *.c -o app编译,记录第一条错误和第一条警告,指出它们属于哪个阶段。然后用gcc -std=c17 -E main.c | grep -c "void bump(void);"统计声明出现的次数,解释这个 0 说明了什么,以及为什么诊断里没有直接出现「保护宏重复」这类消息。 - 只改两个头文件的保护宏,重新编译。记录链接器的两条
multiple definition诊断,分别指出它们由哪条规则造成。 - 修掉
counter的多重定义:头文件只留extern int counter;,定义放进counter.c。再处理scale:把main.c里的同名函数改名为local_scale并加上static。说明为什么不能只加static而不改名(实际试一次,记录诊断)。改完后把printf改成打印四项:get_counter()、local_scale(2)、scale(2)、counter。 - 全部修好后运行,确认输出
2 3 20 2。对三个目标文件运行nm -g --defined-only,列出导出的符号。解释为什么main.o里的counter是U,而counter.o里是B。 - 写一个
check.sh:依次用-c编译三个源文件,每次检查退出状态,任何一步失败就打印是哪一步并以非 0 退出;全部成功后链接并运行。脚本要能在故意改坏任意一个文件时指出失败发生在哪一步。
验收:输出 2 3 20 2;三个目标文件的导出符号分别只有 scale、counter 与 bump/get_counter、main(MinGW 上 main.o 还会多出 .refptr.counter 一类的重定位辅助符号,可以指出它并在报告里说明);check.sh 在改坏 counter.c 时报告编译失败而不是链接失败。