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 定时器管理器的整体架构

为了让你更直观地理解整个系统的运行流程,我画了一张图:

基于回调的定时器管理器架构图 硬件层 硬件定时器(如 TIM2)→ 每1ms产生一次中断 定时器管理层(Timer Manager) 定时器池(Timer_t 数组) 管理:创建、停止、到期检测、回调触发 应用层(回调函数) LED_Blink_Callback Key_Debounce_Callback Temp_Sensor_Callback 硬件定时器提供时钟节拍 → 管理器遍历定时器池 → 到期时调用注册的回调函数

从这张图可以看得很清楚:硬件层只负责产生中断,管理层负责定时器的创建、计数和到期检测,应用层通过注册回调函数来响应定时事件。三层之间通过函数指针解耦,每一层都可以独立修改和扩展。

11.6 实际项目中的注意事项

最后,我结合自己的经验,给你几个实用建议:

  • 定时器数量要预留余量。我一般会预留30%的空闲定时器,方便后期功能扩展。如果项目初期只用了5个定时器,我会把数组大小设为8或10。
  • 回调函数的执行时间要可控。如果某个回调函数执行时间超过了一个时钟节拍(比如1ms),就会导致后续定时器的到期时间不准确。解决办法是把耗时操作放到后台任务中执行。
  • 考虑定时器溢出问题。如果定时周期很长(比如10秒),而时钟节拍是1ms,那么计数值就是10000。如果用的是16位计数器,最大值只有65535,够用。但如果周期是1分钟(60000),就要小心了。建议用32位计数器。
  • 调试时加一个统计接口。我习惯在管理器里加一个 Timer_GetStatus() 函数,返回当前所有定时器的状态、剩余时间等信息。调试的时候非常有用。

小技巧:如果你用的是RTOS,可以把定时器管理器的中断处理放在一个高优先级任务中,而不是直接在中断里执行。这样可以避免中断嵌套带来的问题,而且回调函数里可以放心地调用RTOS的API。

好了,这一章的内容就到这里。定时器管理器虽然看起来简单,但它是回调机制在实际项目中的一个典型应用。你把这个例子吃透了,以后再遇到类似的需求——比如事件驱动框架、状态机调度、协议栈定时重传——都能用同样的思路来解决。


公众号:蓝海资料掘金营,微信 deep3321