一、动机:为什么需要补码?
先看一个事实: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为例,一图看清三步):
原码 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):
【原码】 【反码】 【补码】 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 00001000 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*手写解析、大小端转换)用补码时也不必为负零写特例。
补码溢出判断:只看两个符号位
两个同号数相加,结果符号位与它们不同→ 溢出。
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 → -17.2 位运算符是补码语义的直接体现
x = -8 // 补码 ...11111000 算术右移 x >> 1 → ...11111100 = -4 // 左侧补符号位 1 逻辑右移 x >>> 1 → 0111...1100 = 2147483644 // 左侧补 07.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 x7.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 result7.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(补码溢出)这两个反例,比正面例子记得牢得多。