从原码到浮点数编码:机器数六套编码的演进与工程避坑
搜机器码这三个字搜索引擎给出的答案能把人劈成两半。一半结果是计算机组成原理的课件满屏的原码反码补码另一半结果是换了主板机器码变了怎么办软件绑定机器码怎么处理。第一次接触的人往往会愣住这俩说的是一个东西吗不是。前者的机器码指的是带符号数在计算机内部的二进制编码形式也叫机器数后者指的是由硬件信息拼出来的设备指纹属于软件授权那一摊事跟二进制编码没有半点关系。我当年第一次在教材上看到求 -37 的补码时脑子里浮现的就是主板序列号白纠结了半小时。这篇东西只聊前者。原码、反码、补码、变形补码、移码、浮点数编码这六个词是计算机组成原理里绕不过去的一堵墙考试要考写底层代码要用面试也爱问。但很多资料讲得零碎上来就甩一堆转换公式让你背背完还是不知道为什么要这么设计。我的思路不一样这六套编码其实是一条清晰的演进链每一套都是为了填上一套留下的坑。你只要抓住它解决了什么问题、又留下了什么问题这条线公式根本不用背现场都能推出来。1. 同一个词两套语境先别搞混对象1.1 授权系统里的机器码和设备指纹在软件授权、设备识别这些场景里机器码是一串从硬件信息里算出来的标识符常见的原料包括主板序列号、硬盘物理序号、网卡物理地址、CPU 序列号等再经过一轮哈希得到一串短字符串。它的目的是区分这台设备和那台设备用作授权的绑定对象。这类字符串会随着硬件更换、系统重装、驱动更新而变化所以才会有人到处问机器码变了怎么办。这个话题属于工程授权领域的设计取舍和本文要讲的二进制编码完全是两条线后面不再涉及。1.2 组成原理里的机器数把符号和小数点都塞进 0 和 1计算机内部只有高低电平落到逻辑上就是 0 和 1。它没有专门的负号引脚也没有单独的小数点信号线来给你标记位置。所以任何带符号数、任何小数想要存进去都必须先约定一套编码规则把正负、小数点位置、数值大小全部编码到一串定长的二进制位里。这套规则产出的结果就是机器数。我们平时写的 5、-5那是真值是给人看的101、1101 这种按规则编出来的才是机器能直接处理的机器数。这两者的区分是理解整篇文章的前提很多同学卡在为什么 -5 的补码是 1011本质上就是没意识到自己在讨论的是两套不同的表示体系。1.3 六套编码其实是一条线为什么会有六套因为它们不是同时被设计出来的而是一代一代迭代的结果。原码最直观但做不了减法反码想解决减法但带来循环进位补码用取模的思路彻底解决了加减统一但溢出判断不够直观变形补码给补码加了双符号位让溢出显式暴露移码解决了浮点阶码需要直接比较大小的问题浮点编码则是把符号、阶码、尾数各取所需把前面几套编码缝合在一起。你把这六个环节当成六个待解决的问题去看整个体系就活了。2. 原码最符合直觉的设计却有两处硬伤2.1 符号位加绝对值零门槛但代价不低原码的规则简单到不需要动脑最高位当符号位0 表示正1 表示负剩下的位原封不动存绝对值。以 8 位为例5→0000 0101-5→1000 01010→0000 0000-0→1000 0000人一眼就能读出来这就是它最大的优点。也正因为直观很多早期机器和现在的浮点数尾数都采用了这种符号加绝对值的思路。2.2 零有两个编码白白浪费一个状态n 位含 1 位符号位原码能表示的数值范围是-(2^(n-1)-1)到(2^(n-1)-1)。8 位就是 -127 到 127。注意 2^8 256 个位组合实际只表达了 255 个不同的数——因为0000 0000和1000 0000都表示零。这一个零不唯一看起来只是小瑕疵实际影响不小你没法用一句x 0判断一个原码是不是零得判断两次。做数值比较时也麻烦硬件里得先判符号位再比数值位。2.3 加减法不能直接做这才是真正的致命伤更大的问题是原码下加减法无法统一。5 (-3)如果直接按位加0000 0101 1000 0011 1000 1000这个结果是 -8而正确答案是 2。完全错。原因在于符号位参与了加法却代表符号而非数值。要算对硬件必须先判断两个操作数的符号同号就加绝对值、符号取原符号异号就比绝对值大小、大减小、符号跟着大的走。这套逻辑在门电路层面意味着加法器和减法器得分开做还要额外的比较器、符号判断逻辑和结果选择逻辑面积和延迟都上去了。提示原码的直观是给人看的对电路来说一点都不直观。设计数字系统时凡是需要频繁做加减的场合基本不会用原码存数据。2.4 那原码还有用吗有用。前面提到IEEE 754 浮点数的尾数字段用的就是符号单独一位 尾数存绝对值的结构这本质上就是原码的形态。原因是浮点运算里阶码对齐和尾数加减是分开处理的尾数字段本身不需要承担把减法变加法的任务反而更需要直观的绝对值形式。所以这六套编码不是谁淘汰谁的关系而是各司其职。3. 反码把减法变成加法的第一次尝试3.1 按位取反思路是从借位里长出来的反码的规则是正数的反码和原码一样负数的反码是符号位不变数值位按位取反。8 位下5→0000 0101-5原码1000 0101反码1111 1010-0→1111 1111它想干什么想让A - B变成A (-B 的反码)。我们可以验证一下5 - 3用反码做就是0000 0101 1111 1100 1 0000 0001。3.2 循环进位多出来的那一位还得绕回去上面这个结果1 0000 0001有 9 位最高位溢出了。反码的处理方式叫循环进位end-around carry把溢出的这一位加回到最低位去。0000 0001 1 0000 0010也就是 2。答案对了。再看3 - 50000 0011 1111 1010 1111 1101没有溢出结果就是1111 1101转回原码得1000 0010即 -2。也对。所以反码确实实现了减法转加法代价是硬件必须额外处理循环进位——加法器的进位输出要绕回来接到最低位的进位输入上。这多了一层逻辑也增加了关键路径延迟。3.3 为什么它注定是过渡产物反码解决了减法但没解决零。它的零依然有两个编码0000 0000和1111 1111。而且它比原码多了一个毛病处理进位的方式比直接相加要复杂。所以反码在历史上存在过早期一些机器确实用过但很快就被放弃。它在学习路线里的价值主要是让你理解取反 取模运算的一种近似从而自然过渡到补码。4. 补码真正的底牌是模运算4.1 用钟表理解同余一切就通了墙上挂个 12 小时的钟现在指针指向 3 点你想让它指向 1 点可以往回拨 2 格也可以往前拨 10 格。因为 12 是模-2 ≡ 10 (mod 12)。计算机里的定点整数也是这个逻辑。n 位寄存器就是一个模 2^n 的计数器超过 2^n 的部分自动丢弃等价于取模。这就是为什么补码的溢出位可以直接扔掉——不是扔掉而是它本来就不在模 2^n 的表示范围内。在这个体系下-x的表示就是2^n - x。以 4 位为例模是 16-3就写作16 - 3 13二进制1101。而13这个数在 4 位无符号解释下是 13在补码解释下是 -3同一个位模式两种读法。4.2 三种手算方法选顺手的那个知道了原理手算就有多种路径我按使用频率排一下。方法一先求原码数值位取反加一。求-37的 8 位补码37 0010 0101符号位取 1 得原码1010 0101数值位取反得1101 1010加一得1101 1011。完事。方法二从右往左找第一个 1保留它和右边的位左边全部取反符号位不动。还是-37原码1010 0101符号位1不动剩下的010 0101从右往左第一个 1 在最低位保留它左边的010010取反得101101拼起来1 1011 011也就是1101 1011。结果一致。这个方法做多了会很快因为它不用处理进位。方法三按权展开直接算。补码的权重公式是X -x_(n-1) * 2^(n-1) Σ x_i * 2^i (i 从 0 到 n-2)也就是最高位权重是负的。验证1101 1011-128 64 16 8 2 1 -37。对。注意方法二在两个方向上都成立——已知补码求原码也是同一套操作。因为取反加一是自逆的做两次就回到起点。4.3 已知补码怎么反推原码这是搜索量最高的那个点在实际调试和作业里给了一个补码求它表示的十进制真值或原码的需求非常高频。我总结四条路按场景选方法操作适用场景取反加一符号位不变数值位取反再加一手算最通用从右找首个1符号位不动其余位从右往左首个 1 及其右侧不变左侧取反快速口算按权展开最高位取负权其余位取正权直接累加只需真值不需要原码形式减去 2^n补码值 - 2^n仅在符号位为 1 时心算小位数举个例子给定 8 位补码1110 1100。方法二符号位1不动剩下110 1100从右数第一个 1 在第三位保留100左边1101取反得0010拼起来1 0010 100即1001 0100真值 -20。方法三验证-128 64 32 8 4 -20。对上了。4.4 为什么补码能多表示一个负数8 位补码的范围是 -128 到 127比原码和反码多一个 -128。多出来的这个是哪来的因为零只有一个编码0000 0000原本重复的那个状态原码里的1000 0000被释放出来了。在补码的权重体系下1000 0000算出来是 -128它没有对应的正数。所以这个不对称不是设计缺陷而是省下来的那个位状态的合理归宿。这也是为什么abs(INT_MIN)在多数语言里会返回一个负数或者触发未定义行为——-2147483648 的绝对值 2147483648 超出了 32 位有符号整数的表示范围根本没有对应的表示。写代码时如果要对可能为负的整数取绝对值再累加一定要考虑这个边界。4.5 补码加减法加就完了进位直接丢补码最爽的地方在这里。所有加减法统一成加法符号位参与运算最高位产生的进位直接丢弃。25 - 320001 1001 (25) 1110 0000 (-32) ----------- 1111 1001结果是补码1111 1001最高位产生进位吗没有。求真值取反得0000 0110加一得0000 0111即 -7。正确。-15 (-20)1111 0001 (-15) 1110 1100 (-20) ----------- 1 1101 1101 → 丢弃进位 → 1101 11011101 1101求真值取反0010 0010加一0010 0011 35加负号得 -35。正确。硬件上一个补码加法器加上一个可控的取反加一对减法实现为A ~B 1就能同时处理加减。这是现代 CPU 算术单元的基础也是补码能一统天下的核心原因。5. 溢出与变形补码让越界在电路层面看得见5.1 溢出的本质不是算错了是放不下补码加减法虽然优雅但它有个前提结果必须落在表示范围内。两个 8 位数相加结果可能到 ±254超出 -128 到 127就溢出了。关键在于溢出的结果是无声的。100 100在 8 位补码下得到1100 1000按补码解释是 -56。计算机不会报错它只是给出了一个错误的答案继续往下跑。这是底层编程里最难查的一类 bug。5.2 变形补码给符号位加个备份变形补码也叫模 4 补码的做法是给每个数配两个符号位。正数用00负数用11数值部分不变。对应的单符号位的普通补码就叫模 2 补码。4 位数的变形补码是 5 位5→00 101-5→11 0110→00 000-0→00 000变形补码下零也是唯一的5.3 双符号位怎么判断溢出规则很直白两个符号位相同表示无溢出两个符号位不同表示溢出。而且还能区分方向01是正溢结果太大10是负溢结果太小。来验证几个(5) (4)00 101 00 100 -------- 01 001 → 符号位 01 → 正溢正确因为 9 超出了 4 位补码的 -8 到 7 范围。(-5) (-4)11 011 11 100 -------- 110 111 → 截取低 5 位 → 10 111 → 符号位 10 → 负溢正确-9 超范围。(5) (-3)00 101 11 101 -------- 100 010 → 截取低 5 位 → 00 010 → 符号位 00 → 无溢出结果0010 2正确。(-5) (3)11 011 00 011 -------- 11 110 → 符号位 11 → 无溢出结果1110 -2正确。可以看到检测逻辑简化成了比较两个符号位是否相等用两个异或门就能实现比单符号位方案直观得多。代价是每个数多占一位存储。5.4 单符号位上还有两种判定方法如果你手头只有普通补码也有两种常用的溢出判定法。方法一进位异或。设C_n是符号位产生的进位C_(n-1)是最高数值位产生的进位则溢出条件为C_n ⊕ C_(n-1) 1。回到5 45 位00 101 00 100。最高数值位是权值为 4 的那一位11产生进位所以C_(n-1) 1符号位001不产生进位C_n 0。异或得 1判定溢出。正确。再看-5 -411 011 11 100。数值位011 100 111最高数值位01不产生进位C_(n-1) 0符号位110产生进位C_n 1。异或得 1判定溢出。正确。方法二符号一致性。两个操作数符号相同而结果符号与之相反就是溢出。这个方法不用看进位但需要三级判断逻辑电路上不如异或法简洁。提示这三种方法在考试里都可能被要求实际写硬件时最常用的还是双符号位和进位异或。手算验证时我用方法二最快一眼就能看出来。6. 移码浮点阶码非它不可的理由6.1 定义很简单加个偏置就完事移码的定义是[X]移 2^(n-1) X其中 n 是编码总位数含符号位。也就是把一个真值平移到非负区间再用无符号二进制写出来。4 位移码下1→1000 1 1001-1→1000 - 1 01110→1000最小能表示-8→0000最大能表示7→1111。范围正好和 4 位补码一致。6.2 和补码的关系翻一下符号位就换过来了同一真值下移码就是补码的符号位取反。验证-1的 4 位补码是1111翻符号位得0111正是移码。1的补码0001翻符号位得1001也是移码。这个关系的根源在于补码是模 2^n 下的表示移码是模 2^n 下再平移 -2^(n-1)两者恰好差一个最高位。6.3 无符号比较等于数值大小比较这才是不用补码用移码的真正原因。在补码体系里-1写作11111写作0001。如果按无符号整数比较1111 0001但实际是 -1 1。硬件要做有符号比较必须额外处理符号位逻辑复杂。移码改掉了这一点真值越大编码越大。-1的移码01111的移码1001无符号比较0111 1001直接得出 -1 1。整个比较过程就是一次普通的无符号比较器不需要任何符号位特判。浮点数在做加减法时第一步就是对阶——把两个数的阶码调成一致。对阶的第一步是比大小判断哪个阶码更小。如果阶码用补码存这里就要额外的有符号比较逻辑用移码存直接比就完事。而且移码还有一个附带好处零的表示唯一10004 位不像补码那样要担心正负零。6.4 IEEE 754 的偏置为什么是 127 而不是 128单精度浮点的阶码是 8 位偏置取的是 127也就是2^7 - 1不是2^7 128。双精度阶码 11 位偏置是 1023也就是2^10 - 1。为什么减一实际效果上有三点。第一阶码字段取值 1 到 254 对应真实指数 -126 到 127正负基本对称全 0 和全 1 两个边界值被腾出来用作特殊用途非规格化数、零、无穷、NaN不会被正常数占用。第二阶码字段本身可以直接按无符号数比较大小这点和普通移码一致。第三取倒数时若原数阶码字段为 E其倒数的阶码字段恰好是254 - E硬件上就是按位取反几乎没有开销。这三点加起来146 个偏置值的差别带来的收益是实打实的。7. 浮点数编码把前面几套东西缝在一起7.1 IEEE 754 的字段划分现代浮点数基本都遵循 IEEE 754 标准。它把一串二进制位切成三段类型总位数符号位 S阶码 E尾数 M偏置单精度 float321823127双精度 double64111521023数值的计算方式是值 (-1)^S × 1.M × 2^(E - 偏置) 规格化数注意尾数那个1.——规格化数的最高位一定是 1所以这一位被省略不存这叫隐含位。23 位尾数字段实际提供 24 位精度52 位提供 53 位。7.2 五类数的分类逻辑阶码字段的取值决定了这个数属于哪一类阶码 E尾数 M含义全 0全 0±0全 0非 0非规格化数值为 ±0.M × 2^(1-偏置)1 到 254单精度任意规格化数值为 ±1.M × 2^(E-127)全 1全 0±无穷大全 1非 0NaN不是一个数非规格化数的存在是为了渐进下溢。没有它的话最小正数和 0 之间的空隙会很大很多小数值会直接变成 0。有了非规格化数精度被逐步削减而不是突然消失数值计算的连续性好了很多。单精度非规格化数能表示到2^-149左右。7.3 手算一个浮点数的完整编码把 -12.5 编成单精度走一遍流程。第一步转二进制。12.5 1100.1。第二步规格化。小数点左移 3 位得到1.1001 × 2^3。真实指数 e 3。这里用的是二进制科学计数法和十进制的1.25 × 10^1是同一个道理。第三步填符号位。负数S 1。第四步算阶码字段。E 3 127 130二进制1000 0010。第五步填尾数。去掉隐含的最高位 1剩下的1001后面补 0 到 23 位10010000000000000000000。拼起来1 10000010 10010000000000000000000按 4 位一组排列1100 0001 0100 1000 0000 0000 0000 0000十六进制0xC1480000。再算一个正的0.15625。0.15625 0.00101 1.01 × 2^-3。S 0E -3 127 124 0111 1100尾数01000000000000000000000。结果0 01111100 010000000000000000000000011 1110 0010 0000 ...0x3E200000。7.4 尾数用原码阶码用移码各有各的道理到这里六套编码就全部用上了。尾数字段用的是符号-绝对值形式也就是原码的思路因为浮点数乘除的核心操作是对尾数做乘除、对阶码做加减。尾数部分不需要承担减法转加法的任务那个由阶码和整体算法处理用绝对值形式反而更直接还能让符号位完全独立出来乘除时符号单独计算。阶码用移码理由在 6.3 已经说透了对阶时要快速比大小移码让无符号比较等价于数值比较。这就是为什么我一开始说这六套编码不是层层淘汰的关系而是按场景分工。原码在浮点尾数里活着补码在所有整数运算里活着移码在浮点阶码里活着变形补码主要在教材和溢出检测的教学场景里出现。7.5 精度、舍入以及 0.1 0.2 那个经典问题单精度的尾数 23 位能保证大约 7 位十进制有效数字双精度 52 位大约 15 到 16 位。超过这个位数的十进制小数在二进制里根本存不下必须舍入。最经典的例子就是 0.1。十进制的 0.1 转成二进制是0.000110011001100110011...1100无限循环。单精度只能保留 23 位尾数双精度 52 位无论哪个都存不下完整值只能取近似。于是print(0.1 0.2) # 0.30000000000000004 print(0.1 0.2 0.3) # False这不是 bug是二进制浮点的固有特性。0.1 存的近似值略大于真实值0.2 也是两者相加的误差累积起来落到了另一个浮点数上与 0.3 的近似值不相等。工程上的应对方式很明确金额计算不要用浮点用整数分或者定点数比较浮点不要用用误差阈值。这条规则我见过太多人在生产环境里踩。8. 手算验证与工程避坑8.1 一张 4 位编码速查表把最常用的对照关系列出来需要的时候扫一眼就够。位数换成 8 位、16 位时规律完全一样。真值原码反码补码移码偏置 870111011101111111401000100010011001000100010001100100000000000001000-010001111无无-11001111011110111-41100101111000100-71111100010010001-8不可表示不可表示10000000从表里能直观看出几件事补码的负数部分和原码是错位的移码就是把补码的符号位翻一下原码和反码的零占了两个位置补码和移码只占一个。8.2 用代码把位模式打出来验证手算容易出错用代码验证是最快的。C 里可以用联合体直接看内存位模式#include stdio.h typedef union { float f; unsigned int u; } f2u; int main(void) { f2u x; x.f 0.1f; printf(0.1f 0x%08X\n, x.u); x.f 0.2f; printf(0.2f 0x%08X\n, x.u); x.f 0.1f 0.2f; printf(sum 0x%08X\n, x.u); x.f 0.3f; printf(0.3f 0x%08X\n, x.u); return 0; }在常见平台上会输出0x3DCCCCCD、0x3E4CCCCD、0x3E99999A、0x3E99999A。注意最后两个是一样的——单精度下0.1f 0.2f恰好舍入到了和0.3f相同的位模式所以在单精度下比较可能反而相等。这恰恰说明换一种精度结论就可能变永远别依赖浮点的精确相等。Python 里看阶码和尾数更直观import struct def dump(f): u struct.unpack(I, struct.pack(f, f))[0] s (u 31) 1 e (u 23) 0xFF m u 0x7FFFFF print(f{f!r:8} S{s} E{e:3d} 真实指数{e - 127:4d} 尾数0x{m:06X}) dump(0.15625) dump(-12.5) dump(0.1)跑一遍对照 7.3 的手算结果能立刻发现自己算错在哪一步。我做浮点相关的调试时这个小工具几乎每次都开。8.3 写代码时最容易踩的几个坑有符号整数溢出在 C 里是未定义行为。不是回绕是标准明确说编译器可以假设它不会发生从而做出各种激进的优化。真要依赖回绕行为用无符号整数它的行为是明确定义的模 2^n 运算。Java 里0x80不能直接赋给 byte。byte范围是 -128 到 127字面量0x80是 128超出范围。得写成(byte) 0x80或者用Byte.parseByte(-128)。C 里char的符号性由实现决定。它可能是 signed 也可能是 unsigned跨平台代码里别拿char存需要判断正负的小整数用int8_t或uint8_t。负数右移是算术移位还是逻辑移位是实现定义的。移植代码时这里很容易出问题。需要逻辑移位时先转成无符号类型再移。Integer.MIN_VALUE取绝对值还是负数。前面在 4.4 讲过原因这是补码不对称的直接后果。写求和的代码时如果先取绝对值再累加一定要单独判断这个边界值。float转int时的截断方向是朝零不是四舍五入。(int) 2.9f得到 2(int) -2.9f得到 -2。要四舍五入得用round系列函数。比较浮点不要用相等。用fabs(a - b) EPSEPS 取多少要看数量级。对于大数值相对误差比绝对误差更靠谱。整数转浮点可能丢精度。单精度尾数只有 24 位有效精度超过 2^24 的整数转成 float 会丢低位。64 位整数转 double 同理超过 2^53 就开始丢。这类 bug 在涉及大 ID、时间戳的代码里出现过太多次了。8.4 一套手算的自查流程最后分享一个我教别人时用的自查流程。拿到求某个数的某种机器码这类问题按这个顺序走基本不会错先确定位数。题目说 8 位就是 8 位不要自己加位数。写出真值的绝对值的二进制。判断符号正数时补码反码原码都一样直接填负数时分三种路径处理。负数求补码绝对值二进制 → 数值位取反 → 加一 → 补符号位。求移码先求补码再把符号位取反。求变形补码先求补码再在符号位旁边复制一位。最后用按权展开验证一遍-2^(n-1)加上其余位的正权和看是否等于真值。第 7 步是关键。我见过太多人算完就交卷结果符号位和数值位搞反了都没发现。按权展开只要十几秒能兜住九成的低级错误。这套编码体系学起来确实有点绕但它背后的逻辑非常干净每一套编码都是为了解决一个具体问题而存在的理解了这个动机公式只是顺手写出来的东西。真正需要警惕的是那种背了一堆转换表但说不清为什么的状态一旦题目换个位数或者换个问法就会当场卡住。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →