本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

定时器与单位换算的坑:
为什么注释不能信

前一段时间我集中整理了自己做过的几个固件工程,本来是想提炼架构经验, 结果发现最密集的一类缺陷既不是算法也不是架构,而是时间和单位: 注释写着 100 ms 实际跑的是 500 ms、栈大小“由 400 改为 1000”其实是十六进制、 同一个换算公式在同族代码里有四种写法、温度的单位在注释和实现对不上。 这篇把这类问题归纳成一张核算清单。

定时器 单位换算 代码审查 踩坑复盘 已发布

一、为什么“时间”这类 bug 特别难查

时间相关的错误有一个很不友好的性质:它几乎从不立刻报错。

  • 一个周期本该是 100 ms、实际跑了 500 ms 的定时器,系统照样在跑,只是“反应慢一点”;
  • 一个把秒当成毫秒的换算,结果只是“数字大了一千倍”,看起来还在合理范围内;
  • 一个栈小了一倍的配置,平时毫无异常,只在某个最深的调用路径上偶发跑飞。

它们的共同点是:症状出现在离原因很远的地方,而且表现为“偶发”“不稳定”“不太对”, 而不是明确的崩溃。于是排查时会先去怀疑硬件、干扰、器件批次——那些更难验证的东西。

我整理出来的这类问题可以归成五类,下面逐条说清楚它是怎么发生的、怎么发现、怎么避免。

二、坑一:注释里的时间与实算时间不一致

这是出现频率最高的一类。三个真实例子(都来自我自己的代码或我读过的代码):

#注释写的实算得到的差多少
1 某个采样定时器标注“100 ms” 分频后计数一拍约 1 µs,周期寄存器设成了 50 万 → 500 ms 5 倍
2 某个节拍中断标注“10 ms 一次” 时钟分频与重装值算下来是 1 ms 10 倍
3 某个分频宏的注释写“16 分频” 宏名与寄存器配置实际是 256 分频 16 倍

注意这三条的偏差方向并不一致,所以不能靠“感觉快了还是慢了”去猜。 它们是怎么产生的?大多是改代码的时候只改了配置、没改注释, 或者复制了另一个定时器的初始化段之后只改了其中一两个参数。

/* 定时器初始化:只改参数不改注释,是这类问题的根源 */
/* 下面这段注释里的数字,必须能由这三个参数算出来,否则就是错的 */
#define TIM_CLK_HZ      1000000UL   /* 分频后的计数时钟 */
#define TIM_PERIOD_US   2000UL      /* 期望的中断周期(微秒) */

#define TIM_RELOAD      (TIM_CLK_HZ / 1000000UL * TIM_PERIOD_US - 1UL)
/*                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                       把"周期"用微秒表达,让重装值由它算出来,
                       而不是直接写一个裸数字 —— 裸数字没人知道它对应多少时间 */

我现在的做法是:重装值不再手写,而是由“期望周期”反算出来。 这样注释里写的周期就成了唯一事实来源,改了周期,重装值自动跟着变; 即使注释过期,它离真相也只差一行代码的距离,而不是两次推导。

三、怎么核算一个定时器(五步清单)

读到一个定时器配置,按下面五步走一遍,基本不会漏。这五步我是在反复算错之后总结的。

步骤要确认什么常见陷阱
1. 找时钟源 这个定时器挂在哪条总线上、总线时钟是多少、是否有倍频 同一颗芯片不同定时器挂不同总线,频率不一样;有的芯片定时器时钟有独立倍频位
2. 看分频 预分频寄存器的值 → 实际分频比(注意“值”与“比”差 1) 寄存器写 N 通常表示 N+1 分频;宏名有时叫“分频”有时叫“分频值”
3. 算一拍 分频后的计数时钟周期 = 1 / 计数频率 把“计数频率”当“周期”用;MHz 与 µs 混用
4. 乘重装值 中断周期 = 一拍 × (重装值 + 1) 忘了 +1(向上/向下计数的差异);计数模式不同结果不同
5. 核对注释 算出来的数字与注释、与上层代码的假设是否一致 上层代码往往按注释写逻辑——注释错了,逻辑就错了
从左到右:总线时钟 → 预分频 → 计数一拍 → 重装值 → 中断周期,每一步下方标注最容易出错的单位或进制问题
图 1 · 定时器时钟链路换算图

四、坑二:单位换算的静默错误

单位错误比时间错误更隐蔽,因为它往往“看起来合理”。我遇到过两种典型。

4.1 进制混淆:400 还是 400?

有一条修改记录写的是“系统栈大小由 400 改为 1000”,括号里注明“(4K)”。 看起来像是把栈从 400 字节加到 1000 字节、约 1 KB——如果真是这样,加完还是不够。 实际去看启动文件才发现,这两个数字都是十六进制: 0x400 = 1 KB,0x1000 = 4 KB。 也就是说真实改动是“从 1 KB 加到 4 KB”。

这条记录本身没写错(括号里的 4K 是对的),但看记录的人很容易按十进制理解。 我当时就愣了一下,去数了一遍才确认。而这次改动对应的现象是“运行中栈空间不足、程序跑飞”—— 一个偶发、难复现、容易被归因到别处的问题。

4.2 秒与毫秒:一个潜伏了一整个版本的单位错误

另一条修改记录写的是“加注时间计算错误,已去除 /1000,改记录为秒; V2.2 之前的程序 1 代表 16.6 分钟”。

意思是:内部按毫秒累计,显示和记录时却当成了秒——于是内部计时 1000 秒(约 16.7 分钟) 被记成了 1。这个错误在上一个版本里一直存在,而且因为“记录出来的数字总是很小”, 看起来只是“这次加注时间很短”,不像故障。

这类错误的检测方法其实很简单:拿一个已知时长的事件去对照记录。 如果你掐着表按了 3 分钟,记录显示的是 3,说明对;显示 0.18 或 180,就说明单位错了。 我现在的习惯是,凡是新增一个“时长”字段,都先用一个人工可测量的动作验证一遍。

左右对照,左边写十六进制的容量写法,右边写它对应的十进制与字节数,用醒目的箭头体现“看起来是 400,实际是 1024”
图 2 · 进制误读示意对比图

五、坑三:同一个换算散落在很多地方

整理时我还发现,同一族 ADC 换算公式在同一批代码里出现了四种写法:

写法用途问题
码值 × 参考电压 / 满量程 × 分压比带分压的母线电压通道分压比写死在表达式里
码值 × 参考电压 / 满量程 × 整定系数传感器通道整定系数来源不明
满量程电压 × 码值 / 满量程码值另一个模块的同类通道与上面两种写法等价但顺序不同
上面任一种之后再整体加一个固定偏移修正系统偏差修正量硬编码

四种写法本身都能算对,问题在于改硬件时分压比要改,而它散落在四个文件里。 漏改一处,那一路就会长期带病运行——而且因为“一直有读数”,没人会怀疑它。

顺带说一个相关的坑:判定值和显示值用了同一个数

同一批代码里还有一条修改记录:发现“采集电压比实际输入电压低 0.3 V”,解决办法是在采集结果上加了 0.3 V。 但更值得注意的是它怎么加的——修正后的值用于显示与上报, 而欠压判定用的仍是未修正的原始值。

这看起来不一致,其实是对的:如果判定也用了修正值,那么修正量本身就变成了保护阈值的一部分, 以后调整修正量会静默改变保护点。把“参与判定的量”和“给人看的量”分开, 是一个值得刻意保留的设计。这一条我记在这里,是因为我第一眼看到时以为是 bug。

六、坑四:符号与精度

这一类是“数值对了但含义错了”,通常出现在温度、压力这类带正负和精度的量上。

  • 负温度的补码处理少了一步。 某单总线温度传感器的原始值是补码表示,负温需要“取反加一”; 代码里做了取反、也处理了符号,但漏了加一, 结果是负温区引入一个固定偏差。这个偏差在小数值时相对误差很大。
  • 注释里的精度和实际精度差十倍。 同一份驱动,头文件注释写“精度 0.1 ℃”, 而换算里乘的系数对应的是 0.01 ℃ 的分辨率。 如果上位机按 0.1 ℃ 去解析这个字段,读数就会差一个数量级。

检测办法:找两个已知点代进去算一遍。 0 ℃ 和 100 ℃、或者零点与满量程,手算一遍看结果对不对。 补码的“少加一”、精度的“差十倍”,用两个点一代就能暴露。

七、坑五:把超时阈值当成了精确值

最后这一类是关于“超时时间到底该多长”的。协议标准给的是理论下限, 而工程实现必须给足余量,两者不是一回事。

以串口帧间隔为例:标准规定帧内字节间隔不超过 3.5 个字符时间, 在 9600 bps、8N1 下算出来约 3.65 ms。这是理论值。 但发送端可能因为中断延迟、缓冲区搬运让实际间隔偶尔超过它, 如果你严格按 3.65 ms 去切帧,就会把一帧切成两帧。

我在不同项目里见到的实际取值有 5 ms、8 ms、以及“3 ms 无变化即判帧完成”等几种, 都比理论值宽——这是对的。判断取多少合适的方法不是背数字,而是:

  1. 先算理论下限(3.5 字符时间);
  2. 看发送端最坏情况下的字节间隔能到多少(中断优先级、DMA、是否有大块搬运);
  3. 取一个明显大于它的值,并且做成可配置,让它能随波特率和现场情况调整;
  4. 同时保留帧头/长度/校验的交叉验证——宁可少切一帧,也不要把一帧切错。

八、我的做法:把时间和单位集中管理

归纳完之后,我给自己定了几条规则,用来减少这一类问题:

  1. 时间不许裸写。凡是时间常量,一律写成“数值 + 单位后缀” (_MS / _US / _S), 禁止出现 1000 这种看不出单位的东西。
  2. 周期由目标反算。定时器重装值由“期望周期”算出来,不手写。
  3. 容量的进制写双份。凡涉及字节数、栈大小、缓冲区长度, 十六进制与十进制同时写,并带单位。
  4. 同一换算只留一处。把所有物理量换算集中到一个头文件, 每个常数后面写清来源(算出来的?示波器量的?厂家给的?)。
  5. 判定值与显示值分开命名。 例如把用于保护的量叫 *_raw、用于显示的叫 *_disp, 名字里就体现出区别,避免以后有人“顺手统一一下”。
  6. 新增时长字段先做一次人工对照。 掐着表测一遍,确认记录出来的数字与真实时长一致。
  7. 改栈/改缓冲之后,核对实际链接的文件。 不要只看源码目录里“应该有”的那个文件。

这几条都不难,难的是坚持。但它们的收益很直接: 这一类问题的排查成本极高、修复成本极低, 属于典型的“事前花十分钟、事后省两天”。

九、几条我反复用到的经验

  1. 注释不是事实,代码才是。看到时间、容量、精度这类声明, 条件反射地核算一遍。
  2. 症状离原因越远,越要先怀疑常量。 “反应慢”“偶尔超时”“不算错但就是不对”,优先去核对时间与单位。
  3. 一次改动要连带核对注释与上层假设。 改配置不改注释,等于给后来人埋了一个陷阱。
  4. 用两个已知点验证任何换算。零点与满量程、0 ℃ 与 100 ℃, 手算一遍只需一分钟。
  5. 把“余量”和“标准”分开写。标准给的是下限, 实现取的是有余量的值,文档里要说清楚这是两个数字。

参考资料与说明

  • 所用各系列 MCU 的参考手册中关于定时器时钟树、预分频与自动重装寄存器的章节。
  • Modbus 应用协议规范中关于 RTU 传输模式帧间隔(3.5 个字符时间)的定义。
  • 所用量产工具链的启动文件与链接脚本说明(栈大小与堆大小的配置位置)。
  • 本文将多个个人项目中的同类缺陷做了归纳,不针对任何具体工程; 文中涉及的数值均用于说明“注释与实算不一致”这一现象,不构成对任何产品的参数说明。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
  • 文中芯片与工具名称仅用于说明技术方案,与相关厂商无隶属或授权关系。