Appearance
预处理、多文件编译、头文件
概念
前面十四篇讲的全是"一个 .c 文件内部的事"。但真实程序从来不只有一个文件。
当一个程序变成多个文件,立刻冒出三个新问题:
| 问题 | 谁来回答 |
|---|---|
① #include <stdio.h> 到底做了什么? | 预处理器(preprocessor) |
| ② 为什么"声明"和"定义"要分开写? | 分离编译(separate compilation) |
③ 多个 .c 是怎么变成一个可执行文件的? | 编译 + 链接 |
先给一个必须建立的认知:
这一点极其重要:gcc a.c b.c 看起来是"一次编译两个文件",实际上是"分别编译成 a.o、b.o,再链接"。
所以"编译"其实分两个非常不同的阶段:
a.c ──预处理──► 纯文本 ──编译──► a.s ──汇编──► a.o ─┐
├─链接──► a.out
b.c ──预处理──► 纯文本 ──编译──► b.s ──汇编──► b.o ─┘
↑ ↑ ↑
只看 a.c 只看 a.c 的内容 这时候才"同时看所有 .o"
(文本替换) (不关心 b.c) (符号解析 + 重定位)注意那个分界线:在链接之前,编译器对"另一个文件里有什么"一无所知。
本篇在主线上的位置:
lang/10~lang/14讲了"C 语言本身"。本篇开始讲"工具链(toolchain)"。 这也是"从 C 到可执行文件"这部分的起点(lang/20-steps.md会把四步拆得更细)——因为"预处理"就是那四步的第一步。
原理
一、预处理:编译器之前的"文本处理"
预处理器是个"独立的、很笨的程序"——它不认识 C 语法,只做文本替换。
四个主要指令:
| 指令 | 作用 | 本质 |
|---|---|---|
#include | 把另一个文件的全部文本插到这里 | 文本插入(就是复制粘贴) |
#define | 宏替换(对象宏 / 函数宏) | 文本替换(不带类型、不做语法检查) |
#ifdef / #ifndef / #if / #endif | 条件编译 | "这段文本要不要留下" |
#pragma / #error / #line | 实现相关 / 报错 / 改行号 | 杂项 |
关键一句:
这直接解释了后面两个现象:
| 现象 | 原因 |
|---|---|
为什么同一份 stdio.h 会被抄进每个 .c | #include 是"抄一遍"——所以 100 个 .c 就有 100 份 stdio 的声明副本 |
| 为什么必须写 include guard | 因为如果 A 抄了 C,B 也抄了 C,而 A、B 都被抄进 D,那 C 就被抄了两遍——重复定义就来了 |
#define 的两种形态:
c
#define PI 3.14159 /* 对象宏:无参数 */
#define MAX(a, b) ((a) > (b) ? (a) : (b)) /* 函数宏:带参数 */函数宏的"括号陷阱"(下一篇的考点,先看一眼):
c
#define BAD_MAX(a, b) a > b ? a : b
BAD_MAX(1, 2) + 3 /* 展开成:1 > 2 ? 1 : 2 + 3 → 得到 5,不是 5? */展开后 +3 被吃进了 ?: 的分支里——因为宏是"文本替换",它不理会运算符优先级。 所以宏的参数和整体都要加括号。
预定义宏(编译器自带的,很有用):
| 宏 | 值 |
|---|---|
__FILE__ | 当前源文件名(字符串) |
__LINE__ | 当前行号(整数) |
__func__ | 当前函数名(C99) |
__DATE__ / __TIME__ | 编译时刻 |
__STDC__ | 是否符合标准 C |
__cplusplus | 只有 C++ 编译器才定义(这就是 extern "C" 靠 #ifdef __cplusplus 保护的原因) |
__LINE__与__FILE__是"日志宏 / 断言宏"的核心——assert(x)之所以能打印"哪个文件哪一行",就是预处理器把这两个宏填进去了。
二、声明 vs 定义:C 里最重要的一组区分
先给最硬的定义:
| 声明(declaration) | 定义(definition) | |
|---|---|---|
| 作用 | 告诉编译器"有这么个东西,类型是什么" | 真的把它造出来 |
| 变量 | extern int x;(不分配空间) | int x;(分配空间) |
| 函数 | int f(int);(没有函数体) | int f(int a) { ... }(有函数体) |
| 能否重复 | 可以重复声明任意多次 | 一个程序里只能定义一次(ODR) |
| 可以没有吗 | 不行(编译器需要它) | 可以有声明没定义吗?可以——但链接时会报"未定义" |
"变量声明 vs 定义"的分界线是 extern:
c
int x; /* 定义(除非在 extern 上下文中):分配 4 字节 */
extern int x; /* 声明:不分配,表示"别的文件里有" */
int x = 1; /* 定义(带初始化) */
extern int x = 1; /* 仍然是定义(extern 加上初始化器 = 定义) */为什么需要声明? 因为编译器"一次只看一个 .c":
c
/* a.c */
int add(int, int); /* ← 声明:告诉编译器函数长什么样 */
int main(void) { return add(1, 2); } /* 编译 a.c 时,add 的实体还不存在 */
/* b.c */
int add(int a, int b) { return a + b; } /* ← 定义 */编译 a.c 时,add 的实现根本不在视野里——编译器凭声明生成"调用 add"的指令(先留个空地址),链接器事后去 b.o 里找到 add 的地址填上。
这条区分是理解所有头文件规则的总钥匙。
三、头文件:只放"可以在多个文件里重复出现的东西"
既然 #include 会"抄一遍",那头文件里该放什么?
判据只有一条:
| 该放(可以重复出现) | 不该放(重复就出错) |
|---|---|
函数声明(int f(int);) | 函数定义(int f(int a){...})——会被每个 .c 复制一份 → multiple definition |
宏定义(#define N 10) | 全局变量定义(int g;)——同上 |
类型定义(struct/typedef/enum) | static 变量的定义(每个 .c 各一份,通常不是本意) |
extern 变量声明(extern int g;) | 可执行语句(头文件里不该有代码) |
inline 函数 / static inline | #include 一个 .c 文件 |
include guard / #pragma once | —— |
对照表:
c
/* ── math_utils.h ── 只放"声明类" ─────*/
#ifndef MATH_UTILS_H /* ① include guard 开头 */
#define MATH_UTILS_H
int add(int a, int b); /* 函数声明 —— 可重复出现 */
int mul(int a, int b);
#define MAX3(a,b,c) ((a) > (b) ? ((a) > (c) ? (a) : (c)) : ((b) > (c) ? (b) : (c)))
extern int g_counter; /* 变量声明(不是定义)*/
#endif /* ③ include guard 结尾 */
/* ── math_utils.c ── 放"定义" ─────*/
#include "math_utils.h"
int g_counter = 0; /* ← 变量的定义在这里,全程只此一处 */
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }为什么要有 include guard? 因为"抄进来"可能发生两次:
main.c
#include "a.h" ① 展开 a.h
#include "b.h" ② b.h 里也 #include "a.h" → a.h 又展开一遍!没有 guard 时,a.h 里的 int f(int); 出现了两次——函数声明重复是合法的,但 struct/typedef/#define 重复就不一定了(重复的 struct 定义是编译错误)。
有了 guard,第二次展开时 MATH_UTILS_H 已定义,整个文件体被 #ifndef 挡掉——"抄两遍"变成"第二遍抄到空白"。
三种写法: ①
#ifndef X_H / #define X_H / #endif(最标准、可移植); ②#pragma once(更短,几乎所有编译器都支持,但不是标准); ③ 两者都写(有些项目这么做,稳妥)。
还有一个好习惯:头文件应"自包含"——即 a.h 自己 #include 它需要的所有头,这样别人 #include "a.h" 就能直接用,不用猜还要先包含什么。
四、分离编译与增量构建
为什么要把程序拆成多个 .c? 三个理由:
| 理由 | 说明 |
|---|---|
| ① 增量编译 | 改一个 .c 只需重编这一个,其它 .o 直接复用——大项目里这是"分钟 vs 小时"的差别 |
| ② 协作 | 不同人写不同文件,只要头文件(接口)定下来就能并行开工 |
| ③ 信息隐藏 | .c 里的 static 函数/变量对外不可见——只有头文件里写的才是"公开接口" |
编译命令的样子:
bash
# 一步到位(内部仍是"分别编译 + 链接")
cc -o app main.c math_utils.c
# 分成两步(看清机制)
cc -c main.c # → main.o (只编译,不链接)
cc -c math_utils.c # → math_utils.o
cc -o app main.o math_utils.o # 链接
# 只改了 math_utils.c 时
cc -c math_utils.c # 只重编这一个
cc -o app main.o math_utils.o # 重新链接-c 是关键选项:"编译成目标文件就停,不要链接"。 Makefile 的价值就是把"哪些 .o 过期了"这件事自动算出来——规则是"如果 .c 或它包含的 .h 比 .o 新,就重编"。
五、链接期的两类错误
"编译能过但链接不过"是 C 新手最常见的挫败。两类错误,症状和原因都很清晰:
| 错误 | 报错文本 | 原因 |
|---|---|---|
| 未定义引用 | undefined reference to 'add' | 只有声明、没有定义(忘了把 .c 加进编译命令 / 函数名拼错 / 忘了链接库) |
| 重复定义 | multiple definition of 'g' | 同一个符号被定义了多次(变量定义写进了头文件 → 被多个 .c 各定义一次) |
一张诊断表:
| 症状 | 定位 |
|---|---|
undefined reference to 'foo' | ① 没写 foo 的实现;② 写了但没加进编译命令;③ 名字拼错(C 不像 C++ 会名字修饰,拼错就直接找不到) |
multiple definition of 'x' | ① 变量定义写进了 .h;② 同一份 .c 被编译了两次;③ 头文件没有 include guard(重复展开 struct/typedef) |
| 改了头文件但行为没变 | 忘了重编依赖它的 .c(make 的头文件依赖没写对) |
static 的两种含义(名字撞车,务必分清):
| 位置 | 含义 | 效果 |
|---|---|---|
| 函数/全局变量前 | 文件作用域(内部链接) | 只在本 .c 里可见——其它文件即使声明了也链接不到(信息隐藏) |
| 函数内的局部变量前 | 静态存储期 | 生命周期从"函数调用期间"变成"整个程序运行期间"——但作用域仍是块内 |
c
static int counter; /* 只被本文件看见 */
static int helper(void) { } /* 只被本文件调用 */
void f(void) {
static int calls = 0; /* 只初始化一次;调用之间保留值 */
calls++;
}
static的这两个含义都和"链接(linkage)"或"存储期(storage duration)"有关——记住一句:static= "局限在某个范围内、但活得更久"(文件内 / 程序全程)。
六、构建系统的思路(不写脚本,只看逻辑)
一条命令 cc -o app *.c 在小项目里够用,但大项目不行——因为它每次都重编所有文件。
Makefile 的全部逻辑就是"依赖 + 命令":
make
app: main.o math_utils.o
cc -o app main.o math_utils.o
main.o: main.c math_utils.h
cc -c main.c
math_utils.o: math_utils.c math_utils.h
cc -c math_utils.c读法:目标: 依赖 下面一行是"当依赖比目标新时执行什么"。
关键在第二行
main.c math_utils.h:把.h也列成依赖,改头文件才会触发重编。很多人踩的坑就是"只写.c依赖"——改struct定义后忘了重编,链接出来的程序行为诡异。
示例
例 1:一个三文件项目(完整可读)
任务:把"声明在 .h、定义在 .c、使用在 main.c"完整走一遍。
math_utils.h——接口(只放声明):
c
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
/* 宏:注意每个参数和整体都加括号 */
#define MAX3(a, b, c) \
((a) > (b) ? ((a) > (c) ? (a) : (c)) : ((b) > (c) ? (b) : (c)))
/* 函数声明(可以重复出现) */
int add(int a, int b);
int mul(int a, int b);
/* 变量声明(不是定义!) */
extern int g_ops;
#endif /* MATH_UTILS_H */math_utils.c——实现(放定义):
c
#include "math_utils.h"
int g_ops = 0; /* ← 变量的定义:全程序只此一处 */
int add(int a, int b) {
g_ops++;
return a + b;
}
int mul(int a, int b) {
g_ops++;
return a * b;
}main.c——使用(只 include 头文件):
c
#include <stdio.h>
#include "math_utils.h"
int main(void) {
printf("add(2,3) = %d\n", add(2, 3));
printf("mul(2,3) = %d\n", mul(2, 3));
printf("MAX3(1,5,3) = %d\n", MAX3(1, 5, 3));
printf("g_ops = %d\n", g_ops);
return 0;
}构建与运行:
bash
cc -c main.c # → main.o
cc -c math_utils.c # → math_utils.o
cc -o app main.o math_utils.o
./app用 Python 模拟"分离编译 + 链接"的符号表过程:
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
# 每个 .o 文件贡献两类符号:定义(T/D)与未定义引用(U)
OBJECTS = {
"main.o": {
"defines": [], # main 是内部符号,这里不列
"undefined": ["add", "mul", "g_ops", "printf"],
},
"math_utils.o": {
"defines": ["add", "mul", "g_ops"],
"undefined": [],
},
}
print("=== 各 .o 的符号表 ===")
for obj, sym in OBJECTS.items():
print(f" {obj}")
print(" " + pad("定义", 8) + (", ".join(sym["defines"]) if sym["defines"] else "(无外部定义)"))
print(" " + pad("未定义", 8) + (", ".join(sym["undefined"]) if sym["undefined"] else "(无)"))
# 链接:收集所有定义,匹配未定义引用
defined = {}
for obj, sym in OBJECTS.items():
for s in sym["defines"]:
defined.setdefault(s, []).append(obj)
print("\n=== 链接器的两步 ===")
print(" ① 符号解析:把所有 .o 的'定义'收集起来,去匹配'未定义引用'")
for s, objs in defined.items():
print(f" {s:<8} 由 {', '.join(objs)} 定义")
print(" printf 由 libc 提供(链接时自动带上)")
print("\n ② 重定位:把每个 .o 里'留空的调用地址'填上真实地址")
for caller, callee in (("main.o", "add"), ("main.o", "mul")):
print(f" {caller} 里对 {callee} 的调用 → 填入 {callee} 的地址")
print("\n=== 两类错误的成因 ===")
print(" " + pad("症状", 34) + pad("缺了什么", 26) + "修复")
rows = [
("undefined reference to 'add'", "math_utils.o 没参与链接", "把它加进编译命令"),
("名字拼错(add 写成 Add)", "编译器不会猜", "链接期才报错,看报错名字去查拼写"),
("multiple definition of 'g_ops'", "g_ops 的定义写进了 .h", "改头文件为 extern,定义放 .c"),
]
for a, b, c in rows:
print(" " + pad(a, 34) + pad(b, 26) + c)
print("\n=== 增量构建的账 ===")
print(" 全量:3 个 .c 都重编 → 3 次编译")
print(" 增量:只改 math_utils.c → 编 1 次 + 链接 1 次")
print(" 项目有 500 个 .c 时,差距是'500 次 vs 1 次'")预期输出:
=== 各 .o 的符号表 ===
main.o
定义 (无外部定义)
未定义 add, mul, g_ops, printf
math_utils.o
定义 add, mul, g_ops
未定义 (无)
=== 链接器的两步 ===
① 符号解析:把所有 .o 的'定义'收集起来,去匹配'未定义引用'
add 由 math_utils.o 定义
mul 由 math_utils.o 定义
g_ops 由 math_utils.o 定义
printf 由 libc 提供(链接时自动带上)
② 重定位:把每个 .o 里'留空的调用地址'填上真实地址
main.o 里对 add 的调用 → 填入 add 的地址
main.o 里对 mul 的调用 → 填入 mul 的地址
=== 两类错误的成因 ===
症状 缺了什么 修复
undefined reference to 'add' math_utils.o 没参与链接 把它加进编译命令
名字拼错(add 写成 Add) 编译器不会猜 链接期才报错,看报错名字去查拼写
multiple definition of 'g_ops' g_ops 的定义写进了 .h 改头文件为 extern,定义放 .c
=== 增量构建的账 ===
全量:3 个 .c 都重编 → 3 次编译
增量:只改 math_utils.c → 编 1 次 + 链接 1 次
项目有 500 个 .c 时,差距是'500 次 vs 1 次'三条结论:
| 结论 | 说明 |
|---|---|
| 声明让编译过、定义让链接过 | main.c 里只有 add 的声明——编译期不查实现,链接期才找 |
| 链接器做两件事 | ① 符号解析(定义 ↔ 未定义引用配对)② 重定位(填地址) |
| 增量构建的收益随规模放大 | 500 个 .c 的项目里,改一个文件只需编一个 |
例 2:把预处理器的"文本替换"亲手跑一遍
任务:用 Python 实现一个极简预处理器,看 #include 与 #define 到底做了什么。
c
/* ── config.h ── */
#define SCALE 4
#define BUFSZ (SCALE * 1024)
/* ── main.c ── */
#include "config.h"
#define SQUARE(x) ((x) * (x))
int buf[BUFSZ];
int v = SQUARE(3 + 1);用 Python 模拟预处理器的展开过程:
python
import re, unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
FILES = {
"config.h": [
"#define SCALE 4",
"#define BUFSZ (SCALE * 1024)",
],
"main.c": [
'#include "config.h"',
"#define SQUARE(x) ((x) * (x))",
"",
"int buf[BUFSZ];",
"int v = SQUARE(3 + 1);",
],
}
def expand_obj_macros(lines, macros):
out = []
for ln in lines:
m = re.match(r"#define\s+(\w+)(\([^)]*\))?\s+(.*)$", ln)
if m:
if m.group(2): # 函数宏:这里不展开,只登记名字
out.append(("函数宏", ln, "(留待调用处展开)"))
else: # 对象宏:记入宏表
macros[m.group(1)] = m.group(3)
out.append(("对象宏", ln, "(记入宏表,不输出)"))
continue
before, cur = ln, ln
for _ in range(10):
for k, v in macros.items():
cur = re.sub(r"\b%s\b" % re.escape(k), v, cur)
if cur == before:
break
before = cur
out.append(("替换", ln, cur))
return out
print("=== 第一步:#include 是“把文件内容抄进来” ===")
print(' main.c 的第一行: #include "config.h"')
print(" → 展开后,config.h 的两行原样插在这里:")
for l in FILES["config.h"]:
print(" " + l)
print("\n=== 第二步:逐行做宏替换 ===")
macros = {}
all_lines = FILES["config.h"] + FILES["main.c"][1:]
print(" " + pad("阶段", 8) + pad("原文本", 32) + "处理结果")
for kind, raw, res in expand_obj_macros(all_lines, macros):
if kind in ("对象宏", "函数宏"):
print(" " + pad(kind, 8) + pad(raw, 32) + res)
else:
print(" " + pad("替换", 8) + pad(raw, 32) + (res if raw.strip() else "(空行)"))
print("\n=== 第三步:函数宏的展开(文本替换,不看优先级)===")
def expand_square(expr):
return re.sub(r"SQUARE\((.*)\)", r"((\1) * (\1))", expr)
raw = "int v = SQUARE(3 + 1);"
after = expand_square(raw)
print(f" {raw}")
print(f" → {after}")
print(" → 再代入 3+1: ((3 + 1) * (3 + 1)) = 16 ← 括号救了你")
print("\n=== 如果宏不写括号会发生什么 ===")
print(" #define SQUARE_BAD(x) x * x")
raw2 = "int v = SQUARE_BAD(3 + 1);"
after2 = raw2.replace("SQUARE_BAD(3 + 1)", "3 + 1 * 3 + 1")
print(f" {raw2}")
print(f" → {after2} ← 展开成 3 + 1*3 + 1 = 7,不是 16!")
print("\n=== 预定义宏 ===")
for name, val in (("__FILE__", '"main.c"'), ("__LINE__", "12"), ("__func__", '"main"'),
("__DATE__", '"Sep 29 2026"'), ("__cplusplus", "未定义(这是 C 编译器)")):
print(" " + pad(name, 16) + val)
print(" ★ __FILE__/__LINE__ 就是 assert 与日志宏能报“哪个文件哪一行”的原因")预期输出:
=== 第一步:#include 是“把文件内容抄进来” ===
main.c 的第一行: #include "config.h"
→ 展开后,config.h 的两行原样插在这里:
#define SCALE 4
#define BUFSZ (SCALE * 1024)
=== 第二步:逐行做宏替换 ===
阶段 原文本 处理结果
对象宏 #define SCALE 4 (记入宏表,不输出)
对象宏 #define BUFSZ (SCALE * 1024) (记入宏表,不输出)
函数宏 #define SQUARE(x) ((x) * (x)) (留待调用处展开)
替换 (空行)
替换 int buf[BUFSZ]; int buf[(4 * 1024)];
替换 int v = SQUARE(3 + 1); int v = SQUARE(3 + 1);
=== 第三步:函数宏的展开(文本替换,不看优先级)===
int v = SQUARE(3 + 1);
→ int v = ((3 + 1) * (3 + 1));
→ 再代入 3+1: ((3 + 1) * (3 + 1)) = 16 ← 括号救了你
=== 如果宏不写括号会发生什么 ===
#define SQUARE_BAD(x) x * x
int v = SQUARE_BAD(3 + 1);
→ int v = 3 + 1 * 3 + 1; ← 展开成 3 + 1*3 + 1 = 7,不是 16!
=== 预定义宏 ===
__FILE__ "main.c"
__LINE__ 12
__func__ "main"
__DATE__ "Sep 29 2026"
__cplusplus 未定义(这是 C 编译器)
★ __FILE__/__LINE__ 就是 assert 与日志宏能报“哪个文件哪一行”的原因三条结论:
| 结论 | 说明 |
|---|---|
#include 是文本插入 | 所以 config.h 里的 #define 真的"抄"进了 main.c |
| 宏展开是"反复替换到稳定" | BUFSZ → (SCALE * 1024) → (4 * 1024) —— 可以层层展开 |
| 括号是宏的命 | ((x)*(x)) 得 16;x * x 得 7——宏不理会优先级 |
例 3:没有 include guard 会发生什么
任务:制造"同一头文件被展开两次"的场景。
main.c
├─ #include "a.h" ① 展开 a.h
└─ #include "b.h" ② 展开 b.h —— 而 b.h 里也有 #include "a.h"
→ ③ a.h 又展开一次!用 Python 模拟"有 guard vs 无 guard":
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
# a.h 的内容(无 guard)
A_H = [
"struct Point { int x; int y; };", # 重复定义 struct → 编译错误
"int distance(struct Point a);", # 重复声明函数 → 合法
]
# a.h 的内容(有 guard)
A_H_GUARDED = [
"#ifndef A_H",
"#define A_H",
"struct Point { int x; int y; };",
"int distance(struct Point a);",
"#endif",
]
def preprocess(include_order, use_guard):
out, guard_defined = [], set()
for fname in include_order:
if fname != "a.h":
out.append(f"/* 展开 {fname} */")
continue
if use_guard:
if "A_H" in guard_defined:
out.append("/* a.h:A_H 已定义 → 整个文件体被跳过(空) */")
continue
guard_defined.add("A_H")
out.append(f"/* 展开 {fname}({'有' if use_guard else '无'} guard)*/")
for l in (A_H_GUARDED if use_guard else A_H):
if use_guard and l.startswith(("#ifndef", "#define", "#endif")):
continue # 预处理指令不进最终文本
out.append(" " + l)
return out
print(" 包含顺序:main.c → a.h → b.h → (b.h 里又 a.h)\n")
for use_guard in (False, True):
tag = "有 guard" if use_guard else "无 guard"
print(f"=== {tag} ===")
for l in preprocess(["a.h", "b.h", "a.h"], use_guard):
print(" " + l)
print()
print("=== 结果对照 ===")
print(" " + pad("写法", 12) + pad("struct Point 出现次数", 22) + "结论")
print(" " + pad("无 guard", 12) + pad("2", 22) + "重复定义 struct → 编译错误")
print(" " + pad("有 guard", 12) + pad("1", 22) + "第二次展开为空 → 正常")
print("\n=== 三种 guard 写法 ===")
print(" ① #ifndef A_H / #define A_H / #endif ← 标准、可移植、最常用")
print(" ② #pragma once ← 更短,非标准但广泛支持")
print(" ③ 两者都写 ← 有些项目这么做,稳妥")
print()
print(" 注意:函数声明重复是合法的,但 struct/typedef/#define 重复多半不合法")
print(" → 所以“能不能重复”不能靠运气,必须靠 guard")预期输出:
包含顺序:main.c → a.h → b.h → (b.h 里又 a.h)
=== 无 guard ===
/* 展开 a.h(无 guard)*/
struct Point { int x; int y; };
int distance(struct Point a);
/* 展开 b.h */
/* 展开 a.h(无 guard)*/
struct Point { int x; int y; };
int distance(struct Point a);
=== 有 guard ===
/* 展开 a.h(有 guard)*/
struct Point { int x; int y; };
int distance(struct Point a);
/* 展开 b.h */
/* a.h:A_H 已定义 → 整个文件体被跳过(空) */
=== 结果对照 ===
写法 struct Point 出现次数 结论
无 guard 2 重复定义 struct → 编译错误
有 guard 1 第二次展开为空 → 正常
=== 三种 guard 写法 ===
① #ifndef A_H / #define A_H / #endif ← 标准、可移植、最常用
② #pragma once ← 更短,非标准但广泛支持
③ 两者都写 ← 有些项目这么做,稳妥
注意:函数声明重复是合法的,但 struct/typedef/#define 重复多半不合法
→ 所以“能不能重复”不能靠运气,必须靠 guard三条结论:
| 结论 | 说明 |
|---|---|
| include guard 治的是"同一文件被抄两遍" | #ifndef 第二次直接跳过整个文件体 |
| "重复没关系"的东西是有限的 | 函数声明可以重复;struct/typedef/变量定义不行 |
| 头文件必须"自包含 + 有 guard" | 自包含让人用起来不用猜;guard 让它被包含几次都安全 |
例 4:宏 vs 内联函数——什么时候该用哪个
任务:对比宏与 static inline 函数的取舍。
c
/* 宏版本 */
#define MAX(a, b) ((a) > (b) ? (a) : (b))
/* 内联函数版本(C99) */
static inline int max_i(int a, int b) {
return a > b ? a : b;
}
/* 宏的四个坑 */
int s1 = MAX(1, 2); /* OK */
int s2 = MAX(1, 2) + 3; /* 括号救回来了 */
/* int s3 = MAX(f(), g()); */ /* 参数被求值两次! */
/* int s4 = MAX(i++, j); */ /* 自增执行两次! */用 Python 把"参数求值次数"的差别算出来:
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
print("=== 宏展开:参数求值几次? ===")
MACRO_BODY = "((a) > (b) ? (a) : (b))"
print(f" #define MAX(a, b) {MACRO_BODY}")
print(" MAX(f(), g()) 展开后:")
expanded = "((f()) > (g()) ? (f()) : (g()))"
print(f" {expanded}")
print(" → 条件里 f() 一次、g() 一次;选中的分支再来一次")
print(" → 最坏情况下每个参数被求值 2 次!")
print("\n MAX(i++, j):")
print(" 展开: ((i++) > (j) ? (i++) : (j))")
print(" 若 i=3、j=5:比较用 i++(i 变 4),随后 j 胜出 → i 只加了 1 次?不:")
print(" 比较还要拿 i++ 的值比,再进分支。实际 i 被自增了 2 次(4 → 5)")
print("\n=== 内联函数:正常求值 ===")
print(" static inline int max_i(int a, int b) { return a > b ? a : b; }")
print(" max_i(i++, j):参数先求值各一次 → i 只加 1 次 ✓")
print("\n=== 对照表 ===")
print(" " + pad("维度", 20) + pad("宏", 26) + "inline 函数")
rows = [
("类型检查", "无(纯文本)", "有(编译期检查)"),
("求值次数", "可能多次", "恰好一次"),
("调试", "看不到(展开后)", "可单步"),
("作用域", "全局污染", "遵守作用域"),
("性能", "一定内联", "编译器决定(通常内联)"),
("能否取地址", "不能", "能"),
("用途", "条件编译、常量、字符串化", "小工具函数"),
]
for a, b, c in rows:
print(" " + pad(a, 20) + pad(b, 26) + c)
print("\n=== 结论 ===")
print(" 宏不是函数,是文本。能在函数里表达的就用 static inline 函数")
print(" 宏留在:① #ifdef ② 常量 ③ 需要 # 字符串化 / ## 连接时")
print(" 一个妥协方案:#define MAX(a,b) max_i((a),(b)) ← 宏只管转发,函数管语义")预期输出:
=== 宏展开:参数求值几次? ===
#define MAX(a, b) ((a) > (b) ? (a) : (b))
MAX(f(), g()) 展开后:
((f()) > (g()) ? (f()) : (g()))
→ 条件里 f() 一次、g() 一次;选中的分支再来一次
→ 最坏情况下每个参数被求值 2 次!
MAX(i++, j):
展开: ((i++) > (j) ? (i++) : (j))
若 i=3、j=5:比较用 i++(i 变 4),随后 j 胜出 → i 只加了 1 次?不:
比较还要拿 i++ 的值比,再进分支。实际 i 被自增了 2 次(4 → 5)
=== 内联函数:正常求值 ===
static inline int max_i(int a, int b) { return a > b ? a : b; }
max_i(i++, j):参数先求值各一次 → i 只加 1 次 ✓
=== 对照表 ===
维度 宏 inline 函数
类型检查 无(纯文本) 有(编译期检查)
求值次数 可能多次 恰好一次
调试 看不到(展开后) 可单步
作用域 全局污染 遵守作用域
性能 一定内联 编译器决定(通常内联)
能否取地址 不能 能
用途 条件编译、常量、字符串化 小工具函数
=== 结论 ===
宏不是函数,是文本。能在函数里表达的就用 static inline 函数
宏留在:① #ifdef ② 常量 ③ 需要 # 字符串化 / ## 连接时
一个妥协方案:#define MAX(a,b) max_i((a),(b)) ← 宏只管转发,函数管语义三条结论:
| 结论 | 说明 |
|---|---|
| 宏参数可能被求值多次 | MAX(i++, j) 会让 i 自增两次——这是最难查的宏坑之一 |
static inline 有类型检查和正常求值 | 并且不污染全局作用域(宏是全局的,任何同名标识符都会被替换) |
| 宏的正当用途 | 条件编译(#ifdef)、常量、字符串化(#)、符号连接(##) |
考点
考点
1. 预处理是什么
- 是"编译器之前的纯文本处理"——预处理器不认识 C 语法;
#include= 把文件内容抄进来(所以有了"重复包含"与"include guard");#define= 文本替换(无类型、无作用域、不检查语法);- 预定义宏:
__FILE__(文件名)、__LINE__(行号)、__func__、__DATE__/__TIME__、__cplusplus。
2. 声明 vs 定义(核心)
| 声明 | 定义 | |
|---|---|---|
| 变量 | extern int x;(不分配) | int x;(分配) |
| 函数 | int f(int);(无体) | int f(int a){...}(有体) |
| 可重复 | 可以任意多次 | 全程序只能一次(ODR) |
- 一句概括:声明让"编译"过,定义让"链接"过;
- 头文件只放声明类:函数声明、宏、类型定义、
extern变量声明、inline函数; - 绝不放:函数定义、全局变量定义。
3. include guard
- 必要性:
#include是复制粘贴,同一文件可能被抄两遍(A 引 C、B 引 C、D 引 A 和 B); - 写法:
#ifndef X_H / #define X_H / ... / #endif,或#pragma once; - 为什么必须:函数声明重复合法,但
struct/typedef/ 变量定义重复不合法; - 配套习惯:头文件要"自包含"(自己包含所需的一切)。
4. 分离编译与链接
- 编译单元 = 一个
.c(预处理后的结果);编译器一次只看一个; cc -c a.c→a.o(只编译不链接);cc -o app a.o b.o链接;- 链接器两步:① 符号解析(定义 ↔ 未定义引用)② 重定位(填地址);
- 增量编译:改一个
.c只重编它(Makefile必须把头文件也列为依赖)。
5. 两类链接错误(诊断题)
| 报错 | 原因 | 修法 |
|---|---|---|
undefined reference to 'f' | 有声明无定义 / 忘了把 .c 加进命令 / 名字拼错 | 补实现或补目标文件 |
multiple definition of 'x' | 变量定义写进了 .h / 同一 .c 编了两次 / 缺 include guard | 头文件改 extern,定义移到 .c |
6. static 的两种含义(易混)
| 位置 | 含义 | 效果 |
|---|---|---|
| 函数 / 全局变量前 | 内部链接(文件作用域) | 只在本 .c 可见 |
| 函数内局部变量前 | 静态存储期 | 只初始化一次,调用之间保值 |
7. 宏 vs 内联函数
- 宏:无类型检查、参数可能求值多次、不遵守作用域、调试困难;
static inline:有类型检查、参数求值一次、遵守作用域;- 宏的正当用途:条件编译、常量、
#(字符串化)、##(连接); - 宏必加括号:参数和整体都要加(
((a) > (b) ? (a) : (b)))。
8. 高频易错点
#include "a.h"与#include <a.h>:前者先搜当前目录(用户头文件),后者搜系统路径;- 头文件里写
int g = 0;→ 每个包含它的.c都定义一份 → multiple definition(改extern); - 改
.h后必须重编所有依赖它的.c——这是"行为诡异"的常见来源; - 宏不能"取地址"、不能用调试器看;
#define不会遵循 C 作用域——同名标识符任何地方都会被替换(所以宏名习惯全大写)。
小结
- C 的编译是"先各编译各的、再拼起来":编译器从不同时看两个
.c。 - 预处理是"编译器之前的文本处理":
#include是抄文件,#define是文本替换。 - "抄一遍"导致两个后果:头文件的内容会被复制进每个
.c;同一文件可能被抄两遍 → 必须 include guard。 - 声明 vs 定义:声明让编译过、定义让链接过;头文件只放声明类内容。
extern是"变量声明"的标志:extern int x;不分配空间,int x;分配。- 分离编译三收益:增量编译、多人协作、信息隐藏(
static)。 - 链接器做两步:符号解析 + 重定位。
- 两类链接错误:
undefined reference(缺定义)/multiple definition(重复定义)。 static两种含义:文件作用域(函数/全局变量前)、静态存储期(函数内局部变量前)。- 宏不是函数:无类型检查、可能重复求值、不遵守作用域;能用
static inline就用它;宏必加括号。 Makefile的核心是"目标: 依赖":务必把头文件也写进依赖,否则改头文件不会触发重编。
回到主线:到这里,C 语言本身(lang/10~15)和"多文件怎么组织"都讲完了。
但那个最核心的问题还悬着:
.c文件到底是怎么变成"能跑的程序"的?预处理之后发生了什么?.o文件里是什么形状(ELF)?符号表、重定位表长什么样?静态链接和动态链接差在哪?程序加载时内存映像怎么摆?——接下来五篇(lang/20~24)就是"从 C 到可执行文件"这条主线,也是【由硅到 C】这个站名的落点。
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。