ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深入剖析浮点数内存存储:IEEE 754、精度丢失与大小端实战

深入剖析浮点数内存存储:IEEE 754、精度丢失与大小端实战 1. 一个让新手改Bug改到怀疑人生的经典问题先看一段几乎所有人都会遇到的代码。你用C、Java、Python还是Go写出来结果都一样# Python示例 print(0.1 0.2) # 输出0.30000000000000004我第一次看到这个结果时第一反应是计算机算错了第二反应是编译器坏了第三反应是变量搞混了。但到了最后我翻遍整本教材才发现问题不在我的代码而在0.1这个数字本身——它在内存里压根就不是我们认识的0.1。不光是0.1你在代码里写的任何一个带小数点的字面量比如3.14、2.718、1.414一旦编译成二进制存进内存就开始和十进制世界的原值产生偏差。这种偏差不是编译器的Bug而是浮点数这种数据结构的固有属性。换句话说只要你的程序涉及小数运算、金额计算、传感器读数汇总、物理引擎模拟、机器学习权重更新你就在和这种近似表示打交道。很多刚入行的开发者第一次遇到浮点数精度问题是在做金额相关的需求时。比如订单总价是19.99元数量是3件程序算出来是59.96999999999999页面直接显示成59.96999999999999元用户投诉过来产品经理一脸懵。这种问题之所以难排查是因为它不像空指针那样报错也不像数组越界那样崩溃它只是安安静静地给你一个差一点点的答案。这篇文章不打算停留在浮点数有精度问题所以别用比较这种层面。我要把IEEE 754标准下的浮点数存储机制彻底拆开来讲包括32位和64位浮点数在内存里具体的位布局是什么、为什么尾数是那种分配方式、特殊值Infinity和NaN是怎么编码的、大端小端模式下同一个浮点数的字节顺序有什么不同、以及你能用哪些工具亲手验证这一切。都是一线写代码时真正用得到的东西。2. 从十进制科学计数法到二进制存储位分配背后的数学逻辑2.1 你其实早就懂浮点数的核心思想要理解浮点数在内存中的存储先跳出计算机回到初中数学。134.625这个数用科学计数法可以写成134.625 1.34625 × 10²这个表达式的结构非常清晰一个有效数字1.34625乘以基数10的整数次幂2次方。有效数字变了数的精度就变了指数变了数的大小范围就变了。计算机里的浮点数本质上就是二进制版的科学计数法。只不过基数从10换成了2有效数字也变成了一串二进制位。比如二进制数1101.101用二进制科学计数法表示就是1101.101 1.101101 × 2³你注意二进制的科学计数法格式化之后整数部分永远是1除非这个数是0因为任何非零的二进制数都可以通过调整指数把小数点挪到第一个非零位后面。这个永远为1的整数位在存储时甚至不需要保存这就是IEEE 754标准里一个非常关键的精简设计。后面讲尾数部分时会重点展开。理解了这个原理再看32位浮点数也就是float的存储布局就会觉得顺理成章。IEEE 754规定一个单精度浮点数占32位4字节分成三个区段区段位范围位数用途符号位S第31位1位0表示正数1表示负数指数部分E第23~30位8位以偏移量存储指数值尾数部分M第0~22位23位存储有效数字的小数部分用一张更直观的图来表示就是31 30 23 22 0 --------------------------------------------------- | S | E | M | --------------------------------------------------- 1位 8位 23位双精度double则把总长度扩展到64位符号位还是1位指数扩展到11位尾数扩展到52位。很多教材到这里就停住了只给你一张位分配表但没解释两个核心问题指数为什么要加偏移量尾数为什么只有23位却被称为24位精度2.2 指数偏移量为什么8位有符号整数不能满足需求先看第一个问题。公式是实际指数值 存储的指数位 - 偏移量。对于单精度偏移量是127。对于双精度偏移量是1023。举个例子2³这个数指数是3。存进单精度的指数区时存的是3 127 130二进制是10000010。为什么要多此一举加个127关键在于比较运算。如果你用补码来表示带符号的指数那么比较两个浮点数的大小光比较指数位是不够的——因为负指数的补码看起来像一个很大的正数直接按位比较会出错。但如果把指数统一偏移成无符号整数一切就简单了指数越大偏移后的无符号值越大指数越小偏移后的无符号值越小。这样硬件在比较两个浮点数大小时可以先粗略比较指数位再用后面的位比较整个流程非常高效这也是IEEE 754标准在设计时的一个重要工程考量。同时8位二进制能表示的无符号整数范围是0到255偏移127后实际指数范围就是-126到127。注意这个范围没有包含-127和128这两个极端值被IEEE 754标准预留了用来表示非规格化数和特殊值比如无穷大和NaN后面会专门讲。2.3 隐藏的1为什么23位尾数能提供24位精度然后是第二个问题。23位尾数为什么精度是24位因为规格化数的小数点前面一定是1。比如二进制1.101101 × 2³小数点前的那个1是固定的不用专门用一位去存它。所以23位尾数位实际上记录了小数点后面的23位再加上那个被省略的1一共是24位有效二进制数字。用个生活化的类比如果我的身高铁的规律是所有学生证上都写着1米8那我在登记时就可以把1米8这几个字省掉只记录小数点后面的信息。等需要用的时候再把这个1米8补回去。这就是隐藏位implicit leading bit的意义。虽然它省掉了一个bit但对精度的影响是10%以上的提升因为23位变成了24位有效精度。同样双精度浮点数64位中的52位尾数加上隐藏的1位实际有效精度是53位二进制数字。换算一下2^24大约是1677万约等于10^7.2所以单精度float能精确到大约7位十进制有效数字2^53约等于9×10^15所以双精度double能精确到大约15到16位十进制有效数字。这就是你经常听到的float有7位有效数字、double有15到16位有效数字的真正来源。3. 一步步看0.1的二进制真面目从十进制小数到内存位串3.1 十进制小数转二进制的乘2取整法理解了位分配规则接下来最关键的问题就是一个十进制的实数比如0.1在内存里到底存了一串什么样的二进制位先回忆整数转二进制的方法除以2取余数。比如5 101₂。小数部分转二进制的方法正好反过来乘2取整。步骤是将小数部分乘以2取结果的整数部分作为二进制位不是0就是1。把整数部分去掉剩下的小数部分继续乘以2。重复上述过程直到小数部分为0或者达到所需精度。拿0.1来算一遍步骤小数部分乘以2整数位剩余小数10.10.200.220.20.400.430.40.800.840.81.610.650.61.210.260.20.400.470.40.800.880.81.610.690.61.210.2看出来了吗从第5步开始余数开始循环0.2 → 0.4 → 0.8 → 0.6 → 0.2……对应的二进制小数部分就是0.0001100110011001100110011...无限循环。关键在于0.1在二进制里是一个无限循环小数就像1/3在十进制里是0.33333...一样。计算机的存储空间有限必须在某一位截断这就产生了不可避免的舍入误差。3.2 单精度模式下0.1的完整位串分解既然0.1的二进制表示是无限循环的那把它存进单精度float时实际存的就不是0.1精确值了而是四舍五入后最接近0.1的那个二进制数。完整的推导过程如下。第一步0.1的规范化二进制科学计数法0.00011001100110011001100110011...₂ 1.10011001100110011001100...₂ × 2⁻⁴注意指数是-4因为要把小数点从原来的位置挪到第一个有效数字1后面需要向右移4位。第二步确定指数区存储值实际指数 -4存储的指数位 -4 127 123123的二进制是01111011₂第三步确定尾数位。尾数区存储的是规范形式的小数部分也就是10011001100110011001100……由于无限循环单精度只能保留23位第24位的值是1按照就近舍入规则第23位需要进位。最后的尾数区为10011001100110011001101₂第四步拼装起来符号位 S : 0 指数区 E : 01111011 尾数区 M : 10011001100110011001101 完整32位 : 0 01111011 10011001100110011001101 十六进制 : 0x3DCCCCCD这就是0.1在内存里真正的样子。你可以用一个联合体或者memcpy把它打印出来验证后面会给出具体代码。重要的是0x3DCCCCCD对应的十进制数并不是0.1而是0.100000001490116119384765625。这个数比0.1大了一点点。而0.2也存在类似的情况它的存储值比0.2大了一点点。两个大了一点点的数相加累积的误差就显性化成了0.30000000000000004。3.3 双精度模式下0.1的位串多出来的29位去哪儿了如果你用的是double而不是float0.1的误差会小得多但仍然不等于0.1。原因是双精度的尾数有52位能多保留29位二进制循环数字舍入误差更小但本质上还是近似值。0.1在双精度下的十六进制存储值是0x3FB999999999999A。用Python验证import struct # 查看双精度0.1的完整十六进制字节表示 packed struct.pack(d, 0.1) print(packed.hex()) # 输出可能是 9a9999999999b93f小端序这里输出的字节顺序是小端序的从右往左读或者从反序读才是标准的十六进制数值0x3FB999999999999A。关于大小端的问题第6节会详细展开。搞懂了这个0.1是什么的问题浮点数精度问题的底子就扎实了。以后你做金额计算、做数据对比、做序列化传输都能提前预判误差发生的方向。4. 规格化数之外非规格化数、Infinity和NaN是怎么共存于同一个位模式的4.1 指数区全0和全1的含义IEEE 754的巧妙之处在于它把指数区间的两个极端值——全0和全1——预留出来用来表达普通数字之外的几种特殊情况。这也是为什么前面说单精度浮点数的规格化指数范围是-126到127而不是-127到128。具体规则如下指数区尾数区含义1到254任意规格化数00有符号的00和-00非0非规格化数极小值2550无穷大Inf和-Inf255非0NaN非数值这里有几个值得单独说的点。4.2 非规格化数越来越小却不归零的秘密当指数区全为0时如果尾数区不全为0这个数就不再使用隐藏位1的规则而是直接把它当作0.f × 2^(-126)。换句话说隐藏位从1变成了0这样可以表示比规格化数更小的数。举例来说规格化数能表示的最小正数大约是2^(-126)约等于1.18×10^(-38)。但非规格化数可以进一步表示到2^(-149)约等于1.4×10^(-45)。这个能力在科学计算中很重要因为很多算法在数值逼近时需要逐步逼近一个极小值却不直接归零。如果中间某个值被截断成0后续的除法或者取对数操作就会直接引爆。非规格化数用精度换来了量程。它的小数点后只有23位单精度或52位双精度的有效位精度比规格化数要低但可以让极小值的刻度不断细化而不是一步掉到零。这就是为什么你在C里跑一个逐渐减小的浮点数循环感觉它越来越慢还能蹦跶很久的原因——CPU处理非规格化数通常比规格化数慢几十倍。4.3 NaN和Infinity不仅不是Bug还是设计得非常好的错误处理机制很多人写代码时第一次算出一个nan或者inf第一反应是程序崩溃了。其实这是一种浮点运算的标准结果作用是让错误沿着计算链传导而不是在第一时间就抛出异常中断进程。先说Infinity无穷大当指数区全为1、尾数区全为0时这个数表示正负无穷大。什么场景会产生无穷大最典型的是浮点数除以0。在IEEE 754标准下1.0/0.0并不会报错而是得到Inf。这个设计非常实用如果你在一个循环里计算1/x而x碰巧有一次是0程序不会当场崩溃而是继续算下去直到最后你再检查结果里有没有Inf。这种延迟报错的模式在图形渲染、物理模拟这种大批量数值计算场景非常有用。再说NaNNot a Number当指数区全为1、尾数区不全为0时这个数就是NaN。产生NaN的典型场景包括0.0除以0.0注意和1.0/0.0的区别对负数开方math.sqrt(-1)计算0.0乘以Infinity两个Infinity相减IEEE 754还定义了NaN的载荷尾数区的高位可以区分quiet NaN静默NaN用于正常输出和signaling NaN信号NaN一旦参与运算就触发异常。这在数值计算库中常被用来追踪未初始化的变量。有一个特别容易踩的坑NaN不等于任何数包括它自己。判断一个double变量是否等于NaN用if (x x)比用if (x NaN)更靠谱因为唯一满足x ! x的浮点数就是NaN。很多刚学数值计算的人在这里会用错相等判断。提示在实际工程中永远不要用floatValue Float.NaN这种方式来判断NaN值。正确做法是用Float.isNaN()或者std::isnan()这类函数专门针对位模式做了检查。5. 手写一个浮点数转换器把任何数变成它在内存里的真实位串5.1 用C的联合体直观观察内存布局看再多的位分配表都不如亲手把一个浮点数的内存字节打印出来来得直观。在C/C里最方便的方法是使用union因为union的所有成员共享同一块内存。#include iostream #include bitset #include cstdint void printFloatBits(float f) { union { float f; uint32_t u; } converter; converter.f f; std::bitset32 bits(converter.u); std::cout 浮点数: f std::endl; std::cout 32位位串: bits std::endl; std::cout 符号位: bits[31] std::endl; // 指数区第23位到第30位 uint32_t exponent (converter.u 23) 0xFF; std::cout 指数区(存储值): exponent 实际指数: (int)exponent - 127 std::endl; // 尾数区第0位到第22位 uint32_t mantissa converter.u 0x7FFFFF; std::cout 尾数区(23位): std::bitset23(mantissa) std::endl; std::cout 十六进制: 0x std::hex converter.u std::dec std::endl; std::cout ------------------------------ std::endl; } int main() { printFloatBits(0.1f); printFloatBits(-2.5f); printFloatBits(3.14f); return 0; }编译运行后你会在控制台看到0.1f的完整位分解浮点数: 0.1 32位位串: 00111101110011001100110011001101 符号位: 0 指数区(存储值): 123 实际指数: -4 尾数区(23位): 10011001100110011001101 十六进制: 0x3dcccccd这和前面手算的结论完全一致。建议你照着这个代码在本地跑一下把1.0、2.5、3.14159、100.0这些常见值都打印出来看看能帮你建立非常直观的位串感。5.2 用Python验证双精度并比对大小端在Python里可以用struct模块完成类似工作。Python的float默认是双精度正好配合验证IEEE 754的双精度布局。import struct def inspect_double(x): # d表示double!表示网络字节序大端 # 这样打印出来的十六进制顺序正好是从最高位开始的标准顺序 big_endian_bytes struct.pack(!d, x) print(f数值: {x}) print(f大端字节十六进制: {big_endian_bytes.hex( )}) # 把大端字节解析成64位整数方便逐位查看 bits struct.unpack(!Q, big_endian_bytes)[0] sign (bits 63) 1 exponent (bits 52) 0x7FF mantissa bits 0xFFFFFFFFFFFFF print(f符号位: {sign}) print(f指数区(存储值): {exponent} 实际指数: {exponent - 1023}) print(f尾数区(52位): {mantissa:#014x}) print(---) inspect_double(0.1) inspect_double(1.0) inspect_double(-2.5)输出结果里0.1的双精度十六进制一定是0x3FB999999999999A。1.0的指数区是1023因为1.0 1.0 × 2⁰尾数区全为0。2.5 10.1₂ 1.01 × 2¹所以指数存储值是1024。这里提醒一个非常实用的坑如果你用struct.pack(d, 0.1)而不是struct.pack(!d, 0.1)得到的是本机字节序的字节串。在x86/x64平台上这就是小端序你会看到输出成9a9999999999b93f。这个十六进制看起来和标准的0x3FB999999999999A完全反过来不懂大小端的人很容易在这里懵掉。后面第6节我们会详细讲这个坑。5.3 浮点数计算器能帮你省下大量手工推导既然有热搜词提到了浮点数计算器在线那正好多说一句。手工做二进制小数的乘2取整做几次还行做多了特别容易出错。像我调试协议报文时经常需要把一个4字节的十六进制数据反解成浮点数比如收到0x41200000想知道它代表多少直接心算太费劲了。这种场景我建议直接用在线浮点数转换工具搜索IEEE 754 converter或者float hex to decimal或者写一个自己的工具函数。C里可以用memcpy把一个uint32_t转成floatPython里用struct.unpack(f, bytes)就行。比如在Python里一次性查看0.1f的精确十进制值import struct # 从一个32位位串还原出实际存储的十进制值 hex_str 3DCCCCCD # 0.1f 在内存中的十六进制 as_bytes bytes.fromhex(hex_str) actual_value struct.unpack(!f, as_bytes)[0] print(actual_value) # 输出0.10000000149011612这行输出清晰地告诉你你代码里的0.1f其实是0.10000000149011612。这个数平时打印会被格式化成0.1让你误以为它是精确的。只有到精度要求极高的场景误差才会浮出水面。6. 大端和小端同一个浮点数在不同机器上的字节顺序完全不同6.1 为什么会有大小端之争浮点数在内存里的位布局是国际标准IEEE 754规定的但字节序byte order却不是标准的一部分由CPU架构决定。大端模式Big-endian最高有效字节存储在最低内存地址。就像我们平时写数字从左到右从高位到低位。小端模式Little-endian最低有效字节存储在最低内存地址。就像英语里读数字先说低位的。x86和x64平台几乎全部使用小端模式。ARM架构两种都支持但绝大多数手机、嵌入式设备默认也是小端。网络传输TCP/IP协议栈统一使用大端。如果你把一个float从x86机器写入一个二进制文件再拿到ARM盒子上读取不处理字节序的话读出来的数字会变得非常荒谬——明明存的是1.5读出来可能是1.3255e-38这种毫无逻辑的值。6.2 用一个例子直观感受大小端的差异还是拿0.1f举例它的十六进制标准值是0x3DCCCCCD。在内存中按地址从低到高排列时字节序地址0地址1地址2地址3大端网络序3DCCCCCD小端x86本机序CDCCCC3D这里可能有人会问为什么小端模式下最低地址存的是CD而不是3D因为0x3DCCCCCD最高有效字节是3D最低有效字节是CD。小端就是低字节在低地址所以第一个字节就是CD。在调试串口协议、CAN总线报文、存储文件格式时你经常要面对这种字节倒序的导入导出。最稳妥的做法是在代码里显式指定转换函数不要依赖平台默认行为。下面是一个不依赖大小的转换示例C#include cstdint #include cstring #include iostream // 把字节数组按大端序解成float float bytesToFloatBigEndian(const uint8_t* bytes) { uint32_t raw (uint32_t)bytes[0] 24 | (uint32_t)bytes[1] 16 | (uint32_t)bytes[2] 8 | (uint32_t)bytes[3]; float result; std::memcpy(result, raw, sizeof(result)); return result; } // 把float按大端序写入字节数组 void floatToBytesBigEndian(float f, uint8_t* out) { uint32_t raw; std::memcpy(raw, f, sizeof(raw)); out[0] (raw 24) 0xFF; out[1] (raw 16) 0xFF; out[2] (raw 8) 0xFF; out[3] raw 0xFF; }核心思路是先把float的所有位搬到uint32_t里然后再按字节序手动拼装。这样代码在任何架构上跑出来结果都一样规避了平台相关的大小端差异。注意在C语言里最好别用强制指针转换按字节读取的方式来转换大小端比如((uint8_t*)f)[0]。虽然常见但会受平台字节序影响形成隐式依赖。用memcpy把一个整数原样复制到float变量里才是标准且安全的方式。C20之后还可以用std::bit_cast更简洁也更不容易出错。6.3 协议通信中大小端处理不当的真实事故我之前调试一个传感器协议时踩过类似的坑。传感器返回4字节报文手册上写的是十六进制0x41200000说这是一个浮点数代表10.0因为10.0 1.25 × 2³二进制存储的十六进制正好是0x41200000。我第一次按大端解0x41200000→ float 10.0正确。第二次换了一个设备同样的报文按同一个函数解析结果输出的数变成1.396e-38这种怪异值。查了一圈发现这个设备的固件在填充缓冲区时用了本机字节序直接拷贝所以发出来的字节顺序变成了00 00 20 41。按大端解析就把字节序搞反了。解决的办法有两个方向第一让嵌入式固件侧保证按大端发送第二在应用层做大小端自适应。我后来在代码里加了一个检测先读报文头部的几字节已知标识符如果标识符能正常解开就用对应字节序否则切换另一种再试一次。这个方法不算优雅但对接未知来源的报文时确实很管用。7. 浮点数存储规则带来的三个实战陷阱7.1 不要用比较浮点数误差累积不是特例而是常态很多人背过这条规则“浮点数不能用比较”但真正理解为什么的人不多。请看这个经典例子float sum 0.0f; for (int i 0; i 1000; i) { sum 0.001f; } // 你期望结果是1.0但实际呢 std::cout sum std::endl; // 输出0.99999094为什么会有这么大的误差原因是0.001f这个数本身就不是精确的循环累加1000次每次的舍入误差都在累积最终相差将近9×10⁻⁶。这个误差在显示和简单判断时可能看不出来但如果你拿它去判断sum 1.0f程序就会在错误的分支里运行。正确的比较方法是引入一个容差epsilonbool nearlyEqual(float a, float b, float epsilon 1e-6f) { return fabs(a - b) epsilon; }但这样还不够因为浮点数在数值很大的时候它本身能表示的粒度也变大了。比如单精度float在2亿附近时相邻可表示的浮点数之间的差距约为16。这时用1e-6的容差判断两个数相等永远会失败。更科学的做法是用相对容差bool nearlyEqualRelative(float a, float b, float relEps 1e-6f) { float diff fabs(a - b); float maxVal fmax(fabs(a), fabs(b)); return diff relEps * maxVal; }这个函数可以处理从0.0001到1000000这样跨量级的比较需求。7.2 金额计算不要用浮点数数据库decimal才是正解听到这里你应该明白了浮点数适合表示物理量、传感器读数、工程计算因为那些场景本来就有测量误差浮点数的精度足够用。但金额不是物理量金额是精确到分的整数语义绝不能引入近似误差。一个很常见的反面教材是用double存订单金额算完乘法再传回数据库# 错误做法 price 19.99 quantity 3 total price * quantity # total 59.96999999999999然后被格式化成59.97存进数据库这个看起来只差0.00000000000001的误差在批量结算、财务报表里会因为四舍五入的边界值问题导致对不上账。累积的差值可能在月底对账时变成一个不小的窟窿。安全的做法有这几种使用整数类型int/long存储最小货币单位比如分计算全都在整数空间完成。使用数据库提供的DECIMAL/NUMERIC类型它内部不依赖二进制浮点数而是用BCD或者十进制整数排列存储能精确表示每个十进制小数。如果是JAVA语言使用BigDecimal但注意传入构造函数时要用字符串而不是double。7.3 浮点数序列化与跨语言传输读与写必须使用同一套规则很多微服务项目里A服务用Java把float写进JSONB服务用Go读出来中间再经过一个消息队列缓存以为只要两边的字段名对得上就没问题。等数据到了下游突然出现一个诡异的值排查到最后发现是中间某个环节把浮点数做了二进制转换或截断处理。跨语言传递浮点数时必须明确下面几件事使用JSON、MessagePack、Protobuf这类标准化格式时数字在传输过程中通常保持十进制字符串或变长编码一般不涉及内部二进制布局。问题是接收方解析后用什么类型接收是三精度还是双精度可能造成有效位截断。如果自定义二进制协议必须在文档里明确字节序大端还是小端、精度float还是double、字段对齐方式。以及是否使用固定偏移、是否有尾数截断规则。最稳妥的方式是所有涉及金额或高精度计算的数据用字符串或整数传输所有物理传感器数据统一double并在接收端做好范围校验。8. 用实际调试思路解决浮点精度问题一份可直接复用的排查清单把前面几节的内容落到实际工作中我总结了一份排查浮点相关问题的检查清单。每次遇到算出来的数和预期差了一点点的问题从下往上逐项排查基本都能定位。层级检查项说明数据源输入值是什么类型从JSON/数据库读出来时是否发生过精度截断有些语言JSON解析器默认把数字全部存成double大整数会丢精度计算过程是否进行了大量累加运算能否把累加改为分治求和Kahan求和算法可有效降低累加误差比较逻辑是否用了比较浮点数是否设置了过小或过大的容差需要用相对容差类型转换是否在float和double之间做过隐式转换低精度转高精度不会加分高精度转低精度会截断存储与传输是否通过自定义协议传递了二进制浮点数字节序是否一致检查大小端和字段宽度最终展示格式化输出时使用的精度格式是什么不该用默认精度应该显式指定输出的小数位数针对累加运算这里给一个相对简单的优化方法Kahan求和算法。它的核心思想是维护一个补偿变量把每次舍入丢失的误差记录下来在下一步加回去这样可以显著减少长序列累加的误差。float kahanSum(const float* data, int n) { float sum 0.0f; float c 0.0f; // 补偿值 for (int i 0; i n; i) { float y data[i] - c; // 用上一次的误差修正本次输入 float t sum y; c (t - sum) - y; // 计算这次加法丢失的位数 sum t; } return sum; }这个算法在1000次累加场景下能把结果从0.99999094修正到非常接近1.0代码量也小适合在嵌入式系统和实时计算中直接使用。另一个实战中很实用的思路是让数值保持在同一量级。比如同样累加1000个数如果前面999个数都是1.0最后一个数是1e-8那么最后一个数加进去后因为浮点数的尾数精度不够它很可能会被吞掉。如果你先对所有数排序从小到大累加较小数先累积出足够的量级最终精度通常比从大到小好很多。9. 一个值得掌握的扩展如何手算一个浮点数的精确十进制值最后分享一个我自己特别喜欢做的小练习——从位串反推出这个浮点数到底代表的是哪个精确十进制数。这个练习做完之后你对浮点数是近似值这句话的理解会彻底改观。以单精度0x3DCCCCCD为例完整的还原步骤是写成二进制位串0 01111011 10011001100110011001101符号位S0是正数。指数区123实际指数E123-127-4。尾数区后23位转为小数尾数 1 0.10011001100110011001101₂别忘隐藏位。精确值 1.10011001100110011001101₂ × 2⁻⁴。把这个二进制数展开成十进制把尾数的各位二进制位按权重相加可以得到1 2⁻¹ 2⁻⁴ 2⁻⁵ 2⁻⁸ 2⁻⁹ 2⁻¹² 2⁻¹³ 2⁻¹⁶ 2⁻¹⁷ 2⁻²⁰ 2⁻²¹ 2⁻²³注意进位后的最后一位再乘以2⁻⁴最终结果是0.100000001490116119384765625。对比一下你代码里写的0.1和实际存储在内存里的0.100000001490116119384765625如果只打印6位小数谁也看不出区别。但如果做高精度计算这个差值就会通过乘法被放大产生肉眼可见的偏差。这就是浮点数存储问题的真相——不是计算机算错了是它在按自己的数值刻度表工作而我们要求它处理的是另一套刻度体系里的数。理解了这个底层机制后再遇到float加出精密误差内存里看到一个奇怪的十六进制值跨平台解析浮点数结果不一致这类问题你就能直接穿透表象找到根因了。
返回列表