一、宏与代码生成:从重复劳动中解放

做嵌入式开发这些年,我写过最痛苦的代码就是重复的结构体定义和寄存器操作。你想想看,一个芯片可能有上百个寄存器,每个都要写读、写、置位、清零四个函数。手写?那简直是折磨。还好,C语言的宏给了我们一条出路。

说白了,宏不仅仅是简单的文本替换。它更像一个代码生成器。你给它一个模板,它帮你批量生产代码。我在一个物联网项目里,用宏生成了32个GPIO引脚的操作函数,总共只写了20行宏定义。要是手写,少说也得500行。

1.1 宏生成函数模板

先看一个最实用的例子。假设你要为多个外设生成读写函数:

// 定义函数生成宏
#define DEFINE_RW_FUNC(module, type) \
    type read_##module(uint32_t reg) { \
        return *(volatile type *)(reg); \
    } \
    void write_##module(uint32_t reg, type val) { \
        *(volatile type *)(reg) = val; \
    }

// 使用宏生成函数
DEFINE_RW_FUNC(uart, uint8_t)   // 生成 read_uart 和 write_uart
DEFINE_RW_FUNC(spi,  uint16_t)  // 生成 read_spi 和 write_spi
DEFINE_RW_FUNC(i2c,  uint32_t)  // 生成 read_i2c 和 write_i2c

这里有个关键点:## 是宏的粘合操作符。它把 read_uart 拼在一起,形成 read_uart。我刚开始用的时候,总忘记加这个 ##,结果生成的函数名变成了 read_ uart(中间有个空格),编译直接报错。

我的习惯:写这种宏之前,先用 gcc -E 展开看看。确认宏展开后的代码是你想要的,再集成到项目里。这一步能省下大量调试时间。

1.2 结构体模板生成

结构体定义也经常重复。比如你要定义多个外设的寄存器映射:

// 结构体模板宏
#define DEFINE_PERIPH_REG(prefix) \
    typedef struct { \
        volatile uint32_t prefix##_CR;   /* 控制寄存器 */ \
        volatile uint32_t prefix##_SR;   /* 状态寄存器 */ \
        volatile uint32_t prefix##_DR;   /* 数据寄存器 */ \
    } prefix##_Reg;

// 生成具体结构体
DEFINE_PERIPH_REG(UART)  // 生成 UART_Reg 结构体
DEFINE_PERIPH_REG(SPI)   // 生成 SPI_Reg 结构体
DEFINE_PERIPH_REG(I2C)   // 生成 I2C_Reg 结构体

嗯,这里要注意。结构体里的寄存器顺序必须和芯片手册一致。我曾经因为顺序搞反,调试了整整两天才发现是结构体定义和硬件映射对不上。

1.3 重复代码消除:X宏的妙用

X宏是我个人非常喜欢的一个技巧。它特别适合处理枚举和字符串表的对应关系:

// 定义数据表
#define ERROR_TABLE \
    X(ERR_NONE,    "No error")      \
    X(ERR_TIMEOUT, "Timeout")       \
    X(ERR_CRC,     "CRC mismatch")  \
    X(ERR_OVERRUN, "Buffer overrun")

// 生成枚举
typedef enum {
    #define X(name, str) name,
    ERROR_TABLE
    #undef X
    ERR_COUNT
} ErrorCode;

// 生成字符串表
const char* error_strings[] = {
    #define X(name, str) str,
    ERROR_TABLE
    #undef X
};

你看,同样的数据,通过X宏生成了枚举和字符串表。以后加一个新错误码,只需要在 ERROR_TABLE 里加一行。不用再手动维护两个地方。我在一个通信协议栈里用了这个技巧,维护成本降低了至少60%。

我曾经踩过的坑:X宏里的 #undef X 一定不能少。否则后面再定义其他X宏,就会和前面的冲突。编译器不会报错,但行为完全不可预测。

1.4 实战:寄存器位操作宏

好了,终于到了重头戏。寄存器位操作是嵌入式开发的家常便饭。一个专业的位操作宏,应该同时具备可读性和效率:

// 位操作基础宏
#define BIT(n)              (1UL << (n))
#define BIT_MASK(h, l)      (((1UL << ((h)-(l)+1)) - 1) << (l))

// 寄存器操作宏
#define REG_READ(reg)       (*(volatile uint32_t *)(reg))
#define REG_WRITE(reg, val) (*(volatile uint32_t *)(reg) = (val))

// 位域操作宏
#define SET_BIT(reg, bit)   REG_WRITE(reg, REG_READ(reg) | BIT(bit))
#define CLR_BIT(reg, bit)   REG_WRITE(reg, REG_READ(reg) & ~BIT(bit))
#define GET_BIT(reg, bit)   ((REG_READ(reg) >> (bit)) & 1UL)

// 多体位域操作
#define SET_FIELD(reg, h, l, val) \
    REG_WRITE(reg, (REG_READ(reg) & ~BIT_MASK(h, l)) | ((val) << (l)))
#define GET_FIELD(reg, h, l) \
    ((REG_READ(reg) >> (l)) & BIT_MASK(h-l, 0))

实际使用的时候,我一般会再封装一层,针对具体寄存器:

// 以STM32的GPIO为例
#define GPIOA_BASE  0x40020000
#define GPIOA_ODR   (GPIOA_BASE + 0x14)

// 封装成具体操作
#define LED_ON()    SET_BIT(GPIOA_ODR, 5)   // PA5 控制LED
#define LED_OFF()   CLR_BIT(GPIOA_ODR, 5)
#define LED_TOGGLE() REG_WRITE(GPIOA_ODR, REG_READ(GPIOA_ODR) ^ BIT(5))
关键点:所有寄存器访问都用 volatile 修饰。这是告诉编译器,这个变量的值可能被硬件改变,不要做优化。我见过有人忘了加 volatile,结果读寄存器永远返回同一个值——被编译器优化掉了。

1.5 宏的副作用陷阱

写宏的时候,有个经典问题:参数被多次求值。看这个例子:

// 有问题的宏
#define SET_BIT_BAD(reg, bit) ((reg) |= (1 << (bit)))

// 使用
SET_BIT_BAD(*p++, 3);  // 展开后:(*p++ |= (1 << 3))
// 这里 p 被自增了两次!因为宏展开后 *p++ 出现了两次

正确的做法是:

// 安全的宏
#define SET_BIT_SAFE(reg, bit) do { \
    volatile uint32_t *_r = &(reg); \
    *_r |= (1 << (bit)); \
} while(0)

// 或者用GCC扩展
#define SET_BIT_GCC(reg, bit) ({ \
    typeof(reg) _r = (reg); \
    _r |= (1 << (bit)); \
    _r; \
})

我个人更推荐第一种 do { ... } while(0) 的写法。它兼容所有C编译器,而且不会引入GCC扩展。在跨平台项目里,这个选择能省很多麻烦。

1.6 宏与代码生成的边界

宏不是万能的。当逻辑变得复杂,比如需要循环、条件分支、局部变量时,宏就会变得难以维护。这时候,我建议考虑:

  • 简单重复(函数生成、结构体模板)→ 用宏
  • 中等复杂度(带条件的代码生成)→ 用C++模板或Python脚本预生成
  • 高度复杂(状态机、协议解析)→ 用专门的代码生成工具

我在一个项目里,一开始用宏生成了整个状态机。结果宏嵌套了七八层,调试的时候展开代码有上千行。后来改成Python脚本生成C代码,清晰多了。

我的建议:宏展开后,如果代码超过50行,就考虑用其他方式。宏适合做"微生成",不适合做大工程。

1.7 本章知识体系

下面这张图总结了宏在代码生成中的核心应用场景和注意事项:

宏与代码生成知识体系 宏代码生成 函数模板生成 结构体模板生成 寄存器位操作 ## 粘合操作符 多参数宏展开 X宏技术 枚举与字符串表同步 volatile 关键字 位域操作封装 ⚠ 常见陷阱 参数多次求值 | 宏嵌套过深 | 忘记 #undef | 缺少 volatile

宏代码生成的核心,就是找到重复模式,然后用宏把它抽象出来。但记住,抽象的目的是简化,不是炫技。我在代码评审时见过太多"为了用宏而用宏"的代码,反而把简单问题复杂化了。

最后分享一个经验:写宏之前,先在纸上画出你想要的代码结构。然后问自己:这个宏展开后,别人能一眼看懂吗?如果答案是否定的,那就加注释,或者干脆换一种实现方式。


公众号:蓝海资料掘金营,微信deep3321