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)
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 知识体系:位操作宏的核心逻辑
下面这张图把本章的核心脉络画出来了。你看一遍,基本就记住了。
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_POW2 和 ALIGN_UP 是我额外加的两个实用宏。取模运算在嵌入式里很常见,用位运算代替除法,速度能快一个数量级。对齐宏在分配DMA缓冲区时特别好用。
最后说一句:宏封装不是银弹。如果你的位操作逻辑极其复杂(比如涉及多个寄存器的联动),还是老老实实写函数吧。宏擅长的是「简单、高频、无状态」的操作。把握好这个度,你的代码会既高效又优雅。
公众号:蓝海资料掘金营,微信deep3321