6、宏定义与位操作:用宏封装位操作(SET_BIT、CLR_BIT、GET_BIT、TOG_BIT)

位操作是嵌入式C语言的看家本领。说实话,我见过不少工程师写了三年代码,还在用 a |= 0x01 这种裸写法。不是说不行,但代码的可读性和可维护性,真的差了一大截。

我个人习惯,从第一个项目开始,就把常用的位操作封装成宏。你想想看,一个项目里可能有几十处要置位某个寄存器位,如果每次都手写 REG |= (1 << 3),万一哪天要改位号,你得满世界找。用宏封装,改一处就行。

6.1 为什么非要用宏?

函数也能封装位操作,但函数调用有开销。宏在预处理阶段直接展开,不产生调用指令,不压栈不弹栈。在中断服务程序里,这点开销差别可能就是能不能跑得稳的问题。

另外,宏可以保留调用处的上下文类型信息。比如你传一个 uint32_t 的寄存器地址,宏展开后直接操作那个地址,函数做不到这么灵活。

核心原则: 位操作宏必须保证「无副作用」——宏参数只使用一次,避免自增自减表达式传入后出现诡异行为。

6.2 四大金刚:SET_BIT、CLR_BIT、GET_BIT、TOG_BIT

这四兄弟几乎覆盖了日常90%的位操作需求。我最早是在一个电机驱动项目里把它们定型下来的,后来每个项目都直接复用这套宏。

6.2.1 SET_BIT —— 置位

#define SET_BIT(reg, bit)   ((reg) |= (1UL << (bit)))

用法:SET_BIT(PORTA, 3); 把 PORTA 寄存器的第3位置1。

注意那个 1UL,我吃过亏。在16位单片机上,如果不加UL,左移超过15位可能溢出,结果全错。加上UL强制为无符号长整型,安全很多。

6.2.2 CLR_BIT —— 清零

#define CLR_BIT(reg, bit)   ((reg) &= ~(1UL << (bit)))

用法:CLR_BIT(PORTB, 5); 把 PORTB 的第5位清0。

这里有个坑:取反运算符 ~ 的优先级比 &= 高,但如果你写成 reg &= ~(1 << bit) 忘了括号,在某些编译器上可能先算 reg &= ~1 再移位,结果完全不对。所以宏体里括号一定要加满。

6.2.3 GET_BIT —— 读取

#define GET_BIT(reg, bit)   (((reg) >> (bit)) & 0x01UL)

用法:if (GET_BIT(PINC, 2)) { ... } 判断第2位是否为1。

这个宏返回0或1,可以直接用在条件判断里。我曾经见过有人写成 if (reg & (1 << bit)),其实也行,但可读性不如GET_BIT直观。团队协作时,新人一看GET_BIT就知道是读位,不用猜。

6.2.4 TOG_BIT —— 翻转

#define TOG_BIT(reg, bit)   ((reg) ^= (1UL << (bit)))

用法:TOG_BIT(PORTD, 7); 把第7位翻转一次。

翻转操作在按键消抖、LED闪烁场景特别常用。我之前做的一个智能灯项目,用TOG_BIT控制PWM输出极性,代码简洁到不行。

6.3 进阶:批量操作宏

单一位操作够用了,但有时候要同时操作多个位。比如设置一个寄存器的低4位为特定值。我封装了下面这组宏:

// 批量置位:将 reg 的 [bit_high:bit_low] 位全部置1
#define SET_BITS(reg, bit_low, bit_high) \
    do { \
        uint32_t _mask = ((1UL << ((bit_high) - (bit_low) + 1)) - 1) << (bit_low); \
        (reg) |= _mask; \
    } while(0)

// 批量清零
#define CLR_BITS(reg, bit_low, bit_high) \
    do { \
        uint32_t _mask = ((1UL << ((bit_high) - (bit_low) + 1)) - 1) << (bit_low); \
        (reg) &= ~_mask; \
    } while(0)

// 写入指定值到连续位域
#define WRITE_BITS(reg, bit_low, bit_high, val) \
    do { \
        uint32_t _mask = ((1UL << ((bit_high) - (bit_low) + 1)) - 1) << (bit_low); \
        (reg) = ((reg) & ~_mask) | (((val) << (bit_low)) & _mask); \
    } while(0)
为什么用 do { ... } while(0)? 这是C语言的一个经典技巧。如果不包这个,宏在 if-else 语句里展开时可能出现语法错误。比如 if (a) SET_BITS(...); else b=0; 展开后 if 后面跟多个语句,else 就找不到配对的 if 了。do-while(0) 保证宏始终是一个完整的语句。

6.4 避坑指南:我曾经踩过的雷

宏封装位操作看起来简单,但实际项目里坑不少。我一个个说:

  • 参数副作用: 千万别写 SET_BIT(reg++, 3)。宏展开后 reg++ 被执行了两次,结果完全不是你想要的。宏参数只传变量名或常量,不要传表达式。
  • 位号范围: 对于32位寄存器,bit 参数必须在0~31之间。我曾经接手过一个项目,有人传了bit=33,左移33位在32位机上结果是未定义的,调试了两天才找到原因。建议在宏里加断言,或者用静态检查工具。
  • volatile 关键字: 操作硬件寄存器时,reg 必须是 volatile 类型。宏本身不检查这个,你得在定义寄存器时加 volatile。否则编译器可能优化掉你的写操作,硬件死活不响应。
  • 不同位宽的平台: 8位、16位、32位 MCU 的寄存器位宽不同。我习惯在宏里用 uint32_t 做中间计算,然后强制转换回目标类型。这样移植到不同平台时,只需要改底层类型定义。
重要提醒: 宏不是函数,调试时无法单步进入。如果位操作逻辑复杂,建议先用函数实现并测试通过,再转为宏。或者保留函数版本用于调试,宏版本用于发布。

6.5 知识体系:位操作宏的核心逻辑

下面这张图把本章的核心脉络画出来了。你看一遍,基本就记住了。

位操作宏知识体系 位操作宏 SET_BIT(reg, bit) CLR_BIT(reg, bit) GET_BIT(reg, bit) TOG_BIT(reg, bit) SET_BITS / CLR_BITS WRITE_BITS do { ... } while(0) 避坑要点 参数无副作用 | 位号范围检查 | volatile 修饰 | 不同位宽平台兼容

6.6 实际项目中的使用建议

我建议你把这套宏放在一个单独的头文件里,比如 bit_ops.h。每个新项目直接包含,不用重复造轮子。下面是我常用的完整版:

#ifndef BIT_OPS_H
#define BIT_OPS_H

#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

/* 单一位操作 */
#define SET_BIT(reg, bit)   ((reg) |=  (1UL << (bit)))
#define CLR_BIT(reg, bit)   ((reg) &= ~(1UL << (bit)))
#define GET_BIT(reg, bit)   (((reg) >> (bit)) & 0x01UL)
#define TOG_BIT(reg, bit)   ((reg) ^=  (1UL << (bit)))

/* 批量位操作 */
#define SET_BITS(reg, low, high) \
    do { \
        uint32_t _m = ((1UL << ((high)-(low)+1))-1) << (low); \
        (reg) |= _m; \
    } while(0)

#define CLR_BITS(reg, low, high) \
    do { \
        uint32_t _m = ((1UL << ((high)-(low)+1))-1) << (low); \
        (reg) &= ~_m; \
    } while(0)

#define WRITE_BITS(reg, low, high, val) \
    do { \
        uint32_t _m = ((1UL << ((high)-(low)+1))-1) << (low); \
        (reg) = ((reg) & ~_m) | (((val) << (low)) & _m); \
    } while(0)

/* 取模运算(2的幂次) */
#define MOD_POW2(val, power)    ((val) & ((1UL << (power)) - 1))

/* 对齐到上边界 */
#define ALIGN_UP(val, align)    (((val) + (align) - 1) & ~((align) - 1))

#ifdef __cplusplus
}
#endif

#endif /* BIT_OPS_H */

嗯,这里要注意:MOD_POW2ALIGN_UP 是我额外加的两个实用宏。取模运算在嵌入式里很常见,用位运算代替除法,速度能快一个数量级。对齐宏在分配DMA缓冲区时特别好用。

最后说一句:宏封装不是银弹。如果你的位操作逻辑极其复杂(比如涉及多个寄存器的联动),还是老老实实写函数吧。宏擅长的是「简单、高频、无状态」的操作。把握好这个度,你的代码会既高效又优雅。

一句话总结: SET_BIT、CLR_BIT、GET_BIT、TOG_BIT 这四个宏,是嵌入式C工程师的瑞士军刀。用好了,代码干净、运行飞快、移植方便。用不好,调试到怀疑人生。记住避坑指南,你就能成为团队里那个「写宏从不翻车」的人。

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