C语言宏嵌套展开规则详解:从参数展开到FOR_EACH实现
上周我在给一个跨平台通信模块补版本打印宏写了几层带参数的宏结果预处理后的输出完全出乎意料。VERSION应该变成1.3.2实际却变成了VERSION。我盯着预处理文件看了半天才反应过来这不是字符串拼接问题而是 C 语言宏嵌套的展开规则在起作用。如果你也写过稍微复杂的宏大概率撞过类似场面明明定义了#define A 1结果宏展开后看到的还是字母A明明想通过##把两个宏值拼起来却得到一个大写的原始标识符又或者你写了一个递归宏居然没有死循环而是安静地输出了一个你没想到的结果。这些现象基本都能用“参数展开的时机”和“重扫描中的自我引用抑制”来解释清楚。这篇文章我会从预处理器的执行机制开始把宏嵌套展开的规则拆开讲明白。适合正在被复杂宏折磨的嵌入式开发者、写框架代码的 C/C 程序员以及所有想搞懂#和##为什么经常“不听话”的人。最后还会给一个完整的 FOR_EACH 宏实现带逐步展开过程让你能直接抄作业。1. 宏展开的核心机制先弄清楚四件事1.1 预处理器的“三段论”扫描、替换、重新扫描很多人对宏的认知停留在“宏就是文本替换”。严格说预处理器是这么工作的先对输入的预处理记号流从左到右扫描。每遇到一个名字会判断它是否是一个已定义的宏。如果是对象宏直接替换成宏体如果是类函数宏则要看它后面是否紧跟着一个左括号(。只有紧跟着左括号类函数宏才会被当成宏调用否则这个名字就只是个普通标识符原样保留。替换完成后预处理器还会对替换结果再做一轮扫描重新查找新的宏调用。这个过程叫“重新扫描”。重新扫描不是一次就结束的而是反复进行直到某个替换结果里没有新的宏名可展开或者遇到了下面要说的“自我引用保护”。这里有个值得记住的例子#define FOO BAR #define BAR FOO FOO先展开FOO成BAR重扫描时发现BAR是宏展开成FOO。这时FOO正处于“正在被展开”的状态所以不会再展开最终输出就是FOO。如果把FOO和BAR换成A和B结果同理并不会像你想的那样进入无限循环。很多新手会误以为预处理器会像函数递归那样一层层算下去实际上它对“同一个宏名在同一轮展开过程中再次出现”是有严格抑制的。1.2 类函数宏与对象宏的差别括号决定一切对象宏很简单名字替换成值即可。类函数宏要复杂得多因为它有参数。宏参数不是变量而是一段预处理记号序列这段序列会被原封不动地填进宏体里对应参数的位置。类函数宏的关键在于名字后面必须紧跟(。这解释了为什么FE_4、FE_3这种函数宏名可以被当作普通参数传来传去而不展开因为传参的时候后面是逗号不是左括号。我见过有人写这样的代码#define CALL_FN(fn) fn() #define hello() printf(hello\n) CALL_FN(hello)hello传入CALL_FN时因为后面没有左括号不会立即展开。等替换成hello()后重新扫描时它才真正变成宏调用。这其实就能算是一种最简单的“延迟展开”。1.3 自我引用保护为什么递归宏不死循环标准里对自我引用的描述是如果一个宏的名字在展开过程中再次出现它不会被再次展开。GCC 文档里把它叫作“蓝色颜料”blue paint意思是在展开过程中给这个宏名“刷上颜色”后续重扫描看到带颜色的名字就跳过。这个机制在写递归式宏时特别容易踩坑。比如#define SELF SELF SELF输出只有一个SELF不会无限递归。再看一个套路的例子#define NEXT(x) x NEXT NEXT(1)NEXT(1)展开成1 NEXT重扫描时NEXT后面没有左括号所以不展开最终结果是1 NEXT。要是你本意是想让NEXT继续展开成某个值就会得到一个没展开的残留标识符。这也是为什么很多复杂宏需要借助多层包装和EVAL辅助宏来“强制”展开因为单靠一次替换宏名一旦被抑制就停了。理解了这一点再看那些靠多层EVAL实现的计数宏、循环宏就不会觉得是玄学了。2. 宏嵌套展开规则参数先展开但有两个例外2.1 绝大多数情况下参数先被完全展开这是宏嵌套展开里最重要的一条规则当类函数宏被调用时它的每个参数会先进行完整的宏展开替换进宏体之前参数已经被“处理干净”了。标准里有个说法意思是每个参数在替换前会被当作“整个预处理文件”的其余部分一样单独做一次完整的宏展开。也就是说参数内部如果有嵌套的宏调用会一层层展开完然后才作为实参替换到宏体中。举个例子#define F(x) x 1 F(F(1))内层的F(1)是外层的参数。按照规则外层宏的参数F(1)会先被完全展开F(1)展开成1 1然后再替换进外层的宏体里最终得到1 1 1如果参数本身又是对象宏#define A 100 #define F(x) x 1 F(A)A作为参数会先展开成100然后替换结果是100 1。这条规则在多层嵌套时非常反直觉。很多人以为预处理器会像普通编译器那样“先处理外层遇到内层再说”。实际上大多数情况下是“先处理参数再处理外层”。换句话说从外层的视角看参数在进来之前已经把自己内部的宏都展开完了。2.2 例外一#字符串化会“挡住”参数展开如果宏体内某个参数前面紧跟着#那么这个参数不会被展开而是直接作为原始记号序列转成字符串字面量。这是宏嵌套里最常见的翻车点。看这个经典例子#define STR(x) #x #define VERSION 1.3.2 STR(VERSION)你可能会以为结果是1.3.2但实际结果是VERSION。因为STR的参数VERSION处于#后面预处理器根本不去展开它直接把它当原始 token 用了。解决办法是加一层不带#的包装宏#define STR(x) #x #define XSTR(x) STR(x) #define VERSION 1.3.2 XSTR(VERSION)XSTR的参数VERSION不在#后面所以会先展开成1.3.2然后替换成STR(1.3.2)再经过重新扫描最终得到1.3.2。这种“外层先展开参数内层再做字符串化”的两级嵌套写法在打印版本号、生成错误码字符串、构造文件名等场景下非常常用。2.3 例外二##token 粘贴会“挡住”参数展开##的作用是把左右两个预处理记号拼成一个新的记号。和#类似##两侧的参数也不会先展开。#define CAT(a, b) a##b #define L 100 #define R 200 CAT(L, R)结果是LR不是100200。因为L和R都紧挨着##参数阶段不展开直接进行拼接。想要先展开再拼接同样需要中间加一层#define CAT(a, b) a##b #define XCAT(a, b) CAT(a, b) #define L 100 #define R 200 XCAT(L, R)XCAT的参数L、R不在##旁先展开成100、200然后变成CAT(100, 200)继续展开最终得到100200。注意这里的100200是一个单独的预处理记号不是字符串也不是算术结果。##做的是记号拼接不是数值运算。更微妙的是同一个参数在宏体里可能出现多次有的地方被#挡有的地方正常展开。比如#define f(x) #x x #define N 123 f(N)第一个#x不展开结果是N第二个x正常展开结果是123。所以最终输出是N 123这种同一个参数不同表现的行为最能提醒你宏展开规则不是简单的“替换一下就完事”。2.4 二级宏打破例外的标准解法把XSTR和XCAT放在一起看思路完全一致想要让参数先展开就别让参数直接接触#或##而是让参数先传给一个普通宏由这个普通宏展开参数后再把展开结果的宏名传给真正干活的内层宏。简单总结如下宏定义调用示例输出原因#define STR(x) #xSTR(FOO)FOO#挡住了参数展开#define XSTR(x) STR(x)XSTR(FOO)42外层先展开参数内层再字符串化#define CAT(a,b) a##bCAT(L,R)LR##挡住了参数展开#define XCAT(a,b) CAT(a,b)XCAT(L,R)100200外层先展开参数内层再拼接这条“二级宏”套路基本是所有复杂宏的基础设施。看不懂别人代码里的XSTR、XCAT先往这个方向想就对了。3. 嵌套展开规则实战自己写一个 FOR_EACH 宏3.1 需求与总体设计纸上谈兵没用我们来写一个真实的宏。目标实现FE(F, a, b, c)让它展开成F(a) F(b) F(c)。也就是说传入一个函数宏F再传入一串参数宏自动把每个参数分别套上一层F。这类宏在工程里很常见比如批量注册事件处理函数、批量生成结构体字段的访问宏、批量生成日志打印函数等。如果手写每个F(a)、F(b)一旦参数多就很啰嗦。为什么不用普通函数做因为这是在编译期展开的不产生运行时代码也不占用栈适合替代一部分手写重复代码。代价就是宏本身复杂可读性差需要注释。设计思路是这样的按参数个数选择执行第几层展开每层处理一个参数然后递归处理剩余参数。支持最多 3 个参数你可以按需扩展更多层。#define GET_3(_0, _1, _2, NAME, ...) NAME #define FE_1(F, x) F(x) #define FE_2(F, x, ...) F(x) FE_NAME(__VA_ARGS__)(F, __VA_ARGS__) #define FE_3(F, x, ...) F(x) FE_NAME(__VA_ARGS__)(F, __VA_ARGS__) #define FE_NAME(...) GET_3(__VA_ARGS__, FE_3, FE_2, FE_1) #define FE(F, ...) FE_NAME(__VA_ARGS__)(F, __VA_ARGS__)测试宏#define TOKEN(x) [x] FE(TOKEN, a, b, c)最终展开出来就是[a] [b] [c]3.2 代码与分步展开过程很多人看到这种宏直接蒙我们来手动拆一遍FE(TOKEN, a, b, c)的展开过程看看嵌套规则是怎么一层层生效的。第一步外部调用FE(TOKEN, a, b, c)FE(TOKEN, a, b, c) - FE_NAME(a, b, c)(TOKEN, a, b, c)FE宏体是FE_NAME(__VA_ARGS__)(F, __VA_ARGS__)。它的参数TOKEN、a、b、c不在#或##旁边所以先做参数展开。TOKEN是类函数宏但后面没有紧跟着(不会展开。a、b、c如果没有定义成宏原样保留。替换后得到FE_NAME(a, b, c)(TOKEN, a, b, c)。第二步重扫描时发现FE_NAME(a, b, c)是宏调用FE_NAME(a, b, c) - GET_3(a, b, c, FE_3, FE_2, FE_1)FE_NAME的参数是a, b, c传入GET_3后_0a_1b_2c第四个参数NAMEFE_3剩下的可变参数是FE_2, FE_1。所以GET_3返回FE_3。这里就是嵌套规则的关键GET_3并不认识FE_3是什么它只是把第四个参数作为结果返回。而FE_3是类函数宏名后面没有左括号所以在参数展开阶段不会被展开直到后续与左括号相遇。第三步继续重扫描FE_3(TOKEN, a, b, c) - TOKEN(a) FE_NAME(b, c)(TOKEN, b, c) - [a] FE_2(TOKEN, b, c)FE_3展开时先处理当前第一个元素a生成TOKEN(a)再对剩余参数b, c用FE_NAME重新选择下一层。FE_NAME(b, c)让GET_3选中FE_2。第四步FE_2(TOKEN, b, c) - TOKEN(b) FE_NAME(c)(TOKEN, c) - [b] FE_1(TOKEN, c)FE_NAME(c)让GET_3选中FE_1。第五步FE_1(TOKEN, c) - TOKEN(c) - [c]最后拼起来就是[a] [b] [c]目标达成。3.3 代码里那些“看不见”的规则在起作用这段宏虽然不长但把前面讲到的规则全都用上了第一参数先展开。FE接收的a、b、c如果本身是宏会在进入FE时先展开。所以如果你传FE(TOKEN, A, B)而A、B是对象宏结果是先展开A、B再套TOKEN。第二FE_3、FE_2、FE_1作为函数宏名传进GET_3时不会提前展开因为后面没有左括号等到选择结果和后面的括号相遇时才触发调用。这正好体现了类函数宏“名字加左括号才是调用”的规则。第三每次递归都重新调用FE_NAME选择下一层而不是写死下一层编号。这是为了处理参数数量递减的问题。如果不这么干固定调用下一层很容易在只剩一个元素时多生成一个FE_1(F,)空参数调用展开结果里会多出一个F()那个是很多人写 FOR_EACH 宏翻车的典型原因。第四GET_3的返回结果会和后面的(F, __VA_ARGS__)重新组成宏调用。这个“替换结果与后续 token 重新组合”的机制是宏展开时最容易忽略、也最有用的能力。4. 嵌套宏常见问题与排查实录4.1 问题速查表把常见的坑整理成一张表以后遇到问题直接对号入座。症状原因对策STR(FOO)得到FOO而不是42#后面的参数不展开加一层XSTR(x) STR(x)包装CAT(L, R)得到LR而不是100200##两侧参数不展开加一层XCAT(a,b) CAT(a,b)包装FOR_EACH 宏多出一个空F()递归层数固定剩余参数个数不足用FE_NAME按剩余参数数量重新选层宏展开结果里保留着NEXT、FOO等名字自我引用被抑制或类函数宏后面没有括号确认后是否紧跟(必要时用EVAL多层强制展开换一个编译器后输出不一样宏展开细节依赖实现避免依赖未定义行为优先用标准规则可变参数为空时宏体残留下,或异常 token__VA_ARGS__为空时逗号不消失C23 可用__VA_OPT__或要求至少传一个参数4.2 用预处理输出定位问题排查宏嵌套最常用的手段是查看预处理结果。GCC 和 Clang 都很方便gcc -E -P test.c -o test.i clang -E -P test.c -o test.i-E让编译器只做预处理-P去掉行标记输出更干净。拿到test.i之后直接看宏被展开成什么样就知道是哪一层出了问题。还有一个好用的方式是用-dMgcc -E -dM test.c这个命令会把当前所有宏定义列出来能帮你确认宏是否定义正确名字有没有写错。如果宏太多看不下去我的习惯是“二分注释”先把最外层宏注掉看内层单独展开是否正确再一层层打开逐步缩小范围。这个方法对多层嵌套特别有效。4.3 三条调试经验第一给宏层数编号。写递归宏时用FE_1、FE_2、FE_3这种编号命名每一层。看到名字就能知道当下处理到第几层排查效率高很多。第二在每一层临时插入“哨兵 token”辅助定位。比如临时把宏体改成L2_xxx展开后你能看到哪个位置生成了哨兵就知道哪一层没按预期执行。定位之后再删掉哨兵。第三不要相信眼睛要相信gcc -E的输出。宏展开很多问题看起来像思维推理题实际上你只要把预处理结果打印出来看一眼一切就清楚了。与其在脑子里模拟十层展开不如直接让编译器告诉你答案。5. 使用嵌套宏的一些经验总结5.1 什么时候该用嵌套宏嵌套宏在编译期做代码生成、批量定义、日志模板这类场景非常有用运行时代价为零。但它也有明显的缺点可读性差、调试困难、对新人极不友好。我的原则是如果这个宏展开规则比较复杂调用方又不止一个那必须配详细注释说明它输入什么、输出什么最好直接附上gcc -E展开后的结果示例。否则三个月后你回来看代码大概率自己也看不懂。5.2 怎么让嵌套宏能维护一些实际的工程建议第一宏命名加统一前缀避免污染全局命名空间。比如FE_、XCAT_、XSTR_这种。你不希望某个普通的GET_3和别人代码里的GET_3撞上。第二一层宏只干一件事。负责选择的就只管选择负责拼接的就只管拼接负责字符串化的就只管字符串化不要混在一起。第三凡是涉及字符串化或 token 拼接的宏一律配一个不带#/##的二级包装宏。这是标准写法能避免绝大多数“参数没展开”的意外。第四复杂宏段落最好配上 static assert 或单元测试。宏本身没法调用但你可以用_Static_assert去验证展开结果是否满足某个编译期条件或者干脆在 CI 脚本里加上一条命令比较gcc -E的输出与期望的结果文件是否一致。5.3 我的个人体会说句实话宏嵌套展开规则并不难难的是不为写而写。我见过不少人把宏玩出花来结果一个小改动要调半天团队成员看完代码一脸茫然。它和普通代码一样核心原则是“低耦合、高内聚、好维护”。我自己每次写完一个带嵌套的宏都会顺手留一个注释块把关键的展开步骤写成和本文第 3 节那样的推演过程。下次改代码或者同事来问直接看注释不用再从头推理一遍。这个习惯帮我省了很多沟通成本也避免过好几次改坏宏的问题。如果你现在正被某个宏嵌套搞到头大建议先别急着猜打开gcc -E看一眼输出再回到本文的规则找到对应现象基本一次就能定位。