从第一个hello.c开始我们几乎每个程序开头都有#include stdio.h。你一直知道它是“引入头文件”但你可能没深想过那个#到底是什么#include和#define又是怎么工作的它们都归属于 C 语言的预处理器——在编译器真正开始编译之前有一个独立的“预处理”阶段对源码进行一系列的文本处理。可以把预处理器想象成一个文字编辑助手它按照你的指令进行查找替换、条件保留、文件拼接最后把一份“干净”的.c文件交给编译器。预处理器是 C 语言极具特色的部分用得好可以让代码更简洁、更灵活用不好会引发各种诡异 bug。今天我们就来全面掌握它。一、回顾编译四阶段中的预处理第三篇我们简要介绍过编译的四个阶段。这里再复习一下预处理在整个流程中的位置源文件 (.c) ↓ [预处理] ← 我们在这里处理 #include、#define、#ifdef 等 ↓ 翻译单元 (纯净的 .i 文件) ↓ [编译] → 汇编代码 (.s) ↓ [汇编] → 目标文件 (.o) ↓ [链接] → 可执行文件预处理阶段做的工作包括展开#include把头文件内容插入替换#define宏处理条件编译指令#if、#ifdef等删除注释处理行标识#line、错误指令#error等最终输出一个“翻译单元”其中不包含任何预处理指令全都是纯 C 代码。你可以用gcc -E亲眼看看预处理结果gcc-Ehello.c-ohello.i打开hello.i你会看到原本的#include stdio.h被替换成了好几千行的内容——那就是stdio.h里嵌套包含的所有声明。二、宏定义#define文本替换的利器1. 简单宏对象式宏#definePI3.14159#defineMAX_STUDENTS100#defineGREETINGHello, World!本质就是文本替换。预处理阶段代码中所有出现PI的地方除了字符串字面量内部都会被原样替换成3.14159。这和我们第四篇讲的常量定义形成了对比#define PI 3.14159const double PI 3.14159;本质文本替换带类型的只读变量内存不占运行时内存占内存类型检查无有可以取地址否是作用域从定义处到文件末尾或#undef块作用域定义宏的注意点宏名通常全大写约定俗成一眼就知道它是宏。不要在末尾加分号#define PI 3.14;会让所有PI被替换成3.14;可能导致2 * PI变成2 * 3.14;这种非法语法。2. 带参宏函数式宏宏也可以有参数像函数一样使用#defineSQUARE(x)((x)*(x))#defineMAX(a,b)((a)(b)?(a):(b))使用intySQUARE(5);// 展开为 ((5) * (5))intmMAX(10,20);// 展开为 ((10) (20) ? (10) : (20))注意这里有一个超级大坑——括号如果你写成#defineSQUARE(x)x*x// 危险没有括号当你写SQUARE(2 3)时它会展开成2 3 * 2 3由于乘法优先级高实际计算的是2 6 3 11而不是预期的25。正确做法给每个参数加括号给整个表达式也加括号#define SQUARE(x) ((x) * (x))。这是写宏的铁律。3. 宏 vs 函数什么时候用宏宏的优点没有函数调用的开销不涉及栈帧、参数复制执行快。没有类型限制同一个宏可以用于int、double等。宏的缺点没有类型检查。多次使用会展开多次代码导致可执行文件变大代码膨胀。难以调试调试器只能看到展开后的代码。参数有副作用时非常危险intx5;intySQUARE(x);// 展开为 ((x) * (x))未定义行为x 被递增了两次经验法则简单的小型操作如取最大最小值、简单的数学计算可以考虑用宏较复杂的逻辑优先用函数。如果函数式宏里参数会被多次使用务必在文档中警告。三、条件编译让代码“随机应变”条件编译让预处理器根据条件决定哪些代码被保留哪些被丢弃。这是编写跨平台代码、调试开关、功能裁剪的利器。1.#ifdef/#ifndef/#endif#ifdefDEBUGprintf(调试信息当前值 %d\n,value);#endif如果DEBUG之前被#define过这行printf会被保留否则会在预处理阶段直接被删除完全不存在于最终的可执行文件里。#ifndef是“如果未定义”。我们一直在头文件防护里用它#ifndefMY_HEADER_H#defineMY_HEADER_H// 头文件内容#endif2.#if/#elif/#else#if可以判断常量表达式只认整型常量#defineVERSION2#ifVERSION1printf(版本 1 的功能\n);#elifVERSION2printf(版本 2 的新功能\n);#elseprintf(未知版本\n);#endif还可以配合defined操作符#ifdefined(DEBUG)VERSION1// ...#endifdefined(DEBUG)等价于#ifdef DEBUG但可以与其他条件组合。3. 典型的应用场景跨平台代码#ifdef_WIN32#includewindows.h#defineCLEAR_SCREENcls#elifdefined(__linux__)||defined(__APPLE__)#includeunistd.h#defineCLEAR_SCREENclear#endifintmain(void){system(CLEAR_SCREEN);// ...}调试开关#ifndefNDEBUG#defineLOG(msg)printf([DEBUG] %s:%d: %s\n,__FILE__,__LINE__,msg)#else#defineLOG(msg)((void)0)// 发布版本中 LOG 变成空操作#endif((void)0)是一个什么也不做的表达式编译器会把它优化掉完全零开销。这样就不需要在发布版本里删除调试语句。四、#和##操作符字符串化和拼接这两个操作符是预处理阶段的功能用于高级宏定义。1.#字符串化Stringizing把宏参数转换成字符串字面量在参数两边加双引号。#defineTO_STRING(x)#xprintf(%s\n,TO_STRING(hello));// 输出 helloprintf(%s\n,TO_STRING(3.14));// 输出 3.14printf(%s\n,TO_STRING(ab));// 输出 a b注意如果参数里本身有空格#会保留。它会自动转义参数中的双引号和反斜杠。2.##标记粘贴Token Pasting把两个标记token粘成一个新的标记。#defineCONCAT(a,b)a##bintxy10;printf(%d\n,CONCAT(x,y));// 展开为 xy输出 10intvalue_1100,value_2200;printf(%d\n,CONCAT(value_,2));// 展开为 value_2输出 200这常用于自动生成变量名或函数名。比如在状态机里#defineSTATE(name)state_##nameenum{STATE(start),STATE(running),STATE(stopped)};// 展开为: enum { state_start, state_running, state_stopped };#和##在复杂的宏定义中非常有用但可读性会下降。只在确实能简化代码时使用不要让宏变成“魔法咒语”。五、预定义宏编译器自带的“身份证”C 标准规定了一些预定义宏所有编译器的预处理阶段都自动定义不需要你#define它们提供当前编译的源文件信息。常用的有宏含义示例值__FILE__当前源文件名字符串main.c__LINE__当前行号整数42__DATE__编译日期“Mmm dd yyyy”Jan 15 2025__TIME__编译时间“hh:mm:ss”14:30:00__STDC__如果编译器符合 ANSI C 标准定义为 11__STDC_VERSION__C 标准版本号201112LC11这些宏在调试和日志中非常有用#includestdio.hintmain(void){printf(文件: %s\n,__FILE__);printf(行号: %d\n,__LINE__);printf(编译日期: %s\n,__DATE__);printf(编译时间: %s\n,__TIME__);if(__STDC_VERSION__201112L){printf(当前使用 C11 或更高版本\n);}return0;}结合条件编译你可以写出兼容多版本 C 标准的代码。六、常见错误与陷阱1. 宏定义末尾写分号#defineMAX100;// 在代码中: if (x MAX) → if (x 100;) 语法错误2. 函数式宏不包裹括号这个坑必须刻进脑子里#defineMULTIPLY(a,b)a*b// MULTIPLY(23, 45) → 23*45 19不是 453. 宏参数有副作用#defineMAX(a,b)((a)(b)?(a):(b))intx5,y10;intzMAX(x,y);// 展开: ((x) (y) ? (x) : (y))x 被递增两次4. 宏定义中的类型不安全#defineSQUARE(x)((x)*(x))SQUARE(3.14);// 可以double 没问题SQUARE(hello);// 编译错误但错误信息指向展开后的代码难以定位5. 条件编译里用运行时变量intversion2;#ifversion2// 错误#if 只能处理编译时常量#if发生在预处理阶段此时没有任何变量存在。version会被当成未定义的宏替换为 0。七、小结预处理器是 C 语言给你的“编译前文本处理工具箱”#define定义宏做文本替换。函数式宏高效但需要加括号防副作用。条件编译#ifdef、#if、#else等让代码可以在不同条件下裁剪是跨平台和调试开关的基础。#和##字符串化和标记粘贴用于高级宏技巧。预定义宏__FILE__、__LINE__、__DATE__等提供编译期信息是日志和断言的得力助手。宏是“双刃剑”——它在极简的表象下藏着陷阱。写宏时心里默念括号括号还是括号能用const或函数替代时优先不用宏。下一篇我们将把这些知识融会贯通学习如何编写可移植的头文件与模块——处理平台差异的条件编译、防止重复包含的最佳实践、以及把一个中等规模项目组织得井井有条的方法。课后小练习编写一个带参宏CUBE(x)计算x的立方。测试CUBE(23)是否输出 125如果不是修正你的宏。用条件编译实现一个程序如果定义了ENGLISH输出Hello如果定义了FRENCH输出Bonjour如果什么都没定义输出你好。通过修改#define来切换语言。用__FILE__和__LINE__实现一个调试宏PRINT_HERE调用它时打印当前文件名和行号。然后再写一个LOG(fmt, ...)宏使用可变参数宏...和__VA_ARGS__输出带文件名、行号的格式化日志。小挑战分析下面这段宏有什么问题并给出正确的写法#defineSWAP(a,b){inttempa;ab;btemp;}// 在 if-else 中使用:if(xy)SWAP(x,y);elseprintf(ok\n);我们下期见获取本系列示例代码请访问 GitCode 仓库。
26. 【C语言】编译前的“文本大师”:预处理器指令
从第一个hello.c开始我们几乎每个程序开头都有#include stdio.h。你一直知道它是“引入头文件”但你可能没深想过那个#到底是什么#include和#define又是怎么工作的它们都归属于 C 语言的预处理器——在编译器真正开始编译之前有一个独立的“预处理”阶段对源码进行一系列的文本处理。可以把预处理器想象成一个文字编辑助手它按照你的指令进行查找替换、条件保留、文件拼接最后把一份“干净”的.c文件交给编译器。预处理器是 C 语言极具特色的部分用得好可以让代码更简洁、更灵活用不好会引发各种诡异 bug。今天我们就来全面掌握它。一、回顾编译四阶段中的预处理第三篇我们简要介绍过编译的四个阶段。这里再复习一下预处理在整个流程中的位置源文件 (.c) ↓ [预处理] ← 我们在这里处理 #include、#define、#ifdef 等 ↓ 翻译单元 (纯净的 .i 文件) ↓ [编译] → 汇编代码 (.s) ↓ [汇编] → 目标文件 (.o) ↓ [链接] → 可执行文件预处理阶段做的工作包括展开#include把头文件内容插入替换#define宏处理条件编译指令#if、#ifdef等删除注释处理行标识#line、错误指令#error等最终输出一个“翻译单元”其中不包含任何预处理指令全都是纯 C 代码。你可以用gcc -E亲眼看看预处理结果gcc-Ehello.c-ohello.i打开hello.i你会看到原本的#include stdio.h被替换成了好几千行的内容——那就是stdio.h里嵌套包含的所有声明。二、宏定义#define文本替换的利器1. 简单宏对象式宏#definePI3.14159#defineMAX_STUDENTS100#defineGREETINGHello, World!本质就是文本替换。预处理阶段代码中所有出现PI的地方除了字符串字面量内部都会被原样替换成3.14159。这和我们第四篇讲的常量定义形成了对比#define PI 3.14159const double PI 3.14159;本质文本替换带类型的只读变量内存不占运行时内存占内存类型检查无有可以取地址否是作用域从定义处到文件末尾或#undef块作用域定义宏的注意点宏名通常全大写约定俗成一眼就知道它是宏。不要在末尾加分号#define PI 3.14;会让所有PI被替换成3.14;可能导致2 * PI变成2 * 3.14;这种非法语法。2. 带参宏函数式宏宏也可以有参数像函数一样使用#defineSQUARE(x)((x)*(x))#defineMAX(a,b)((a)(b)?(a):(b))使用intySQUARE(5);// 展开为 ((5) * (5))intmMAX(10,20);// 展开为 ((10) (20) ? (10) : (20))注意这里有一个超级大坑——括号如果你写成#defineSQUARE(x)x*x// 危险没有括号当你写SQUARE(2 3)时它会展开成2 3 * 2 3由于乘法优先级高实际计算的是2 6 3 11而不是预期的25。正确做法给每个参数加括号给整个表达式也加括号#define SQUARE(x) ((x) * (x))。这是写宏的铁律。3. 宏 vs 函数什么时候用宏宏的优点没有函数调用的开销不涉及栈帧、参数复制执行快。没有类型限制同一个宏可以用于int、double等。宏的缺点没有类型检查。多次使用会展开多次代码导致可执行文件变大代码膨胀。难以调试调试器只能看到展开后的代码。参数有副作用时非常危险intx5;intySQUARE(x);// 展开为 ((x) * (x))未定义行为x 被递增了两次经验法则简单的小型操作如取最大最小值、简单的数学计算可以考虑用宏较复杂的逻辑优先用函数。如果函数式宏里参数会被多次使用务必在文档中警告。三、条件编译让代码“随机应变”条件编译让预处理器根据条件决定哪些代码被保留哪些被丢弃。这是编写跨平台代码、调试开关、功能裁剪的利器。1.#ifdef/#ifndef/#endif#ifdefDEBUGprintf(调试信息当前值 %d\n,value);#endif如果DEBUG之前被#define过这行printf会被保留否则会在预处理阶段直接被删除完全不存在于最终的可执行文件里。#ifndef是“如果未定义”。我们一直在头文件防护里用它#ifndefMY_HEADER_H#defineMY_HEADER_H// 头文件内容#endif2.#if/#elif/#else#if可以判断常量表达式只认整型常量#defineVERSION2#ifVERSION1printf(版本 1 的功能\n);#elifVERSION2printf(版本 2 的新功能\n);#elseprintf(未知版本\n);#endif还可以配合defined操作符#ifdefined(DEBUG)VERSION1// ...#endifdefined(DEBUG)等价于#ifdef DEBUG但可以与其他条件组合。3. 典型的应用场景跨平台代码#ifdef_WIN32#includewindows.h#defineCLEAR_SCREENcls#elifdefined(__linux__)||defined(__APPLE__)#includeunistd.h#defineCLEAR_SCREENclear#endifintmain(void){system(CLEAR_SCREEN);// ...}调试开关#ifndefNDEBUG#defineLOG(msg)printf([DEBUG] %s:%d: %s\n,__FILE__,__LINE__,msg)#else#defineLOG(msg)((void)0)// 发布版本中 LOG 变成空操作#endif((void)0)是一个什么也不做的表达式编译器会把它优化掉完全零开销。这样就不需要在发布版本里删除调试语句。四、#和##操作符字符串化和拼接这两个操作符是预处理阶段的功能用于高级宏定义。1.#字符串化Stringizing把宏参数转换成字符串字面量在参数两边加双引号。#defineTO_STRING(x)#xprintf(%s\n,TO_STRING(hello));// 输出 helloprintf(%s\n,TO_STRING(3.14));// 输出 3.14printf(%s\n,TO_STRING(ab));// 输出 a b注意如果参数里本身有空格#会保留。它会自动转义参数中的双引号和反斜杠。2.##标记粘贴Token Pasting把两个标记token粘成一个新的标记。#defineCONCAT(a,b)a##bintxy10;printf(%d\n,CONCAT(x,y));// 展开为 xy输出 10intvalue_1100,value_2200;printf(%d\n,CONCAT(value_,2));// 展开为 value_2输出 200这常用于自动生成变量名或函数名。比如在状态机里#defineSTATE(name)state_##nameenum{STATE(start),STATE(running),STATE(stopped)};// 展开为: enum { state_start, state_running, state_stopped };#和##在复杂的宏定义中非常有用但可读性会下降。只在确实能简化代码时使用不要让宏变成“魔法咒语”。五、预定义宏编译器自带的“身份证”C 标准规定了一些预定义宏所有编译器的预处理阶段都自动定义不需要你#define它们提供当前编译的源文件信息。常用的有宏含义示例值__FILE__当前源文件名字符串main.c__LINE__当前行号整数42__DATE__编译日期“Mmm dd yyyy”Jan 15 2025__TIME__编译时间“hh:mm:ss”14:30:00__STDC__如果编译器符合 ANSI C 标准定义为 11__STDC_VERSION__C 标准版本号201112LC11这些宏在调试和日志中非常有用#includestdio.hintmain(void){printf(文件: %s\n,__FILE__);printf(行号: %d\n,__LINE__);printf(编译日期: %s\n,__DATE__);printf(编译时间: %s\n,__TIME__);if(__STDC_VERSION__201112L){printf(当前使用 C11 或更高版本\n);}return0;}结合条件编译你可以写出兼容多版本 C 标准的代码。六、常见错误与陷阱1. 宏定义末尾写分号#defineMAX100;// 在代码中: if (x MAX) → if (x 100;) 语法错误2. 函数式宏不包裹括号这个坑必须刻进脑子里#defineMULTIPLY(a,b)a*b// MULTIPLY(23, 45) → 23*45 19不是 453. 宏参数有副作用#defineMAX(a,b)((a)(b)?(a):(b))intx5,y10;intzMAX(x,y);// 展开: ((x) (y) ? (x) : (y))x 被递增两次4. 宏定义中的类型不安全#defineSQUARE(x)((x)*(x))SQUARE(3.14);// 可以double 没问题SQUARE(hello);// 编译错误但错误信息指向展开后的代码难以定位5. 条件编译里用运行时变量intversion2;#ifversion2// 错误#if 只能处理编译时常量#if发生在预处理阶段此时没有任何变量存在。version会被当成未定义的宏替换为 0。七、小结预处理器是 C 语言给你的“编译前文本处理工具箱”#define定义宏做文本替换。函数式宏高效但需要加括号防副作用。条件编译#ifdef、#if、#else等让代码可以在不同条件下裁剪是跨平台和调试开关的基础。#和##字符串化和标记粘贴用于高级宏技巧。预定义宏__FILE__、__LINE__、__DATE__等提供编译期信息是日志和断言的得力助手。宏是“双刃剑”——它在极简的表象下藏着陷阱。写宏时心里默念括号括号还是括号能用const或函数替代时优先不用宏。下一篇我们将把这些知识融会贯通学习如何编写可移植的头文件与模块——处理平台差异的条件编译、防止重复包含的最佳实践、以及把一个中等规模项目组织得井井有条的方法。课后小练习编写一个带参宏CUBE(x)计算x的立方。测试CUBE(23)是否输出 125如果不是修正你的宏。用条件编译实现一个程序如果定义了ENGLISH输出Hello如果定义了FRENCH输出Bonjour如果什么都没定义输出你好。通过修改#define来切换语言。用__FILE__和__LINE__实现一个调试宏PRINT_HERE调用它时打印当前文件名和行号。然后再写一个LOG(fmt, ...)宏使用可变参数宏...和__VA_ARGS__输出带文件名、行号的格式化日志。小挑战分析下面这段宏有什么问题并给出正确的写法#defineSWAP(a,b){inttempa;ab;btemp;}// 在 if-else 中使用:if(xy)SWAP(x,y);elseprintf(ok\n);我们下期见获取本系列示例代码请访问 GitCode 仓库。