3. 定时器创建与初始化:API接口设计(timer_create)、参数校验与资源分配、初始化流程详解

好,咱们接着往下聊。上一章我们把软定时器的核心数据结构理清楚了,这一章就进入实操环节——怎么把定时器“造”出来。

说白了,timer_create 这个 API,就是用户和内核之间的一个契约。用户告诉内核:“嘿,我需要一个定时器,它长这样、那样,你给我安排上。” 内核收到请求后,得先检查这个请求合不合理,然后从资源池里捞一块出来,最后把它初始化好。

我个人习惯把创建流程拆成三步走:参数校验 → 资源分配 → 初始化。每一步都有坑,咱们一个一个说。

3.1 API 接口设计:给用户一个干净的入口

先看接口长什么样。我见过很多 RTOS 的接口设计,有的参数多得吓人,有的又太简陋。我自己的经验是,接口要够用,但别过度设计

/**
 * @brief 创建并初始化一个软定时器
 *
 * @param timer_handle   [out] 返回定时器句柄
 * @param config         [in]  定时器配置参数
 * @param callback       [in]  超时回调函数
 * @param user_data      [in]  用户私有数据(传给回调)
 * @return int           0 成功,负值表示错误码
 */
int timer_create(timer_handle_t *timer_handle,
                 const timer_config_t *config,
                 timer_callback_t callback,
                 void *user_data);

你想想看,为什么我把 timer_handle 设计成输出参数?嗯,这里有个小讲究。句柄本质上是一个索引或者指针,由内核分配后交给用户。用户拿着这个句柄,后续的 timer_starttimer_stoptimer_delete 都得靠它来定位。

我在项目中遇到过有人把句柄设计成返回值,结果错误码没地方放了,只能搞个全局变量存错误码——那玩意儿在多任务环境下就是个灾难。

我的小建议: 句柄用输出参数,返回值只放错误码。这样调用链清晰,也方便做错误传播。

3.2 参数校验:别让脏数据进来

参数校验这一步,很多人觉得是“防御性编程”,可有可无。但我告诉你,这一步是系统的第一道防线。我曾经在一个项目里,因为漏掉了对回调函数指针的判空,结果系统跑着跑着就跳飞到 0x00000000 去了——那叫一个酸爽。

来看看我通常校验哪些东西:

校验项 检查内容 错误码
句柄指针 timer_handle 是否为 NULL -EINVAL
配置参数 config 是否为 NULL -EINVAL
回调函数 callback 是否为 NULL -EINVAL
定时周期 config->period 是否在 [1, MAX_TIMEOUT] 内 -ERANGE
定时器类型 config->type 是否为合法枚举值 -ENOTSUP

代码实现大概是这样的:

int timer_create(timer_handle_t *timer_handle,
                 const timer_config_t *config,
                 timer_callback_t callback,
                 void *user_data)
{
    // 第一步:参数校验
    if (timer_handle == NULL) {
        return -EINVAL;
    }
    if (config == NULL) {
        return -EINVAL;
    }
    if (callback == NULL) {
        return -EINVAL;
    }
    if (config->period == 0 || config->period > MAX_TIMEOUT) {
        return -ERANGE;
    }
    if (config->type != TIMER_ONESHOT && config->type != TIMER_PERIODIC) {
        return -ENOTSUP;
    }

    // ... 后续步骤
}
注意: 参数校验的顺序也有讲究。我习惯把最轻量级的检查(比如判空)放在前面,把稍微复杂一点的检查(比如范围判断)放在后面。这样大部分非法调用在早期就被拦截了,不用做无谓的计算。

3.3 资源分配:从池子里捞一块出来

校验通过后,就该分配资源了。RTOS 里最忌讳的就是在中断里用 malloc,那玩意儿不可重入,而且慢。所以软定时器通常用静态内存池来管理。

我一般会预分配一个数组,里面放 N 个定时器控制块。每个控制块有一个状态标记:FREEUSED

static timer_control_block_t timer_pool[MAX_TIMER_COUNT];

int timer_create(...)
{
    // ... 参数校验 ...

    // 第二步:从池中查找空闲控制块
    int index = -1;
    for (int i = 0; i < MAX_TIMER_COUNT; i++) {
        if (timer_pool[i].state == TIMER_STATE_FREE) {
            index = i;
            break;
        }
    }

    if (index == -1) {
        return -ENOMEM;  // 池子满了
    }

    // 标记为已占用(防止并发分配)
    timer_pool[index].state = TIMER_STATE_ALLOCATING;

    // ... 初始化 ...
}

这里有个细节:为什么分配前要先标记为 ALLOCATING 因为如果这个函数被两个任务同时调用,它们可能同时找到同一个空闲块。先标记一下,相当于“占个座”,防止别人抢走。

当然,这个操作需要在关中断或者持有锁的情况下做。我习惯用关中断的方式,因为快。

3.4 初始化流程:把每个字段填对

资源拿到手了,接下来就是初始化。这一步看似简单,但容易漏东西。我列一下必须初始化的字段:

  • 状态:设为 TIMER_STATE_STOPPED,表示还没启动
  • 周期值:从 config->period 拷贝过来
  • 剩余时间:初始化为 0,等启动时再设
  • 回调函数:保存 callback 指针
  • 用户数据:保存 user_data 指针
  • 定时器类型:单次还是周期
  • 链表节点:初始化为空,还没挂到任何链表上
    // 第三步:初始化控制块
    timer_control_block_t *tcb = &timer_pool[index];

    tcb->state       = TIMER_STATE_STOPPED;
    tcb->type        = config->type;
    tcb->period      = config->period;
    tcb->remaining   = 0;
    tcb->callback    = callback;
    tcb->user_data   = user_data;
    tcb->list_node.prev = NULL;
    tcb->list_node.next = NULL;

    // 第四步:返回句柄
    *timer_handle = (timer_handle_t)(uintptr_t)tcb;

    return 0;

你可能会问:为什么 remaining 初始化为 0,而不是直接等于 period 嗯,这里有个设计上的考量。定时器创建后不一定马上启动,用户可能先配置好,等某个条件满足后再调用 timer_start。如果创建时就设了剩余时间,那万一用户忘了启动,定时器就自己跑起来了——这显然不合理。

核心原则: 创建只负责“造出来”,启动才负责“跑起来”。职责分离,逻辑才清晰。

3.5 避坑指南:我踩过的几个坑

讲到这里,我分享几个实战中遇到的坑,希望能帮你少走弯路。

坑一:句柄类型用指针还是索引?

我曾经用指针直接当句柄,结果用户不小心把句柄值改了,系统直接崩溃。后来我改成索引(数组下标),虽然多了一次边界检查,但安全多了。当然,索引也有问题——如果控制块被释放后又被重新分配,旧的句柄还能用吗?这就需要引入“世代号”机制了,这个咱们后面讲高级话题时再聊。

坑二:参数校验要不要检查 config 内部的指针?

比如 config 里有个 name 字段,要不要判空?我的建议是:看使用场景。如果名字只是用来调试打印,那判空后给个默认名字就行,没必要返回错误。但如果名字用来做哈希索引,那就必须校验。

坑三:资源分配时要不要考虑优先级?

有些场景下,高优先级的任务应该优先分配到定时器资源。但说实话,我很少见到有 RTOS 在定时器分配上做优先级调度——因为定时器数量通常不大,分配频率也不高,先来先服务就够用了。别过度设计。

3.6 小结

这一章我们走通了 timer_create 的完整流程:

  • 接口设计:句柄做输出参数,返回值放错误码
  • 参数校验:判空、范围检查、类型检查,一个不能少
  • 资源分配:用静态内存池,避免动态内存分配
  • 初始化:状态、周期、回调、用户数据,每个字段都要填对

下一章,我们聊聊定时器的启动和停止——timer_starttimer_stop 的实现细节。到时候你会看到,启动一个定时器可比创建它要复杂得多,因为要操作定时器链表,还要处理时间同步的问题。

好,今天就到这儿。有问题欢迎留言讨论。