一、动机:为什么需要补码?
先看一个事实:8 位无符号数能表示 0 ~ 255,共 256 个状态。
一旦要表示负数,同样 8 位还是只有 256 个状态,必须"挤"进去:
| 表达需求 | 需要的状态数 |
|---|---|
0 ~ 127 |
128 |
-1 ~ -128 |
128 |
| 合计 | 256 |
刚好塞满,一个不多一个不少。所以补码的表示范围是 -128 ~ 127 (-128 是"多出来"的那个特殊值,来源见第四节)。
那问题来了:负数存进去之后,计算机要怎么算数?
如果把符号位和数值位分开对待,加减法会爆炸式地分情况:
(+5) + (+3) → 同号,直接相加
(-5) + (-3) → 同号,取绝对值相加,再加负号
(+5) + (-3) → 异号,大减小,符号跟大的走
一套加法器要分三类实现,硬件代价极高。
补码的贡献就是:让"加法"变成唯一一种运算,减法变成"加一个相反数"。
结论前置:补码不是"某种编码偏好",它是为了让 CPU 只用一套加法电路 而设计的表示法。
现代 CPU 的 ALU 里根本没有减法器,全是加法器。
二、三种码的定义
设一个数:-3,用 8 位表示。
2.1 原码(Sign-Magnitude)
原码 = 符号位 + 绝对值的二进制
-3 → 1 | 000 0011 → 1000 0011
3 → 0 | 000 0011 → 0000 0011
- 优点:人类直观,符号位一目了然。
- 缺点:加减要分情况 ,且存在
+0(0000 0000) 和-0(1000 0000) 两个"零"。 - 范围:
-127 ~ 127(浪费了-128这个位置)。
2.2 反码(Ones' Complement)
反码 = 符号位不变,数值位按位取反
-3 原码 1000 0011
-3 反码 1111 1100 ← 数值位 0000011 取反 → 1111100
3 原码 0000 0011
3 反码 0000 0011 ← 正数反码 == 原码
- 正数的反码永远等于原码。
- 负零:
1111 1111表示-0,和0000 0000的+0并存。 - 范围:
-127 ~ 127。
2.3 补码(Two's Complement)
补码 = 反码 + 1(只在负数上加 1)
-3 原码 1000 0011
-3 反码 1111 1100
-3 补码 1111 1101 ← 末位 +1
3 原码 0000 0011
3 反码 0000 0011
3 补码 0000 0011 ← 正数三者完全相同
核心口诀(记这一句就够):
正数:原码 = 反码 = 补码
负数:反码 = 原码符号位不变、其余取反;补码 = 反码 + 1
三、转换规则与对照表
以 8 位为例,符号位恒为最高位,0 正 1 负:
| 数值 | 原码 | 反码 | 补码 | 说明 |
|---|---|---|---|---|
+6 |
0000 0110 |
0000 0110 |
0000 0110 |
正数三码完全一致 |
-6 |
1000 0110 |
1111 1001 |
1111 1010 |
数值位取反 → 末位 +1 |
-3 |
1000 0011 |
1111 1100 |
1111 1101 |
最常考的 −3 补码 |
0 |
0000 0000 |
0000 0000 |
0000 0000 |
补码的零唯一 |
-128 |
(无) | (无) | 1000 0000 |
补码独有,比原码多一个数 |
一眼看出规律:正数三码完全一致;负数原码→反码是"除符号位外全翻",反码→补码是"末位 +1"。
规则三条,照记:
- 正数:原码 = 反码 = 补码,一个字节用满 8 位。
- 负数 · 反码:符号位不变,其余 7 位按位取反。
- 负数 · 补码 :反码末位 +1,即
[x]补 = x + 2ⁿ。
负数转换链(以 -6 为例,一图看清三步):
text
原码 1 000 0110 符号位 1 不动,数值 0000110 按位取反
│
▼
反码 1 111 1001 末位 +1
│
▼
补码 1 111 1010 ← -6 在计算机里的真实形态
正数(如
+6)三码完全相同:0000 0110,不需要任何转换。
四、实战演算:-3 + 2,三种码各走一遍
-3 + 2 正确答案是 -1。下面把三种码的计算过程都列出来,差异一目了然。
4.1 用原码算(异号相加无能为力)
1000 0011 (-3 原码)
+ 0000 0010 ( 2 原码)
-----------
1000 0101 (看起来像 -5)
按原码规则解读结果:1000 0101 → 符号位 1,数值 000 0101 = 5 → 得到 -5 。
原码加法只适用于同号;这里是异号,原码没有定义该怎么做,硬做就会得到错误的结果。
4.2 用反码算(对了,但有坑)
1111 1100 (-3 反码)
+ 0000 0010 ( 2 反码)
-----------
1111 1110 (-1 反码) ✅
这一次结果正确了。但换个数据就出岔子:
1111 1101 (-2 反码)
+ 0000 0010 ( 2 反码)
-----------
1 1111 1111
-----------
1111 1111 ( 丢弃进位 → 这是 -0!) ❌
-2 + 2 明明等于 0,反码体系却给出 -0。
虽然可以靠额外规则把 1111 1111 归一化成 0000 0000,但每个结果都要检查是不是负零,硬件上又是一堆判断。
4.3 用补码算(一路到底,零特判)
1111 1101 (-3 补码)
+ 0000 0010 ( 2 补码)
-----------
1 1111 1111
-----------
1111 1111 (丢掉最高位进位)
得到 1111 1111,它是不是 -1?把补码还原成原码验证:
补码 1111 1111
↓ 符号位不变,数值位取反 → 反码
反码 1000 0000
↓ 末位 +1 → 原码
原码 1000 0001 → 符号位 1,数值 0000001 = 1 → -1 ✅
对照一下 -3 自己(-3 补码 = 1111 1101,1111 1101 → 1000 0010 → 1000 0011 ✅),规律完全一致。
4.4 三种码结果对比
| 编码方式 | -3 + 2 运算结果 |
解读 | 对不对 |
|---|---|---|---|
| 原码 | 1000 0101 |
-5 |
❌ 异号相加无定义 |
| 反码 | 1111 1110 |
-1 |
✅(但 -2 + 2 会得到 -0) |
| 补码 | 1111 1111 |
-1 |
✅ 无特判 |
这才是补码的价值:不管加什么,相加 → 丢弃最高位进位 → 结果就是对的。
三种码列竖式对比(-3 + 2):
text
【原码】 【反码】 【补码】
1 0 0 0 0 0 1 1 (-3) 1 1 1 1 1 1 0 0 (-3) 1 1 1 1 1 1 0 1 (-3)
+0 0 0 0 0 0 1 0 (+2) +0 0 0 0 0 0 1 0 (+2) +0 0 0 0 0 0 1 0 (+2)
──────────────── ──────────────── ────────────────
1 0 0 0 0 1 0 1 → -5 ❌ 1 1 1 1 1 1 1 0 → -1 ✅ 1 1 1 1 1 1 1 1 1 (进位1丢弃)
└──────────┘
1 1 1 1 1 1 1 1 = -1 ✅
原码竖式把符号位当普通位相加,异号时直接算错;反码对但
-2+2会出-0;补码多出的进位直接丢弃,结果永远正确。
五、为什么补码"相加就一定对"?
补码的定义可以写成数学形式(这才是它的本质):
[x]补 = x + 2ⁿ (n = 位宽,x 为真值)
既然 [x]补 只是 x 平移了 2ⁿ,那加减法在补码空间里就退化成模 2ⁿ 运算:
[x + y]补 = [x]补 + [y]补 (mod 2ⁿ)
而"丢弃最高位进位"正好就是 mod 256。
所以它天然成立 ------ 补码的正确性不是巧合,是定义使然。
-128 是怎么来的?
-128 真值 = -128
-128 补码 = -128 + 256 = 128 = 1000 0000
1000 0000 在无符号视角是 128,在补码视角是 -128。
同一个 bit 序列解释出两个数,这就是补码"挤满 256 个状态"的体现,也是它比原码多表示一个 -128 的原因。
进位丢弃 = 模运算的直观证明
1111 1101 (-3)
+ 0000 0010 (+2)
= 1 1111 1111
这个"多出来的 1"就是 2⁸,减去它(等价于丢弃)就完成了 mod 256。
丢弃进位不是"无奈之举",而是补码算法的一部分。
六、零与范围的对比(补码的"额外福利")
| 对比项 | 原码 | 反码 | 补码 |
|---|---|---|---|
0 的表示 |
0000 0000 和 1000 0000 两个 |
0000 0000 和 1111 1111 两个 |
只有 0000 0000 一个 ✅ |
| 8 位范围 | -127 ~ 127 |
-127 ~ 127 |
-128 ~ 127 ✅ |
| 加减法是否需分情况 | 必须分(同号/异号) | 分,且要处理负零 | 完全不分 ✅ |
| 加法器电路 | 多套 | 多套 + 负零修正 | 一套 ✅ |
| 是否需要额外校验位 | 需要 | 需要 | 不需要 ✅ |
| 溢出判断 | 麻烦 | 麻烦 | 简单(见下节) |
补码零的唯一性带来的连锁好处 :判断
x == 0不需要同时比较两个 bit 模式;变长整数(C/C++ 的
char*手写解析、大小端转换)用补码时也不必为负零写特例。
补码溢出判断:只看两个符号位
两个同号数相加,结果符号位与它们不同 → 溢出。
text
0111 1111 (+127) 两个 0,符号位不变 → +127,无溢出
+ 0000 0001 (+1)
1 1000 0000 → 模 256 后 1000 0000 = -128 ❌ 溢出
这就是为什么很多语言都提供「溢出即报错」的安全加法函数的原因。
七、现代写法:写代码时补码怎么出现
以下均用伪代码 表达,与具体语言无关。主流语言的整数类型(定长有符号)一律采用补码,不存在原码/反码参与运算。
7.1 把整数展开成补码位串
// 把整数按 n 位补码展开成二进制串
toBinary(x, n):
if x >= 0:
return x 的 n 位二进制 // 正数补码即原码
else:
return (2^n + x) 的 n 位二进制 // 负数补码 = x + 2^n
toBinary(-3, 8) → "11111101"
toBinary( 2, 8) → "00000010"
(-3) + 2 → -1
7.2 位运算符是补码语义的直接体现
x = -8 // 补码 ...11111000
算术右移 x >> 1 → ...11111100 = -4 // 左侧补符号位 1
逻辑右移 x >>> 1 → 0111...1100 = 2147483644 // 左侧补 0
7.3 一个"教科书级"的分支无关绝对值
利用补码「负数右移得到全 1、正数右移得到全 0」的特性,可以不借助判断/分支求出绝对值:
// 负数时 (x XOR -1) - (-1) = ¬x + 1 = -x;正数时 = x
abs(x) = (x XOR (x >> 位宽-1)) - (x >> 位宽-1)
同样利用补码,可以判断两数是否同号(符号位相同 → 异或后最高位为 0):
sameSign(a, b) = (a XOR b) >= 0 // 同号返回真;异号或含 0 需另判
7.4 补码回原码(按位操作)
// 以 8 位为例:负数补码 → 原码 = 取反 +1,再置回符号位
compToOrig(x):
if x 的最高位 == 1: // 负数
return ( (NOT x + 1) AND 01111111 ) OR 10000000
return x
7.5 Kernighan 位技巧集(全部基于补码)
isPow2(x) = (x > 0) AND (x AND (x - 1) == 0) // 是否 2 的幂
clearLowest1(x) = x AND (x - 1) // 清除最低位的 1
popCount(x): // 统计 1 的个数
c = 0
while x != 0:
x = x AND (x - 1) // 每次消掉一个 1
c = c + 1
return c
same(a, b) = (a XOR b) == 0 // 两数是否完全相同
merge(lo, hi) = (lo AND 0xFFFF) OR ((hi AND 0xFFFF) << 16) // 合并高低 16 位
7.6 溢出安全的加减
// 加法前先检查是否会溢出,溢出则报错
addExact(a, b):
result = a + b
if 同号相加却得异号: // 溢出特征
error "overflow"
return result
7.7 不同语言对溢出的处理差异
| 整数模型 | 表示方式 | 溢出行为 | 坑 |
|---|---|---|---|
| 定长补码(C / Java / Go / Rust 等) | 补码,定长 | 静默回绕(Rust 默认报错) | 溢出不报错,结果错误 |
| C / C++ | 补码(标准强制) | 有符号溢出属未定义行为 | 编译器可能优化成任意结果 |
| 无限精度(如 Python) | 符号-数值,内部处理 | 永不溢出 | 负数不是补码,不能用 bin(负数) 验证补码 |
⚠️ 面试/实战高频陷阱:无限精度语言的负数不是补码存储 。
它直接输出带符号的二进制(如
-0b11),位运算时对负数"按位截取"才得到补码位串,不要用这类语言去"验证补码"。
7.8 补码在工程里最常见的场景
| 场景 | 补码如何参与 |
|---|---|
| 网络字节序 | 大小端转换函数处理的就是补码 + 字节序的组合 |
| TCP 校验和 | 16 位按大端取补码求和,溢出即回绕 |
| CRC / 哈希(CRC32) | 本质是 GF(2) 上的补码多项式运算 |
| HMAC / 签名算法 | 字节序、补码进位规则直接影响实现兼容 |
| 字节码指令 | 局部变量索引和常量池索引都是补码整数 |
| Base64 解码后的字节 | 直接按无符号读,切勿误当补码字符 |
| 数据库 DECIMAL 转整数 | 依赖十进制补码定义(不同厂商曾因此差 1) |
八、常见误区速查
| 误区 | 正解 |
|---|---|
| "反码是符号位也取反" | ❌ 符号位始终不变,只翻数值位 |
| "正数的补码要加 1" | ❌ 只有负数反码才 +1;正数三码全等 |
| "补码要额外加一个符号位" | ❌ 符号位就在最高位里,8 位就是 8 位,不多不少 |
| "负数相加要手动处理进位" | ❌ 最高位进位直接丢,这正是补码正确性的来源 |
"-3 + 2 原码得到的结果是答案" |
❌ 原码异号相加无定义,会得到错误值;补码下答案恒为 -1 |
| "补码里 0 有两个" | ❌ 补码只有 0000 0000 一个零 |
"Python 的 bin(-3) 能验证补码" |
❌ Python 是符号-数值模型,不是补码 |
| "位运算会把负数当无符号" | ❌ 位运算在补码语义下操作,Python 除外 |
九、总结
- 正数:原码 = 反码 = 补码 ;负数:反码 = 原码数值位取反,补码 = 反码 + 1。
- 补码 =
x + 2ⁿ的模表示 ,所以[x+y]补 = [x]补 + [y]补 (mod 2ⁿ)------ 加法天然正确。 - 丢掉最高位进位 不是 bug,而是
mod 2ⁿ的实现,是补码算法的一部分。 - 补码带来三个工程红利:唯一零 、多表示一个数(-128) 、只需一套加法电路。
-3 + 2:原码异号相加无定义、反码对但负零有坑、补码恒为-1且零特判。- 写代码时:定长补码溢出通常是静默的,应优先使用「溢出即报错」的安全加法;分支无关绝对值
((x XOR (x>>位宽-1)) - (x>>位宽-1))玩的就是补码;无限精度语言不是补码,别拿来验证。
一句话记忆
原码是"人话",反码是"半成品",补码是"CPU 的语言"。
正数三者相同,负数取反再加一,加法直接丢进位。
本文的换算过程建议动手敲一遍,尤其是 -2 + 2(反码出 -0)和 127 + 1(补码溢出)这两个反例,比正面例子记得牢得多。