第12章 时间与定时器接口:抽象时间获取与定时器回调接口

说到时间管理,嵌入式系统里这玩意儿其实挺烦人的。你想想看,不同的芯片、不同的RTOS,获取时间的API五花八门。有的用毫秒,有的用微秒,有的甚至只有Tick计数。我早年做项目时,就吃过这个亏——代码里到处是HAL_Delay()和vTaskDelay(),后来换平台,改得我头皮发麻。

所以这一章,咱们就来聊聊怎么把时间获取和定时器回调给抽象出来。说白了,就是让上层代码不关心底层是哪个芯片、哪个系统。你只管调用接口,底层怎么实现,那是移植的事。

12.1 时间获取接口:统一的时间基

先看时间获取。我习惯定义一个结构体,里面放秒和微秒。为什么不用毫秒?因为很多底层驱动需要微秒级精度,你转来转去反而容易丢精度。

// time_if.h
#ifndef TIME_IF_H
#define TIME_IF_H

#include <stdint.h>

typedef struct {
    uint32_t sec;      // 秒
    uint32_t usec;     // 微秒
} time_val_t;

// 获取当前时间
void time_get(time_val_t *tv);

// 计算时间差(微秒)
int64_t time_diff_us(const time_val_t *start, const time_val_t *end);

// 延时(微秒)
void time_delay_us(uint32_t us);

#endif

这个接口很简单,但很实用。你想想看,上层代码只需要调用time_get(),就能拿到当前时间。至于底层是读RTC还是读系统Tick,上层完全不用管。

我的习惯: 时间基统一用微秒。虽然有些平台只提供毫秒级精度,但接口层面保留微秒字段,移植时大不了填0。这样上层代码至少能编译通过,不会因为精度问题报错。

12.2 定时器回调接口:让测试不再依赖硬件

定时器回调,这个坑我踩过好几次。以前写代码,直接调用硬件定时器的回调函数。结果单元测试时,硬件没跑起来,回调根本不会触发。怎么办?

我的做法是:把定时器抽象成一个接口,回调函数通过注册的方式绑定。这样测试时,我可以手动触发回调,模拟定时器到期。

// timer_if.h
#ifndef TIMER_IF_H
#define TIMER_IF_H

#include <stdint.h>

typedef void (*timer_callback_t)(void *arg);

typedef struct {
    uint32_t period_us;          // 周期(微秒)
    timer_callback_t callback;   // 回调函数
    void *arg;                   // 回调参数
    uint8_t is_periodic;         // 是否周期定时器
} timer_cfg_t;

// 初始化定时器
int timer_init(void);

// 启动定时器
int timer_start(uint32_t timer_id, const timer_cfg_t *cfg);

// 停止定时器
int timer_stop(uint32_t timer_id);

// 获取定时器状态
int timer_is_running(uint32_t timer_id);

#endif

这里的关键是timer_cfg_t结构体。它把回调函数和参数都封装进去了。你注册定时器时,把回调函数传进去,底层定时器到期时,自动调用这个函数。

核心思想: 上层代码只关心「什么时候回调」和「回调做什么」,不关心「底层怎么实现定时」。这就是解耦。

12.3 单元测试:模拟定时器,不再依赖硬件

有了上面的接口,单元测试就好办了。我们可以写一个模拟的定时器实现,专门用于测试。

// timer_mock.c
#include "timer_if.h"

static timer_cfg_t mock_timers[10];
static uint32_t mock_time = 0;

int timer_init(void) {
    for (int i = 0; i < 10; i++) {
        mock_timers[i].callback = NULL;
    }
    return 0;
}

int timer_start(uint32_t timer_id, const timer_cfg_t *cfg) {
    if (timer_id >= 10) return -1;
    mock_timers[timer_id] = *cfg;
    return 0;
}

// 测试时手动触发回调
void timer_mock_trigger(uint32_t timer_id) {
    if (mock_timers[timer_id].callback) {
        mock_timers[timer_id].callback(mock_timers[timer_id].arg);
    }
}

// 模拟时间流逝
void timer_mock_elapse(uint32_t us) {
    mock_time += us;
}

你看,测试代码里,我们不需要真的等定时器到期。直接调用timer_mock_trigger(),就能模拟回调触发。这样单元测试跑得飞快,而且结果可预测。

我曾经踩过的坑: 一开始我把定时器ID写死在代码里,结果测试时多个模块共用同一个ID,回调互相覆盖。后来我改成动态分配ID,每个模块注册时返回一个唯一ID,问题就解决了。

12.4 平台移植:换个芯片,改底层就行

假设你从STM32换到ESP32,底层实现变了,但接口不变。你只需要重新实现time_get()和timer_start()等函数。

// time_stm32.c
#include "time_if.h"
#include "stm32f4xx_hal.h"

void time_get(time_val_t *tv) {
    uint32_t tick = HAL_GetTick();
    tv->sec = tick / 1000;
    tv->usec = (tick % 1000) * 1000;
}

void time_delay_us(uint32_t us) {
    uint32_t start = HAL_GetTick();
    while ((HAL_GetTick() - start) < (us / 1000));
}
// time_esp32.c
#include "time_if.h"
#include "esp_timer.h"

void time_get(time_val_t *tv) {
    uint64_t us = esp_timer_get_time();
    tv->sec = us / 1000000;
    tv->usec = us % 1000000;
}

void time_delay_us(uint32_t us) {
    esp_rom_delay_us(us);
}

看到了吗?上层代码完全不用改。你只需要换一个.c文件,重新编译,就完成了移植。这就是抽象接口的魅力。

12.5 知识体系:一张图看懂

下面这张图,把整个时间与定时器抽象接口的架构画出来了。你一看就明白。

时间与定时器抽象接口架构 上层应用代码 抽象接口层 time_get() | timer_start() | timer_stop() 平台实现(STM32 / ESP32 / Linux) HAL_GetTick() | esp_timer_get_time() | gettimeofday() 模拟实现(测试用) timer_mock_trigger() 硬件定时器 / RTC 模拟时间变量

这张图里,上层应用只跟抽象接口层打交道。抽象接口层下面,可以接真实的平台实现,也可以接模拟实现。测试时用模拟实现,发布时换平台实现,代码完全不用动。

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

最后,分享几个我实际项目中遇到的坑,你注意避开。

  • 回调函数不要做耗时操作: 定时器回调通常是在中断上下文中执行的。你在里面做printf或者malloc,系统很容易崩。我早期就因为这个,查了整整两天。
  • 注意回调重入问题: 如果定时器周期很短,回调还没执行完,下一次又触发了怎么办?我习惯在回调里加一个标志位,如果正在执行,直接返回。
  • 时间获取的精度问题: 有些平台的HAL_GetTick()是毫秒级的,你用它做微秒延时,误差会很大。我建议底层实现时,尽量用硬件定时器或者高精度计时器。
  • 定时器ID管理: 别用静态数组硬编码。我后来写了一个简单的ID分配器,用位图管理,支持动态申请和释放,方便多了。
我的建议: 如果你刚开始做这个抽象,先从时间获取接口开始。它最简单,也最不容易出错。等跑通了,再加定时器回调。一步一步来,别想一口吃成胖子。

好了,这一章就到这里。时间与定时器的抽象,说白了就是「接口不变,实现可变」。你把这个思想吃透了,后面做其他模块的解耦,思路是一样的。

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