news 2026/10/2 11:06:19

0.1+0.2≠0.3?一文搞懂小数的二进制和十六进制表示

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
0.1+0.2≠0.3?一文搞懂小数的二进制和十六进制表示

小数这个东西,二进制的账目是真的不好算。整数二进制大部分人花十分钟就能上手,毕竟“逢二进一”跟“逢十进一”在左往右的位权逻辑上是一模一样的,但一旦小数点冒出来,很多人都懵了——0.1在十进制里写得清清楚楚,结果换成二进制竟然变成了一串循环不完的数字,存进计算机里还会出现“0.1+0.2不等于0.3”这种反直觉的情况。今天这篇博文,我就把“小数的二进制和十六进制表示”这件事从头到尾拆一遍,从为什么小数位就是负次幂,到手推算0.625的二进制,再到用十六进制编辑器HxD在文件里直接看到float的原始字节,最后把负数、补码、C语言strstr()查二进制数据、CTF二进制题里怎么用十六进制视角看栈溢出这些问题都串起来聊。不管你是刚学编程的新手,还是在调串口协议、读写bin文件、啃二进制漏洞的进阶玩家,这篇文章都能给你点实在的东西。

1. 从整数到小数:二进制的第一课

1.1 整数的二进制到底是怎么回事

先复习基础,因为后面讲小数都必须建立在整数的位权概念上。十进制里从右往左每一位分别是10^0、10^1、10^2……也就是个位、十位、百位,每一位的取值从0到9。二进制也一样,只是底数从10变成了2,从右往左每一位分别是2^0、2^1、2^2……也就是1、2、4、8、16这样一直翻倍,每位只能取0或者1。

举例:二进制1011,从左往右拆开是18 + 04 + 12 + 11 = 11。所以“1011”是“8+2+1”,这就是十进制11。这套位权逻辑是最核心的,后面所有内容都是从这个“每一位有自己的权重”延伸出去的。

顺带把网络上经常被人搜的“二进制除法”也说一句。二进制的加减乘除和十进制算法完全一样,只是“借位”和“进位”的规则变成了2。比如1010 ÷ 10(十进制10 ÷ 2)用长除法做,跟十进制里列竖式除法一个套路,一位一位除过去就行。因为二进制只有0和1,商这一位不是0就是1,判断起来反而比十进制更机械,很适合硬件电路实现。

1.2 小数位为什么是“负次幂”

小数点的意义,十进制里应该都清楚:小数点右边第一位是十分位,权重是10^-1 = 0.1;第二位是百分位,权重是10^-2 = 0.01。二进制完全照搬这套逻辑,只不过底数换成2:小数点后第一位权重是2^-1 = 0.5,第二位是2^-2 = 0.25,第三位是2^-3 = 0.125,依此类推。

所以二进制小数0.101,意思就是1个0.5加上0个0.25加上1个0.125,算出来是0.625。这个例子特别适合用来理解,因为0.625恰好是2的负幂次相加的典型,转换过程干净利落。

再扩展一个场景:单片机开发时经常要发小数给上位机,最土的办法就是把这个小数乘上一个倍率变整数,比如×1000之后发送,上位机再除以1000。为什么这么干?因为很多协议只支持整数传输,而浮点数的字节序和格式在两端容易搞出幺蛾子。理解了小数位就是负次幂,你再看到0.5、0.25、0.125这些“二进制好数”的时候,就知道它们为什么能精准表示,而0.1、0.2这种数是天生就不行的了。

2. 十进制小数转二进制:乘2取整法的完整拆解

2.1 手推一个0.625

整数部分的十进制转二进制用“除2取余法”,小数部分则用“乘2取整法”,这两个方法相反,却经常被混在一起记错。乘2取整的操作步骤是:

  1. 把小数部分单独拎出来,乘以2。
  2. 记下乘积的整数部分(必然是0或1),把小数部分再拿出来继续乘2。
  3. 反复执行,直到小数部分变成0,或者精度够了为止。
  4. 从上往下把这些整数部分依次排列,就是二进制小数位。

实操一遍0.625:

  • 第1次:0.625 × 2 = 1.25,整数部分1,剩余0.25
  • 第2次:0.25 × 2 = 0.5,整数部分0,剩余0.5
  • 第3次:0.5 × 2 = 1.0,整数部分1,剩余0

从第1次到第3次得到的整数依次是1、0、1,所以0.625转二进制就是0.101。验证一下,0.101 = 0.5 + 0.125 = 0.625,完全正确。

这里有个容易弄错的地方:乘2取整是从上往下读,也就是第一次的结果写在小数点后第一位。很多人习惯性学整数那样从下往上读,转出来就完全是另一个数了。我自己刚学时踩过这个坑,用0.75试了一下,按从下往上读会得到0.11(新手的常见错误),但0.11是0.75没错——因为0.75乘2第一次就是1.5取1,第二次0.5乘2取1,读下来也还是11,看不出问题。换成0.625就能明显看出方向错误了,因为0.101和0.110差得十万八千里。

2.2 为什么0.1永远写不完

用乘2取整法去处理0.1,你会发现一个很崩溃的现象:

  • 0.1 × 2 = 0.2,取0,剩0.2
  • 0.2 × 2 = 0.4,取0,剩0.4
  • 0.4 × 2 = 0.8,取0,剩0.8
  • 0.8 × 2 = 1.6,取1,剩0.6
  • 0.6 × 2 = 1.2,取1,剩0.2
  • 0.2 × 2 = 0.4,取0……

算到后面又回到了0.2,显然进入了一个无限循环,二进制下0.1 = 0.0001100110011……循环体是“0011”,永远写不完。这一切的根本原因是:二进制小数只能精确表示能够写成若干个“2的负次幂之和”的数,分母必须是2的幂。十进制里的0.1相当于分数1/10,分母10不是2的幂,无论怎么拆都拆不成纯粹的2的负次幂相加。

这跟十进制里1/3 = 0.3333……写不完是一个道理。十进制能轻松写出0.1,不代表二进制天然就能。所以那些要求精确计算的金融软件,通常不用浮点数,而是用整数“分”来记账,或者用十进制定点数,就是为了绕开这个天生缺陷。

2.3 精度取舍:舍入是必须的

网络热词里有一句“十进制小数转换为二进制有精度限制时需要考虑舍入吗”,答案是必须考虑,而且舍入策略会直接影响结果。以0.1为例,如果要把它转成二进制并且只保留23位小数(对应单精度float的尾数长度),那第24位之后的部分就必须处理。计算机里最常用的是“就近舍入”,也就是看第24位是0还是1,如果超出部分过半就进位,如果刚好一半则让最后一位变成偶数(银行家舍入)。这就是为什么0.1存进单精度float后,实际值并不是0.1的无限循环本身,而是被截断或进位后的近似值。

实际调试中我发现,很多人排查“浮点数相加不对”时都会忽略舍入方向。比如你写C语言float a = 0.1; 然后在调试器里看a的十六进制,经常看到0x3DCCCCCD,而不是想象中规规矩矩的0x3DCCCCCC。最后一个D就是舍入进位的结果。如果你在写跨平台协议,一端用float存,一端用double读写,这种舍入差异会被放大,最好的做法是明确约定“用双精度传输,统一转成十六进制字节”,尽量避免让不同编译器各自做舍入。

3. 二进制转十六进制:小数的“4位一组”玩法

3.1 为什么偏偏是4位一组

十六进制和二进制之间不是某个人拍脑袋定下的关系,而是因为16 = 2^4,一位十六进制数正好能精确表达四位二进制数。十六进制数的取值范围是0~15,恰好对应二进制的0000~1111。所以二进制转十六进制根本不需要像十进制转十六进制那样又除又模,只需把二进制位从头到尾每4位切成一组,每组单独转成一个十六进制字符就行。

这个特性让十六进制成了二进制世界的“速记法”。一个32位的二进制数写出来能占一行,而且特别容易看花眼;转成十六进制之后变成8个字符,阅读和记忆难度直线下降。做逆向、调驱动、刷固件、看TCP报文时,看到的原始数据几乎全是十六进制,就是这个原因。网络热词里有人搜“十六进制编辑器hxd”,这类工具的本质就是把磁盘或内存中的二进制数据按字节拆开,用两位十六进制数去展示每一字节的内容,方便你直接检查。

下面这张0到15的对应表建议存脑子里,用得极多:

十进制二进制十六进制
000000
100011
200102
300113
401004
501015
601106
701117
810008
910019
101010A
111011B
121100C
131101D
141110E
151111F

3.2 小数部分的分组与补零技巧

整数部分的二进制转十六进制,是从右往左每4位一组,最左边不够4位就补0。小数部分则是从左往右每4位一组,最右边不够4位就补0。这个方向千万不要搞反,我见过有人把小数部分也从右往左分组,转出来的十六进制小数完全是另一个数。

举个完整例子:二进制0.1011011。先把小数部分按从左往右分组:1011 0110,左边一组1011正好4位,右边一组0110因为原始只有3位所以补了一个0变成0110。1011十六进制是B,0110十六进制是6,所以0.1011011 = 0.B6(十六进制)。

再来一个验证:0.B6的十进制值,十六进制小数每一位权重是1/16、1/256……所以0.B6 = 11/16 + 6/256 = 0.6875 + 0.0234375 = 0.7109375。你可以用乘2取整去验证原来的0.1011011,会发现结果相同。平时做嵌入式开发时,寄存器里的某个字段如果定义了“低10位是某个小数部分”,用这种4位分组法可以飞快地把寄存器值转成可读性更好的十六进制描述,而不用一长串二进制写来写去。

3.3 十六进制小数的快速心算技巧

十六进制小数读起来可能有点反直觉,但它和十进制小数是一回事:0.A就是A除以16,0.C8就是C除以16再加上8除以256。如果你要快速把十六进制小数量化成十进制,可以直接用“第一位除以16,第二位除以256”逐项加。比如想确认协议文档里写的0x04D2高16位和低16位怎么组成小数,先把它拆成0x04和0xD2,一个乘256,一个直接加,再整体除以65536,不出一分钟就能估出精度损失是否在可接受范围。

这个技巧在处理非标准浮点协议时特别有用。很多私有协议不用IEEE 754,而是用16位或32位定点数,比如高8位表示整数部分、低8位表示小数部分,那么0x1A.C5就表示整数26再加0xC5/256 = 197/256 ≈ 0.7695。理解了十六进制小数的位权,这类协议在你眼里就是透明的,不用每次查转换工具。

4. 深入内存:IEEE 754浮点数与十六进制编辑器实战

4.1 单精度和双精度的内存格局

前面讲的二进制和十六进制转换都是纯数学层面的“十进制小数 ↔ 二进制小数”,但是当小数真正存进计算机内存之后,问题又多了一层:现代CPU和编译器普遍遵循IEEE 754标准来表示浮点数。这个标准跟直接写一个二进制小数的位模式不一样,它用的是“科学计数法”的思路。

单精度float占32位,双精度double占64位。float的32位分成三段:最高1位符号位,接着8位指数位,剩下23位尾数位。double则是1位符号位、11位指数位、52位尾数位。因为规格化浮点数通过“隐含前导1”的技巧省掉一位,所以float实际能精确表示的尾数精度相当于24位二进制数,double相当于53位。

指数部分不是朴素的“指数直接存”,而是带偏移的。float的指数偏移量是127,double是1023。也就是说,如果实际指数是2,那存进指数位的是129,即二进制10000001。这套设计的好处是,可以用无符号整数来比较浮点数指数大小,方便硬件做排序和比较器。

4.2 手算一个float的十六进制字节

纸上谈兵没意思,直接手算一个具体的值:5.625。

第1步,整数部分5转二进制是101,小数部分0.625转二进制是0.101(前面算过了),合并起来是101.101。

第2步,把101.101规格化成科学计数法形式,小数点向左挪两位,变成1.01101 × 2^2。注意这里挪的是二进制小数点,挪几位是指数,不是十进制科学计数法。

第3步,符号位:正数,所以符号位S = 0。

第4步,指数位:实际指数是2,加上float的偏移127,得到129,转二进制是10000001。

第5步,尾数位:取1.01101去掉隐含的“1.”之后的小数部分01101,右侧补0到23位,即01101000000000000000000。

第6步,把三部分按S+E+M的顺序拼接起来:0 10000001 01101000000000000000000。每4位分组转十六进制:0100 0000 1011 0100 0000 0000 0000 0000,即0x40B40000。

第7步,如果按小端序存进内存,从低地址到高地址的字节是00 00 B4 40。你在HxD里看到一个float的原始字节是“00 00 B4 40”,对应的数字就是5.625,而不是0x40B40000直接按大端读出来的那个值。

动手操作一遍特别能破除“16进制编辑器里看到的数字就是内存中数字本身”的迷思。实际文件里99%的x86、ARM设备都是小端序,也就是低字节排在前面,直接拿十六进制编辑器看到的排列和数学上写的十六进制数是相反的。

4.3 用HxD在文件里定位小数

HxD是一款免费十六进制编辑器,打开bin文件、磁盘扇区、内存镜像都很方便。要在里面定位某个小数,你得先知道自己搜索的对象在文件中的字节序。这里给一个实用的复现流程:

先用Python生成一个包含5.625和-5.625两个float的文件,用struct模块按小端打包。

import struct values = [5.625, -5.625] with open('floats.bin', 'wb') as f: for v in values: f.write(struct.pack('<f', v)) # '<' 表示小端,'f' 表示单精度float

运行后用HxD打开floats.bin,你应该看到8个字节:00 00 B4 40 00 00 B4 C0。其中00 00 B4 40是5.625,00 00 B4 C0是-5.625。注意最后的40变成C0,差别只在符号位上,符号位从0变成1,所以0x40B40000变成0xC0B40000,小端排列后就是00 00 B4 C0。

实际调试中,我的习惯是:先在程序里打印出某个float的字节序列,确认字节序,再去HxD里搜对应的模式。像搜“00 00 B4 40”这种8字符模式,HxD支持十六进制搜索,直接粘进去就行,比在二进制文件里搜ASCII字符串可靠得多。如果你要搜一个double,比如5.625的double是0x4016800000000000,小端存储就变成00 00 00 00 00 80 16 40,HxD里搜“00 00 00 00 00 80 16 40”能命中。

5. 负数的二进制与二进制操作的常见坑

5.1 负小数的表示:符号位与补码

整数负数在计算机里用补码表示,这个很多学过C语言的人都知道。补码的本质是“按位取反再加1”,比如-1在32位下是0xFFFFFFFF,-2是0xFFFFFFFE,越负数值的编码反而越小,这就让整数加减可以统一成加法,硬件设计大幅简化。

但到了小数,事情比较微妙:纯小数二进制本身没有标准的“负数位模式”,而IEEE 754浮点数的负数不是用补码,只是把符号位S置为1,指数和尾数完全不变。所以-5.625的float是0xC0B40000,前面算过;正负5.625在内存里只差一个最高位。从HxD里看,00 00 B4 40变成00 00 B4 C0,就是这个原因。

很多人在学习“负数的二进制”时会下意识认为所有负数都要用补码,可一旦切换到浮点数就懵了。其实只需要记住一句话:整数补码、浮点数符号位,这是两套完全不同的编码体系。比如你用C语言把float强制转换成int,中间并不会自动帮你做“符号位转补码”的换算,得到的是某段二进制按int解读后的值,经常会是一个很奇怪的整数。

5.2 用strstr()找二进制内存,为什么那么坑

网络热词里有个问题非常典型:C语言的strstr()能否用于查找二进制内存?答案是不能安全使用。strstr()按字符串处理,它把传入的指针当成以'\0'结尾的字符串,一旦内存中出现0x00字节,strstr()就会认为字符串结束,后面再长的模式也搜不到。但二进制数据里0x00恰恰是最常见的内容,比如float 5.625的小端第一个字节就是0x00,int 0x00000001小端前三个字节也都是0x00。拿strstr()去做二进制匹配,漏掉率几乎是100%。

正确做法是自己写一个按长度匹配的函数,比如先比较首字节,匹配后再用memcmp逐段比较,或者直接调用系统提供的memmem(Linux中可用)。下面是一个简单的参考实现,避免了strstr()的终止符问题:

#include <string.h> void *mem_find(const void *haystack, size_t haystack_len, const void *needle, size_t needle_len) { const unsigned char *h = haystack; const unsigned char *n = needle; if (needle_len == 0) return (void *)haystack; if (needle_len > haystack_len) return NULL; for (size_t i = 0; i <= haystack_len - needle_len; i++) { if (h[i] == n[0] && memcmp(h + i, n, needle_len) == 0) { return (void *)(h + i); } } return NULL; }

这个函数先在缓冲区里逐字节找“目标模式”的起始字节,匹配上了再用memcmp整段比较。实测在小文件里性能完全够用,如果你要在大文件里做频繁搜索,再用KMP或BM算法优化不迟。这类底层细节最容易让新手踩坑,因为编译器不报错,只有运行结果完全对不上时才会如梦初醒。

5.3 十六进制键盘编码器里的映射表

网络热词里还提到“用verilog设计一个十六进制键盘电路的键盘扫描和编码器”,这种硬件场景同样离不开今天讲的十六进制表。十六进制键盘常见的做法是4x4矩阵,16个按键分别对应0~9和A~F。扫描时通过行线列线逐行拉低,检测列线电平变化,“去抖”后得到一个键坐标,再通过一个case/查表将坐标映射成4位二进制编码,也就是十六进制的一位。

本质上这就是一张查找表,跟你拿二进制/十六进制对应表做转换一样。写verilog时,我一般会在模块里用一个4位的reg做编码输出,代码如下风格:

always @(*) begin case (key_col) 4'd0: hex_out = 4'h0; 4'd1: hex_out = 4'h1; // ... 4'd10: hex_out = 4'hA; 4'd15: hex_out = 4'hF; default: hex_out = 4'h0; endcase end

这里的4'hA直接表示二进制1010,跟前面说的十六进制速记完全一致。硬件工程师看到4'hF就知道是4位全1,这种表达比4'b1111更简洁,也更让人容易理解位宽和值。学习数字电路的同学,把那一张16行的对应表记下来,写编码器、译码器的时候根本不用再临时翻文档。

6. CTF视角:二进制题目里的小数与RIP控制

6.1 为什么二进制分析要盯着一堆十六进制字节

CTF的PWN方向(二进制漏洞利用)经常被人搜到“ctf二进制中的rip题目怎么做”。这类题目的核心,是让你通过输入数据去覆盖程序栈上的内存,最终改写返回地址,把程序的控制流劫持到你想要的位置。分析过程中你看到的几乎全是十六进制字节:栈上的数据、覆盖的偏移量、目标地址,每一样都需要以字节为单位去理解。

很多新手拿到题目先懵的一件事是:程序明明在正常跑,为什么要去管一堆0x41、0x42的十六进制?其实0x41就是大写字母A的ASCII码,也是栈溢出填充常用字符。当程序把输入拷贝到固定大小的缓冲区时,超出的数据会一路写到栈上,把更上层的返回地址覆盖掉。你在调试器里看到栈上一大片0x41414141,就说明“A”已经被填到了返回地址的位置,控制流的转折点出现了。

6.2 从溢出到RIP控制的思路简化

我在这里不展开具体的攻击过程,因为现代系统有各种防护机制,真实利用需要绕ASLR、NX、canary等,那是一门庞大的课程。但从理解“二进制与十六进制”的角度,把思路讲清楚对学习非常有帮助:

第一,你得确定覆盖返回地址需要多少字节。办法很简单:用模式字符串(cyclic pattern)填充输入,崩溃时查看RIP寄存器里是哪四个字节,再用工具算出偏移。比如RIP显示0x62616166,对应小端字节66 61 61 62,查模式就知道第96个字节开始覆盖返回地址。

第二,你要把用来覆盖的地址按小端序写进payload。比如目标函数入口地址是0x4007a3,那写在payload里的字节序列就是a3 07 40 00 00 00 00 00,如果你的目标环境是64位,地址是8字节;是32位,就是4字节。这段字节在十六进制编辑器里看起来就是“a3 07 40 00”,一点都看不出是个“地址”。平时那些用十六进制编辑器直接修改游戏存档、修改bin文件的操作,本质上都是在替换这类字节序列。

第三,遇到要布置shellcode的题目,你需要把机器码逐字节写对。机器码本质也是二进制/十六进制,比如直接ret的指令是0xC3,nop是0x90。手工调试时看着这些字节去推控制流,比自己凭空想象指令长什么样子要靠谱太多。理解了“内存里的一切都是一串十六进制字节”这个观念,后面学rop、学堆利用都会顺畅很多。

我建议入门CTF时,先把栈布局、字节序、偏移计算这三样练扎实。不要一上来就背exp框架,先能在十六进制编辑器和调试器里看懂“输入的A是怎么一步步变成RIP里的0x41414141”的,再往后学就会豁然开朗。

最后聊点实际使用中的体会。我第一次很清楚地感受到小数二进制和内存字节序的纠缠,是在调试一个MCU的串口浮点传输协议时。上位机发来四个字节“00 00 B4 40”,我在单片机里按uint8逐个收,拼成uint32后直接强转float,结果怎么都是个接近0的数,后来反应过来是大小端的问题,把字节顺序换一下就正常了。这类问题用文字描述特别抽象,但你一旦在HxD里亲眼看一次正负5.625的存储形式,再手写一次从“0x40B40000”还原成5.625的过程,这辈子都不会再忘。建议读者也自己建个bin文件,写下今天算出来的几个值,打开十六进制编辑器对着看几遍,比死记硬背各种转换公式有用得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 11:06:13

XXL-JOB分布式任务调度原理与实战入门

1. 为什么今天还在学 XXL-JOB&#xff1f;它真不是“过气中间件”XXL-JOB 这四个字母&#xff0c;我第一次在生产环境里看到时&#xff0c;是在一个凌晨三点的告警群里——调度中心挂了&#xff0c;二十多个定时任务集体失联&#xff0c;订单对账中断&#xff0c;库存校验停摆&…

作者头像 李华
网站建设 2026/10/2 11:06:10

风格化村庄塞进PICO Neo3:移动端VR渲染优化完整实践

从拿到“把风格化村庄塞进 PICO Neo3”这个需求到现在&#xff0c;前两篇已经解决了整体架构选型和流程搭建&#xff0c;这篇本来是打算写写“穿模修复”&#xff0c;结果真正动起手来才发现&#xff0c;绝大多数时间都花在了“怎么让它不卡”上。一个在PC上可以开满特效的风格…

作者头像 李华
网站建设 2026/10/2 11:05:27

Java开发者AI入门实战:Spring AI集成、RAG与工程化落地路线图

1. Java 开发者切入 AI 的真实动机与路线选择1.1 为什么 Java 开发者现在必须正视 AI 这件事这两年我身边不少写了七八年 Java 的朋友&#xff0c;聊天时总会绕到一个话题&#xff1a;AI 到底跟咱们做业务后端的人有多大关系。我的判断很直接——关系比想象中大得多。原因不复杂…

作者头像 李华
网站建设 2026/10/2 11:02:52

Linux内核同步机制详解:从原子操作到RCU的并发基石

1. 为什么说同步机制是Linux内核的“地基”1.1 从一次并发事故说起&#xff1a;同步机制到底解决什么问题先讲一个我早年做嵌入式驱动时的真实案例。当时在双核ARM平台上写一个中断处理与内核线程共享的计数器&#xff0c;逻辑非常简单&#xff1a;中断里对全局变量做加一操作&…

作者头像 李华
网站建设 2026/10/2 11:02:29

MindSpore 上高效跑通 LLM 预训练:从环境配置到并行策略的完整实践指南

跑过几回模型训练的人都懂&#xff0c;LLM 预训练不是“把数据喂进去等 loss 掉下来”那么简单。框架选型、权重格式、并行策略、混合精度、checkpoint 存取&#xff0c;每一个环节都能让训练进度条从“稳步推进”变成“原地罚站”。我之前在 MindSpore 上折腾 Transformers 生…

作者头像 李华