Skip to content

位运算与求值顺序

上一篇里我们把算术、关系、逻辑这些常用运算符过了一遍。现在我们来啃两块比较硬的骨头:位运算和求值顺序。位运算在一般的应用层编程中用得不多,但如果你以后要接触嵌入式开发或者底层系统编程,位运算就是你的日常工具——配置硬件寄存器、解析通信协议的位字段、实现标志位集合,全靠它。求值顺序和序列点则是理解"为什么有些代码在不同编译器上结果不一样"的关键。

说实话,这两块内容初学的时候确实会觉得有点绕。但别担心,我们一步一步来,先从最直观的位运算开始。

学习目标 完成本章后,你将能够:

  • [ ] 掌握位运算的四大经典操作:置位、清零、取反、检查
  • [ ] 理解左移和右移的细节与陷阱
  • [ ] 记住运算符优先级中最容易踩坑的几个反直觉规则
  • [ ] 理解求值顺序和序列点,避免写出未定义行为的代码

环境说明

我们接下来的所有实验都在这个环境下进行:

  • 平台:Linux x86_64(WSL2 也可以)
  • 编译器:GCC 13+ 或 Clang 17+
  • 编译选项:-Wall -Wextra -std=c17

第一步——认识位运算符

什么是"位"

上一篇讲数据类型的时候我们提到,变量的值在内存中是用 0 和 1 存储的。一个 uint8_t 有 8 个二进制位,一个 uint32_t 有 32 个二进制位。位运算就是直接操作这些二进制位——你不再把数据当成"数字",而是当成"一排开关"。

C 提供了六种位运算符:

运算符含义简单理解
&按位与两个都是 1 才得 1
|按位或有一个 1 就得 1
^按位异或不同得 1,相同得 0
~按位取反0 变 1,1 变 0
<<左移所有位向左移动,低位补 0
>>右移所有位向右移动,高位补 0(无符号数)

我们用 8 位无符号数来演示,这样比较直观:

text
  0b11001100  (204)
& 0b10101010  (170)
-----------
  0b10001000  (136)

  0b11001100  (204)
| 0b10101010  (170)
-----------
  0b11101110  (238)

  0b11001100  (204)
^ 0b10101010  (170)
-----------
  0b01100110  (102)

~ 0b11001100  (204)
-----------
  0b00110011  (51)    (8 位取反)

第二步——四大经典操作:置位、清零、翻转、检查

位运算在嵌入式开发中有四个最常用的操作模式,必须烂熟于心。

置位(Set)——把某一位变成 1

把某个位置设为 1,用"或"运算配合"左移"。原理是:0 | 1 = 11 | 1 = 1——只要和 1 做或运算,结果一定是 1;而其他位和 0 做或运算,保持不变。

c
uint8_t reg = 0x00;       // 00000000
reg |= (1 << 3);          // 把第 3 位置 1 → 00001000 = 0x08
reg |= (1 << 0);          // 把第 0 位置 1 → 00001001 = 0x09

// 一次置多个位
reg |= 0x07;              // 置位第 0、1、2 位 → 00001111 = 0x0F

清零(Clear)——把某一位变成 0

把某个位清零,用"与"运算配合"取反"。原理是:x & 1 = xx & 0 = 0——和 0 做与运算一定变 0,和 1 做与运算保持不变。

c
uint8_t reg = 0x0F;       // 00001111
reg &= ~(1 << 3);         // 清除第 3 位 → 00000111 = 0x07

~(1 << 3) 的值是 0xF711110111),和 0x0F 做与运算后,第 3 位变成 0,其他位不变。

翻转(Toggle)——把某一位取反

翻转某一位用"异或"运算。原理是:x ^ 1 = ~x(取反),x ^ 0 = x(不变)。

c
uint8_t reg = 0x07;       // 00000111
reg ^= (1 << 0);          // 翻转第 0 位 → 00000110 = 0x06

检查(Check)——看某一位是 0 还是 1

检查某一位的值,用"与"运算配合"左移",然后看结果是否非零:

c
uint8_t reg = 0x06;       // 00000110
if (reg & (1 << 1)) {
    // 第 1 位是 1(确实如此:00000110 的第 1 位是 1)
}
if (reg & (1 << 0)) {
    // 第 0 位是 0(不会进入这个分支)
}

来验证一下,我们把四个操作串起来跑一遍:

c
#include <stdio.h>
#include <stdint.h>

/// @brief 将一个 uint8_t 按二进制打印出来
void print_binary(uint8_t val)
{
    for (int i = 7; i >= 0; i--) {
        printf("%d", (val >> i) & 1);
    }
    printf(" (0x%02X)\n", val);
}

int main(void)
{
    uint8_t reg = 0x00;
    printf("初始值:       "); print_binary(reg);

    reg |= (1 << 3);       // 置位第 3 位
    printf("置位第3位:    "); print_binary(reg);

    reg |= 0x07;           // 置位第 0、1、2 位
    printf("置位0,1,2位:  "); print_binary(reg);

    reg &= ~(1 << 3);      // 清零第 3 位
    printf("清零第3位:    "); print_binary(reg);

    reg ^= (1 << 0);       // 翻转第 0 位
    printf("翻转第0位:    "); print_binary(reg);

    printf("第1位是: %d\n", (reg >> 1) & 1);

    return 0;
}

编译运行:

bash
gcc -Wall -Wextra -std=c17 bitwise_demo.c -o bitwise_demo && ./bitwise_demo

运行结果:

text
初始值:       00000000 (0x00)
置位第3位:    00001000 (0x08)
置位0,1,2位:  00001011 (0x0B)
清零第3位:    00000011 (0x03)
翻转第0位:    00000010 (0x02)
第1位是: 1

和预期完全一致。如果你觉得 (1 << n) 的写法不够直观,可以用宏封装一下:

c
#define BIT(n)              (1U << (n))
#define SET_BIT(x, n)       ((x) |= BIT(n))
#define CLEAR_BIT(x, n)     ((x) &= ~BIT(n))
#define TOGGLE_BIT(x, n)    ((x) ^= BIT(n))
#define CHECK_BIT(x, n)     (((x) & BIT(n)) != 0)

⚠️ 踩坑预警 宏定义里每个参数和整体表达式都加了括号,这不是多此一举。如果不加括号,CLEAR_BIT(x | y, 3) 会展开为 x | y &= ~(1 << 3),由于 &= 的优先级低于 |,含义完全变了。宏里的括号是最便宜的保险。

第三步——移位的注意事项

左移和右移的行为

左移 << 在无符号数上行为明确——低位补 0,高位丢弃。右移 >> 在无符号数上也是明确的(高位补 0)。

但有符号数的右移是实现定义的——编译器可以选择算术右移(高位补符号位,保持负数不变)或逻辑右移(高位补 0)。大多数平台采用算术右移,但这不是标准保证的:

c
int8_t x = -4;         // 二进制:11111100
int8_t y = x >> 1;     // 可能是 -2(算术右移,高位补 1)
                        // 也可能是 126(逻辑右移,高位补 0)
                        // 大多数平台是前者,但不保证

⚠️ 踩坑预警 如果移位量是负数,或者等于/超过类型的位宽(比如 int32_t 移 32 位),行为是未定义的。直觉上你可能觉得 1 << 32 的结果是 0,但标准规定这是 UB——实际可能得到 1(因为 CPU 只取移位量的低 5 位,32 变成了 0)。

位运算的优先级陷阱

这是位运算初学者最容易踩的坑——位运算的优先级全部低于关系运算符。也就是说,&|^ 的优先级都比 ==!=<> 低。

c
if (flags & 0x0F == 0) { }    // 实际解析为 flags & (0x0F == 0)
                                // 也就是 flags & 0,永远为假!
if ((flags & 0x0F) == 0) { }  // 这才是你想要的意思

第一个写法的问题在于 == 先和 0x0F 以及 0 结合(因为 == 优先级高于 &),结果是 0(因为 0x0F != 0),然后 flags & 0 永远为假。

核心原则:凡是涉及位运算和比较混用的场景,必须加括号。括号不会让代码变慢,但能让你避免这种优先级陷阱。

一个实用的优先级速记法,从高到低排列:

  1. 括号 () > 下标 [] > 成员访问 . ->
  2. 单目运算符(! ~ ++ -- * & sizeof
  3. 算术(* / % > + -
  4. 移位(<< >>
  5. 关系(< > <= >= > == !=
  6. 位运算(& > ^ > |
  7. 逻辑(&& > ||
  8. 三目 ?: > 赋值 = > 逗号 ,

第四步——求值顺序与序列点

这是 C 语言中最容易让人困惑的概念之一。我们分两件事来理解:优先级求值顺序。这两者是独立的——优先级决定运算符怎么绑定操作数,求值顺序决定操作数什么时候被计算。

求值顺序是未指定的

在大多数表达式中,操作数的求值顺序是编译器自己决定的。比如 f() + g(),标准不规定 fg 谁先被调用——编译器可以选择任意顺序。如果两个函数没有副作用(不修改全局变量、不读写文件),谁先谁后无所谓;但如果有副作用,结果就可能因编译器而异。

序列点——副作用的安全边界

序列点(sequence point)是程序执行中的特定位置,在这个位置上,之前所有的操作都已经完成,之后的操作还没开始。C 语言中的序列点包括:

  • && 的左操作数求值之后(这就是短路求值的原理)
  • || 的左操作数求值之后
  • ?: 的第一个操作数求值之后
  • 逗号运算符的左操作数求值之后
  • 完整表达式(语句末尾的分号)的末尾
  • 函数调用时,实参求值完毕之后、函数体开始执行之前

未定义行为:两次修改之间没有序列点

如果在一个序列点之间,同一个变量被修改了两次,或者被修改的同时又被读取(且读取不是用来计算新值的),那就是未定义行为

c
int i = 3;

i = i++;                  // UB:i 同时被赋值和自增
a[i] = i++;               // UB:i 被读取的同时被修改
printf("%d %d", i++, i++); // UB:i 被修改两次,参数之间没有序列点

// 正确写法
i = i + 1;    // OK:只修改一次
i++;          // OK:单独使用

⚠️ 踩坑预警 这类 bug 特别阴险,因为某个编译器上可能"看起来正常",换了编译器或开了优化就出问题。面试中遇到 i = i++ 这种题,正确答案是"这是 UB,没有标准答案",而不是去猜编译器会怎么处理。

如果大家想深入了解 UB 这个概念,可以把它类比为一个交通规则:标准规定"不许闯红灯",如果你闯了,后果不可预测——可能没事,可能被拍到罚款,可能出事故。UB 就是编程世界里的"闯红灯"。

C++ 衔接

C++ 在位运算方面做了几件有用的事情。<bitset> 中的 std::bitset<N> 可以用 [] 运算符直接访问单个位,还提供 test()set()reset()flip() 等语义明确的操作——比手写位运算更安全、更可读。在 C++ 中应该优先使用 std::bitset,除非确实需要极致的性能或直接操作硬件。

关于求值顺序,C++17 加强了规则——函数表达式一定在参数之前求值,比 C 的"未指定"更确定。另外,constexpr 函数在编译期求值时如果触发了 UB,编译器会直接报错——相当于一个免费的 UB 检测器。

小结

位运算的四大操作——置位(|= + <<)、清零(&= + ~ + <<)、翻转(^= + <<)、检查(& + <<)——是嵌入式开发的必备技能。运算符优先级中最大的坑是位运算优先级低于关系运算符,涉及位运算和比较混用时必须加括号。求值顺序和序列点的核心原则是:不要在同一个表达式内对同一个变量进行多次修改——那是未定义行为。

到这里,C 语言运算符的方方面面我们都已经覆盖了。接下来我们要学习控制流——怎么让程序根据条件执行不同的代码、怎么重复执行一段代码。

练习

练习 1:位操作工具集

实现以下位操作函数:

c
/// @brief 将 value 的第 n 位置为 1
uint32_t bit_set(uint32_t value, int n);

/// @brief 将 value 的第 n 位清零
uint32_t bit_clear(uint32_t value, int n);

/// @brief 翻转 value 的第 n 位
uint32_t bit_toggle(uint32_t value, int n);

/// @brief 提取 value 的 [high:low] 位域(包含两端)
uint32_t bit_extract(uint32_t value, int high, int low);

练习 2:安全的移位

写一个函数,安全地执行左移操作,处理所有边界情况:

c
/// @brief 安全的左移操作
/// @param val 要移位的值
/// @param n 移位量
/// @param bits 类型的位宽(如 32)
/// @return 移位结果,非法移位量返回 0
uint32_t safe_shift_left(uint32_t val, int n, int bits);

练习 3:表达式分析

分析以下表达式的求值行为(不实际运行),标出每个是"明确定义"、"未指定行为"还是"未定义行为":

c
int a = 5, b = 3;
int r1 = a++ + b;            // ?
int r2 = a++ + ++a;          // ?
int r3 = (a > b) ? a-- : b--; // ?
printf("%d %d\n", a++, a++);  // ?

参考资源

基于 VitePress 构建