尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

定点数与浮点数深入解析:原理、转换与嵌入式实战

定点数与浮点数深入解析:原理、转换与嵌入式实战 做嵌入式这些年浮点数和定点数这两个词我是真真切切被项目教育明白的。头一回踩坑是在一个电机控制项目里MCU不带FPU用float跑PID一算脉冲周期就超时后来全部换成Q15定点运算主频没动运行时间直接缩到原来的三分之一。另一头调试设备通信时又撞上更尴尬的事下位机按协议发的是定点数上位机软件里要的是浮点数两边对不上我抱着一堆十六进制数挨个对位折腾了一整天才发现是两端的定标没对齐。这篇文章就把这些年攒下的理解、转换方法和踩过的坑一次性写清楚给同样在嵌入式、自动化、机器人领域打滚的朋友做个参考。你如果刚接触这个概念看完应该能直接上手如果是老手也可以跳着看看转换代码和工业场景那两节也许能对上你正在头疼的问题。1. 先把概念掰开揉碎定点数和浮点数到底在解决什么问题1.1 小数点位置固定与否就是两者的分水岭很多人一听到定点数浮点数就觉得高深其实两者的差别一句话就能说清小数点能不能动。定点数就是小数点固定在某个位置比如你约定一个16位数高8位是整数部分低8位是小数部分1.5就存成1.5×256384这个整数而浮点数的小数点是可以漂的它内部用类似科学计数法的方式存储1.5可能是1.1乘以2的0次方这种形式。这个差异看起来不大但直接决定了两种数据类型在不同场景下的命运。打个比方定点数像一把固定刻度的尺子最小刻度定了就不能变你量1.5米和量1000米精度都是一样的绝对值误差浮点数则像一把能自己调整量程的尺子量小数值时自动把刻度调细量数值大时把刻度调粗所以它既能表示很大的数也能表示很小很小的数但每个量程内的绝对精度是不一样的。定点数适合那些我知道数据的范围大概在哪的场景浮点数适合数据范围变化很大我不能预先定标的场景。从计算机底层的角度看定点数存储的就是一个普通整数只是打印、运算时你心里要惦记着它的定标系数浮点数则严格按照IEEE 754标准来组织符号位、指数位、尾数位各占多少bit都有明确规定硬件FPU和编译器都认识它所以在高级语言里用起来非常顺手。难就难在顺手并不等于免费它背后是有代价的这个我们后面详细展开。1.2 没有FPU的年代定点数为什么能扛起半边天现在的单片机很多都带硬件浮点单元FPUARM Cortex-M4/M7、RISC-V的不少内核甚至一些低成本的国产MCU都有。但在十年前甚至更早8位51单片机、16位MCU、早期的ARM7TDMI这些芯片都是没有硬件浮点指令的。如果你在那种环境里写float运算编译器会帮你链接一个软件浮点库把一个a b变成好几十条甚至上百条指令的调用每次运算都意味着一大堆栈操作和位运算。我调试过一个用STM32F103做无刷电机FOC控制的案子那个芯片是Cortex-M3内核不带FPU。初期算法全部用float写电流环跑10kHz主循环里还要叠加位置环、速度环和通讯任务一上电CPU负载就飙到90%以上中断稍微抖一下就失控。后来把电流环、速度环内部全部改成Q15和Q24混合的定点运算同一个芯片CPU占用直接降下来了控制周期也稳了。这个项目让我彻底明白了定点数在实时控制里的价值它的运算就是整数加法和乘法一条指令搞定时间开销可预估行为完全确定没有浮点库那些不确定的延迟。即便到现在FPGA、PLC、DSP这些场合依然大量使用定点数不是大家不会用浮点而是定点数在成本、实时性、可复现性上的优势太明显。理解了这一点你就知道为什么定点数转浮点数不是一个面试题专属概念而是工业现场天天要面对的现实需求。2. 定点数的核心玩法Q格式与运算中的隐形炸弹2.1 Q格式定标给小数找一个固定住处定点数不像浮点数那样有统一的国际标准它全靠定标来约定小数点的位置。工程上最常见的定标方式是Q格式记作Qm.n其中m表示整数部分的位数含符号位n表示小数部分的位数。比如Q15意思是1位符号加15位小数可以表示的数值范围大约是-1到0.999969482421875精度为2的-15次方约等于0.000030517578125。Q31就是1位符号加31位小数常用于32位定点DSP。举个具体的例子你想把3.25存成Q15格式的定点数就用3.25乘以2的15次方得到3.25×32768106496这个整数就是3.25的定点表示。反过来拿到定点数106496要还原成真实值就除以32768得到3.25。就这么简单本质上是给小数做了一次缩放把小数运算变成整数运算等算完再用缩放系数换算回来。选Q格式有个原则先估算信号的最大范围保证整数部分够用然后再尽量多留小数位来保证精度。比如一个电压采样信号在-10V到10V之间你用Q3.12表示3位整数位最多能表示到7.999不够如果用Q4.12能表示到15.999够了精度是2的-12次方约0.000244。而如果你用Q0.15来表示最大值只有0.9999直接溢出。定标不是随便选的必须对着信号的物理范围做一遍纸面计算这一步偷懒后面调试必炸。2.2 定点运算最容易翻车的三个地方定点数的加减乘除并不是简单拿整数运算完就结束了有几个坑我敢说每个写过定点代码的人都踩过。第一个是加法/减法的溢出。两个Q15数相加结果可能超过32767或者小于-32768。比如Q15能表示的最大值是0.999969两个0.9相加是1.8超出范围了。所以定点加法要么预留整数位把结果放在Q14或者Q13里要么做饱和处理saturate超过上限就钳到上限低于下限就钳到下限。饱和处理在控制算法里尤其重要不然一个过冲就可能让整个系统状态翻车。第二个是多乘法的定标变化。两个Q15数相乘得到的是一个Q30结果因为151530。如果你想把它存回Q15需要右移15位再取低16位实际工程里往往是保留高位、舍弃低位。这里有个非常常见的错误直接对乘法结果做(int16_t)强转你会得到一堆莫名其妙的数。正确做法是先右移再截断而且右移要考虑正负数的舍入一般用算术右移。第三个是除法的精度损失。两个定点数相除如果先做整数除法再移位精度会丢得很难看。工程上通常先把被除数左移n位再除商的结果就等于一个带小数的定点数。比如Q15除以Q15先把被除数左移15位得到Q30再除以Q15除数结果自动就是Q15精度保住了。这些细节看起来简单但忘了定标推导写出来的代码就是一团浆糊。3. IEEE 754浮点数拆解0.1加0.2为什么不等于0.33.1 一张图看懂float和double的内存布局浮点数的存储标准是IEEE 754它把一个浮点数分成三部分符号位、指数位、尾数位。单精度float总共32位1位符号8位指数23位尾数双精度double是64位1位符号11位指数52位尾数。这和社会上大家常说的float精度7位有效数字、double精度15位有效数字是直接对应的。关键在指数存储上它用的不是补码而是偏移量编码。float的指数偏移量是127double是1023。也就是说如果某个数的实际指数是0指数位存的是127实际指数是1指数位存的是128。为什么要加偏移量因为这样可以方便比较大小两个正浮点数可以直接把整个位模式当无符号整数比较大小排序就完成了硬件实现起来非常快。尾数部分还有个精妙设计叫隐含前导1。对于规格化的浮点数因为二进制科学计数法下尾数部分最高位永远是1这个1就不用存了直接省一位精度。所以float看起来尾数只有23位实际有效精度是24位二进制位约等于7位十进制的有效数字。这个隐含位在理解转换精度的时候很重要后面我会再提到。3.2 规格化、非规格化与那些特殊数IEEE 754里并不是所有位模式都表示普通数值它把情况分成了几类。指数位不为0且不为255时这个数是规格化的normal尾数带隐含前导1。指数位全为0时是非规格化的subnormal尾数没有隐含前导1这时候表示的是非常接近0的数可以渐次下溢到0而不是突然跳变。指数位全为1且尾数全为0表示正负无穷大这在运算中用于表示溢出结果指数位全为1但尾数不为0表示NaNNot a Number比如0除以0的结果。搞懂这些特殊数在项目调试中很有用我见过排查了半天发现程序是除以0之后一路把NaN传下去整个控制系统乱套的情况。另外还有一个容易忽略的点浮点数在数轴上的分布是不均匀的。靠近0的区域相邻两个浮点数的间隔非常小数值越大相邻间隔越大。float在表示100000000时的间隔可能已经超过8这意味着100000001和100000008用float存储时可能是同一个数。有次我在上位机上处理一个累计流量数据数据量到几十万以后发现尾数怎么都对不上最后查出来是float的间隔变大了换成double才解决。3.3 二进制小数本质为什么0.1是无限循环小数很多人第一次看到0.10.2不等于0.3会觉得很诡异甚至怀疑CPU坏了。其实这和十进制里1/3永远除不尽是一个道理。二进制小数里只有那些能写成2的负数次幂之和的数才是有限小数比如0.5、0.25、0.125、0.0625而0.1等于0.0001100110011001100...无限循环在计算机里只能截取近似值。以单精度float为例0.1存进去实际存的数是0.100000001490116119384765625。0.2存进去实际是0.20000000298023223876953125。这两个数相加结果当然不等于0.3而是0.300000004470348358154296875打印出来就是0.30000000000000004之类的数字。这不是bug而是二进制表示的固有特性你只能接受它、适应它实在需要精确十进制运算时就得用十进制字符串或高精度库来解决。这种误差平时写显示程序可能看不出来因为printf会按照有效数字帮你美化输出但在判断浮点数相等、做累加、做财务计算或传感器标定时就会翻车。我后面专门用一节讲判断相等的问题那里值得所有人看一看。4. 定点数与浮点数互相转换原理、公式和可复用的C代码4.1 从定点数到浮点数两行代码搞定但要注意类型转换顺序定点数转浮点数原理就是把定点数除以它的缩放系数。假设一个Q15定点数q_value对应的浮点数是float f_value (float)q_value * (1.0f / 32768.0f);这里有个非常重要的细节为什么我是先转成float再乘以1/32768而不是直接写(float)q_value / 32768.0f两者都能得到结果但前者的好处是1.0f/32768.0f是一个编译期常量编译器可以直接生成乘法指令而除法在某些平台会被转成一次浮点除法或者调用一个较慢的软件除法函数。在实时系统里每个周期几十上百次转换这个差异就体现出来了。同理一个通用的定点转浮点函数可以是float fixed_to_float(int32_t fixed_value, int n) { return (float)fixed_value * (1.0f / (float)(1L n)); }这里的n就是小数的位数。注意int32_t做移位时如果n311L 31可能溢出实际工程中要用uint32_t或者单独处理Q31这种边界情况。我的建议是先把缩放因子算成float常量避免在浮点转换时做大量整数移位。4.2 从浮点数到定点数舍入方向比想象中更重要浮点转定点反过来乘以缩放系数然后取整。很多人第一版代码会写成int16_t float_to_q15(float value) { return (int16_t)(value * 32768.0f); }这个写法在value为正值时没问题但value是负数时C语言的强制类型转换是向零截断的不是四舍五入。比如-0.0001×32768-3.2768向零截断变成-3误差是0.2768而正确的四舍五入应该是-3.2768舍到-3但-3.5应该舍入到-3还是-4如果你用round()-3.5会舍到-4或者-3取决于round的舍入模式但通常比直接截断要好。工程上推荐使用roundf函数并且加上范围钳制#include math.h #include stdint.h int16_t float_to_q15(float value) { float scaled value * 32768.0f; if (scaled 32767.0f) { return INT16_MAX; } if (scaled -32768.0f) { return INT16_MIN; } return (int16_t)roundf(scaled); }为什么不直接返回(int16_t)scaled除了舍入模式的考虑还有溢出问题。如果value是一个很大的数比如1000000scaled会达到几十亿此时直接强转到int16_t是未定义行为结果完全不可控。先做钳制能避免这个问题。我做音频处理时曾经因为没做钳制一个高幅值信号转换后直接把缓冲区写穿程序跑飞了后来才意识到是强转溢出。这个函数看着多几行但安全性完全不同。至于选择roundf还是floorf还是ceilf取决于你的具体语义。做控制算法时通常用roundf或最近偶数舍入做传感器或显示数据时有人喜欢用floorf来保证读数不会偏高做累加运算时则要考虑舍入误差的统计特性。我的习惯是绝大部分场景直接用roundf因为它的误差在统计上是对称的长期累加不会单方向漂移。4.3 工业协议里的转换陷阱怎么把一个浮点数塞进两个寄存器除了数值大小上的转换工业现场还有一种非常常见的转换需求位数转换。Modbus、CANopen、MQTT这些协议里很多寄存器或报文只支持16位整数但项目里传的偏偏是float。这时候就需要把float的位模式拆成两个16位或者四个8位传到另一端再拼回去。C语言里最稳妥的做法是用memcpy因为它不受大小端和别名问题影响#include string.h #include stdint.h void float_to_two_regs(float value, uint16_t *reg_hi, uint16_t *reg_lo) { uint32_t bits 0; memcpy(bits, value, sizeof(bits)); *reg_hi (uint16_t)(bits 16); *reg_lo (uint16_t)(bits 0xFFFF); } float two_regs_to_float(uint16_t reg_hi, uint16_t reg_lo) { uint32_t bits ((uint32_t)reg_hi 16) | reg_lo; float value 0.0f; memcpy(value, bits, sizeof(value)); return value; }这里有个跨设备的大坑寄存器顺序。有些PLC是高字在前有些是低字在前还有的模拟量模块把float按两个字颠倒处理。我接过的几个不同品牌设备同样是Modbus读float有的需要按高16位寄存器的顺序反过来读有的不需要不查手册纯靠猜很容易把数据读成一堆天文数字。所以写这种转换代码时一定要在协议文档里明确定义字节序和寄存器顺序甚至给自己留一个#define开关来切换顺序避免换个设备就重写一遍代码。5. 工业控制中的浮点应用从PLC模拟量到机器人信号定义5.1 为什么库卡机器人要把32个输入定义成浮点数工业机器人控制器的IO信号定义是个很实际的场景就拿库卡机器人的KRL语言来说它的SIGNAL指令可以把机器人控制器上的物理输入输出映射到变量。默认的数字量输入是BOOL类型一个点一个bit但如果你接的是模拟量传感器、力控传感器或者某种连续量信号就不能拿BOOL去读了得定义成浮点数。常见做法是把多个物理输入组合起来映射成REAL或者浮点类型这样一条SIGNAL定义就能读出完整的浮点数值。有些应用里用户会把32个输入通道统一声明为浮点数然后通过一个全局数组或者结构体来管理。这个做法在自动化产线集成时非常实用无论是读取称重传感器的mV信号还是从视觉系统拿到坐标偏移统一用浮点数表示上位机做数据记录和曲线分析就省事多了。但这里有个容易忽略的问题控制器里的REAL本质上是32位IEEE 754浮点数如果你从第三方设备接收的数据原本是定点数或者整数直接赋给REAL变量数值上不会错但如果你在机器人程序里做加减乘除、比较大小浮点误差和控制器的计算速度就会成为新的瓶颈。我见过一个集成项目机器人通过总线接收视觉系统发来的目标坐标视觉端把坐标乘以10000后以整数形式发送机器人端直接把这个整数赋给REAL变量然后坐标就偏了因为整数在浮点里并不能精确表示。正确做法是在机器人程序里先除以10000.0再做后续运算。所以不管是定义信号还是写转换逻辑脑子里必须时刻清楚对方发过来的是定点数还是浮点数定标系数是多少。这个对齐工作如果没做设备联调的时候就是一场灾难。5.2 控制算法里定点数比浮点数更听话的底层原因在运动控制、电源控制、电机驱动这些领域定点数有一个浮点数很难替代的优势确定性。定点数本质是整数运算同一次运算在任意时刻、任意编译器优化级别下结果都是完全一致的。浮点运算虽然也有IEEE 754的标准约束但很多编译器默认不开启严格浮点模式会把一些浮点表达式重排、合并甚至用更高精度的中间变量比如在x86上用80位扩展精度导致同样的代码在不同编译器、不同优化选项下结果有细微差别。这个细微差别在控制环路里可能表现为几个LTS的抖动或者在不同批次设备上表现不一致。在伺服驱动器这种同样输入必须同样输出的场景厂商往往更信任定点实现。另外定点数没有NaN、无穷大这些魔法值运算结果要么是合法数值要么是饱和到边界调试时心智负担小很多。当然定点数也不是万能的。它最大的痛点是定标系数一旦定死动态范围就受限遇到量级变化很大的算法就抓狂。所以实际项目里常见组合拳是外层用浮点数做标定、显示、通信内层用定点数做实时控制。而这两层之间就需要一套可靠、高效的转换函数。这也是我花大量篇幅讲转换代码的原因——转换代码就是两个世界的接口接口稳了系统就稳了。6. 实战一线的问题排查清单6.1 C语言判断浮点数相等直接写会死得很难看浮点数不能用比较这句话几乎每个老工程师都对新人口头禅过但真正理解为什么的人不多。前面说了0.1在float里的值和你在纸上写的0.1并不完全一样。假设你从ADC采样读到一个电压经过计算得0.1然后拿它和一个常量0.1比较哪怕数学上应该相等二进制上也可能差那么一两个bit就直接返回假了。常见的替代方案有两种。第一种是绝对误差比较fabsf(a - b) 1e-6。这种方法简单直观但对不同量级的数据不通用。你在比较0.000001和0.000002时1e-6的阈值可能太大在比较100000000和100000001时1e-6的阈值又太小因为float本身在大数上的间隔就超过1了。所以更推荐的是相对误差比较或者干脆用ULPUnit in the Last Place末位单位来比较。ULP比较的思路是两个浮点数差不多就用它们位模式之差的绝对值来衡量差距差值在几个ULP以内就算相等。这是很多高级语言的浮点比较库采用的做法在C里可以这样实现#include math.h #include stdint.h #include string.h int float_equal_ulp(float a, float b, int max_ulp) { // 先处理符号和零的特殊情况 if (a b) return 1; if (signbit(a) ! signbit(b)) return 0; uint32_t ia, ib; memcpy(ia, a, sizeof(ia)); memcpy(ib, b, sizeof(ib)); uint32_t diff (ia ib) ? (ia - ib) : (ib - ia); return diff (uint32_t)max_ulp; }这个函数在嵌入式里稍作修改就能用。实际项目中我通常把判断浮点相等封装成一个通用函数在单元测试和传感器数据校验里反复调用省了很多因为浮点比较引发的灵异bug。6.2 转换后数据飘了先查定标系数再查舍入模式定点转浮点后数值不对或者浮点转定点后误差大得离谱大多数情况下不是代码逻辑的问题而是定标系数没对齐。我见过最典型的案例设备A用Q12格式发送电压值真实值整数×2的-12次方设备B以为是Q15格式收到0x1234解码后得出一个完全离谱的电压。排查时如果只盯代码很难发现问题根源因为代码里每一步看着都对。正确排查顺序是先确认通信双方协议文档里的定标说明再对着一个已知真实值手动算一遍确认基准然后才去看代码里的移位和乘法。其次要查舍入模式。浮点转定点时如果用了截断而非round在大量转换后误差会单方向累积数据看起来整体偏小或者整体偏大。这种情况在音频、温度采集这类需要统计平均的场景特别明显。你用一个整数位很足的定点格式比如Q3.12截断误差最大为0.001看起来不大但每秒采样1000次一分钟就积累了60个单位。改用round之后误差正负抵消长期均值就稳了。最后要查的是中间溢出。浮点数乘以2的n次方之后如果数值很大可能超出int32_t甚至int64_t的范围。比如float值是1000000.0要转成Q31格式直接乘2147483648会溢出int32_t必须转成int64_t算完再截断或者干脆做饱和处理。这个坑在代码审查时很容易漏掉因为单测样本通常不会选到极值。6.3 跨平台移植时容易忽略的坑大小端、快速运算开关同样是float在不同平台上内存布局不一样直接memcpy成uint32_t读位模式结果可能完全不同。x86和ARM小端平台上float的位模式低字节在前而一些网络设备、老式DSP可能是大端。如果你写了一个函数从float的位模式里取某个字段换平台后要重点验证字节序。更麻烦的是有些DSP的float不是IEEE 754格式而是自定义的32位浮点编译器会自动帮你处理但你自己写的位操作代码就失效了。所以凡是把float当整数看的代码都要加明确的平台注释和断言。编译器优化开关也值得注意。GCC和Clang都有-ffast-math之类的选项它会跳过NaN/Inf检查、允许重排浮点运算最要命的是让浮点比较行为变得不可预测。曾经有人开了fast-math后原来正确的浮点相等判断变得时灵时不灵查了半天才发现是这个开关惹的祸。一般调试和交付阶段我都建议关掉这些激进优化宁可慢几个百分比也要保证行为可控。7. 好用的工具和工作习惯7.1 在线转换与16位/32位浮点工具很多问题靠人脑笔算太累尤其是一个十六进制数要拆分成IEEE 754各字段的时候。这里推荐几个我常用的省力方案。在线方面h-schmidt的FloatConverter这类网页工具可以直接输入十进制数或十六进制位模式立刻显示符号位、指数位、尾数位以及真实存储的二进制很适合讲课和排查问题。IEEE 754标准委员会官网也有合法的计算器。另外一些支持半精度16位浮点的转换工具也越来越常见做音频、图像处理时很有用因为那些领域经常要把数据从float压缩成half再存。本地方面我习惯用Python做快速验证一条命令就能看到浮点位模式比手动算快得多import struct def float_to_hex(f): return hex(struct.unpack(I, struct.pack(f, f))[0]) def float_to_bin(f): bits struct.unpack(I, struct.pack(f, f))[0] return f{bits:032b} print(float_to_hex(1.5)) # 0x3fc00000 print(float_to_bin(0.1)) # 00111101110011001100110011001101写C代码的时候我也经常用调试器直接看浮点数的十六进制视图再和在线工具对一遍确认没有字节序问题。7.2 在调试器里直接查看浮点位模式的土办法工程现场可能没有趁手的在线工具这时候一些土办法反而更可靠。我常用的一个技巧是在代码里把float转成uint32_t再打印#include stdio.h #include string.h #include stdint.h void dump_float(float f) { uint32_t bits; memcpy(bits, f, sizeof(bits)); printf(float %g - 0x%08X\n, f, bits); }在Keil、IAR、VS的调试模式里你也可以建立Memory窗口输入变量的地址直接看这4个字节的内容然后对照IEEE 754表格手动解码。这个方法在调试没有printf条件的环境里特别管用。我出差调设备时包里常备一张打印好的IEEE 754速查表包含偏移量、特殊值、常见浮点数的位模式真心方便。还有一个小习惯定义好一个转换函数后先写几个单元测试用例覆盖0、1、-1、0.1、最大正数、最小正数、NaN、Inf确认转换结果和预期一致再往上叠业务代码。浮点数这种问题等到系统联调时再排查成本要高出十倍不止。最后再分享一个心得处理定点数和浮点数转换一定要把定标这个词刻在脑子里。不管是软件和硬件之间、设备与设备之间还是算法与显示之间所有数据的含义都依赖于这个整数代表多少倍的物理量这一约定。只要这个约定清晰、文档明确、代码里处处可见转换问题就只是一行代码的事。反过来约定一旦模糊再好的算法也会被数据乱象拖垮。我的做法是在每个使用定点数的源文件顶部写清楚当前文件的定标格式和物理单位在每个转换函数接口注释里把输入输出的语义写明白。这种看似不起眼的习惯帮我省掉的调试时间比任何优化手段都多。
返回列表