4. 定时器与超时陷阱:软件定时器精度不足、定时器回调上下文错误、超时处理中的竞态条件
定时器,说白了就是协议栈的「心跳」。我见过太多协议栈跑着跑着就崩了,最后查下来,十有八九是定时器在搞鬼。你想想看,一个 TCP 重传定时器早到了 10 毫秒,或者一个 MQTT 保活定时器晚到了 500 毫秒,整个协议状态机就乱套了。
这一章,我把这些年踩过的定时器坑,一个一个掰开来讲。
4.1 软件定时器精度:你以为的 10ms,其实是 50ms
很多嵌入式 RTOS 都提供软件定时器。但它们的精度,远没有你想象的那么高。
我习惯把定时器精度分为三个等级:
| 等级 | 典型实现 | 实际精度 | 适用场景 |
|---|---|---|---|
| 低精度 | 基于系统 Tick(1ms~10ms) | ±1 Tick | 保活、心跳、周期性轮询 |
| 中精度 | 基于硬件定时器中断 | ±1 硬件定时器周期 | 协议重传、超时检测 |
| 高精度 | 硬件定时器 + DMA/PWM | 微秒级 | 物理层时序、时间戳 |
我在项目中遇到过一个问题:一个 TCP 协议栈,重传超时设了 200ms。结果在压力测试下,实际超时时间变成了 250ms 到 300ms 不等。查了半天,发现是软件定时器的 Tick 是 10ms,但定时器任务优先级太低,被其他中断抢占了。
怎么解决?我个人的做法是:
- 区分精度需求:协议栈的快速重传(比如 RTO 计算)用硬件定时器;保活和心跳用软件定时器。
- 设置定时器任务优先级:至少比普通应用任务高一个等级。
- 不要依赖绝对精度:超时判断时,留一个「精度余量」。比如期望 200ms,实际判断用 220ms。
4.2 定时器回调上下文:你在谁的「地盘」上跑?
这是新手最容易犯的错。定时器回调函数,到底在哪个线程里执行?
我见过最离谱的一次,有人在定时器回调里直接调了 malloc()。结果系统在中断上下文里调了内存分配,直接死锁。
常见的定时器回调上下文有三种:
| 上下文类型 | 特点 | 风险 |
|---|---|---|
| 中断上下文 | 优先级最高,不能阻塞 | 调阻塞函数、调 printf、调 malloc |
| 定时器任务上下文 | 专用任务,可阻塞 | 优先级反转、死锁 |
| 用户任务上下文 | 在调用 TimerCheck() 的线程中执行 |
调用频率不稳定 |
核心原则: 定时器回调里只做「最小必要操作」。比如设置一个标志位、发一个信号量。真正的协议处理,放到协议栈的主循环或专用任务里去做。
嗯,这里要注意。如果你用的是 FreeRTOS 的软件定时器,它的回调是在 TimerTask 里执行的。这个任务的栈空间有限,千万别在里面开大数组。
我曾经在回调里定义了一个 1KB 的局部变量,结果栈溢出了。排查了两天才找到原因。
4.3 超时处理中的竞态条件:定时器到了,但连接已经关了
这是协议栈里最隐蔽的坑。场景是这样的:
- 连接 A 发送数据,启动重传定时器。
- 收到 ACK,关闭定时器,释放连接 A。
- 定时器回调刚好在「关闭定时器」之前触发了。
- 回调里访问了已经释放的连接 A 的内存——崩溃。
为什么会这样?因为定时器到期和连接释放,发生在不同的任务/中断里。你没法保证它们的执行顺序。
我的习惯: 在定时器回调里,不要直接操作协议对象。而是通过一个「超时事件队列」来传递事件。主循环从队列里取事件,再判断对象是否还活着。
具体做法:
- 定时器到期时,往队列里塞一个
TimeoutEvent{conn_id, timer_type}。 - 主循环处理事件时,先查
conn_id是否有效。 - 如果连接已释放,直接丢弃事件。
这样就把「定时器回调」和「连接管理」解耦了。竞态条件自然消失。
4.4 定时器管理的数据结构陷阱
协议栈里定时器数量一多,管理就成了问题。我见过有人用链表管理所有定时器,每次 Tick 都遍历一遍。结果 100 个定时器,每个 Tick 都要遍历 100 次,CPU 占用率直接拉满。
更好的做法是用「时间轮」或「最小堆」:
| 数据结构 | 插入复杂度 | 到期检查复杂度 | 适用场景 |
|---|---|---|---|
| 有序链表 | O(n) | O(1) | 定时器数量少(< 20) |
| 最小堆 | O(log n) | O(1) | 通用场景 |
| 时间轮 | O(1) | O(1) | 大量定时器(> 1000) |
我个人习惯用最小堆。实现简单,性能也够用。时间轮虽然快,但实现起来容易出 bug,尤其是多级时间轮的级联处理。
4.5 避坑指南:我踩过的那些定时器坑
- 我曾经在定时器回调里直接调了
vTaskDelay(),结果整个定时器任务卡住了。所有定时器都不跑了。 - 我曾经在释放连接时忘了关定时器,结果回调里访问了野指针。那个 bug 查了整整一周。
- 我曾经把定时器周期设成了 0,结果系统在中断里疯狂触发,直接死机。
- 我曾经在多个任务里同时操作同一个定时器,没有加锁。结果定时器状态错乱,该停的没停,不该停的停了。
铁律: 定时器的启动、停止、修改,必须在同一个任务/线程里做。如果做不到,就用互斥锁保护。别偷懒。
4.6 本章知识体系
下面这张图,把定时器相关的陷阱和应对策略串起来了。你可以把它当作一个检查清单:
这张图把三大陷阱和对应的解决方案都列出来了。你可以把它贴在工位旁边,写代码时对照着检查。
定时器这东西,看着简单,用起来全是细节。精度、上下文、竞态、数据结构,每一个都能让你加班到深夜。但只要你把这些原则刻在脑子里,大部分坑都能绕过去。