补码与位运算原理
# 07.补码与位运算原理
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. 原码反码补码
- 4. -x = ~x + 1的证明
- 5. 有符号溢出是UB
- 6. 无符号回绕机制
- 7. 六种位运算符详解
- 8. 经典位运算技巧
- 9. 位掩码与标志位
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
看一段在金融支付系统中跑了三个月的代码——某天清算时发现,用户A向用户B转账100元,结果用户B的余额暴增了42亿:
// payment_engine.c —— 支付清算引擎
#include <stdio.h>
#include <stdint.h>
#include <limits.h>
typedef struct {
int32_t balance; /* 余额——分 */
char name[32];
} Account;
/* 转账函数——从 from 扣款,给 to 加款 */
int transfer(Account *from, Account *to, int amount) {
/* 前置检查 */
if (amount <= 0) return -1; /* ← Line A */
if (from->balance < amount) return -2; /* ← Line B */
from->balance -= amount;
to->balance += amount;
/* 后置检查——确保没有溢出 */
if (from->balance < 0) { /* ← Line C */
/* 回滚 */
from->balance += amount;
to->balance -= amount;
return -3;
}
return 0;
}
int main(void) {
Account alice = { .balance = 50, .name = "Alice" }; /* 5毛钱 */
Account bob = { .balance = 2000000000, .name = "Bob" }; /* 2万元 */
/* 某第三方清算平台返还 Bob 一笔大额退款 */
int refund = 2100000000; /* 21亿分 = 2100万元 */
int ret = transfer(&alice, &bob, -refund);
/* ↑ Alice扣款 ↑ Bob收款 ↑ 负数金额! */
printf("Alice: %d, Bob: %d\n", alice.balance, bob.balance);
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
现象:
amount = -2100000000(负数)——绕过了amount <= 0检查!因为-2100000000 > 0在int的语义下吗?不是——读者再看,这个值是负数,-2100000000 <= 0为真,应该被拦住才对。但实际运行结果是没拦住。
等等,再检查。int refund = 2100000000;——这个值超过了 INT_MAX(2147483647)!在大多数平台上这是个 UB 或者实现定义行为。但 GCC 下这个值恰好被解释为 -2147483648。不对,2100000000 < 2147483647——它没超。那问题出在哪里?
重新看:transfer(&alice, &bob, -refund)——refund = 2100000000,-refund = -2100000000。这个值传进去,amount <= 0 检查——-2100000000 <= 0 为真,应该 return -1 才对。但 Line A 先检查。等一下,关键是——
-refund = -(2100000000) = -2100000000。因为补码表示下,INT_MIN = -2147483648,而 2100000000 作为正数在 int 范围内(INT_MAX = 2147483647),所以 -2100000000 也是一个合法的 int(大于 INT_MIN)。-2100000000 <= 0 为真——应该被 if (amount <= 0) return -1 拦住。那为什么没拦住?
真正的原因是:transfer 的 amount 参数类型是 int,但 -refund = -2100000000 在 int 范围内。然而——仔细看 -refund 这个表达式的计算:
在 C 语言中,-refund 是对 refund(int 类型,值 2100000000)取负。在补码表示下,-2100000000 的值是 (2^32 - 2100000000) = 4294967296 - 2100000000 = 2194967296。这个值超过了 INT_MAX!
C 标准规定:有符号整数的溢出是未定义行为。所以编译器可以在 -O2 下做任何事——包括不触发 amount <= 0 检查。
让我们实际看看 -O2 下发生了什么:
/* 编译器看到 */
int refund = 2100000000;
int amount = -refund; /* UB——有符号溢出 */
/* 在 -O2 下,编译器推导:
refund > 0,且 -refund 的"正确"结果应该 > 0(补码溢出回绕到正数)
所以编译器可能"优化"为 amount > 0
因此 amount <= 0 恒为假 → 整个 if 被删除
*/
2
3
4
5
6
7
8
9
这就是最可怕的 UB 形式——一条看似合理的检查被编译器基于"溢出不可能发生"的假设删除了。
这段代码埋藏了 6 个核心问题:
① 为什么 -2100000000 在 int 里会溢出?补码到底怎么算? → 第 3-4 章
② 有符号溢出是 UB——编译器会怎么处理? → 第 5 章
③ 无符号溢出为什么是回绕而不是 UB? → 第 6 章
④ & | ^ ~ << >> 六种位运算符在 CPU 怎么执行? → 第 7 章
⑤ 位运算有哪些经典技巧?为什么 n&(n-1) 能清零最低位 1? → 第 8 章
⑥ 位掩码和标志位怎么设计? → 第 9 章
2
3
4
5
6
# 1.2 顺藤摸到根因
打开 godbolt(gcc 14.1 -O2)看汇编:
; transfer 在 -O2 下的汇编(简化)
transfer:
; if (amount <= 0) return -1;
test esi, esi ; 测试 amount(ESI)
jle .L_error ; <= 0 → 跳错误
; from->balance -= amount
mov eax, DWORD PTR [rdi]
sub eax, esi
mov DWORD PTR [rdi], eax
; to->balance += amount
mov eax, DWORD PTR [rsi]
add DWORD PTR [rdx], esi
ret
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在 -O0 下,-2100000000 被补码解释为 2194967296(正数),所以 test esi, esi 看到 esi > 0 → 不跳错误 → 继续执行。amount 变成了一个巨大的正数。
在 -O2 下,编译器发现 -refund 处有溢出 UB,因此可以自由假设 amount 的值"必须"是非溢出结果(即正数)。于是优化掉 amount <= 0 检查。
无论哪种情况,Bob 都收到了 2194967296 分(约 2195 万元),而不是被拦截。
# 1.3 我们要回答什么
这个案例就是本篇的主线。我们从补码的数学基础出发,深入有符号溢出的 UB 性质,再到六种位运算符的汇编实现和经典技巧,最后用安全的整数运算改写这个支付引擎。
编码全景图 (第 2 章)
↓
原码→反码→补码 (第 3 章) ─→ 解开①
↓
-x = ~x+1 证明 (第 4 章) ─→ 补码的数学根基
↓
有符号溢出UB (第 5 章) ─→ 解开②
↓
无符号回绕 (第 6 章) ─→ 解开③
↓
六种位运算符 (第 7 章) ─→ 解开④
↓
经典技巧 + 掩码 (第 8-9 章) ─→ 解开⑤~⑥
↓
综合案例 (第 10 章) ─→ 安全 rewrite
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2. 架构概览
# 2.1 编码与运算全景
整数编码的三种方案:
┌──────────────────────────────────────────────────────────┐
│ 编码 │ +5 的表示 │ -5 的表示 │ 问题 │
├──────────┼───────────┼───────────┼────────────────────────┤
│ 原码 │ 00000101 │ 10000101 │ +0 和 -0 共存,减法复杂 │
│ 反码 │ 00000101 │ 11111010 │ 仍有 +0 和 -0 │
│ 补码 │ 00000101 │ 11111011 │ 非对称——INT_MIN 无边 │
└──────────────────────────────────────────────────────────┘
位运算符的六大成员:
& 按位与 │ 位掩码提取、清零特定位
| 按位或 │ 设置特定位
^ 按位异或 │ 翻转特定位、无临时变量交换
~ 按位取反 │ 补码转换的基石
<< 左移 │ ×2 的廉价替代
>> 右移 │ ÷2 的廉价替代(有符号→算术右移)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 2.2 为什么需要位运算
Java 和 Python 也有位运算——但使用频率远低于 C。C 语言中位运算的核心使用场景:
| 场景 | 例子 |
|---|---|
| 硬件寄存器 | GPIOA->ODR \|= (1<<5); |
| 标志位压缩 | 用一个 uint32_t 存 32 个布尔标志 |
| 性能优化 | x / 8 → x >> 3(除数为 2 的幂时) |
| 加密/哈希 | CRC、MD5、SHA 内部重度依赖位运算 |
| 网络协议 | IP 头字段的位级解析 |
C 语言是唯一一个位运算和硬件 MMIO 直接绑定的主流语言。
# 3. 原码反码补码
# 3.1 原码的天然缺陷
用 8 位原码表示 +5 和 -5:
+5 = 0 0000101 (最高位=0 表示正数)
-5 = 1 0000101 (最高位=1 表示负数)
2
做加法:
5 + (-5) = ?
0 0000101
+ 1 0000101
───────────
1 0001010 = -10 ← 不是 0!原码不能直接用同一套加法器
2
3
4
5
两个零:
+0 = 0 0000000
-0 = 1 0000000 ← 浪费了一个编码
2
原码在硬件层面无法直接用同一套加法器同时处理正负数的加减——这就是它被淘汰的原因。
# 3.2 反码的过渡方案
反码:正数不变,负数的每一位取反:
+5 = 0 0000101
-5 = 1 1111010 (+5 的每一位取反)
2
做加法:
5 + (-5) = ?
0 0000101
+ 1 1111010
───────────
1 1111111 = -0 ← 反码下这也是 0
2
3
4
5
比原码好——但仍有 +0(0x00) 和 -0(0xFF) 两个零,浪费编码且需要处理边界。
# 3.3 补码的物理意义
补码:反码 + 1:
+5 = 0 0000101
-5 = 1 1111010 + 1 = 1 1111011
2
做加法——终于对了:
5 + (-5) = ?
0 0000101
+ 1 1111011
───────────
10 0000000 ← 溢出位被丢弃 → 0 0000000 = 0 ✅
2
3
4
5
为什么溢出位丢弃就能得到正确结果——因为补码系统工作在模 2^N 的数学环上。8 位系统就是 mod 256。
# 3.4 补码的模运算本质
把 8 位整数想象成 256 格的钟表盘:
0
255 1
... ...
128 127
-128
2
3
4
5
在模 256 的世界里:
-1 ≡ 255(8 位补码:0xFF)-5 ≡ 251(8 位补码:0xFB)128 + 128 = 256 ≡ 0(0x80 + 0x80 = 0x100 → 取低 8 位 = 0x00)
这就是补码的核心思想:用正的模值来表示负数——不需要额外的减法器,加法器天然就支持了减法。
# 4. -x = ~x + 1的证明
# 4.1 代数推导
在模 2^N 的数学环上,我们要找 y 使得 x + y ≡ 0 (mod 2^N)。
~x = (2^N - 1) - x (按位取反的定义)
~x + 1 = 2^N - x (两边 +1)
x + (~x + 1) = 2^N ≡ 0 (mod 2^N)
2
3
因此 -x ≡ ~x + 1。这是一个纯代数的恒等式——不依赖任何硬件实现。
int x = 42;
printf("-x = %d, ~x+1 = %d\n", -x, ~x + 1); /* 两个输出相同 */
printf("%d\n", -x == ~x + 1); /* 1(真) */
2
3
# 4.2 晶体管级验证
用 4 位补码表示进行验证(硬件验证的最小规模):
x = 5 → 0101
~x = 1010
~x+1 = 1011 → 这是 -5 的补码
x + (-x) = 0101 + 1011 = 10000 → 丢弃溢出 → 0000 = 0 ✅
2
3
4
5
汇编层面(gcc -O2):
; -x = ~x + 1 在 x86 上用一条 NEG 指令
neg eax ; eax = -eax → 等价于 NOT + INC
2
x86 的 NEG 指令内部等效于 NOT + ADD 1——直接对应补码的数学定义。
# 4.3 INT_MIN的特殊性
int x = INT_MIN; /* 0x80000000 = -2147483648 */
int y = -x; /* ⚠️ UB——有符号溢出! */
printf("%d\n", -x == x); /* 在大多数实现上输出 1,但这是 UB */
2
3
为什么 -INT_MIN == INT_MIN:
INT_MIN = 10000000_00000000_00000000_00000000
~INT_MIN = 01111111_11111111_11111111_11111111 (INT_MAX)
~INT_MIN + 1 = 10000000_00000000_00000000_00000000 (INT_MIN again!)
2
3
4
补码表示下,INT_MIN 的对偶是自己——因为在 32 位有符号补码中,负数比正数多一个(-2147483648 存在,但 2147483648 不存在于 32 位有符号中)。这是一个 UB 陷阱——不要对 INT_MIN 取负。
# 5. 有符号溢出是UB
# 5.1 UB不是随机行为
疑惑:有符号溢出是 UB——但我在我的机器上它总是回绕,这不是"定义好的"吗?
论证——C 标准 §6.5 para 5:
If an exceptional condition occurs during the evaluation of an expression (that is, if the result is not mathematically defined or not in the range of representable values for its type), the behavior is undefined.
有符号溢出是上述"结果不在可表示范围内"的情况——它就是 UB。
int x = INT_MAX;
x++; /* UB——尽管在你的机器上可能变成 INT_MIN */
2
UB 不等于"机器相关的回绕"——UB 意味着编译器可以做任何事。
# 5.2 编译器如何利用UB
/* 常见错误——用加法后检查来防御溢出 */
int safe_add(int a, int b) {
int result = a + b;
if (result < a) { /* ← 编译器:a+b 溢出是 UB——不可能发生 */
return INT_MAX; /* ← 这条代码在 -O2 下被删除 */
}
return result;
}
2
3
4
5
6
7
8
-O2 汇编:
safe_add:
lea eax, [rdi + rsi] ; result = a + b
ret ; 直接返回——不检查!
2
3
编译器推理链:
a + b溢出是 UB- 假设程序不包含 UB → a+b 不会溢出
- result < a 仅在溢出时成立 → 不可能触发
- 删除 if 块
这就是第 1 章支付引擎的根因——编译器把溢出检查优化掉了。
# 5.3 生产中的溢出case
/* Case 1:二分查找——经典溢出 */
int mid = (low + high) / 2;
/* low + high 可能溢出!→ mid = (low + (high - low) / 2) */
/* Case 2:数组索引 */
int index = user_input * element_size;
/* 如果 user_input 很大,index 可能变成负数——访问非法地址 */
/* Case 3:时间戳 */
int future = now + timeout_ms;
/* timeout_ms 来自网络包——可能被恶意构造导致溢出 */
2
3
4
5
6
7
8
9
10
11
安全做法——提前检查:
int safe_add(int a, int b, int *result) {
if (a > 0 && b > INT_MAX - a) return -1; /* 正溢出 */
if (a < 0 && b < INT_MIN - a) return -1; /* 负溢出 */
*result = a + b;
return 0;
}
2
3
4
5
6
# 6. 无符号回绕机制
# 6.1 回绕是定义好的
unsigned int x = UINT_MAX;
x++; /* ✅ 不是 UB!标准保证回绕到 0 */
printf("%u\n", x); /* 0 */
2
3
C 标准 §6.2.5 para 9 明确保证无符号整数的运算遵循模 2^N 的算术——回绕是语言标准化的行为,不是实现定义行为。
# 6.2 有符号与无符号对比
| 行为 | int(有符号) | unsigned int(无符号) |
|---|---|---|
INT_MAX/UINT_MAX + 1 | UB | 0(定义好的回绕) |
0 - 1 | UB | UINT_MAX(回绕) |
-1 | -1 | UINT_MAX(无符号无负数) |
| 编译器优化 | 可假设不溢出→删除检查 | 不可假设——必须生成正确的回绕代码 |
# 6.3 Implicit conversion陷阱
/* 陷阱——有符号和无符号混用 */
int a = -1;
unsigned int b = 1;
if (a < b) {
printf("a < b\n"); /* 这条不会执行! */
}
/* 为什么?C 语言的"usual arithmetic conversions"规则:
当 int 和 unsigned int 比较时,int 被隐式转为 unsigned int
(unsigned int)(-1) = UINT_MAX >> 1 → a < b 为假
*/
2
3
4
5
6
7
8
9
10
11
12
永远不要让有符号和无符号整数直接比较或运算。如果需要,显式转换:
if ((int)b > 0 && a < (int)b) { /* ✅ 显式、可读 */
# 7. 六种位运算符详解
# 7.1 六种运算符汇编
unsigned int a = 0x5A; /* 01011010 */
unsigned int b = 0x3C; /* 00111100 */
unsigned int r_and = a & b; /* 00011000 = 0x18 */
unsigned int r_or = a | b; /* 01111110 = 0x7E */
unsigned int r_xor = a ^ b; /* 01100110 = 0x66 */
unsigned int r_not = ~a; /* 10100101 = 0xFFFFFFA5 (32位) */
unsigned int r_shl = a << 2; /* 0101101000 = 0x168 */
unsigned int r_shr = a >> 2; /* 00010110 = 0x16 */
2
3
4
5
6
7
8
9
汇编(x86-64):
; a & b
and eax, edx ; 1 条指令、1 cycle
; a | b
or eax, edx ; 1 条指令、1 cycle
; a ^ b
xor eax, edx ; 1 条指令、1 cycle
; ~a
not eax ; 1 条指令、1 cycle
; a << 2
shl eax, 2 ; 1 条指令、1 cycle
; a >> 2 (无符号)
shr eax, 2 ; 1 条指令、1 cycle
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
所有位运算在 CPU 上都是 1 条指令、1 个时钟周期。没有任何高阶语言能提供比 C 更接近硬件的位操作。
# 7.2 左移的逻辑与陷阱
unsigned int x = 1;
x = x << 31; /* ✅ 0x80000000 = 2147483648 —— 合法 */
x = x << 1; /* ✅ 0x00000000 —— 无符号溢出回绕 */
int y = 1;
y = y << 30; /* ✅ 正数——合法 */
y = y << 1; /* ❌ UB——有符号溢出(1<<31 超出了 INT_MAX) */
2
3
4
5
6
7
左移的 UB 边界:
- 无符号:左边被移出的位被丢弃——回绕
- 有符号:结果超出类型范围 → UB
- 移位量 ≥ 类型位宽(如
int << 32)→ UB - 移位量为负 → UB
# 7.3 右移的算术与逻辑
int x = -8; /* 补码: 11111000 */
int y = x >> 2; /* 算术右移: 11111110 = -2 ✅ */
/* 高位填充符号位 */
unsigned int u = 0xFFFFFFF8; /* 无符号: 4294967288 */
unsigned int v = u >> 2; /* 逻辑右移: 0x3FFFFFFE */
/* 高位填充 0 */
2
3
4
5
6
7
汇编差异:
; 有符号右移 → SAR (Shift Arithmetic Right)
sar eax, 2 ; 高位填充符号位
; 无符号右移 → SHR (Shift Logical Right)
shr eax, 2 ; 高位填充 0
2
3
4
5
关键规则:int >> N 是实现定义行为——C 标准不强制是算术右移还是逻辑右移。但在所有现代编译器上,有符号整数使用算术右移(保持符号)。
# 8. 经典位运算技巧
# 8.1 八大基本功技巧
技巧 1:判断奇偶
(x & 1) == 0 → 偶数
(x & 1) == 1 → 奇数
2
技巧 2:乘除 2 的幂
x << 3 → x × 8
x >> 4 → x ÷ 16(无符号或有符号非负数)
2
技巧 3:清零最低位 1
x & (x - 1)
/* 原理:
x = 01011000 (88)
x-1 = 01010111
x&(x-1)=01010000 → 最低位的 1 被清零 */
2
3
4
5
技巧 4:获取最低位 1
x & -x /* = x & (~x + 1) */
/* 原理:-x = ~x+1 只保留最低位 1 */
2
技巧 5:判断 2 的幂
x > 0 && (x & (x - 1)) == 0
/* 2的幂的特点:二进制中只有 1 个 1 */
2
技巧 6:无临时变量交换
a ^= b; b ^= a; a ^= b;
技巧 7:绝对值
int mask = x >> 31;
(x + mask) ^ mask;
2
技巧 8:大小写转换
'A' | 0x20 → 'a' /* 大写→小写 */
'a' & 0xDF → 'A' /* 小写→大写 */
2
# 8.2 组合技巧的实际应用
/* 计算二进制中 1 的个数——Brian Kernighan 算法 */
int popcount(unsigned int x) {
int count = 0;
while (x) {
x &= (x - 1); /* 每次清零最低位的 1 */
count++;
}
return count;
}
2
3
4
5
6
7
8
9
# 8.3 与除法的性能对比
/* 1000万次操作——Intel i7 实测 */
volatile unsigned int x = 12345678;
/* 除法 */
x / 16; /* ~2.3 ns → 除法器需要多个 cycle */
/* 右移 */
x >> 4; /* ~0.3 ns → 1 条指令 */
/* 取模 */
x % 16; /* ~2.3 ns */
/* 位与 */
x & 0x0F; /* ~0.3 ns → 1 条指令 */
2
3
4
5
6
7
8
9
10
11
12
13
14
位运算比除法和取模快 5~8 倍。
# 9. 位掩码与标志位
# 9.1 位掩码的设计模式
/* 用一个 uint32_t 代表 32 个布尔标志——零内存浪费 */
#define FLAG_ENABLED (1 << 0) /* bit 0 */
#define FLAG_ACTIVE (1 << 1) /* bit 1 */
#define FLAG_ERROR (1 << 2) /* bit 2 */
#define FLAG_VERIFIED (1 << 3) /* bit 3 */
uint32_t flags = 0;
/* 设置 */
flags |= FLAG_ENABLED; /* 开启 bit 0 */
flags |= (FLAG_ACTIVE | FLAG_ERROR); /* 同时开启多个 */
/* 清零 */
flags &= ~FLAG_ACTIVE; /* 关闭 bit 1 */
/* 翻转 */
flags ^= FLAG_ERROR; /* 翻转 bit 2 */
/* 测试 */
if (flags & FLAG_VERIFIED) { /* 测试 bit 3 */
/* 已验证 */
}
/* 清零所有标志 */
flags = 0;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
与 bool 数组的对比:
| 维度 | uint32_t 位掩码 | bool flags[32] |
|---|---|---|
| 内存 | 4 字节 | 32 字节(8×) |
| 原子性 | 1 个 32 位变量天然原子 | 数组整体不原子 |
| 网络传输 | 直接发送 4 字节 | 需要序列化 |
| 可读性 | 需要宏辅助 | 直接 flags[3] |
# 9.2 用宏封装位操作
#define BIT(n) (1UL << (n))
#define SET_BIT(v, n) ((v) |= BIT(n))
#define CLR_BIT(v, n) ((v) &= ~BIT(n))
#define TGL_BIT(v, n) ((v) ^= BIT(n))
#define CHK_BIT(v, n) (((v) >> (n)) & 1)
uint32_t status = 0;
SET_BIT(status, 5); /* 设置第 5 位 */
if (CHK_BIT(status, 5)) { /* 检查第 5 位 */ }
CLR_BIT(status, 5); /* 清零第 5 位 */
2
3
4
5
6
7
8
9
10
# 9.3 sizeof不是函数
int x = 10;
printf("%zu\n", sizeof(x)); /* 4 */
printf("%zu\n", sizeof x); /* 4——可以用在变量上(不加括号) */
printf("%zu\n", sizeof(int)); /* 4——但用于类型时必须加括号 */
/* sizeof 是编译期求值的——不产生任何运行时代码 */
2
3
4
5
6
汇编验证:
; printf("%zu\n", sizeof(x));
mov esi, 4 ; ← 编译期直接计算为 4,没有运行时指令
mov edi, fmt_str
call printf
2
3
4
sizeof 完全在编译期求值——它是运算符,不是函数。这是 C 语言中最常见的误解之一。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的支付引擎,六个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
| ① -2100000000 在 int 中为何溢出? | 第 3-4:补码不对称——负数比正数多 1,-(2100000000) 通过补码运算得到 2194967296 > INT_MAX |
| ② 有符号溢出是 UB——编译器怎么办? | 第 5:-O2 下删除溢出的检查路径——因为 UB 被假定为"不可能发生" |
| ③ 无符号溢出为何是回绕? | 第 6:C 标准明确保证无符号整数运算在模 2^N 环上——不是 UB |
| ④ 六种位运算符怎么执行? | 第 7:全部是单周期 CPU 指令——and/or/xor/not/shl/shr |
| ⑤ 经典位运算技巧? | 第 8:n&(n-1)清零最低位1、n&-n获取最低位1、异或交换等 |
| ⑥ 位掩码怎么设计? | 第 9:1<<n 定义标志位、|= 设置、& ~ 清零 |
这个支付引擎的安全 rewrite:
#include <stdint.h>
#include <stdbool.h>
/* 安全的加法——提前检查,避免溢出 */
bool safe_add_int(int32_t a, int32_t b, int32_t *result) {
if (a > 0 && b > INT32_MAX - a) return false; /* 正溢出 */
if (a < 0 && b < INT32_MIN - a) return false; /* 负溢出 */
*result = a + b;
return true;
}
bool safe_sub_int(int32_t a, int32_t b, int32_t *result) {
if (b == INT32_MIN) return false; /* -b 会溢出 */
return safe_add_int(a, -b, result);
}
int transfer_fixed(Account *from, Account *to, int amount) {
int32_t new_from_bal, new_to_bal;
/* 负数金额直接拒绝 */
if (amount <= 0) return -1;
if (from->balance < amount) return -2;
/* 安全的加减 */
if (!safe_sub_int(from->balance, amount, &new_from_bal)) return -3;
if (!safe_add_int(to->balance, amount, &new_to_bal)) return -4;
/* 提交 */
from->balance = new_from_bal;
to->balance = new_to_bal;
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 10.2 位运算实战案例
用一个真实场景收束本章所有位运算知识——解析 IPv4 包头:
#include <stdint.h>
#include <stdio.h>
/* IPv4 包头(不含选项字段)——固定 20 字节 */
typedef struct {
uint8_t ver_ihl; /* 版本(4bit) + 首部长度(4bit) */
uint8_t dscp_ecn;
uint16_t total_length;
uint16_t identification;
uint16_t flags_fragment; /* 标志(3bit) + 片偏移(13bit) */
uint8_t ttl;
uint8_t protocol;
uint16_t header_checksum;
uint32_t src_addr;
uint32_t dst_addr;
} __attribute__((packed)) IPv4Header;
void parse_ip_header(const uint8_t *raw) {
const IPv4Header *ip = (const IPv4Header *)raw;
/* 位掩码提取——版本号(4bit) */
uint8_t version = (ip->ver_ihl >> 4) & 0x0F; /* 右移4位+掩码 */
/* 位掩码提取——首部长度(4bit) */
uint8_t ihl = ip->ver_ihl & 0x0F; /* 低4位 */
/* 标志位(3bit) + 片偏移(13bit) —— ntohs 后提取 */
uint16_t fo = ntohs(ip->flags_fragment);
uint8_t df = (fo >> 14) & 0x01; /* Don't Fragment 标志 */
uint8_t mf = (fo >> 13) & 0x01; /* More Fragments 标志 */
uint16_t frag_offset = fo & 0x1FFF; /* 13bit 的片偏移 */
/* 把 4 字节的 IP 地址转成可读字符串(用位运算而非 snprintf) */
uint32_t addr = ntohl(ip->src_addr);
printf("%u.%u.%u.%u\n",
(addr >> 24) & 0xFF, /* 最高字节 */
(addr >> 16) & 0xFF, /* 次高字节 */
(addr >> 8) & 0xFF, /* 次低字节 */
addr & 0xFF); /* 最低字节 */
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
这个函数展示了位运算的四大模式:
- 移位+掩码提取:
(x >> N) & MASK - 掩码直接提取:
x & MASK - 网络字节序转换后提取:
ntohs+ 位运算 - 字节级拆分:移位到最低字节再
& 0xFF
# 10.3 设计哲学回扣
哲学 1:补码是数学必然——硬件用模运算,软件用补码
8 位 CPU 只有 8 位寄存器,任何溢出都被自动丢弃——这就是模 256 运算。补码不是"一种编码方式",而是在这个物理约束下唯一能使加法器同时处理正负数的方案。原码和反码被淘汰不是设计缺陷——它们是真正的数学推导中间步骤。
哲学 2:UB 是编译器的优化燃料——不是运行时的随机数
C 语言的有符号溢出是 UB,不是因为标准委员会忘了定义它——而是故意不定义,给编译器留出优化空间。编译器基于"溢出不会发生"的假设做死代码消除——这不是 bug,是 feature。代价是程序员必须自己处理溢出检查。UB 不是运行时的赌博,而是编译期的信任契约。
哲学 3:位运算 = 与晶体管直接对话
& | ^ ~ << >> 六种操作都直接对应 CPU 的六条单周期指令。C 语言把它们暴露给程序员,不做任何封装——这是 C "零开销抽象"的终极体现。Java 也有 &,但你不能用它操作 MMIO 寄存器——因为 Java 没有"地址"这个概念。
哲学 4:sizeof 是编译期运算符——不要把它当函数
sizeof(variable) 在编译期求值→没有运行时代码生成。它可以不带括号(sizeof x),因为它是运算符而不是函数。C 语言中凡是能在编译期做的事,一定不在运行时做。
# 10.4 速查表
| 操作 | 语法 | 效果 |
|---|---|---|
| 按位与 | a & b | 对应位都为 1→1 |
| 按位或 | a \| b | 对应位有 1→1 |
| 按位异或 | a ^ b | 对应位不同→1 |
| 按位取反 | ~a | 每位取反 |
| 左移 | a << n | ×2^n |
| 右移 | a >> n | ÷2^n(有符号→算术、无符号→逻辑) |
| 设置位 | x \|= (1<<n) | 第 n 位置 1 |
| 清零位 | x &= ~(1<<n) | 第 n 位清 0 |
| 测试位 | x & (1<<n) | 第 n 位是否为 1 |
| 翻转位 | x ^= (1<<n) | 第 n 位翻转 |
| 清零低位1 | x & (x-1) | 最低位的 1→0 |
| 取低1位 | x & -x | 只保留最低位 1 |
溢出安全清单:
1. 有符号加法——提前用 safe_add 检查
2. 有符号左移——结果不超过 INT_MAX → UB
3. 移位量 ≥ 位宽——UB(如 int << 32)
4. 无符号溢出——合法但需确认是否符合业务预期
5. 有符号和无符号混合——先显式转换再运算
2
3
4
5
下一篇:位运算让我们可以在比特级别操控数据,下一步进入 08.IEEE754浮点本质——把浮点数的符号/指数/尾数拆开,揭开 0.1+0.2≠0.3 的硬件真相。