小数二进制与十六进制转换全解析:从0.1的真相到浮点数精度
先说一个反直觉的事0.1 这个数在十进制里干干净净写起来就一位小数算起来也规规矩矩可一旦进了计算机它就成了一个无限循环小数。更麻烦的是绝大多数人第一次手算小数的二进制表示时都会被乘2取整这套流程弄晕算几步就卡住怀疑自己是不是算错了。这篇文章就把小数在二进制和十六进制下的表示这件事彻底讲透。我会从小数的本质讲起把转换原理、手算步骤、精度问题和工程里的常见坑一起打包说清楚。无论你是计算机专业的学生、刚接触底层开发的程序员还是做嵌入式、写C语言的老手这篇文章都能帮你把小数到底怎么存在计算机里这个问题理顺。1. 为什么0.1在计算机里是个麻烦精小数位权的本质1.1 小数点右侧每一格代表多少要真正理解小数的二进制表示第一步是把小数点这三个字的含义搞清楚。我们用的十进制计数法是位置计数法也就是说同一个数字放在不同位置上代表的大小完全不同。整数部分我们很熟个位代表1十位代表10百位代表100。到了小数点右侧规律其实是往另一方向延续的。十进制的十分位代表 10 的负一次方也就是 0.1百分位代表 10 的负二次方也就是 0.01。小数点往右走一位位权就除以10。二进制同理不过基数从10换成了2小数点后第一位代表 2 的负一次方也就是 0.5第二位代表 2 的负二次方也就是 0.25第三位代表 0.125以此类推。二进制小数位第1位第2位第3位第4位第5位对应十进制的值0.50.250.1250.06250.03125计数表示2^-12^-22^-32^-42^-5这个规律看起来简单但它是后面所有转换方法的基础。所谓某一个小数的二进制表示本质上就是在问我需要把哪几个 2 的负数次幂加起来才能凑出这个十进制小数就像用砝码称重一样二进制小数的每一位就是一枚砝码要么放上去该位为1要么不放该位为0而且每种重量的砝码最多只能用一个。1.2 分母法则一眼判断小数是否会无限循环十进制小数转二进制有一种情况会让你陷入死循环比如 0.1。为什么会这样看一个分数就能明白。十进制小数的本质是分母为 10 的幂的分数。0.1 就是 1/10。要把这个分数表示成二进制小数意味着要把它拆成若干个分母为 2 的幂的分数之和。问题来了1/10 的分母是 10而 10 分解质因数是 2 × 5。二进制只认分母是 2 的幂的数这个 5 的存在就导致 1/10 无法写成有限位的二进制小数。判断规则其实非常简单一个十进制小数能否用有限位二进制小数表示看它化为最简分数后的分母。如果分母只含有质因数 2那么可以有限表示只要分母含有 2 以外的质因数就必然产生无限循环的二进制展开。拿几个数练一下0.5 1/2分母只有2二进制表示为 0.1干净利落。0.25 1/4分母是 2^2二进制表示为 0.01。0.2 1/5分母含5所以 0.2 在二进制里也是无限循环小数。0.75 3/4分母是 2^2二进制是 0.11。这个规律不光解释为什么 0.1 会循环还能解释为什么在十六进制里 0.1 反而不会循环。十六进制的分母是 16 2^4所以一位十六进制小数就对应四分之一分之一的精度任何二进制有限小数都能写成有限的十六进制小数反过来也一样。这就引出了二进制和十六进制之间的天然亲近关系后文会详细展开。2. 十进制小数转二进制的核心操作乘2取整法的完整推导2.1 手算流程与原理对照十进制小数转二进制标准方法是乘2取整顺序排列。具体操作是每次把当前的小数部分乘以2取乘积的整数部分作为二进制的一位然后丢弃整数部分继续对剩余的小数部分乘以2重复这个过程直到小数部分为0或达到所需精度。用 0.625 完整走一遍0.625 × 2 1.25整数部分为1这是二进制小数点后第1位剩余小数 0.25。0.25 × 2 0.5整数部分为0这是第2位剩余小数 0.5。0.5 × 2 1.0整数部分为1这是第3位剩余小数 0。所以 0.625 的二进制就是 0.101。验算一下1×0.5 0×0.25 1×0.125 0.625没问题。但如果只给步骤很多人会疑惑为什么要乘2取整数部分凭什么就能得到二进制位道理其实藏在上一位数的位权里。一个二进制纯小数 0.a1a2a3... 的值等于 a1×0.5 a2×0.25 a3×0.125...。把这个数整体乘以2就得到 a1 a2×0.5 a3×0.25...。注意整数部分 a1 就是二进制小数点后的第一位。把 a1 拿走剩下的小数部分再乘以2就相当于在问第二位的值。循环往复每一位就被依次逼出来了。这个原理也解释了为什么乘的是2而不是别的数。十进制小数转十进制小数比如要把 0.25 转成十进制小数的每一位其实也可以类比做乘10取整只不过十进制下我们天生熟悉用不上这个方法而已。任何进制转任何进制这套乘以目标基数取整数位的思路都是通用的。2.2 循环小数的处理与停止条件再看 0.1 的情况。用乘2取整法算0.1 × 2 0.2整数位00.2 × 2 0.4整数位00.4 × 2 0.8整数位00.8 × 2 1.6整数位1剩0.60.6 × 2 1.2整数位1剩0.20.2 × 2 0.4整数位0剩0.4看到没有从第2位到第6位小数部分从 0.4 又回到了 0.2 的循环起点。接着算下去会得到 0.4、0.8、1.6、1.2、2.4 这样一个循环块。所以 0.1 的二进制是 0.000110011001100... 循环节是 0011永不终止。实操的时候判断停止条件有两种情况一是小数部分恰好乘成0得到精确的有限二进制小数二是发现小数部分出现了重复说明进入循环此时就该根据精度需求截断。现实中绝大多数十进制小数都走第二条路。这里有一个新手很容易犯的错误算到某一步小数部分重复了就以为算错了重新再来一遍。其实循环出现恰恰说明你的计算是正确的——问题不在算法而在这个十进制小数本身就不能被二进制有限表示。记住这个判断口诀能除尽的继续算除不全的就截断循环节可以不用管它到底多长够用就行。2.3 二进制小数转回十进制的验算方法算完一个二进制小数强烈建议立刻反向验算能拦下一大批低级错误。从二进制转十进制用位权展开求和就够了。把每一位的1乘上对应的 2 的负数次幂然后相加。用前面算出的 0.101二进制验算第一位1 × 0.5 0.5第二位0 × 0.25 0第三位1 × 0.125 0.125总和0.5 0 0.125 0.625如果位数比较多我习惯从右往左分组算右边是精度更高的低位验算起来不容易漏。还有一个小技巧是记住前几个常用权重2^-1 是0.52^-2 是0.252^-3 是0.1252^-4 是0.06252^-5 是0.031252^-6 是0.015625。这些常见权重用熟了口算验算完全不成问题。位数1234562的负幂0.50.250.1250.06250.031250.0156253. 从二进制到十六进制四位一组的快速转换法3.1 二进制小数转十六进制小数的分组规则二进制和十六进制之间的转换是计算机领域最常用的技能没有之一。原因也很简单十六进制中一位数恰好对应二进制中的四位。这个四位一组的关系来自 16 2^4是完全确定的映射关系没有任何近似。整数部分大家都会分从右往左每四位一组高位不足四位补前导0。但小数部分常常被搞错方向。小数部分的正确分组方向是从左往右也就是从靠近小数点的位置开始每四位一组低位不足四位在末尾补0。拿 0.101101 这个二进制小数来操作一遍。从小数点后第1位开始从左往右分组1011 和 01。第一组 1011 正好四位对应十六进制的 B。第二组 01 只有两位要补两位0凑成 0100对应十六进制的 4。所以二进制 0.101101 等于十六进制 0.B4。注意这里有一个特别容易踩的坑整数部分分组是从右向左小数部分是从左向右方向完全不同。我见过不少人把整数部分的规则惯性套到小数部分导致结果完全错误。口诀记牢整数右对齐小数左对齐。补0方向也不同整数补左边小数补右边。为什么二进制转十六进制要做四位一组而不是其他组合方式因为十六进制每一位能表示 0~15 共 16 种状态正好等于四位二进制能表示的全部组合数量2^4 16。这个天然映射让两种进制之间可以无损互转而且每一位都是独立翻译不会产生进位或借位的干扰。这也是为什么阅读汇编代码、看内存dump文件时大家总是习惯用十六进制而不是直接看二进制串——同样的信息十六进制长度只有二进制长度的四分之一人类的大脑处理起来轻松得多。3.2 十进制小数直接转十六进制乘16取整法有了乘2取整法的经验十进制小数转十六进制的方法就可以直接迁移把乘2换成乘16即可叫乘16取整法。操作流程和二进制版本完全同构每次把当前小数部分乘以16乘积的整数部分作为十六进制的一位注意10~15要写成A~F然后丢弃整数部分继续对剩余小数进行计算。以 0.1 为例0.1 × 16 1.6整数部分为1十六进制小数第1位是1剩余0.60.6 × 16 9.6整数部分为9第2位是9剩余0.60.6 × 16 9.6整数部分又为9第3位还是9看0.6 进入循环了所以 0.1 的十六进制表示是 0.19999...循环节是9。这很有意思同一个十进制数 0.1在二进制里是 0001100110011... 循环套循环在十六进制里则是 199999...。两种写法本质上都在描述同一个无限逼近过程只是二进制每次逼近一半十六进制每次逼近十六分之一收敛速度更快写起来更短。再用一个能正好除尽的例子试一下 0.06250.0625 × 16 1.0整数部分为1小数部分清零。所以 0.0625 的十六进制小数是 0.1。这个数如果转二进制要费点事0.0625 2^-4二进制是 0.0001。十六进制却只要一位 0.1简洁得很。这侧面说明了一个规律分母只含2的质因数的分数在二进制和十六进制里都能有限表示而十六进制往往写起来更短。3.3 进制转换一致性验证三副面孔的同一个数一个小数可以有几种等价表示方式三种起步十进制原样、二进制展开、十六进制展开。这三个表示之间必须能互相推导推导不通说明你的某个转换环节出了问题。验证一致性最直接的方法是先转成十进制比较是否相等。比如十进制 0.625二进制 0.101十六进制 0.A二进制 0.101 展开是 0.5 0.125 0.625十六进制 0.A 中 A 代表10而 A × 16^-1 10 × 0.0625 0.625。三面结果完全吻合。再试一个十六进制和二进制直接互推的例子0.C8十六进制。C 对应二进制 11008 对应 1000所以 0.C8 的二进制是 0.11001000去掉末尾多余的0写作 0.11001。这是四位一组的直接推导不需要经过十进制中转。反过来从二进制 0.11001 转十六进制从左往右分组为 1100 和 1000末尾补3个0凑四位得到 C 和 8即 0.C8。两条路径殊途同归。这套一致性验证是排查进制转换错误的利器。手算完成一组转换后我强烈建议至少用另一种途径验算一次——要么走十进制中转要么从二进制直接转十六进制再来回比对。两种独立路径得出的结论一致才算真正算对了。4. 精度受限时到底该不该舍入单精度与双精度的底层逻辑4.1 从无限循环到有限存储浮点数的舍入策略上两节反复提到无限循环小数但计算机的存储空间永远是有限的。一个无限循环的二进制小数计算机到底怎么装得下答案很粗暴装不下只能截断。计算机存储小数用的是浮点数标准 IEEE 754。单精度 float 有32位其中1位符号、8位指数、23位有效数字尾数双精度 double 有64位1位符号、11位指数、52位有效数字。这里的有效数字就是用来存二进制小数尾数的空间。 0.1 这个数要用无限的二进制位才能精确表示但 float 只有23位尾数空间double 只有52位剩下的位数只能被丢弃。问题的关键就在怎么丢。直接截断舍掉多出来的位会带来系统性偏差误差总是偏向同一个方向在大量计算中会累积放大。IEEE 754 采用的是就近舍入策略更精确的说法是舍入到最接近的可表示值若刚好在中间则舍入到末位为偶数。这和十进制四舍五入的逻辑一致但多了个中间值取偶数的规则目的是让大量随机数据在舍入时正负误差对半减少累积偏差。这就是热词里那个问题的答案十进制小数转换为二进制有精度限制时必须考虑舍入而且不是简单的四舍五入而是浮点数标准规定的就近舍入到偶数。这个细节平时写代码时感觉不到但一旦涉及高精度计算、自动控制、金融计算误差会从角落里跳出来咬你一口。4.2 真实世界验证用代码看看0.1在内存里的样子光讲理论不够直接在代码里验证一下才有实感。Python 里可以用 float.hex() 方法查看一个浮点数底层的二进制布局 (0.1).hex() 0x1.999999999999ap-4这一串是什么意思拆开看1.999999999999a 是十六进制表示的尾数部分p-4 表示乘以 2 的负4次方。尾数部分 1.999999999999a后面的字母 a 代表十六进制的10。这个数展开为二进制就是 1.1001100110011001100110011001100110011001100110011010乘以 2^-4正好对应 0.0001100110011001100110011001100110011001100110011010。这就是 0.1 在 double 里的真实存储值而不是我们直觉中的 0.1。C语言里也有对应的展示方法#include stdio.h #include stdint.h #include string.h int main(void) { double d 0.1; uint64_t bits; memcpy(bits, d, sizeof(bits)); printf(0x%016llx\n, (unsigned long long)bits); return 0; }运行结果一般来说是 0x3fb999999999999a。把十六进制尾数部分 999999999999a 和上面 Python 显示的结果对照会发现它们完全一致可见底层二进制表示是统一的和语言无关。这个实验给人最直观的感受是0.1 不是一个干净的二进制数它在内存里是经过舍入后的近似值。你平时用的 0.1 都是替身真身在内存里是一位名叫 0x3fb999999999999a 的邻居。4.3 舍入误差对编程实战的具体影响舍入误差不是纸上谈兵它在实际项目中制造过太多经典的灵异事件。最常见的两个场景场景一浮点数相等比较。在 JavaScript 里0.1 0.2 0.3的结果是 false原因就是 0.1 和 0.2 的二进制表示都是近似值二者相加的结果 0.30000000000000004永远不可能精确等于 0.3 的近似值。C语言里float类型也经常出现类似问题。场景二累积误差。在循环里反复做浮点累加时每一次的舍入误差都会累积。比如从 0 开始每次加 0.1循环 100 次理想结果是 10.0但实际常常是 9.99999999999998 或 10.000000000000002。在长期运行的算法里这种误差会被不断放大。针对这两类问题工程上的应对方式不是追求精确的浮点存储做不到而是改变策略比较两个浮点数是否相等时不要用改用差值绝对值小于某个极小阈值如 1e-9的方式。做精确计算尤其是涉及金额的场景时尽量用整数运算以分为单位或定点数。在循环累加场景中优先使用整数循环变量必要时在最后一步统一换算成浮点结果。这些策略的共同思路是承认浮点数的表示是近似的不要让系统设计依赖一个精确值。5. 从手算到代码小数的进制转换与工程实践5.1 程序实现思路把算法翻译成代码理解了乘2取整和乘16取整的原理后把它翻译成代码是水到渠成的事。下面给一个十进制小数转二进制的 Python 实现核心逻辑和手算流程完全一致def dec_to_bin_frac(decimal, precision32): 十进制小数转二进制小数字符串。 decimal: 0到1之间的十进制小数要求是float类型。 precision: 最多计算的二进制位数。 返回形如 0.101101... 的字符串。 result [] frac decimal while frac 0 and len(result) precision: frac * 2 bit int(frac) # 取整数部分 result.append(str(bit)) # 记录当前位 frac - bit # 丢弃整数部分 return 0. .join(result) if not integral_part else ... # 使用示例 print(dec_to_bin_frac(0.625, 10)) # 0.101 print(dec_to_bin_frac(0.1, 20)) # 0.00011001100110011001注意一个边界情况如果输入的小数大于1比如 5.625最好先拆分成整数部分和小数部分整数部分用除2取余法转换小数部分用上面的函数转换最后拼起来。如果你和我一样追求稳妥反转验证的环节一定不能省把生成好的二进制小数字符串按位权求和看看能不能回到原十进制数。如果精度有限回不到精确值就把误差打印出来观察这能帮你判断 precision 参数是否设得足够。5.2 保留两位小数的各种姿势与注意事项热词里有c 中保留两位小数怎么写这虽然是个很基础的问题但确实问到点子上了。保留两位小数容易但保留后是否要参与后续运算决定了该用哪种方式。只影响显示、不影响计算值时用printf的格式化输出double value 3.14159; printf(%.2f\n, value); // 输出 3.14这里注意printf只是按照指定的精度做了舍入和格式化内存里的 value 依然是 3.14159value本身没变。如果你需要真的把变量改成两位小数的值保留后的值参与后续运算就要自己做舍入#include math.h double round_to_two(double value) { return round(value * 100.0) / 100.0; }把数字放大100倍取整再缩小100倍好理解又好用。但这里有几个坑要提醒round()是就近舍入0.125这种数要小心因为 0.125 在二进制里是精确值但放大100倍后引入新的舍入误差结果可能出乎意料。负数舍入时round()的行为是round(-1.5) -2和很多人直觉里的绝对值四舍五入不同。如果金额计算上面这种方式仍然不够可靠更推荐用整数存储分。5.3 转换结果的自查与工具链手算进制转换很容易出错尤其是小数部分位数一多眼睛就看花了。分享几个我常用的自查手段。第一用计算器或命令行工具交叉验证。Windows 自带计算器的程序员模式能转整数进制小数的支持有限推荐用 Python 这种带任意进制转换能力的工具做验证。把十进制小数转成二进制可以用format(0.625, .10b)之类的操作虽然 Python 的format对浮点数进制的支持细节需要留意但作为参考验证完全够用。第二用十六进制中转。因为二进制转十六进制是四位一组的无损映射你可以把一个二进制小数的计算结果先转成十六进制再通过乘16取整手算一遍十进制到十六进制的过程两条独立路径相互印证比单纯复核自己的笔算可靠得多。第三别忽略整数部分。很多人在算小数转换时把整数部分和小数部分分开处理但最后忘了它们的位权关系。8.625 的二进制应该是 1000.101其中 8 是 10000.625 是 0.101。这两个部分各自正确还不够合在一起时小数点位置如果放错结果就完全不一样。每次组合后做一次整体验算是成本最低的错误拦截方式。6. 几个容易混淆的相关问题顺便把路上的坑清一清6.1 二进制除法和小数转换的关系热词里出现了二进制除法它和本文的小数转换其实是同一个底层的思维模型。二进制除法执行过程中商的小数部分实际上就是把余数继续按二进制拆分的过程。举个例子十进制 11 ÷ 2 5.5。二进制里 11 是 1011除以 2二进制里就是右移一位得到 101.1。为什么小数位是1因为 1011 右移一位后最低位的 1 被移到了小数点右侧这一位代表 0.5。在布尔运算和位操作中右移 n 位就等价于除以 2^n包括小数部分的位全部保留下来。这就是二进制除法和二进制小数的天然联结。理解了这个联系在写底层算法时就有一种视角优势做除2操作时可以直观地想象位模式的移动而不是抽象地背规则。6.2 十六进制补1到底补的是什么十六进制补1这个说法有歧义我在网上也见过不少完全跑偏的解释。就常见工程场景而言它通常指的是补码操作或者掩码对齐。补码操作中十六进制表示负数时对每个十六进制位取反0变FF变0也就是对每一位做15 - x 的运算最后加1。一个十六进制数位的取反映射表很有规律0↔F1↔E2↔D3↔C4↔B5↔A6↔97↔8。记住这张表做十六进制补码运算会比先转二进制再取反快得多。另一种应用场景是位域操作时的补位比如给一个小数尾数补零到某个对齐边界。这和前文提到的小数分组补0是同一类操作为了在固定位宽下表达数值在低位补0不会改变数值大小但在高位补0会改变位置关系。补充时先确认方向再动手。6.3 十六进制编辑器里的二进制内存观察热词里提到 hxd 这类十六进制编辑器。观察一个小数在内存里的存储是理解本文所有内容的最佳实操方式。用 C 语言写一个double变量到文件再用十六进制编辑器打开你会看到一串十六进制字节比如 0x3FB999999999999A 这样的结构注意字节序问题x86 架构下要反着读。按本文前面讲的方法把这段十六进制展开成二进制尾数部分再按位权求和你会亲眼看到那个近似值。我第一次这么干的时候对浮点数只是近似值的理解彻底落地了——纸上讲一百遍不如在内存里亲眼看一遍。这套能力虽然看起来偏底层但在调试协议解析、做嵌入式开发、排查数据序列化问题时都极其有用。数据是死的格式是活的掌握了小数的二进制和十六进制表示你就有了一双能透视数据底层结构的眼睛。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →