第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,上层完全不用管。
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(),就能模拟回调触发。这样单元测试跑得飞快,而且结果可预测。
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 知识体系:一张图看懂
下面这张图,把整个时间与定时器抽象接口的架构画出来了。你一看就明白。
这张图里,上层应用只跟抽象接口层打交道。抽象接口层下面,可以接真实的平台实现,也可以接模拟实现。测试时用模拟实现,发布时换平台实现,代码完全不用动。
12.6 避坑指南:我踩过的几个坑
最后,分享几个我实际项目中遇到的坑,你注意避开。
- 回调函数不要做耗时操作: 定时器回调通常是在中断上下文中执行的。你在里面做printf或者malloc,系统很容易崩。我早期就因为这个,查了整整两天。
- 注意回调重入问题: 如果定时器周期很短,回调还没执行完,下一次又触发了怎么办?我习惯在回调里加一个标志位,如果正在执行,直接返回。
- 时间获取的精度问题: 有些平台的HAL_GetTick()是毫秒级的,你用它做微秒延时,误差会很大。我建议底层实现时,尽量用硬件定时器或者高精度计时器。
- 定时器ID管理: 别用静态数组硬编码。我后来写了一个简单的ID分配器,用位图管理,支持动态申请和释放,方便多了。
好了,这一章就到这里。时间与定时器的抽象,说白了就是「接口不变,实现可变」。你把这个思想吃透了,后面做其他模块的解耦,思路是一样的。