Appearance
第 17 讲:预处理和编译链接
⏱️ 本讲 L1 内容建议分 2 次 学习,每次约 45 分钟。标有 📘 的提高内容与末尾 🔬 选学拓展可按需跳过,不影响后续学习。
🎯 学完本讲,你将能够
- 按顺序说出源代码到可执行程序的四个阶段,并会用
gcc -E / -S / -c实际观察每个阶段的产物 - 解释"为什么多个
.c文件分别编译后还能互相调用函数",说清链接器做的符号决议与重定位 - 使用
__FILE__、__LINE__等预定义符号 - 正确编写带参数的宏,记住"参数加括号、整体加括号"两条铁律
- 识别带副作用的宏参数陷阱,并能根据场景在宏和函数之间做选择
- 使用
#、##运算符;使用条件编译、命令行-D定义和头文件保护
🔗 先修知识
学习本讲前,你应当已经掌握:函数的声明与定义、#include 的基本用法(第 2 讲)、全局变量与 static(第 11 讲)。
自检三问(答不上来先复习对应章节):
- 为什么调用自定义函数前通常要先写函数声明?
#include <stdio.h>这一行到底做了什么?- 全局变量和局部变量的"寿命"有什么不同?
第一部分:编译和链接
一、两个环境:翻译环境与运行环境
C 标准把程序的一生划分在两个不同的环境里 🟢:
- 翻译环境(编译时):在这个环境中,源代码被翻译成可执行的机器指令。
- 运行环境(执行时):可执行程序被真正加载、运行的环境。
写代码时遇到的报错也分两类,搞清楚它们属于哪个阶段,排查时才不会乱:
| 报错类型 | 发生阶段 | 例子 |
|---|---|---|
| 编译错误 | 翻译环境 | 少分号、类型不匹配 |
| 链接错误 | 翻译环境 | undefined reference to 'Add'(找不到函数实现) |
| 运行错误 | 运行环境 | 程序崩溃、输出结果不对 |
二、翻译流程总览
翻译过程由编译和链接两个大阶段组成;其中"编译"又细分为预处理、编译、汇编三步:
text
┌─────────────────────────────────────────────────────────────┐
│ C 程序的翻译流程 │
│ │
│ test.c ┐ ┌─ test.obj │
│ add.c ├─→ ① 预处理 → ② 编译 → ③ 汇编 ─→ ├─ add.obj │
│ xxx.c ┘ └─ xxx.obj │
│ │
│ 多个目标文件 + 链接库 ──→ ④ 链接器 ──→ 可执行程序 xxx.exe │
└─────────────────────────────────────────────────────────────┘- 一个项目可以有多个
.c文件,它们各自独立走完前三步,生成各自的目标文件(Windows 下后缀.obj,Linux 下.o)。 - 目标文件还不能直接运行,链接器把所有目标文件和链接库(运行时库或第三方库)拼到一起,才生成可执行程序。
下面把四个阶段逐个拆开看。
三、第 1 步:预处理(预编译)
预处理器处理源文件中所有以 # 开头的指令,产物是 .i 文件。用 GCC 可以停下来观察:
bash
gcc -E test.c -o test.i预处理阶段做的事:
| 操作 | 说明 |
|---|---|
删除所有 #define,展开所有宏 | 宏名被直接替换成实际文本 |
| 处理条件编译指令 | #if、#ifdef、#elif、#else、#endif |
处理 #include | 把头文件内容递归插入到指令所在位置(头文件里还可能包含别的头文件) |
| 删除所有注释 | 注释被替换成空格 |
| 添加行号与文件名标识 | 供编译器生成调试信息、报错定位 |
保留 #pragma 指令 | 留给后续编译阶段使用 |
所以预处理结束后,.i 文件里已经没有宏、没有注释,只有展开、插入完毕的纯 C 代码。宏展开得对不对、头文件包含得对不对,打开 .i 文件一看便知——这是调试宏问题的第一手段。
四、第 2 步:编译
编译器对预处理后的文件依次做词法分析、语法分析、语义分析和优化,产物是汇编代码 .s 文件:
bash
gcc -S test.i -o test.s以这行代码为例:
c
array[index] = (index + 4) * (2 + 6);- 词法分析:扫描器把字符流切成一个个记号(关键字、标识符、字面量、运算符等)。上例被切成:
array、[、index、]、=、(、index、+、4、)、*、(、2、+、6、)、;。 - 语法分析:把记号按语法规则组织成以表达式为节点的语法树。
- 语义分析:做静态类型检查,比如声明与类型是否匹配、是否需要隐式类型转换;这个阶段会报告编译错误。
- 优化:在许可的优化等级(
-O0~-O3)下改进代码,例如常量表达式(2 + 6)直接折叠成8。
五、第 3 步:汇编
汇编器把 .s 里的汇编指令逐条翻译成机器指令(二进制),产物是目标文件:
bash
gcc -c test.s -o test.o一条汇编语句几乎对应一条机器指令,这一步只做"查表翻译",基本不做优化。
六、第 4 步:链接(多文件项目的关键)
每个 .c 文件都是单独编译的。假设项目里有两个文件:
🧩 test.c(多文件源文件片段) · 需与 add.c 一起编译
c
#include <stdio.h>
extern int Add(int x, int y); /* 声明:Add 在别的文件里定义 */
extern int g_val; /* 声明:g_val 是别的文件里的全局变量 */
int main(void)
{
int a = 10;
int b = 20;
int sum = Add(a, b);
printf("%d %d\n", sum, g_val);
return 0;
}🧩 add.c(多文件源文件片段) · 需与 test.c 一起编译
c
int g_val = 2022; /* 全局变量的定义 */
int Add(int x, int y) /* 函数的定义 */
{
return x + y;
}编译 test.c 时,编译器知道 Add 和 g_val "被使用了",却不知道它们的地址——它们在另一个文件里。于是调用指令的目标地址暂时空着。
可以分别生成目标文件,再让 gcc 充当链接器完成最后一步:
bash
gcc -c test.c -o test.o
gcc -c add.c -o add.o
gcc test.o add.o -o multi也可以一条命令让 gcc 自动完成:gcc test.c add.c -o multi。运行 multi,实测输出:
text
30 2022链接器在这一步做了三件事:地址与空间分配、符号决议、重定位——在 add.o 里查到 Add 和 g_val 的真实地址,再回头把 test.o 里所有引用它们的地方逐一修正。这就是多文件、多模块能够互相调用的原因。
📜 标准卡(L2 选读):翻译与执行环境
- 标准依据:C11 §5.1.2(执行环境)描述程序的翻译与执行;编译/链接的具体实现细节标准不做强制规定 🟠。
- gcc:上述
gcc -E/-S/-c行为遵循常见 Unix 工具约定,Windows 版 MinGW 的目标文件为 COFF 格式(.obj/.o均可识别)。 - MSVC:对应命令为
cl /P(预处理)、cl /c(编译)、link(链接)。
七、运行环境
可执行程序生成之后,运行时依次发生:
- 载入内存:有操作系统时由 OS 把程序从磁盘加载进内存;无操作系统的独立环境(如单片机)则需要手工安排,或把代码固化进只读存储器。
- 开始执行:程序入口启动,随后调用
main函数。 - 使用运行时栈:函数的局部变量、参数、返回地址存放在栈上;同时可以使用静态内存,其中的
static变量在整个运行期间一直保留值。 - 终止:
main正常返回,或意外终止(如崩溃被 OS 终止)。
第二部分:预处理详解
八、预定义符号
C 语言内置了一组预定义符号,预处理时自动替换,可直接使用:
| 符号 | 含义 |
|---|---|
__FILE__ | 正在编译的源文件名(字符串) |
__LINE__ | 当前行号(整数) |
__DATE__ | 编译日期(字符串,形如 Jul 1 2025) |
__TIME__ | 编译时间(字符串,形如 14:30:05) |
__STDC__ | 编译器遵循标准 C 时定义为 1,否则未定义 |
📄 01_predefined_symbols.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
printf("源文件:%s\n", __FILE__);
printf("当前行:%d\n", __LINE__);
printf("编译日期:%s\n", __DATE__);
printf("编译时间:%s\n", __TIME__);
#ifdef __STDC__
printf("__STDC__ = %d(遵循标准 C)\n", __STDC__);
#else
printf("__STDC__ 未定义\n");
#endif
return 0;
}实测(文件名、日期、时间随你的机器与编译时刻而变):
text
源文件:C:\...\block_xx.c
当前行:7
编译日期:Jul 1 2025
编译时间:15:02:18
__STDC__ = 1(遵循标准 C)最常用的是这一对,打印日志时顺手带上,定位问题很方便:
c
printf("file:%s line:%d\n", __FILE__, __LINE__);九、#define 定义常量
基本语法是 #define 名字 替换文本。预处理器只做文本替换,不理解含义:
🧩 常量定义(示意片段) · 不能单独编译
c
#define MAX 1000
#define reg register /* 给关键字起个短名字 */
#define do_forever for (;;) /* 用更形象的写法表达"死循环" */
#define CASE break; case /* 让每个 case 自动带上 break */替换文本太长时,除最后一行外,每行末尾用反斜杠 \ 续行(反斜杠后不能再有任何字符,包括空格):
🧩 多行宏(示意片段) · 不能单独编译
c
#define DEBUG_PRINT printf("file:%s\tline:%d\t" \
"date:%s\ttime:%s\n", \
__FILE__, __LINE__, \
__DATE__, __TIME__)❓ 末尾要不要加分号?
不要加。 宏是文本替换,分号会被当成替换文本的一部分:
🧩 待改错片段 · 该片段编译会失败
c
#define MAX 1000; /* ❌ 分号会被一起替换 */
if (condition)
max = MAX; /* 替换后:max = 1000;; */
else
max = 0;替换后 if 与 else 之间出现了两条语句(赋值语句 + 一条空语句),而不带花括号的 if 后面只能跟一条语句,于是编译报错。直接定义成 #define MAX 1000 即可。
十、#define 定义宏(带参数)
宏可以带参数,把参数替换进文本中:
c
#define SQUARE(x) x * x注意:参数列表的左括号必须紧贴宏名,中间一旦有空格,(x) x * x 整段都会被当成替换文本。
陷阱 1:参数不加括号
📄 02_macro_paren.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#define BAD_SQUARE(x) x * x
#define GOOD_SQUARE(x) ((x) * (x))
#define BAD_DOUBLE(x) (x) + (x)
#define GOOD_DOUBLE(x) (((x) + (x)))
int main(void)
{
int a = 5;
printf("BAD_SQUARE(a + 1) = %d\n", BAD_SQUARE(a + 1));
printf("GOOD_SQUARE(a + 1) = %d\n", GOOD_SQUARE(a + 1));
printf("10 * BAD_DOUBLE(a) = %d\n", 10 * BAD_DOUBLE(a));
printf("10 * GOOD_DOUBLE(a) = %d\n", 10 * GOOD_DOUBLE(a));
return 0;
}实测输出:
text
BAD_SQUARE(a + 1) = 11
GOOD_SQUARE(a + 1) = 36
10 * BAD_DOUBLE(a) = 55
10 * GOOD_DOUBLE(a) = 100原因全是文本替换 + 运算符优先级:
BAD_SQUARE(a + 1)展开为a + 1 * a + 1,先算乘法,结果5 + 5 + 1 = 11;- 即便给参数加了括号,
BAD_DOUBLE仍不安全:10 * (5) + (5)先乘后加,结果55; - 只有"每个参数加括号、整个表达式再加括号"的
GOOD_*版本才符合直觉。
🔧 铁律:所有用于数值表达式的宏,参数加括号、整体加括号。
十一、陷阱 2:带副作用的参数
"副作用"指表达式求值时带来的永久性改变。x + 1 没有副作用,x++ 有副作用。如果参数在宏体里出现超过一次,传入带副作用的表达式就会被求值多次:
📄 03_side_effect.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int main(void)
{
int x = 5;
int y = 8;
int z = MAX(x++, y++);
printf("x=%d y=%d z=%d\n", x, y, z);
return 0;
}宏展开后是:
c
z = ((x++) > (y++) ? (x++) : (y++));比较时 x++(取 5,x 变 6)、y++(取 8,y 变 9);5 > 8 为假,执行冒号后的 y++(取 9,y 变 10)。实测输出:
text
x=6 y=10 z=9函数不存在这个问题——实参在调用时只求值一次。
十二、宏替换的规则
展开宏时,预处理器按以下顺序工作:
- 调用宏时,先检查实参里是否含有别的
#define符号,有则先替换; - 把替换文本插到原位置,形参替换成实参文本;
- 对替换后的结果再扫描一遍,如仍含宏名则重复上述过程。
两条限制:
- 宏不能递归——展开自己时,自己的名字不会再次被替换;
- 字符串常量内部不被搜索:
printf("MAX")里的MAX永远不会被展开。
十三、宏还是函数?
宏常被用于简单运算(如求两数较大值),优势有二:①没有函数调用与返回的开销;②参数与类型无关,int、long、double 都能套。劣势同样明显:
| 对比项 | 宏 | 函数 |
|---|---|---|
| 代码长度 | 每次使用都原地展开,宏体大时代码明显膨胀 | 只有一份函数体 |
| 执行速度 | 无调用开销,快 | 有调用/返回开销 |
| 类型检查 | 无类型,不严谨 🟠 | 参数有类型,编译期检查 |
| 调试 | 预处理后就不存在了,无法单步调试 | 可以逐步调试 |
| 参数求值 | 可能多次求值,副作用有风险 | 只求值一次 |
| 特殊能力 | 参数可以是类型名 | 不可以 |
宏能做到而函数做不到的事——把类型当参数传:
🧩 MALLOC 宏(示意片段) · 展示类型参数用法
c
#include <stdlib.h>
#define MALLOC(num, type) ((type *)malloc((num) * sizeof(type)))
int *p = MALLOC(10, int); /* 展开为 ((int *)malloc((10) * sizeof(int))) */选择建议:简单、类型无关的计算用宏;逻辑复杂、需要类型安全和调试能力时用函数。
十四、# 与 ## 运算符
14.1 #:字符串化
# 只能出现在带参宏的替换列表中,作用是把参数变成字符串字面量:
c
#define PRINT(n) printf("the value of "#n" is %d\n", n)调用 PRINT(a) 时,#n 变成 "a",配合相邻字符串字面量自动拼接的规则,预处理结果为:
c
printf("the value of ""a"" is %d\n", a);
/* 等价于 */
printf("the value of a is %d\n", a);14.2 ##:记号粘合
## 把左右两边的记号粘成一个新记号(必须粘出合法标识符,否则行为未定义 🔴)。典型用途是批量生成同类函数:
📄 04_stringify_paste.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#define PRINT(n) printf("the value of "#n" is %d\n", n)
#define GENERIC_MAX(type) \
type type##_max(type x, type y) \
{ \
return x > y ? x : y; \
}
GENERIC_MAX(int) /* 生成 int_max 函数定义 */
GENERIC_MAX(float) /* 生成 float_max 函数定义 */
int main(void)
{
int a = 10;
PRINT(a);
printf("%d\n", int_max(2, 3));
printf("%.1f\n", float_max(3.5f, 4.5f));
return 0;
}实测输出:
text
the value of a is 10
3
4.5十五、命名约定与 #undef
语言本身无法区分语法相似的宏与函数,因此靠约定:宏名全部大写,函数名不要全部大写(例外:后面会见到的特殊宏,以及部分库宏如 putchar)。
用 #undef 可以提前取消一个宏,取消后该名字可重新定义:
🧩 #undef(示意片段) · 不能单独编译
c
#define MAX 100
/* ... 这段范围内 MAX 有效 ... */
#undef MAX
/* 此后 MAX 未定义,可重新 #define */十六、命令行定义
很多编译器支持在编译命令行中定义宏,便于用同一份源码编译出不同版本(如不同机器上数组大小不同):
📄 05_cmd_define.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#ifndef ARRAY_SIZE
#define ARRAY_SIZE 5 /* 命令行未指定时的默认值 */
#endif
int main(void)
{
int array[ARRAY_SIZE];
for (int i = 0; i < ARRAY_SIZE; i++)
array[i] = i;
for (int i = 0; i < ARRAY_SIZE; i++)
printf("%d ", array[i]);
printf("\n");
return 0;
}两种编译方式,实测输出不同:
bash
gcc 05_cmd_define.c -o small # 输出:0 1 2 3 4
gcc -D ARRAY_SIZE=10 05_cmd_define.c -o big # 输出:0 1 2 3 4 5 6 7 8 9十七、条件编译
条件编译让一段代码"参与编译"或"被放弃"。调试代码删了可惜、留着碍事,用它最合适:
📄 06_debug_switch.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int arr[10] = {0};
for (int i = 0; i < 10; i++)
{
arr[i] = i;
#ifdef DEBUG
printf("arr[%d] = %d\n", i, arr[i]); /* 只有定义了 DEBUG 才编译 */
#endif
}
printf("结束(arr[9]=%d)\n", arr[9]);
return 0;
}- 直接编译
gcc 06_debug_switch.c:只输出一行结束(arr[9]=9); - 调试编译
gcc -D DEBUG 06_debug_switch.c:先输出 10 行赋值过程,最后输出结束(arr[9]=9)。
小细节:结尾特意读取了
arr[9]。若完全不读取arr,不带 DEBUG 编译时 gcc 会警告"变量只赋值未使用"——完整程序必须保证两种编译方式都零警告。
常见的条件编译形式:
🧩 条件编译形式(语法模板) · 展示语法结构
c
/* 1. 常量表达式(由预处理器求值) */
#if 常量表达式
#endif
/* 2. 多分支 */
#if 常量表达式
#elif 常量表达式
#else
#endif
/* 3. 判断是否已定义,下面两种写法等价 */
#ifdef SYMBOL /* 等价于 #if defined(SYMBOL) */
#ifndef SYMBOL /* 等价于 #if !defined(SYMBOL) */
/* 4. 嵌套 */
#if defined(OS_UNIX)
#ifdef OPTION1
unix_version_option1();
#endif
#elif defined(OS_MSDOS)
#endif注意:#ifdef 后面直接跟宏名,不能加括号;要加括号就写 #if defined(SYMBOL)。
十八、头文件的包含
18.1 两种包含方式的查找顺序
| 写法 | 查找策略 |
|---|---|
#include "file.h" | 先在当前源文件所在目录查找;找不到,再按标准库路径查找;仍找不到则报错 |
#include <file.h> | 直接在标准库路径查找;找不到则报错 |
常见的标准头文件位置:Linux 下通常是 /usr/include;Windows + VS 下在 VS 安装目录的 VC\include(MinGW 下在其安装目录的 include)。库头文件也可以用 "" 包含,能找到,但多绕一圈,也不利于区分项目文件和库文件。
18.2 头文件为什么要加"保护"
#include 本质是把头文件内容复制插入。同一个头文件被包含 5 次,内容就被插入 5 份,预处理后代码量膨胀,还可能引起重复定义。解决方法是让头文件"只生效一次":
🧩 头文件保护(语法模板) · 头文件写法
c
#ifndef TEST_H
#define TEST_H
/* ……头文件的全部内容,例如: */
void test(void);
struct Stu { int id; };
#endif /* TEST_H */第一次包含时 TEST_H 未定义,内容生效并顺便定义 TEST_H;再次包含时整个块被跳过。另一种写法是编译器普遍支持但不属于 C 标准的 🟡:
c
#pragma once实际项目中两种写法都很常见,许多项目甚至同时写两层保险。
十九、其他预处理指令
还有三条预处理指令,知道名字、用时会查即可:
#error 信息:让预处理直接报错并停止,常用于"不满足配置条件就拒绝编译";#line 行号 "文件名":改写__LINE__、__FILE__的值,多见于代码生成工具;#pragma:留给编译器的扩展指令,如第 14 讲讲过的#pragma pack。
#error 的典型用法在 L3 中演示。
⚠️ 常见坑与报错表
| 现象 / 报错 | 原因 | 对策 |
|---|---|---|
undefined reference to 'Add' | 链接器找不到函数实现(只编译了调用方,或函数名拼写不一致) | 确认定义所在文件参与了链接 |
宏加了分号后 if...else 报语法错误 | 分号被替换进代码,多出空语句 | 宏定义末尾不加分号 |
SQUARE(a+1) 结果不对 | 参数/整体没加括号,优先级被外部运算符改变 | 参数、整体都加括号 |
MAX(x++, y++) 后变量值意外 | 参数带副作用且在宏体出现多次 | 避免对宏参数使用 ++/-- |
| 宏名和括号之间有空格,宏不展开 | #define M (x) ... 把参数表当成了替换文本 | 宏名紧跟 ( |
#ifdef(NAME) 报错 | #ifdef 后只能直接跟标识符 | 改用 #if defined(NAME) |
| 改了头文件,宏行为却没变 | 看的是旧的编译结果,或改错了被优先包含的同名文件 | 重新编译,用 -E 确认实际包含路径 |
🛠️ 动手练习
📊 本讲网页练习进度0 / 8(0%)
进度自动保存在本浏览器;编程题不计数,请在编辑器中完成。
第 1 题(知识点:翻译流程 · 难度:⭐)
一个 C 源文件变成可执行程序,正确的阶段顺序是?
第 2 题(知识点:链接 · 难度:⭐)
test.c 调用了 add.c 中定义的 Add 函数,但单独编译 test.c 时编译器并不知道 Add 的地址。最终由谁、通过什么过程补上真实地址?
第 3 题(知识点:宏的括号 · 难度:⭐⭐)
已知 #define SQUARE(x) x * x,int a = 5;,则 printf("%d", SQUARE(a + 1)); 打印多少?
空①
第 4 题(知识点:副作用参数 · 难度:⭐⭐)
沿用正文中的宏 #define MAX(a,b) ((a)>(b)?(a):(b)),执行 int x=5,y=8,z=MAX(x++,y++); 后,x、y、z 分别是多少?
空①空②空③
第 5 题(知识点:宏与函数对比 · 难度:⭐⭐)
下列关于宏和函数的说法,哪些是正确的?
第 6 题(知识点:头文件查找 · 难度:⭐⭐)
#include "test.h" 和 #include <test.h> 的查找过程有什么区别?
第 7 题(知识点:头文件保护 · 难度:⭐)
头文件开头的 #ifndef / #define 与结尾的 #endif 组合,其作用是什么?
空①
第 8 题(知识点:宏的综合运用 · 难度:⭐⭐⭐)
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
编写一个宏 SWAP(type, x, y),能够交换任意类型的两个变量。
✅ 过关标准:
- 能交换两个
int(如 3 和 7,交换后为 7、3); - 能交换两个
double; - 宏能用在不带花括号的
if后面作为单条语句,且编译零警告。
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
💡 思路(卡住再点开)
宏无法预知变量类型,所以让使用方把类型名也作为参数传进来;宏体内部声明一个同类型的临时变量。关键在第 3 条标准:裸的 { ... } 复合语句在 if 后会因为末尾分号产生问题,需要包装成 do { ... } while (0)。
🧭 步骤
- 宏参数定为
(type, x, y); - 宏体:
do { type tmp = x; x = y; y = tmp; } while (0); - 对
x、y的引用加括号,避免传入复杂表达式时出错; - 分别用
int、double和if单语句场景验证。
🔑 点击查看参考答案
c
#include <stdio.h>
#define SWAP(type, x, y) \
do { \
type tmp = (x); \
(x) = (y); \
(y) = tmp; \
} while (0)
int main(void)
{
int a = 3, b = 7;
double p = 1.5, q = 2.5;
SWAP(int, a, b);
SWAP(double, p, q);
printf("a=%d b=%d\n", a, b);
printf("p=%.1f q=%.1f\n", p, q);
if (a > b)
SWAP(int, a, b); /* 作为单条语句也安全 */
printf("a=%d b=%d\n", a, b);
return 0;
}实测输出:
text
a=7 b=3
p=2.5 q=1.5
a=3 b=7📝 小结与自测
本讲脉络:
- 源代码经历 预处理 → 编译 → 汇编 → 链接 四步才成为可执行程序;
-E/-S/-c可以分别观察前三步。 - 多个
.c文件独立编译,链接器通过符号决议与重定位把跨文件的函数、全局变量引用修正为真实地址。 #define只做文本替换:宏定义不加分号;数值宏的参数与整体都要加括号;警惕++/--这类副作用参数。- 宏无类型、无调用开销、不可调试;函数有类型、可调试、只求值一次——按场景选择。
#字符串化、##记号粘合;条件编译与命令行-D配合实现调试开关;头文件用#ifndef保护防重复包含。
自测三问(先自己说,再对答案):
- 把
.c文件编译成.o的完整命令链路是什么? - 为什么说"宏不严谨"?请从类型检查和副作用两个角度解释。
- 一个头文件被多个
.c文件分别包含,需要保护吗?为什么?
🔑 自测答案
gcc -E a.c -o a.i(预处理)→gcc -S a.i -o a.s(编译)→gcc -c a.s -o a.o(汇编);多个.o再经gcc a.o b.o -o prog链接。- 宏不检查参数类型,任何能套进文本的类型都会被展开,出错往往要到展开后的代码里才暴露;参数若在宏体出现多次,带副作用的实参会被重复求值,产生无法预期的结果。
- 需要注意区分:被同一个
.c文件多次包含(或被多个头文件嵌套重复包含)时,保护能防止内容重复;即使只被每个.c各包含一次,保护宏也不会有副作用,因此一律加上最稳妥。
🔬 选学拓展(L3)
WARNING
以下内容超出基础要求,供学有余力或准备阅读开源项目的同学了解。
拓展 1:为什么多语句宏要包 do { ... } while (0)
宏体包含多条语句时,如果裸写成 { ... },这个场景会出错:
🧩 待改错片段 · 编译会失败
c
#define BAD_SWAP(x, y) { int tmp = (x); (x) = (y); (y) = tmp; }
if (flag)
BAD_SWAP(a, b);
else
printf("不交换\n");展开后 if 的语句是 { ... };——花括号块本身没问题,但它后面的分号构成一条空语句,else 就找不到对应的 if 了。标准写法是 do { ... } while (0):它在语法上是一条语句,末尾恰好需要一个分号,if/else 中使用完全安全。正文习题的 SWAP 就是这个模式。
拓展 2:变参宏——实现带级别的日志
C99 起,宏可以接受可变数量的参数,... 收集到的实参在宏体中用 __VA_ARGS__ 引用:
📄 L3_variadic_log.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#define LOG_INFO(fmt, ...) printf("[INFO] " fmt "\n", __VA_ARGS__)
#define LOG_WARN(fmt, ...) printf("[WARN] " fmt "\n", __VA_ARGS__)
#define LOG_ERROR(fmt, ...) printf("[ERROR] " fmt "\n", __VA_ARGS__)
int main(void)
{
int code = 404;
LOG_INFO("server started, port=%d", 8080);
LOG_WARN("memory usage %.0f%%", 87.5);
LOG_ERROR("request failed, code=%d", code);
return 0;
}实测输出:
text
[INFO] server started, port=8080
[WARN] memory usage 88%
[ERROR] request failed, code=404相邻字符串字面量自动拼接,让 fmt 与 "\n" 合成一个格式串。注意 C11 下 __VA_ARGS__ 至少要有一个实参;"零实参"的合法写法 __VA_OPT__ 要到 C20 才有。
拓展 3:用 #error 给编译器"立规矩"
🧩 #error 用法(示意片段) · 条件满足时编译中止
c
#if !defined(__cplusplus)
#error This library requires a C++ compiler.
#endif当条件成立,预处理直接打印后面的信息并停止编译,避免用错误的编译器/配置编出一堆难懂的后续错误。工程中常用于检测字节序、平台、必需宏是否齐备。
📜 标准卡(L2 选读):预处理指令条款索引
- 标准依据(C11 §6.10):条件包含 §6.10.1;源文件包含 §6.10.2;宏替换 §6.10.3(
#§6.10.3.2、##§6.10.3.3、再扫描 §6.10.3.4、#undef§6.10.3.5);#line§6.10.4;#error§6.10.5;#pragma§6.10.6;预定义宏 §6.10.8。 - gcc:
__STDC__在-std=c11等严格模式下为 1;默认的gnu方言下 MinGW gcc 同样定义为 1,可直接用gcc -dM -E a.c查看全部预定义宏。 - MSVC:
__STDC__定义情况较复杂(默认可能不定义,/Za禁用扩展时定义),跨平台代码中应使用#ifdef __STDC__判断而非假定其值。