ARTICLE DETAIL

资讯详情

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

补码等于反码加一?从模运算与位权看补码的完整证明与实战

补码等于反码加一?从模运算与位权看补码的完整证明与实战 很多人第一次接触原码、反码、补码记住的是一个顺口溜负数补码等于反码加一。可你要是追问一句“为什么”大多数人就卡住了。更麻烦的是网上能找到的资料要么只给结论要么把模运算、同余、位权这些概念一箩筐倒出来看似高深却没有直击“反码加一”这个公式的内核。我早年学计算机组成原理时也被这个“取反加一”折磨过。后来才发现只要把模的思想、位权的展开、以及硬件加法器的实现一对照这件事其实非常自然甚至还能推出不少做题技巧。这篇文章我就把这个证明链路完整拆一遍同时把做题、调试、看十六进制内存时容易踩的坑一并讲清楚。无论你是刚学数字逻辑的学生还是想补基础的同学这篇应该能给你一个allback到位的理解。1. 先搞懂三种编码各自的“性格”符号位的处理方式1.1 原码人看起来很舒服机器用起来头疼原码是最符合直觉的表示方式最高位当符号位0 表示正数1 表示负数剩下的位存绝对值。拿 8 位来举例5 0000 0101-5 1000 0101人一眼就知道哪个是正哪个是负绝对值也好读。但这个“舒服”是有代价的代价之一就是符号位没法直接参与运算。试试 -5 51000 0101 (-5的原码) 0000 0101 (5的原码) 1000 1010 (-10这显然不对)问题出在哪符号位被当成普通二进制位相加了但原码的符号位只是个“标签”并没有数值权重。如果你在硬件里直接用这个结果那不是加法器错了而是编码方式本身不配合运算。那怎么办传统做法是先判断两个数的符号相同就加绝对值不同就比大小做减法最后再决定结果符号。这逻辑听着不复杂但落在电路上就是一大堆比较器、减法器、符号判定逻辑成本高、延时大。早期计算机工程师最想干掉的东西里减法器一定排在前列因为减法永远比加法麻烦。所以原码只适合“给人看”不适合“给机器算”。顺带说一个原码的经典毛病它有两个零。0 0000 0000-0 1000 0000。两个零在数学上是同一个数但在编码里占用两个不同状态这既是浪费又让判断“结果是否为0”这种基础操作变得啰嗦你得写两条比较语句。这个隐患后面会反复出现。1.2 反码对称是美但“零”出了两个反码的规则也很简单正数不变负数在保持符号位为1的同时把数值位按位取反。5 0000 0101-5 1111 1010注意看规律5 和 -5 的每一位都互为相反包括符号位也是 0 和 1 完全对称。所以反码最迷人的地方是它把正负数做成了一个“镜像对称”的结构从 5 翻到 -5 只需要把每一位翻转。这个对称性对加法有明显改善。试一下 -5 51111 1010 (-5的反码) 0000 0101 (5的反码) 1111 1111 (-0)结果居然是 -0。虽然这个“-0”也挺诡异但至少数值大小是合理的只是符号不对。好那 -1 2 呢1111 1110 (-1的反码) 0000 0010 (2的反码) 0000 0000 最高位进位了18 位加法器只能保留低 8 位结果变成 0000 0000。按二进制加法正确结果应该是 1这里多了个进位。反码时代有个处理办法叫“循环进位”把最高位产生的进位再拿回来加到最低位上。0000 0000 0000 0001 (把进位加回来) 0000 0001 (1)这法子能用但多了一步“查进位、再加回来”让加法器的关键路径更长速度上不去。更难受的是反码仍然有两个零0000 0000 是 01111 1111 是 -0。两个零带来的真实痛点是比较两个数是否相等时必须先判断是不是“一个正零一个负零”做浮点数规范化、排序、哈希时也得小心处理。工程师们很快就意识到与其修补反码的零问题不如换一种编码方式把“负零”这个位型利用起来。1.3 补码把“负零”的位置让给最小负数补码的规则是正数的补码和原码一样负数的补码等于它的反码再加 1。5 0000 0101-5 1111 1010 1 1111 1011先看结果-5 51111 1011 (-5的补码) 0000 0101 (5的补码) 0000 0000 进位直接丢掉结果是 0进位丢掉即可。这是原码和反码都做不到的干净结果。再看另一个关键变化8 位补码中1111 1111 不再表示 -0而是表示 -1。0000 0000 是唯一的零。那么原来的 -0 位置1000 0000空出来了补码用这个位置表示 -128这就是为什么 8 位有符号数的范围是 -128 到 127而不是 -127 到 127。这一小步的直接收益是单个位型就能表示一个额外的负数同时加法器不需要判断符号位所有位都可以当作普通二进制位去相加进位溢出只做一次模 2^n 处理。补码基本上就是为“让电路简单可靠”而生的。下面用 4 位二进制做个直观对比重点看 -0 和最小负数的位型十进制原码反码补码701110111011150101010101010000000000000-010001111不存在-5110110101011-7111110001001-8无法表示无法表示1000这张表很值得多看两遍。原码和反码的位型数量都是对称的各有一个 0 和 -0补码却把 -0 的位置换成了 -8正数范围少了一个负数范围多了一个。所以补码的区间是不对称的负数能到 -2^(n-1)正数最多到 2^(n-1)-1。2. 补码的两个定义为什么是同一回事2.1 模运算视角补码就是“拨时钟”要理解补码等于反码加一得先接受一个数学概念模。n 位二进制数能表示 2^n 个状态加法器计算时一旦结果超过 2^n - 1高位就会被丢弃。这等价于所有计算都在“模 2^n”的意义下进行。往深了说这叫同余运算但你可以把它想象成一个只有 n 位刻度的时钟。时钟上 12 点再拨 3 小时等于 3 点也就是 12 ≡ 0 (mod 12)。如果你想把时针从 10 点拨到 7 点可以倒拨 3 小时也可以正拨 9 小时效果一样。因为 -3 ≡ 9 (mod 12)。补码干的事一模一样。对 n 位二进制系统模是 2^n负数 -X 的补码定义为[-X]补 2^n - X为什么是这个定义因为这样可以直接把减法变成加法A - X A (2^n - X) (mod 2^n)多出来的 2^n 在 n 位系统里会被自然丢弃剩下的就和 A - X 完全一致。举个例子4 位系统模是 16-3 的补码 16 - 3 1313 的二进制是 1101这就是 -3 的 4 位补码。验证一下-3 5 2用补码算就是 1101 0101 10010截断成 4 位是 0010正好是 2。这个“模”的思想是整个证明的地基也是后面所有结论的圆心。2.2 位权视角符号位是“负权重”另一种理解补码的方法是给每一位分配一个不同的权重然后把所有位乘上权重再求和这就是“位权展开”。原码的位权符号位权重为 0它只是个标记数值位权重为正。反码的位权符号位权重是 -(2^(n-1) - 1)数值位权重为正。补码的位权符号位权重是 -2^(n-1)数值位权重为正。拿 8 位补码 1111 1011 来算-128×1 64×1 32×1 16×1 8×1 4×0 2×1 1×1 -128 64 32 16 8 2 1 -5所以补码可以统一写成数值 -2^(n-1) × 符号位 (其余位按正权重相加)这个位权模型和模运算模型是等价的。设一个负数 x -X它的补码是 2^n - X。把 2^n 拆开2^n 2^(n-1) (2^(n-1) - 1) 1而 2^(n-1) 正是符号位的权重剩余部分全部落在低位。这个视角最大的好处是它把“符号位”从标签变成了真正参与运算的数值位。硬件里加法器根本不需要知道最高位是符号它只要按二进制权值相加结果自然正确。2.3 两个定义为什么等价从模出发补码 2^n - X (2^n - 1) - X 1。对一个 n 位正数 X按位取反的结果就是 (2^n - 1) - X因为全 1 的 n 位数是 2^n - 1减去 X 等价于逐位取反。所以补码 按位取反的结果 1这句话不是“看起来像”而是数学上严格相等。从位权出发也能推出同样结论补码的符号位权重是 -2^(n-1)要让一个负数和它对应的正数相加为 0负数数值部分的二进制值必须等于 2^(n-1) - X。正数 X 按位取反后数值部分是 2^(n-1) - 1 - X对这个值加 1正好是 2^(n-1) - X。所以“取反加一”就是在位权模型下构造相反数的自然结果。两个定义一个盯着“模”一个盯着“权重”最终都落到同一个公式上。理解到这个层面后面做证明就顺理成章了。3. 严格证明补码确实等于反码加一3.1 按位权的代数证明现在正式证明“补码 反码 1”。假设 n 位二进制系统负数 -X 的绝对值 X 满足 1 ≤ X ≤ 2^(n-1) - 1也就是常规原码能表示的范围。先写出原码结构原码 1(符号位) X 的 n-1 位二进制绝对值表示反码的规则是符号位不变、数值位取反。数值位取反等价于从 2^(n-1) - 1 中减去 X反码数值位 (2^(n-1) - 1) - X好现在给反码加 1数值位变成(2^(n-1) - 1) - X 1 2^(n-1) - X再来看补码的定义补码 2^n - X。把它拆成符号位和数值位2^n - X 2^(n-1) (2^(n-1) - X)等号右边第一项 2^(n-1) 就是符号位 1 在这个 n 位二进制里的权重第二项就是数值位。注意我们的加法都是在 n-1 位数值位上做的所以当 2^(n-1) - X 落在 [0, 2^(n-1) - 1] 范围内时不会有进位干扰符号位。对比一下反码加一后的数值位也是 2^(n-1) - X。一模一样。所以证明完成补码 符号位1 (2^(n-1) - X) 符号位1 反码数值位 1 反码 1这个证明的关键点在于数值位取反本质上就是在算 2^(n-1) - 1 - X而补码的数值部分需要的是 2^(n-1) - X中间只差了一个“加一”。3.2 从低位进位的位运算视角代数证明很干净但对一部分人来说不容易产生直觉。换个视角从最低位的进位来看“取反加一”到底发生了什么。假设一个负数 -X它的绝对值 X 的二进制是 01011007 位数值位举例而已。按位取反后原本所有 0 变 1所有 1 变 0。现在执行加 1加 1 从最低位开始。如果最低位原本是 0取反后就变成 1加 1 不产生进位直接就停在最低位。如果最低位原本是 1取反后就变成 0加 1 会让它变成 1但有进位送到下一位。进位送到下一位后同样要结合那一位取反后的值。如果那一位取反后是 0进位会继续往上传。而取反后是 0说明原绝对值那一位是 1。所以加法器会从最低位一路找一直找到绝对值中第一个 1。这个 1 被取反后变成 0加 1 进位传到这里把它重新变成 1进位停止。更准确地说从原数右起第一个 1 开始低位那段保持不变这个 1 本身也保持不变它左边的高位全部取反。举个例子8 位中 12 0000 1100右边起第一个 1 在第 2 位从 0 开始编号那么低三位 100 保持其余高位取反得到 1111 0100这就是 -12 的补码。直接算一遍取反加一验证12 取反是 1111 0011加 1 得到 1111 0100。结果一致。这就是“从右边找第一个 1”这个心算技巧的来历后面第 5 节还会细说。3.3 边界情况验证零、负一、最小负数证明公式只是第一步还得看看边界上成立不成立。第一个边界是 X 1也就是 -1。8 位下 -1 的反码是 1111 1110加 1 得到 1111 1111。用位权展开验证1111 1111 -128 127 -1完全正确。第二个边界是 X 0。0 的反码是 1111 1111加 1 后是 1 0000 0000这是一个 9 位结果。在 8 位机器里最高位进位被丢弃剩下的低 8 位是 0000 0000。所以补码中 0 是唯一的没有 -0。第三个边界是 X 2^(n-1)以 8 位为例就是 -128。-128 没有 8 位原码也没有 8 位反码它对应的二进制补码是 1000 0000。如果按“取反加一”过程来推128 的 8 位二进制是 1000 0000取反是 0111 1111加 1 是 1000 0000。所以 -128 的补码是它自己。这也是补码区间不对称的根源-128 的相反数 128 无法装进 8 位有符号数里。这带来一个经典陷阱在 8 位补码中对 -128 执行取反加一会得到 -128 本身这在数学上是不对的但按二进制运算规则它就是会这样。很多新手第一次调饱和运算或取绝对值时在这里被坑得很惨。最后一个边界是 -1补码全 11111 1111。它是所有负数里面位型最“整齐”的一个也直接解释了为什么任何负数和它自身做按位与、移位等操作时会出现各种全 1 的中间结果。3.4 反向操作也一样成立既然负数补码是“绝对值取反加一”那反过来给一个补码想求它的原码应该怎么做做法很统一把这个补码整体取反再加一。因为“取反加一”这个操作本质上是在模 2^n 意义下求相反数。对一个负数补码 M它的相反数是 -M而 -M 的补码正好是 (2^n - M) mod 2^n也就是 M 取反加一。举个例子-5 的补码是 1111 1011。整体取反得到 0000 0100加一得到 0000 0101。这个 0000 0101 是 5 的补码也就是 -5 的相反数。如果你想找原码还需要把符号位改成 1得到 1000 0101。很多人把“补码取反加一”误当成“补码转原码”的完整过程其实中间结果只是绝对值还差一个符号位。这一点做题时极容易出错第 5 节我会专门展开。4. 从纸面证明到硬件电路补码到底给CPU省了多少钱4.1 用加法器实现减法A-B A~B1证明补码等于反码加一不只是为了考试它直接转化成了电路设计。数字电路里的全加器只能做二进制加法。要让同一个加法器同时完成加减法思路很直接控制信号 sub 决定是加是减。sub 0 时普通加法 A B。sub 1 时做减法 A - B把 B 的每一位取反同时把加法器最低位的进位输入设为 1。为什么最低位进位要设成 1因为“取反”只完成了 B 到 ~B 的转换B 的相反数是 ~B 1这个 1 正好用最低位进位来实现。于是A - B A ~B 1这不就是“反码加一”在电路里的物理实现吗所以补码最重要的价值不是“好看”而是它能用一套加法电路通吃加减法。CPU 里的 ALU 因此可以少造一整套减法器节省的晶体管、面积、功耗和设计复杂度都是实打实的。4.2 溢出判断进位不等于溢出很多人在学补码时被“溢出”和“进位”两个词绕晕。其实这是两个完全不同的概念进位Carry表示最高位向更高位产生的进位它关注的是无符号数结果是否超出 2^n - 1。溢出Overflow表示有符号数结果是否超出 [-2^(n-1), 2^(n-1)-1]。补码加法里判断溢出的规则非常简练最高位的进位 与 次高位的进位 不相同则发生溢出。两个正数相加变负数或者两个负数相加变正数都属于溢出。比如 4 位补码中 7 1 80111 0001 1000 -8次高位 11 产生进位最高位 00 没有进位两者不同所以溢出。结果从 8 变成了 -8完全不线性。如果你在 C 语言里写了signed char a 127; a a 1;实际可能得到 -128这也不是巧合正是因为补码区间的不对称性。硬件上用两个进位异或就能产生溢出标志非常便宜。这也是补码能让 ALU 变得极其精简的原因之一。4.3 符号扩展负数补码天然能“拉长”另一个体现补码优势的场景是符号扩展。把一个 8 位有符号数转成 16 位时正数直接在高 8 位补 0负数要在高 8 位补 1。看看 -58位补码 1111 1011 16位补码1111 1111 1111 1011为什么高位补 1 不改变数值用位权就能解释16 位补码中最高位权重是 -32768新增的高位全 1 整体贡献是 -32768 16384 8192 ... 256对 8 位到 16 位来说。而原 8 位最高位符号位原来权重是 -128扩展后它的权重变成 128。净效果正好抵消于是数值不变。如果你在调试时手动看内存遇到负数扩展后的字节头总是一串 F这也是补码直接带来的视觉特征。熟悉这个特征后一眼就能认出负数的十六进制表示后面实操部分会用到。5. 做题和调试中的常见坑补码转换的快速心算5.1 从右往左找第一个130秒写出补码做题最烦的就是一步步取反再加一过程长还容易错。我推荐一个心算规则对一个正数的二进制表示从右往左找到第一个 1这个 1 及其右边的所有位保持不变左边的所有位全部取反得到的就是它相反数的补码。举例12 0000 1100。从右往左看第一个 1 在第 2 位那么低三位 100 保持其余高位 0000 11 取反成 1111 00拼接起来是 1111 0100就是 -12。再用取反加一验证0000 1100 取反 1111 0011加 1 得 1111 0100一致。这个规则为什么成立回到 3.2 的进位视角只有第一个 1 被取反成 0 后加 1 才能把它变回 1 并停止进位。所以在第一个 1 右侧的位不受进位影响左侧的位全部是“取反后加进位”等价于取反。调试时这个技巧特别省时间。你看到 0xFFFFFFF4先用这个规则反推它是 -12整个过程用不了三秒。5.2 补码求原码小心“整体取反加一”得到的是绝对值这是考试和面试里最典型的坑。负数的补码整体取反加一得到的是它的相反数的补码也就是绝对值。不是完整意义上的原码。例-5 的 8 位补码 1111 1011。整体取反加一0000 0100 1 0000 0101 5。如果你以为 5 就是 -5 的原码那就错了原码还要求符号位是 1。正确原码是 1000 0101。所以严谨地说补码转原码的流程是先判断符号位如果是负数就把补码整体取反加一得到绝对值再把最高位置 1。如果你只想快速做题也可以“符号位不动数值位取反加一”比如 1111 1011 数值位取反加一1 111 1011 - 保持符号位1数值位 111 1011 取反为 000 0100加一为 000 0101拼起来正好 1000 0101。注意这两种方法只对非边界负数能用。对 -128 这种最小值取反加一还是它自己无论怎么操作都会得到 1000 0000这时候应该意识到8 位有符号数根本无法表示 128它溢出到 -128 了。5.3 十六进制里快速看负数调试时我们看到的往往不是二进制而是十六进制。十六进制转补码有个更实用的口诀负数补码的十六进制就是它的绝对值的十六进制按位取反再加 1 的 2^n 位结果。但手算时更推荐这么做记住几个基准值。-1 永远是 0xFF…FF。-2 是 0xFF…FE。常见负数的十六进制比如 8 位 -5 0xFB-12 0xF4-128 0x80。32 位下 -1 0xFFFFFFFF-2 0xFFFFFFFE-127 0xFFFFFF81。看到 0xFFFFFFF4先看低字节 0xF4比 0xFF 少 0x0B也就是少 11所以它是 -11不对0xF4 是 244是无符号 244有符号 -12。这里更容易的方法0xF4 0x100 - 12所以它是 -12。对 32 位同理0xFFFFFFF4 2^32 - 12所以是 -12。一句话把十六进制补码看成“离 2^n 差多少”那个差值就是负数的绝对值。这就是模运算的实战用法。5.4 代码验证别凭感觉跑一下再看内存理论和手算都说完了最后建议你自己动手跑一次。C 语言就能做最简单的验证#include stdio.h int main(void) { signed char a -5; unsigned char b (unsigned char)a; printf(a %d\n, a); // -5 printf(b %u, 0x%02X\n, b, b); // 251, 0xFB signed char c 12; signed char d ~c 1; // 取反加一 printf(c %d, d %d\n, c, d); // 12, -12 signed char e -128; printf(~e 1 %d\n, (signed char)(~e 1)); // 还是 -128 return 0; }这段代码能同时验证补码编码、取反加一、以及 -128 的边界坑。我的经验是一旦你亲眼在终端里看到 -128 取反加一还是 -128对这个不对称性的印象就会深很多。另外如果你在用调试器看内存记得把显示格式切到十六进制和十进制来回切。比如 GDB 里x/4bx是按字节看十六进制print输出十进制对照着看会比单纯背概念直观得多。我在实际处理协议解析、传感器数据拼接时经常要判断两个有符号字节合并后的值。比如一个 16 位温度值的高字节是 0xFF低字节是 0x38如果当成无符号是 65336但按有符号来看就是 -200。这就是因为 0xFF38 的补码值等于 0x10000 - 0x0038 65480 - 65536 -200。每次换算都靠补码的模运算公式比临时查资料快得多。原码、反码、补码这套东西初看只是数字电路里的一小页实际上它贯穿了 ALU 设计、数据类型转换、溢出判断、调试器显示等所有底层环节。我个人最大的体会是别死记“取反加一”这个结论而是把“模 2^n”当成圆心。当你看到 -128 取反加一还是自己时当你看到 0xFFFFFFF4 瞬间算出 -12 时你才真正把这一页翻过去了。
返回列表