20、宏实现代码生成器:重复代码的宏生成,模板模式的宏实现,代码生成与维护
说实话,写嵌入式代码最烦什么?
我个人觉得,最烦的就是重复劳动。比如你要给十几个外设写初始化函数,结构几乎一模一样,就是寄存器地址和参数不同。你复制粘贴改参数,改到眼花,一不小心就漏改了一个地方。这种问题,我早年踩过不少坑。
后来我发现,C语言的宏其实可以当代码生成器用。说白了,就是让宏帮我们写代码。你想想看,这多省事。
20.1 重复代码的痛点
先看个例子。假设你要定义几个GPIO端口的寄存器结构:
// 传统写法
typedef struct {
volatile uint32_t MODER;
volatile uint32_t OTYPER;
volatile uint32_t OSPEEDR;
volatile uint32_t PUPDR;
volatile uint32_t IDR;
volatile uint32_t ODR;
volatile uint32_t BSRR;
volatile uint32_t LCKR;
volatile uint32_t AFR[2];
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *)0x40020000)
#define GPIOB ((GPIO_TypeDef *)0x40020400)
#define GPIOC ((GPIO_TypeDef *)0x40020800)
// ... 还有D、E、F、G、H
这种写法有什么问题?
- 每个端口都要写一行宏定义,重复性高
- 如果基地址有规律,完全可以自动生成
- 维护麻烦,增加一个端口要改多处
我在项目中遇到过,一个芯片有8个UART,每个UART有十几个寄存器。如果手写,光地址定义就要写上百行。后来我用宏生成器,十行代码搞定。
20.2 宏实现代码生成器
核心思路很简单:用宏来生成宏。
嗯,这里要注意,宏展开是文本替换,所以我们可以利用这个特性,让一个宏定义生成一组代码。
20.2.1 基础模式:生成地址定义
// 定义生成器宏
#define DEFINE_GPIO_PORT(port, base_addr) \
GPIO_TypeDef * const GPIO##port = (GPIO_TypeDef *)(base_addr)
// 使用生成器
DEFINE_GPIO_PORT(A, 0x40020000);
DEFINE_GPIO_PORT(B, 0x40020400);
DEFINE_GPIO_PORT(C, 0x40020800);
DEFINE_GPIO_PORT(D, 0x40020C00);
DEFINE_GPIO_PORT(E, 0x40021000);
DEFINE_GPIO_PORT(F, 0x40021400);
DEFINE_GPIO_PORT(G, 0x40021800);
DEFINE_GPIO_PORT(H, 0x40021C00);
你看,原来要写8行,现在还是8行。但好处是什么?
- 地址规律一目了然,都是0x40020000 + 0x400 * n
- 如果要改结构,只改DEFINE_GPIO_PORT一个地方
- 不容易写错,因为宏帮你保证了格式统一
20.2.2 进阶:生成函数族
有时候我们需要为一组相似的外设生成操作函数。比如多个定时器,都要有初始化、启动、停止、获取计数值这些操作。
// 生成器宏:为每个定时器生成一组函数
#define GENERATE_TIMER_FUNCTIONS(timer_id) \
static void TIM##timer_id##_Init(void) { \
/* 初始化定时器 timer_id */ \
TIM##timer_id->CR1 = 0x01; \
TIM##timer_id->PSC = 1000; \
TIM##timer_id->ARR = 50000; \
} \
static void TIM##timer_id##_Start(void) { \
TIM##timer_id->CR1 |= 0x01; \
} \
static void TIM##timer_id##_Stop(void) { \
TIM##timer_id->CR1 &= ~0x01; \
} \
static uint32_t TIM##timer_id##_GetCounter(void) { \
return TIM##timer_id->CNT; \
}
// 使用生成器
GENERATE_TIMER_FUNCTIONS(2);
GENERATE_TIMER_FUNCTIONS(3);
GENERATE_TIMER_FUNCTIONS(4);
GENERATE_TIMER_FUNCTIONS(5);
这个宏展开后,会生成4组、每组4个函数,一共16个函数。你想想看,手写要多久?用宏只要4行。
20.3 模板模式的宏实现
模板模式,说白了就是定义一个"代码模板",然后通过参数填充不同的内容。C语言没有C++的template,但宏可以模拟。
20.3.1 简单模板:寄存器位操作
// 模板:定义寄存器的位操作
#define DEFINE_BIT_OPERATIONS(reg_name, reg_addr) \
static inline void SET_##reg_name(void) { \
*(volatile uint32_t *)(reg_addr) = 1; \
} \
static inline void CLR_##reg_name(void) { \
*(volatile uint32_t *)(reg_addr) = 0; \
} \
static inline uint32_t GET_##reg_name(void) { \
return *(volatile uint32_t *)(reg_addr); \
}
// 使用模板
DEFINE_BIT_OPERATIONS(LED_RED, 0x40020C14);
DEFINE_BIT_OPERATIONS(LED_GREEN, 0x40020C18);
DEFINE_BIT_OPERATIONS(LED_BLUE, 0x40020C1C);
这个模板生成了3组、每组3个函数。而且因为是static inline,编译器会优化掉函数调用开销,性能跟直接操作寄存器一样。
20.3.2 复杂模板:带条件判断
有时候模板需要根据参数不同,生成不同的代码。可以用宏的"条件编译"技巧:
// 复杂模板:根据外设类型生成不同代码
#define GENERATE_PERIPHERAL_CODE(periph, type) \
void periph##_Init(void) { \
if (type == TYPE_UART) { \
/* UART初始化代码 */ \
periph->BRR = 0x1A0; \
periph->CR1 = 0x200C; \
} else if (type == TYPE_SPI) { \
/* SPI初始化代码 */ \
periph->CR1 = 0x0347; \
periph->CR2 = 0x0000; \
} else if (type == TYPE_I2C) { \
/* I2C初始化代码 */ \
periph->CR1 = 0x8000; \
periph->CCR = 0x0050; \
} \
}
// 使用
GENERATE_PERIPHERAL_CODE(UART1, TYPE_UART);
GENERATE_PERIPHERAL_CODE(SPI1, TYPE_SPI);
GENERATE_PERIPHERAL_CODE(I2C1, TYPE_I2C);
20.4 代码生成与维护
宏生成代码虽然方便,但维护起来有讲究。我总结了几条经验:
20.4.1 生成代码的可读性
宏展开后的代码,调试器里看到的是原始宏调用,不是展开后的代码。所以:
- 宏定义要写注释,说明生成什么代码
- 宏调用处也要写注释,说明参数含义
- 可以用"#define MACRO_DEBUG"之类的宏,在调试时打印展开结果
// 调试技巧:打印宏展开
#define STRINGIFY(x) #x
#define TOSTRING(x) STRINGIFY(x)
// 在代码中插入
#pragma message("GENERATE_TIMER_FUNCTIONS(2) expands to: " TOSTRING(GENERATE_TIMER_FUNCTIONS(2)))
不过说实话,这个技巧在嵌入式编译器上不一定支持。我一般用另一种方法:
// 手动展开验证
// 把宏调用复制出来,手动替换参数,看看生成什么
// 比如 GENERATE_TIMER_FUNCTIONS(2) 展开后应该是:
// static void TIM2_Init(void) { TIM2->CR1 = 0x01; ... }
// static void TIM2_Start(void) { TIM2->CR1 |= 0x01; ... }
// ...
20.4.2 避免宏的副作用
宏参数是文本替换,不是函数参数。所以:
- 参数尽量用括号包起来,避免优先级问题
- 不要在宏参数里写有副作用的表达式(比如i++)
- 宏体内每个语句最好用do { ... } while(0)包起来
// 安全的宏写法
#define SAFE_GENERATE(periph) do { \
periph##_Init(); \
periph##_Start(); \
} while(0)
// 使用
SAFE_GENERATE(TIM2); // 安全,不会因为外部if语句而出错
20.4.3 维护策略
我个人的维护习惯是这样的:
- 集中管理:所有生成器宏放在一个头文件里,比如"code_generator.h"
- 版本注释:在宏定义上方写清楚版本号和修改记录
- 单元测试:写一个测试文件,调用所有生成器宏,编译看是否通过
- 不要过度使用:如果模板逻辑太复杂(比如超过3层嵌套),不如手写
20.5 知识体系图
下面这张图总结了宏实现代码生成器的核心逻辑:
20.6 实战建议
最后,给几个实战中的建议:
- 从小处开始:别一上来就想生成整个驱动层。先从一个外设的地址定义开始,尝到甜头再扩大
- 保留手写接口:宏生成的代码,最好只负责"机械重复"的部分。复杂的业务逻辑还是手写
- 代码审查:宏展开后的代码,让同事也看看。有时候自己写的宏,别人看不懂
- 不要炫技:能用简单宏解决的问题,别搞多层嵌套。代码是给人看的,不是给编译器看的
嗯,差不多就这些。宏代码生成器是个好工具,但用好了是利器,用不好是坑。我的建议是:多实践,多总结,慢慢就能找到感觉。