一、宏与嵌入式开发:寄存器地址映射宏,位域操作宏,中断处理宏,实战:MCU 驱动库
嵌入式开发,说白了就是跟硬件寄存器打交道。我刚开始做这行的时候,最头疼的就是那些密密麻麻的寄存器地址。每次写代码都要翻数据手册,查地址,然后写一堆 *(volatile uint32_t *)0x40021000 这样的东西。写多了自己都晕,更别说别人看了。
后来我学乖了——用宏。宏在嵌入式里,简直就是瑞士军刀。今天我就把这几把最常用的刀,一把一把拿出来给你看看。
1.1 寄存器地址映射宏
先说说最基础的。你想想看,一个 MCU 里动辄上百个寄存器,每个寄存器都有个地址。如果每次都用绝对地址,代码可读性基本为零。
我个人的习惯是,先定义基地址,再定义偏移量。比如 STM32 的 GPIOA:
// 基地址
#define GPIOA_BASE 0x40020000
#define GPIOB_BASE 0x40020400
// 偏移量
#define GPIO_CRL_OFFSET 0x00
#define GPIO_CRH_OFFSET 0x04
#define GPIO_IDR_OFFSET 0x08
#define GPIO_ODR_OFFSET 0x0C
// 最终映射
#define GPIOA_CRL (*(volatile uint32_t *)(GPIOA_BASE + GPIO_CRL_OFFSET))
#define GPIOA_CRH (*(volatile uint32_t *)(GPIOA_BASE + GPIO_CRH_OFFSET))
#define GPIOA_IDR (*(volatile uint32_t *)(GPIOA_BASE + GPIO_IDR_OFFSET))
#define GPIOA_ODR (*(volatile uint32_t *)(GPIOA_BASE + GPIO_ODR_OFFSET))
这里有个关键点——volatile 关键字绝对不能少。我在项目中遇到过有人忘了加,结果编译器优化后,读寄存器永远读到同一个值。排查了整整两天,最后发现是 volatile 的问题。嗯,从那以后我写寄存器宏,第一件事就是检查 volatile。
核心要点:寄存器映射宏必须包含 volatile,否则编译器优化会要你的命。
1.2 位域操作宏
寄存器操作,说白了就是置位、清零、读取、修改。如果每次都写位运算,代码会变得又臭又长。我一般会定义这样一组宏:
// 置位
#define SET_BIT(reg, bit) ((reg) |= (1UL << (bit)))
// 清零
#define CLR_BIT(reg, bit) ((reg) &= ~(1UL << (bit)))
// 读取
#define GET_BIT(reg, bit) (((reg) >> (bit)) & 0x01)
// 修改多位
#define MODIFY_REG(reg, mask, val) ((reg) = ((reg) & ~(mask)) | ((val) & (mask)))
你看,这样写起来就清爽多了:
SET_BIT(GPIOA_ODR, 5); // PA5 输出高
CLR_BIT(GPIOA_ODR, 5); // PA5 输出低
if (GET_BIT(GPIOA_IDR, 3)) // 读取 PA3 输入
我个人特别喜欢 MODIFY_REG 这个宏。有一次调试一个 SPI 模块,需要修改时钟极性和相位。如果用位运算,得先读寄存器,再与掩码,再或值,再写回去。用这个宏一行搞定,而且不容易出错。
小技巧:位域操作宏最好用 do-while(0) 包裹,避免在 if-else 中出问题。比如:
#define SET_BIT(reg, bit) do { (reg) |= (1UL << (bit)); } while(0)
1.3 中断处理宏
中断处理是嵌入式开发的硬骨头。我见过太多人直接在中断服务函数里写大段逻辑,结果中断嵌套把自己搞死。我的做法是用宏来管理中断的开关和优先级。
// 全局中断控制
#define ENABLE_GLOBAL_IRQ() __asm volatile("cpsie i" ::: "memory")
#define DISABLE_GLOBAL_IRQ() __asm volatile("cpsid i" ::: "memory")
// 外设中断使能
#define NVIC_ENABLE_IRQ(n) NVIC->ISER[(n) / 32] = (1UL << ((n) % 32))
#define NVIC_DISABLE_IRQ(n) NVIC->ICER[(n) / 32] = (1UL << ((n) % 32))
// 中断优先级设置
#define NVIC_SET_PRIORITY(irq, prio) \
NVIC->IP[irq] = ((prio) << 4) & 0xFF
这里有个坑——中断优先级分组。不同的 MCU 优先级分组方式不一样。我曾经在一个项目里,把两个中断的优先级设反了,结果高优先级的中断一直被低优先级的打断,系统直接卡死。排查了整整一个下午,最后发现是优先级分组没配置对。
警告:中断优先级分组必须在系统初始化时设置,且只能设置一次。中途修改会导致不可预知的行为。
1.4 实战:MCU 驱动库
光说不练假把式。我们拿一个实际的 GPIO 驱动库来演示。假设我们要封装一个 LED 控制模块:
// led.h
#ifndef __LED_H__
#define __LED_H__
// 寄存器映射
#define LED_GPIO_BASE GPIOB_BASE
#define LED_PIN 5
// 操作宏
#define LED_ON() SET_BIT(GPIOB_ODR, LED_PIN)
#define LED_OFF() CLR_BIT(GPIOB_ODR, LED_PIN)
#define LED_TOGGLE() (GPIOB_ODR ^= (1UL << LED_PIN))
// 初始化宏
#define LED_INIT() do { \
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOBEN; \
GPIOB_MODER &= ~(0x03UL << (LED_PIN * 2)); \
GPIOB_MODER |= (0x01UL << (LED_PIN * 2)); \
GPIOB_OTYPER &= ~(0x01UL << LED_PIN); \
GPIOB_OSPEEDR |= (0x03UL << (LED_PIN * 2)); \
GPIOB_PUPDR &= ~(0x03UL << (LED_PIN * 2)); \
LED_OFF(); \
} while(0)
#endif
你看,整个驱动库就靠几个宏撑起来了。调用的时候特别简单:
int main(void) {
LED_INIT();
while(1) {
LED_ON();
delay_ms(500);
LED_OFF();
delay_ms(500);
}
}
我在实际项目中,用这种方式封装过 UART、I2C、SPI 等多个外设驱动。好处是代码量少、执行效率高、可移植性好。坏处是——宏展开后不好调试。所以我的建议是:调试阶段先用函数,稳定后再改成宏。
实战总结:宏在嵌入式开发中的三大优势——零开销、类型无关、代码复用。但要注意宏的副作用,比如参数多次求值的问题。
知识体系图
下面这张图,把本章的核心知识点串起来了。你可以看到,从最底层的寄存器地址映射,到上层的驱动库封装,宏贯穿始终。
从这张图可以看得很清楚:宏定义是地基,三大应用是支柱,最终汇聚到驱动库这个顶层建筑。每一层都依赖下一层,缺一不可。
我的建议:刚开始学的时候,别急着写复杂的宏。先把寄存器映射搞明白,再玩位操作,最后才碰中断。一步一步来,稳得很。
公众号:蓝海资料掘金营,微信deep3321