ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

定点数与浮点数:从IEEE 754到实战精度陷阱全解析

定点数与浮点数:从IEEE 754到实战精度陷阱全解析 “1.4 定点数和浮点数”这节内容很多人第一次看教材时觉得就是讲计算机怎么表示小数没什么特别的。直到我在项目里踩了0.1加0.2不等于0.3的坑用LabVIEW从Modbus报文里读温度读出一长串看起来完全不对劲的数字在C里对浮点数取余被结果吓一跳我才意识到这节基础课根本不是考试题那么简单它是从底层芯片到上层业务迟早要还的技术债。这篇文章就把这节基础课彻底讲透定点数到底还在哪些地方用IEEE 754浮点数用什么思路工作以及工程师最常遇到的各种精度、字节序、特殊值问题怎么排查。适合需要和底层寄存器打交道的嵌入式开发写C和Python做数据处理的同学以及所有被浮点数坑过、想搞明白根本原因的人。1. 定点数和浮点数到底在解决什么问题很多概念听起来抽象其实核心问题就一个计算机里只有0和1怎么用有限的bit把小数存下来。定点数和浮点数给出了两条完全不同的路线。1.1 从十进制小数转为二进制说起先别急着看定义我们拿标题里出现的1.4来走一遍二进制转换。整数部分1二进制就是1。小数部分0.4呢二进制小数位权重依次是1/2、1/4、1/8、1/16……所以0.4转为二进制的过程是反复乘2取整0.4 × 2 0.8取整数0小数部分是0.80.8 × 2 1.6取整数1小数部分是0.60.6 × 2 1.2取整数1小数部分是0.20.2 × 2 0.4取整数0小数部分是0.40.4 × 2 0.8取整数0又回到了第一步看到没有0.4的二进制小数是0.011001100110……无限循环。也就是说1.4这个在十进制里很“规整”的数在二进制里根本无法用有限位表示出来。这不是计算机偷懒是数学上就注定的二进制只能表示分母是2的幂的数而0.4的分母含5这个因子永远除不尽。这一点是所有浮点数精度问题的总根源。你写的每个十进制小数绝大多数在转成二进制的那一刻就已经带上了误差后续所有运算只是在这个误差上继续累积。1.2 定点数把小数的位置焊死定点数的思路非常直白我不让小数点乱跑从最开始就规定好32个bit里面多少位给整数部分多少位给小数部分。小数点位置永远固定。比如我们用1个符号位、3位整数、4位小数来存1.4整数的4位是0001小数的0.4只能用4位去逼近0110就是1/41/80.3750111是1/41/81/160.4375。所以1.4会存成0001.0110或者0001.0111分别对应1.375和1.4375。就这么粗。工业界为了统一描述会用Q格式。Q15表示16位有符号定点数所有数值都要乘以2^15再存成整数。1.4转成Q15就是 round(1.4 × 32768) round(45875.2) 45875。反过来45875 / 32768 1.39999……误差约万分之0.6。你可能会问这种精度不高的东西现在还有人用吗当然有而且很常见没有浮点单元的MCU和DSP比如很多8位单片机、老式音频DSP做滤波、PID控制时全靠定点数FPGA内部想高效做数值运算浮点资源太贵定点IP核才是主流银行核心系统算金额时很多会直接用十进制定点数或者用整数表示“分”就是为了绝对可控通信协议里的坐标、增益、时间戳小数部分为了跨平台一致性也经常用定点格式定点数的核心优势是确定性和速度。同一个Q15数值在不同编译器、不同CPU上算出来的bit结果完全一致加减乘都是整数运算速度快、功耗低没有舍入模式那套复杂规则。缺点是所有人都要自己管范围、自己管溢出稍不注意就可能出现巨大的数值错误。1.3 浮点数让小数点动态起来浮点数的思路是另一条路既然小数点固定位置会严重限制范围那我干脆把小数点放到“该在的地方”用科学计数法来记录。1.4写成科学计数法就是1.4 × 10^0二进制里则是1.011001100110…… × 2^0。这个2的几次方就是指数而1.0110……里的那些bit就是尾数。存储一个浮点数时只需要记三样东西符号正负、指数小数点在哪儿、尾数有效数字是什么。指数变了小数点就跟着移动所以叫“浮点”。浮点数的好处是动态范围极大同一个格式既能表示1.4×10^-38这种极小的数也能表示3.4×10^38这种极大的数不需要像定点数那样提前猜数值范围。代价就是精度有限而且因为二进制和十进制之间的天然差异很多十进制小数存进去就已经带误差了。2. IEEE 754 标准一个全球统一的口径早期各家计算机厂商浮点格式五花八门同样的数据在不同机器上解释出来完全不一样后来IEEE 754标准统一了格式现在几乎所有现代CPU、编程语言、通信协议都遵循它。2.1 三种常用格式先看最常用的三种格式网上搜“浮点数计算器在线”基本也都是围绕它们做的格式总位数符号位指数位尾数位指数偏移十进制有效数字最大有限值半精度 binary1616151015约3位65504单精度 binary32321823127约7位约3.4×10^38双精度 binary6464111521023约15~17位约1.8×10^308单精度float是绝大多数场景的默认选择C语言里的float、Python里的numpy.float32、LabVIEW里的SGL都是binary32。双精度double是科学计算的常客Python默认的float其实就是双精度。半精度fp16以前用得少但深度学习兴起后它在神经网络训练和推理里非常吃香后面实战部分我再细说。2.2 规格化数与隐藏位IEEE 754有个很聪明的设计规格化normalized表示。它要求所有正常数值的尾数都写成“1.xxxxx × 2^指数”的形式也就是说尾数的整数部分永远是1。既然这个1永远存在那干脆就不存了只存小数点后面的那些bit省出来的1位等于白赚了精度。拿单精度举例尾数位是23位但因为有这个隐藏位实际能表示24位有效二进制数字。这就是为什么float的十进制有效数字大概在7位左右而不是6位。指数部分同样有讲究。单精度指数的取值范围是8位但需要用一部分来记负数指数所以采用偏移bias存储真实指数 存储值 - 127。比如指数为0时存储值是127。这个设计让正负指数都能有序比较而且直接按无符号整数比较浮点数大小也基本可行。双精度的偏移是1023半精度是15。同时指数位全1和全0有特殊含义。全1表示无穷大或NaN全0表示0或者非规格化数subnormal。非规格化数专门用来处理那些指数已经小到最小正常值的数让数值能渐进式地下溢到0而不是直接跳成0。代价是非规格化数的精度很低计算速度也会慢一些。2.3 手动把一个数转成IEEE 754位模式理论说再多不如亲手算一次。我们继续用1.4完整走一遍单精度的转换流程。第一步判断符号。1.4是正数符号位S0。第二步把1.4写成规格化二进制形式。前面算过1.4的二进制是1.01100110011001100110011……因为它落在[1,2)区间所以指数E 0。第三步计算指数编码 E 127 0 127 127转成二进制就是01111111。第四步取尾数。规格化后隐藏位是1我们只需要存后面的01100110011001100110011……但只有23位所以要截断或舍入。1.4的尾数是011001100110011001100110……无限循环取前24位看看第24位是什么来决定舍入。实际算下来23位尾数存的是01100110011001100110011。最后把三部分拼起来 0 01111111 01100110011001100110011十六进制就是0x3FB33333。你可以用Python的struct模块验证一下import struct # 把1.4打包成单精度浮点再转成hex print(hex(struct.unpack(I, struct.pack(f, 1.4))[0])) # 输出: 0x3fb33333再算一个负的-2.75。符号位S1。2.75的二进制是10.11规格化后是1.011 × 2^1所以指数E1指数编码 1 127 128 10000000。尾数部分存01100000000000000000000。最终位模式是1 10000000 01100000000000000000000十六进制0xC0300000。这个结果同样可以验证。3. 实操定点数怎么写、浮点数怎么算光会看定义不够真正干活的时候要能写代码、会用工具。这一节给的都是可以直接抄的。3.1 用Q格式做定点数转换嵌入式里最常用的场景是把浮点传感器值转成定点数传给DSP或者下位机。Q15转换在C语言里长这样#include stdint.h #include math.h #define Q15_SCALE 32768.0f // float转Q15定点 int16_t float_to_q15(float x) { // 饱和处理防止溢出 if (x 1.0f) return INT16_MAX; if (x -1.0f) return INT16_MIN; return (int16_t)roundf(x * Q15_SCALE); } // Q15定点转float float q15_to_float(int16_t x) { return (float)x / Q15_SCALE; }这里有两个细节很多人第一次会踩。第一转换一定要做饱和处理尤其从传感器读到的数据可能有异常毛刺直接强转会导致数值翻转比如原本接近1的值可能会变成-1附近的数后面整个控制逻辑就乱了。第二float转int16要记得用roundf而不是直接截断否则1.4会变成1.39996而不是更接近的1.39999虽然差别不大但做积分累加时误差会滚雪球。定点数乘法更是经典陷阱。两个Q15数相乘结果的整数范围理论上是Q3015位小数乘15位小数得到30位小数不能直接赋给Q15变量。正确做法是// Q15乘法a * b结果仍是Q15 int16_t q15_mul(int16_t a, int16_t b) { int32_t tmp (int32_t)a * (int32_t)b; // Q30 // 右移15位回到Q15加上0x4000做四舍五入 return (int16_t)((tmp 0x4000) 15); }那为什么先转成int32再移位因为两个16位数相乘可能得到32位结果如果不提升类型编译器可能会用16位乘法截断结果完全错误。加上0x4000即2^14是四舍五入让结果更接近真实值。这类细节就是定点数代码里最常见也最不容易排查的问题。如果不想手写这些嵌入式里也有很多现成的定点数学库比如CMSIS-DSP里的Q15、Q31函数FPGA里拟合直接用定点IP核但理解了上面这些基础用起来才能心里有底。3.2 在线工具与调试技巧排查浮点问题时手边有个靠谱的位模式查看工具能省一半时间。网上搜“浮点数计算器在线”“浮点数16位工具”能找到不少把十进制输入转成十六进制位模式的页面也可以反过来输入hex看它表示的浮点数是多少。这类工具对确认Modbus报文、解析文件格式的用户数据非常有用。更实用的其实是直接用代码现场看。Pythonimport struct def float_to_hex(f): # 单精度 return hex(struct.unpack(I, struct.pack(f, f))[0]) def hex_to_float(h): # 解析单精度hex return struct.unpack(f, struct.pack(I, int(h, 16)))[0] print(float_to_hex(1.4)) # 0x3fb33333 print(hex_to_float(3fb33333)) # 1.399999976158142用struct打包时表示小端字节序f表示单精度浮点。如果你想看双精度1.4的位模式把f换成d即可。Julia里更简单直接用bits(1.4f0)就能看到完整的二进制字符串对学习和排查都特别直观。3.3 浮点四则运算到底发生了什么很多时候我们看到浮点数计算结果“不对”是因为不知道背后有几道工序。以加法为例两个浮点数相加要经过四步对阶把指数小的那个数尾数右移让两个数的指数对齐尾数相加在同一个指数下做整数加法规格化如果结果不是1.xxx的形式就要调整指数并移位尾数舍入尾数位超出存储范围时按照舍入规则处理多余位0.1 0.2为什么不等于0.3因为0.1和0.2在二进制里都不是精确值它们各自经过舍入后已经带上了微小误差。相加以后这个误差经过对阶、舍入最终得到的最近可表示数是0.30000000000000004而不是0.3。C语言和Python里输出来都是这个数不是某个语言特有的bug。乘法和加法思路类似但更直接指数相加尾数相乘然后同样要规格化和舍入。这里要特别注意指数相加后可能溢出一旦超范围就变成Inf。舍入方式默认是“舍入到最近偶数”round to nearest evenRNE。为什么不用简单的四舍五入因为四舍五入在统计上会带来系统性偏差而偶数舍入在大量计算中偏差能互相抵消。这个决策也是有理论和工程实践支撑的。4. 实战场景从通信报文到跨语言精度理论掌握的标志是能解决实际问题。下面这几个场景都是我实际碰过的热搜词里也恰好都有它们的身影。4.1 LabVIEW 读取 Modbus 报文中的 4 字节浮点数工业现场最常见的需求PLC通过Modbus协议把温度、压力、频率等参数发给上位机上位机用LabVIEW读取。Modbus寄存器是16位一个所以一个32位浮点数占了两个寄存器这里就会出现两类经典问题字节序和寄存器顺序。先说我踩过的坑。现场传感器返回的温度报文一直是0x3FB33333对应的字节流看起来明明是1.4但LabVIEW读出来却变成了一个巨大的数。后来排查才发现Modbus的不同设备对寄存器字的发送顺序不一样有的是高字在前、低字在后有的是反过来甚至还有字内字节也反过来的情况。IEEE 754的4字节在内存里常见顺序有这几种寄存器组合内存字节顺序大端数据流常见场景ABCD大端3F B3 33 33标准大端ModbusCDAB高字低字交换33 33 3F B3部分国产仪表BADC字内字节交换B3 3F 33 33部分协议网关DCBA小端33 33 B3 3F小端单片机写入在LabVIEW里处理时我建议直接使用Unflatten From String函数指定数据类型为SingleSGL字节序根据硬件手册选Big Endian或Little Endian。如果读出来不对就用Type Cast的方式手动拼接先把两个寄存器的高低位组合成U32再Type Cast成SGL。验证方法是拿已知的1.4去对比单精度1.4的hex是0x3FB33333如果报文顺序和预期不一致马上就能发现。用Python做同样的事情会直观很多import struct # 假设Modbus读到的两个寄存器值是 0x3FB3 和 0x3333 # 高字在前0x3FB33333 val1 struct.unpack(f, bytes([0x3F, 0xB3, 0x33, 0x33]))[0] print(val1) # 1.399999976158142 # 低字在前0x33333FB3 val2 struct.unpack(f, bytes([0x33, 0x33, 0x3F, 0xB3]))[0] print(val2) # 一个完全不同的数所以在写任何数据解析代码前第一件事是翻设备手册确认字节序而不是盲目套模板。这能省掉大量现场调试时间。4.2 C 中浮点数相除的余数C里整数取余用%但浮点数没有定义%运算要用fmod或者remainder。这个区别很关键。fmod(a, b)的余数符号和a一致结果等于a - trunc(a/b)×b也就是向零取整后的余数。remainder(a, b)则按照IEEE 754规则余数是a - round(a/b)×b用的是最近整数。举例来说#include cmath #include cstdio int main() { double a 5.3; double b 0.1; printf(fmod(5.3, 0.1) %.20f\n, fmod(a, b)); // 非0 printf(remainder(5.3, 0.1) %.20f\n, remainder(a, b)); // 非0 return 0; }理论上5.3除以0.1应该是53余0但实际结果你会发现两个函数的输出都不是0。原因还是那个老问题5.3和0.1在二进制里都是近视值存进去的那一刻精度就丢了计算机实际上在做的是“近视值除以近视值”自然得不到精确0。如果只是想判断一个数是否能被另一个数整除正确做法是设定一个容差double a 5.3, b 0.1; double r fabs(fmod(a, b)); if (r 1e-9 || fabs(r - b) 1e-9) { printf(可以被整除\n); }注意我判断了两种情况因为浮点余数可能非常接近0也可能非常接近除数本身这取决于舍入方向。这种细节只有被坑过才知道写代码时要覆盖。4.3 Julia 的高精度浮点数有时候默认的双精度不够用。比如做数值验证时发现两个算法的结果在小数点后10位左右发生分歧你要判断到底哪个对就得用更高精度的计算来做参照。Julia在这方面做得非常顺手BigFloat类型可以动态调整精度。setprecision(256) do x BigFloat(1.4) y BigFloat(0.7) println(x * y) # 1.39999999999999999999999999999999999999999999999999999999999999999999999999999? end注意我用了BigFloat(1.4)字符串构造会按十进制精确解析。如果写BigFloat(1.4)那这个1.4是从Float64转过来的一开始就带上了Float64的二进制误差后面精度再高也救不回来。这是使用高精度浮点最常见的大坑。Julia还提供了Rational类型就是分数。1.4可以表示成7/5这种精确形式。如果业务逻辑只需要加减乘除且数值都是有理数用Rational从根本上绕开浮点误差。缺点也很明显分母一大计算开销和内存占用会急剧增长。所以实际工程里BigFloat通常用在验证算法、金融计算、科学计算的关键结算环节而不是所有循环都塞进去。4.4 16位半精度为什么最近这么火半精度fp16原本是图形学里的“小配角”现在却是深度学习的明星。原因很直白FP16只占2字节相比FP32的4字节显存占用直接减半内存带宽占用也减半训练和推理的速度都能明显提升。但FP16的代价是有效数字只有约3位十进制最大只能到65504比FP32的范围小太多了。于是出现了混合精度训练模型的权重副本用FP32甚至FP64保存前向和反向计算用FP16加速优化器更新时回到FP32。如果不做任何保护FP16的梯度很容易因为太小而直接变成0所以有了loss scaling技术在反向传播前把损失放大一定倍数让梯度落在FP16能表示的范围里。这个系数也不是越大越好太大会导致梯度溢出变成Inf。那普通工程师遇到FP16的机会在哪一是模型推理很多边缘设备为了省内存会直接导出FP16权重二是如果你用C写音频算法、图像处理有些SIMD指令也支持半精度处理大数组能省带宽。基本原则是数据范围能控制住、精度损失能接受的场景才适合FP16否则就用FP32。5. 常见问题与排查技巧实录最后把实战里最容易出事的几个场景集中盘一遍每个都是我自己或同事真实踩过的。5.1 0.10.2不等于0.3原因与比较策略这个问题太经典了经典到有点“烂大街”但它背后的处理思路值得认真对待。直接写一行代码看看#include cstdio int main() { double a 0.1 0.2; printf(%.17f\n, a); // 0.30000000000000004 }原因前面已经讲透二进制表示误差 舍入规则。比较浮点数时绝对不能直接写if (a b)除非确保两个值的来源路径完全相同。工程上推荐的做法是给一个容差bool almost_equal(double a, double b, double epsilon) { return fabs(a - b) epsilon; }但这个epsilon选多大很有讲究。固定1e-6在某些场景合适在某些场景会出问题。如果你在算天文级别的数值两个数本身都到1e20量级了浮点分辨率已经大于1e4这时候还用1e-6当阈值永远都不会相等。相反如果是小额度的价格计算1e-6又太宽松。更稳妥的方式是用相对误差bool almost_equal_rel(double a, double b, double rel_eps) { double diff fabs(a - b); double scale fmax(fabs(a), fabs(b)); return diff rel_eps * scale; }根据我的经验绝大多数情况下绝对误差和相对误差结合判断会比单独用其中一个更可靠。5.2 大数加小数丢失精度这个坑比0.10.2隐蔽得多尤其做累加、统计时很容易翻车。#include cstdio int main() { float x 16777216.0f; // 2^24 float y x 1.0f; printf(%.1f\n, y); // 还是16777216.0 }为什么因为单精度浮点只有24位有效二进制位含隐藏位能精确表示的整数范围就到2^24。超过这个范围后整数之间的最小间隔变成2所以加1相当于没加。如果你的计费系统或者计数器用float存整数累加超过16777216后就开始“吞”小数值这是非常严重的bug。对策有几个一是用双精度双精度能精确表示到2^53绝大部分整数累加场景够用二是如果必须用float就不要让一个累加器跨越比较大的范围而是分段累加后合并三是用Kahan求和算法把小数误差累积到单独的补偿变量里double kahan_sum(const double* arr, int n) { double sum 0.0; double c 0.0; // 补偿值 for (int i 0; i n; i) { double y arr[i] - c; double t sum y; c (t - sum) - y; sum t; } return sum; }这个算法在大量同数量级小数累加时效果显著误差可以从O(n)级别降到几乎为常数级别。代码看起来有点绕核心思想就是每次相加后把丢失的低位误差记录下来下次加回去。5.3 指数溢出与特殊值Inf、NaN和-0.0写数值程序时最怕看到Inf和NaN出现。它们的成因和排查需要区分清楚。Inf主要来自指数溢出比如FP32下1e38 × 10直接变成Inf或者非零数除以0时得到Inf注意整数除以0是直接崩溃浮点则是返回Inf这是两种完全不同的行为。NaN则来自0/0、Inf-Inf、负数开平方这类没有数学意义的运算。实战中NaN特别麻烦因为任何数和NaN比较都是false包括NaN NaN所以一个NaN如果在计算链里传播会把后面所有结果都污染成NaN而且很难定位源头。排查手段是用std::isnan()或isnan()函数挨个环节检查或者在关键位置打印中间值。还有一点有人会用x ! x来判断x是不是NaN这在大多数编译器上是可行的但标准库的isnan语义更明确建议优先用isnan。还有一个容易被忽略的-0.0。1.0 / -0.0 -Inf而1.0 / 0.0 Inf两者不同。处理传感器数据、做符号相关分析时负零可能会带来莫名其妙的符号问题。判断一个值是否为零时建议用x 0.0而不是x -0.0因为后者永远为false。更好的是用fabs(x) epsilon。5.4 字节序、类型和位模式的关系最后把类型和字节序混在一起说因为这是读取外部数据时最容易翻车的地方。同样四个字节3F B3 33 33用SGL解释是1.4用int32解释是1069547315用无符号16位拆开解释就成了16307和13107。所以当你从文件、网络、Modbus报文里拿到一堆字节时光知道“它是4字节”远远不够还必须知道它到底是什么类型、什么字节序、什么编码标准。这就是为什么我前面反复强调先看手册、先确认格式。排查流程我一般这样走先确认数据源的类型声明是float、int还是uint再确认字节序大端还是小端字段顺序是否交换然后用已知值做交叉验证。比如往PLC里写一个1.4读回来把它转成hex和0x3FB33333对比。如果对不上就按前面表格里的四种顺序逐个试。这个方法虽然笨但是最可靠。千万不要凭感觉猜我在现场就见过因为字节序搞错把1.4读成5.6×10^-24这种离奇数的例子。另一个常见错误是直接用memcpy或者类型强转去把字节数组解释成浮点数这在有些旧代码里会遇见。C20里推荐用std::bit_cast它明确告诉编译器和读代码的人我要做的是类型重新解释而不是数值转换。写出来的代码既安全又清楚。我对这一节内容最深的体会是定点数和浮点数不是两个互相替代的选项而是两套设计哲学。定点数把小数点焊死换来确定性和速度浮点数把小数点放走换来范围和灵活性但你必须接受它的误差。当年理解1.4的IEEE 754表示后再去排查Modbus的浮点数据、去看C的fmod结果、去调半精度训练的loss scale都顺畅了很多。建议每个做嵌入式、做C或Python数据处理、做数值计算的读者都亲手算一遍1.4的二进制表示再在调试器里看一眼它的hex。你迟早会因为花过这半小时少熬好几个夜。
返回列表