如何可靠、安全地检测 hardware timer overflow 导致的逻辑错误
结论
只看两个 32-bit 模时间戳,无法判断实际经过的是 x 微秒,还是 x + n × 2^32 微秒。计数器回绕后,这些时长对应的位模式完全相同,比较公式无法找回未记录的回绕次数。
要可靠判断时间,必须增加约束或额外状态,例如:
- 保证在计时器完整回绕前处理事件状态。
- 为事件保存
pending/active状态,超时后将其清除。 - 扩展计数器位宽,并规定系统允许的最长停顿时间。
- 使用硬件 one-shot timer、比较中断或单调递增的高位计数器记录额外信息。
如果需求是“事件发生后 1 秒内有效”,通常可以增加一个有效标志,同时保证主循环在每个 32-bit 回绕周期内至少处理一次过期状态。
为什么无符号减法仍会误判
假设计数器模数为:
M = 2^32
表达式:
uint32_t elapsed = now - eventTime;
无符号整数运算会自动按模 M 计算,所以能正确处理一次回绕。例如:
eventTime = 0xFFFFFF00
now = 0x00000100
elapsed = 0x00000200
这是计算无符号时间差的常用方式,不需要手动判断 now < eventTime。
但它计算的是:
elapsed = 实际经过时间 mod M
如果实际经过时间分别为:
x
M + x
2M + x
结果都会是 x。因此:
if (now - eventTime < 1000000)
只有在确定实际经过时间小于一个完整回绕周期时,才能表示“事件发生在最近 1 秒内”。如果事件可能静默超过一个回绕周期,这个条件提供的信息就不够了。
使用显式的事件有效状态
可以单独保存“是否存在尚未过期的事件”这一状态。事件到来时设置状态,主循环发现事件过期后再将其清除:
#include <stdint.h>
constexpr uint32_t EVENT_WINDOW_US = 1000000UL;
volatile uint32_t eventTime;
volatile bool eventPending;
void someISR()
{
eventTime = static_cast<uint32_t>(micros());
eventPending = true;
}
void loop()
{
bool pending;
uint32_t timestamp;
// 这里必须使用目标平台提供的临界区或原子读取机制。
enterCriticalSection();
pending = eventPending;
timestamp = eventTime;
leaveCriticalSection();
if (!pending) {
return;
}
const uint32_t now = static_cast<uint32_t>(micros());
const uint32_t elapsed = now - timestamp;
if (elapsed < EVENT_WINDOW_US) {
// 事件仍处于 1 秒有效窗口内
runEventCode();
return;
}
// 只清除本次读取的事件,避免覆盖刚由 ISR 写入的新事件。
enterCriticalSection();
if (eventPending && eventTime == timestamp) {
eventPending = false;
}
leaveCriticalSection();
}
enterCriticalSection() 和 leaveCriticalSection() 是占位名称,需要替换成目标 MCU 或框架提供的机制,例如能够保存并恢复中断状态的临界区。
清除 eventPending 后,即使计数器再次回到相同的低 32 位,这个事件也不会重新通过判断。
该方案有一个运行前提:主循环必须在事件发生后的一个完整计数器周期内执行过期处理。准确地说,状态需要在 EVENT_WINDOW_US 结束后、时间差再次落入有效窗口前被清除。如果系统可能暂停超过整个回绕周期,就要保存更多历史信息。
注意 ISR 与主循环之间的数据同步
volatile 只要求每次访问都实际读写对象。它不保证复合操作具有原子性,也不能提供完整的并发同步。
在 8-bit MCU 上,读取一个 32-bit uint32_t 通常需要多条指令。如果 ISR 在读取期间更新 eventTime,主循环可能读到由新旧字节拼成的错误值。共享数据应在很短的临界区内完成复制,时间计算和业务处理则放在临界区外执行。
还要处理下面这种竞争:
- 主循环读取旧事件。
- ISR 写入新事件。
- 主循环根据旧事件已经超时,直接将
eventPending清零。
示例通过条件清除避免覆盖新事件。如果事件非常密集,或者多个事件需要分别处理,单个时间戳和布尔标志无法满足要求,应改用计数器或队列。
也不能默认 std::atomic<uint32_t> 一定适合 ISR。它是否 lock-free、底层是否会调用运行库,以及能否在 ISR 中安全使用,都取决于编译器和目标平台。
ISR 应尽量只记录状态
ISR 通常只需要记录必要的状态:
void someISR()
{
eventTime = static_cast<uint32_t>(micros());
eventPending = true;
}
超时判断和耗时的业务操作交给主循环,以免其他中断长时间等待。
能否在 ISR 内调用 micros() 取决于平台实现。有些平台通过定时器中断维护 micros() 的高位计数,而 ISR 执行期间,相关中断可能无法及时运行。短 ISR 带来的影响通常有限,但这不是跨平台保证。对精度要求较高时,可以直接捕获硬件定时器寄存器,或使用平台提供的 input capture 功能。
什么时候需要扩展为 64-bit
如果系统可能在超过一个 32-bit 周期后才处理事件,就必须保存额外信息。常见做法是提供扩展时钟:
uint64_t extendedMicros();
扩展时钟通常由低位 hardware timer 和软件高位计数器组成。读取高低位时必须保证两者一致,软件也要及时观察或处理每次回绕产生的中断,否则仍会漏掉回绕次数。
64-bit 计数器最终同样会回绕。任何有限位宽的计数器都有这个问题,因此系统仍需规定可接受的最大时间跨度。以微秒为单位的 64-bit 无符号计数器,回绕周期远超一般设备的设计寿命,可以据此设定一个能够实际验证的运行边界。
如果 64-bit 运算成本较高,可以只在维护扩展时间或记录事件时使用 64-bit 操作。频繁执行的短周期判断仍可使用 32-bit 差值。
signed 时间比较不能补回丢失的信息
有些代码会用下面的形式比较 deadline:
if (static_cast<int32_t>(now - deadline) >= 0) {
// deadline reached
}
当平台采用预期的二补码转换语义,并且所有待比较时间点之间的差值小于半个计数范围时,这种写法很实用。对于 32-bit 计数器,比较跨度通常需要限制在 2^31 tick 以内。
这种比较仍然分不清已经经过了 x、2^32 + x,还是更多周期。signed 比较只能按照预先约定的时间范围解释模运算结果,无法恢复未记录的回绕次数。
实际选择建议
如果事件只在短时间内有效,而且主循环能够稳定运行,可以采用以下组合:
- 用固定宽度的
uint32_t保存时间戳。 - 通过
now - eventTime计算无符号时间差。 - 用
eventPending表示事件是否仍可参与判断。 - 事件超时后主动清除
eventPending。 - 在短临界区内复制共享数据,避免 ISR 与主循环之间出现撕裂读取和竞争。
- 明确要求主循环在一次完整回绕前完成过期处理。
如果无法保证这个处理时限,就需要使用扩展时钟、overflow counter、硬件捕获或持久化事件队列。overflow counter 最终也会溢出,但只要系统规定的最大可比较时间远小于它的回绕周期,就不会影响这套逻辑。
备注:内容仅供参考。
