ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

补码的符号位为什么能参与运算:从原码反码到模运算

补码的符号位为什么能参与运算:从原码反码到模运算 当年在课堂上第一次听到补码的符号位可以参与运算这句话我的第一反应是——凭什么符号位不就是个标记吗标记怎么能跟数值位一起扔进加法器老师在黑板上写了 1 (-1) 0 的竖式我照着抄下来也照着背下了取反加一但心里那个疙瘩一直没解开。后来自己动手写过定点数运算的代码也把 8 位补码的 256 个状态在纸上排过一遍才真正想通补码的符号位之所以能参与运算不是因为它可以而是在补码这套编码里它压根就不是一个标记位而是一个权值为负的普通数据位。补码、原码、反码这三个词几乎是每个学计算机的人都要过的一道坎。教材上的讲法通常是给出定义、给出口诀、给出范围背下来应付考试没问题但真要去写底层代码、做嵌入式定点运算、或者面试时被追问一句为什么那点背诵量是不够的。这篇东西我想把符号位参与运算这件事从头到尾拆开讲清楚从原码为什么会卡住到反码补了一半再到补码为什么能一路通达从权值展开式到模运算模型再到四类加减法的逐一验算最后落到代码验证和几个我自己踩过的坑。刚入门的朋友可以顺着读有基础的朋友可以直接跳到第 2 和第 3 节那两节是整件事的根。1. 先把问题问对符号位到底特殊在哪1.1 原码的想法很自然但它把加减法逼进了死胡同原码的思路特别符合人的直觉最高位拿出来当正负号0 表示正1 表示负剩下的位老老实实放绝对值。8 位原码下3 就是 0000 0011-3 就是 1000 0011。人一眼就能看出这是几可读性拉满。问题出在它不能算。你想算 3 (-1)位模式是 0000 0011 和 1000 0001如果让加法器无脑逐位相加得到的是 1000 0100也就是 -4而正确答案是 2。为什么会这样因为最高位那个 1 在加法器眼里就是128这个权值它一参与运算就凭空多算了 128结果自然错得离谱。于是原码时代只能把符号位锁起来先判断两个操作数是不是同号同号就把绝对值相加、符号照搬异号还得先比谁的绝对值大用大的减小的符号取绝对值大的那一位。这套逻辑要额外的比较器、要多路选择在最朴素的硬件年代非常不划算。更要命的是零有两个0000 0000 是 01000 0000 是 -0连结果是不是零都得判两次。原码的符号位不能参与运算不是什么理论限制而是它把符号位当成纯标记的直接后果。一个位只要被赋予了权值它就会参与运算。1.2 反码补了一半补码才把门关上反码的思路是负数就按位取反。3 是 0000 0011-3 就是 1111 1100。这时再算 3 (-1)0000 0011 1111 1110 1 0000 0001丢掉最左边的进位得到 0000 0001正好是 1。看起来能用了别急反码有个尾巴它要求循环进位也就是把溢出的那个最高位进位再塞回最低位加一次。上面例子里丢掉的进位是 1得回加到最低位0000 0001 1 0000 0010结果是 2这才对。也就是说反码的加法器除了做加法还得再走一趟回卷加一的链路。而且反码的零依然有两个0000 0000 是 01111 1111 是 -0表示范围还是 -127 到 127。补码只改了最后一步负数取反之后再加一。3 是 0000 0011-3 变成 1111 1101。再算 3 (-1)0000 0011 1111 1111 1 0000 0010丢掉进位得到 0000 0010答案是 2一次到位不用回卷。而且零只有一个0000 0000。原来那个碍事的 1000 0000 被重新征用解释成 -128表示范围扩到了 -128 到 127。三次演进走完符号位从一个必须隔离的标签变成了可以和数值位一起加的普通位这就是整件事要解释的东西。1.3 一句话的答案和它需要展开的三部分如果只想要结论那就是补码的最高位不是标记而是权值为 -2^(n-1) 的数据位所以它本来就该参与运算而整套补码体系建立在模 2^n 的环上加减法在这个环里天然封闭硬件只需要一套无符号加法器取低 n 位即可。这句话里有三个东西需要展开验证。第一权值为负到底怎么落实到求和式里第二模 2^n为什么能解释减法变加法第三为什么丢弃最高位这个看似粗暴的动作在数学上恰好是正确的。下面三节分别处理这三个问题。2. 权值视角把补码拆成一个带负权的求和式2.1 n 位补码的定义式长什么样很多人学补码只记住了取反加一这条操作口诀却从没看过它的定义式。而定义式才是符号位能参与运算的根源。对一个 n 位补码位串 b(n-1) b(n-2) ... b1 b0它代表的十进制真值是V -b(n-1) × 2^(n-1) Σ(i0 到 n-2) b(i) × 2^i把这个式和最朴素的无符号数求和式 U Σ(i0 到 n-1) b(i) × 2^i 摆在一起对比你会发现两者只差一项最高位的权值从 2^(n-1) 变成了 -2^(n-1)其余所有位一模一样。这个改动不是拍脑袋定的它背后是 2^n 的补数关系V U - b(n-1) × 2^n。最高位是 0 时V U最高位是 1 时V U - 2^n。也就是说同一个位串无符号读法和有符号读法之间只差一个最高位权值 2^n的修正项。硬件层面根本没有第二种电路区别只在你用哪个公式去解释结果。2.2 拿几个极端值验一遍心里就有底了空讲公式容易飘直接把 8 位补码的几个典型位串代进去算一遍位串按定义式展开真值备注0000 000000零唯一0111 11110 127127最大值0000 00110 33正数1111 1111-128 127-1全 1 是 -11111 0000-128 112-16高位块1111 1001-128 121-7后面要用1000 0001-128 1-127最小值之上1000 0000-128 0-128最小值特例注意到 1111 1111 这个全 1 的位串按无符号读是 255按补码读是 -1。这两个值之间正好差 256也就是 2^8。这不是巧合就是 2.1 节那个修正项在起作用。我个人习惯用首位权值法来快速读负数先减去 128再把剩下的低位加起来。比如 1111 1001先 -128低位 0111 1001 121合起来 -7。熟练之后看一眼位串就能报数比取反加快得多。2.3 加法器为什么可以逐位无脑加一段严格的推导现在来回答最核心的问题——把两个补码位串丢进无符号加法器逐位相加、丢弃最高位进位为什么得到的还是正确结果的补码设 A、B 是两个 n 位补码位串把它们当作无符号数看的值分别是 U(A) 和 U(B)对应的补码真值是 V(A) U(A) - a × 2^n、V(B) U(B) - b × 2^n其中 a、b 分别是两者的最高位。硬件做的是无符号加法得到一个 n1 位的和低 n 位记作 S于是有U(S) ≡ U(A) U(B) (mod 2^n)把 U(A) V(A) a × 2^n、U(B) V(B) b × 2^n 代入两个 2^n 的倍数在 mod 2^n 下全部消失U(S) ≡ V(A) V(B) (mod 2^n)也就是说低 n 位对应的无符号值等于两个真值之和模 2^n。接下来只差最后一步如果 V(A) V(B) 落在 [-2^(n-1), 2^(n-1) - 1] 这个可表示范围里那么它的补码编码和它模 2^n 后的无符号表示就是同一个位串。所以 S 正好是正确结果的补码。一旦 V(A) V(B) 溢出这个范围S 仍然等于和模 2^n但它对应的真值已经不再是数学上的和了——这就是溢出的真正含义。整个推导里最高位从头到尾没有被特殊对待它和别的位一样接受同一条进位链。结论不是恰好成立而是必然成立。3. 模运算视角减法变加法的那把钥匙3.1 从墙上的钟说起十二小时制的钟表是理解模运算最顺手的模型。现在是 3 点你要知道 10 小时前是几点3 - 10 -7不直观。换个算法往前拨 10 小时等价于往后拨 2 小时3 2 5 点。因为在模 12 的世界里-10 和 2 是同一件事它们的差正好是 12。所谓模就是只保留除以模数之后的余数。12 点整在钟面上就是 0 点13 点就是 1 点。所有运算都在 0 到 11 这 12 个状态里打转超过就绕回来。这个绕回来的动作在数字电路里对应的就是最高位进位丢掉。3.2 8 位补码的模是 256不是 255位宽决定模。n 位能表示 2^n 个状态模就是 2^n。8 位是 25616 位是 6553632 位是 4294967296。这个数字要记牢因为它直接给出负数补码的速算公式-x 的补码 2^n - x拿几个数验一下-3 的补码应该是 256 - 3 253二进制 1111 1101对上了。-1 应该是 255也就是 1111 1111对上了。-128 应该是 256 - 128 128二进制 1000 0000也对上了。最后这条要特别注意-128 的补码就是它自己取反加一之后还是 1000 0000绕回原点了。这个性质在第五节讲补码求原码的时候会变成一个必须单独处理的坑。顺便说一句反码的模是 2^n - 1也就是 8 位下的 255。这个差异正是反码需要循环进位、而补码不需要的根本原因。3.3 那声丢弃进位其实是在取模现在把减法 a - b 拆开看。硬件先把 b 取负而 -b 在补码里就是 2^n - b 这个无符号数于是a (2^n - b) 2^n (a - b)硬件只保留低 n 位等价于对 2^n 取模那个多出来的 2^n 直接被抹掉剩下 a - b。如果 a - b 是正数结果自然正确如果 a - b 是负数比如 3 - 7 -4那么实际算式是 3 249 252低 8 位是 1111 1100按定义式读出来是 -128 124 -4。负数的补码自动生成中间不需要任何判断谁大谁小的分支。这就是丢弃进位的全部含义它不是 bug是取模。很多教材用溢出的进位舍去一笔带过让人误以为这是个工程上的权宜之计实际上它是数学上唯一正确的做法。3.4 定长是整套机制的地基如果没有固定位宽模就不存在补码这套机制也就无从谈起。这也是为什么在 Python 里做位运算必须手动 mask——它的 int 是任意精度你写 -5 3 得到 -2看着对但你根本看不到1111 1011 0000 0011 1 0000 0010 丢弃进位这个过程因为那个进位在 Python 里是真实存在的高位不会被丢掉。反过来Java 的 int 固定 32 位位运算结果被硬性截断所以能看到完整的回绕行为。C 语言的情况最微妙int 在绝大多数平台是 32 位但标准只保证至少 16 位而且有符号整数溢出在标准层面是未定义行为编译器有权假设它不发生然后做出各种看起来不讲道理的优化。这个差异在实际项目里会咬人第 7 节会展开。4. 四类加减法逐一验算以下所有演算都按 8 位进行加号左侧是位串右侧是它按定义式读出来的十进制真值。进位过程用文字描述清楚方便对位检查。4.1 同号相加符号位真的在做加法正加正符号位两个 0加上进位最多是 1结果符号位还是 05 30000 0101 0000 0011 0000 1000读出 8正确。100 200110 0100 0001 0100 0111 1000读出 120正确。负加负符号位两个 1这一位会产生进位(-5) (-3)1111 1011 1111 1101 1 1111 1000丢掉最高进位得 1111 1000读出 -128 120 -8正确。(-100) (-20)1001 1100 1110 1100 1 1000 1000丢掉进位得 1000 1000读出 -128 8 -120正确。第二个例子里两个负数的符号位在做加法时经历了1 1 10本位留 0、进位 1的完整进位过程最后结果符号位仍然是 1。这就是符号位参与运算最直观的证据——它不是被跳过、也不是被强行写回去的而是老老实实跟着进位链走完了全程。4.2 异号相加进位会一路串到最高位异号相加时符号位一个 0 一个 1加上低位串上来的进位结果符号位自然变成 0 或 1跟数学结果的正负完全吻合5 (-3)0000 0101 1111 1101 1 0000 0010丢掉进位得 0000 0010读出 2正确。3 (-5)0000 0011 1111 1011 1111 1110读出 -128 126 -2正确。(-128) 11000 0000 0000 0001 1000 0001读出 -127正确。127 (-1)0111 1111 1111 1111 1 0111 1110丢掉进位得 0111 1110读出 126正确。(-1) 11111 1111 0000 0001 1 0000 0000丢掉进位得 0000 0000读出 0正确。这里有一条特别实用的性质异号相加永远不会溢出。因为结果的绝对值不会超过两个操作数里绝对值较大的那一个一定落在可表示范围内。写边界检查逻辑的时候这一条可以直接帮你砍掉一半的分支。4.3 减法转加法一套电路干两件事减法在硬件里从来不是独立实现的全部转成加负数7 - 3 → 7 (-3)0000 0111 1111 1101 1 0000 0100读出 4正确。3 - 7 → 3 (-7)0000 0011 1111 1001 1111 1100读出 -4正确。0 - 1 → 0 (-1)0000 0000 1111 1111 1111 1111读出 -1正确。(-128) - 1 → (-128) (-1)1000 0000 1111 1111 1 0111 1111读出 127溢出。工程实现上把行波进位加法器的 B 端加一排非门再把最低位的进位输入 Cin 直接接成 1这套电路就同时具备了加法和减法两种功能。成本几乎为零而收益是省掉一整块减法器。这个细节很多教材一笔带过但它恰恰是补码最大的工程价值所在——它把符号这件事从逻辑判断问题变成了电路连线问题。4.4 溢出怎么判两种方法都要会溢出不是进位溢出这两件事经常被混为一谈。判断方法有三种前两种最常用。方法一看符号。两个同号数相加结果的符号与之相反就是溢出。异号相加不溢出。这条规则的物理直觉很清楚同号相加的结果绝对值变大有可能顶出表示范围。方法二看进位。记 C7 为最高位bit7向外的进位C6 为 bit6 向 bit7 的进位则Overflow C7 ⊕ C6拿三个例子验一下算式位串相加C7C6异或结果127 10111 1111 0000 0001011溢出(-1) (-1)1111 1111 1111 1111110不溢出(-128) (-1)1000 0000 1111 1111101溢出第一行最能说明问题127 1 的最低位一路串行进位到 bit6但 bit7 是 0 0 1没有向外进位C7 0。如果你用有进位就是溢出来判断这里会漏判。反过来第二行两个 -1 相加最高位明明有进位结果却是正确的 -2。所以进位和溢出完全是两码事。方法三双符号位法也叫变形补码。把符号位扩成两位正数用 00负数用 11。运算之后看这两位00 是正常正数11 是正常负数01 表示正溢出上溢10 表示负溢出下溢。这个方法在需要区分溢出方向的场合很好用也是很多教材考试题的常客。5. 反过来补码求原码的三种手算方法知道一个补码怎么快速还原出它的十进制值或者原码下面三种方法各有用武之地我按使用频率排一下。5.1 取反加一教科书标准答案对符号位为 1 的补码先全部按位取反再加 1得到的就是绝对值对应的原码数值位。以 1111 1001 为例取反得 0000 0110加 1 得 0000 0111也就是 7所以原值是 -7。这里有个前提要强调只对负数用这个方法。正数的补码就是原码本身符号位是 0你要是也去取反加一就画蛇添足了。5.2 减一取反另一种思路同样是 1111 1001先减 1 得 1111 1000再按位取反得 0000 0111 7原值是 -7。两种方法结果一样但思路完全不同。取反加一是先补到模再减回偏移量减一取反是先把偏移量去掉再还原。真正需要警惕的是顺序必须是取反加一或者减一取反不能写成取反减一或者加一取反。我用 1111 1001 试一次错误顺序你就明白了——取反得 0000 0110减 1 得 0000 0101 5答案是 -5差得离谱。这个顺序错误我在带新人的时候见过太多次而且因为差 1 而差 2很难一眼看出来。5.3 首位权值法口算小负数最快直接把定义式套上去。1111 1001 就是 -128 64 32 16 8 1 -128 121 -7。这个方法的好处是不用记口诀、任何位宽都通用、心算负数小值时速度最快。缺点是位串一长就容易加错建议配合分段心算先减 2^(n-1)再把剩下的低位按 8 位一组切开来加。5.4 -128 这个特例必须单独记8 位补码的 1000 0000 表示 -128。对它用取反加一取反得 0111 1111加 1 又回到 1000 0000原地打转。这不是方法错了而是 -128 在 8 位原码里根本没有对应编码。8 位原码的范围是 -127 到 127那个 1000 0000 原本是给负零准备的在补码体系里被回收利用、赋予了 -128 的含义。所以补码求原码遇到 1000 0000只能单独判断并明确报出无对应原码。顺带说一句这个多出来的一个数正是补码比原码多表示一个值的来源也解释了为什么 int8_t 的范围是 -128 到 127 而不是对称的 -127 到 127。写边界检查代码时最小值的绝对值比最大值大 1取绝对值会溢出、除 -1 也会溢出这两个坑都出自这里。6. 用代码把位模式摊开来看纸面推演再多不自己敲一遍总归不踏实。同一个算子在不同语言里的行为差异很大这几个例子值得动手跑一跑。6.1 Python必须先手动 maskdef bits(v, n8): return format(v ((1 n) - 1), 0{}b.format(n)) def truth(v, n8): u v ((1 n) - 1) return u - (1 n) if u (n - 1) else u a, b -5, 3 print(a , bits(a)) print(b , bits(b)) print(ab , bits(a b), -, truth(a b))跑出来的结果是a 11111011 b 00000011 ab 11111110 - -2两个要点。第一Python 的 int 是任意精度所以 0xFF 这个动作必须自己写否则 -5 3 直接给你 -2你看不到位模式演化的过程。第二truth 函数里的符号扩展判断用的是最高位是不是 1这个条件等价于判断 u 128两种写法都可以但用移位写法在任意位宽下更通用。经常有人问我为什么 Python 里 -1 的二进制显示是 -0b1 而不是一串 1。原因就是它没有固定位宽负数在 Python 里是负号 绝对值的表示方式跟补码是两套东西。想看补码位模式必须自己 mask。6.2 JavaScript算术右移和无符号右移是两个物种const bits (v, n 8) (v ((1 n) - 1)).toString(2).padStart(n, 0); const signed (v, n 8) (v (32 - n)) (32 - n); console.log(bits(-5), bits(3), bits(-5 3)); // 11111011 00000011 11111110 console.log(signed(-2)); // -2JS 的位运算固定在 32 位有符号范围内所以 (32 - n) 这个移位技巧可以用来模拟任意位宽的符号扩展先左移把有效位顶到最高再算术右移拉回来符号位自动复制。真正要小心的是 和 的区别。 是算术右移补符号位-1 1 还是 -1 是无符号右移补 0-1 1 是 2147483647。搞混这两个代码会出现那种本地测试全过、线上偶发崩溃的诡异 bug。我自己的经验是凡是出现 的地方都要在注释里写清楚为什么这里需要无符号语义否则半年后回来看连自己都要愣三秒。6.3 符号扩展最高频的一个坑把 8 位有符号数提升到 16 位正确做法是复制符号位而不是高位补零。-1 在 8 位是 1111 1111扩到 16 位应该是 1111 1111 1111 1111真值还是 -1。如果你高位补零得到 0000 0000 1111 1111真值就变成 255 了。同一个字节两种解释方式差了 256。C 语言里 int8_t 到 int 的整型提升会自动做符号扩展所以是安全的但只要你中间经过一次 uint8_t 中转符号信息就丢了。这个坑在解析二进制协议、处理传感器数据时特别常见。我见过有人把温度传感器返回的 -1 摄氏度读成 255 摄氏度排查了半天才发现是中间转了一次无符号类型。Java 的情况 (byte) 0xFF 是 -1int x (byte) 0xFF 得到 -1符号扩展由虚拟机帮你做。但 (0xFF) 本身是 int 类型的 255要转成 byte 才变成 -1。这两行代码看着差不多结果完全相反是面试里很爱考的点。还有一个容易忽略的右移不等于除以 2。补码体系下算术右移是向负无穷取整而 C 的整除是向零取整。-3 1 得到 -2-3 / 2 得到 -1。在分页计算、坐标映射、音频采样下标换算这类场景里这个 1 的差异会真的让你少算或多算一格。我现在的习惯是凡是负数可能参与右移的地方就先在纸上把 -3、-1、0、1 这四个值代进去试一遍。7. 常见问题与排查实录7.1 常见现象速查表现象常见原因处理方式两个负数相加结果突然变正有符号溢出检查数值范围改用更宽类型或做饱和处理明明有进位却判定溢出把进位当成溢出用 C7 ⊕ C6 判断或者用同号异号法8 位扩到 16 位后数值翻正用了补零扩展改成符号扩展复制最高位补码求原码结果差 1顺序搞错写成取反减一严格按取反加一或减一取反1000 0000 求不出原码-128 无 8 位原码单独做边界判断有符号和无符号比较结果诡异隐式类型提升两边显式转成同一类型再比Python 里 -1 的二进制不是一串 1int 是任意精度手动 mask 后再格式化取绝对值之后变成负数对最小值取绝对值溢出先把变量提升到更宽类型表格里每一条我都在真实项目或别人的提问里遇到过。其中取绝对值之后变成负数这条最隐蔽int8_t 的 -128 取绝对值应该是 128但 128 超出 int8_t 的正数范围结果还是 -128。这个 bug 在信号处理代码里出现频率极高表现是偶尔出现一个巨大的负值毛刺。7.2 关于1024QAM 的符号位长为啥是 10bit这个搜索词网上有不少人搜这个其实是被术语撞车带偏了。1024QAM 是正交幅度调制的一种1024 指的是星座图上有 1024 个不同的状态点。要让接收端区分这 1024 个点就得给每个点分配唯一的编号编号需要多少位2 的 10 次方等于 1024所以是 10 位。这里的符号指的是调制符号symbol也就是一个星座点、一次传输所承载的信息单元跟符号位里的符号正负号完全没有关系。一个是通信里的调制概念一个是编码里的正负标记撞在同一个中文词上而已。同样的规律可以套到其他调制方式16QAM 是 4 位64QAM 是 6 位256QAM 是 8 位4096QAM 是 12 位全都是 log2(星座点数)。下次再看到多少 QAM 对应多少 bit直接取对数就行不用去翻规范。7.3 几条最容易记反的结论第一有进位就是溢出是错的。判断溢出看的是最高两位的进位是否一致或者用同号异号法跟有没有进位是两件事。127 1 有溢出但最高位没有向外进位两个 -1 相加有向外进位却完全没有溢出。第二补码求原码和原码求补码算法相反是错的。对负数而言两者是同一条公式——取反加一因为这两种编码在这个操作上恰好互为逆运算。真正需要小心的只有 -128 这个边界值。第三符号位参与运算所以符号位变成了数据这个说法也不严谨。在补码体系里符号位一直就是数据位只是在原码和反码体系里被人为地隔离出去了。换个角度看不是补码允许符号位参与运算而是原码和反码被迫把它隔离。第四补码范围比原码大是因为它更节省也不对。位宽一样都是 8 位 256 个状态谁也没多占地方。补码只是把原码和反码浪费掉的负零那个编码回收利用重新定义成了 -128。零唯一加上多一个最小值这两件事是同一个动作的两面。第五无符号数没有补码是混淆了概念。位模式是一样的区别只在于你怎么解释最高位的权值。同一串 1111 1111无符号读作 255有符号读作 -1硬件层面从头到尾没有任何变化加法器更不会因为你的类型声明而改变接线。我自己在这件事上最大的体会是不要试图用记忆去覆盖这个知识点。取反加一四个字谁都背得下来但真正能在调试时帮上忙的是脑子里能立刻把位串展开成负的首位权值加低位那个求和式。养成一个习惯看到任何负数位串先用首位权值法读一遍真值再用取反加一验一遍两边对上了再继续往下写代码。这么练个二三十次之后判断溢出、做符号扩展、写定点数运算这些事会变得几乎不需要思考而且出错的时候自己就能感觉到这个位串不对劲。
返回列表