2. 位操作封装:从宏定义到原子操作

寄存器操作,说白了就是跟一个个比特位打交道。我刚开始做MCU开发时,总觉得直接写寄存器地址挺酷的——直到有一次,一个同事review我的代码,皱着眉头说:“你这代码,除了你自己,没人看得懂。”嗯,从那以后,我开始认真思考位操作的封装问题。

2.1 基础BIT宏:让位编号变得可读

先从一个最简单的宏开始。我们经常需要操作某个特定位,比如GPIO的第5位。直接写 (1 << 5) 当然可以,但代码里到处都是魔法数字,维护起来很痛苦。

#ifndef BIT
#define BIT(n)  (1UL << (n))
#endif

这个宏接受一个位编号,返回对应的位掩码。我个人习惯在宏后面加 UL 后缀,避免在16位MCU上出现隐式类型转换问题。你想想看,如果 n 是31,不加 UL 的话,在某些编译器上可能就溢出了。

位编号 BIT(n) 展开 二进制值
0 1UL << 0 0b0001
3 1UL << 3 0b1000
7 1UL << 7 0b1000 0000

2.2 三剑客宏:SET_BIT、CLR_BIT、READ_BIT

有了BIT宏,接下来就是最常用的三个操作:置位、清零、读取。我在项目中见过太多人直接用 |= 和 &=~ 裸写,其实封装成宏后,代码意图一目了然。

/* 置位:将指定位置1 */
#define SET_BIT(reg, bit)     ((reg) |= (bit))

/* 清零:将指定位置0 */
#define CLR_BIT(reg, bit)     ((reg) &= ~(bit))

/* 读取:获取指定位的值(0或非0) */
#define READ_BIT(reg, bit)    ((reg) & (bit))

使用示例:

/* 点亮LED(假设PB5连接LED) */
SET_BIT(GPIOB->ODR, BIT(5));

/* 关闭LED */
CLR_BIT(GPIOB->ODR, BIT(5));

/* 读取按键状态(假设PA0连接按键) */
if (READ_BIT(GPIOA->IDR, BIT(0))) {
    /* 按键按下 */
}

这里有个细节要注意:READ_BIT 返回的是位掩码值,不是0或1。如果你需要严格的布尔值,可以再加一层:!!READ_BIT(reg, bit)。不过我个人觉得,在条件判断里直接用非零值就够了,没必要多此一举。

2.3 位域操作:批量处理相邻位

有时候我们需要操作的不是单个位,而是一个位域(bit field)。比如定时器的预分频器,可能占用4个连续位。这时候再用SET_BIT一个个设,就太傻了。

/* 写位域:将reg的[start:end]位设置为val */
#define WR_BITS(reg, mask, shift, val)  \
    ((reg) = ((reg) & ~(mask)) | (((val) << (shift)) & (mask)))

/* 读位域:提取reg的[start:end]位 */
#define RD_BITS(reg, mask, shift)       \
    (((reg) & (mask)) >> (shift))

举个例子,假设某个寄存器的位[7:4]是分频系数:

/* 定义掩码和偏移 */
#define TIM_PSC_MASK    (0xF0)    /* 位[7:4] */
#define TIM_PSC_SHIFT   (4)

/* 设置分频系数为8 */
WR_BITS(TIM1->PSC, TIM_PSC_MASK, TIM_PSC_SHIFT, 8);

/* 读取当前分频系数 */
uint8_t psc = RD_BITS(TIM1->PSC, TIM_PSC_MASK, TIM_PSC_SHIFT);

我的经验:掩码和偏移量最好定义成宏,不要直接在WR_BITS里写数字。我曾经在一个项目中看到 WR_BITS(reg, 0xF0, 4, val) 满天飞,三个月后我自己都忘了0xF0对应哪个位域。

2.4 原子操作:中断环境下的保命符

好了,前面那些宏在单线程环境下跑得挺欢。但一旦进了中断,问题就来了。我记得有一次调试一个电机驱动,电机时不时抽风一下。查了两天,最后发现是主循环和中断服务程序同时操作了同一个寄存器。

为什么会这样?因为 SET_BIT 和 CLR_BIT 本质上是“读-改-写”三步操作。如果在读和写之间被中断打断,中断也修改了同一个寄存器,那么回来后的写入就会覆盖中断的修改。

解决方案?关中断,操作,开中断。这是最朴素也最可靠的原子操作方式。

/* 原子置位 */
#define ATOMIC_SET_BIT(reg, bit)                \
    do {                                        \
        uint32_t __primask;                     \
        __primask = __get_PRIMASK();            \
        __disable_irq();                        \
        SET_BIT(reg, bit);                      \
        __set_PRIMASK(__primask);               \
    } while(0)

/* 原子清零 */
#define ATOMIC_CLR_BIT(reg, bit)                \
    do {                                        \
        uint32_t __primask;                     \
        __primask = __get_PRIMASK();            \
        __disable_irq();                        \
        CLR_BIT(reg, bit);                      \
        __set_PRIMASK(__primask);               \
    } while(0)

注意:

  • 关中断的时间要尽可能短,只包裹必要的操作
  • 保存并恢复中断状态,而不是简单地开中断——如果调用前中断本来就是关的,你开中断反而会出问题
  • 对于Cortex-M系列,使用 __get_PRIMASK() 和 __set_PRIMASK() 是标准做法

2.5 硬件原子操作:更优雅的解决方案

关中断虽然可靠,但毕竟有开销。现代MCU通常提供硬件级别的原子操作支持。比如STM32的 BSRR(位设置/复位寄存器):

/* 使用BSRR实现原子置位和清零 */
#define HW_SET_BIT(reg, bit)    ((reg)->BSRR = (bit))
#define HW_CLR_BIT(reg, bit)    ((reg)->BSRR = ((bit) << 16))

写入BSRR的低16位置位对应引脚,高16位清零对应引脚。硬件保证这个操作是原子的,不需要关中断。我在做高速PWM控制时,就靠这个避免了中断延迟带来的抖动。

方法 原子性 开销 适用场景
关中断 + 宏 软件原子 高(中断延迟) 通用,所有寄存器
BSRR寄存器 硬件原子 低(单次写入) GPIO输出
LDREX/STREX 硬件原子 中(多核共享) 多核/共享内存

2.6 封装层次:从宏到API

宏虽然高效,但调试不方便。我建议在宏的基础上,再封装一层内联函数。这样既保留了性能,又能在调试时设置断点。

static inline void gpio_set_pin(GPIO_Type *gpio, uint8_t pin) {
    HW_SET_BIT(gpio, BIT(pin));
}

static inline void gpio_clr_pin(GPIO_Type *gpio, uint8_t pin) {
    HW_CLR_BIT(gpio, BIT(pin));
}

static inline uint8_t gpio_read_pin(GPIO_Type *gpio, uint8_t pin) {
    return (READ_BIT(gpio->IDR, BIT(pin)) != 0);
}

你想想看,这样的代码,别人一看就知道是操作GPIO的引脚。比起直接写 GPIOA->ODR |= (1 << 5),可读性提升了不止一个档次。

本章核心要点:

  • BIT宏是位操作的基础,注意加UL后缀防溢出
  • SET_BIT/CLR_BIT/READ_BIT三剑客覆盖90%的位操作需求
  • WR_BITS/RD_BITS处理位域,记得定义掩码和偏移宏
  • 中断环境下必须用原子操作,关中断或利用硬件特性
  • 内联函数封装宏,兼顾性能和可调试性
位操作封装体系 用户API层(内联函数) 单比特操作宏 位域操作宏 原子操作宏 SET_BIT / CLR_BIT / READ_BIT WR_BITS / RD_BITS ATOMIC_xxx BIT(n) 基础宏 所有位操作最终都基于 BIT(n) 宏,它定义了从位编号到位掩码的映射