位运算可能是 C 语言里最"性感"的部分:别人还在用乘除法算,你一行 & 或 << 就把活干了。它不只是炫技——网络协议解析、文件权限、图像处理、嵌入式寄存器操作,全都在二进制层面直接干活。这篇文章从进制讲起,带你吃透 &、|、^、~、<<、>> 六种运算,再给出一批可以直接抄进项目的实战技巧。
1. 从十进制到二进制
计算机里一切数据归根结底都是二进制,一个字节 8 个位(bit),每位是 0 或 1。二进制的 1101 等于十进制的 1×8 + 1×4 + 0×2 + 1×1 = 13。C 语言里写二进制比较麻烦,最常用的是十六进制:一位十六进制恰好对应四位二进制,比如 0xF 就是 1111,0xA 就是 1010,转换起来又快又不容易错。
| 十六进制 | 二进制 | 十进制 |
|---|---|---|
| 0x0 | 0000 | 0 |
| 0x3 | 0011 | 3 |
| 0x8 | 1000 | 8 |
| 0xA | 1010 | 10 |
| 0xF | 1111 | 15 |
写位运算代码时强烈建议先在纸上把十六进制展开成二进制,算完再合回去。下面这个函数能把任意 unsigned int 以二进制形式打印出来,是你调试位运算的最佳伙伴:
#include <stdio.h>
void print_bin(unsigned int x) {
for (int i = 31; i >= 0; i--) {
putchar(((x >> i) & 1u) ? '1' : '0');
if (i % 4 == 0) putchar(' ');
}
putchar('\n');
}
int main(void) {
print_bin(0x0C); /* 0000 0000 0000 0000 0000 0000 0000 1100 */
print_bin(0xA5); /* ... 1010 0101 */
return 0;
}
打印的原理就是"一位一位抠":把 x 右移 i 位再和 1 做 &,得到第 i 位是 0 还是 1。(x >> i) & 1u 是提取任意一位的通用写法,后面会反复用到。
2. 三种基本运算:与、或、异或
按位与 &(两个位都是 1 结果才是 1)、按位或 |(任一个位是 1 结果就是 1)、按位异或 ^(两个位不同结果才是 1),它们对每一位独立计算,和普通逻辑运算 &&、|| 是两码事。
| a | b | a & b | a | b | a ^ b |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 |
| 0 | 1 | 0 | 1 | 1 |
| 1 | 0 | 0 | 1 | 1 |
| 1 | 1 | 1 | 1 | 0 |
用代码验证一下,顺便看看取反 ~ 的效果——它把每一位都翻转,所以 ~0x0C 在 32 位机器上是 0xFFFFFFF3:
#include <stdio.h>
int main(void) {
unsigned int a = 0x0C; /* 二进制:1100 */
unsigned int b = 0x0A; /* 二进制:1010 */
printf("a & b = 0x%X\n", a & b); /* 1000 = 0x8 */
printf("a | b = 0x%X\n", a | b); /* 1110 = 0xE */
printf("a ^ b = 0x%X\n", a ^ b); /* 0110 = 0x6 */
printf("~a = 0x%X\n", ~a);
return 0;
}
异或有个有趣的性质:x ^ x == 0,x ^ 0 == x,而且它可逆——(a ^ b) ^ b == a。这个性质后面会用来做"无临时变量交换"和"找唯一出现一次的数"。
3. 移位运算:快速乘除
左移 << 把每个位往左挪,右边补 0;右移 >> 往右挪,左边补什么取决于类型:无符号数补 0(逻辑右移),有符号负数通常补符号位(算术右移)。每左移一位相当于乘以 2,右移一位相当于除以 2——所以 x << 3 就是 x * 8。
#include <stdio.h>
int main(void) {
int n = 3;
printf("%d << 1 = %d\n", n, n << 1); /* 3 * 2 = 6 */
printf("%d << 3 = %d\n", n, n << 3); /* 3 * 8 = 24 */
printf("%d >> 1 = %d\n", n, n >> 1); /* 3 / 2 = 1 */
int neg = -16;
printf("%d >> 2 = %d\n", neg, neg >> 2); /* -16 / 4 = -4 */
unsigned int mask = 1u << 7; /* 第 7 位为 1,其余为 0 */
printf("mask = 0x%X\n", mask); /* 0x80 */
return 0;
}
两个提醒:第一,现代编译器早就会把乘 2 的整数次幂优化成移位,所以"移位比乘法快"这个传说只在极老或极特殊的平台上成立,写代码优先选可读性;第二,对负数做右移在 C 标准里是"实现定义"的,GCC/Clang 会做算术右移(补符号位),但可移植代码应避免依赖它,左移负数则是未定义行为,千万别写。
4. 实战技巧一:奇偶、交换与 2 的幂
下面这几个技巧都是位运算的"招牌菜",每个都能在真实项目里找到用武之地:
#include <stdio.h>
int main(void) {
int x = 42;
printf("%d 是%s\n", x, (x & 1) ? "奇数" : "偶数");
int a = 5, b = 9;
a ^= b;
b ^= a;
a ^= b; /* 三步异或,不用临时变量 */
printf("交换后:a=%d b=%d\n", a, b);
int y = 64;
printf("%d %s 2 的幂\n", y,
(y > 0 && (y & (y - 1)) == 0) ? "是" : "不是");
int p = -3, q = 8;
printf("%s\n", ((p ^ q) < 0) ? "异号" : "同号");
return 0;
}
逐个解释:x & 1 提取最低位,是 1 就是奇数;异或交换利用的是可逆性,但只对整数有效,而且读起来晦涩,面试能讲清楚即可,生产代码还是老实写临时变量;y & (y - 1) 能把最低位的 1 消掉,所以"是 2 的幂"当且仅当这个结果是 0 且 y 为正;(p ^ q) < 0 利用符号位判断异号,因为两个异号的数最高位必然不同,异或后符号位必为 1。
5. 实战技巧二:标志位与位掩码
真实世界里"一个开关"用 1 个 bit 就够。与其定义一堆 0/1 变量,不如用一个 unsigned int 装 32 个开关,这就是位掩码(bitmask)。经典的例子是文件权限:Linux 里 r w x 三个权限正好一个字节的 3 个位。
#include <stdio.h>
#define PERM_READ (1u << 0) /* 0001 */
#define PERM_WRITE (1u << 1) /* 0010 */
#define PERM_EXEC (1u << 2) /* 0100 */
int main(void) {
unsigned int perm = 0;
perm |= PERM_READ | PERM_WRITE; /* 打开"读"和"写" */
if (perm & PERM_READ) {
printf("可以读\n");
}
if (perm & PERM_EXEC) {
printf("可以执行\n");
}
perm &= ~PERM_WRITE; /* 关掉"写" */
printf("perm = 0x%X\n", perm); /* 只剩读:0x1 */
return 0;
}
记住三件套:用 | 打开位、用 & ~mask 关闭位、用 & mask 查询位。关掉某位必须先取反再与,直接 & PERM_WRITE 会把其他位全部清零,这是新手最容易犯的错误。位掩码的好处不止省内存,还让"一组标志"可以整体赋值、整体比较、放进结构体里随数据一起传递。
6. 实战技巧三:位的读写与 RGB 颜色
最后是一组"位操作工具函数"和一个真实场景:从 0xRRGGBB 格式的颜色值里拆出红绿蓝三个通道。这种拆包操作在网络协议、文件格式解析里天天见。
#include <stdio.h>
int get_bit(unsigned int x, int pos) {
return (x >> pos) & 1u;
}
unsigned int set_bit(unsigned int x, int pos) {
return x | (1u << pos);
}
unsigned int clear_bit(unsigned int x, int pos) {
return x & ~(1u << pos);
}
unsigned int toggle_bit(unsigned int x, int pos) {
return x ^ (1u << pos);
}
int count_ones(unsigned int x) {
int c = 0;
while (x) {
x &= x - 1; /* 每次消掉最低位的 1 */
c++;
}
return c;
}
int main(void) {
unsigned int x = 0x0F; /* 0000 1111 */
printf("bit2 = %d\n", get_bit(x, 2));
printf("set bit5 = 0x%X\n", set_bit(x, 5));
printf("clear bit0 = 0x%X\n", clear_bit(x, 0));
printf("1 的个数 = %d\n", count_ones(0x0F));
return 0;
}
count_ones 用的是 Brian Kernighan 算法:x & (x - 1) 消掉最低位的 1,循环几次就有几个 1,比逐位检查快得多。颜色拆包则是"先移位把目标字节挪到最低位,再 & 0xFF 把其他位清零":
#include <stdio.h>
int main(void) {
unsigned int color = 0x2E86AB; /* 一种蓝色 */
unsigned int r = (color >> 16) & 0xFF;
unsigned int g = (color >> 8) & 0xFF;
unsigned int b = color & 0xFF;
printf("R=%u G=%u B=%u\n", r, g, b); /* R=46 G=134 B=171 */
unsigned int back = (r << 16) | (g << 8) | b; /* 反向拼回 */
printf("0x%X\n", back);
return 0;
}
拆包的关键是"先移后与"的顺序:把想要的那 8 位挪到最低位,再用 & 0xFF 屏蔽其余位。拼回去则反过来,"先与后或",把每个通道挪到自己的位置再合体。这套思路可以推广到任意"位字段"的解析——一个 32 位整数里塞 3 个字段,就靠这种手法拆装。
7. 总结与练习
位运算的全部心法可以浓缩成一句话:与用来清零、或用来置位、异或用来翻转、移位用来搬位、取反用来配合清零。无符号数是位运算的安全区,有符号数右移要小心实现定义行为,左移负数则是未定义行为,能避免就避免。虽然现代编译器已不太需要你用位运算去"优化"乘除法,但理解二进制仍然是你读懂底层代码、写出高效解析器的基本功。
练习建议:
- 用位运算实现"判断一个整数是不是 4 的倍数",并说明为什么它比取模快不了多少;
- 在一个整数里同时存两个 16 位字段(高低各半),写函数分别读写它们;
- 给定一个数组,其中只有一个数出现奇数次、其他数都出现偶数次,用异或找出它(提示:全部异或一遍)。
💡 调试位运算的实用建议:永远用十六进制打印中间结果(%X),再用"十六进制 ↔ 二进制"的 4 位对应表在纸上换算。肉眼读二进制容易花,读十六进制不容易错。