Appearance
有符号数与补码转换

前四课里,我们一直把寄存器当成装数字的盒子,加加减减、按位拨开关,看一切都岁月静好。但你有没有想过一个根本问题:寄存器里只有 0 和 1,CPU 上根本没有"负号"这个按钮——那 -5 到底是怎么存进去的?
这一课不学新指令,专门把这个最容易被忽略、却悄悄支配了前面所有加减法结果的问题讲透。谜底就一个词:补码(two's complement)。搞懂它,你才真正看懂了寄存器里那一串十六进制到底在说什么。
本系列基于 ARMv7(32 位版本)。这一课强烈建议在手边备一个"二进制 ↔ 十进制"换算工具,因为很多恍然大悟的瞬间都发生在你看清二进制的那一刻。
1. 朴素的尝试:原码,和它的两个尴尬
最直觉的方案是"原码"(sign-magnitude)——把最高位拿来当符号位:0 表示正数、1 表示负数,剩下的位直接写绝对值。比如用 4 位表示 +5 是 0101,-5 就是 1101(最高位翻成 1,低三位保持 101)。
听起来没毛病,可它有两个让人抓狂的坑:
第一,零有两个。0000 和 1000 一个是 +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 的全是负数、是 0 的全是正数(零算正数那边);同一个二进制串(比如 1000)在有符号和无符号两种解读下是完全不同的数。CPU 寄存器里存的从来只是一串位,它"是几"取决于你用哪条指令去读它。
3. 取反、加一:补码名字的由来
把 +5 变成 -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 位数,它的"取负"等价于用
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 位,数值才不会变
8 位的 -5 是 0xFB。如果偷懒补 0(叫"零扩展"),结果 0x000000FB 被解读成 +251——符号丢了,数值完全错位。正确的做法是补 1,得到 0xFFFFFFFB,它仍然是 -5。
ARM 用两条不同的加载指令来区分这两种语义:第三课我们见过 LDR 加载 32 位字;加载 8 位时有 LDRB(零扩展,按无符号读)和 LDRSB(Signed,符号扩展,按有符号读)。带 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 位有符号数的取值范围:负数比正数多一个,最小负数取负会溢出
注意一个不对称的细节:负数比正数多一个。原因是零占了正数那半边(0x00000000 算"非负"),所以正数只有 0x80000000(即 -2147483648)取反加一得到它自己——理论上它的"正数对应物" +2147483648 在 32 位里根本存不下。所以这条指令
asm
mvn r0, #0x80000000 @ 试图对最小负数取反在数学上是没有意义的,CPU 算出来的结果还是 0x80000000 本身。这是补码一个绕不开的"角落",写底层代码时尤其要小心。
7. 看到 0xFFFFFFFB,它到底是几?
这是初学者最常卡住的问题。答案是——CPU 不知道,取决于你用什么指令去读它。

图 5 · 同一个 0xFFFFFFFB,用有符号指令读是 -5,用无符号指令读是 4294967291——CPU 没有类型,类型在你脑子里
寄存器里的位是一串 0 和 1,它本身没有"类型"。是程序员脑子里的约定 + 选用的指令,赋予了它有符号或无符号的含义。比如做除法:SDIV(Signed divide)会把操作数当有符号数,0xFFFFFFFB ÷ 1 = -5;UDIV(Unsigned divide)会把同一串位当无符号数,结果是 4294967291。同一个寄存器、不同的指令,天差地别。
这就解释了为什么高级语言里"整数"要分 int 和 unsigned 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 真正会做选择。下一课见。