嵌入式面试真题第 13 题:单硬件 Timer 下的高精度多路软件定时器架构设计

# 嵌入式面试真题第 13 题:单硬件 Timer 下的高精度多路软件定时器架构设计

问题

在资源受限的实时系统、嵌入式设备、工业控制器、音视频终端、通信协议栈或边缘计算节点中,硬件可能只允许软件独占一个可编程高精度 Timer,或者虽然芯片内部存在多个 Timer,但其他通道已经被 PWM、输入捕获、编码器、操作系统 Tick、协议时基等功能占用。

与此同时,系统内部往往存在数量不定、周期跨度很大、实时等级不同的软件时间事件,例如:协议超时、传感器采样、控制环触发、音频帧节拍、设备状态机延时、重传定时、去抖、看门狗喂狗窗口、异步操作截止时间、短脉冲控制、统计采样和低功耗唤醒等。部分事件要求微秒级时间基准,部分事件只要求毫秒级;部分事件只需记录“已到期”,部分事件需要尽快执行;还有少数事件可能具有硬实时属性。

如果每个模块各自占用硬件 Timer,硬件资源很快就会耗尽;如果全部依赖 RTOS Tick,分辨率、抖动和长周期漂移又可能无法满足要求;如果直接在 Timer ISR 中遍历全部定时器并执行回调,则一次集中到期、复杂回调或异常路径就可能拉长中断时间,阻塞更高优先级的数据采集、通信或音频流任务。

请设计一套可复用的多路软件定时器服务,使唯一硬件 Timer 同时承担高精度单调时间基准和最近截止时间触发器,并说明:

  1. 如何组织任意数量的一次性和周期性软件定时器;
  2. 如何选择最小堆、红黑树、差分链表、时间轮或混合结构;
  3. 如何处理 ISR、回调线程、并发启停、取消、重启和删除;
  4. 如何定义微秒级“精度”,并控制中断延迟、调度延迟和回调抖动;
  5. 如何处理计数器回绕、低功耗、动态时钟、集中到期和系统过载;
  6. 如何保证高优先级实时任务不被软件定时器回调阻塞;
  7. 如何量化容量、执行预算、队列深度并完成系统化验证。

回答

结论:把唯一硬件 Timer 设计成“自由运行的单调时钟 + 单次 Compare/Alarm 触发器”,所有软件定时器统一保存为绝对截止时间。软件层使用按截止时间排序的数据结构管理待触发对象,只把最近截止时间写入硬件 Compare。硬件中断只完成时间戳捕获、到期标记、有限数量的事件转移和下一次 Compare 编程;普通回调由受控优先级的 Timer Dispatcher 或业务工作队列执行,不能在 ISR 中执行不可预测的业务逻辑。

这套设计的核心不是“用一个硬件 Timer 模拟很多硬件 Timer”,而是把时间系统拆成四个相互独立的平面:

  • 时间平面:提供连续、单调、可换算的绝对时间;
  • 调度平面:维护所有软件定时器的截止时间和状态;
  • 触发平面:用唯一硬件 Compare 唤醒系统处理最近截止事件;
  • 执行平面:按照实时等级、优先级和预算执行回调或投递业务事件。

只要这四个平面解耦,就能同时满足高精度、可扩展、低中断占用、可测试和不干扰关键任务等要求。

总体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flowchart LR
A[业务模块 / 协议栈 / 驱动] --> B[High-Resolution Timer API]
B --> C[Timer Core]
C --> D[Absolute Timebase\n64-bit monotonic time]
C --> E[Pending Timer Queue\nheap / rbtree / wheel]
C --> F[State and Generation\n状态机与代次]
E --> G[Next Deadline]
G --> H[One Hardware Timer\nfree-running counter + compare]
H --> I[Timer ISR]
I --> J[Expired Event Queue]
J --> K[Timer Dispatcher Task]
K --> L[Fast Callback Lane]
K --> M[Normal Work Queue]
K --> N[Low-priority Work Queue]
O[Audio DMA / Control Loop / Critical ISR] -.更高优先级.-> I

从模块边界看,业务模块不应直接操作硬件寄存器,也不应自行维护倒计时变量。业务只通过统一 API 创建、启动、停止和查询定时器。Timer Core 负责绝对时间换算、队列排序、并发保护、硬件 Compare 更新、到期状态迁移和统计;Timer ISR 只负责触发链路;Timer Dispatcher 决定回调在哪个执行上下文运行。

推荐把硬件层抽象成以下最小接口:

1
2
3
4
5
6
7
clock_now_ticks()                 读取自由运行计数器
clock_ticks_to_ns()/us() 时间单位换算
alarm_set_absolute(deadline) 设置绝对 Compare
alarm_cancel() 关闭 Compare 中断
alarm_ack_irq() 清除硬件中断状态
clock_get_frequency() 获取计数频率
clock_is_always_on() 查询休眠期间是否继续运行

Timer Core 不依赖某个 MCU 的具体 Timer 名称。底层可以是通用定时器、SysTick 之外的基本定时器、ARM Generic Timer、RISC-V mtime/mtimecmp、SoC System Timer、低功耗 RTC Alarm,甚至 Linux 用户态的 timerfd。只要能够提供单调计数和“下一次绝对截止时间”中断,软件层结构都可以复用。

先澄清:微秒级分辨率不等于微秒级回调实时性

讨论高精度定时器时,必须区分五个容易混淆的指标。

指标 含义 主要决定因素
时间分辨率 可表达或可测量的最小时间单位 计数器频率、换算精度、API 单位
触发精度 硬件 Compare 相对目标截止时间的偏差 Timer 时钟、Compare 机制、编程延迟
中断延迟 截止时间到 ISR 开始执行的延迟 中断优先级、临界区、屏蔽时间、总线竞争
分发延迟 ISR 到回调执行上下文真正运行的延迟 事件队列、调度器、任务优先级、系统负载
回调抖动 多次触发时回调延迟的波动范围 抢占、同批到期、锁竞争、缓存和内存访问

例如,硬件计数器每 1 us 增加一次,只能说明时间基准具有 1 us 的量化分辨率,并不能保证业务回调一定在截止时间后 1 us 内开始执行。一个任务态回调的实际开始时间可以写成:

1
2
3
4
5
6
T_callback_start = T_deadline
+ T_irq_latency
+ T_isr
+ T_event_queue
+ T_scheduler
+ T_prior_callbacks

因此,对外 API 应明确承诺什么:

  • deadline 是高精度绝对时间;
  • ISR 触发时间具有给定误差上界;
  • 普通回调只保证“尽快分发”,其最坏延迟由任务优先级和执行预算决定;
  • 真正需要亚十微秒确定性的动作,应优先使用硬件输出比较、PWM、DMA、外设触发链或专用 Timer 通道,而不是普通软件回调。

这是整个设计的边界条件。软件定时器可以复用单个硬件时基,但无法消除 CPU 抢占、关中断、总线阻塞和回调执行时间带来的物理限制。

绝对时间模型

为什么统一使用绝对截止时间

每个软件定时器都应保存绝对到期时间 deadline,而不是持续递减的剩余时间。相对延时只在 API 入口转换一次:

1
deadline = now + delay

绝对时间有四个直接收益:

  1. 不需要每个 Tick 遍历并递减所有定时器;
  2. 比较两个截止时间时不依赖调用顺序;
  3. 周期定时器可基于上一个理论截止时间续算,避免执行延迟造成累积漂移;
  4. 系统从休眠恢复后,只需读取新的单调时间并判断哪些事件已经过期。

单调时钟与墙上时间分离

软件定时器必须使用单调时钟,不应直接使用可被校时、NTP、RTC 更新或用户修改的“日期时间”。墙上时间突然向前或向后调整,会导致超时提前、重复或长期不触发。

推荐内部使用 64 位整数表示纳秒、微秒或硬件 Tick:

1
2
3
uint64_t now;
uint64_t deadline;
uint64_t period;

如果底层计数器频率为 F_timer,把微秒延时转换为硬件 Tick 时应采用向上取整,避免提前触发:

1
delta_ticks = ceil(delay_us * F_timer / 1_000_000)

在整数实现中要防止乘法溢出,可使用 128 位中间值、分解计算或预先计算的定点比例。对频率不是 1 MHz 整数倍的 Timer,还要明确换算误差和长期累积误差。

计数器宽度与回绕

64 位、1 MHz 自由运行计数器的回绕周期极长,通常可以忽略;但许多 MCU 只提供 16 位或 32 位 Timer。以 32 位、1 MHz 为例,约 71.6 分钟回绕一次,不能直接把原始计数值当作长期绝对时间。

常见处理方法有三类:

  1. 硬件级联或 64 位 Timer:优先使用,逻辑最简单;
  2. 溢出中断扩展:每次硬件溢出增加软件高位,组合成 64 位时间;
  3. 周期采样扩展:保证读取间隔小于一个回绕周期,通过差值累计高位。

读取“软件高位 + 硬件低位”时必须防止读取过程中发生溢出。可采用“高位—低位—高位”双读法,若两次高位不一致则重读;也可在极短临界区内读取。

如果硬件 Compare 只能在一个回绕周期内可靠比较远近,则应遵循半范围比较原则:只有当 deadline - now 小于计数范围的一半时,才直接写入目标 Compare;更远的截止时间先编程一个中间唤醒点,之后再推进。这能避免无符号回绕比较产生歧义。

唯一硬件 Timer 如何复用

硬件 Timer 采用自由运行模式,不为每个软件定时器重新清零。硬件 Compare 始终指向当前待处理队列中的最早截止时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
sequenceDiagram
participant App as 业务线程
participant Core as Timer Core
participant Heap as Pending Queue
participant HW as Hardware Timer
participant ISR as Timer ISR
participant Disp as Dispatcher

App->>Core: start(timer, delay)
Core->>Core: deadline = now + delay
Core->>Heap: 插入定时器
alt 新定时器成为最早截止项
Core->>HW: compare = deadline
end
HW-->>ISR: counter >= compare
ISR->>ISR: capture now / ack IRQ
ISR->>Disp: 投递到期通知
ISR->>HW: 设置下一次 compare 或暂时关闭
Disp->>Heap: 取出所有已到期定时器
Disp->>Disp: 执行/转投回调

关键点是:硬件不需要知道系统中有多少软件定时器,只需要知道下一次必须唤醒 CPU 的时刻。软件层每次插入、删除或重新启动定时器后,只在“堆顶发生变化”时更新 Compare。

这种模式本质上是 Tickless One-Shot:没有待处理事件时不产生周期中断;有事件时只在最近截止时间到达时中断。与固定 1 kHz 或 10 kHz Tick 相比,它能减少无效中断、降低功耗,并把时间精度从 RTOS Tick 中解耦出来。

待触发定时器的数据结构选型

通用实现不应盲目宣称某一种结构永远最好。定时器数量、截止时间分布、插入/取消频率、内存预算、同一时刻集中到期数量和精度要求都会影响选择。

数据结构 插入 删除/取消 取最早项 内存特征 适用场景
有序差分链表 O(N) O(N) 或 O(1) O(1) 每项开销低 定时器数量少,插入不频繁
二叉最小堆 O(log N) O(log N) O(1) 数组连续、缓存友好 16~数百个高精度定时器
红黑树 O(log N) O(log N) O(1) 或 O(log N) 指针较多 动态数量大、频繁取消、严格排序
分层时间轮 近似 O(1) 近似 O(1) 按槽推进 静态桶和链表开销较大 大量低/中精度超时
桶化差分队列 近端 O(1),远端退化 视实现而定 近端快 介于链表与时间轮之间 截止时间集中在近未来
混合结构 取决于组合 取决于组合 取决于组合 设计复杂 高精度短定时 + 大量长超时并存

有序差分链表

差分链表的每个节点不保存绝对时间,而保存相对前一节点的间隔。头节点到期时为 O(1),删除头节点后把其差值传递给下一节点。它的优势是实现简单、元数据少,适合同时活动定时器不超过十几个的小系统。

缺点是插入需要线性遍历;一个新定时器插入中间时还要修改后继节点的差值;并发取消和重启容易增加边界条件。定时器数量增长后,关中断临界区可能随 O(N) 线性增大,不适合严格限制 ISR 屏蔽时间的系统。

二叉最小堆

最小堆用连续数组保存节点,堆顶始终是最早截止项。插入和删除都是 O(log N),读取最早项 O(1),内存局部性好,特别适合固定最大容量的 MCU。

为了支持按句柄 O(log N) 取消,每个 Timer 对象应保存当前堆索引 heap_index。堆元素交换时同步更新索引,不能每次取消都线性搜索。对相同截止时间,可使用 (deadline, priority, sequence) 作为比较键,保证更高实时等级优先,并用递增序号获得稳定顺序。

对于几十到几百个高精度 Timer,最小堆通常是最均衡的默认方案。

红黑树

红黑树提供稳定的 O(log N) 插入和删除,并天然维持有序集合。Linux 高精度定时器 hrtimer 选择红黑树按绝对到期时间排序,同时用额外链路快速访问到期对象。红黑树适合动态创建/销毁频繁、定时器数量大、需要灵活删除的系统,但每个节点需要父子指针和颜色位,代码复杂度与元数据开销高于数组堆。

在无 MMU、内存紧张、最大定时器数量已知的 MCU 上,最小堆往往更容易验证;在大型 RTOS、内核或多核系统中,红黑树更有扩展性。

分层时间轮

时间轮把未来时间划分为槽,定时器按到期范围挂入不同层级的桶。临近到期时逐级下放,插入和删除可接近 O(1),特别适合成千上万个协议超时、连接超时和重传超时。

时间轮的代价是量化粒度、级联开销、静态桶内存和 Tickless 低功耗兼容性。微秒级精确截止事件如果直接放入粗粒度时间轮,容易产生额外量化误差;把时间轮粒度设得极细,又会消耗大量槽和推进成本。

推荐的混合方案

面对“少量高精度短定时 + 大量普通长超时”的通用系统,可采用双层设计:

1
2
3
4
5
6
flowchart TD
A[Timer Start] --> B{截止时间距离 now}
B -->|小于 HighRes Window| C[高精度最小堆 / 红黑树]
B -->|大于 HighRes Window| D[粗粒度时间轮 / 长期队列]
D -->|进入高精度窗口| C
C --> E[硬件 Compare 指向最早截止时间]

例如,把未来 100 ms 内的事件放入 1 us 精度最小堆,更远的事件放入 1 ms 或 10 ms 粒度时间轮。长定时器接近截止时间时再迁移到高精度队列。这样既避免用高精度结构维护海量长期超时,也不会牺牲临近截止阶段的精度。

Timer 对象与状态机

Timer 对象不应只有回调函数和到期时间。为了支持并发启停、取消、重启、过期事件排队和同步删除,至少需要以下字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @brief Software timer control block.
*/
typedef struct sw_timer {
uint64_t deadline;
uint64_t period;
uint32_t generation;
uint32_t heap_index;
uint16_t flags;
uint8_t state;
uint8_t dispatch_class;
void (*callback)(void *arg);
void *arg;
const char *name;
} sw_timer_t;

建议的状态包括:

1
2
3
4
5
6
7
IDLE             已创建但未启动
ARMED 位于待触发队列
EXPIRING 已被判定到期,正在从待触发队列迁移
QUEUED 到期事件已进入分发队列
CALLBACK_RUNNING 回调正在执行
CANCEL_PENDING 请求取消,但存在在途事件或回调
DELETING 正在同步删除,禁止重新启动
1
2
3
4
5
6
7
8
9
10
11
12
13
14
stateDiagram-v2
[*] --> IDLE
IDLE --> ARMED: start
ARMED --> ARMED: restart
ARMED --> IDLE: stop before expiry
ARMED --> EXPIRING: deadline reached
EXPIRING --> QUEUED: enqueue event
QUEUED --> CALLBACK_RUNNING: dispatcher runs
CALLBACK_RUNNING --> ARMED: periodic rearm
CALLBACK_RUNNING --> IDLE: one-shot complete
QUEUED --> IDLE: stale generation / async cancel
ARMED --> DELETING: cancel_sync + delete
CALLBACK_RUNNING --> DELETING: callback exits
DELETING --> [*]

为什么需要 generation

仅靠状态位不能完全解决“停止后旧事件仍在队列中”的竞态。例如:

  1. Timer 到期,ISR 已把事件写入分发队列;
  2. 业务线程调用 stop()
  3. 业务马上用新周期重新启动同一个 Timer;
  4. Dispatcher 随后取出旧事件并执行,误以为它属于新一轮启动。

解决方法是每次启动、停止或重置时递增 generation。到期事件中保存投递时的代次,Dispatcher 执行前比较:

1
2
if event.generation != timer.generation:
discard stale event

这种“代次失效”比试图从无锁队列中删除任意旧事件更简单,也适用于 DMA 完成、异步 I/O 和工作队列等通用异步对象。

API 设计

建议同时提供绝对时间和相对时间接口,并明确任务态与 ISR 安全版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* @brief Start or restart a one-shot timer using an absolute deadline.
* @param timer Timer object.
* @param deadline_us Absolute monotonic deadline in microseconds.
* @return 0 on success, otherwise a negative error code.
*/
int sw_timer_start_abs(sw_timer_t *timer, uint64_t deadline_us);

/**
* @brief Start or restart a timer after a relative delay.
* @param timer Timer object.
* @param delay_us Relative delay in microseconds.
* @param period_us Period in microseconds, or 0 for one-shot mode.
* @return 0 on success, otherwise a negative error code.
*/
int sw_timer_start(sw_timer_t *timer, uint64_t delay_us, uint64_t period_us);

/**
* @brief Stop a timer without waiting for an in-flight callback.
* @param timer Timer object.
* @return 0 on success, otherwise a negative error code.
*/
int sw_timer_stop(sw_timer_t *timer);

/**
* @brief Stop a timer and wait until any in-flight callback has finished.
* @param timer Timer object.
* @param timeout_us Maximum wait time in microseconds.
* @return 0 on success, otherwise a negative error code.
*/
int sw_timer_cancel_sync(sw_timer_t *timer, uint64_t timeout_us);

/**
* @brief Return the current monotonic time in microseconds.
* @return Current monotonic timestamp.
*/
uint64_t sw_timer_now_us(void);

可进一步增加:

1
2
3
4
5
6
7
sw_timer_start_from_isr()
sw_timer_stop_from_isr()
sw_timer_get_remaining()
sw_timer_is_active()
sw_timer_set_slack()
sw_timer_get_stats()
sw_timer_dump_all()

slack 表示允许合并的时间容差。例如一个状态灯更新允许延迟 2 ms,系统可把它与附近事件合并唤醒,减少中断和功耗;音频采样触发则设置为 0,不允许合并。

启动、停止与硬件 Compare 更新

启动路径

启动定时器的关键不是单纯插入队列,而是判断它是否成为新的最早截止项。

1
2
3
4
5
6
7
8
9
10
11
lock timer core
now = clock_now()
validate deadline and minimum lead time
if timer is already armed:
remove old entry
generation++
update deadline / period / state
insert into pending queue
if timer is new earliest item:
program hardware compare
unlock timer core

临界区必须短且有确定上界。不能在持锁期间分配内存、打印日志、等待队列或执行回调。

停止路径

停止 ARMED 状态的 Timer 时,从待触发结构删除即可。如果删除的是最早项,需要重新设置 Compare。对于已经 QUEUEDCALLBACK_RUNNING 的 Timer,普通 stop() 只能通过递增 generation 阻止旧事件继续生效,不能假装正在执行的回调已经消失。

因此需要区分两种取消语义:

  • 异步取消:返回时保证未来不会再开始新的有效回调,但可能已有回调正在运行;
  • 同步取消:返回时保证 Timer 不在队列中、没有待执行事件、回调也已经退出。

同步取消可能阻塞,只能在允许睡眠的任务上下文调用,不能从 ISR 或该 Timer 自己的回调中调用,否则可能死锁。

Compare 编程竞态

编程硬件 Compare 时存在经典竞态:读取 now 后,CPU 执行若干指令才写入 Compare;如果目标时间已经到达或距离太近,硬件可能无法产生预期中断。

安全做法是定义最小提前量 MIN_COMPARE_DELTA

1
programmed = max(deadline, now + MIN_COMPARE_DELTA)

写入 Compare 后再次读取计数器并检查是否已经越过目标;若已经越过,应触发软件补偿路径、设置立即中断或直接唤醒 Dispatcher,不能无条件等待一个已经错过的 Compare。

MIN_COMPARE_DELTA 应通过芯片手册和实测确定,包含寄存器同步、总线写入、时钟域跨越和中断置位的最坏时间。

ISR 应该做什么

Timer ISR 的目标是“时间确定、工作有界、不可阻塞”。推荐只执行以下动作:

  1. 清除或确认硬件中断;
  2. 捕获当前单调时间;
  3. 设置一个到期标志或向 ISR 安全队列投递轻量事件;
  4. 必要时转移有限数量的到期节点;
  5. 编程下一次 Compare,或者暂时关闭 Compare 并唤醒 Dispatcher;
  6. 记录 ISR 延迟和最长执行时间;
  7. 请求一次延后的上下文切换。

ISR 中禁止:

  • 调用 malloc/free
  • 打印同步日志;
  • 等待互斥量、信号量或消息;
  • 访问文件系统、Flash 擦写或网络栈复杂路径;
  • 执行无法给出最坏时间上界的业务回调;
  • 在持有 Timer Core 锁时调用外部函数;
  • 一次性无界遍历所有定时器。

两种 ISR 分工模式

模式 A:ISR 只唤醒 Dispatcher

ISR 只记录 now、关闭当前 Compare、通知高优先级 Timer Dispatcher。Dispatcher 取出所有到期对象并设置下一次 Compare。

优点是 ISR 极短;缺点是 Dispatcher 被更高优先级任务长期压制时,下一次 Compare 更新也会延迟。该模式适合回调本来就允许任务态延迟的系统。

模式 B:ISR 有界搬运到期对象

ISR 从堆顶取出到期对象,最多处理 MAX_ISR_BATCH 个,并写入无锁 Ring Queue,然后立即设置下一次 Compare。若到期对象超过批处理上限,则设置“仍有积压”标志并唤醒 Dispatcher 继续处理。

优点是硬件 Compare 能较快推进;缺点是每个节点会产生 O(log N) 堆操作,必须严格限制批量和临界区。

推荐的混合模式

通用实现可将到期处理分为“硬实时快车道”和“普通延后车道”:

1
2
3
4
5
6
7
8
9
10
11
12
flowchart TD
A[Timer IRQ] --> B[读取 now / 清 IRQ]
B --> C{堆顶已到期?}
C -->|否| D[设置下一 compare 并退出]
C -->|是| E[搬运最多 MAX_ISR_BATCH 个描述符]
E --> F{dispatch class}
F -->|ISR_FAST| G[执行受认证的极短回调]
F -->|DEFERRED| H[写入 Expired Queue]
H --> I[唤醒 Timer Dispatcher]
E --> J{仍有到期积压?}
J -->|是| K[设置立即软件事件 / 禁止中断风暴]
J -->|否| D

ISR_FAST 回调必须经过设计审查,限制在少量寄存器写、GPIO 翻转、DMA 启动或事件置位等操作,并给出最大执行周期。默认所有 Timer 都应走 DEFERRED

回调执行平面

Timer Dispatcher 的优先级

Timer Dispatcher 的优先级不能简单设为全系统最高,也不能固定为最低。推荐优先级关系如下:

1
2
3
4
5
6
7
最高:安全关键中断 / 硬件保护中断
音频 DMA、控制环、采样等关键 ISR
关键实时任务
Timer ISR(极短,可被更关键 ISR 抢占)
Timer Dispatcher
普通协议与业务任务
最低:日志、统计、后台维护

实际中断优先级取决于芯片的抢占模型。关键原则是:Timer ISR 不应长时间屏蔽更关键的音频、控制或通信中断;Timer Dispatcher 低于必须准时运行的实时任务,但高于普通后台任务,使普通超时不会无限拖延。

不要在单一 Timer 线程里运行所有复杂业务

如果所有回调都串行运行在一个 Timer Service Task 中,一个耗时回调会造成后续所有 Timer 的队头阻塞。更稳妥的做法是按类别转投不同工作队列:

分发等级 典型用途 执行上下文 约束
ISR_FAST 极短硬件动作 Timer ISR 不阻塞,数微秒级预算
TIMER_FAST 状态置位、通知、轻量协议推进 Timer Dispatcher 严格预算,不调用慢 I/O
REALTIME_WORK 音频或控制相关非 ISR 工作 专用实时工作队列 优先级低于关键流,高于普通业务
NORMAL_WORK 普通状态机、超时处理 普通工作队列 可使用大部分 RTOS API
BACKGROUND 统计、日志整理 低优先级队列 可延后或丢弃

Timer 回调最推荐的形态是“发布事件”,而不是“完成全部业务”:

1
定时器到期 -> 更新原子状态/写入消息 -> 唤醒业务任务 -> 业务任务处理

这样 Timer 子系统只承担时序责任,业务任务承担功能责任,能够分别设置优先级、栈大小、监控和故障隔离。

并发模型与锁设计

单核系统

单核 MCU 最简单的做法是用短暂关中断或提高 BASEPRI 保护 Timer Core 的堆、状态和 Compare。临界区只覆盖结构修改,不覆盖回调和队列等待。

如果 Timer API 可能从高于 Timer ISR 的中断调用,必须重新审查临界区优先级屏蔽范围,确保所有访问者都被正确序列化。不能假设“关调度器”等于“关中断”。

多核系统

多核系统需要自旋锁或每 CPU Timer 队列。若硬件只有一个全局 Compare,可选择:

  1. 一个全局队列和全局锁,所有核心共享;
  2. 每 CPU 本地队列,各核心维护本地最早截止时间,再由一个聚合器编程全局 Compare;
  3. Timer IRQ 固定在一个核心,其他核心通过 MPSC 命令队列提交启停请求。

全局锁实现简单,但高频跨核操作会产生缓存抖动。每 CPU 队列扩展性更好,但迁移任务、CPU 下线和全局 Compare 竞争更复杂。资源受限设备通常优先采用“单核心拥有 Timer Core,其他核心发命令”的所有权模型。

命令队列还是直接修改

让所有 start/stop 都通过 Timer Service Task 的命令队列,可以自然串行化并发,但会引入命令排队延迟;对于几微秒后就要到期的 Timer,命令还没被处理就可能错过截止时间。

推荐分层:

  • 普通任务使用命令队列,简化并发;
  • 高精度启动路径直接进入短临界区修改队列;
  • ISR 使用专用 FromISR API,将命令写入有界队列;
  • 对超短定时器设定最小可支持延时,不接受无法兑现的请求。

周期定时器的语义

周期定时器至少有两种完全不同的续期语义。

Fixed-rate:按理论时间轴续期

1
next_deadline = previous_deadline + period

它不会因回调晚执行而逐周期漂移,适合采样节拍、协议时槽和媒体时间轴。若系统延迟跨过多个周期,需要定义漏期策略:

  • Catch-up:补发所有漏掉的周期;
  • Coalesce:合并为一次回调,并报告漏掉次数;
  • Skip:直接跳到第一个未来截止时间;
  • Fail:认为实时约束已经破坏,进入故障处理。

Fixed-rate 计算可写成:

1
2
missed = floor((now - previous_deadline) / period)
next_deadline = previous_deadline + (missed + 1) * period

Fixed-delay:按实际完成时间续期

1
next_deadline = now + period

它保证两次实际执行至少间隔一个周期,不会补发形成回调风暴,但会随调度延迟产生累计漂移,适合后台轮询、状态刷新和“完成后再等一段时间”的任务。

API 应明确选择 FIXED_RATEFIXED_DELAY,不能把两种语义混在一个模糊的“periodic”中。

集中到期与过载控制

单个 Timer 平时运行正常,不代表在大量 Timer 同时到期时仍然可控。集中到期可能来自同一模块批量启动、系统从休眠恢复、长时间关中断、时钟恢复、总线阻塞或周期事件对齐。

中断和回调预算

设平均每秒到期事件数为 lambda,Timer ISR 每个事件的最坏处理时间为 C_isr,则事件搬运对 CPU 的平均占用近似为:

1
U_isr = lambda * C_isr

对周期回调,可用类似实时调度的利用率估算:

1
U_callback = sum(C_i / T_i)

其中 C_i 是第 i 类回调最坏执行时间,T_i 是平均触发周期。该式不能直接证明系统可调度,但能快速发现“回调负载已经接近或超过一个 CPU”的不可能配置。

有界批处理

ISR 和 Dispatcher 都应设置批处理上限:

1
2
3
MAX_ISR_BATCH
MAX_DISPATCH_BATCH
MAX_CALLBACK_TIME_PER_WAKE

当批量达到上限时,主动让出 CPU,使更高优先级任务得到运行机会,再继续处理积压。不能为了“清空 Timer 队列”而一次执行数百个回调。

事件队列深度

到期事件队列至少要覆盖 Dispatcher 最长无法运行期间的事件峰值:

1
Q_min >= lambda_peak * T_dispatch_blocked + B_simultaneous

其中:

  • lambda_peak:峰值到期速率;
  • T_dispatch_blocked:Dispatcher 可能被更高优先级任务压制的最长时间;
  • B_simultaneous:同一截止时间集中到期的最大数量。

还应增加安全余量,并对队列满定义策略:

Timer 类型 队列满策略
安全关键 触发故障、进入安全态,不允许静默丢失
可合并周期事件 累计 missed count,只保留一个待处理事件
普通状态刷新 丢弃旧事件,保留最新状态
日志/统计采样 丢弃本次并计数
协议超时 保证至少投递一次,必要时使用独立保留槽

低功耗与系统休眠

软件定时器必须明确底层计数器在不同睡眠级别是否继续运行。

Timer 在休眠中继续计数

如果使用 always-on counter,可直接把最早截止时间编程为唤醒源。恢复后读取 now,处理所有已到期事件,再设置下一次 Compare。

Timer 在休眠中停止

需要使用 RTC 或低功耗时钟记录睡眠时长:

1
2
3
4
5
sleep_enter_monotonic = now
sleep_enter_rtc = rtc_now
...
elapsed = rtc_now_after_wakeup - sleep_enter_rtc
monotonic_offset += elapsed

恢复时不能无条件重放每一个漏掉的周期事件,否则可能产生队列风暴。应根据 Timer 的 miss_policy 选择补发、合并或跳过。

Tickless 与唤醒合并

具有 slack 的低优先级 Timer 可以与附近事件合并,以减少唤醒次数:

1
actual_wakeup in [deadline, deadline + slack]

绝不能把需要“不晚于截止时间”的硬实时 Timer 向后合并。对这类 Timer,slack 必须为 0,并确保 Compare 采用不提前或不延后的明确策略。

动态时钟与 DVFS

如果 Timer 时钟随 CPU 主频变化,单纯保存“每微秒多少 Tick”的固定比例会导致时间跳变。解决方案按优先级排序:

  1. 使用独立、恒定频率的外设时钟或 always-on counter;
  2. 频率切换前读取时间并冻结换算基准,切换后更新新频率和基准;
  3. 让时钟驱动在频率变化时通知 Timer Core,重新编程 Compare;
  4. 不允许在存在严格高精度 Timer 时动态改变该时钟源。

换算层可维护:

1
2
3
4
base_ticks
base_time_ns
mult
shift

读取时间时用基准点与定点比例换算,类似操作系统时钟源的思路。所有更新必须保证时间单调,不能因频率切换而倒退。

内存与容量设计

资源受限系统应优先采用静态对象池和固定容量队列,避免 Timer ISR 或实时路径动态分配。

假设一个 Timer 控制块为 72 B,系统支持 128 个 Timer:

1
2
3
4
5
Timer control blocks = 72 B * 128 = 9216 B
Heap index array = 4 B * 128 = 512 B
Expired queue = 24 B * 64 = 1536 B
Statistics = about 1 KB
Total = about 12 KB

实际大小取决于指针宽度、名称、统计字段和对齐。设计时要明确:

  • 最大同时活动 Timer 数;
  • 最大创建数与活动数是否相同;
  • 是否允许动态对象;
  • 队列满是否可恢复;
  • 是否需要每 Timer 统计;
  • 是否支持同步取消所需的完成对象;
  • 是否需要多优先级工作队列。

对于只在编译期存在的 Timer,可静态定义对象并在链接期分配,减少句柄管理和内存碎片。

精度预算与最坏情况分析

一个可验证的设计需要给出延迟预算,而不是只写“微秒级”。例如:

路径 预算示例 测量方法
Compare 量化误差 <= 1 us 计数频率和寄存器行为分析
最大关中断延迟 <= 4 us DWT/ETM/逻辑分析仪
Timer ISR <= 3 us GPIO 翻转或周期计数器
ISR 到 Dispatcher 唤醒 <= 8 us trace timestamp
Dispatcher 单批处理 <= 20 us 回调统计和预算监控
普通回调开始延迟 <= 100 us 压力测试下直方图

上述数值只是说明预算形式,不能直接套用。实际项目应分别测量空载、典型负载和最坏负载,并记录 P50、P95、P99、P99.9 和最大值。只看平均值会掩盖长尾延迟。

延迟监控字段

建议每个 Timer 或全局维护:

1
2
3
4
5
6
7
8
9
10
armed_count
expired_count
cancel_count
stale_event_count
missed_period_count
queue_overflow_count
max_irq_lateness
max_dispatch_lateness
max_callback_runtime
callback_overrun_count

Timer Dispatcher 在回调前后读取高精度时间:

1
2
lateness = callback_start - deadline
runtime = callback_end - callback_start

runtime 超过配置预算,可记录告警、禁用该 Timer、转移到低优先级队列或触发系统降级。回调超时不能只依赖人工代码审查。

与高优先级音频、控制和通信任务共存

虽然题目常以音频流为例,这个原则对任何关键实时任务都相同。

中断优先级

  • I2S/SAI DMA 半满、全满中断通常高于 Timer ISR;
  • 电机保护、采样同步等安全关键中断高于 Timer ISR;
  • Timer ISR 只做有界操作,允许被更高优先级中断抢占;
  • Timer Core 临界区不能屏蔽高于系统允许调用 RTOS API 的关键中断。

任务优先级

  • 音频解码、控制环或高速协议任务高于 Timer Dispatcher;
  • Timer Dispatcher 高于普通 UI、日志和后台维护;
  • Timer 回调不持有音频数据路径上的互斥量;
  • 回调通过非阻塞通知与关键任务交互,不在回调中等待关键任务响应。

避免共享资源反转

Timer 回调若持有某个互斥量,而高优先级音频任务随后等待该互斥量,即使 Timer Dispatcher 优先级较低,也会产生优先级反转。应减少共享锁,使用单向消息、双缓冲、原子状态或优先级继承互斥量。对于时间敏感数据,最好由拥有者任务更新,Timer 只发送“到期”通知。

典型实现伪代码

启动定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int sw_timer_start(sw_timer_t *timer, uint64_t delay_us, uint64_t period_us)
{
uint64_t now;
bool reprogram;
unsigned long key;

if ((timer == NULL) || (delay_us == 0U)) {
return -EINVAL;
}

key = timer_core_lock();
now = clock_now_us();

if (timer->state == TIMER_ARMED) {
heap_remove(timer->heap_index);
}

timer->generation++;
timer->deadline = now + delay_us;
timer->period = period_us;
timer->state = TIMER_ARMED;

reprogram = heap_is_empty() || timer_precedes(timer, heap_top());
heap_insert(timer);

if (reprogram) {
program_next_alarm_locked(now);
}

timer_core_unlock(key);
return 0;
}

Timer ISR

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void hardware_timer_isr(void)
{
uint64_t now;
unsigned int batch = 0U;
unsigned long key;

alarm_ack_irq();
now = clock_now_us();

key = timer_core_lock_from_isr();

while (!heap_is_empty() &&
time_after_eq(now, heap_top()->deadline) &&
(batch < MAX_ISR_BATCH)) {
sw_timer_t *timer = heap_pop();

timer->state = TIMER_QUEUED;
expired_queue_push_from_isr(timer, timer->generation, timer->deadline);
batch++;
}

program_next_alarm_locked(now);
timer_core_unlock_from_isr(key);

if (batch != 0U) {
notify_timer_dispatcher_from_isr();
}
}

这段伪代码只是说明职责划分。实际实现必须处理队列满、周期续期、Compare 已错过、在途取消、优先级分类和硬件回绕。

Dispatcher

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void timer_dispatcher_task(void *arg)
{
for (;;) {
expired_event_t event;
uint64_t batch_start;
unsigned int count = 0U;

wait_for_expired_event();
batch_start = clock_now_us();

while (expired_queue_pop(&event)) {
sw_timer_t *timer = event.timer;

if (event.generation != timer->generation) {
timer_stats.stale_event_count++;
continue;
}

dispatch_timer_callback(timer, &event);
update_or_rearm_periodic_timer(timer, event.deadline);

count++;
if ((count >= MAX_DISPATCH_BATCH) ||
((clock_now_us() - batch_start) >= MAX_DISPATCH_BUDGET_US)) {
yield_to_higher_priority_work();
break;
}
}
}
}

开源组件与实现思路对照

本文机制 开源实现 关键原理 可直接借鉴的部分 需要注意的边界
高精度绝对时间排序 Linux hrtimer 64 位高分辨率时间,红黑树按到期时间排序 绝对时间、取消语义、排序结构、到期队列 Linux 内核复杂度和多核机制不适合直接照搬到小 MCU
可选择超时后端 Zephyr timeout queue 差分链表、最小堆、时间轮、桶化队列可配置 根据 Timer 数量和分布选择结构 Zephyr 内核超时最终按 Tick 管理,精度取决于配置与驱动
单硬件高分辨率 Timer 服务 ESP-IDF esp_timer 64 位时间、任务态或 ISR 分发、漏期跳过和统计 任务/ISR 双分发、短回调、睡眠漏期策略 最短可实现周期受 SoC 和实现开销限制
Timer 服务任务与命令队列 FreeRTOS software timers API 命令进入 Timer Queue,由 Timer Service Task 串行处理 控制平面串行化、任务优先级和队列容量配置 分辨率通常受 RTOS Tick 限制,不是微秒级硬时基
RTOS 无关 Timer API CMSIS-RTOS2 Timer 一次性/周期 Timer 抽象,回调上下文由实现决定 统一 API、对象和生命周期 规范不保证具体精度,ISR 可用性受实现限制
用户态事件循环 Timer Libevent 二叉堆管理随机超时,共同超时可使用 O(1) 队列 最小堆、相同周期分组、事件优先级 面向通用操作系统,实时性和线程模型不同

Linux hrtimer

Linux 高分辨率定时器子系统使用与低精度 Timer Wheel 分离的设计。其内部以 64 位纳秒时间表示绝对到期时间,并用红黑树做时间排序。这说明高精度 Timer 与大量“通常会在到期前被取消”的普通超时并不一定适合共用同一种结构。可借鉴的重点是:绝对时间、O(log N) 动态排序、明确的异步/同步取消语义,以及把排序结构与到期处理结构分开。

官方文档:https://www.kernel.org/doc/html/latest/timers/hrtimers.html

Zephyr Kernel Timing

Zephyr 的超时队列后端可在构建时选择。默认差分双向链表适合少量待处理超时;最小堆提供 O(log N) 插入和删除;分层时间轮针对大量近未来事件;桶化差分队列在内存、顺序和插入性能之间折中。这个设计非常适合说明:数据结构应由工作负载决定,而不是由个人偏好决定。

Zephyr 还明确区分 cycles、ticks 和实际时间单位,并支持绝对超时。其 Timer 驱动通过“设置下一次超时”自然形成 Tickless 工作模式。

官方文档:https://docs.zephyrproject.org/latest/kernel/services/timing/clocks.html

ESP-IDF esp_timer

esp_timer 是“单一高分辨率硬件时间源上管理多个软件 Timer”的直接案例。它提供微秒级时间 API、一次性和周期 Timer,并支持任务态分发与 ISR 分发。官方文档强调任务态回调串行执行,耗时工作应继续投递到低优先级任务;ISR 回调只适合极短低延迟动作。它还提供睡眠期间漏期事件跳过和回调执行统计,适合作为本文回调分层、过载和低功耗策略的参考。

官方文档:https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/esp_timer.html

FreeRTOS Software Timers

FreeRTOS 软件 Timer 通过 Timer Command Queue 把启动、停止和修改命令发送给 Timer Service/Daemon Task。Timer 回调也在该任务上下文串行执行。该结构适合参考“控制命令串行化”和“服务任务优先级/队列容量配置”,但默认 Timer 分辨率受 RTOS Tick 限制,不能直接替代独立微秒级高精度时间服务。

官方文档:https://freertos.org/Documentation/02-Kernel/02-Kernel-features/05-Software-timers/03-Timer-daemon-configuration

CMSIS-RTOS2 Timer

CMSIS-RTOS2 提供与具体 RTOS 解耦的一次性和周期 Timer API。规范提醒回调可能运行在专用 Timer 线程或中断上下文,因此可移植代码只能依赖实现明确保证的能力。这对本文的启示是:API 必须暴露或文档化回调上下文、可调用函数集合和取消语义,不能让使用者猜测。

官方文档:https://arm-software.github.io/CMSIS_6/main/RTOS2/group__CMSIS__RTOS__TimerMgmt.html

Libevent

Libevent 的普通超时使用二叉堆,因此随机分布的 Timer 插入和删除为 O(log N);大量具有相同超时时长的事件可使用“common timeout”队列优化到接近 O(1)。它还提供事件优先级,但也提醒高优先级事件可能饿死低优先级事件。虽然 Libevent 面向通用操作系统用户态事件循环,其“堆 + 共同超时队列 + 分发优先级”仍可用于嵌入式 Timer 设计。

官方文档:https://libevent.org/libevent-book/Ref4_event.html

常见错误方案

错误一:为每个软件 Timer 保存一个递减计数

每个 RTOS Tick 遍历全部 Timer 并减一,复杂度与 Timer 数量和 Tick 频率成正比。为了获得 1 us 精度而产生 1 MHz Tick 中断几乎不可接受,CPU 会被空转中断耗尽。

错误二:在 ISR 中直接执行所有回调

回调一旦访问日志、Flash、协议栈或阻塞同步对象,Timer ISR 就失去最坏执行时间上界。多个 Timer 同时到期时,关键中断延迟会快速恶化。

错误三:只用平均回调时间设计

平均 5 us 的回调可能偶尔执行 500 us。实时系统要关注最坏值和高分位长尾,并在运行时监控超预算。

错误四:周期 Timer 用 now + period 却声称无漂移

这种写法是 Fixed-delay,会把每次调度延迟累积到后续周期。无漂移节拍必须以理论截止时间续期,并定义漏期策略。

错误五:停止 Timer 就立即释放对象

如果到期事件已经进入队列或回调正在执行,立即释放会形成 Use-After-Free。必须使用 generation、引用计数、RCU 类延迟释放或同步取消。

错误六:Compare 写入后不检查是否已经错过

短延时、总线同步和临界区可能使 Compare 在写入时已经位于过去。如果硬件不会自动立即触发,Timer 将一直等到下一次回绕。

错误七:用壁钟时间做协议超时

校时会改变壁钟,造成超时提前或延后。协议和内部调度必须使用单调时钟。

错误八:为了追求精度把 Timer IRQ 设为最高优先级

高优先级不能弥补过长 ISR。Timer ISR 若高于安全保护、音频 DMA 或控制采样中断,反而会破坏系统核心实时路径。

测试与验证方案

1. 基础功能

  • 一次性 Timer 正常启动、到期和停止;
  • 周期 Timer 的 Fixed-rate 与 Fixed-delay 行为;
  • 重启活动 Timer;
  • 查询剩余时间和活动状态;
  • 多个相同截止时间的顺序;
  • 最小延时、最大延时和非法参数。

2. 竞态测试

  • 截止前一瞬间停止;
  • ISR 投递后、Dispatcher 执行前重启;
  • 回调执行中同步取消;
  • 回调内部重新启动自身;
  • 多线程同时启动/停止同一 Timer;
  • 新 Timer 插入后成为新的堆顶;
  • 删除堆顶、删除中间节点、删除最后节点。

3. 回绕与长时间测试

  • 人工缩短计数器位宽,加速验证多次回绕;
  • Compare 跨越回绕边界;
  • 远期 Timer 分段唤醒;
  • 运行数天或数周,验证单调性和漂移;
  • 动态频率切换前后时间连续。

4. 集中到期与过载

  • 数十或数百个 Timer 同一时刻到期;
  • 到期速率超过 Dispatcher 服务能力;
  • 事件队列满;
  • 回调故意超预算;
  • 周期 Timer 在长时间关中断后产生多次漏期;
  • 验证合并、跳过、告警和安全降级策略。

5. 关键任务共存

在音频、控制或通信压力下测试:

  • I2S/SAI DMA 是否发生 underrun/overrun;
  • 关键任务最大响应时间是否恶化;
  • Timer Core 最长临界区;
  • Timer ISR 最大执行时间;
  • Dispatcher 被高优先级任务压制时的队列增长;
  • 回调持锁是否造成优先级反转。

6. 测量方法

最可靠的方法是在关键位置翻转 GPIO,并使用逻辑分析仪或示波器同时观察硬件 Compare 输出、ISR GPIO、Dispatcher GPIO 和业务动作 GPIO。也可以使用 DWT Cycle Counter、ETM Trace、RTOS Trace Hook 或芯片性能计数器。

1
2
3
4
5
6
7
8
9
sequenceDiagram
participant CMP as Hardware Compare
participant ISR as ISR Entry GPIO
participant DISP as Dispatcher GPIO
participant CB as Callback GPIO

CMP-->>ISR: T_irq_latency
ISR-->>DISP: T_isr + T_schedule
DISP-->>CB: queue wait + prior callbacks

需要输出延迟直方图,而不是只记录某一次测量值。压力测试应覆盖 Flash 擦写、Cache Miss、总线 DMA、最高优先级任务持续运行、频繁中断和低功耗唤醒等最差条件。

设计检查表

检查项 必须回答的问题
时间源 是否单调?频率是否稳定?休眠中是否运行?
分辨率 API 分辨率和实际 Compare 分辨率分别是多少?
最短延时 从调用到可可靠触发的最小提前量是多少?
数据结构 定时器数量和分布为何适合该结构?
取消 异步取消和同步取消语义是否明确?
生命周期 如何避免旧事件访问已释放对象?
ISR 最坏执行时间和最大批处理数量是多少?
回调 运行在哪个上下文?允许调用哪些 API?
优先级 是否可能阻塞关键 ISR 或实时任务?
周期语义 Fixed-rate 还是 Fixed-delay?漏期如何处理?
队列 满时如何处理?是否存在静默丢失?
低功耗 睡眠后如何补偿时间和处理漏期?
监控 是否记录 lateness、runtime、miss 和 overflow?
验证 是否完成回绕、竞态、压力和长时间测试?

最终回答组织方式

回答这类问题时,可以按以下顺序展开:

  1. 先给出核心原则:唯一硬件 Timer 作为自由运行单调时钟和最近截止时间 Compare,不为每个软件 Timer 分配硬件资源;
  2. 再讲时间模型:所有 Timer 保存绝对截止时间,内部优先使用 64 位单调时间,处理回绕和单位换算;
  3. 再讲数据结构:少量 Timer 可用差分链表,中等规模高精度 Timer 用最小堆,大规模动态 Timer 可用红黑树,大量普通超时用时间轮,混合负载采用分层结构;
  4. 再讲触发链路:硬件只编程最近截止时间,ISR 只做有界到期检测、事件投递和下一次 Compare 更新;
  5. 再讲执行隔离:普通回调由 Timer Dispatcher 或分级工作队列执行,优先级低于关键音频、控制或采集任务;
  6. 再讲并发与生命周期:短临界区保护队列,使用 generation 防止旧事件,区分异步取消和同步取消;
  7. 再讲周期、过载和低功耗:明确 Fixed-rate/Fixed-delay、漏期策略、批处理上限、队列满策略和睡眠补偿;
  8. 最后量化:给出最短可支持延时、ISR/回调预算、队列容量、最大 Timer 数,并通过逻辑分析仪和 Trace 验证长尾延迟。

一句话概括:一个硬件 Timer 足以管理多路软件时间事件,前提是把“时间基准、截止时间排序、硬件触发、事件分发和业务执行”彻底解耦;硬件只负责叫醒系统,软件结构负责排序,ISR 负责快速搬运,受控任务负责执行,真正的硬实时动作则交给专用外设或 DMA。

参考链接