群里一位做电机驱动的开发者甩过来一段串口日志温度那一列打印出来是25.299999而他代码里明明写的是25.3。更离谱的是同一份代码在电脑上跑仿真输出的却是25.300000。他怀疑是自己算错了改了半天公式最后发现问题出在printf的浮点数格式化上。这类事情我见得太多了printf的浮点数格式化输出看着是三行文档就能说清的东西实际上牵扯到二进制浮点表示、十进制转换算法、C 标准的舍入规定以及嵌入式精简 C 库里那一堆默认被砍掉的功能。精度、舍入、嵌入式避坑这三件事任何一件没搞明白日志就会骗你而工程师最怕的就是被自己的日志骗。这篇内容我打算把printf浮点数格式化从底层表示到格式串写法到嵌入式移植坑完整捋一遍适合刚接触 C 语言的学生、写上位机工具的后端开发者以及天天跟 MCU、RTOS、串口日志打交道的嵌入式工程师。看完之后你至少能做到两件事看到异常输出能立刻判断这是数据本身的问题还是格式串的问题在资源受限的板子上选出一条既能打印浮点又不撑爆 Flash 和栈的路子。1. 打印 25.3 却出来 25.299999先把误差和责任分清楚浮点打印出的数字不对绝大多数人第一反应是printf有问题或者编译器的库有 bug。实际上这条链路上有三个独立的角色在各自制造误差数据本身的二进制表示、十进制转换算法、以及你写的格式串。把责任分清楚后面所有的排查才有方向。1.1 二进制浮点存不下大多数十进制小数IEEE 754 的 double 用 1 位符号、11 位指数、52 位尾数拼出一个数本质上是一堆 2 的负幂次的和。0.5、0.25、0.125 这种能写成 2 的负整数次幂的数它可以精确表示0.1、0.2、25.3 这种分母带因子 5 的十进制小数在二进制下是无限循环小数只能截断成最近的 53 位有效二进制位。拿 25.3 举例它存进 double 之后实际是 25.300000000000000710542735760100185871124267578125比 25.3 大了那么一点点。这个误差大约是 7×10⁻¹⁶相对于数值本身是 2.8×10⁻¹⁷ 的相对误差完全在 double 的精度范围内但对打印结果有决定性影响当你用%.6f打印时第 7 位小数是 0舍掉之后正好落在25.300000而当你用%.8f打印就会看到25.30000000再往上加位数误差就露出来了。那位开发者遇到的25.299999问题不在 double而在他中间做了一次 float 转换。float 只有 24 位有效二进制位25.3 存成 float 实际是 25.299999237060546875误差放大到 7.6×10⁻⁷。再用%.6f打印第 7 位是 2舍掉后变成25.299999。数据在这一步就已经坏了printf只是忠实地把坏数据翻译出来。这里有个很实用的判断法则float 的有效十进制位数大约 7 位double 大约 15 到 17 位。C 标准里FLT_DIG是 6、DBL_DIG是 15意思是这两种类型至少能保证这么多位十进制数字往返转换不失真。所以对一个 float 变量打印超过 9 位小数你打印的基本上是二进制截断的残渣不是有效信息。这一条在传感器调试时特别重要——很多人体会不到看到%.9f输出一长串乱七八糟的数字就以为传感器坏了。提示如果你需要判断某个十进制小数在 double 里到底存成了什么用printf(%.20f, x)打出来看这是最直接的办法。0.1 打出来是0.10000000000000000555这就是它的真身。1.2 printf 的锅到底有多大把数据表示这一层剥掉之后printf的浮点转换本身其实相当可靠——前提是你用的是完整的标准库实现。glibc、musl、newlib 的完整版都实现了正确舍入的二进制到十进制转换输出的每一位都是精确计算出来的不是估算的。正确舍入意味着什么意味着printf(%.20f, 0.1)输出的0.10000000000000000555里的每一位都是那个 double 真实值的精确十进制展开的前 20 位一位不差。这个计算并不轻松把一个 53 位二进制尾数乘以 10 的 20 次方中间结果需要上百位精度glibc 内部用的是 David Gay 那套大数算法代码量不小。这就是为什么精简 C 库干脆把这块砍掉的直接原因。反过来如果库的实现比较简化它可能只用了 64 位或者 128 位的中间精度在打印很多位小数时末几位会出现偏差。这种现象在早期的嵌入式 libc 里不算罕见。判断方法很简单拿几个已知的难缠数值在目标平台上打一遍跟桌面平台的结果对比差一位就说明库的转换精度不够。所以一个靠谱的排查顺序是先确认数据类型的宽度float 还是 double再用%.20f看真实存储值最后才怀疑库。顺序反了你会在库源码里浪费一整天。1.3 精度、转换符、宽度三个旋钮的职责划分格式串里跟浮点相关的旋钮其实就三个搞清楚各自的职责写格式串就不会瞎猜。转换说明符决定用什么记数法表达这个数%f是定点%e是科学计数法%g是让库自己挑%a是十六进制浮点。精度小数点后的那个数字或者.*决定保留多少位对%f是小数位数对%e也是小数位数对%g是有效数字总位数——这个差异坑过无数人。字段宽度决定最少占多少个字符位不够就补空格或补零超了就原样输出不做截断。举一个三者同时起作用的例子printf(%012.4f, 3.14159)输出0000003.1416。宽度 12 表示至少 12 个字符0标志表示用前导零填充精度 4 表示小数点后四位数值 3.14159 舍入到 4 位是 3.1416本身占 6 个字符补 6 个零刚好 12 位。理解了这个展开过程格式串就不再是背下来的口诀了。值得单独说一句的是%f的缺省精度是 6 位小数这个数字不是随便定的。C 语言早期的浮点几乎清一色是 float6 位正好是 float 能保证的有效十进制位数再多打就是噪声。这个默认值一直沿用到今天尽管现在默认的double能撑到 15 位。很多为什么打印出来总是 6 位小数的疑问答案就在这里。2. 转换符怎么选%f、%e、%g、%a 的适用边界选错转换符的后果有时候比精度设置错误更严重因为它会直接导致缓冲区溢出或者信息丢失。我见过有人在嵌入式日志里用%f打印时间戳单位秒量级 10⁶结果一行日志占掉 20 多个字节串口带宽直接吃满也见过有人用%g打印传感器的小数结果某些值被自动转成科学计数法上位机解析脚本当场崩溃。2.1 %f 的定长小数与隐形长度炸弹%f的规则极其朴素把数的整数部分原样打出来小数点后固定保留精度指定的位数不够补零多了舍入。它不考虑数值大小一个 1e-9 和一个 1e9 用同一个格式串打印长度天差地别。这就是它的第一个坑整数部分没有上限。printf(%f, 1e308)会输出一个 309 位的整数部分加上小数点再加 6 位小数总共 316 个字符外加结束符需要 317 字节的缓冲区。嵌入式里谁会准备 317 字节的 buffer通常就是char buf[32]直接写穿栈。这类事故在调试阶段往往看不出来栈刚好没被踩到关键位置一上量产就成批死机排查起来极其痛苦。第二个坑是精度没有上限。%.100f是合法的输出一百位小数虽然从第 17 位以后全是二进制残渣。如果这个精度是从配置参数或者上位机下发的那就等于把缓冲区长度交给了外部输入控制。第三个坑比较隐蔽精度为 0 时小数点不出现。printf(%.0f, 4.7)输出5不是5.。这不是 bugC 标准明确规定精度为 0 且没有#标志时不输出小数点。想要小数点就写%#.0f输出5.。%f最适合的场景是数值范围已知、需要固定小数位的场合电压 0 到 5V 打印 3 位小数、温度打印 2 位、金额打印 2 位。范围一旦不可控就该考虑别的转换符或者先做范围钳位。2.2 %e 与 %g让量级自己说话%e的输出格式是d.dddddde±dd尾数部分一位整数加精度位小数指数至少两位不够补零。printf(%.3e, 1234.5)输出1.234e03或者1.235e03——这里正好撞上舍入问题1234.5 的 double 表示略小于真值所以舍到 4 位有效数字得到1.234e03。这类边界值是最容易让人怀疑库有 bug 的地方。%e的长度是可控的精度 p 对应 6p 左右的字符数不含符号和宽度填充跟数值大小完全无关。所以当你要打印的量级跨度极大比如从 pA 级的漏电流到 A 级的工作电流%e是安全的选择。缺点是可读性差人眼看着一串指数形式很累。%g的规则复杂一些值得完整说一遍。设精度为 P缺省 6如果写 0 则按 1 处理先用%e的形式算出指数 X。如果 P X ≥ -4就用%f风格输出小数位数取 P-1-X否则用%e风格输出小数位数取 P-1。最后如果没有#标志去掉小数部分末尾的零如果小数点后什么都不剩连小数点一起去掉。这个规则的效果是printf(%g, 0.0001)输出0.0001printf(%g, 0.00001)输出1e-05因为指数 -5 小于 -4切到科学计数法。printf(%g, 123456789.0)输出1.23457e08因为默认精度 6 小于指数 8。很多上位机解析脚本栽在%g上就是因为没料到它会突然切模式。注意如果日志要被机器解析别用%g。它的输出格式会随数值大小变化解析器得同时处理两种形态。这种场景老老实实用%f配固定精度或者干脆自己拼字符串。2.3 %a调试期照妖镜%a是 C99 引入的十六进制浮点格式输出形如0x1.91eb851eb851fp4意思是 1.xxxxx 乘以 2 的 4 次方尾数部分每一位十六进制对应 4 位二进制。它的价值在调试%f输出的十进制是经过转换的你看到的每一位都掺了转换算法的因素%a输出的是内存里的原始位模式只差一个规格化带不带误差、误差在哪一位一目了然。排查这个数到底是算错了还是打印错了这种问题时%a能一秒钟给出答案。%a的缺省精度是足够精确表示这个数不会丢位也不会多打。它还有一个副作用是零成本判断相等性——两个%a输出完全一样的数在 double 层面就是逐位相等除了 -0.0 和 0.0 这种情况。生产环境的日志里当然不该出现%a但调试分支里留着它比打断点看内存窗口快得多。2.4 %lf、%Lf 与可变参数提升的连环坑这一节是事故高发区。先说%lf。在printf里l修饰符对f转换符是没有效果的C99 明确规定%lf和%f完全等价因为printf的%f本来就接收 double。但是在scanf里%f收 float、%lf收 double两者完全不同。这一正一反的差异导致无数人写反scanf(%f, d)而 d 是 double写进去 4 个字节剩下 4 个字节是垃圾。再说%Lf。printf(%Lf, x)要求 x 的类型是long double传 double 进去是未定义行为标准里写得清清楚楚。在 x86-64 上long double 是 80 位扩展精度占 16 字节对齐后参数传递方式和 double 完全不同。传错类型的结果不是数值略有偏差而是直接读错栈上的内容输出一个毫无意义的巨大数字或者干脆崩溃。这个坑最阴险的地方在于在 32 位 ARM 上long double和double都是 64 位传错了照样能出正确结果代码看起来是对的。等到移植到 x86 或者换编译器问题才爆发而且已经埋了很久。还有一个更基础但同样致命的点printf是可变参数函数float 实参在传参时会被默认实参提升为 double。这意味着printf(%f, 3.14f)是合法的3.14f 提升成 double 再传进去不会有类型不匹配的问题。但这不代表你可以随便混用因为提升只发生在 float→double不发生在 double→long double也不发生在任何指针类型之间。至于printf(%d, 3.14)这种把 double 当 int 传的写法标准里是未定义行为实际表现取决于 ABIx86-64 上浮点参数走 XMM 寄存器整数走通用寄存器读出来基本是垃圾。好消息是现在的编译器在开启-Wall -Wformat之后会直接报错坏消息是嵌入式工具链的警告等级经常被调低。3. 舍入这件事printf 不是四舍五入是正确舍入中文语境里说四舍五入默认指的是5 就进一位。printf干的不是这个事。它遵循的规则跟硬件浮点单元的舍入模式一致默认是就近舍入、偶数优先round to nearest, ties to even也叫银行家舍入。这个差异制造了一大批看起来像 bug 的输出。3.1 就近舍入、偶数优先0.5 打印出 0 不是 bug规则本身很简单把要保留的最后一位之后的部分拿出来看如果小于一半就舍大于一半就进正好等于一半的时候看保留的最后一位是奇数还是偶数——偶数就舍奇数就进目的是让结果落在偶数上统计上避免误差累积。拿%.0f测试几个值结果如下表表达式输出舍入方向原因printf(%.0f, 0.5)0舍恰好一半0 是偶数printf(%.0f, 1.5)2进恰好一半1 是奇数进到 2printf(%.0f, 2.5)2舍恰好一半2 是偶数printf(%.0f, 3.5)4进恰好一半3 是奇数printf(%.0f, 2.6)3进大于一半printf(%.0f, -2.5)-2舍对称处理绝对值 2.52 是偶数第一次看到0.5打印成0的人几乎都会觉得库坏了。但这是 IEEE 754 默认舍入模式的标准行为printf只是忠实地执行。如果你真的需要传统的逢五进一那就不能靠printf得自己算。这里还有个前提要说清楚C 标准规定舍入行为依赖于当前的浮点舍入模式实现可以把舍入方向当成实现定义的行为。绝大多数基于 IEEE 754 的系统默认就是就近偶数所以你实测到的就是这个结果。但如果你的目标平台的fesetround()被改过有些 DSP 库会这么干printf的输出会跟着变。这是个非常隐蔽的耦合出问题时很难想到。3.2 那些看起来舍错了的经典案例比 0.5 更迷惑的是那些看起来五入没生效的例子比如金额计算里的 2.675printf(%.2f, 2.675); /* 输出 2.67不是 2.68 */原因在 1.1 节已经埋下伏笔2.675 在 double 里实际存的是 2.67499999999999982236431605997495353221893310546875比真值小。这个数的第三位小数是 4按照看第三位是否大于等于 5的传统思路它是 4直接舍掉得到 2.67。printf没有做错任何事它精确地把 2.67499... 舍到了两位小数。同样的道理2.685 会舍成 2.69因为 2.685 的存储值略大而 2.675 舍成 2.67。这两个数在纸上看起来规律一样实际行为相反。这就是为什么涉及金额的场景坚决不能用浮点不是精度不够是十进制语义根本没法用二进制浮点表达。再看一个组合运算的例子float a 0.1f, b 0.2f; printf(%.10f, a b); /* 0.3000000119 */0.1f 和 0.2f 各自都带误差加起来误差叠加实际值是 0.300000011920928955078125。用%.1f打印确实是0.3但用%.10f就原形毕露。这提醒我们精度设置要跟数据的真实精度匹配多打几位不会让数据变准只会把误差暴露出来。3.3 精度取值的实操建议表精度该设多少取决于数据类型和业务需求我整理了一张可以直接查的表数据类型有效位数建议最大精度典型场景说明float约 7 位6 位小数传感器原始值、ADC 换算超过 9 位是噪声double约 15 位12 位小数一般工程计算、坐标超过 17 位是噪声long double18 位以上15 位小数高精度累加、金融模拟嵌入式基本不用定点整数分为单位取决于宽度2 位小数金额、计费存整数打印时拆分除了位数还有一个容易被忽略的点精度的选择和数值范围要一起考虑。同样的%.6f打印 0.000001 得到0.000001打印 123456789.0 得到123456789.000000长度差了 8 个字符。如果你的日志缓冲区是按最坏情况设计的那就得按最大可能值的整数位数来算容量而不是按典型值。再补一个动态精度的用法。%.*f允许把精度作为参数传入int prec 3; printf(%.*f, prec, value);这个特性在需要根据配置动态调整显示精度的场景比如上位机软件里的小数位设置非常好用比拼接格式串安全得多。注意参数顺序%.*f的精度参数在值之前如果同时用%*.*f宽度参数又在前三个参数依次是宽度、精度、值写反了就是灾难。3.4 需要严格十进制语义时绕开浮点如果业务要求的是十进制意义上的精确舍入比如财务金额、计量结算正确做法是全程用整数只在最后展示时插入小数点。具体操作金额以最小货币单位分、厘存成整数int64_t能表示的分的范围超过 ±9×10¹⁶任何实际业务都够用。打印时把整数拆成整数部分和小数部分中间手工插一个小数点。这个过程没有浮点参与也就没有舍入误差。需要做舍入运算时比如按比例分摊用整数运算加显式舍入规则比如(a * b c / 2) / c这是在整数域里实现四舍五入的标准写法。想要银行家舍入就自己写分支别指望printf。还有一个折中方案是定点数用 Q 格式把小数存成整数比如 Q16.16 用 32 位存 16 位整数和 16 位小数。打印时整数部分右移 16 位小数部分与 0xFFFF 做掩码再乘 10000 除以 65536。这种方案在 DSP 和低端 MCU 上很常见因为全程都是整数运算不需要浮点单元。4. 嵌入式里的 printf 浮点裁剪、体积、性能三座山桌面环境下printf打浮点是理所当然的事嵌入式环境下它是个需要论证的决策。Flash 从几十 KB 到几 MB 不等栈可能只有几百字节一次浮点格式化的开销可能是整数格式化的几十倍。搞清楚这三座山的量级才能做出合理取舍。4.1 精简 C 库默认把浮点格式化砍掉了这是新手最常撞的墙代码里写了printf(%.2f, x)编译链接一路绿灯烧进去之后串口什么都不输出或者只输出格式串里的固定字符。原因很简单——精简版 C 库把浮点格式化这条路径默认裁掉了链接时被当作死代码丢弃。不同工具链的处理方式不一样但套路类似newlib-nano需要显式链接浮点格式化模块通常是在链接选项里加上-u _printf_float让链接器把这个符号强制拉进来。不拉的话格式化函数遇到浮点转换符会直接放弃输出。ARM 工具链的 microlib这是超精简版本对浮点格式化的支持有限可能需要换用完整库或者做额外配置具体看所用版本的文档。IAR工程选项里有 printf 格式化器档位通常是 Tiny、Small、Large、Full 几档只有靠后的档位才支持浮点。默认档位为了省空间一般是小档。其他商业工具链多数也是类似的格式化器档位或库变体选择。这件事的坑在于编译期一切正常运行期静默失败。没有告警没有报错就是没输出。所以你如果接手一个别人搭的工程第一件事就是确认浮点格式化到底有没有被编译进去写一行printf(%.2f\n, 1.5)烧进去看最快。注意有些精简实现遇到不支持的转换符时会把格式串原样输出或者输出一个问号。看到日志里出现奇怪的%.2f字样先怀疑是库裁剪别去查数据。4.2 代码体积与栈开销的量级估算开启浮点格式化之后会多占多少 Flash这个数字各家差别很大但量级上是几 KB 到十几 KB主要来自三部分格式串解析的公共代码、浮点转十进制的核心算法、以及可能被拉进来的大数运算辅助函数。对一颗只有 64KB Flash 的 MCU 来说十几 KB 是个不能忽略的数字可能就是你加不进下一个功能的直接原因。所以评估要不要开浮点打印得看你的日志预算不能说方便就行。栈的问题更难缠。浮点格式化路径里的函数调用层级比整数深中间变量也多某些实现会在大精度的情况下使用变长数组或者递归。实测下来一次浮点格式化可能吃掉几百字节到上千字节的栈。如果你的 RTOS 任务栈只分了 512 字节printf一跑就溢出而且溢出表现是随机的——有时候踩到别的变量有时候踩到返回地址有时候什么都没发生。这类问题最难查因为现象和数据毫无关系。稳妥的做法是把浮点打印从任务上下文里挪出来放进一个专门的日志任务给这个任务分配充足的栈我一般直接给 2KB 起步视实现调整用 map 文件或者工具链的栈使用分析功能看格式化路径的最大栈深是多少别凭感觉。这个数字不是猜出来的是量出来的。还有一个容易被忽略的空间格式化用的缓冲区。前面算过%f打印极端值需要 300 多字符。4.3 单精度 FPU 也救不了 double 的格式化开销Cortex-M4F、M7、M33 这些带 FPU 的核容易让人产生浮点很快的错觉。问题在于那些 FPU 是单精度的只加速 float 的运算double 的加法和乘法依然是软件模拟一次 double 乘法可能要走几十到上百个周期。更糟的是printf的可变参数提升规则会把所有 float 实参提升成 double。也就是说你传一个 float 进去编译器悄悄转成 double格式化函数内部全程用 double 运算。哪怕你的 MCU 有单精度 FPU这条路径上的运算也完全用不上硬件加速。实测的体感是一次printf(%.3f, x)在 72MHz 的 M4 上可能需要几十微秒到上百微秒具体取决于数值大小和精度。如果你在 1kHz 的控制中断里打印浮点日志光这一下就能把中断时间撑爆直接影响控制环路稳定性。所以嵌入式日志有个基本规矩中断里不打日志尤其不打浮点日志。把数据丢进环形缓冲区让低优先级的任务或者空闲循环去格式化输出。这个设计模式看起来是额外工作但它把不可预测的格式化开销从关键路径上挪走了是值得的。4.4 多任务环境下的重入与日志乱序printf是不是线程安全的标准里没有强制要求但多数实现内部有一个 stdio 锁保证单次调用的原子性。问题是这个锁的粒度它保证一次printf内部的输出不被打断但不保证多次调用的顺序。实际表现是两个任务同时打日志你能看到 A 任务的半行接上 B 任务的半行日志被搅成一团。如果格式化过程本身还要读全局状态比如 locale 信息并发调用可能出现更微妙的问题。解决思路有三个层次最简单所有日志统一走一个任务其他任务通过队列投递日志内容。这招最干净也最省心因为格式化只在单一线程发生。中等复杂度给printf外面包一层互斥锁。注意锁的范围要覆盖整个格式化加发送过程不能只锁发送。而且如果用的是 RTOS 的互斥量注意不要在中断里获取。最麻烦用可重入版本。有些库提供_r系列函数把状态结构体作为参数传入但嵌入式精简库里往往没有。还有一种情况是 UART 输出本身的并发。即使printf是原子的你的串口发送函数如果是分块写 FIFO 的两个任务的输出一样会交错。所以互斥保护要放在最外层把格式化到缓冲区 发送缓冲区整体包起来。5. 落地实现一套能抄走的浮点打印方案前面讲了原理和坑这一节给可以直接用的代码。目标很明确在不牺牲安全性的前提下让浮点打印在嵌入式环境里跑得稳。5.1 snprintf 两次调用法彻底告别缓冲区溢出sprintf的问题是它不知道缓冲区有多大数值一超长就写穿。snprintf加了长度保护会截断但它同时也带来一个新问题你怎么知道截断了没有答案是看返回值。snprintf返回的是假如缓冲区足够大应该写入的字符数不包括结束符。如果这个数大于等于你传的缓冲区大小说明被截断了。基于这个特性有两次调用的经典写法static char g_line[128]; static int log_float(const char *tag, double v) { int need snprintf(NULL, 0, %s: %.3f\r\n, tag, v); if (need 0) { return -1; /* 格式化出错 */ } if ((size_t)need sizeof(g_line)) { /* 超长走降级路径只打标签和整数部分 */ snprintf(g_line, sizeof(g_line), %s: too long\r\n, tag); uart_write(g_line, strlen(g_line)); return -2; } snprintf(g_line, sizeof(g_line), %s: %.3f\r\n, tag, v); uart_write(g_line, (size_t)need); return need; }第一次调用传NULL和 0什么都不写只算长度。这招在 C99 下是标准行为但要注意个别老的精简库对NULL缓冲区处理不当会直接崩溃。如果你的工具链比较老改用一个大一点的临时缓冲区做试写再判断返回值效果一样。降级路径的设计才是关键。日志系统不能因为某一条太长就整个崩溃必须有兜底。上面这段里超长就走固定文本保证输出始终可控。实际项目里我还会加一层数值钳位如果v超出了合理范围比如温度超过 ±1000先打一个SENSOR_ERR标记避免把一个明显错误的数值混在正常数据里。提示如果你的日志缓冲区是全局的记得配合互斥锁或者仅单任务使用的约束。全局缓冲区被两个任务同时写比缓冲区溢出还难查。5.2 自写定点格式化函数附完整代码如果 Flash 实在紧张或者需要对舍入规则有完全的控制权可以自己写一个定点格式化函数绕开库里的浮点路径。核心思路是把 double 乘上 10 的精度次方四舍五入成整数再手工拼小数点。#include stdio.h #include stdint.h static const uint64_t k_pow10[] { 1ULL, 10ULL, 100ULL, 1000ULL, 10000ULL, 100000ULL, 1000000ULL, 10000000ULL }; /* 把 v 格式化成 prec 位小数的字符串返回写入长度负值表示失败 */ int fmt_fixed(char *out, size_t cap, double v, unsigned prec, int *trunc) { if (prec 7 || cap 0) { return -1; } if (v ! v) { /* NaN */ return snprintf(out, cap, nan); } const uint64_t scale k_pow10[prec]; /* 限制范围(v*scale0.5) 必须远小于 2^53否则 double 精度不够 */ const double limit 1.0e15 / (double)scale; if (v limit || v -limit) { return -2; /* 超范围交给调用方处理 */ } int neg (v 0.0); if (neg) { v -v; } uint64_t scaled (uint64_t)(v * (double)scale 0.5); uint64_t ip scaled / scale; uint64_t fp scaled % scale; int n; if (prec 0) { n snprintf(out, cap, %s%llu, neg ? - : , (unsigned long long)ip); } else { n snprintf(out, cap, %s%llu.%0*llu, neg ? - : , (unsigned long long)ip, (int)prec, (unsigned long long)fp); } if (trunc) { *trunc (n 0 (size_t)n cap); } return n; }几个设计要点值得说清楚。limit这个检查不是保守是必须的。double 只有 53 位有效尾数能精确表示的最大整数是 2⁵³ ≈ 9.007×10¹⁵。如果v * scale超过这个量级加 0.5 再截断的结果就不再可信误差可能达到好几个单位。把上限设在 10¹⁵留出安全余量超过就返回错误让上层处理比输出一个假数字强得多。0.5再截断这个操作实现的是正数四舍五入因为我们在前面已经把符号剥离了所以不用操心负数舍入方向的问题。这一点跟直接写(int)(v * 100 0.5)不一样后者遇到负数会舍错。%0*llu里的0*是零填充 动态宽度保证小数部分不足位时补零比如0.05打印成0.05而不是0.5。这个细节如果漏了输出会很难读而且解析脚本容易出错。范围检查的另一面是精度上限。prec限制到 7 是因为scale最大到 10⁷再乘上限制值 10¹⁵ 就让scaled接近uint64_t的上限了。真的需要更多位数就得换大数算法那时候还不如用库。这个函数还有一个隐含限制它不做十进制正确舍入只做最接近的 double 乘 10ⁿ 后的四舍五入。对于 2.675 这种存储值略小于真值的数它同样会给出 2.67跟printf一致。这是 double 的锅不是函数的锅。5.3 传感器与日志场景的精度选择清单写日志时精度设多少不该拍脑袋我按常见场景列一份参考场景数据来源精度理由温度工业12 位 ADC2 位分辨率约 0.06℃2 位足够温度消费电子内置传感器1 位精度本来就 ±1℃多打没意义电压 / 电流12 位 ADC3 位0.001 对应 5V 量程的 1.2%陀螺仪角速度16 位传感器2 到 3 位取决于量程与零偏稳定性位置坐标双精度计算6 位约 0.1 米分辨率时间戳秒系统时钟3 位毫秒级够定位问题计算中间变量double8 到 10 位调试用暴露误差金额分为单位整数2 位固定两位不涉及二进制浮点这张表的用法是先定业务需要的分辨率再往上取一位用于观察。取太多位的直接后果是日志变长、串口带宽被占、而且你看到的大部分数字都是二进制噪声。另外有个实操经验同一份日志里同一个物理量的精度要保持一致。曾经有人在一个函数里用%.2f在另一个函数里用%.4f结果做趋势分析时数据对不上多花了半天才反应过来是格式问题。5.4 编译期把格式错误挡在门外格式串和参数不匹配是最容易犯也最容易发现的错误前提是你开了正确的编译选项。GCC 和 Clang 下-Wall基本就能覆盖大部分格式问题-Wformat2更严格一些会检查%n的用法和格式串非字面量的情况。加上-Werrorformat可以直接把它变成编译错误防止有人偷懒忽略警告。如果格式串是运行时拼出来的编译器就无能为力了。这类代码要格外小心因为格式串里混进未预期的%会导致参数错位。最稳妥的做法是永远不要让外部输入直接当格式串用printf(user_input)是绝对禁止的必须写成printf(%s, user_input)。这个习惯在日志函数里尤其重要因为日志函数的第一个参数往往来自宏展开容易出错。还有一个技巧是给自定义的日志函数加上格式检查属性这样编译器也能帮你检查你的包装函数__attribute__((format(printf, 2, 3))) static void log_printf(int level, const char *fmt, ...);第一个参数是格式串在参数列表里的位置从 1 开始第二个是可变参数的起始位置。加上这个声明之后log_printf(LOG_INFO, %.2f, 3.14)和log_printf(LOG_INFO, %d, 3.14)都会被检查出来。这段属性是 GCC 和 Clang 的扩展ARM 和 IAR 工具链多数也支持类似写法具体名字查一下文档。6. 排障实录那些年在 printf 上栽的跟头原理讲完最后落到排查上。我把自己和周围人踩过的坑整理成速查表加现场还原遇到问题时先查表能省掉大量试错。6.1 症状与原因速查表现象最可能原因快速验证处理方式浮点完全无输出精简库裁掉浮点格式化打printf(%.2f, 1.5)看有没有输出开启对应链接选项或换库档位输出一长串数字后崩溃缓冲区不够%f整数部分过长用%.20f看真实值量级换%e或snprintf加长度检查打印 25.3 得到 25.299999中间某步用了 float查变量声明和函数原型统一用 double 或调整精度0.5 打印成 0就近偶数舍入正常行为试%.0f打 1.5、2.5需要传统舍入就自己算2.675 打印成 2.67double 存储值略小于真值%.20f看实际存储值金额改整数运算数值很大时日志变乱码缓冲区溢出踩坏其他变量检查最坏情况字符数加大缓冲区或钳位多任务日志交错缺少互斥保护两任务同时高频打印统一日志任务或加锁%Lf输出巨大错误数字传了 double 给%Lf查变量类型改成%f或真用 long double换平台后结果变化long double 宽度不同对比两平台sizeof(long double)避免依赖 long double精度调高后末位乱跳超过该类型的有效位数对照FLT_DIG、DBL_DIG降精度到有效位以内6.2 三个典型现场还原现场一串口突然没日志了。某开发者给一个基于精简库的工程加了浮点温度日志代码编译通过烧录后串口一片安静连启动横幅都没有。他第一反应是系统没起来查了两个小时时钟和启动文件。实际情况是浮点格式化被裁掉printf内部走到浮点分支直接返回连之前该输出的内容也被吞了。这类问题的排查入口很明确——在main开头写一行printf(boot %.2f\n, 1.0)如果这行出不来而printf(boot\n)能出来基本可以锁定是库裁剪。现场二精度设了 6 位数值看起来跳变。某实验室的一个采集板电压值用%.6f打印发现同一个稳定输入下最后两位在 51 和 49 之间来回跳。他怀疑 ADC 噪声。实际情况是他把 ADC 的原始值转成电压时用了 floatfloat 在这个量级上的分辨率不足以支撑 6 位小数最后两位纯粹是表示误差的抖动。把中间变量改成 double最后按%.3f输出跳变消失因为真实噪声本来就在第 3 位以后。现场三换了个编译器日志全乱了。一个跨平台的项目从 A 工具链换到 B 工具链printf(%Lf, x)的输出从正常数字变成一个十几位长的怪数。原因是两个平台long double的实现不同A 平台是 64 位跟 double 一样传错了也能碰巧对B 平台是 80 位参数读取方式完全不同。这类问题的教训是格式串里的类型必须严格对应不能靠在我机器上能跑来验证。修法是把%Lf改回%f同时把变量声明统一成 double。6.3 一份可以直接跑的回归测试清单新平台、新工具链、新库版本上手第一件事就是跑一份浮点打印的回归测试把行为摸清楚。下面这组用例cover了主要的边界情况输出结果记录下来日后出问题有对照#include stdio.h #include float.h void printf_selftest(void) { printf(DBL_DIG%d FLT_DIG%d\n, DBL_DIG, FLT_DIG); printf(sizeof(double)%u sizeof(long double)%u\n, (unsigned)sizeof(double), (unsigned)sizeof(long double)); printf(A %.6f\n, 25.3); /* 基础定点 */ printf(B %.20f\n, 0.1); /* 真实存储值 */ printf(C %.0f %.0f %.0f\n, 0.5, 1.5, 2.5); /* 舍入模式 */ printf(D %.2f\n, 2.675); /* 经典舍入争议值 */ printf(E %.3e\n, 1234.5); /* 科学计数法 */ printf(F %g %g\n, 0.0001, 0.00001);/* %g 切换阈值 */ printf(G %a\n, 1.0 / 3.0); /* 十六进制浮点 */ printf(H %f\n, -0.0); /* 负零 */ printf(I %f %f\n, 1.0 / 0.0, 0.0 / 0.0); /* inf 与 nan */ printf(J %.0f\n, 4.7); /* 精度 0 无小数点 */ printf(K %#.0f\n, 4.7); /* # 强制小数点 */ printf(L [%12.3f]\n, 3.14159); /* 宽度与填充 */ printf(M [%-12.3f]\n, 3.14159); /* 左对齐 */ printf(N %.*f\n, 2, 1.005); /* 动态精度 */ }这组用例里有几个值的输出在不同平台上可能不一样这正好是你需要知道的舍入模式是不是就近偶数、inf 和 nan 的小写形式还是别的、负零是否带符号、%g的切换阈值。把第一次跑出来的结果存档后面换环境再跑一遍对比一眼就能看出差异。注意测试里用1.0 / 0.0生成无穷大在默认设置下是合法的 IEEE 行为但某些编译器在激进优化下可能报除零警告甚至直接优化掉。如果在意改成从变量读入避免被常量折叠。真正落到日常我的习惯是在每个项目的main之前留一个隐藏的selftest命令串口收到特定按键就触发这一串输出五秒钟看完平台的全部浮点行为。这比翻文档快也比凭记忆可靠。
阅读完成 · 觉得有帮助?