把 8 位二进制想成一排开关,补码只是换了个数数的约定。八只开关一共 256 种组合,无符号时读作 0 到 255;一旦约定「左边第一只开关拨到 1 就代表负数」,同一串 0 和 1 马上变成 -128 到 127。原码、反码、补码就是三套读法:原码贴近人的书写习惯,反码是推导中的中间产物,补码是处理器真正采用的方案。
这个差别在做 8 位 ALU 仿真时最容易暴露。当时用门电路搭加减法器,学生按原码思路写负数,加法器结果总是差一,排查了半天才发现减法要靠「取反加一」把负数转成补码再进加法器。同一批实验里,还有人在 127 加 1 之后看到 -128,以为是仿真器出错。三套编码的符号位处理方式不同,转换步骤不同,溢出判定的依据也不同,把这三件事分开记会清楚很多。
准备:固定位宽与符号位的约定
转换前必须先固定位宽和符号位位置,否则同一串 0 和 1 可以有两种合法读法。约定最高位(左边第一位)为符号位,0 表示正、1 表示负;剩下的位是数值位。位宽决定可表示范围:n 位补码覆盖 -2^(n-1) 到 2^(n-1)-1,8 位是 -128 到 127,16 位是 -32768 到 32767。
三套编码在零的写法上分道扬镳,这也是补码被硬件接纳的原因。
| 表示法 | 8 位可表示范围 | 零的写法 | 主要问题 |
|---|---|---|---|
| 原码 | -127 ~ +127 | 0000 0000 与 1000 0000 | 零有两种写法,减法需额外判断符号 |
| 反码 | -127 ~ +127 | 0000 0000 与 1111 1111 | 零仍不统一,运算后可能要循环进位 |
| 补码 | -128 ~ +127 | 只有 0000 0000 | 负数不能直接读出绝对值 |
准备工作按下面三步做完,后面就不会在中途反复改约定。
- 确定位宽,是 8 位还是 16 位。
- 约定最高位为符号位,其余为数值位。
- 写下该位宽对应的范围上下界。
位宽选错是常见返工点。同一个十进制数 -25,写成 8 位补码是 1110 0111,写成 16 位补码却是 1111 1111 1110 0111,前面多出一串 1。这不是算错,而是负数在高位补齐时补 1 而不是补 0,理解了这一点,跨位宽转换就不会出错。
执行:三种编码的转换步骤
正数的三套编码写法完全一致,负数按「原码 → 符号位不动、数值位取反 → 末位加一」就能得到补码。整套流程固定成五步,照做即可。
- 写出该数绝对值的二进制。
- 补齐到指定位宽,前导位补零。
- 把最高位改为 1,得到原码。
- 符号位不动,其余位逐位取反,得到反码。
- 反码末位加一,得到补码。
以 -25 为例:绝对值 25 的二进制是 11001,补齐 8 位得 0001 1001;最高位改 1,原码为 1001 1001;符号位保持 1、其余取反,反码为 1110 0110;末位加一,补码为 1110 0111。整个过程不需要理解为什么这样做,先按步骤走通,再回头看补码解决了什么问题。
正数:三码相同
正数只要写出二进制并补齐位宽就够了。+25 的原码、反码、补码都是 0001 1001,最高位天然是 0。这一点在写代码时同样成立:非负整数的补码表示就是它本身的二进制,不需要任何转换函数。
负数:取反之后还要加一
负数的关键在最后那次加一。只取反不加一得到的是反码,反码参与运算时结果可能差一,必须在某处做循环进位修正,硬件反而更复杂。补码把修正动作提前到编码阶段,运算过程中就只剩加法。下面这段代码把三套编码放在一起对照,方便直接验证手工计算的结果。
WIDTH = 8
def to_mag(value): # 原码:符号位 + 绝对值
if value < 0:
return (1 << (WIDTH - 1)) | (-value)
return value
def to_ones(value): # 反码:负数按位取反(符号位保持不变)
if value < 0:
return ((1 << WIDTH) - 1) ^ (-value)
return value
def to_twos(value): # 补码:负数取模 2^WIDTH
return value & ((1 << WIDTH) - 1)
def from_twos(bits): # 补码还原十进制
if bits >> (WIDTH - 1):
return bits - (1 << WIDTH)
return bits
for v in (5, -5, 25, -25):
print(v, format(to_mag(v), "08b"), format(to_ones(v), "08b"), format(to_twos(v), "08b"))
print(from_twos(0b11100111)) # -25
跑一遍就能确认 -25 的三码分别是 10011001、11100110、11100111。注意 to_twos 用的是取模而不是位运算拼接,因为负数在补码里的语义就是「模 256 的余数」,这也解释了为什么 -128 能写出来而原码写不出来。
从补码还原十进制
补码还原有两条等价路径:一条是「取反、加一、再加负号」,另一条是直接算无符号值减去 2ⁿ。以 1110 0111 为例,取反得 0001 1000,加一得 0001 1001,也就是 25,加负号得 -25;用第二条路径,1110 0111 的无符号值是 231,231 减 256 同样是 -25。第二条路径在心算时更快,唯一需要留意的是最高位为 0 时不要减,直接读原值即可。
三种编码在几个典型数值上的对照如下,可以把这几行背下来当检查清单。
| 十进制 | 原码 | 反码 | 补码 |
|---|---|---|---|
| +5 | 0000 0101 | 0000 0101 | 0000 0101 |
| -5 | 1000 0101 | 1111 1010 | 1111 1011 |
| +25 | 0001 1001 | 0001 1001 | 0001 1001 |
| -25 | 1001 1001 | 1110 0110 | 1110 0111 |
| 0 | 0000 0000 | 0000 0000 | 0000 0000 |
| -128 | 无法表示 | 无法表示 | 1000 0000 |
-128 这一行是三套编码差异的直接证据:原码和反码受「零吃掉两个编码」的影响,只能表示到 -127,补码把省下来的那个编码给了 -128。
验证:拿边界值回代检查
转换结果用 0、-1、-128、127 四个边界值回代,能查出绝大多数写错的位置。这四个值的补码形态固定且好记,写错时会立刻与预期不符。
- 检查 0 的补码是否为 0000 0000。
- 检查 -1 的补码是否为全 1。
- 检查 -128 是否为 1000 0000。
- 检查 +127 是否为 0111 1111。
- 把结果代回加法,验证 a + (-a) 是否回零。
第五步是最有价值的一步。取某个数及其相反数的补码相加,结果在 8 位下应当被截断成 0000 0000,例如 0000 0011 加 1111 1101 得 1 0000 0000,丢掉第 9 位的进位后正是零。如果这一步得不到零,说明符号位处理错了,而不是运算错了。
验证时还要留意「截断」和「溢出」是两个不同概念。截断发生在位宽固定的加法中,多出来的高位直接丢弃;溢出指的是丢弃后的结果已经不能代表真实数值,这两件事在补码加法里经常同时出现,但只有溢出的结果不可信。
溢出判定:结果符号不对就说明越界
两个同号数相加,如果结果的符号位与两个加数相反,就是溢出;两个异号数相加不会溢出。这条规则比记公式实用,因为在纸上算的时候只需要看符号位。
把 127 和 1 相加:0111 1111 加 0000 0001 得 1000 0000,符号位从 0 变成 1,正数加正数得到负数,结果 -128 显然是错的,真实值 128 超出了 8 位补码的上界。反方向同理,-128 加 -1 得 0111 1111,负数加负数变成正数,真实值 -129 越过了下界。
硬件把这条规则做成一个异或门:最高位向外的进位与次高位向外的进位不一致时,置起溢出标志。写代码时用符号位比较更直观。
def add_overflow(a, b):
s = (a + b) & 0xFF
sign_a, sign_b, sign_s = (a >> 7) & 1, (b >> 7) & 1, (s >> 7) & 1
overflow = (sign_a == sign_b) and (sign_s != sign_a)
return from_twos(s), overflow
print(add_overflow(127, 1)) # (-128, True) 上界越界
print(add_overflow(-128, -1)) # (127, True) 下界越界
print(add_overflow(100, -50)) # (50, False) 异号相加不会溢出
这段判定的前提是先把结果截断回原位宽,否则 Python 的整数不会自己回绕,符号位比较也就失去意义。在固定位宽的场合里,有符号溢出看符号位,无符号溢出看进位标志,两者判定依据不同,混用会得出相反结论。
常见问题(FAQ)
Q1:为什么 8 位补码的范围是 -128 到 127?
补码的零只有一种写法,省下的那个编码给了 -128,正负两侧因此不对称。
Q2:16 位补码怎么转换?
步骤相同,只把位宽换成 16,负数高位补齐时补 1 而不是补 0。
Q3:符号位参与运算会不会算错?
不会,补码的设计就是让符号位像普通位一样参与加法,前提是位宽固定且检查溢出。