ARTICLE DETAIL

资讯详情

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

原码反码补码详解:从数学原理到调试实战

原码反码补码详解:从数学原理到调试实战 1. 正码、反码、补码到底在解决什么问题如果你正在啃计算机组成原理准备考研或者刚被 C 语言里signed char打印出来的负数搞到怀疑人生那么正码很多教材里叫原码、反码、补码这三兄弟就是你绕不过去的一道坎。别小看这个知识点它不单是笔试面试里的常客实际开发中你也会反复撞到它——比如Wireshark抓包看到一个十六进制数0xFFFFFFF6或者 Java 里byte类型强转后数值突然变成负的根子都在这里。这篇文章把三者的定义、转换方法、数学原理和实际调试技巧一次讲透。搞懂之后你可以手算出任意 8 位、16 位、32 位整数的内存存储形态能看懂调试器里的十六进制内容也就能理解为什么减法在 CPU 里是一套加法电路就能搞定的事连带着溢出判断、符号扩展、位运算这些衍生问题也会通通串起来。不管是正在上学的学生、准备面试的求职者还是写嵌入式、做协议解析的程序员花半小时把这块掰清楚后面能省下大把时间。1.1 从一条减法算式说起我们从小算数是十进制做5 - 3脑子转两下就出结果但硬件不行。CPU 里的加法器处理两个数的加法只需要一条电路处理减法却要考虑借位逻辑复杂度直接上一个台阶。既然减法这么麻烦那能不能换个思路把5 - 3变成5 (-3)让所有减法都统一成加法这个想法很美好问题随即而来-3在二进制里到底怎么表示最直观的思路是拿最高位当符号位0 代表正数、1 代表负数剩下的位照抄绝对值。比如用 4 位二进制3就是0011-3就是1011。这就是正码也叫符号位加绝对值表示法。它好在人类看着亲切可坏在硬件上你把1和-1直接按位相加得到的是1000...0010谁也不认识这是 0。硬件如果要用正码做减法还得先比较两个数绝对值谁大、再决定谁减谁、最后处理符号绕了一大圈加法器省下来的复杂度全还回去了。1.2 三种编码的名字和关系正码、反码、补码三者的关系其实一句话就能说清正码sign-magnitude符号位 绝对值最符合人的直觉。反码ones complement正数和正码一样负数把正码的数值位逐位取反符号位不动。补码twos complement正数和正码一样负数在反码基础上再加 1。理论上就是反码 正码按位取反符号位除外补码 反码 1。这里我想多说一句反码的英文ones complement直译是“对 1 求补”补码的英文twos complement直译是“对 2 求补”。这两个名字的由来都和取模运算有关理解了背后的模数你就不会再觉得“反码加一”是个死记硬背的技巧。后面第 3 节我会专门展开这层数学含义。2. 三种编码的定义与手算转换先说明一下为了避免跟标题脱节下文我会用“正码”来称呼大家更熟悉的“原码”两者是同一个东西。另外一个约定下面所有例子都用 4 位二进制方便你拿笔自己推一遍4 位虽然小但所有规律都成立比直接甩 8 位例子更容易看清本质。2.1 正码——最直观但最不好用正码的规则最简单最高位是符号位0 为正、1 为负剩下位数表示绝对值。以 4 位为例3符号位 0绝对值 3 的二进制是011合起来0011。-3符号位 1绝对值 3 的二进制是011合起来1011。00000。-0最高位置 1、数值位全 0得到1000。看出来问题了吗0 有两种表示0和-0同时存在。这可不是小麻烦CPU 里拿两个数做相等比较遇到0和-0还得额外判断。更难受的是正码做加减法必须拆成“同号相加、异号相减”的情况处理硬件电路复杂速度也上不去。所以正码除了让人看懂之外几乎没有被现代 CPU 用作整数运算的存储格式它最大的价值是“教学意义”和“人类理解中间层”。2.2 反码——规则简单却留下两个“0”反码的规则就一句正数的反码等于正码负数的反码是正码的数值位逐位取反符号位保持 1。还是 4 位3的反码0011。-3的正码是1011数值位011取反变成100符号位不动得到1100。所以-3的反码是1100注意别忘符号位不变。0的反码0000。-0的反码正码1000数值位三位000取反变成111得到1111。反码比正码进步的地方在于负数之间的加法可以直接套用加法规则先算反码再加。但0和-0各有一份的毛病依旧存在做运算等于要处理两套 0。而且反码有个反直觉的细节如果两个反码相加产生了最高位的进位这个进位必须绕回最低位再加一次这叫“循环进位”多一步操作就多一份电路开销还容易出错。2.3 补码——让减法变成加法的关键补码的规则前面说过了正数不变负数在反码基础上加 1。还是用-3举例反码是1100加 1 得到1101这就是-3的补码。这个“加一”看似轻描淡写实际上解决了两个历史遗留难题第一0 的表示唯一了。0的补码是0000-0先取反得到1111再加 1 变成10000在 4 位里最高位进位被丢掉结果还是0000。于是正零负零合并成了同一个0000CPU 比较两个数是否相等就不需要特殊处理了。第二减法彻底变成了加法。5 - 3可以写成5 (-3)而-3套上补码表示之后拿加法器按位相加就行。关于这一步背后的数学原理我放到第 3 节详细展开这里先记住操作规则即可。因为补码同时解决了表示的唯一性和运算统一性从 80386 时代开始Intel 和所有主流 CPU 的有符号整数几乎都采用补码存储。你现在写int、long内存里那一串二进制全是补码形态。2.4 一张表看懂 4 位二进制下的三种编码下面这张 4 位二进制对照表建议你亲手抄一遍、自己算一遍比对结果。这一步做扎实了后面所有推导都能在脑内完成。十进制正码反码补码701110111011110001000100010000000000000-010001111不存在归入 0000-1100111101111-3101111001101-7111110001001-8无法表示无法表示1000这张表藏着两个关键点你对照着看正数和 0 的三种编码完全一样只有负数才分叉。4 位补码能表示-8而正码和反码在 4 位下只能表示到-7因为它们的符号位占用一位后数值位最多表达 7。所以 n 位补码的数值范围是-2^(n-1) ~ 2^(n-1)-1比正码/反码多出最左端那个最小负数。3. 为什么补码能成为工业标准很多人学会了转换规则却不知道补码为什么是这么设计的。这一节把底层的数学逻辑讲透你以后就不用死记“取反加一”了。3.1 模运算和进位丢失补码的数学原理想象一个只能显示两位十进制数的计算器你按99再按1结果本该是100但两位显示屏只能显示00。这个“溢出后从零开始”的现象就是数学里的模运算这里的模是100。补码的本质也是模运算。n 位二进制数能表示的组合有2^n种所以它的模是2^n。现在重新看-3的 4 位补码1101把它当成无符号数读是十进制 13而 13 16 - 3换句话说1101就是模 16 意义下-3的等价替代品。5 - 3变成5 13结果是 18超过 16 后模掉剩下 2正好是5 - 3的结果。这就是补码最漂亮的点负数不用真的去做减法给它一个“补”到模数上的代表再和正数一样直接相加。加法器做完加法溢出的最高位进位自然丢掉结果自动落在模运算范围内。硬件不需要知道操作数到底是正是负一套加法电路通吃。3.2 硬件电路为什么偏爱补码从电路设计角度补码有三个压倒性优势加法器和减法器合二为一。用正码实现减法得比较大小、判断符号补码则连符号位都参与加法运算不需要额外的减法器。0 的表示唯一相等判断和循环控制逻辑都更简单。符号扩展直接复制符号位到高位即可不需要额外查表。我举个直观对比如果硬件采用正码一次a - b的减法运算控制逻辑要先看两个人的符号和大小关系再用专门的减法器或先取反再加一的临时寄存器指令周期明显拉长。换成补码一条ADD指令就能完成指令流水线不用为符号位额外操心。这也是为什么从早期 8086 到现在主流 CPU 里的整数运算单元都把有符号数做成补码形态。3.3 补码能表示的数值范围与符号扩展n 位补码能表示的数值范围是-2^(n-1)到2^(n-1) - 1。以 8 位为例最大正数0111 1111 127。最小负数1000 0000 -128。1000 0000之所以是-128是因为它作为无符号数是 128而 128 256 - 128模 256 下它就是-128的代表。由这个范围引出一个常见操作符号扩展。当你把一个 8 位的-11111 1111扩展成 16 位正确做法不是在高位补 0而是把符号位 1 一直复制上去得到1111 1111 1111 1111这样数值才仍然是-1。如果你在高位补 0得到的是0000 0000 1111 1111读出来就是 255数值彻底变了。注意无符号数的“零扩展”和有符号数的“符号扩展”是两个完全不同的操作。很多新手把char转int后数值突然变了往往就是在这里踩的坑。后面的第 4.3 节我会专门讲。4. 实操要点手算转换、溢出判断与常见坑理论讲完下面全是实战里能用到的操作技巧。我按自己平时 debug 和写代码的习惯把最容易踩的坑整理成四个部分。4.1 快速转换技巧给你一个负数要求 5 秒内写出它的补码我的做法是这样的从右边往左找第一个 1这个 1 以及右边的所有位原样保留左边的所有数值位全部取反符号位当然是 1。举个例子8 位下求-20的补码。先写20 0001 0100符号位改成 1 得到1001 0100但从右往左找第一个 1最低位是 0倒数第二位是 1于是这一位和右侧不动左侧数值位取反得到1110 1100。验证一下用标准“取反加一”法0001 0100取反得1110 1011加 1 得1110 1100跟快捷法结果完全一致。反过来从补码推十进制也一样看符号位如果是 1先减 1 再取反得到绝对值或者先取反再加一也行。这个方法在调试程序、手工分析二进制报文时能省不少时间。4.2 溢出判断——不能只盯符号位溢出是补码运算里最容易被忽略的坑。很多人以为最高位有进位就是溢出其实不然。以 4 位补码为例-7 (-1)1001 1111 11000最高位进位被丢掉结果是1000也就是-8这个结果完全正确。真正要盯的是两个同号数相加结果的符号位变了这才说明溢出。规则就两条正 正 负溢出。负 负 正溢出。比如 4 位里7 10111 0001 1000结果是-8显然错了这就是正正得负的溢出。再比如-7 (-2)1001 1110 10111截断 4 位得0111是7这是负负得正同样溢出。这个判断在高级语言里大多被封装好了但你在写协议解析、图像像素处理、嵌入式采集数据这类贴着底层数据类型边界写代码的时候溢出判断非常重要。比如你用一个int8_t去累加传感器读数加到 127 后再加 1 就变成 -128如果没提前判断溢出后面所有计算都会一路错下去。4.3 符号扩展与截断符号扩展的规则前面讲过有符号数扩展时复制符号位无符号数扩展时补 0。在 C 语言里这个行为会自动发生但也会悄悄坑人。看这段代码signed char c -1; // 0xFF int i c; // 0xFFFFFFFF还是 -1 unsigned char uc 0xFF; int j uc; // 0x000000FF变成 255-1在有符号扩展下是0xFFFFFFFF而无符号0xFF扩展成0x000000FF两个数看起来都是 FF 开头数值却天差地别。我在实际项目里遇到过一种情况从网络报文里读出一个字节0x80如果按signed char处理是 -128按unsigned char处理是 128解析出来的数据直接翻倍。所以一旦涉及跨类型转换先问自己一句这个数据的语义到底是有符号还是无符号截断是另一个反方向的问题把一个 16 位值强转成 8 位高位直接丢弃保留低 8 位。比如0x01FF截断成0xFF按有符号读是 -1。这类问题在文件格式解析、取模运算里经常出现碰到时要清楚“截断相当于对 2^n 取模”。4.4 编程语言里的实际行为不同语言对补码的处理细节不同实际开发里经常因此出现“同样的数据不同语言读出不同结果”的现象Java整数默认有符号byte范围是 -128 到 127。你写(byte) 0x80得到的就是 -128打印出来直接是负数很多刚从 Python 过来的人非常不适应。Pythonint 是无限精度的不存在 8 位补码溢出的说法。但如果你要模拟 C 语言行为可以用掩码操作比如(-1) 0xFF得到 255(x 0xFF) - 256 if x 0x80 else x 0xFF可以把 0x80-0xFF 还原成 -128 到 -1。C/C有符号整数溢出是未定义行为编译器可能优化出让你目瞪口呆的结果而无符号整数溢出是明确定义的取模行为。所以写可移植代码时别依赖有符号溢出。JavaScript位运算会把数字先转成 32 位有符号整数(0x80 24) 24得到 -128这就是补码在 JS 里的体现。5. 常见问题速查与排查实录最后把这段时间我见过的、被问过最多的几个问题整理成一张速查表都是可以直接拿去抄作业的结论。5.1 常见问题速查表问题答案要点为什么1000 0000表示 -128 而不是 -08 位补码里 0 只有0000 0000一种1000 0000在模 256 下等价于 -128。负数补码怎么快速手算从右往左找第一个 1此位及右边保留左边数值位取反。字节0xFF转 int 应该是 255 还是 -1取决于原始类型signed char是 -1unsigned char是 255。什么情况下7 1会变成负数4 位补码里0111 0001 1000即 -8正正相加符号位翻转溢出。为什么~x 1等于-x按位取反相当于对0xFFFF...求补再加 1 完成对2^n的取模正好是补码的几何意义。16 位转 8 位时数值突然变了高位被截断相当于对 256 取模别忘按有符号/无符号重新解释。5.2 我踩过的几个坑第一个坑是还在写单片机程序时踩的用int8_t累加一个角度值转数超过 127 的瞬间读数直接跳到 -128后面所有 PID 控制全部失稳。排查了半天最后用调试器一看内存0x80明明白白摆在那里。从那以后我给自己定了个规矩任何可能跨边界的累加先算好上下限要么换更大的类型要么显式做溢出检查。第二个坑是解析二进制协议时遇到的报文里有个一字节的状态字段文档写的是“0x80 表示-128”我拿 Python 的struct.pack(b)解出来是 -128同事用struct.pack(B)解出来是 128两个人为此争论了半小时。其实文档没说谎问题是解析方的类型语义不同。从那以后我在设计协议结构体时一律明确标注每个字段是有符号还是无符号省得后续维护的人猜。第三个坑相对隐蔽在 C 里写了if (a b 0)而a、b都是signed char时编译器会把它们提升到int再算加法的溢出行为和直接看 8 位补码完全不同。这种“整数提升”和补码是两个层面的事但叠加在一起经常让新手摸不着头脑。排查办法很简单把中间结果打印成十六进制看每一步在内存里的真实位模式一切就都清楚了。我个人在实战里最大的体会是补码不是考试结束就可以扔掉的知识点。你调试一遍0x80变成 -128 的过程比背十遍定义都管用。电脑里所有整数不管十进制看多正常底层都是这一套同一的取模逻辑。下一次你再遇到负数和十六进制混在一起时别急着怀疑编译器先拿笔把补码推一遍多半答案自己就浮出来了。
返回列表