位运算与求值顺序
上一篇里我们把算术、关系、逻辑这些常用运算符过了一遍。现在我们来啃两块比较硬的骨头:位运算和求值顺序。位运算在一般的应用层编程中用得不多,但如果你以后要接触嵌入式开发或者底层系统编程,位运算就是你的日常工具——配置硬件寄存器、解析通信协议的位字段、实现标志位集合,全靠它。求值顺序和序列点则是理解"为什么有些代码在不同编译器上结果不一样"的关键。
说实话,这两块内容初学的时候确实会觉得有点绕。但别担心,我们一步一步来,先从最直观的位运算开始。
学习目标 完成本章后,你将能够:
- [ ] 掌握位运算的四大经典操作:置位、清零、取反、检查
- [ ] 理解左移和右移的细节与陷阱
- [ ] 记住运算符优先级中最容易踩坑的几个反直觉规则
- [ ] 理解求值顺序和序列点,避免写出未定义行为的代码
环境说明
我们接下来的所有实验都在这个环境下进行:
- 平台: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 位无符号数来演示,这样比较直观:
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 = 1,1 | 1 = 1——只要和 1 做或运算,结果一定是 1;而其他位和 0 做或运算,保持不变。
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 = x,x & 0 = 0——和 0 做与运算一定变 0,和 1 做与运算保持不变。
uint8_t reg = 0x0F; // 00001111
reg &= ~(1 << 3); // 清除第 3 位 → 00000111 = 0x07~(1 << 3) 的值是 0xF7(11110111),和 0x0F 做与运算后,第 3 位变成 0,其他位不变。
翻转(Toggle)——把某一位取反
翻转某一位用"异或"运算。原理是:x ^ 1 = ~x(取反),x ^ 0 = x(不变)。
uint8_t reg = 0x07; // 00000111
reg ^= (1 << 0); // 翻转第 0 位 → 00000110 = 0x06检查(Check)——看某一位是 0 还是 1
检查某一位的值,用"与"运算配合"左移",然后看结果是否非零:
uint8_t reg = 0x06; // 00000110
if (reg & (1 << 1)) {
// 第 1 位是 1(确实如此:00000110 的第 1 位是 1)
}
if (reg & (1 << 0)) {
// 第 0 位是 0(不会进入这个分支)
}来验证一下,我们把四个操作串起来跑一遍:
#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;
}编译运行:
gcc -Wall -Wextra -std=c17 bitwise_demo.c -o bitwise_demo && ./bitwise_demo运行结果:
初始值: 00000000 (0x00)
置位第3位: 00001000 (0x08)
置位0,1,2位: 00001011 (0x0B)
清零第3位: 00000011 (0x03)
翻转第0位: 00000010 (0x02)
第1位是: 1和预期完全一致。如果你觉得 (1 << n) 的写法不够直观,可以用宏封装一下:
#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)。大多数平台采用算术右移,但这不是标准保证的:
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)。
位运算的优先级陷阱
这是位运算初学者最容易踩的坑——位运算的优先级全部低于关系运算符。也就是说,&、|、^ 的优先级都比 ==、!=、<、> 低。
if (flags & 0x0F == 0) { } // 实际解析为 flags & (0x0F == 0)
// 也就是 flags & 0,永远为假!
if ((flags & 0x0F) == 0) { } // 这才是你想要的意思第一个写法的问题在于 == 先和 0x0F 以及 0 结合(因为 == 优先级高于 &),结果是 0(因为 0x0F != 0),然后 flags & 0 永远为假。
核心原则:凡是涉及位运算和比较混用的场景,必须加括号。括号不会让代码变慢,但能让你避免这种优先级陷阱。
一个实用的优先级速记法,从高到低排列:
- 括号
()> 下标[]> 成员访问.-> - 单目运算符(
!~++--*&sizeof) - 算术(
*/%>+-) - 移位(
<<>>) - 关系(
<><=>=>==!=) - 位运算(
&>^>|) - 逻辑(
&&>||) - 三目
?:> 赋值=> 逗号,
第四步——求值顺序与序列点
这是 C 语言中最容易让人困惑的概念之一。我们分两件事来理解:优先级和求值顺序。这两者是独立的——优先级决定运算符怎么绑定操作数,求值顺序决定操作数什么时候被计算。
求值顺序是未指定的
在大多数表达式中,操作数的求值顺序是编译器自己决定的。比如 f() + g(),标准不规定 f 和 g 谁先被调用——编译器可以选择任意顺序。如果两个函数没有副作用(不修改全局变量、不读写文件),谁先谁后无所谓;但如果有副作用,结果就可能因编译器而异。
序列点——副作用的安全边界
序列点(sequence point)是程序执行中的特定位置,在这个位置上,之前所有的操作都已经完成,之后的操作还没开始。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:位操作工具集
实现以下位操作函数:
/// @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:安全的移位
写一个函数,安全地执行左移操作,处理所有边界情况:
/// @brief 安全的左移操作
/// @param val 要移位的值
/// @param n 移位量
/// @param bits 类型的位宽(如 32)
/// @return 移位结果,非法移位量返回 0
uint32_t safe_shift_left(uint32_t val, int n, int bits);练习 3:表达式分析
分析以下表达式的求值行为(不实际运行),标出每个是"明确定义"、"未指定行为"还是"未定义行为":
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++); // ?