11、项目实战:基于回调的定时器管理器
终于到了实战环节。前面几章我们聊了函数指针的各种用法,从基础语法到回调机制,再到驱动层和应用层的解耦。说实话,光讲理论总觉得差点意思。我个人的习惯是——学完一个知识点,必须亲手搭一个能跑的东西,心里才踏实。
这一章,我们就来做一个基于回调的定时器管理器。你想想看,嵌入式系统里最常用的功能是什么?定时器绝对排前三。无论是按键消抖、LED闪烁,还是任务调度、超时检测,都离不开定时器。但问题是,如果每个模块都自己开一个硬件定时器,资源很快就耗光了。所以我们需要一个统一的定时器管理器,让多个任务共享同一个硬件定时器。
11.1 定时器管理器的设计思路
先说说设计目标。我希望这个管理器能做到:
- 支持多个定时任务同时运行
- 每个任务到期后自动调用回调函数
- 支持单次定时和周期定时两种模式
- 启动、停止、修改定时参数都很方便
说白了,就是用一个软件定时器池,来管理所有定时请求。底层只需要一个硬件定时器提供固定的时钟节拍(比如1ms中断一次),上层所有定时任务都在这个节拍上跑。
我在项目中遇到过类似的需求。当时一个产品需要同时处理5个传感器的数据采集,每个传感器的采样周期还不一样。如果每个传感器都开一个硬件定时器,MCU的定时器资源根本不够用。后来就是用这种软件定时器管理器搞定的。
核心思想:用一个硬件定时器产生固定频率的时钟节拍,上层通过链表或数组管理多个定时任务,每个任务到期时通过回调函数通知上层模块。
11.2 数据结构设计
先定义定时器的数据结构。我习惯用结构体来描述一个定时任务:
/* 定时器工作模式 */
typedef enum {
TIMER_MODE_ONESHOT, /* 单次定时 */
TIMER_MODE_PERIODIC /* 周期定时 */
} TimerMode_t;
/* 定时器状态 */
typedef enum {
TIMER_STOPPED, /* 停止 */
TIMER_RUNNING, /* 运行中 */
TIMER_EXPIRED /* 已到期 */
} TimerState_t;
/* 定时器回调函数类型 */
typedef void (*TimerCallback_t)(void *arg);
/* 定时器控制块 */
typedef struct {
uint32_t id; /* 定时器ID */
TimerState_t state; /* 当前状态 */
TimerMode_t mode; /* 工作模式 */
uint32_t period; /* 定时周期(单位:时钟节拍数) */
uint32_t counter; /* 当前计数值 */
TimerCallback_t callback; /* 回调函数指针 */
void *arg; /* 回调函数参数 */
} Timer_t;
嗯,这里要注意。回调函数的参数我用了 void *,这样上层模块可以传入任意类型的数据。你想想看,如果回调函数只能传一个整数,那灵活性就差太多了。用 void * 虽然多了一次类型转换,但换来的是通用性。
11.3 定时器管理器的实现
接下来是管理器的核心代码。我选择用数组来管理定时器,因为数组的访问速度快,而且对于嵌入式系统来说,定时器数量通常是固定的,不需要动态分配。
#define MAX_TIMERS 10 /* 最大定时器数量 */
static Timer_t timer_pool[MAX_TIMERS];
static uint32_t tick_count = 0; /* 系统时钟节拍计数器 */
/* 初始化定时器管理器 */
void Timer_Manager_Init(void)
{
for (int i = 0; i < MAX_TIMERS; i++) {
timer_pool[i].state = TIMER_STOPPED;
timer_pool[i].callback = NULL;
}
tick_count = 0;
}
/* 创建一个定时器,返回定时器ID */
int Timer_Create(TimerMode_t mode, uint32_t period,
TimerCallback_t callback, void *arg)
{
for (int i = 0; i < MAX_TIMERS; i++) {
if (timer_pool[i].state == TIMER_STOPPED) {
timer_pool[i].id = i;
timer_pool[i].mode = mode;
timer_pool[i].period = period;
timer_pool[i].counter = period;
timer_pool[i].callback = callback;
timer_pool[i].arg = arg;
timer_pool[i].state = TIMER_RUNNING;
return i; /* 返回定时器ID */
}
}
return -1; /* 没有空闲定时器 */
}
/* 停止指定定时器 */
void Timer_Stop(int timer_id)
{
if (timer_id >= 0 && timer_id < MAX_TIMERS) {
timer_pool[timer_id].state = TIMER_STOPPED;
}
}
/* 硬件定时器中断服务函数,每1ms调用一次 */
void Timer_ISR(void)
{
tick_count++;
for (int i = 0; i < MAX_TIMERS; i++) {
if (timer_pool[i].state != TIMER_RUNNING) {
continue;
}
timer_pool[i].counter--;
if (timer_pool[i].counter == 0) {
/* 定时到期,调用回调函数 */
if (timer_pool[i].callback != NULL) {
timer_pool[i].callback(timer_pool[i].arg);
}
/* 根据模式决定下一步行为 */
if (timer_pool[i].mode == TIMER_MODE_ONESHOT) {
timer_pool[i].state = TIMER_EXPIRED;
} else {
/* 周期模式:重新加载计数值 */
timer_pool[i].counter = timer_pool[i].period;
}
}
}
}
这段代码看起来简单,但有几个关键点我得提醒你:
- 中断服务函数要尽量短。回调函数里不要做耗时操作,否则会影响其他定时器的精度。我曾经见过有人在回调里做延时,结果整个定时器管理器都卡死了。
- 回调函数不能阻塞。如果回调函数需要执行较长时间,建议用消息队列或任务通知的方式,把工作交给后台任务处理。
- 注意临界区保护。如果主循环和中断同时访问定时器数组,需要用关中断或信号量来保护。
避坑指南:我曾经在一个项目里,因为回调函数里调用了 printf 打印调试信息,导致定时器精度严重下降。printf 本身很慢,而且会占用中断时间。后来我把调试信息改成用环形缓冲区暂存,等退出中断后再统一输出,问题就解决了。
11.4 回调机制如何解耦
你可能会问:为什么非要用回调?直接在中断里判断定时器ID,然后调用对应的处理函数不行吗?
当然可以。但那样做的话,定时器管理器和业务逻辑就耦合在一起了。每次新增一个定时任务,都要修改中断服务函数。这违反了开闭原则——对扩展开放,对修改关闭。
用回调函数的好处是:定时器管理器完全不知道回调函数里做了什么。它只负责在到期时调用你注册的函数。至于这个函数是点亮LED、读取传感器,还是发送网络数据包,管理器一概不管。
这种解耦方式在实际项目中非常有用。我举个例子:
/* 模块A:LED闪烁 */
void LED_Blink_Callback(void *arg)
{
/* 翻转LED引脚 */
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
/* 模块B:按键消抖检测 */
void Key_Debounce_Callback(void *arg)
{
/* 读取按键状态并进行消抖处理 */
uint8_t key_state = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);
// ... 消抖逻辑 ...
}
/* 主函数中注册定时任务 */
void main(void)
{
Timer_Manager_Init();
/* LED每500ms闪烁一次 */
Timer_Create(TIMER_MODE_PERIODIC, 500, LED_Blink_Callback, NULL);
/* 按键每10ms检测一次 */
Timer_Create(TIMER_MODE_PERIODIC, 10, Key_Debounce_Callback, NULL);
while (1) {
/* 主循环做其他事情 */
}
}
你看,LED模块和按键模块各自注册自己的回调函数,互不干扰。如果以后要增加一个温度采集任务,只需要再写一个回调函数,然后调用 Timer_Create 注册进去就行了。定时器管理器不需要做任何修改。
11.5 定时器管理器的整体架构
为了让你更直观地理解整个系统的运行流程,我画了一张图:
从这张图可以看得很清楚:硬件层只负责产生中断,管理层负责定时器的创建、计数和到期检测,应用层通过注册回调函数来响应定时事件。三层之间通过函数指针解耦,每一层都可以独立修改和扩展。
11.6 实际项目中的注意事项
最后,我结合自己的经验,给你几个实用建议:
- 定时器数量要预留余量。我一般会预留30%的空闲定时器,方便后期功能扩展。如果项目初期只用了5个定时器,我会把数组大小设为8或10。
- 回调函数的执行时间要可控。如果某个回调函数执行时间超过了一个时钟节拍(比如1ms),就会导致后续定时器的到期时间不准确。解决办法是把耗时操作放到后台任务中执行。
- 考虑定时器溢出问题。如果定时周期很长(比如10秒),而时钟节拍是1ms,那么计数值就是10000。如果用的是16位计数器,最大值只有65535,够用。但如果周期是1分钟(60000),就要小心了。建议用32位计数器。
- 调试时加一个统计接口。我习惯在管理器里加一个
Timer_GetStatus()函数,返回当前所有定时器的状态、剩余时间等信息。调试的时候非常有用。
小技巧:如果你用的是RTOS,可以把定时器管理器的中断处理放在一个高优先级任务中,而不是直接在中断里执行。这样可以避免中断嵌套带来的问题,而且回调函数里可以放心地调用RTOS的API。
好了,这一章的内容就到这里。定时器管理器虽然看起来简单,但它是回调机制在实际项目中的一个典型应用。你把这个例子吃透了,以后再遇到类似的需求——比如事件驱动框架、状态机调度、协议栈定时重传——都能用同样的思路来解决。