资讯动态

从 -1 >> 1 讲透补码、加减法与位移运算

发布时间:2026/9/30 1:17:42 来源:尧图企业网站定制
-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 位转一圈是 25632 位转一圈是 2^32。所以-5完全可以用256 - 5 251来代替加法和减法从此合并成同一件事。这也解释了为什么1 - 2在补码里能算出 -10000 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 30000 0101 0000 00110000 100085 (-3)0000 0101 1111 11010000 00102-5 (-3)1111 1011 1111 11011111 1000-8-5 31111 1011 0000 00111111 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 4000 101000 00 101000 01 010000双符号位变成了01正溢出。这个标记很直观考试和实际校验都爱用它。判断方法适用范围优点缺点符号比较法仅加法心算快不能判减法进位异或法加减法硬件友好需要跟踪进位双符号位法加减法结果自带溢出位要多占一位符号位3. 位移运算正数和负数走的是两条路位移运算经常被当成乘以或除以 2 的幂的工具但这句话只在特定条件下成立。一旦对象是负数或者涉及不同语言位移就会露出它本来的面目——它是对二进制位进行操作而不是对数学值进行操作。这两者之间的差就是无数 bug 的来源。3.1 左移数学上是乘 2 的幂代价是丢高位左移的规则很统一所有位向左移动 n 位右边空出来的低位补 0左边超出去的高位直接丢弃。对正数来说左移 n 位等价于乘以 2^n因为二进制里每往左挪一位权重就翻一倍。问题出在丢弃高位上。8 位补码里64 10100 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-4floor(-4.0)-7 1-4floor(-3.5)-1 1-1floor(-0.5)7 13floor(3.5)注意最后一行正数的算术右移和向下取整、向零截断结果都一样所以平时用正数测试根本发现不了问题这也是这个坑容易被忽略的原因。3.3 逻辑右移与循环移位无符号数的地盘逻辑右移Java 里的位数固定的一种操作不管符号位是什么左边一律补 0。它专为无符号数设计把整个寄存器当成纯二进制位来处理。对无符号数来说逻辑右移等价于除以 2 的幂并向下取整对有符号数使用它则会把负数当成巨大的正数来移动。-1的 32 位补码是全 1。对它做逻辑右移-1 1最高位补 0结果变成0111 1111 1111 1111 1111 1111 1111 1111也就是2147483647Integer.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 10111 1111 0000 0001 1000 0000最高位变成 1解释成 -128。两个正数相加却得到负数这就是典型正溢出。第四行的-128 - 11000 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 1C / 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 里同一段代码用了负数验证码直接被当成大正数处理。两边都合法只是语义不同。那之后我养成了一个习惯凡是跨语言的位运算代码都在注释里显式标明此处依赖算术右移还是逻辑右移多写一行字少熬一个夜。补码这套东西说到底就两条转换逻辑位串和真值之间来回换。把这两行用顺了加减法的溢出、位移的取整方向、跨语言的差异全都能自己推出来不用死记。真要记的话就记这一句——位移动的是位不是数凡是把位位移当成数学运算的地方都得先确认操作数的符号。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑