Skip to content

位运算与移位:直接操作每一个比特

引言:位运算是 C 的看家本领

C 之所以长期霸占底层编程,一个根本原因是它允许你直接操作每一个比特——这对操作硬件寄存器、解析二进制协议、做加密、管标志位都是刚需。这一章我们把六个位运算符(& | ^ ~ << >>)真跑一遍,重点讲两个最容易出 UB 的坑(移位越界、有符号左移溢出),再给出工程里天天用的「标志位三件套」。

位运算:& | ^ ~

四个按位运算符,它们对两个操作数的每一个对应比特独立运算(ISO/IEC 9899 §6.5.10 &、§6.5.11 |、§6.5.12 ^、§6.5.3.3 ~)。真跑:

c
#include <stdio.h>

int main(void) {
    unsigned a = 0xC; /* 1100 */
    unsigned b = 0xA; /* 1010 */
    printf("a & b = %u   (1000 = 8)\n", a & b);
    printf("a | b = %u   (1110 = 14)\n", a | b);
    printf("a ^ b = %u   (0110 = 6)\n", a ^ b);
    printf("~a    = %u\n", ~a);
    return 0;
}
text
$ gcc -std=c11 -Wall bitwise.c -o bw && ./bw
a & b = 8   (1000 = 8)
a | b = 14   (1110 = 14)
a ^ b = 6   (0110 = 6)
~a    = 4294967283

对着二进制看:a=1100b=1010&(按位与)每一位「两个都是 1 才是 1」,得 1000=8;|(按位或)「有一个 1 就是 1」,得 1110=14;^(按位异或)「不同为 1、相同为 0」,得 0110=6;~(按位取反,一元)把 a=...01100 每位翻转成 ...10011,因为 a 是 32 位 unsigned,结果是 4294967283(= 2³²−1−12)。

异或 ^ 有几个好用性质:x ^ x == 0(自己异或自己为 0)、x ^ 0 == xx ^ y ^ y == x(异或同一个数两次还原)——这些性质在加密、校验、不用临时变量交换两个数(a^=b; b^=a; a^=b;,虽然可读性差、不推荐日常用)里都会冒头。注意按位运算符和第 5 章的逻辑运算符(&& || !)是完全不同的两套——& 是按位与、&& 是逻辑与,别写混了(a & b 是逐位算、a && b 是「都非 0 则 1」)。

移位:<< >>

移位运算符把一个数的所有比特整体左移或右移若干位(§6.5.7)。真跑:

c
#include <stdio.h>

int main(void) {
    printf("1u << 4   = %u\n", 1u << 4);   /* 16 */
    printf("256u >> 4 = %u\n", 256u >> 4); /* 16 */
    printf("1u << 31  = %u\n", 1u << 31);  /* 没问题:unsigned 移位 */
    return 0;
}
text
$ gcc -std=c11 -Wall shift2.c -o sh && ./sh
1u << 4   = 16
256u >> 4 = 16
1u << 31  = 2147483648

1u << 41...0001)左移 4 位成 ...10000 = 16——左移 n 位相当于乘 2ⁿ256u >> 4 右移 4 位成 16——无符号右移 n 位相当于整除 2ⁿ(高位补 0)。1u << 312147483648(= 2³¹),因为操作数是 unsigned,没问题。移位在工程里常用来「快速乘除 2 的幂」(比 / * 快,虽然现代编译器常常自动帮你这么优化)、设置掩码(1u << n 生成「第 n 位为 1」的掩码)。

移位的两个 UB 坑

移位运算有两个 UB,必须记牢,否则又是「我机器上对、换个编译选项就翻」。第一,移位位数大于等于类型位宽、或是负数,是 UB(§6.5.7 第 3 段)——int 是 32 位,那 1 << 321 << -1 都是 UB,标准不管结果。阶段 0 第 11 章我们已经用 UBSan 真跑过这个:1 << 32 会报 runtime error: shift exponent 32 is too large for 32-bit type 'int'。所以写移位时,移位位数一定要在 [0, 位宽-1] 范围内,尤其是当位数是个变量时(1 << nn 可能来自外部输入),要先检查 n >= 0 && n < 32

第二,有符号数左移,如果结果溢出了能表示的范围,也是 UB——比如 INT_MAX << 1,因为左移后符号位被改、超出 int 范围,UB。更麻烦的是有符号右移:高位补什么(算术右移补符号位、逻辑右移补 0)是实现定义的(§6.5.7 第 5 段),不同编译器/平台可能不一样。所以一条安全的规矩:做移位操作时,默认用 unsigned——无符号左移是确定地「乘 2ⁿ + 丢高位」(不算 UB,只要位数合法)、无符号右移确定补 0,没有「符号位」那些破事。这也是为什么硬件寄存器定义、协议字段几乎一律用 uint32_t 而不是 int

应用:标志位三件套

位运算最经典的应用是「标志位」——用一个整数的每一位表示一个开关,8 个标志塞进一个字节、32 个塞进一个 uint32_t。套路固定三招,记住能用一辈子:

c
#define FLAG_A (1u << 0)
#define FLAG_B (1u << 1)

flags |= FLAG_A;      /* 置位:把 FLAG_A 那一位变成 1 */
if (flags & FLAG_A) { /* 测试:FLAG_A 那一位是不是 1 */
    /* ... */
}
flags &= ~FLAG_A; /* 清位:把 FLAG_A 那一位变成 0 */

flags |= FLAG_A 用按位或「把那一位置 1」(其它位不变);flags & FLAG_A 用按位与「单独测那一位」(结果非 0 表示该位是 1);flags &= ~FLAG_A 用「按位与上取反」清掉那一位(~FLAG_A 是「除了那一位都为 1」的掩码,与一下就把那一位清 0、其它位保留)。这套在内核、驱动、协议解析里到处都是,是位运算最实用的收成。

小结

位运算符对每个比特独立运算:&(按位与,两个都 1 才 1)、|(按位或,有 1 就 1)、^(按位异或,不同为 1)、~(按位取反),真跑 0xC & 0xA 得 8、| 得 14、^ 得 6、~0xC(32 位 unsigned)得 4294967283——注意它们和逻辑运算符 &&/||/! 是两回事。移位 <<(左移 n 位 ≈ 乘 2ⁿ)、>>(无符号右移 n 位 ≈ 整除 2ⁿ),真跑 1u<<4 得 16、256u>>4 得 16。移位两个 UB:位数 ≥ 位宽或为负是 UB(§6.5.7 第 3 段,阶段 0 第 11 章 UBSan 真跑过 shift exponent 32 is too large)、有符号左移溢出也是 UB,而且有符号右移高位补什么是实现定义的——所以移位默认用 unsigned 最稳,位数是变量时先检查范围。标志位三件套天天用:置位 flags |= BIT、测试 flags & BIT、清位 flags &= ~BIT。下一章我们看控制流(if/for/while/switch)。

参考资源

  • ISO/IEC 9899:2011 §6.5.10(&)、§6.5.11(|)、§6.5.12(^)、§6.5.3.3(~)、§6.5.7(移位:第 3 段位数越界 UB、第 5 段有符号右移实现定义)
  • 阶段 0 · 第 11 章:Sanitizer 门禁(UBSan 真跑抓 shift exponent 32 is too large
  • 第 3 章:整型提升(位运算里 char/short 先提升成 int)、第 5 章:逻辑运算符(&&/||/!,和按位区分)