-1 >> 1等于多少?这问题看着像入门题,但它能把不少写过几年代码的人卡住。有人答0,有人答-0,正确答案是-1——因为 -1 的 32 位补码是全 1,算术右移补的是符号位,高位继续填 1,结果还是全 1,也就是 -1。这个反直觉的答案背后,牵出来的是计算机里最基础也最容易被跳过的一整套东西:补码、加减法运算、位移运算。很多教材把这三块拆成独立章节讲,学完还是不知道它们怎么串在一起。实际上它们是同一根链条上的三环:补码是为了让减法变成加法,而位移运算则是补码在"缩放数字"这个场景下的延伸。
这篇文章想做的事很直接:把补码的来历、加减法的运算规则、溢出的判断方法、正数与负数在位移时的不同行为,从头到尾串成一条线讲透。适合那些写过x >> 1却说不清负数为什么会往小里跑的人,也适合准备面试、复习计算机组成原理的人,还适合被-7 / 2 != -7 >> 1坑过一次、想知道为什么的工程师。我会尽量用生活化的类比配合二进制实算,一个数字一个数字地摆出来,让抽象规则变成能自己动手复现的东西。
1. 补码不是凭空冒出来的:它要替硬件省掉一套减法电路
先说结论:补码存在的唯一理由,是让计算机用一套加法电路同时完成加法和减法。这个动机听起来朴素,但它是理解后面所有细节的钥匙。如果只是一味背"负数取反加一",你会记住规则却说不清规则为什么长这样,碰到溢出、位移、类型转换就会乱。
1.1 原码和反码为什么在负数上翻车
最早的编码思路非常符合直觉,叫原码:最高位当符号位,0 表示正,1 表示负,剩下的位放绝对值。比如 8 位下,+5是0000 0101,-5是1000 0101。看着清爽,但一算加法就出问题:0000 0101 + 1000 0101 = 1000 1010,翻译回去是-10,而正确答案是 0。符号位不参与运算,硬件就得先判断符号、再决定是加还是减,等于白准备了。
于是有了反码:正数不变,负数是符号位不动、其余位全部取反。-5的反码是1111 1010。反码解决了"正负相加"的一部分问题,0000 0101 + 1111 1010 = 1111 1111,这恰好是-0的反码,勉强算对。但反码埋了个大坑:它有两个零,+0是0000 0000,-0是1111 1111。同一台机器里出现两个表示同一个值的编码,比较运算、判零逻辑都要额外处理,硬件成本又上去了。
提示:原码和反码都有一个共同的硬伤——符号位是"贴上去"的,不参与自然进位。只要符号位游离在运算之外,加减法就永远需要分情况讨论。
1.2 换个角度看:补码就是模运算下的"倒数"
真正干净的方案是补码。它的定义来自模运算:在 8 位世界里,模是 2^8 = 256。一个数 x 的补码,就是它在模 256 意义下的"加法逆元",也就是256 - x。对于-5,它的补码是256 - 5 = 251,写成二进制就是1111 1011。
你可以用生活里的时钟来理解这件事。一个 12 小时的钟表,指针现在指在 3 点,你想让它退回到 1 点,可以倒拨 2 小时,也可以正拨 10 小时。10 和 -2 在 12 小时制里是等价的,因为10 + 2 = 12,正好等于"一圈"。计算机的二进制寄存器也是这样一圈一圈转的,8 位转一圈是 256,32 位转一圈是 2^32。所以-5完全可以用256 - 5 = 251来代替,加法和减法从此合并成同一件事。
这也解释了为什么1 - 2在补码里能算出 -1:0000 0001 + 1111 1110 = 1111 1111,而1111 1111就是这个 8 位系统里的 -1。符号位在这里不是标签,而是天然参与进位的普通位,硬件不需要为它开小灶。
1.3 手算补码的三条路径
理解原理之后,手算补码可以走三条路,各有适用场景。
- 路径一:取反加一。最常用。先写负数的原码,符号位保留为 1,其余位取反得到反码,再整体加 1。
-5的原码1000 0101→ 反码1111 1010→ 加 1 得1111 1011。 - 路径二:模减。用
2^n - |x|直接算。8 位下-5就是256 - 5 = 251 = 1111 1011。位数多的时候这个路径反而更直观。 - 路径三:从右往左第一个 1 保留。这是路径一的简化技巧:从最低位往左找,找到第一个 1,把它和它右边的所有 0 保持不变,左边的所有位全部取反。
5是0000 0101,第一个 1 在最右边,保留它,左边的0000 010取反成1111 101,拼起来就是1111 1011。这个方法可以让你一眼出结果,不用真的做加法。
反过来,从补码求原码同样是取反加一。比如1111 1011取反得0000 0100,加 1 得0000 0101,也就是 5,所以原值是 -5。这里有个容易绕晕的点:正数的补码等于原码,负数的补码才是取反加一,判断依据看符号位是 0 还是 1 就行。
2. 补码加减法的运算细节与溢出判断
规则记住之后,真正让人头晕的是"什么时候结果是对的、什么时候它悄悄错了"。这一节把运算过程和溢出判断讲清楚,尤其是溢出,这是补码运算里最容易被忽视、又最容易在真实项目里埋雷的地方。
2.1 加法:符号位参与运算,结果仍是补码
补码加法只有一条规则:把所有位,包括符号位,当成普通二进制数相加,丢掉最高位的进位,结果自然就是补码。没有正负分情况,没有额外判断。
看几个例子(8 位):
| 运算 | 补码过程 | 结果 | 真值 |
|---|---|---|---|
5 + 3 | 0000 0101 + 0000 0011 | 0000 1000 | 8 |
5 + (-3) | 0000 0101 + 1111 1101 | 0000 0010 | 2 |
-5 + (-3) | 1111 1011 + 1111 1101 | 1111 1000 | -8 |
-5 + 3 | 1111 1011 + 0000 0011 | 1111 1110 | -2 |
注意第三个例子:1111 1011 + 1111 1101,原始相加会得到一个第 9 位的进位 1,这一位被直接丢掉,剩下的1111 1000恰好是 -8 的补码。这个"丢掉进位"不是偷懒,而是模运算的必然结果——超过 8 位的部分本来就"溢出"了模,属于应该被舍弃的那一圈。
2.2 减法:A - B 转成 A + (-B)
减法在补码里根本不存在独立电路,全部转成加法。转法很简单:A - B = A + (-B),其中-B用上面三种路径之一求出补码,然后套用加法规则。
举个稍复杂的:算3 - 5。先求-5的补码是1111 1011,再做0000 0011 + 1111 1011 = 1111 1110,即 -2,正确。
在硬件层面,这个"取反加一"的转换还能更省:因为-B等于~B + 1,所以减法器往往只需要一个反相器加一个"最低位进位输入置 1"的加法器,连独立的求补电路都不用。这也是为什么早期的 CPU 只需要一颗加法器就能同时支持加减——补码把硬件成本压到了最低。
注意:减法转加法时,被减数和减数的位数必须对齐。8 位系统里如果遇到从 16 位借过来的数,要先做符号扩展(正数补 0,负数补 1),否则结果会莫名其妙地错。
2.3 溢出的三种判法,以及为什么双符号位最好用
补码最大的"副作用"是溢出:两个合法范围内的数相加减,结果可能超出当前位数能表示的范围。8 位补码的范围是-128 ~ +127,一旦算出的结果越过边界,回绕就会发生,符号会翻转,比如127 + 1 = -128。
判断溢出有三种主流方法,实际做题和工程里各有取舍:
- 符号比较法(单符号位)。只适用于同号相加:两个正数相加得到负数,或两个负数相加得到正数,就是溢出。异号相加永远不会溢出,因为结果绝对值只会变小。这个方法最快,但只覆盖加法。
- 进位异或法。看最高数值位向符号位的进位
C1,和符号位向外的进位C0。如果C1 XOR C0 = 1,就溢出了。它同时适用加减法,是硬件实现里最常见的做法。 - 双符号位法(变形补码)。用两位符号位,
00表示正、11表示负,运算后如果出现01就是正溢出,10就是负溢出。这个方法最大的好处是结果本身就带着溢出标志,一眼能看出来,不用回头查进位。
举一个双符号位的例子。用 8 位(2 位符号 + 6 位数值)算25 + 25:两个数分别是00 011001,相加得00 110010= 50,没问题。再算-25 + (-25),11 100111相加得11 001110,这是 -50,也没问题。但如果算40 + 40,00 101000 + 00 101000 = 01 010000,双符号位变成了01,正溢出。这个标记很直观,考试和实际校验都爱用它。
| 判断方法 | 适用范围 | 优点 | 缺点 |
|---|---|---|---|
| 符号比较法 | 仅加法 | 心算快 | 不能判减法 |
| 进位异或法 | 加减法 | 硬件友好 | 需要跟踪进位 |
| 双符号位法 | 加减法 | 结果自带溢出位 | 要多占一位符号位 |
3. 位移运算:正数和负数走的是两条路
位移运算经常被当成"乘以或除以 2 的幂"的工具,但这句话只在特定条件下成立。一旦对象是负数,或者涉及不同语言,位移就会露出它本来的面目——它是对二进制位进行操作,而不是对数学值进行操作。这两者之间的差,就是无数 bug 的来源。
3.1 左移:数学上是乘 2 的幂,代价是丢高位
左移(<<)的规则很统一:所有位向左移动 n 位,右边空出来的低位补 0,左边超出去的高位直接丢弃。对正数来说,左移 n 位等价于乘以 2^n,因为二进制里每往左挪一位,权重就翻一倍。
问题出在"丢弃高位"上。8 位补码里64 << 1:0100 0000左移一位得1000 0000,最高位变成 1,被解释成 -128。你说的"乘以 2"确实发生了回绕,128 超出了 8 位正数的上限 127,回绕到了 -128。这就是左移的代价:它不检查溢出,也不告诉你结果错了,只是默默把高位扔掉。
对负数来说,左移在数学上仍然是乘 2 的幂(在没溢出的前提下),但符号位的变动要特别小心。-3的补码是1111 1101,左移一位得1111 1010,即 -6,正确。再左移一次1111 0100= -12,也对。但一旦符号位的"1"被挤出寄存器,结果就会从负变正,这是最隐蔽的一类左移错误。
提示:左移不会因为操作数是负数就改变规则,它永远补 0。真正区分正负号处理的是右移,别把两者混为一谈。
3.2 算术右移:负数会露出"向下取整"的真面目
算术右移(>>)是所有位移里最容易让人栽跟头的一个。它的规则是:右移 n 位,左边空出的高位补符号位——正数补 0,负数补 1。这样做的目的是保持符号不变,让负数继续是负数。
用 8 位算-8 >> 1:-8是1111 1000,算术右移一位,高位补 1,得1111 1100,即 -4。这符合"除以 2"的预期。但再试-7 >> 1:-7是1111 1001,右移一位得1111 1100,结果是 -4,而不是 -3.5 或者 -3。为什么?因为-3.5落不到整数格子上,算术右移只能选择往更小的方向取整,也就是向下取整(floor),得到 -4。
这就引出了那个经典坑:-7 / 2在很多语言里等于 -3(向零截断),而-7 >> 1等于 -4(向下取整)。两者差了整整 1。代码里如果用>>去替代/2,只要操作数可能是负数,结果就会在负数区间系统性偏移。这不是玄学,是两种取整策略的根本差异。
| 表达式 | 运算结果(8 位) | 数学含义 |
|---|---|---|
-8 >> 1 | -4 | floor(-4.0) |
-7 >> 1 | -4 | floor(-3.5) |
-1 >> 1 | -1 | floor(-0.5) |
7 >> 1 | 3 | floor(3.5) |
注意最后一行,正数的算术右移和向下取整、向零截断结果都一样,所以平时用正数测试根本发现不了问题,这也是这个坑容易被忽略的原因。
3.3 逻辑右移与循环移位:无符号数的地盘
逻辑右移(Java 里的>>>,位数固定的一种操作)不管符号位是什么,左边一律补 0。它专为无符号数设计,把整个寄存器当成纯二进制位来处理。对无符号数来说,逻辑右移等价于除以 2 的幂并向下取整;对有符号数使用它,则会把负数当成巨大的正数来移动。
-1的 32 位补码是全 1。对它做逻辑右移:
-1 >>> 1:最高位补 0,结果变成0111 1111 1111 1111 1111 1111 1111 1111,也就是2147483647(Integer.MAX_VALUE)。一个 -1 逻辑右移一位,跨界变成了最大正数,这就是"当成无符号处理"的直观后果。
循环移位则是另一个体系,分左循环、右循环和带进位循环。它把移出去的位从另一端补回来,常用于加密算法、校验码计算和某些位图处理。循环移位不是"缩放数字"的工具,而是"旋转位模式"的工具,理解它要和算术、逻辑右移分开。
| 移位类型 | 高位补什么 | 低位补什么 | 数学效果 | 典型场景 |
|---|---|---|---|---|
左移<< | 丢弃 | 0 | 乘 2^n(可能溢出) | 快速乘法、构造掩码 |
算术右移>> | 符号位 | 丢弃 | floor(x / 2^n) | 保持符号的缩放 |
逻辑右移>>> | 0 | 丢弃 | 无符号除法 | 无符号数处理 |
| 循环移位 | 从另一端补回 | 从另一端补回 | 位模式旋转 | 加密、校验 |
4. 拿代码把补码行为跑一遍
光看规则容易飘,真正记住这些行为的方式是自己动手跑一遍。这一节用 8 位补码模拟,把加减法和位移的边界情况全测出来。之所以选 8 位而不是 32 位,是因为 8 位足够小,人眼能直接数位,溢出也更容易触发。
4.1 先写个 8 位二进制打印函数
要观察补码,得先把数字还原成二进制。Python 的整数是任意精度,不会自动截断成 8 位,所以需要手动处理低 8 位并补符号位。
def to_signed8(n): n &= 0xFF # 只保留低 8 位,模拟 8 位寄存器 return n - 256 if n >= 128 else n # 最高位是 1 则减 256 还原真值 def to_bin8(n): return format(n & 0xFF, '08b') # 统一按 8 位二进制展示to_signed8这两行就是补码解释的精髓:n & 0xFF对应"丢弃高于 8 位的进位",n - 256 if n >= 128对应"最高位是 1 就当作负数"。任何语言里手动模拟固定宽度整数,逻辑都是这两步。
注意:
format(n & 0xFF, '08b')里必须加& 0xFF,否则负数会被 Python 打印成带负号的十进制而不是补码位串。
4.2 加减法与溢出的实测
先验证加法,再人为制造溢出:
print(to_bin8(5), to_signed8(5 + 3)) # 00000101 8 print(to_bin8(-5), to_signed8(-5 + -3)) # 11111011 -8 print(to_bin8(127 + 1), to_signed8(127 + 1)) # 10000000 -128 溢出 print(to_bin8(-128 - 1), to_signed8(-128 - 1))# 01111111 127 溢出第三行的127 + 1:0111 1111 + 0000 0001 = 1000 0000,最高位变成 1,解释成 -128。两个正数相加却得到负数,这就是典型正溢出。第四行的-128 - 1:1000 0000 - 0000 0001,转成加法是1000 0000 + 1111 1111 = 0111 1111,得到 127,两个负数相加得到正数,负溢出。
用双符号位复查第三行:00 1111111 + 00 0000001 = 01 0000000,双符号位01,正溢出,判断一致。这说明符号位翻转和双符号位标记是同一件事实的两种观察角度。
4.3 位移的实测与/的差异对比
位移部分重点测负数和取整差异:
for x in (-8, -7, -1, 7): print(x, to_bin8(x), ">>1 =", to_signed8(x >> 1))在 Python 里>>是算术右移,输出会是-8 -> -4、-7 -> -4、-1 -> -1、7 -> 3。为了验证溢出后高位的行为,还可以做固定的 8 位右移:
print(to_bin8(-1), "逻辑右移1位:", to_bin8((-1 & 0xFF) >> 1))-1的 8 位是1111 1111,逻辑右移一位补 0 得0111 1111,也就是 127。这个结果和 32 位下-1 >>> 1得到最大正数是同一个道理,只是宽度变了。
跑完这几段代码你会发现,补码运算的所有"奇怪"行为都能被这两行转换逻辑解释清楚:低 8 位是物理表示,减去 256 是数学解释。位移则是在物理表示上直接动手,得到结果后再用同一套逻辑解释。
5. 这些底层规则落到工程里的样子
把原理跑通之后,值得看看它在真实项目里长什么样。补码和位移不是只能在考试卷上出现,它们藏在你每天用的库函数、哈希算法、权限系统里,只是被封装得看不见了。
5.1 位运算替代乘除法的边界条件
编译器其实一直在替我们做"位移替代乘除"的优化,但它比自己手写更聪明。手写x << 1替代x * 2,对正数没问题,对负数的取整行为和/不一致,前面已经验证过。编译器生成的优化代码会在乘除负数时插入修正步骤,保证和除法语义一致,而你自己写的位移不会带这些修正。
所以实践中的建议很明确:优先写* 2和/ 2,把优化交给编译器。只有在明确操作数非负、或者在做位掩码、哈希这类本来就是按位操作的场景,才手动用位移。我见过有人为了"性能"把一堆除法改成右移,结果负数区间结果全偏,排查了半天才发现是取整方向的问题。
提示:判断"位移能不能替代除法"时,先问操作数是否可能为负。只要答案是"可能",就别替换。
5.2 哈希、位图、权限掩码里的补码身影
哈希函数里到处是位移和异或,比如常见的混合步骤h ^= h >> 16。这里的>>必须是逻辑右移,因为哈希值应该被当作无符号位串处理,如果有符号右移把高位补 1,哈希分布的均匀性就被破坏了。很多语言里哈希值用无符号类型存储,就是为了避免这个坑。
位图(bitset)和权限掩码是另一类典型场景。用第 n 位表示第 n 个权限时,1 << n生成掩码是最自然的写法,这里的1必须是无符号或足够宽的类型。如果 n 超过类型宽度,或者左移溢出,掩码就会错位,权限判断跟着失效。工程里这类 bug 的特点是"平时不触发,加上某个高权限位才爆炸",很难靠常规测试覆盖。
5.3 跨语言、跨平台最容易踩的位移坑
不同语言对位移的处理差异,是这套知识里最实用的部分。
| 语言 | >>行为 | 逻辑右移写法 | 移位量处理 |
|---|---|---|---|
| Java | 算术右移 | >>> | 对 32 位自动取模(x << 33等价x << 1) |
| C / C++ | 有符号数是实现定义(通常算术) | 用无符号类型 | 移位量超出宽度是未定义行为 |
| Python | 算术右移,任意精度 | 需手动掩码 | 不固定宽度,行为最"数学" |
| JavaScript | 位运算先转 32 位有符号 | >>> | 按 32 位取模 |
Java 那条特别值得记:x << 33不会把数清空,它会自动把 33 对 32 取模,等于x << 1。这个设计是为了配合 CPU 硬件里移位量只用低 5 位的行为,但第一次遇到1 << 32 == 1的人几乎都会愣住。C 和 C++ 更危险,移位量超过类型宽度是未定义行为,编译器可能给你任何结果,写跨平台代码时必须自己限制范围。
单独提一下 JavaScript:它的位运算会先把操作数转换成 32 位有符号整数,所以对超过 32 位范围的数做位运算会先被截断。处理大数哈希或者时间戳相关的位运算时,这个转换经常造成难以发现的精度丢失。
我在实际做低层数据处理时有过一次教训:一段用 C 写的校验逻辑移植到 Java 后,结果对不上。查下来是原本依赖"有符号右移补符号位"的行为,而 Java 里同一段代码用了>>>,负数验证码直接被当成大正数处理。两边都"合法",只是语义不同。那之后我养成了一个习惯:凡是跨语言的位运算代码,都在注释里显式标明"此处依赖算术右移"还是"逻辑右移",多写一行字,少熬一个夜。
补码这套东西,说到底就两条转换逻辑:位串和真值之间来回换。把这两行用顺了,加减法的溢出、位移的取整方向、跨语言的差异,全都能自己推出来,不用死记。真要记的话,就记这一句——位移动的是位,不是数,凡是把位位移当成数学运算的地方,都得先确认操作数的符号。