五个常用位操作符的核心特性
在C语言开发中,位操作符是最容易被新手忽略,却能在嵌入式开发、底层驱动、算法优化中发挥巨大作用的工具。和常规的算术操作、逻辑操作相比,位操作直接操作二进制位,执行效率更高,占用代码空间更小,能轻松实现很多用常规方法很难实现的功能:比如状态标志位管理、寄存器配置、数据压缩、奇偶校验等等。
很多开发者对位操作的认知只停留在“与或非异或”的基础定义,不知道实际开发中有哪些实用的技巧。本文整理了嵌入式开发、系统开发中最常用的位操作小技巧,每个技巧都配了可直接复用的代码,帮你快速掌握位操作的精髓,写出更简洁高效的C语言代码。
一、先回顾基础:五个常用位操作符的核心特性
C语言一共提供了六种位操作符,最常用的是五个,我们先梳理它们的核心特性,这是所有技巧的基础:
操作符名称核心规则关键特性
&按位与对应位都为1结果才是1,否则为0和1相与保留原位,和0相与清零原位
``按位或对应位只要有一个为1结果就是1,否则为0
^按位异或对应位不同结果为1,相同为0和1异或翻转原位,和0异或保留原位,同一个数异或两次结果是原数
~按位取反所有0变1,1变0注意是所有位取反,包括符号位,整数类型结果是负数补码
<>左移/右移所有位向左/右移N位,左移补0,右移无符号数补0,有符号数补符号位左移N位等价于乘以2^N,右移N位等价于除以2^N(正整数)
这些基础规则看起来简单,但组合起来就能实现很多神奇的效果,下面我们来看实际开发中最常用的技巧。
二、状态标志位管理:用一个字节存8个标志
这是嵌入式开发中最常用的技巧,很多时候我们需要保存多个开关状态(比如外设的开/关、功能的使能/禁用),如果每个状态用一个bool变量保存,8个状态就要占用8字节,如果用位操作,只需要1字节就能存8个状态,节省8倍内存,对于RAM紧张的MCU来说非常实用。
1. 常用操作:置位、清零、取反、判断
针对一个32位整数中的某一位(我们要操作第n位,n从0开始计数),四个最基础的操作:
// 把第n位置1
#define SET_BIT(reg, n) ((reg) |= (1U << (n)))
// 把第n位清0
#define CLEAR_BIT(reg, n) ((reg) &= ~(1U << (n)))
// 把第n位翻转(0变1,1变0)
#define FLIP_BIT(reg, n) ((reg)^= (1U << (n)))
// 判断第n位是否为1
#define IS_BIT_SET(reg, n) (((reg) >> (n)) & 1U)
这里有一个细节:我们用的是1U(无符号1)而不是普通的1,避免移位的时候触发符号位扩展,导致出现负数错误,尤其是操作最高位(比如32位整数的第31位)的时候,用1U是更安全的做法。
2. 实际使用示例
比如我们要保存8个传感器的开关状态,只需要定义一个uint8_t类型的变量:
uint8_t sensor_status = 0; // 初始全是0,全部关闭
// 打开第3号传感器
SET_BIT(sensor_status, 3);
// 关闭第0号传感器
CLEAR_BIT(sensor_status, 0);
// 翻转第5号传感器状态
FLIP_BIT(sensor_status, 5);
// 判断第2号传感器是否打开
if (IS_BIT_SET(sensor_status, 2)) {
// 执行对应逻辑
}
一个字节存8个状态,要是需要10个状态,用两个字节(uint16_t)就能存,非常节省RAM,代码也很清晰,比用多个变量简洁太多。
三、提取指定位域:从一个整数中取出连续几位
有时候我们需要从一个寄存器或者字节中提取连续几个二进制位,比如串口协议的状态字节,高4位是命令类型,低4位是参数,怎么快速提取出高4位?
用位操作可以非常轻松实现:提取从第start位开始,共length位,方法是:先右移start位,把要提取的位移到最低位,然后和一个低length位全是1的掩码按位与,就能得到结果。
// 从data中提取从start位开始,长度为len的位段
#define EXTRACT_BITS(data, start, len) (((data) >> (start)) & ((1U << (len)) - 1))
这个技巧非常实用,我们测试几个例子:
// 例子1:提取0xAA(二进制10101010)的高4位,start=4,len=4
uint8_t data = 0xAA;
uint8_t high4 = EXTRACT_BITS(data, 4, 4);
// 结果是 0x0A = 10,和预期一致:1010就是十进制10
// 例子2:提取0x1234(二进制0001 0010 0011 0100)的第5位到第10位(共6位),start=5,len=6
uint16_t data = 0x1234;
uint16_t result = EXTRACT_BITS(data, 5, 6);
// 计算:0x1234 >>5 = 0x91(二进制10010001),(1<<6)-1=0x3F,0x91 & 0x3F = 0x11 = 17,结果正确
这个掩码生成的技巧(1 << len) -1非常巧妙,len位全是1的掩码,不需要自己手动算,代码自动生成,非常简洁。如果是修改连续几个位,也可以用类似的思路:先把原来的对应位清零,然后把新值左移start位,再或进去:
// 把data中从start开始长度为len的位段设置为value
#define SET_BITS(data, start, len, value) \
do { \
(data) &= ~(((1U << (len)) - 1) << (start)); \
(data) |= ((value) & ((1U << (len)) - 1)) << (start); \
} while(0)
这种写法在寄存器配置的时候非常常用,比如修改定时器的预分频器位域,只需要一句宏就能搞定,不需要手动计算掩码。
四、用异或实现两个变量交换,不需要临时变量
这是位操作非常经典的一个技巧,交换两个整数的值,不需要额外定义临时变量,原理就是利用异或的三个特性:a^ a = 0、a^ 0 = a、异或满足交换律结合律。
交换代码如下:
void swap(int *a, int *b) {
if (a != b) { // 必须判断地址不同,否则自己和自己异或会清零
*a^= *b;
*b^= *a;
*a^= *b;
}
}
原理拆解一下:
第一步*a^= *b:现在*a保存了a^ b,*b还是原来的b;
第二步*b^= *a:*b = b^ (a^ b) = a^ (b^ b) = a^ 0 = a,现在*b已经变成原来的a;
第三步*a^= *b:*a = (a^ b)^ a = b^ (a^ a) = b^0 = b,现在*a变成原来的b; 交换完成。
不过这个技巧现在更多是用于面试题,实际开发中不推荐用:一方面现在编译器对临时变量交换已经优化的很好,效率比异或交换差不多,甚至更高;另一方面代码可读性差,还必须处理地址相同的特殊情况,容易出bug。但是作为位操作的经典技巧,我们还是需要知道原理。
五、判断整数是不是2的幂
这也是面试和实际开发都常用的技巧,判断一个正整数是不是2的幂,2的幂有一个非常明显的二进制特性:二进制中只有一个1,剩下全是0,比如:
1 = 0b1,只有1个1
2 = 0b10,只有1个1
4 = 0b100,只有1个1
8 = 0b1000,只有1个1
利用这个特性,加上位操作可以快速判断,公式是(n & (n - 1)) == 0,原理是什么呢?我们看例子: n=8(0b1000),n-1就是0b0111,按位与之后:
1000
& 0111
= 0000
结果就是0,如果不是2的幂,比如n=6(0b110),n-1=0b101,按位与是0b100≠0,结果不为0。
所以判断代码可以写成:
bool is_power_of_two(unsigned int n) {
return (n & (n - 1)) == 0;
}
这个写法比循环计数二进制中1的个数快很多,只需要一次位操作就能出结果,效率极高。这里需要注意n=0的时候,结果也是0,会被误判,所以如果包含0的场景,要加上n !=0的判断:return n && ((n & (n -1)) ==0);。
延伸技巧:如果要统计一个整数二进制中1的个数,可以用这个方法循环:每次n &= n-1,都会消掉最后一个1,循环次数就是1的个数,比逐位判断快很多:
int count_one_bits(unsigned int n) {
int count = 0;
while(n) {
n &= n - 1;
count++;
}
return count;
}
六、快速判断奇偶,不用取模
判断一个整数是奇数还是偶数,其实只需要看最低位:最低位是1就是奇数,0就是偶数,直接用位操作就能判断,比取模%2更快:
bool is_odd(unsigned int n) {
return (n & 1U) == 1;
}
bool is_even(unsigned int n) {
return (n & 1U) == 0;
}
对于现代编译器来说,其实n%2也会被优化成位操作,但是直接写位操作更直观,也能体现对二进制的理解。
七、高低字节交换,快速大小端转换
很多时候我们需要做大小端转换,比如把16位整数的高低字节交换,把32位整数的字节序反转,用位操作可以非常高效实现:
1. 16位高低字节交换
uint16_t swap16(uint16_t val) {
return (val >> 8) | (val << 8);
}
原理就是把高8位右移到低8位,低8位左移到高8位,然后按位或起来,一步完成,不需要额外变量,非常简洁。
2. 32位整数字节序反转
uint32_t swap32(uint32_t val) {
return ((val & 0x000000FF) << 24) |
((val & 0x0000FF00) << 8) |
((val & 0x00FF0000) >> 8) |
((val & 0xFF000000) >> 24);
}
每个字节提取出来,放到对应的位置,一次计算就能完成转换,比循环处理每个字节效率高很多。实际开发中处理网络字节序、传感器数据的时候经常用到,非常方便。
八、把整数向上对齐到2的幂的整数倍
在内存分配、帧缓存设计中,我们经常需要把一个长度向上对齐到N(N是2的幂)的整数倍,比如对齐到4字节、8字节、16字节,用位操作可以快速实现:
// 把size向上对齐到align的整数倍,align必须是2的幂
unsigned int align_up(unsigned int size, unsigned int align) {
return (size + align - 1) & ~(align - 1);
}
我们测试几个例子:
对齐到4字节:size=5,align=4,(5+4-1) & ~(4-1) = 8 & ~3 = 8 & 0x...11111100 = 8,正确,5向上对齐到4就是8;
对齐到16字节:size=20,align=16,(20+16-1) & ~15 = 35 & 0x...11110000 = 32,正确,20对齐到16就是32;
size刚好是align的倍数:size=8,align=4,(8+4-1) & ~3 = 11 & ~3 = 8,结果还是8,正确。
这个技巧非常常用,很多内存管理器、链表节点对齐都用这个写法,一句代码搞定对齐,非常高效。如果是向下对齐,更简单:return size & ~(align -1);就可以。
九、快速计算最低位的1的位置
很多算法需要找到一个整数中最低位的1是第几位,比如0b10100最低位的1在第2位(从0开始),用位操作可以快速计算,利用n & -n的特性:这个操作会保留最低位的1,其他位都清零,原理是负数用补码存储,-n = ~n +1,按位与之后刚好保留最低位的1。
比如n=0b10100,-n补码是...11101100,按位与之后结果就是0b100,也就是4,然后我们算一下这个结果是2的几次方,就是对应的位置。
代码实现:
// 找最低位1的位置,返回从0开始的位号,n不能为0
int find_lowest_bit(unsigned int n) {
unsigned int lowest_one = n & -n; // 提取最低位的1
int pos = 0;
while(lowest_one >>= 1) {
pos++;
}
// 如果是x86架构,还可以用内置函数更快:pos = __builtin_ctz(n);
return pos;
}
GCC编译器还提供了内置函数__builtin_ctz(count trailing zeros),直接返回末尾0的个数,也就是最低位1的位置,一条指令就能完成,比循环快很多,适合性能要求高的场景。
十、寄存器配置中的实用技巧:清零后再赋值
STM32等MCU开发中,修改寄存器的某个位域的时候,一定要先清零原来的位,再设置新值,不能直接或进去,否则原来的残留位会影响结果,正确写法是:
// 错误写法:直接或,原来的位没有清零,会残留错误值
// USART1->BRR |= (9600 << 4);
// 正确写法:先清零对应位域,再或新值
USART1->BRR &= ~(USART_BRR_DIV_FRACTION); // 先清零分数位域
USART1->BRR |= (my_div & USART_BRR_DIV_FRACTION); // 再设置新值
我们之前的SET_BITS宏也是这个逻辑,先清零再赋值,这是寄存器配置的标准写法,很多新手容易忘记清零,导致寄存器配置错误,出现莫名其妙的bug。
十一、使用位操作的注意事项
虽然位操作很好用,但也有几个坑需要注意,避免出错:
移位的位数不要超过类型的位数:比如32位int类型,不要移32位以上,行为是未定义的,不同编译器结果不一样,一定要注意,操作n位类型,移位位数最大是n-1。
注意符号位的问题:有符号整数右移会补符号位,导致结果不对,所以位操作尽量用无符号类型(uint8_t/uint16_t/uint32_t),或者用1U而不是1,避免符号位扩展。
宏定义要加足够的括号:位操作的宏里面每个参数都要加括号,否则运算符优先级会导致结果错误,比如1 << n + 1会先算n+1再移位,和预期不一样,所以宏里面每个变量都要加括号。
不要过度追求位操作:现在编译器优化能力很强,可读性优先,性能差不多的情况下,优先用可读性好的写法,不要为了炫技用位操作,导致代码难维护。
总结
位操作是C语言开发者的基本功,这些小技巧看起来简单,但在实际开发中非常实用:既能节省内存空间,又能提升执行效率,很多场景下用一句位操作就能搞定的事情,用常规方法要写好几行循环,代码简洁很多。
上面整理的这些技巧,都是开发中经常用到的,状态位管理、提取位域、对齐、大小端转换这些,几乎每个嵌入式项目都会用到,记住这些写法,需要的时候直接拿来用,能帮你节省很多时间,写出更高效简洁的C语言代码。如果能熟练掌握这些技巧,也能体现你对C语言和二进制的理解深度,不管是开发还是面试,都能得心应手。





