-1 为什么是一串全 1:补码的减法魔术,与整数溢出那些不讲道理的坑
大一学 C,有天我用调试器盯着一个 int 变量看它的二进制,存 -1 的时候,32 位全是 1:11111111 11111111 11111111 11111111。
我当时的第一反应是:这不对吧?-1 不就是”1 前面加个负号”吗,怎么会是一堆 1?符号跑哪去了?
顺着这个疑问往下挖,我才发现,负数在内存里的样子,和我以为的完全不一样。而这套叫”补码”的规则背后,藏着一个让整个 CPU 变简单的设计,一批优雅的位运算性质,以及一类我后来栽过好几次的坑。
一个不对称的谜题
先看一个更勾人的现象。int 是 32 位,你可能以为它能表示的正负范围是对称的。但事实是:
#include <stdio.h>
#include <limits.h>
int main(void) {
printf("max = %d\n", INT_MAX); // max = 2147483647
printf("min = %d\n", INT_MIN); // min = -2147483648
return 0;
}最大值是 2147483647,最小值是 -2147483648。最小值的绝对值,比最大值大了整整 1。
为什么会差这个 1?为什么不是干净的 ±2147483647?这个不对称不是 bug,它是补码这套设计的直接后果。想讲清楚,得从”如果让你来设计”开始。
第一种想法:原码,最直觉也最麻烦
假设你手上只有 4 个二进制位(先用 4 位讲清楚,道理和 32 位一模一样),要同时表示正数和负数。最自然的想法是:拿出最高位当符号位,0 表示正,1 表示负,剩下 3 位存数值大小。
这叫原码。于是:
0011 = +3 1011 = -3
0101 = +5 1101 = -5
0000 = +0 1000 = -0 ← 问题来了原码符合直觉,但它有两个要命的毛病。
第一,出现了两个零:0000 是 +0,1000 是 -0。同一个数字有两种编码,不仅浪费了一个宝贵的位模式,还让”判断是否为零”变得别扭。
第二,也是更致命的:加减法不统一。算 5 + (-3),你不能直接把 0101 和 1101 加起来(那样会得到一个错误的结果),必须先看符号位、比较大小、决定是做加法还是减法、结果取谁的符号。这意味着 CPU 里要专门造一套处理符号的复杂电路。
上一篇讲门电路时我们看到,加法器本身就是一堆逻辑门。如果每次加负数还要额外判断符号,硬件成本会翻上去。有没有办法,让减法自动变成加法?
第二种想法:反码,离答案差一步
一个中间产物叫反码:正数不变,负数是把对应正数逐位取反。
+3 = 0011
-3 = 1100 (把 0011 每一位翻转)反码让减法开始有点像加法了,但它还是没解决两个零的问题(1111 是 -0),而且做加法时会冒出一个”循环进位”的麻烦——最高位溢出的那个 1 得绕回来加到最低位。仍然不够干净。
反码只差最后一步。把这一步迈过去,就是补码。
补码:给负数的编码 +1,减法就消失了
补码的规则只比反码多一步:正数不变;负数先取反(得到反码),再加 1。
求 -3 的 4 位补码:
3 = 0011
取反 = 1100 (反码)
加 1 = 1101 ← 这就是 -3 的补码看似只是多加了个 1,但这一下,所有麻烦全消失了。我们验证一下 5 + (-3),直接把两个补码相加,不做任何符号判断:
5 = 0101
(-3) = 1101
─────────────
10010 (5 位)结果是 10010,有 5 位。但我们只有 4 位,最高那个 1 是溢出,直接丢掉,剩下 0010,正好是 +2。5 + (-3) = 2,完全正确,而且全程只用了加法器,一次符号判断都没有。
这就是补码的魔术:它让 CPU 里那个由逻辑门堆出来的加法器,不加任何额外电路,就能同时算加法和减法。减法 a - b 直接变成 a + (-b),而 -b 用补码一表示,加上去就对了。
补码还顺手消灭了 -0
用补码,0000 是唯一的零。原本原码里那个多余的 1000(-0),在补码体系里被重新利用,拿去表示了一个原码表示不了的数:-8。这正是开头那个”不对称”的来源,我们马上会回到它。
为什么补码”恰好”就是对的:它其实是时钟算术
到这里你可能觉得,补码像个凑出来的技巧:取反、加 1、丢溢出,碰巧结果就对了。但它一点都不巧,背后是一个干净的数学结构:模运算。
想想墙上的钟表,它是模 12 的。现在是 10 点,过 5 个小时是几点?10 + 5 = 15,但钟面上没有 15,15 - 12 = 3,是 3 点。在钟表的世界里,15 和 3 是同一个位置,因为它们模 12 同余。
更妙的是:在钟面上,倒拨 3 小时,和正拨 9 小时,结果完全一样(因为 -3 和 +9 模 12 同余)。减法,在模运算里天然就是加法。
4 位二进制能表示的位模式有 2^4 = 16 种,所以它是一个模 16 的系统。补码做的事,就是把 -3 映射成 16 - 3 = 13,而 13 的二进制正是 1101——和我们前面取反加 1 算出来的一模一样。
把 16 个位模式想成一个环(像时钟):
0000(0)
1111(-1) 0001(1)
1110(-2) 0010(2)
1101(-3) 0011(3)
... ...
1001(-7) 0111(7)
1000(-8)
上半圈是正数,下半圈是负数,
最高位(符号位)就是"你在哪半圈"的指示灯。于是”取反加 1”不再是黑魔法,它就是在算 2^n - x,也就是 x 在模 2^n 环里的相反数。补码之所以能让加减统一,是因为它本质上就是在一个模 2^n 的环里做算术,而模运算里加减本来就是一回事。
理解了这一层,补码从一个”要背的规则”,变成了一件”想通了就忘不掉”的事。这也是我大一第一次感到,底层那些看着别扭的约定,往往不是随意的,而是被某个更深的简洁性逼出来的。
回到那个多出来的 1
现在能解释开头的谜题了。n 位补码的表示范围是:
对 4 位,是 [-8, 7];对 32 位的 int,就是 [-2147483648, 2147483647]。
为什么负数那头多一个?因为零占用了”正数那半”的一个编码。位模式一共 2^n 个,零算作非负的一员,于是非负数(含 0)有 2^(n-1) 个(0 到 2^(n-1)-1),负数也有 2^(n-1) 个(-1 到 -2^(n-1))。负数不需要给 0 留位置,所以能多探到一个 -2^(n-1)。
这个多出来的 INT_MIN = -2147483648,是个危险的孤儿——它没有对应的正数。+2147483648 超出了 int 范围,根本存不下。于是:
printf("%d\n", -INT_MIN); // 期望 2147483648,实际还是 -2147483648
printf("%d\n", abs(INT_MIN)); // abs 一个最小值,结果依然是负的-INT_MIN 在数学上是 2^31,可它塞不进 int,于是绕回来还是 INT_MIN 自己。abs(INT_MIN) 同理。这不是库写错了,是补码范围不对称的必然结果。很多真实的崩溃和安全漏洞,就藏在这个不起眼的孤儿身上。
补码顺手还送了你几个位运算恒等式
补码的好处不止”减法变加法”。一旦负数用补码表示,一批看着神秘的位运算恒等式就全都自洽了。
最有用的一个是:
-x == ~x + 1
~x == -x - 1第一行就是补码的定义(取反加一)。把它移项,就得到第二行:~x(按位取反)等于 -(x + 1)。所以 ~0 是 -1,~5 是 -6,~(-1) 是 0。以前我背 ~ 是”按位取反”却不知道它的数值含义,理解补码后才发现,取反在数值上就是”取相反数再减一”。
第二个是符号扩展(sign extension)。当你把一个 8 位的 signed char 赋给 32 位的 int,不能简单地在前面补 0。-1 的 8 位补码是 0xFF,如果补 0 变成 0x000000FF,那就成了 255,符号丢了。正确做法是补符号位:负数补 1,正数补 0,于是 0xFF 扩展成 0xFFFFFFFF,还是 -1。
signed char sc = -1; // 0xFF
int a = sc; // 符号扩展 → 0xFFFFFFFF,还是 -1
unsigned char uc = 0xFF;
int b = uc; // 零扩展 → 0x000000FF,是 255x86 里专门有两条指令干这事:movsx(符号扩展,给有符号数用)和 movzx(零扩展,给无符号数用)。C 里有符号和无符号类型转换规则不同,底层就差在这一位补什么。
第三个是算术右移。有符号数右移,高位补的是符号位(算术右移),这样 -8 >> 1 得到 -4,相当于除以 2 向下取整;无符号数右移则补 0(逻辑右移)。同一个 >>,对有符号和无符号做的是两件事,而这恰恰是补码让”右移即除二”对负数也成立的自然结果。
这些恒等式我一开始都是零散记的,直到把补码想成模运算,它们才串成一张自洽的网。
-1 在内存里到底怎么摆:字节序
我们说 -1 是 32 位全 1。那这 4 个字节,在内存里从低地址到高地址,是按什么顺序摆的?这就牵出另一个绕过很多人的概念:字节序(endianness)。
对 -1(0xFFFFFFFF)来说,四个字节都是 FF,看不出差别。但换一个每个字节都不同的数,比如 0x12345678,顺序就暴露了:
大端 (big-endian):高位字节放低地址
低地址 → 高地址: 12 34 56 78 (和人读写顺序一致)
小端 (little-endian):低位字节放低地址
低地址 → 高地址: 78 56 34 12 (x86、ARM 主流都是这个)用代码把字节挨个抠出来看:
#include <stdio.h>
int main(void) {
int x = 0x12345678;
unsigned char *p = (unsigned char *)&x; // 把 int 当字节数组看
printf("%02x %02x %02x %02x\n", p[0], p[1], p[2], p[3]);
// 小端机器上输出:78 56 34 12
return 0;
}为什么大多数机器选小端?一个好处是类型截断很自然:想把一个 int 当 char 用,只取它的低字节,直接读地址最低的那个字节就行,不用管原来是几字节的类型。
字节序不是学术细节,它在三个地方会当场咬你:网络传输(网络字节序规定是大端,所以有 htonl、ntohl 这些转换函数)、读写二进制文件、以及跨不同架构交换数据。它和补码合在一起,才凑齐”一个整数在内存里到底长什么样”的完整答案:补码决定每一位是什么,字节序决定这些字节按什么顺序摆。
char 到底有没有符号:一个实现定义的坑
还有一个和补码纠缠在一起、极其隐蔽的坑:char 到底是有符号还是无符号?
你可能以为 char 就是”字符”,和正负没关系。但 C 里其实有三个不同的类型:char、signed char、unsigned char。而 char 具体等价于哪一个,是实现定义的——x86 Linux 上通常是有符号,某些 ARM 平台却是无符号。同一份代码,换个平台行为就变。
这会怎么咬人?看这段:
char c = 0xFF; // 在 char 有符号的平台上,这其实是 -1
if (c == 0xFF) { // 永远为假!
// 因为比较时 c(-1)被符号扩展成 0xFFFFFFFF,
// 而 0xFF 是 255,两者不等
}根子还是补码加符号扩展:有符号 char 里的 0xFF 是 -1,一参与比较就被扩展成 0xFFFFFFFF,和 255 对不上。读二进制文件、处理 UTF-8 的字节流时,只要某个字节的最高位是 1(值 ≥ 128),用 char 存就可能变成负数,在比较、当数组下标、做位运算时集体出乱子。
把字节当字节,就用 unsigned char
一条能省掉无数麻烦的规矩:只要你是在处理”原始字节”而不是”可打印字符”,就显式写 unsigned char。需要一个”能装任意字节又不管符号”的类型时,也优先用它。char 的符号性靠不住,别赌。
溢出:无符号会”绕”,有符号是”未定义”
补码把范围钉死在 [-2^(n-1), 2^(n-1)-1]。一旦算出的结果越过这个边界,就是溢出。而这里有一个绝大多数初学者都不知道、却极其重要的区别。
无符号数溢出,行为是明确定义的:它就是模 2^n 回绕(wrap around)。
unsigned char u = 255; // 8 位无符号的最大值
u = u + 1; // 变成 0,干净地绕回去
unsigned int z = 0;
z = z - 1; // 不是 -1,而是 4294967295(绕到最大)无符号的世界就是那个模 2^n 的环,绕回去是规则的一部分,可以放心依赖。
但有符号数溢出,是未定义行为(undefined behavior,UB)。
int x = INT_MAX;
int y = x + 1; // UB!不是"保证变成 INT_MIN"注意措辞:不是”溢出后变成 INT_MIN”,而是整个程序的行为变得未定义。C 标准不保证任何结果——它可能碰巧回绕,可能被编译器优化成别的东西,可能让程序崩溃。
为什么有符号溢出是 UB,无符号却不是
历史上,C 要跑在各种奇怪的机器上,不是所有机器都用补码(早年有原码、反码机器)。为了不把标准绑死在某种表示上,C 干脆规定有符号溢出的结果”未定义”,把自由留给实现。直到 C23,标准才终于强制有符号数用补码——但即便如此,有符号算术溢出依然是 UB。补码是表示方式,溢出是运算越界,两回事。
UB 不是”结果随机”,而是编译器可以假装它不会发生
“未定义”听起来只是”结果不确定”,似乎无伤大雅。但真正的杀伤力在于:编译器会假设 UB 永远不发生,并据此做激进优化。
看这段想检测溢出的代码,很多人会这么写:
// 想判断 x + 1 是否溢出
int x = /* ... */;
if (x + 1 < x) {
// 以为溢出时 x+1 会绕成负数,于是比 x 小
puts("overflow!");
}逻辑看着没问题:如果 x 是 INT_MAX,x + 1 溢出绕成负数,就会小于 x。但编译器的推理是这样的:既然有符号溢出是 UB,那我可以假设它永远不溢出;既然不溢出,x + 1 就必然大于 x,那么 x + 1 < x 永远是假。于是编译器直接把整个 if 删掉。
你的溢出检测,被优化没了。这不是假想,主流编译器在开优化时确实会这么干,它还是不少 CVE 安全漏洞的根源。
别用有符号溢出去检测有符号溢出
if (x + 1 < x) 这类写法,是在用一个 UB 去检测这个 UB 本身,逻辑上就站不住。编译器有权假设 UB 不存在,你的检测自然失效。正确姿势见下文。
最阴险的坑:有符号和无符号一相遇
前面说无符号溢出是”定义良好”的,这话没错,但无符号数藏着一个比溢出更常见的坑:当有符号数和无符号数在同一个表达式里相遇,有符号的那个会被悄悄转成无符号。
这是 C 的”通常算术转换”规则。后果可以非常反直觉:
int a = -1;
unsigned int b = 0;
if (a > b) {
puts("我竟然大于 0?"); // 这一行真的会执行
}-1 > 0 明明是假,可这段代码会打印。因为比较时 a 被转成无符号,-1 的补码 0xFFFFFFFF 被解读成无符号的 4294967295,当然大于 0。符号位没变,变的是”怎么解读这堆比特”。
更经典的是这个循环,几乎每个 C 程序员都写错过一次:
size_t n = 5;
for (size_t i = n - 1; i >= 0; i--) {
// 本想倒着遍历 4 3 2 1 0
}size_t 是无符号的,i >= 0 永远为真,循环根本停不下来。当 i 减到 0 再减一,它不会变成 -1,而是绕成一个天文数字,继续跑,直到访问越界崩溃。
同一类坑还藏在标准库里。strlen 返回的是 size_t,于是:
if (strlen(s) - 1 >= 0) // 永远为真:size_t 不可能小于 0当 s 是空串,strlen(s) 是 0,0 - 1 绕成巨大的无符号数,判断照样成立,后面一旦拿它当下标就出事。
有符号和无符号,尽量别混着比
这些 bug 都不报错、不溢出,编译器最多给个警告(记得开 -Wall -Wsign-compare)。根子还是补码:-1 和 4294967295 是同一堆比特,唯一的区别是你告诉编译器”把最高位当符号,还是当数值”。理解了这一点,再看这些坑,就不再是玄学。
正确的做法:把溢出挡在发生之前
既然不能”先溢出再检测”,那就在运算之前判断,或者借助专门的工具。
方法一,运算前用边界判断(不触发 UB):
#include <limits.h>
// 安全地判断 a + b 是否会溢出
if (b > 0 && a > INT_MAX - b) { /* 会上溢 */ }
if (b < 0 && a < INT_MIN - b) { /* 会下溢 */ }方法二,用编译器内建函数,它会告诉你有没有溢出,且不踩 UB:
int result;
if (__builtin_add_overflow(a, b, &result)) {
// 溢出了,result 里是回绕值,但至少你知道溢出发生了
}方法三,让工具在运行时抓现行。编译时加上 UBSan(未定义行为消毒器):
gcc -fsanitize=undefined overflow.c -o overflow
./overflow
# runtime error: signed integer overflow:
# 2147483647 + 1 cannot be represented in type 'int'UBSan 会在溢出真正发生的那一刻,打印出精确的文件、行号和越界的值,比事后猜测强太多。
亲手复现这一切
把上面的现象凑成一个小程序,自己跑一遍:
#include <stdio.h>
#include <string.h>
#include <limits.h>
int main(void) {
// 1. 负数的补码:-1 是全 1
printf("%08x\n", -1); // ffffffff
// 2. 不对称的边界
printf("%d %d\n", INT_MAX, INT_MIN); // 2147483647 -2147483648
// 3. 孤儿 INT_MIN 取负还是自己
printf("%d\n", -INT_MIN); // -2147483648(UB,典型表现)
// 4. 无符号干净回绕
unsigned z = 0;
printf("%u\n", z - 1); // 4294967295
// 5. 有符号败给无符号:-1 竟然大于 0
int a = -1;
unsigned b = 0;
printf("%d\n", a > b); // 1,也就是 true
return 0;
}先普通编译看现象,再用 gcc -fsanitize=undefined 编一次,你会看到 UBSan 把第 3 条的有符号溢出当场标红。两次输出对照着看,“定义良好”和”未定义”的分界一下就清楚了。
写在最后
补码这套东西,最开始让我觉得计算机故意跟人过不去:好好的负数,非要弄成一串反着的 1 再加个 1。可当我把它追到模运算那一层,才明白它恰恰相反——它是用表示上的一点点别扭,换来了硬件上的巨大简化,让一个加法器就能包办所有加减,还顺带把符号扩展、按位取反、算术右移全都统一进同一套逻辑。好的底层设计常常长这样:某个地方看着不顺眼,是因为它把复杂性搬到了你看不见的、更划算的地方。
而整数溢出、字节序、有符号无符号那些坑教给我的是另一件事:对”未定义行为”和”实现定义”要有敬畏。计算机不会永远按你的直觉办事,你以为的”溢出会绕回来”,在编译器眼里可能是”这段代码根本不会执行”;你以为的”-1 当然小于 0”,在类型转换面前可能彻底反过来;甚至同一个 char,换台机器有没有符号都会变。想写出可靠的代码,得先知道脚下哪里是实地、哪里是标准没兜底的悬空。
整数用有限的位,换来了精确,代价是范围有限、越界就翻车。那如果要表示的不是整数,而是 3.14 这种小数呢?有限的位又该怎么装下无限的实数?那是下一篇的事了。