嵌入式高手都在偷偷用的“第6条”:两个符号 # 和 ## ,让编译器帮你拼出寄存器名和变量名

嵌入式高手都在偷偷用的“第6条”:两个符号 # 和 ## ,让编译器帮你拼出寄存器名和变量名
该文章同步至OneChan你有没有经历过需要访问一串编号连续的寄存器比如TIM2-CCR1、TIM2-CCR2……明知道名字规律明显却只能一个一个手写改一处就得全部重敲一遍这是资深工程师压箱底的编程技巧系列第六篇。前面我们聊了编译期断言、X-Macro、do{...}while(0)安全包装、编译期常量分流、_Generic类型重载。今天这一招让预处理器成为你的代码生成器用两个极其简单的符号自动拼出变量名、寄存器地址甚至函数名。它就是 C 预处理器中最容易被忽视、却又最能提升编码效率的组合#和##—— 字符串化与参数粘贴。很多人只在教科书上见过它们真正写代码时却从来想不起来用。但资深工程师的代码里这两个符号几乎无处不在。今天我们就一起把它变成你的肌肉记忆。一、这两个东西到底是干什么用的简单说#是“字符串化运算符”它把宏参数原封不动地转成一个字符串字面量。##是“标记粘贴运算符”它把左右两边的记号拼成一个新的合法 C 语言记号可以是变量名、类型名、函数名等。二者都发生在预处理阶段生成的代码在编译器看来和其他手写代码没有任何区别零运行时开销零额外体积。在嵌入式领域我们常常需要根据外设基地址和寄存器名拼出完整的寄存器访问宏用同一个宏生成结构体成员名、数组名、函数名在调试打印里同时输出变量名和它的值而不用重复写两次。这些需求如果纯靠手写既繁琐又容易出错但若让预处理器来拼接就变成了“只写规则让编译器干活”。二、上硬菜直接看怎么用Step 1#的基础——把变量名变成字符串假如你要写一个调试宏打印某个变量的名字和值#definePRINT_VAR(var)printf(#var %d\n,var)调用intengine_temp85;PRINT_VAR(engine_temp);预处理后变成printf(engine_temp %d\n,engine_temp);相邻的字符串字面量在 C 里会自动拼接最终输出engine_temp 85你只写了一次变量名打印信息却自动同步了。以后修改变量名字符串也会跟着变永远一致。Step 2##的基础——拼出变量名或寄存器名再看粘贴运算符。假设我们有一组定时器通道的捕获寄存器名字是CCR1、CCR2、CCR3、CCR4你不想每次手动写#defineTIM_CH_CCR(TIM,CH)(TIM)-CCR##CH调用TIM_CH_CCR(TIM2, 3)就展开为(TIM2)-CCR3。更进一步我们可以用宏生成完整的寄存器访问函数或配置表。例如很多 STM32 的 GPIO 寄存器命名是GPIOA-BSRR、GPIOB-BSRR你可以写一个宏根据引脚组和寄存器名拼出完整访问路径#defineGPIO_REG(PORT,REG)(GPIO##PORT)-REGGPIO_REG(A, BSRR)展开为(GPIOA)-BSRR。这种组合意味着你可以用循环宏、递归宏或 X-Macro 批量生成任意多外设的初始化代码而不需要为每个外设手写一遍。Step 3高级技巧——同时字符串化和粘贴生成完整的调试表下面这个例子综合了#和##并借助 X-Macro 的思想生成一个“引脚配置表”同时输出枚举名和字符串// 定义引脚列表#defineIO_PINS\X(LED_RED,PORTA,5)\X(LED_GREEN,PORTA,6)\X(BUZZER,PORTB,1)// 生成枚举#defineX(name,port,pin)PIN_##name,typedefenum{IO_PINS}PinID_t;#undefX// 生成初始化代码#defineX(name,port,pin)\GPIO_Init(port,pin);voidInitAllPins(void){IO_PINS}#undefX// 生成名称字符串表用##defineX(name,port,pin)#name,constchar*PinNames[]{IO_PINS};#undefX一张表IO_PINS维护了所有引脚信息##负责拼出枚举成员PIN_LED_RED等#负责把LED_RED变成字符串LED_RED。你增加一个引脚只需要在表里加一行所有相关代码自动同步。三、举一反三这些玩法你还真没见过1. 利用##构造回调函数名实现“约定优于配置”在一些模块化驱动里我们可以规定回调函数的命名格式是OnModuleEvent然后用##来自动生成函数调用#defineMODULE_CALLBACK(module,event)On##module##event// 调用 MODULE_CALLBACK(Usart1, RxComplete)();// 展开为 OnUsart1RxComplete();这样只要你遵循命名约定写驱动时就可以直接通过模块和事件名拼出回调完全不必维护庞大的函数指针表。代码干净跳转查找也容易。2. 字符串化结合__LINE__做唯一变量名有时在一个宏里需要临时变量又怕和外部的变量名冲突。我们可以利用##和__LINE__生成唯一的名字#defineUNIQUE_VAR(prefix)prefix##__LINE__// 展开为 tmp123取决于行号在宏里写int UNIQUE_VAR(tmp) 0;就能得到int tmp42 0;基本避免了同名冲突。3. 粘贴出外设寄存器的位域名称很多 MCU 的头文件里控制寄存器的位域被定义成了类似USART_CR1_TE、USART_CR1_RE这样的宏。我们可以用一个宏自动组合模块、寄存器和位域#defineREG_BIT(periph,reg,bit)periph##_##reg##_##bit// 调用 REG_BIT(USART, CR1, TE) → USART_CR1_TE这样当你需要对某个寄存器连续置位时可以用循环或列表来批量操作不再需要一个个手敲又长又容易拼错的宏名。4. 字符串化用来生成版本信息利用#把编译时的宏定义转成字符串嵌入到固件的版本字符串里#defineSTRINGIFY(x)#x#defineTOSTRING(x)STRINGIFY(x)#defineFW_VERSION_STRvTOSTRING(FW_MAJOR).TOSTRING(FW_MINOR)// 如果 FW_MAJOR1, FW_MINOR4 → v1.4这里用了两层宏STRINGIFY和TOSTRING是为了确保先展开宏参数再进行字符串化不然你会得到FW_MAJOR而不是1。这个细节很多初级工程师会踩坑。四、留两个问题给你思考现在请你停下来推演一下##能不能用来拼接两个字符串字面量比如hello ## world变成helloworld为什么如果不能怎么正确拼接字符串你在一个宏里写#define CONCAT(a,b) a##b调用CONCAT(3.14, 15)想得到变量名3.1415可以吗为什么粘贴运算符对操作数的类型有什么要求想清楚了你对预处理器的工作原理理解就会达到底层原理级别。五、总结与思考题回答核心总结#的作用将宏参数转为字符串字面量常用于调试打印、生成名字表、版本信息。##的作用将两个标记粘贴成一个新的合法 C 语言标记变量名、类型名、函数名可用于批量生成代码、寄存器访问、回调函数名等。适用场景与 X-Macro 联动的引脚/寄存器表、调试日志、自动化命名、外设驱动抽象。运行时代价绝对零。所有拼接都在预处理器阶段完成。思考题回答问题1##能拼接两个字符串字面量吗不能。##操作的是预处理记号必须能形成合法的单个 C 语言记号。hello和world作为两个字符串字面量已经是完整的记号把它们粘贴在一起会得到helloworld这样的两个连续字符串而不是一个字符串字面量。正确的做法是利用 C 语言的自动相邻字符串拼接特性直接写hello world即可不需要##。如果你想在宏里拼接字符串使用#加相邻串特性即可。问题2CONCAT(3.14, 15)能行吗不行。宏展开后得到3.14##15预处理器会尝试粘贴3.14和15两个记号但3.1415不是一个合法浮点常量的构成过程。更重要的是##要求产生一个有效预处理记号3.14是一个浮点字面量一个记号15是一个整数字面量另一个记号把它们粘贴在一起得到3.1415虽然看起来像浮点数但对预处理器来说它先形成的是3.14和15两个独立的记号粘贴后变成3.1415预处理器会把3.1415当成一个单一的记号一个 pp-number所以其实可以。等一下测试一下GCC 下CONCAT(3.14, 15)确实能生成3.1415。但这并不是可移植的可靠用法因为标准说结果必须是一个有效记号而3.1415作为 pp-number 碰巧合法。但如果写成CONCAT(3.14, abc)得到3.14abc就会报错。正确做法是需要连接变量名时操作数必须是标识符或数字不能混入无关字符。粘贴运算符主要用于标识符和数字的拼接而不是任意字符串拼接。想要可靠生成浮点常量直接用宏定义数值即可不要把粘贴当字符串连接用。好了第 6 招我们就彻底吃透了。下次再批量定义引脚、写调试日志或者拼寄存器名时别手动了把#和##用起来吧。如果今天的内容让你对宏的理解更深一层欢迎转发和点赞。下一篇我们继续挖在编译期用sizeof和static_assert检查缓冲区容量是否足够。咱们不见不散