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 超时处理中的竞态条件:定时器到了,但连接已经关了

这是协议栈里最隐蔽的坑。场景是这样的:

  1. 连接 A 发送数据,启动重传定时器。
  2. 收到 ACK,关闭定时器,释放连接 A。
  3. 定时器回调刚好在「关闭定时器」之前触发了。
  4. 回调里访问了已经释放的连接 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 本章知识体系

下面这张图,把定时器相关的陷阱和应对策略串起来了。你可以把它当作一个检查清单:

定时器与超时陷阱知识体系 定时器与超时陷阱 · 知识体系 定时器陷阱 精度不足 回调上下文错误 竞态条件 区分软/硬件定时器 提高定时器任务优先级 超时判断留精度余量 回调只做最小操作 用信号量/标志位传递 注意栈空间限制 超时事件队列解耦 释放前先关定时器 单任务操作定时器 核心原则:定时器回调只做最小操作 用事件队列解耦 + 单任务管理 + 释放前关定时器

这张图把三大陷阱和对应的解决方案都列出来了。你可以把它贴在工位旁边,写代码时对照着检查。

定时器这东西,看着简单,用起来全是细节。精度、上下文、竞态、数据结构,每一个都能让你加班到深夜。但只要你把这些原则刻在脑子里,大部分坑都能绕过去。