--- title: "02-原码、反码、补码" aliases: - 原码反码补码 created: 2026-08-29 tags: - 基础与理论 - 信息的表示与处理 --- # 原码、反码、补码 > 本篇是总篇 [[00-信息的表示与处理]] 第三章的展开:从"二进制里负数怎么表示"这个问题出发,看原码、反码、补码三代方案如何一代一代填上一代的坑,最后补码一统天下。 在 C 和 C++ 中,整数有两种方式编码:一种只能表示非负数,另一种能表示负数、0 和正数。而 Java 只支持有符号数。 C 语言中有着很多表示有限范围的整数的数据类型:char、short、long……它们默认表示可能为负数,还可以声明为 unsigned 表示非负数。为这些不同大小分配的字节数,根据程序编译为 32 位还是 64 位而有所不同——long 是与机器相关的大小指示符,64 位机器用 8 字节表示,32 位机器用 4 字节表示。 ![[image-fed93318.png]] 32 位程序上 long 表示为 -2147483648 ~ 2147483647,unsigned long 表示为 0 ~ 4294967295。 可以发现取值范围其实是不对称的——负数的范围比正数范围大 1。这个"不对称"的伏笔,读到补码一节就解开了。 ## 一、无符号数的编码 假设一个整数的数据类型有 w 位,我们可以将位向量写成 [x(w-1), x(w-2), ……, x0]。 把向量 x 看做一个二进制的数,每个 xi 都取 0 或 1,就获得了它的无符号表示。可以使用函数表示为: ![[image-def50d2e.png]] 一个特别好用的理解方式——**把每一个位当做一个向量**: - 该位为 1 时,就加上该向量的长度 - 为 0 则不加 - 最终的总长度即为无符号表示的值 (就是我们二进制转十进制的步骤,参见 [[01-进制与进制转换]]) ![[image-faf9509b.png]] ![[image-266a700f.png]] 所能表示的范围为: ![[image-0a0d395f.png]] ## 二、有符号数的编码 有符号就引出了"负号"的概念。 平时的十进制计算中,看到 2 + (-18) 会把它变成 2 - 18;2 - (-18) = 2 + 18;2 × (-18) = -(2×18)——把负号换掉或者拿掉,在结果上再加上负号。 那么二进制怎么做?历史提出了三种解决方式——**原码、反码、补码**。 ### 2.1 原码 ![[image-812168e8.png]] 最高位是符号位,0 代表正数,1 代表负数,非符号位为该数字绝对值的二进制。 ![[image-eee777d6.png]] 通俗的理解:仿照十进制,用一个特殊标志表示负数,该标志在这个数以外——该位为 1 则整个数为负,反之为正。 **通俗的理解,原码就是一个整数本来的二进制形式,原码最左边的一个数字就是符号位,0 为正,1 为负。** 例如:18(D) = 0000 0000 0001 0010,-18 = 1000 0000 0001 0010。左边第一位为符号位,其他位为数据位。 一个 byte 有 8 bit,最大值是 0111 1111(+127),最小值是 1111 1111(-127)。 这样一来问题就来了:**符号位不参与数本身**,就不能直接进行二进制运算,还需要一个新硬件去判断结果到底是正还是负,对于乘除来说则更加麻烦。 **正数计算**——使用原码对正数进行计算不会有任何问题。例如 5 + 2: ``` 0 0 0 0 0 1 0 1 + 0 0 0 0 0 0 1 0 ----------------- 0 0 0 0 0 1 1 1 ``` 把这个结果转成十进制刚好就等于 7,完全正确无误。 **负数计算**——但如果是负数的话,那计算的结果就会大相径庭了。计算 -56 - 1: ``` 1 0 1 1 1 0 0 0 ← -56 的原码 - 0 0 0 0 0 0 0 1 ----------------- 1 0 1 1 0 1 1 1 ``` -56 的原码是 1011 1000,减 1 之后变成 1011 0111,这个数转成十进制就是 **-55**。 计算前是 -56,减一之后正确的结果应该是 -57(原码 1011 1001)才对,居然还**越减越大**了。 > 📌 原码的坑:① 负数不能用这套位模式直接算;② 0 有两种表示(+0 = 0000 0000,-0 = 1000 0000)。 为了解决原码不能用于计算负数的这种问题,这时候,**反码**出现了,作为负数的"计算的救星"。 ### 2.2 反码 ![[image-077d01f4.png]] **正数的反码不变和原码一致,负数的反码会在原码的基础上,高位符号位不变,其他位取反(1 变成 0,0 变为 1)。** **反码的存在是为了正确计算负数,因为原码不能用于计算负数。** ![[image-00b272cb.png]] ![[image-33fbf4e0.png]] 再来使用反码计算一下 -56 - 1: -56 的原码是 1011 1000,转成反码(符号位不变,其他位取反)就是 1100 0111: ``` 1 1 0 0 0 1 1 1 - 0 0 0 0 0 0 0 1 ----------------- 1 1 0 0 0 1 1 0 ``` -56 - 1 = -57,-57 的原码是 1011 1001,转成反码刚好是 1100 0110,刚好等于刚才算出的值 ✓ 不过**反码也有它的"软肋":如果是负数跨零进行计算的话,计算得出的结果不对。** 拿 -3 + 5 来举例: -3 的原码是 1000 0011,转成反码就是 1111 1100: ``` 1 1 1 1 1 1 0 0 + 0 0 0 0 0 1 0 1 ----------------- 0 0 0 0 0 0 0 1 ``` 把计算结果转成十进制是 1。但 -3 + 5 显然等于 2——**差了 1**。 这正是跨零的代价:只要运算跨过了零,反码的结果就会偏 1(根源还是反码里有两个零,0 和 -0 各占了一个编码,挤掉了真实值的位置)。 > 📌 反码的坑:跨零计算差 1,仍然有两个零。 那么我们该怎么计算呢?这时候,作为反码的补充编码——**补码**就出现了。 ### 2.3 补码 ![[image-1fca7bca.png]] ![[image-99c0dbd2.png]] ![[image-3459e3b0.png]] **正数的补码是其本身;负数的补码等于其反码 + 1,符号位不变。** 因为反码不能解决负数跨零(类似于 -3 + 5)的问题,所以补码出现了。它的思路很妙:**利用越界会丢失的特性**。 考虑 -1,希望 -1 + 1 = 0,该怎么做到? ``` 0000 0001 是 1 0000 0000 是 0 如果可以让 1111 1111 表示 -1 的话,就能实现这个操作: 1111 1111 + 0000 0001 ----------- 1 0000 0000 ← 最高位溢出丢弃,剩下 0000 0000,正是 0 ✓ ``` ![[image-dddc65a5.png]] ![[image-6fa10603.png]] ![[image-e5716360.png]] 用首位判断符号、并且首位的 1 也是这个数的一部分——补码的权重理解: **把每一个位当做一个向量:首位为 1 时方向向左(负权重),为 0 则向右,依次加上每位长度,最终的总长度即为有符号补码表示的值。** ![[image-aaf36f75.png]] 再来使用补码计算一下 -3 + 5 的结果: -3 的原码是 1000 0011,转成反码是 1111 1100,再转成补码就是 1111 1101: ``` 1 1 1 1 1 1 0 1 + 0 0 0 0 0 1 0 1 ----------------- 0 0 0 0 0 0 1 0 ``` 把这个数转成十进制刚好等于 2,结果正确 ✓ 同一个 1111 1111,被当成纯二进制来看待是 255,被当成补码来看时是 -1——**解释方式不同而已**,这句话是整篇的钥匙,后面转换一节还会回到它。 对于 -a,它的补码就是 0 - a,实际上是 2ⁿ - a(n 为位数): ![[image-493e5311.png]] 如果 1 没被舍去,它就是 256(2⁸),2⁸ 减 1 就得到了 -1 的补码。 **补码的意义就是:拿补码和原码可以加出一个溢出的零。** ``` 1111 1111 补码是 -1,按无符号看是 255 -1 + 1 → 255 + 1 = 256 → 1 0000 0000 溢出,得到全 0 ``` 有了补码最大的好处就是:**做计算的时候不需要做符号判断,可以直接做简单的二进制加法**——硬件终于不用再准备一套"符号判断电路"了。 ## 三、三者关系与取值范围 ### 3.1 三者转换关系 | 数型 | 原码 | 反码 | 补码 | | --- | --- | --- | --- | | 正数 | 本身 | 本身(不变) | 本身(不变) | | 负数 | 符号位 1 + 绝对值 | 符号位不变,其余取反 | 反码 + 1 | 举例验证: - 1 的原码是 [0000 0001],它的反码是其本身 [0000 0001],补码也是其本身 - -1 的原码是 [1000 0001],其反码是 [1111 1110],补码是 [1111 1111] ### 3.2 范围为什么不对称 现在的计算机当中都是使用补码来进行计算和存储的。补码很好地解决了反码负数不能跨零计算的弊端,并且补码还可以记录一个特殊的值:**-128**——这个数据在 1 个字节下是没有原码和反码的(原码/反码的 8 位表示不出来它)。 补码的范围是不对称的,Tmin 没有与之对应的正数。是因为有 0 的存在:一半的位模式表示负数,另一半表示非负数,因为 0 占用了非负数这边的一个名额,所以能表示的正数比负数少一个。 Java 中要求用补码表示,单字节为 byte 而非 char,为保证无论在何机器运行,程序都表现一样: ![[image-1c7c3f7d.png]] ### 3.3 时钟:理解溢出的最好比喻 ![[image-bf07d68f.png]] 其实可以理解成一个时钟:顺时针 0 - 1 = -1 很正常,逆时针 -1 + 1 = 0 也很正常。顺时针为减、逆时针为加:-128 - 1 = 127,127 + 1 = -128。 **比最小的小就变成最大的,比最大的大就变成最小的**(24 点过后不就是新的一天变成 1 点了)。 对于无符号数也是同理:255 + 1 = 0,0 - 1 = 255。 ![[image-92c2165e.png]] 1111 1111 + 1 = 1 0000 0000,最高位溢出了,留下的只有 0000 0000;0000 0000 - 1: ![[image-ecdca16a.png]] 留下的是 1111 1111。 顺便回答开头的伏笔:如果单看二进制还可以理解,突然告诉我 127 + 1 = -128 就大吃一惊了——这里是作为**有符号数**看待的:char 无符号数范围为 0~255,有符号数的范围为 -128~127。时钟转过头去,就从最大值绕回了最小值。 ## 四、有无符号数的转换 ### 4.1 核心原则 C 语言允许在各种不同数字数据类型间强制类型转换: ``` int x; unsigned u; (unsigned)x; // 把 x 的值换成一个无符号数值 (int)u; // 把 u 转换成有符号整数 ``` 两种表示的范围不一样,就可能出现几种情况: 1. 对于两种形式下都存在的值——保持不变 2. 对于负数换成无符号数——可能得到大正数 3. 转换的无符号数太大,超过了补码能表示的范围——可能得到负数 **在类型转换下,位值是不会变的,改变的只是解释这些位的方式**(就像存入一个数,这个数在内存里不会变,变的只是你用二进制还是十进制去解释它、取出它)。 -12345 的 16 位补码表示和 54191 的 16 位无符号表示的位模式是一样的。也就是说 0xCFC7 的 16 位位模式既是 -12345 的补码表示又是 54191 的无符号表示。 12345 + 54191 = 65536 = 2¹⁶。 ??????? 这并非巧合——这个属性可以推广到给定位模式的两个数值(补码和无符号数)之间的关系。 ### 4.2 补码 → 无符号数 对于 4 位的转换: | 补码 | 无符号数 | | --- | --- | | -8 | -8 + 16 = 8 | | -2 | -2 + 16 = 14 | | 0 | 0 | | 5 | 5 | 补码能表示的正数范围小于无符号数,所以无需考虑正数越界的问题:若为正则保持一致;若为负,加上 2^w 就能得到转换结果。 为什么?用 4 位来解释: - 对于补码来说,最高位是符号位,用**向左的条**表示(权重 -8) - 对于无符号数来说,最高位是正权重,用**向右的条**表示(权重 +8) 从补码变到无符号数,最高有效位的权重从 -8 变成了 +8,因此补码表示的负数如果看成无符号数,值会增加 2⁴ = 16。 ![[image-9b5a715e.png]] 相差了两个权重的绝对值之和。所做的事情也可以用图像表示——非负数保存不变,负数转换成了大的正数: ![[image-5bfe407f.png]] ### 4.3 无符号数 → 补码 对于 4 位的转换: | 无符号数 | 补码 | | --- | --- | | 14 | 14 - 16 = -2 | | 7 | 7 | ![[image-f0225929.png]] ![[image-75285f90.png]] 大于有符号表示范围的数变成负的,小于等于有符号表示范围的数不变。 ### 4.4 总结 对于 0 ≤ x ≤ Tmax 范围内的值而言,T2U(x) = x 且 U2T(x) = x——在这个范围内数字有着同样的无符号和补码表示。 对于这个范围以外的数值,转换需要加上或减去 2^w。这样一来: - 最靠近 0 的负数会被映射成最大的无符号数 - 最小的负数映射为一个刚好在补码正数范围以外的无符号数 ## 五、C 语言中的有无符号数 C 语言支持所有整型数据类型的有符号和无符号运算。尽管 C 语言标准没有指定有符号数要采用某种表示,但几乎所有机器都使用补码。 通常大多数数字都被默认为是有符号的,如果要创建一个无符号常量,就必须加上后缀 U 或者 u。C 语言允许有符号和无符号数之间的转换,虽然 C 标准没有精确定义如何进行这种转换,但大多数系统遵循的原则是**底层的位表示保持不变**,因此在一台采用补码的机器上,转换进行的就是上面两个函数的操作。 这种转换分为显式的和隐式的: ``` // 显式 int tx, ty; unsigned ux, uy; tx = (int)ux; uy = (unsigned)ty; // 隐式:赋值时自动转换 tx = ux; uy = ty; // 隐式:printf 中 int x = -1; printf("x=%u=%d\n", x, x); // 两种解释同时打印 // 隐式:运算中 ``` **最要命的是运算中的隐式转换**:执行一个运算时,一个运算数是有符号的而另一个为无符号的,那么 C 语言会**隐式地将参数强制类型转换成无符号数,并假设这两个数都是非负的**再执行运算。 这对于算术运算可能没多大差异,但是在关系运算中会导致非直观的结果: ``` -1 < 0U 先转换成无符号的,就成了: 4294967295U < 0U ← 最靠近 0 的负数被映射成最大的无符号数 显然是错的(本应为真)。 ``` ## 六、盲点自测 1. **为什么负数的表示要迭代三代?** ——原码不能算负数(还俩零),反码跨零差 1(还是俩零),补码全解决(零唯一)——每一代都是给上一代填坑。 2. **-1 的补码为什么是 1111 1111?** ——利用溢出丢失:255 + 1 = 2⁸ 溢出归零,所以 1111 1111 和 1 相加正好得 0,它就是 -1。 3. **补码范围为什么是 -128 ~ 127 而不是 -127 ~ 127?** ——0 占了非负数一个名额,正数比负数少一个;多出来的 1000 0000 直接定义为 -128。 4. **为什么 -128 没有原码和反码?** ——原码/反码的 8 位里,1000 0000 被规定为 -0,表示不到 -128。 5. **有无符号转换时内存变了吗?** ——没变,变的只是解释方式;负数补码看成无符号 = 值 + 2^w,本质是最高位权重从 -2^(w-1) 翻成 +2^(w-1)。 6. **-1 < 0U 为什么是假的?** ——C 隐式把 -1 转成无符号,得 4294967295,自然大于 0。关系运算混用有无符号是经典事故现场。 7. **127 + 1 为什么等于 -128?** ——把 8 位补码想成时钟,转到头就绕回另一端;无符号的 255 + 1 = 0 同理。 --- 补码把"负数"焊死在了硬件里。接下来看看程序员能直接摸到的位运算兵器谱 → [[03-位运算应用]] ⬅️ [[01-进制与进制转换|进制]] 🏠 [[00-信息的表示与处理]] ➡️ [[03-位运算应用]]