7. 位运算符:嵌入式开发的“底层魔法”

位运算,说白了就是直接操作二进制位。很多初学者觉得这东西抽象,不如加减乘除直观。但我做了这么多年嵌入式,可以负责任地告诉你:位运算是C语言里性价比最高的知识点之一。你花一小时搞懂它,未来能省下几百小时的调试时间。

为什么?因为寄存器操作、状态标志、数据压缩、加密算法……底层处处是位运算。我见过不少项目,明明一个&或|就能搞定的事,愣是写了十几行if-else,跑起来还慢。嗯,咱们今天就把这事彻底讲透。

核心要点:位运算操作的是二进制位,不是数值本身。理解这一点,后面全通了。

7.1 按位与(&)—— 清零与保留

按位与的规则很简单:两个位都是1,结果才是1。用口诀记就是“全1则1,有0则0”。

// 示例:保留低4位,清零高4位
unsigned char a = 0xAB;  // 二进制:1010 1011
unsigned char mask = 0x0F; // 二进制:0000 1111
unsigned char result = a & mask; // 结果:0000 1011 = 0x0B

我在项目中常用它来读取寄存器的特定位。比如某个状态寄存器的bit3表示“设备是否就绪”,我只需要 status & 0x08 就能拿到这个标志,其他位一概不管。

个人习惯:我写掩码时喜欢用十六进制,比如0x01、0x02、0x04、0x08……这样一眼就能看出对应哪个bit。用十进制写掩码?我试过,维护起来太痛苦了。

7.2 按位或(|)—— 置位

按位或的规则:只要有一个是1,结果就是1。口诀:“有1则1,全0才0”。

// 示例:将bit2和bit5置1,其他位不变
unsigned char reg = 0x00;
reg |= (1 << 2) | (1 << 5);  // reg = 0x24 (二进制:0010 0100)

你想想看,如果不用位运算,你要怎么实现“只把某几个bit改成1,其他不动”?用赋值语句?那会把其他位覆盖掉。用if判断?代码会膨胀得没法看。按位或就是干这个的。

我曾经踩过的坑:有一次在中断服务函数里用 reg |= 0x01 置位,但忘了这个寄存器是硬件自动清零的。结果读到的值永远不对。后来查手册才发现,硬件会在读取后自动清零某些位。所以位运算前,一定先确认寄存器的读写特性。

7.3 按位异或(^)—— 翻转与校验

异或的规则:相同为0,不同为1。这个运算符很有意思,它有三个经典用途。

用途 说明 示例
翻转特定位 与1异或,该位取反 a ^= (1<<3) 翻转bit3
交换两值 不需要临时变量 a ^= b; b ^= a; a ^= b;
校验和 数据异或结果作为校验 串口通信常用
// 交换两个变量(无临时变量版)
int x = 5, y = 9;
x ^= y;  // x = 5 ^ 9 = 12
y ^= x;  // y = 9 ^ 12 = 5
x ^= y;  // x = 12 ^ 5 = 9
// 现在 x=9, y=5

不过我得提醒一句:这种交换方式虽然看起来很酷,但在实际项目中,除非是极端内存受限的场景,否则我建议还是用临时变量。为什么?因为可读性更重要。你写的代码是给人看的,不是给编译器炫技的。

7.4 按位取反(~)—— 一键翻转所有位

这个运算符最简单:0变1,1变0。但有个细节容易出错——它是对整个数据类型的所有位取反,不只是你关心的那几位。

unsigned char a = 0xF0;  // 1111 0000
unsigned char b = ~a;     // 0000 1111 = 0x0F

// 常见用法:清零特定位
unsigned char reg = 0xFF;
reg &= ~(1 << 3);  // 将bit3清零,其他位不变
// reg = 0xF7 (1111 0111)

注意:~(1 << 3) 的结果是 1111 0111,与reg做按位与,就能把bit3清零。这个模式在寄存器操作中极其常见,建议背下来。

7.5 左移(<<)与右移(>>)—— 快速乘除

左移n位相当于乘以2ⁿ,右移n位相当于除以2ⁿ(对无符号数)。这是编译器优化乘除法的主要手段。

// 左移:快速乘法
int a = 3;
int b = a << 2;  // 3 * 4 = 12

// 右移:快速除法(无符号数)
unsigned int c = 100;
unsigned int d = c >> 3;  // 100 / 8 = 12(注意是整数除法)

我记得有一次优化一个音频算法,里面大量用到乘8和除8的操作。把 *8 改成 <<3/8 改成 >>3,整个循环快了将近30%。在单片机这种资源受限的环境里,这种优化是实打实的。

避坑指南:有符号数的右移是算术右移还是逻辑右移?C标准没有强制规定,取决于编译器实现。我建议:涉及位运算时,一律用 unsigned 类型。否则右移可能在高位补符号位,结果跟你预期的不一样。

7.6 位运算的实际应用

光讲理论没意思,咱们看几个真实场景。

场景一:寄存器位操作

// 假设有一个控制寄存器,地址0x40021000
// bit0: 使能位,bit1: 中断使能,bit2: 复位位
#define REG_ADDR  (*(volatile unsigned int*)0x40021000)

// 使能设备(置bit0)
REG_ADDR |= (1 << 0);

// 使能中断(置bit1)
REG_ADDR |= (1 << 1);

// 复位设备(置bit2,然后清零)
REG_ADDR |= (1 << 2);
// 延时
REG_ADDR &= ~(1 << 2);

场景二:状态标志判断

// 假设状态寄存器,bit3表示发送完成,bit4表示接收完成
#define TX_DONE  (1 << 3)
#define RX_DONE  (1 << 4)

unsigned char status = read_status_register();

if (status & TX_DONE) {
    // 发送已完成
}

if (status & RX_DONE) {
    // 接收已完成
}

// 同时判断两个标志
if ((status & (TX_DONE | RX_DONE)) == (TX_DONE | RX_DONE)) {
    // 发送和接收都完成了
}

场景三:数据打包与解包

// 将两个12位数据打包成3个字节
unsigned int data1 = 0xABC;  // 12位
unsigned int data2 = 0xDEF;  // 12位

unsigned char packet[3];
packet[0] = (data1 >> 4) & 0xFF;        // 高8位
packet[1] = ((data1 & 0x0F) << 4) | ((data2 >> 8) & 0x0F);  // 中间字节
packet[2] = data2 & 0xFF;                // 低8位

// 解包
unsigned int recovered1 = (packet[0] << 4) | ((packet[1] >> 4) & 0x0F);
unsigned int recovered2 = ((packet[1] & 0x0F) << 8) | packet[2];

这种打包方式在通信协议里太常见了。我做过一个物联网项目,传感器数据就是12位精度的,用这种打包方式,每两个数据只占3个字节,比用两个short省了25%的带宽。

7.7 知识体系图

下面这张图总结了位运算的核心知识点和它们之间的关系。我建议你把它存下来,写代码时对照着看。

位运算知识体系 位运算符 & 按位与 | 按位或 ^ 按位异或 ~ 按位取反 << 左移 >> 右移 典型应用场景 • 寄存器位操作(置位/清零/翻转) • 状态标志判断与组合 • 数据打包/解包(通信协议) • 快速乘除运算(性能优化)

7.8 小结

位运算这东西,刚开始可能觉得有点绕,但一旦用熟了,你会发现它就像一把瑞士军刀——小巧、锋利、用途广泛。我个人建议你养成两个习惯:

  • 写掩码用十六进制,别用十进制。0x01比1更直观地表示“最低位”。
  • 涉及位运算的变量一律用unsigned,避免符号位带来的意外。

好了,这一章的内容就到这里。记住:位运算不是炫技,是嵌入式开发的必备技能。你用得越多,就越能体会到它的精妙之处。


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