-1 为什么是一串全 1:补码的减法魔术,与整数溢出那些不讲道理的坑

#C 语言#补码#整数溢出 共 5,183 字 约 17 分钟

大一学 C,有天我用调试器盯着一个 int 变量看它的二进制,存 -1 的时候,32 位全是 1:11111111 11111111 11111111 11111111

我当时的第一反应是:这不对吧?-1 不就是”1 前面加个负号”吗,怎么会是一堆 1?符号跑哪去了?

顺着这个疑问往下挖,我才发现,负数在内存里的样子,和我以为的完全不一样。而这套叫”补码”的规则背后,藏着一个让整个 CPU 变简单的设计,一批优雅的位运算性质,以及一类我后来栽过好几次的坑。

一个不对称的谜题

先看一个更勾人的现象。int 是 32 位,你可能以为它能表示的正负范围是对称的。但事实是:

c
UTF-8|7 Lines|
#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 位存数值大小。

这叫原码。于是:

Text
UTF-8|3 Lines|
0011 = +3          1011 = -3
0101 = +5          1101 = -5
0000 = +0          1000 = -0   ← 问题来了

原码符合直觉,但它有两个要命的毛病。

第一,出现了两个零:0000 是 +0,1000 是 -0。同一个数字有两种编码,不仅浪费了一个宝贵的位模式,还让”判断是否为零”变得别扭。

第二,也是更致命的:加减法不统一。算 5 + (-3),你不能直接把 01011101 加起来(那样会得到一个错误的结果),必须先看符号位、比较大小、决定是做加法还是减法、结果取谁的符号。这意味着 CPU 里要专门造一套处理符号的复杂电路。

上一篇讲门电路时我们看到,加法器本身就是一堆逻辑门。如果每次加负数还要额外判断符号,硬件成本会翻上去。有没有办法,让减法自动变成加法?

第二种想法:反码,离答案差一步

一个中间产物叫反码:正数不变,负数是把对应正数逐位取反。

Text
UTF-8|2 Lines|
+3 = 0011
-3 = 1100   (把 0011 每一位翻转)

反码让减法开始有点像加法了,但它还是没解决两个零的问题(1111 是 -0),而且做加法时会冒出一个”循环进位”的麻烦——最高位溢出的那个 1 得绕回来加到最低位。仍然不够干净。

反码只差最后一步。把这一步迈过去,就是补码。

补码:给负数的编码 +1,减法就消失了

补码的规则只比反码多一步:正数不变;负数先取反(得到反码),再加 1

Text
UTF-8|4 Lines|
求 -3 的 4 位补码:
  3      = 0011
  取反    = 1100     (反码)
  加 1    = 1101     ← 这就是 -3 的补码

看似只是多加了个 1,但这一下,所有麻烦全消失了。我们验证一下 5 + (-3),直接把两个补码相加,不做任何符号判断:

Text
UTF-8|4 Lines|
   5  = 0101
 (-3) = 1101
 ─────────────
       10010      (5 位)

结果是 10010,有 5 位。但我们只有 4 位,最高那个 1 是溢出,直接丢掉,剩下 0010,正好是 +25 + (-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 点。在钟表的世界里,153 是同一个位置,因为它们模 12 同余。

更妙的是:在钟面上,倒拨 3 小时,和正拨 9 小时,结果完全一样(因为 -3+9 模 12 同余)。减法,在模运算里天然就是加法。

4 位二进制能表示的位模式有 2^4 = 16 种,所以它是一个模 16 的系统。补码做的事,就是把 -3 映射成 16 - 3 = 13,而 13 的二进制正是 1101——和我们前面取反加 1 算出来的一模一样。

Text
UTF-8|12 Lines|
把 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 位补码的表示范围是:

[2n1, 2n11][-2^{n-1},\ 2^{n-1} - 1]

对 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 范围,根本存不下。于是:

c
UTF-8|2 Lines|
printf("%d\n", -INT_MIN);        // 期望 2147483648,实际还是 -2147483648
printf("%d\n", abs(INT_MIN));    // abs 一个最小值,结果依然是负的

-INT_MIN 在数学上是 2^31,可它塞不进 int,于是绕回来还是 INT_MIN 自己。abs(INT_MIN) 同理。这不是库写错了,是补码范围不对称的必然结果。很多真实的崩溃和安全漏洞,就藏在这个不起眼的孤儿身上。

补码顺手还送了你几个位运算恒等式

补码的好处不止”减法变加法”。一旦负数用补码表示,一批看着神秘的位运算恒等式就全都自洽了。

最有用的一个是:

Text
UTF-8|2 Lines|
-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

c
UTF-8|4 Lines|
signed char sc = -1;      // 0xFF
int a = sc;               // 符号扩展 → 0xFFFFFFFF,还是 -1
unsigned char uc = 0xFF;
int b = uc;               // 零扩展   → 0x000000FF,是 255

x86 里专门有两条指令干这事:movsx(符号扩展,给有符号数用)和 movzx(零扩展,给无符号数用)。C 里有符号和无符号类型转换规则不同,底层就差在这一位补什么。

第三个是算术右移。有符号数右移,高位补的是符号位(算术右移),这样 -8 >> 1 得到 -4,相当于除以 2 向下取整;无符号数右移则补 0(逻辑右移)。同一个 >>,对有符号和无符号做的是两件事,而这恰恰是补码让”右移即除二”对负数也成立的自然结果。

这些恒等式我一开始都是零散记的,直到把补码想成模运算,它们才串成一张自洽的网。

-1 在内存里到底怎么摆:字节序

我们说 -1 是 32 位全 1。那这 4 个字节,在内存里从低地址到高地址,是按什么顺序摆的?这就牵出另一个绕过很多人的概念:字节序(endianness)。

-1(0xFFFFFFFF)来说,四个字节都是 FF,看不出差别。但换一个每个字节都不同的数,比如 0x12345678,顺序就暴露了:

Text
UTF-8|5 Lines|
大端 (big-endian):高位字节放低地址
   低地址 → 高地址:  12 34 56 78    (和人读写顺序一致)

小端 (little-endian):低位字节放低地址
   低地址 → 高地址:  78 56 34 12    (x86、ARM 主流都是这个)

用代码把字节挨个抠出来看:

c
UTF-8|8 Lines|
#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;
}

为什么大多数机器选小端?一个好处是类型截断很自然:想把一个 intchar 用,只取它的低字节,直接读地址最低的那个字节就行,不用管原来是几字节的类型。

字节序不是学术细节,它在三个地方会当场咬你:网络传输(网络字节序规定是大端,所以有 htonlntohl 这些转换函数)、读写二进制文件、以及跨不同架构交换数据。它和补码合在一起,才凑齐”一个整数在内存里到底长什么样”的完整答案:补码决定每一位是什么,字节序决定这些字节按什么顺序摆。

char 到底有没有符号:一个实现定义的坑

还有一个和补码纠缠在一起、极其隐蔽的坑:char 到底是有符号还是无符号?

你可能以为 char 就是”字符”,和正负没关系。但 C 里其实有三个不同的类型:charsigned charunsigned char。而 char 具体等价于哪一个,是实现定义的——x86 Linux 上通常是有符号,某些 ARM 平台却是无符号。同一份代码,换个平台行为就变。

这会怎么咬人?看这段:

c
UTF-8|5 Lines|
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)。

c
UTF-8|4 Lines|
unsigned char u = 255;   // 8 位无符号的最大值
u = u + 1;               // 变成 0,干净地绕回去
unsigned int z = 0;
z = z - 1;               // 不是 -1,而是 4294967295(绕到最大)

无符号的世界就是那个模 2^n 的环,绕回去是规则的一部分,可以放心依赖。

但有符号数溢出,是未定义行为(undefined behavior,UB)。

c
UTF-8|2 Lines|
int x = INT_MAX;
int y = x + 1;           // UB!不是"保证变成 INT_MIN"

注意措辞:不是”溢出后变成 INT_MIN”,而是整个程序的行为变得未定义。C 标准不保证任何结果——它可能碰巧回绕,可能被编译器优化成别的东西,可能让程序崩溃。

为什么有符号溢出是 UB,无符号却不是

历史上,C 要跑在各种奇怪的机器上,不是所有机器都用补码(早年有原码、反码机器)。为了不把标准绑死在某种表示上,C 干脆规定有符号溢出的结果”未定义”,把自由留给实现。直到 C23,标准才终于强制有符号数用补码——但即便如此,有符号算术溢出依然是 UB。补码是表示方式,溢出是运算越界,两回事。

UB 不是”结果随机”,而是编译器可以假装它不会发生

“未定义”听起来只是”结果不确定”,似乎无伤大雅。但真正的杀伤力在于:编译器会假设 UB 永远不发生,并据此做激进优化

看这段想检测溢出的代码,很多人会这么写:

c
UTF-8|6 Lines|
// 想判断 x + 1 是否溢出
int x = /* ... */;
if (x + 1 < x) {
    // 以为溢出时 x+1 会绕成负数,于是比 x 小
    puts("overflow!");
}

逻辑看着没问题:如果 xINT_MAX,x + 1 溢出绕成负数,就会小于 x。但编译器的推理是这样的:既然有符号溢出是 UB,那我可以假设它永远不溢出;既然不溢出,x + 1必然大于 x,那么 x + 1 < x 永远是假。于是编译器直接把整个 if 删掉。

你的溢出检测,被优化没了。这不是假想,主流编译器在开优化时确实会这么干,它还是不少 CVE 安全漏洞的根源。

别用有符号溢出去检测有符号溢出

if (x + 1 < x) 这类写法,是在用一个 UB 去检测这个 UB 本身,逻辑上就站不住。编译器有权假设 UB 不存在,你的检测自然失效。正确姿势见下文。

最阴险的坑:有符号和无符号一相遇

前面说无符号溢出是”定义良好”的,这话没错,但无符号数藏着一个比溢出更常见的坑:当有符号数和无符号数在同一个表达式里相遇,有符号的那个会被悄悄转成无符号。

这是 C 的”通常算术转换”规则。后果可以非常反直觉:

c
UTF-8|5 Lines|
int a = -1;
unsigned int b = 0;
if (a > b) {
    puts("我竟然大于 0?");   // 这一行真的会执行
}

-1 > 0 明明是假,可这段代码会打印。因为比较时 a 被转成无符号,-1 的补码 0xFFFFFFFF 被解读成无符号的 4294967295,当然大于 0。符号位没变,变的是”怎么解读这堆比特”。

更经典的是这个循环,几乎每个 C 程序员都写错过一次:

c
UTF-8|4 Lines|
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,于是:

c
UTF-8|1 Line|
if (strlen(s) - 1 >= 0)   // 永远为真:size_t 不可能小于 0

s 是空串,strlen(s) 是 0,0 - 1 绕成巨大的无符号数,判断照样成立,后面一旦拿它当下标就出事。

有符号和无符号,尽量别混着比

这些 bug 都不报错、不溢出,编译器最多给个警告(记得开 -Wall -Wsign-compare)。根子还是补码:-14294967295 是同一堆比特,唯一的区别是你告诉编译器”把最高位当符号,还是当数值”。理解了这一点,再看这些坑,就不再是玄学。

正确的做法:把溢出挡在发生之前

既然不能”先溢出再检测”,那就在运算之前判断,或者借助专门的工具。

方法一,运算前用边界判断(不触发 UB):

c
UTF-8|4 Lines|
#include <limits.h>
// 安全地判断 a + b 是否会溢出
if (b > 0 && a > INT_MAX - b) { /* 会上溢 */ }
if (b < 0 && a < INT_MIN - b) { /* 会下溢 */ }

方法二,用编译器内建函数,它会告诉你有没有溢出,且不踩 UB:

c
UTF-8|4 Lines|
int result;
if (__builtin_add_overflow(a, b, &result)) {
    // 溢出了,result 里是回绕值,但至少你知道溢出发生了
}

方法三,让工具在运行时抓现行。编译时加上 UBSan(未定义行为消毒器):

Bash
UTF-8|4 Lines|
gcc -fsanitize=undefined overflow.c -o overflow
./overflow
# runtime error: signed integer overflow:
# 2147483647 + 1 cannot be represented in type 'int'

UBSan 会在溢出真正发生的那一刻,打印出精确的文件、行号和越界的值,比事后猜测强太多。

亲手复现这一切

把上面的现象凑成一个小程序,自己跑一遍:

c
UTF-8|24 Lines|
#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 这种小数呢?有限的位又该怎么装下无限的实数?那是下一篇的事了。