一、为什么“时间”这类 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. 核对注释 | 算出来的数字与注释、与上层代码的假设是否一致 | 上层代码往往按注释写逻辑——注释错了,逻辑就错了 |
四、坑二:单位换算的静默错误
单位错误比时间错误更隐蔽,因为它往往“看起来合理”。我遇到过两种典型。
4.1 进制混淆:400 还是 400?
有一条修改记录写的是“系统栈大小由 400 改为 1000”,括号里注明“(4K)”。
看起来像是把栈从 400 字节加到 1000 字节、约 1 KB——如果真是这样,加完还是不够。
实际去看启动文件才发现,这两个数字都是十六进制:
0x400 = 1 KB,0x1000 = 4 KB。
也就是说真实改动是“从 1 KB 加到 4 KB”。
这条记录本身没写错(括号里的 4K 是对的),但看记录的人很容易按十进制理解。 我当时就愣了一下,去数了一遍才确认。而这次改动对应的现象是“运行中栈空间不足、程序跑飞”—— 一个偶发、难复现、容易被归因到别处的问题。
0x1000(4096 字节 = 4 KB)。多写这十来个字,能省掉后来人一次困惑。
另外,改栈大小这种改动要连带核对实际链接的是哪个启动文件——
同目录下往往躺着好几个不同型号的启动文件,只有工程实际引用的那一个才起作用。
4.2 秒与毫秒:一个潜伏了一整个版本的单位错误
另一条修改记录写的是“加注时间计算错误,已去除 /1000,改记录为秒;
V2.2 之前的程序 1 代表 16.6 分钟”。
意思是:内部按毫秒累计,显示和记录时却当成了秒——于是内部计时 1000 秒(约 16.7 分钟) 被记成了 1。这个错误在上一个版本里一直存在,而且因为“记录出来的数字总是很小”, 看起来只是“这次加注时间很短”,不像故障。
这类错误的检测方法其实很简单:拿一个已知时长的事件去对照记录。 如果你掐着表按了 3 分钟,记录显示的是 3,说明对;显示 0.18 或 180,就说明单位错了。 我现在的习惯是,凡是新增一个“时长”字段,都先用一个人工可测量的动作验证一遍。
五、坑三:同一个换算散落在很多地方
整理时我还发现,同一族 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 无变化即判帧完成”等几种, 都比理论值宽——这是对的。判断取多少合适的方法不是背数字,而是:
- 先算理论下限(3.5 字符时间);
- 看发送端最坏情况下的字节间隔能到多少(中断优先级、DMA、是否有大块搬运);
- 取一个明显大于它的值,并且做成可配置,让它能随波特率和现场情况调整;
- 同时保留帧头/长度/校验的交叉验证——宁可少切一帧,也不要把一帧切错。
八、我的做法:把时间和单位集中管理
归纳完之后,我给自己定了几条规则,用来减少这一类问题:
- 时间不许裸写。凡是时间常量,一律写成“数值 + 单位后缀”
(
_MS/_US/_S), 禁止出现1000这种看不出单位的东西。 - 周期由目标反算。定时器重装值由“期望周期”算出来,不手写。
- 容量的进制写双份。凡涉及字节数、栈大小、缓冲区长度, 十六进制与十进制同时写,并带单位。
- 同一换算只留一处。把所有物理量换算集中到一个头文件, 每个常数后面写清来源(算出来的?示波器量的?厂家给的?)。
- 判定值与显示值分开命名。
例如把用于保护的量叫
*_raw、用于显示的叫*_disp, 名字里就体现出区别,避免以后有人“顺手统一一下”。 - 新增时长字段先做一次人工对照。 掐着表测一遍,确认记录出来的数字与真实时长一致。
- 改栈/改缓冲之后,核对实际链接的文件。 不要只看源码目录里“应该有”的那个文件。
这几条都不难,难的是坚持。但它们的收益很直接: 这一类问题的排查成本极高、修复成本极低, 属于典型的“事前花十分钟、事后省两天”。
九、几条我反复用到的经验
- 注释不是事实,代码才是。看到时间、容量、精度这类声明, 条件反射地核算一遍。
- 症状离原因越远,越要先怀疑常量。 “反应慢”“偶尔超时”“不算错但就是不对”,优先去核对时间与单位。
- 一次改动要连带核对注释与上层假设。 改配置不改注释,等于给后来人埋了一个陷阱。
- 用两个已知点验证任何换算。零点与满量程、0 ℃ 与 100 ℃, 手算一遍只需一分钟。
- 把“余量”和“标准”分开写。标准给的是下限, 实现取的是有余量的值,文档里要说清楚这是两个数字。
参考资料与说明
- 所用各系列 MCU 的参考手册中关于定时器时钟树、预分频与自动重装寄存器的章节。
- Modbus 应用协议规范中关于 RTU 传输模式帧间隔(3.5 个字符时间)的定义。
- 所用量产工具链的启动文件与链接脚本说明(栈大小与堆大小的配置位置)。
- 本文将多个个人项目中的同类缺陷做了归纳,不针对任何具体工程; 文中涉及的数值均用于说明“注释与实算不一致”这一现象,不构成对任何产品的参数说明。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
- 文中芯片与工具名称仅用于说明技术方案,与相关厂商无隶属或授权关系。