LK 博客
RTOS设计与开发(11):软件定时器,active list、expired FIFO 与 daemon task
嵌入式
约 1 分钟阅读 0 赞 0 条评论 鸿蒙黑体

RTOS设计与开发(11):软件定时器,active list、expired FIFO 与 daemon task

Yukikaze
Yukikaze @Yukikaze
累计点赞 0 登录后每个账号只能点一次
内容长度 0 正文词元数
正文
目录会跟随阅读位置移动。
阅读进度

RTOS设计与开发(11):软件定时器,active list、expired FIFO 与 daemon task

软件定时器是 RTOS 很容易“看起来简单,做起来很脏”的模块。

你当然可以在 SysTick 里直接扫一遍定时器然后执行回调,但那样很快就会碰到两个现实问题:

  1. ISR 里不适合跑用户 callback。
  2. 周期定时器、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];

这里的职责划分很清楚:

  1. active_list 负责按到期时间排序。
  2. expired_list 负责把“已经到期但 callback 还没执行”的 timer 排成 FIFO。
  3. work_sem 负责唤醒后台 daemon。
  4. 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;
}

可以看到,这里做的事情非常克制:

  1. 把到期 timer 从 active list 摘下来。
  2. 记录一笔 pending_expirations
  3. 必要时把 periodic timer 重装回 active list。
  4. 最后通过信号量唤醒 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 这个普通任务上下文里执行的。

这意味着:

  1. callback 可以安全调用线程态 API。
  2. callback 不会拉长 SysTick ISR 的执行时间。
  3. 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 了”,而是它把三个本来很容易混在一起的问题拆开了:

  1. active list 负责管理未来 deadline。
  2. expired FIFO + pending_expirations 负责可靠记录已到期事件。
  3. timer daemon task 负责在线程态消费 callback。

这种拆法非常适合一个正在成长的 RTOS。因为以后无论你要做更复杂的 timer 命令队列、timer ISR API,还是更强的统计与诊断,都能在这个模型上继续扩,而不用把现在这套最小语义推翻。

下一篇会回到更“接口设计”层面,讲这轮新增里另一个很有价值的变化:稳定 public API 的收口,包括 os.h umbrella header、semantic alias、compatibility API 和 internal/public 边界。

作者名片

Yukikaze
Yukikaze
@Yukikaze

私にできることなら何でもするから

评论区
文章作者和管理员都可以管理这里的评论。
0 条评论
登录后即可参与评论。 去登录
还没有评论,欢迎留下第一条交流内容。