位运算可能是 C 语言里最"性感"的部分:别人还在用乘除法算,你一行 & 或 << 就把活干了。它不只是炫技——网络协议解析、文件权限、图像处理、嵌入式寄存器操作,全都在二进制层面直接干活。这篇文章从进制讲起,带你吃透 &、|、^、~、<<、>> 六种运算,再给出一批可以直接抄进项目的实战技巧。

1. 从十进制到二进制

计算机里一切数据归根结底都是二进制,一个字节 8 个位(bit),每位是 0 或 1。二进制的 1101 等于十进制的 1×8 + 1×4 + 0×2 + 1×1 = 13。C 语言里写二进制比较麻烦,最常用的是十六进制:一位十六进制恰好对应四位二进制,比如 0xF 就是 1111,0xA 就是 1010,转换起来又快又不容易错。

十六进制二进制十进制
0x000000
0x300113
0x810008
0xA101010
0xF111115

写位运算代码时强烈建议先在纸上把十六进制展开成二进制,算完再合回去。下面这个函数能把任意 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),它们对每一位独立计算,和普通逻辑运算 &&、|| 是两码事。

aba & ba | ba ^ b
00000
01011
10011
11110

用代码验证一下,顺便看看取反 ~ 的效果——它把每一位都翻转,所以 ~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. 总结与练习

位运算的全部心法可以浓缩成一句话:与用来清零、或用来置位、异或用来翻转、移位用来搬位、取反用来配合清零。无符号数是位运算的安全区,有符号数右移要小心实现定义行为,左移负数则是未定义行为,能避免就避免。虽然现代编译器已不太需要你用位运算去"优化"乘除法,但理解二进制仍然是你读懂底层代码、写出高效解析器的基本功。

练习建议:

💡 调试位运算的实用建议:永远用十六进制打印中间结果(%X),再用"十六进制 ↔ 二进制"的 4 位对应表在纸上换算。肉眼读二进制容易花,读十六进制不容易错。