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_start、timer_stop、timer_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 个定时器控制块。每个控制块有一个状态标记:FREE 或 USED。
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_start 和 timer_stop 的实现细节。到时候你会看到,启动一个定时器可比创建它要复杂得多,因为要操作定时器链表,还要处理时间同步的问题。
好,今天就到这儿。有问题欢迎留言讨论。