你肯定在代码里撞过0.1 + 0.2 != 0.3这种邪门事件,也肯定在调试器里见过0x3f800000这种读起来像乱码的十六进制数字。这两件事表面看八竿子打不着,实际上背后是同一个基础问题:带小数的数字,在计算机里到底是怎么用二进制存储的,又为什么偏偏用十六进制展示给人类看。这篇文章就从“小数”这个角度切入,把十进制小数转二进制、二进制转十六进制的完整方法、手算步骤、误差边界,以及常见的调试工具用法一次性讲清楚。不管你是刚学进制转换的学生,还是被浮点数精度坑过的开发,这篇文章都能给你一套能直接拿去用的思路。
1. 先把整数转换讲透:二进制和十六进制的基础心法
小数转换之所以容易卡壳,往往是因为整数部分就没形成肌肉记忆。所以咱们先花一小段把整数的二进制和十六进制彻底捋顺,后面处理“小数点右边”的内容才会有参照物。
1.1 整数转二进制:除2取余其实是在拆秤砣
十进制整数转二进制,教科书上写的是“除2取余,倒序排列”。这个口诀背起来容易,但很多人不知道它背后的直觉是什么。我的理解是:二进制数字每一位代表一个固定的“秤砣”,从右往左依次是 1、2、4、8、16……你要做的就是用这套砝码去“凑出”目标数字,能凑上就在这一位写1,凑不上就写0。
除2取余的本质,就是在判断这个数里包含了多少个“1”、多少个“2”、多少个“4”。比如十进制13:
13 ÷ 2 = 6余1,说明你拆出了一个1;6 ÷ 2 = 3余0,说明这个位置没有2;3 ÷ 2 = 1余1,说明这里有4;1 ÷ 2 = 0余1,说明还有8。
把余数从下往上倒着写,就是1101。验证一下:8 + 4 + 0 + 1 = 13,完全正确。
值得提醒的是,网上有些教程会把这一步写反,最后得出1011,那就是漏了“倒序排列”这个动作。我自己当年初学时就栽在这个坑里,心算很快但顺序错得离谱。如果你也容易迷糊,可以反过来用加法验证一遍:从左往右每一位的权重是 8、4、2、1,算出来必须等于原数字。
1.2 整数转十六进制:四位二进制拼成一位十六进制
整数转十六进制最省事的路径,不是直接除16取余,而是先转成二进制,再从右往左每四位一组。因为16正好是2的4次方,一位十六进制数对应四位二进制数,这层关系是天然绑定的。
我列一张4位二进制到十六进制的对照表,你后面频繁用,建议直接刻进脑子:
| 二进制 | 十六进制 | 十进制 |
|---|---|---|
| 0000 | 0 | 0 |
| 0001 | 1 | 1 |
| 0010 | 2 | 2 |
| 0011 | 3 | 3 |
| 0100 | 4 | 4 |
| 0101 | 5 | 5 |
| 0110 | 6 | 6 |
| 0111 | 7 | 7 |
| 1000 | 8 | 8 |
| 1001 | 9 | 9 |
| 1010 | A | 10 |
| 1011 | B | 11 |
| 1100 | C | 12 |
| 1101 | D | 13 |
| 1110 | E | 14 |
| 1111 | F | 15 |
举个例子,刚才算的二进制1101,从右往左刚好四位,查表就是D,所以十进制13的十六进制就是0xD。再看大一点的数,比如十进制255,二进制是11111111,从右往左四位一拆:1111 1111,写着就是0xFF。你马上能发现十六进制的优势:4位二进制数字压缩成1位十六进制,打印出来短了一半都不止,可读性瞬间提升。这也是为什么内存调试、文件分析、网络报文这些场景,清一色用十六进制做展示格式。
2. 小数转二进制:乘2取整与“算不干净”的真相
整数解决了,现在攻克核心难点:小数点右边那部分怎么转二进制。
2.1 小数部分的手算流程
小数部分转二进制,口诀是“乘2取整,顺序排列”。每乘一次2,把整数位摘下来记着,剩下的小数部分继续乘。举个例子,十进制0.625:
0.625 × 2 = 1.25,取整数位1,剩0.25;0.25 × 2 = 0.5,取整数位0,剩0.5;0.5 × 2 = 1.0,取整数位1,剩0.0,结束。
把取出来的整数位从上往下写:0.101。验算一下:1×1/2 + 0×1/4 + 1×1/8 = 0.5 + 0 + 0.125 = 0.625,完全对得上。
所以二进制小数每一位的意义,就是二分之一、四分之一、八分之一……这些“秤砣”的累加。和整数部分不同,小数部分的秤砣是从左往右依次减半的,方向不能搞混。
2.2 为什么0.1在二进制里永远算不干净
0.625能算干净,是因为它恰好能由 1/2 和 1/8 组合出来。但换成十进制0.1,情况就完全变了。咱们动手算几位看看:
0.1 × 2 = 0.2,取0;0.2 × 2 = 0.4,取0;0.4 × 2 = 0.8,取0;0.8 × 2 = 1.6,取1,剩0.6;0.6 × 2 = 1.2,取1,剩0.2;0.2 × 2 = 0.4,取0……
看到没,到第6步开始循环了。后面会出现0.4 → 0.8 → 1.6 → 0.6 → 1.2 → 0.4的无限循环,二进制下0.1是一个无限循环小数:0.0001100110011...。
这个现象的原因,本质上是“进制权重不同”。十进制能用十分之一、百分之一凑出0.1,二进制只有二分之一、四分之一、八分之一这些砝码,你永远没法用有限的 1/2 的幂组合精确拼出0.1。你想想生活中用杆秤,如果秤杆上只有 1/2、1/4、1/8 这些刻度,想称出 0.1 斤东西,也只能无限逼近,道理完全一样。
2.3 精度限制时到底该不该舍入
很多热词都在讨论“十进制小数转换为二进制有精度限制时,需要考虑舍入吗”。结论是:需要,而且舍入策略会直接影响结果。
计算机里浮点数不可能存无限位,IEEE 754 单精度 float 尾数只有 23 位,双精度 double 尾数 52 位。超出位数的部分就必须处理,常见策略有:就近舍入、朝0舍入、向上舍入、向下舍入。平时默认用的“就近舍入”和我们学数学的四舍五入类似,但二进制下它是“舍到最近的偶数”,这么做是为了让大量数据的累计误差尽量均衡。
举一个很典型的例子,十进制0.1转成双精度二进制时,尾数被截断为某个 52 位近似值,这个值比真正的0.1稍微大了一点点。所以你在 Python 里执行0.1 + 0.2,得到的是0.30000000000000004,而不是干净利落的0.3。这不是计算机算错了,而是它在用二进制“近似”0.1 时的副作用。
实操上我的建议是:做金额、计时这类不允许误差的开发时,别直接用二进制浮点数,要么用整数按“分”存储,要么用Decimal这类十进制定点数库。做科学计算有误差预算,那再用浮点不迟。
3. 小数从二进制到十六进制:分组的顺序是关键
二进制小数会很长,比如0.1的完整双精度二进制写出来一遍根本记不住。这时十六进制就该登场了。小数的二进制转十六进制,方法仍然是“四位一组”,但方向要特别注意。
3.1 十六进制的优势:四位对一个,人脑可直接换算
为什么调试工具偏爱十六进制?因为一个字节是 8 位二进制,拆成两半正好是两位十六进制。任何二进制序列,你用十六进制压缩展示,长度只有原来的四分之一,而且每一个十六进制位都能用上面那张表秒转回对应的四位二进制,不会丢失信息。
举个直观例子,0b10110010这个字节,它等于十六进制0xB2。如果把B2展开,就是1011 0010。你不需要借助任何计算器,看到B立刻想到1011,看到2立刻想到0010,拼起来就是原始字节。这个“本能反应”一旦练成,读内存调试信息的速度能快一个量级。
嵌入式领域里面经常提到的“用 Verilog 设计十六进制键盘电路的键盘扫描和编码器”,本质上也是这个映射关系的应用:键盘矩阵里按下的键位置,被编码成 4 位二进制键值,再用一位十六进制0-F直接输出显示。硬件调试人员一眼就能看出按下的是几号键,比看 4 位二进制直观得多。
3.2 小数十六进制转换实操
小数部分转十六进制,核心步骤是:先把十进制小数转成二进制,然后从小数点开始向右每四位一组,最后一组不足四位时在右侧补零。
比如0.101:
- 把
101看成1010,因为小数点右侧要从左到右补够四位,所以是1010; - 查表
1010是十六进制的A; - 结果是
0x0.A。
再看一个复杂一点的,0.625的二进制是0.101,所以它的十六进制就是0x0.A。验证一下:十六进制A对应的四位二进制1010的权重是1/2 + 0/4 + 1/8 + 0/16 = 0.625,正确。
我再演示一个四组以上,带循环的例子。十进制0.1的二进制是0.00011001100110011...,从右往左每四位一组:
0001 1001 1001 1001 1001...;- 查表就是
1 9 9 9 9...。
所以0.1在十六进制下的近似表示是0x0.1999...。这个写法在 Python 里可以直接验证,后面工具章节会详细演示。
3.3 综合示例:12.375 的二进制与十六进制写法
把整数和小数合到一起走一遍完整流程。十进制12.375:
- 整数
12除2取余:1100; - 小数
0.375乘2取整:0.375×2=0.75取0;0.75×2=1.5取1,剩0.5;0.5×2=1.0取1。所以小数部分是011; - 合起来:
1100.011。
转十六进制:
- 整数部分
1100不用拆,正好四位,查表是C; - 小数部分
011不够四位,右侧补零成0110,查表是6; - 最终结果是
0xC.6。
再来验证:十六进制C.6展开为二进制是1100.0110,去掉末尾补零的0,正好是1100.011,和前面一致。这道综合题值得你亲手算两遍,因为它是整数除2取余、小数乘2取整、以及补零分组三个知识点的一次完整串联。
4. 负数的小数表示:补码与定点数
热词里面总有人搜“负数的二进制”,说明这也是新手绕不过去的坎。负数在计算机里的二进制表示不是简单地在前面加个负号,它遵循“补码”规则。
4.1 补码并不是“密码”,而是模运算的计数器
补码的核心思想可以类比一个里程表。假设一个 4 位里程表,从0000开始加 1,顺序变成0001、0010……一直到1111,再走一步就滚回0000。这就是“模16”的运算。在这种表盘上,-1如果用1111表示,那么5 + (-1)就相当于0101 + 1111 = 1 0100,溢出的最高位不要,低位是0100,正好是 4。这比设计“原码 + 符号位”的加法电路简单得多。
所以补码的生成规则“取反加一”不是天降神律,而是为了满足x + (-x) = 0这个等式。拿 4 位二进制举例:
5的二进制是0101;- 取反得
1010; - 加一得
1011; - 所以
-5的补码是1011。 - 验证:
0101 + 1011 = 10000,丢弃最高位进位,结果是0000,成立。
4.2 定点小数的负数怎么表示
定点小数是早期嵌入式系统和数字信号处理里常用的格式,它的思路是把小数点固定在某一个二进制位后面。比如 Q3.4 格式,意思就是 3 位整数位、4 位小数位,符号位单独占最高位。
在这种格式里,0.625的定点表示是0.1010(保留4位小数)。那-0.625怎么表示?先把0.1010当成一个整体数值,取反加一:
- 原码(符号位+数值位)
0.1010; - 取反得
1.0101; - 加一得
1.0110。
所以-0.625的 Q1.4 补码是1.0110。这里你看到的1.0110的“1”是符号位,也代表负数,但它还参与模运算,不是简单把原码的 0 换成 1 就行的。这也是新手最容易误解的地方:补码的符号位不是“开关”,而是数轴的一部分。
4.3 C语言float的十六进制真相:符号位、指数位、尾数位
现代 C 语言里的 float 用的是 IEEE 754 标准,和上面的定点数不太一样,它是“浮点”的,意思是小数点的位置可以浮动,靠指数位控制。一个 32 位 float 的结构是:
- 最高 1 位:符号位;
- 中间 8 位:指数位(带偏移量);
- 低 23 位:尾数位(存储二进制小数)。
十进制0.75转成二进制是0.11,科学计数法写成1.1 × 2^(-1)。套进 float 结构,符号位 0,指数位存-1 + 127 = 126即二进制01111110,尾数位存1.1小数点后面的10000000000000000000000。整个拼起来是00111111010000000000000000000000,转十六进制就是0x3F400000。
这就是为什么你在调试器里看到 float 的十六进制长成0x3F400000这样的“大数字”——它其实打包了三个独立字段。你理解了打包规则,拿笔就能把十六进制反推回十进制。现在网上不少人问“float 的十六进制怎么看”,大部分答案只给转换工具,我给的建议是亲手拆一次字段,比用一万次工具都管用。
5. 用工具验证转换结果:命令、脚本与十六进制编辑器
手算容易错,所以实际做进制转换时我会用工具交叉验证。工具链搭起来很简单,几行命令就能覆盖你 90% 的需求。
5.1 Linux命令行里快速做进制转换
Linux 下最常用的两个工具是printf和bc。printf能快速输出整数格式:
# 十进制255转十六进制 printf '%x\n' 255 # 十进制255转八进制 printf '%o\n' 255 # 十六进制FF转十进制(需要借助算术展开) echo $((0xFF))bc则可以做小数级的进制转换,配合ibase和obase使用:
# 十进制小数0.625转二进制,精度设为10位 echo 'obase=2; 0.625' | bc -l # 十进制0.1转二进制,你马上能看到循环小数 echo 'obase=2; scale=20; 0.1' | bc -l实测下来,bc在处理小数的二进制输出时能直接展示无限循环的趋势,比如0.1会输出一串0.00011001100110011001100,对理解“算不干净”特别直观。不过要注意bc的小数进制转换在不同发行版上行为有细微差异,遇到输出异常时先检查scale设置。
5.2 Python的float.hex():一把钥匙打开全部秘密
如果你手头装了 Python,那验证小数进制转换的最佳工具是float.hex()方法。它能把一个浮点数的真实二进制内容,用十六进制直接打印出来:
# 查看0.1在双精度下的真实存储 print((0.1).hex()) # 输出:0x1.999999999999ap-4这个输出怎么读?0x1.999999999999ap-4的意思是1.999999999999a(十六进制)× 2^(-4)。展开0x1.999999999999a的尾数部分,其实就是0.1的二进制循环小数截断到 52 位后再转十六进制的样子。我强烈建议你把几个常用小数都跑一遍,用这个命令反向验证你手算的结果,对理解浮点存储帮助极大。
再看一个0.75:
print((0.75).hex()) # 输出:0x1.8000000000000p-10x1.8就是二进制1.1,指数p-1代表乘以 2 的 -1 次方,最终就是0.75。整个表达式的可读性比直接看一长串二进制强太多。
5.3 用HxD观察二进制文件:为什么十六进制比十进制更顺眼
热词里有“十六进制编辑器 hxd”,这是 Windows 平台上一款非常经典的十六进制编辑器。打开一个二进制文件,比如图片、可执行文件,你看到的是一排排从00到FF的字节值。比如文件头里常见的89 50 4E 47,就是 PNG 图片的魔数。
为什么这里不用十进制显示?因为一个字节包含 8 位,用十六进制恰好映射成 2 位,等于一个字节固定两列,宽度完全对齐,扫一眼就知道文件结构。如果换成十进制显示,一个字节可能是 1 到 3 位数字,列宽根本无法对齐,人脑也没法快速把它拆回二进制位。十六进制的“友好”在这里体现得淋漓尽致。
另外 HxD 还有一个我常用的功能:内存对比。把内存中的原始字节直接导出来和文件字节对比,确认某个浮点数在内存中的实际字节序是大端还是小端。Windows 上小端序居多,0x3F400000在内存里实际存储为00 00 40 3F,这个顺序颠倒问题,不亲眼看一次很容易在底层调试时栽跟头。
6. 常见问题与避坑速查(附独家排查心得)
最后汇总一些我实际踩过、以及被问过多次的问题,很多人搜热词就是想解决这类具体疑问,这里直接给结论。
6.1 0.1 + 0.2 不等于0.3 的完整推导
很多人只知道“浮点数有误差”,但不知道误差怎么来的。0.1转二进制是无限循环小数0.0001100110011...,0.2同样在二进制下无限循环。由于 double 只能存 52 位尾数,这两个数都被截断,相加结果也不是精确的0.3。
具体过程可以用 Python 验证:
print(0.1 + 0.2) # 0.30000000000000004 print((0.1 + 0.2) == 0.3) # False解决方式一般有三种:比较时用误差范围,比如abs(a - b) < 1e-9;或者做固定小数位运算时全部乘成整数;或者直接用decimal.Decimal:
from decimal import Decimal Decimal('0.1') + Decimal('0.2') # Decimal('0.3')核心原则是:知道你在用二进制近似表示小数,就不要用“==”去比较浮点数。
6.2 补零方向搞反,结果全错的陷阱
这是我在教别人十六进制转换时遇到最多的错。整数部分转十六进制,从右往左四位一组,最后一组不足四位要在左侧补零;小数部分转十六进制,从左往右四位一组,最后一组不足四位要在右侧补零。
我自己记的方法是:把小数点当成“锚点”,哪个方向离锚点远,补零就补在哪边。整数部分的小数点在最右侧,离最左边远,所以补在最左;小数部分的小数点在最左侧,离最右边远,所以补在最右。这个“远离锚点补零”的心法,我还没在任何教科书上见过,但确实帮我少错了一百次。
6.3 strstr()查二进制内存为什么翻车
C语言里strstr()是字符串查找函数,它按\0判断结尾。但二进制数据里完全可以包含0x00字节,比如浮点数0.75的 IEEE 754 表示0x3F400000内存里就是00 00 40 3F,前面就有两个0x00。你用strstr()去查,它会在第一个\0处就停止,怎么可能找到后面的数据。
正确做法是用memmem()(GNU扩展)或者自己写一个按长度比较的循环:
// 用memmem按长度在二进制缓冲区中查找模式 void *pos = memmem(buf, buf_len, pattern, pattern_len);核心教训:凡是面向字节的二进制处理,一律带长度,不要依赖任何“以\0结尾”的字符串函数。这个坑在解析文件格式、通信报文时特别常见。
6.4 从键盘扫描到CTF:十六进制在嵌入式与安全的用处
热词里有“用Verilog设计一个十六进制键盘电路的键盘扫描和编码器”,也有“CTF二进制中的rip题目”,两者看起来不相关,其实都用到了十六进制的“字段打包”思维。
键盘扫描电路把 4 行 4 列的按键矩阵编码成 4 位二进制,四位的组合正好一位十六进制。硬件输出直接接数码管显示0到F,调试的时候你不用盯着四位线折腾,看一个字符就知道哪个键被按下。
CTF 的 pwn 题目里改 return address(也就是热词提到的“rip”)时,目标地址通常是一串十六进制,比如0x7fffffffdbe0。你在构造 payload 时还得考虑字节序:小端机器上地址要按字节倒序写入内存。只给一个十六进制串而不讲字节序的教程,基本都会让你在最后一步翻车。我建议遇到这类题,先用 Python 的p32()或struct.pack('<Q', addr)处理地址,再放进 exploit 脚本,不要手动去比对内存顺序。
这些实际案例都指向同一个道理:二进制是一切机器语言的底层语言,而十六进制是人与二进制之间的桥梁。你把小数的二进制、十六进制转换吃透了,再去理解浮点、内存布局、网络字节序,都是顺水推舟的事。
最后分享一点个人体会
我最早学进制转换的时候,纯靠背步骤,整数除2取余、小数乘2取整、四位一分组,背得滚瓜烂熟,但一遇到0.1转十六进制这种小数就发懵。后来我突然想明白,进制转换的关键不是步骤,而是“权重”这两个字。二进制的每一位就是一个二进制的秤砣,十六进制的每一位就是四个二进制的秤砣捆在一起。一旦你把转换看成“用秤砣配重”而不是“套公式”,所有步骤都变得特别自然。
如果你也想练出这种直觉,我建议你拿一张纸,写上几个带小数的十进制数,比如6.75、10.25、0.3125,先用手算转二进制,再转十六进制,最后用文章里的bc或者 Python 的float.hex()验证。误差不匹配就回去检查补零方向和舍入方式。练上三五天,你再看内存调试页面里那些十六进制字节,就不再是乱码,而是能一眼读出它背后真实数值。