Skip to content

有符号数与补码转换

有符号数与补码转换封面

前四课里,我们一直把寄存器当成装数字的盒子,加加减减、按位拨开关,看一切都岁月静好。但你有没有想过一个根本问题:寄存器里只有 0 和 1,CPU 上根本没有"负号"这个按钮——那 -5 到底是怎么存进去的?

这一课不学新指令,专门把这个最容易被忽略、却悄悄支配了前面所有加减法结果的问题讲透。谜底就一个词:补码(two's complement)。搞懂它,你才真正看懂了寄存器里那一串十六进制到底在说什么。

本系列基于 ARMv7(32 位版本)。这一课强烈建议在手边备一个"二进制 ↔ 十进制"换算工具,因为很多恍然大悟的瞬间都发生在你看清二进制的那一刻。

1. 朴素的尝试:原码,和它的两个尴尬

最直觉的方案是"原码"(sign-magnitude)——把最高位拿来当符号位:0 表示正数、1 表示负数,剩下的位直接写绝对值。比如用 4 位表示 +50101-5 就是 1101(最高位翻成 1,低三位保持 101)。

听起来没毛病,可它有两个让人抓狂的坑:

第一,零有两个。00001000 一个是 +0、一个是 -0,明明是同一个数却有两种表示。光判断"是不是零"就得先比较两次,CPU 和程序员都得多干活。

第二,加减法不能用同一套电路。算 5 + (-5),CPU 得先看符号位:同号才真做加法,异号就得转成减法再比绝对值——一套加法器、一套减法器,逻辑又绕又容易错。

这两个坑让早期计算机吃够了苦头,于是工程师们想出了补码。

2. 补码登场:同样的位,不一样的世界

补码的核心思想只有一句话:让负数以一种"和正数直接相加就自动得到正确结果"的方式存在。具体怎么造出一个负数的补码?规则简单到离谱——

取反,加一。

+5 写成 4 位:0101。先把每一位翻个面,得到 1010;再加 1,得到 1011。这个 1011 就是 -5 的补码。验证一下:0101 + 1011 = 1 0000,最高位的 1 溢出被丢掉,剩下的正好是 0000——你看,+5 加上它的补码表示自动归零,这就是补码的魔力。

图 1 · 同一个 4 位二进制,有符号和无符号两种解读:1000 在有符号下是最小的 -8,无符号下却是 8

图 1 · 同一个 4 位二进制,有符号和无符号两种解读:1000 在有符号下是最小的 -8,无符号下却是 8

这张表值得反复看。注意两个关键点:最高位是 1 的全是负数、是 0 的全是正数(零算正数那边);同一个二进制串(比如 1000)在有符号和无符号两种解读下是完全不同的数。CPU 寄存器里存的从来只是一串位,它"是几"取决于你用哪条指令去读它。

3. 取反、加一:补码名字的由来

+5 变成 -5,完整流程是这样的:

图 2 · 取反、加一:把 +5 的二进制全位翻面,再加 1,就得到 -5 的补码

图 2 · 取反、加一:把 +5 的二进制全位翻面,再加 1,就得到 -5 的补码

三步走:写出 +5 的 8 位二进制 0000 0101;全部取反得到 1111 1010(十六进制 0xFA);再加 1 得到 1111 1011(十六进制 0xFB)。这个 0xFB 就是 -5 的补码表示。

关键性质:操作可逆。 想从 -5 回到 +5,还是同一招——取反、加一。把 1111 1011 取反得 0000 0100,加 1 得 0000 0101,正是 +5。所以补码下"取负"是一套通用操作,正变负、负变正用同一条电路搞定。

这也解释了为什么前几课里 SUB(减法)在 ARM 内部根本不需要专门的减法器——CPU 只要先把减数取反加一,再走一遍加法器就行了。加减法共用电路,这就是补码的工程价值,也是它名字"two's complement"(二的补数)的由来:对一个 N 位数,它的"取负"等价于用 2N 减去它本身。

4. 用 ARM 指令亲手验证

光说不练假把式。我们用第二、四课学过的 MVN(取反)和 ADD(加一)来亲手造一个 -5

asm
    mov r0, #5         @ R0 = 0x00000005(+5
    mvn r1, r0         @ R1 = 全位取反 = 0xFFFFFFFA
    add r1, r1, #1     @ R1 = 0xFFFFFFFB(-5 的补码)

单步走完这三行,R1 里赫然是 0xFFFFFFFB——和上一节手算的结果一字不差。高位的 6 个 F 就是取反时把 +5 高 24 位的 0 全翻成 1 的产物(第四课讲 MVN 时我们见过一模一样的现象,那会儿只是不知道这串 F 还和负数有关)。

要验证它真是 -5?反过来再走一遍:

asm
    mvn r2, r1         @ R2 = ~0xFFFFFFFB = 0x00000004
    add r2, r2, #1     @ R2 = 0x00000005(+5 回来了)

取反加一之后 +5 原样返回——这就是补码可逆性的活证明。

注: ARM 其实有更直接的方式让汇编器帮你算补码:写 mvn r1, #4(对 4 取反)也能得到 -5,因为 4 取反是 ...FB;但更推荐用伪指令 mov r1, #-5,汇编器会自动把它翻译成正确的补码常量。这里手动取反加一,是为了让你看清机制。

5. 符号扩展:从小盒子搬进大盒子

寄存器是 32 位的,但内存里经常存的是 8 位(byte)或 16 位(halfword)的小数字。把一个 8 位的有符号数加载进 32 位寄存器,多出来的高位怎么填?这就是符号扩展(sign extension)要解决的问题。

规则一句话:用符号位填充所有新增的高位。正数(符号位 0)补 0,负数(符号位 1)补 1。

图 3 · 符号扩展:8 位的 -5 搬进 32 位,必须用符号位 1 填满新增的高 24 位,数值才不会变

图 3 · 符号扩展:8 位的 -5 搬进 32 位,必须用符号位 1 填满新增的高 24 位,数值才不会变

8 位的 -50xFB。如果偷懒补 0(叫"零扩展"),结果 0x000000FB 被解读成 +251——符号丢了,数值完全错位。正确的做法是补 1,得到 0xFFFFFFFB,它仍然是 -5

ARM 用两条不同的加载指令来区分这两种语义:第三课我们见过 LDR 加载 32 位字;加载 8 位时有 LDRB(零扩展,按无符号读)和 LDRSBSigned,符号扩展,按有符号读)。带 S 的版本会自动用符号位填充高位,你不用操心。

易错点: 别以为"S"是第二课那个更新 CPSR 的 S 后缀——那里 S = Set flags,这里 LDRSB 里的 S = Signed。同样是字母 S,含义完全不同,新手极易混淆。

6. 取值范围:负数为什么多一个

32 位有符号数的取值范围,是面试必背题:

  • 最小值:0x80000000 = -2,147,483,648(约 -21 亿)
  • 最大值:0x7FFFFFFF = +2,147,483,647(约 +21 亿)

图 4 · 32 位有符号数的取值范围:负数比正数多一个,最小负数取负会溢出

图 4 · 32 位有符号数的取值范围:负数比正数多一个,最小负数取负会溢出

注意一个不对称的细节:负数比正数多一个。原因是零占了正数那半边(0x00000000 算"非负"),所以正数只有 2311 个、负数有 231 个。这就引出一个尴尬的边界情况:0x80000000(即 -2147483648)取反加一得到它自己——理论上它的"正数对应物" +2147483648 在 32 位里根本存不下。所以这条指令

asm
    mvn r0, #0x80000000   @ 试图对最小负数取反

在数学上是没有意义的,CPU 算出来的结果还是 0x80000000 本身。这是补码一个绕不开的"角落",写底层代码时尤其要小心。

7. 看到 0xFFFFFFFB,它到底是几?

这是初学者最常卡住的问题。答案是——CPU 不知道,取决于你用什么指令去读它。

图 5 · 同一个 0xFFFFFFFB,用有符号指令读是 -5,用无符号指令读是 4294967291——CPU 没有类型,类型在你脑子里

图 5 · 同一个 0xFFFFFFFB,用有符号指令读是 -5,用无符号指令读是 4294967291——CPU 没有类型,类型在你脑子里

寄存器里的位是一串 0 和 1,它本身没有"类型"。是程序员脑子里的约定 + 选用的指令,赋予了它有符号或无符号的含义。比如做除法:SDIVSigned divide)会把操作数当有符号数,0xFFFFFFFB ÷ 1 = -5UDIVUnsigned divide)会把同一串位当无符号数,结果是 4294967291。同一个寄存器、不同的指令,天差地别。

这就解释了为什么高级语言里"整数"要分 intunsigned int——编译器记着这个约定,在生成机器码时帮你挑对应的指令。但一旦你直接写汇编,这个选择权就交到了你自己手上。

快速心算法。 看到一个形如 0xFFFF... 开头的 32 位值,几乎可以确定是有符号负数。想快速知道它是负几?对它取反加一回正数,再加个负号即可:0xFFFFFFFB 取反得 0x00000004,加 1 得 0x00000005,所以它是 -5。熟练之后这步心算只要两秒。

小结

这一课没学新指令,但补码是理解前面所有加减法、以及后续条件分支的基石:

  • 原码的坑:零有两个、加减法要分两套电路——这就是为什么计算机不用它
  • 补码规则:取反、加一;可逆,正负转换用同一条电路
  • 符号位:最高位是 0 为正、是 1 为负;但同一串位的有符号/无符号解读完全不同
  • 符号扩展:小位数搬进大位数,用符号位填满新增高位;对应 ARM 的 LDRSB(S = Signed,不是 Set flags)
  • 取值范围:32 位有符号数约 ±21 亿,负数比正数多一个,最小负数取负会原地不动
  • 类型在脑子里:CPU 不存类型,是 SDIV 还是 UDIV、是 LDRSB 还是 LDRB 决定了同一串位被读成几

补码看懂之后,第二课里那盏一直没说清的 V(溢出)标志终于有了清晰的判据:有符号运算超出 ±21 亿 的范围就溢出,V 置位。下一课,我们就拿着补码这块拼图,去解锁 ARM 最有魅力的能力——分支与条件执行,让 CPU 真正会做选择。下一课见。

本章自测