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):正数和正码一样,负数把正码的数值位逐位取反,符号位不动。
- 补码(two's complement):正数和正码一样,负数在反码基础上再加 1。
理论上就是:反码 = 正码按位取反(符号位除外),补码 = 反码 + 1。
这里我想多说一句:反码的英文ones' complement直译是“对 1 求补”,补码的英文two's complement直译是“对 2 求补”。这两个名字的由来都和取模运算有关,理解了背后的模数,你就不会再觉得“反码加一”是个死记硬背的技巧。后面第 3 节我会专门展开这层数学含义。
2. 三种编码的定义与手算转换
先说明一下:为了避免跟标题脱节,下文我会用“正码”来称呼大家更熟悉的“原码”,两者是同一个东西。另外一个约定:下面所有例子都用 4 位二进制,方便你拿笔自己推一遍;4 位虽然小,但所有规律都成立,比直接甩 8 位例子更容易看清本质。
2.1 正码——最直观但最不好用
正码的规则最简单:最高位是符号位,0 为正、1 为负,剩下位数表示绝对值。以 4 位为例:
+3:符号位 0,绝对值 3 的二进制是011,合起来0011。-3:符号位 1,绝对值 3 的二进制是011,合起来1011。+0:0000。-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。于是正零负零合并成了同一个0000,CPU 比较两个数是否相等就不需要特殊处理了。 - 第二,减法彻底变成了加法。
5 - 3可以写成5 + (-3),而-3套上补码表示之后,拿加法器按位相加就行。关于这一步背后的数学原理,我放到第 3 节详细展开,这里先记住操作规则即可。
因为补码同时解决了表示的唯一性和运算统一性,从 80386 时代开始,Intel 和所有主流 CPU 的有符号整数几乎都采用补码存储。你现在写int、long,内存里那一串二进制全是补码形态。
2.4 一张表看懂 4 位二进制下的三种编码
下面这张 4 位二进制对照表,建议你亲手抄一遍、自己算一遍,比对结果。这一步做扎实了,后面所有推导都能在脑内完成。
| 十进制 | 正码 | 反码 | 补码 |
|---|---|---|---|
| +7 | 0111 | 0111 | 0111 |
| +1 | 0001 | 0001 | 0001 |
| +0 | 0000 | 0000 | 0000 |
| -0 | 1000 | 1111 | (不存在,归入 0000) |
| -1 | 1001 | 1110 | 1111 |
| -3 | 1011 | 1100 | 1101 |
| -7 | 1111 | 1000 | 1001 |
| -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 位的-1(1111 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 + 1:0111 + 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 过来的人非常不适应。 - Python:int 是无限精度的,不存在 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 而不是 -0? | 8 位补码里 0 只有0000 0000一种,1000 0000在模 256 下等价于 -128。 |
| 负数补码怎么快速手算? | 从右往左找第一个 1,此位及右边保留,左边数值位取反。 |
字节0xFF转 int 应该是 255 还是 -1? | 取决于原始类型:signed char是 -1,unsigned 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 的过程,比背十遍定义都管用。电脑里所有整数,不管十进制看多正常,底层都是这一套同一的取模逻辑。下一次你再遇到负数和十六进制混在一起时,别急着怀疑编译器,先拿笔把补码推一遍,多半答案自己就浮出来了。