int 溢出为什么会变成负数?从里程表讲明白二进制回绕

写业务代码的时候,很少有人会主动去想 int 的边界。直到某天金币显示成 -2147483648,或者伤害算出个巨大的负数,你才意识到——这个数不知道什么时候转了一圈。
这篇文章不用你背补码公式,我们从"里程表"开始,一步步把这件事说清楚。
一句话本质
int 只有 32 个二进制位,能表示的范围是固定的:
-2147483648 ~ 2147483647
一旦算出来的结果超出这个范围,多出来的部分不会报错,而是像里程表一样转一圈从头开始。
最直观的比喻:里程表
老式汽车里程表只有 6 位数字。跑到 999999 再走一公里,它不会显示 1000000,而是翻回 000000。
int 一模一样,只不过它最高位被借去当"正负号"了,所以翻回去之后不是变成 0,而是变成最小的负数:
int.MaxValue + 1 → -2147483648
int.MinValue - 1 → 2147483647
用十六进制看更清楚:
0x7FFFFFFF (+2147483647) 最正
+1
0x80000000 (-2147483648) 一个进位,符号位被"撞"成 1,整个数变成最负
底层原因是:CPU 做加法时只保留低 32 位,第 33 位的进位直接丢掉。丢掉进位 = 转圈。
为什么翻回去是"最负"而不是 0
这是全文最反直觉的一点,画个图就清楚了。
第一步:int 的 32 个位长什么样
位号: 31 30 29 28 ... 2 1 0
↑
符号位 (0=正, 1=负) └──── 剩下 31 位放数值 ────┘
关键:第 31 位既是符号,也是一个真正的二进制数位。它不是贴在旁边的一个标记,而是参与数值计算的一位。
第二步:MaxValue 加 1,进位怎么冲上去的
2147483647 的二进制是「符号位 0,后面 31 个全是 1」:
进位: 1111111 11111111 11111111 11111111 ← 每一位都被前一位的进位顶爆
┌──────────────────────────────────────┐
0111 1111 1111 1111 1111 1111 1111 1111 2147483647
+ 0000 0000 0000 0000 0000 0000 0000 0001 1
───────────────────────────────────────────
1000 0000 0000 0000 0000 0000 0000 0000 -2147483648
↑
符号位被最后一道进位顶成了 1
和 999 + 1 = 1000 是同一个道理:每一位都是 1,加 1 就全部翻成 0 并向前进一位,进位一路传到最左端,把符号位的 0 顶成了 1。
第三步(关键):为什么"符号位变 1"就等于 -2147483648
因为同一串二进制,有两种读法。CPU 默认按"有符号"读:
位模式 当作无符号数读 当作有符号 int 读
─────────────────────────────────────────────────────
0000...0000 0 0
0111...1111 2147483647 2147483647 ← 正数到顶了
1000...0000 2147483648 -2147483648 ← 同一个位模式!
1111...1111 4294967295 -1
看第三行:位模式完全一样是 0x80000000,但只要符号位是 1,读法就整体平移 -2³²:
2147483648 - 4294967296 = -2147483648
(真实位值) (2^32) (有符号解释)
所以不是数字"变小"了,而是读法在符号位翻面的那一刻换了规则,一下子跳到了负数区间的另一头。
第四步:把数轴首尾接起来看
既然加满会转圈,那 int 的数轴其实不是一条线,是一个环:
-2147483648 ───── ... ───── -1 ──┬── 0 ── 1 ───── ... ───── 2147483647
↑ │
│ │
└──────────────────── +1 ───────────────────────────────────┘
加 1 就绕到这里
而"最大值"和"最小值"在这个环上是紧挨着的邻居
和里程表对比,差在哪
里程表(无符号): 999999 +1 → 000000 → 读作 0 (回到起点)
int(有符号) : 7FFFFFFF +1 → 80000000 → 读作 -2147483648(跳到最负)
↑
同样的"转圈",但因为最高位被借去当符号,
转出来的结果不是 0,而是最负的那个数
一句话总结:位是从 全1 翻成 1后面全0,这一步和里程表一样;区别在于 int 把最左边那一位解释成了符号,所以 1后面31个0 不读作 2147483648(超范围了),而被读成 -2147483648。
C# 里的实际表现:静默发生
C# 默认是 unchecked(不检查),所以溢出不抛异常、不报警:
int a = int.MaxValue;
a = a + 1; // 编译通过,运行时 a == -2147483648(悄悄变成负数)
int b = int.MaxValue + 1; // 编译报错 CS0220:常量溢出
注意这两行的差别非常反直觉:
- 编译期能算出来的常量溢出 → 直接编译失败
- 运行时才算出来的溢出 → 完全不管
所以 int.MaxValue + 1 编译不过,但换个变量绕一下就能编过 —— 这也是这类 bug 特别难发现的原因。
想让它报错,得显式写 checked:
checked { c = a + 1; } // 抛 OverflowException
最容易咬人的几个场景
1. 资源/伤害/经验累加爆表变负数
玩家金币 2_000_000_000,打一局又拿了奖励,+= 之后变成负数 —— 界面显示负金币,或者"余额不足"判断直接失效,甚至出现负负得正的刷道具漏洞。这是数值型游戏最经典的事故。
2. 乘法溢出比加法更隐蔽
单次加法很难溢出(要 21 亿),但乘法几十万 × 几万就翻了:
int damage = attack * multiplier; // 攻击力 50000 × 倍率 50000 = 25 亿 → 溢出变负
表面数值玩家看起来都"正常",一进公式就翻车。
3. Math.Abs 的陷阱
Math.Abs(int.MinValue) == int.MinValue // 还是负数!
因为 -(-2147483648) 是 +2147483648,超出 int 范围,又绕回去了。取绝对值反而得到负数,后续用它算距离/差值就全乱了。
4. 时间戳(2038 问题)
Unix 时间戳用 int(秒)存的话,2038 年 1 月 19 日这一天会溢出,时钟跳到 1901 年。这类字段在存档、活动配置、离线收益计算里很常见。
5. end - start 当 end 小于 start
发奖励时算"应补发时长",如果一方时间比另一方小,减法得到负数,取绝对值又可能撞上第 3 条的坑。
怎么防
| 做法 | 说明 |
|---|---|
资源/时间戳统一用 long |
64 位,够用到人类灭亡。最省事的办法,代价只是每字段多 4 字节 |
求平均别写 (a + b) / 2 |
写 a + (b - a) / 2,两边都很大时避免先溢出 |
关键计算块加 checked |
让它炸出来而不是悄悄错。宁可报错,也别静默产生负数 |
| 乘法前先预判 | 用 long 做中间结果再截断,如 (int)Math.Min((long)a * b, int.MaxValue),顺手还做了上限钳制 |
结尾
简单记:int 的溢出不是"报错",是"转圈";转圈之后符号会翻,正数变负数。
所以这类 bug 的症状通常很统一:
- 值突然变成负的
- 界面显示一个巨大的奇怪数字
- 判断逻辑莫名其妙失效
下次看到界面里蹦出一个 -2147483648,你就知道——它只是跑完了一整圈。
如果这篇对你有帮助,欢迎点赞收藏。有任何疑问或者遇到别的溢出场景,评论区聊聊。

浙公网安备 33010602011771号