
RTOS设计与开发(11):软件定时器,active list、expired FIFO 与 daemon task
RTOS设计与开发(11):软件定时器,active list、expired FIFO 与 daemon task
软件定时器是 RTOS 很容易“看起来简单,做起来很脏”的模块。
你当然可以在 SysTick 里直接扫一遍定时器然后执行回调,但那样很快就会碰到两个现实问题:
- ISR 里不适合跑用户 callback。
- 周期定时器、stop、重复到期、回调执行时间这些语义会互相缠在一起。
当前这版实现选择了一条很清晰的路:
SysTick 只负责发现“谁到期了”,真正执行 callback 的,是一个专门的 timer daemon task。
先看软件定时器服务的全局对象
os_timer.c 一开头就把整个服务模型写得很明白:
static uint8_t g_os_timer_service_initialized = 0U;
static list_t g_os_timer_active_list;
static list_t g_os_timer_expired_list;
static os_sem_t g_os_timer_work_sem;
static tcb_t g_os_timer_task;
static uint32_t g_os_timer_task_stack[OS_TIMER_TASK_STACK_DEPTH];
这里的职责划分很清楚:
active_list负责按到期时间排序。expired_list负责把“已经到期但 callback 还没执行”的 timer 排成 FIFO。work_sem负责唤醒后台 daemon。g_os_timer_task就是后台消费线程。
这意味着当前实现不是“中断里直接跑 callback”,而是“中断里只做转运,线程里做执行”。
为什么 active list 要按 expiry_tick 升序维护
active list 的插入策略如下:
static void os_timer_active_list_insert_ordered_locked(os_timer_t *timer)
{
...
current = g_os_timer_active_list.head;
while (current != NULL)
{
current_timer = LIST_CONTAINER_OF(current, os_timer_t, active_node);
if (os_timer_tick_deadline_is_before(timer->expiry_tick, current_timer->expiry_tick) != 0U)
{
...
timer->active = 1U;
return;
}
current = current->next;
}
(void)list_insert_tail(&g_os_timer_active_list, node);
timer->active = 1U;
}
和任务层的 timed-wait list 一样,这里也采用了按 deadline 排序的链表。
这么做的最大好处是:
每次 tick 处理时,只需要从链表头一路往后看,直到遇到第一个“还没到期”的 timer 就可以停下。因为队头没到期,后面的就一定更不该到期。
这让 tick 路径的复杂度保持在“只处理本轮真正到期的 timer”,而不是每次都全表扫描。
为什么 timer service 要做成 first-use lazy init
当前软件定时器服务不是靠显式 init() 建立,而是在第一次 os_timer_start() 时按需初始化:
static os_status_t os_timer_service_init(void)
{
...
primask = os_port_enter_critical();
if (g_os_timer_service_initialized != 0U)
{
os_port_exit_critical(primask);
return OS_STATUS_OK;
}
...
status = os_sem_init(&g_os_timer_work_sem, 0U);
...
status = task_create(&g_os_timer_task, &config);
...
g_os_timer_service_initialized = 1U;
os_port_exit_critical(primask);
return OS_STATUS_OK;
}
这里有两个设计点值得注意。
第一,它把“第一次使用 timer service”整段包进了临界区,避免两个线程同时第一次 os_timer_start() 时重复初始化全局对象。
第二,它不是只建一条链表,而是把 work_sem 和 daemon task 一次性都拉起来。也就是说,软件定时器在这个系统里不是一个纯数据结构,而是一个完整的小服务。
为什么 start 语义是“启动或重启”
os_timer_start() 的路径很直接:
primask = os_port_enter_critical();
os_timer_remove_locked(timer);
timer->period_ticks = (timer->mode == OS_TIMER_PERIODIC) ? timeout_ticks : 0U;
timer->expiry_tick = (os_tick_t)(os_kernel_tick_get() + timeout_ticks);
os_timer_active_list_insert_ordered_locked(timer);
os_port_exit_critical(primask);
它不是“如果已经 active 就报错”,而是先移除旧状态,再重新装填新的 expiry_tick。
这就把 start 做成了一个很自然的“启动或重启”语义。对于应用层来说,这通常比“必须先 stop 再 start”更顺手。
SysTick 路径为什么只负责搬运到期事件
真正的 tick 处理在 os_timer_system_tick():
current_tick = os_kernel_tick_get();
primask = os_port_enter_critical();
node = g_os_timer_active_list.head;
while (node != NULL)
{
timer = LIST_CONTAINER_OF(node, os_timer_t, active_node);
if (os_timer_tick_is_due(current_tick, timer->expiry_tick) == 0U)
{
break;
}
if (list_remove(&g_os_timer_active_list, &timer->active_node) == 0U)
{
os_port_exit_critical(primask);
return OS_STATUS_INVALID_STATE;
}
timer->active = 0U;
os_timer_queue_expiration_locked(timer);
expired_any = 1U;
if (timer->mode == OS_TIMER_PERIODIC)
{
timer->expiry_tick = (os_tick_t)(timer->expiry_tick + timer->period_ticks);
os_timer_active_list_insert_ordered_locked(timer);
}
node = g_os_timer_active_list.head;
}
可以看到,这里做的事情非常克制:
- 把到期 timer 从 active list 摘下来。
- 记录一笔
pending_expirations。 - 必要时把 periodic timer 重装回 active list。
- 最后通过信号量唤醒 daemon。
它没有在中断里直接调用 timer->callback()。
这就是这版实现最正确的地方。因为 callback 本质上是应用代码,应该在线程态跑,而不是在 SysTick ISR 里跑。
为什么 expired list 还要再配一个 pending_expirations
当前实现没有把“到期一次”简单建模成“expired list 里存在一次节点”,而是又加了一个计数:
static void os_timer_queue_expiration_locked(os_timer_t *timer)
{
if (timer->pending_expirations < UINT32_MAX)
{
timer->pending_expirations++;
}
if (timer->expired_node.owner == NULL)
{
(void)list_insert_tail(&g_os_timer_expired_list, &timer->expired_node);
}
}
这说明当前设计已经考虑到一个现实问题:
一个 timer 可能在 daemon 还没来得及消费完前,又再次到期。
如果只有“是否挂在 expired list 中”这一位状态,那第二次到期就没法记账了。pending_expirations 的存在,正是为了解决这个问题。
为什么 daemon 每次执行 callback 前都重新读共享计数
daemon 主循环里有一段很值得写进博客:
for (;;)
{
primask = os_port_enter_critical();
if (timer->pending_expirations > 0U)
{
timer->pending_expirations--;
should_run_callback = 1U;
}
else
{
should_run_callback = 0U;
}
os_port_exit_critical(primask);
if (should_run_callback == 0U)
{
break;
}
timer->callback(timer->arg);
}
这段代码旁边的注释已经把问题说透了:
不能在 daemon 一拿到 expired 节点时,就把 pending_expirations 一次性拷到本地变量里全部消费。
因为如果这时候另一个线程调用了 os_timer_stop(),它会把共享计数清零。只有 daemon 每执行一轮 callback 前都重新读一次共享计数,stop() 才能真正阻止后续还没开始的 callback。
这说明当前实现已经开始认真处理“后台线程消费期间,前台控制路径还可能修改同一个 timer”这种典型并发问题了。
为什么 callback 在线程态而不是 ISR 态运行
daemon 入口一开始就说明了当前软件定时器的执行模型:
for (;;)
{
status = os_sem_take(&g_os_timer_work_sem, OS_WAIT_FOREVER);
...
timer->callback(timer->arg);
}
callback 是在 timerd 这个普通任务上下文里执行的。
这意味着:
- callback 可以安全调用线程态 API。
- callback 不会拉长
SysTickISR 的执行时间。 - timer service 和对象层同步机制天然能复用同一套任务等待语义。
当然,这也意味着 callback 的实时性上限取决于 daemon 任务何时被调度到。但对于当前这版最小软件定时器来说,这是一个很合理的 tradeoff。
timer service 为什么默认只高于 idle
配置宏里给 timer daemon 的默认优先级是:
#define OS_TIMER_TASK_PRIORITY ((uint8_t)(OS_MAX_PRIORITIES - 2U))
也就是说,它默认只比 idle 高一档。
这体现了当前设计的态度:
软件定时器服务是系统后台能力,不应该默认去抢占普通业务线程。除非应用明确想要更强的 timer callback 时效性,否则后台服务线程就应该尽量低调。
小结
这一版软件定时器最值得肯定的,不是“终于有 timer 了”,而是它把三个本来很容易混在一起的问题拆开了:
active list负责管理未来 deadline。expired FIFO + pending_expirations负责可靠记录已到期事件。timer daemon task负责在线程态消费 callback。
这种拆法非常适合一个正在成长的 RTOS。因为以后无论你要做更复杂的 timer 命令队列、timer ISR API,还是更强的统计与诊断,都能在这个模型上继续扩,而不用把现在这套最小语义推翻。
下一篇会回到更“接口设计”层面,讲这轮新增里另一个很有价值的变化:稳定 public API 的收口,包括 os.h umbrella header、semantic alias、compatibility API 和 internal/public 边界。