4、实战:定时器驱动分层与动态时钟配置
定时器这东西,说简单也简单,说复杂能让你调一整天。我早年做一款工业采集器的时候,就因为定时器驱动和上层业务耦合太紧,换了个芯片平台,整个定时器模块重写了三遍。从那以后,我就养成了一个习惯——定时器驱动必须分层。
这一章,咱们就手把手把定时器驱动拆成三层,再配上动态时钟配置。说白了,就是让上层业务只管「多久触发一次」,底层驱动只管「怎么配置寄存器」,中间层负责适配和调度。
4.1 为什么定时器驱动需要分层?
你想想看,一个典型的单片机项目里,定时器用在哪些地方?
- 系统心跳 tick(通常是 1ms 或 10ms)
- PWM 输出(电机控制、呼吸灯)
- 输入捕获(测频率、测脉宽)
- 软件定时器(延时、超时管理)
- 低功耗唤醒定时器
这些功能对定时器的要求完全不一样。有的要精度,有的要低功耗,有的要多个通道。如果所有代码都直接操作寄存器,换芯片就是噩梦。
核心原则:上层业务不感知定时器硬件细节,底层驱动不关心业务逻辑。中间层做适配和资源管理。
4.2 三层架构设计
我习惯把定时器驱动拆成这样三层:
| 层级 | 名称 | 职责 | 典型文件 |
|---|---|---|---|
| HAL 层 | 硬件抽象层 | 直接操作定时器寄存器,提供最基础的启动/停止/配置接口 | timer_hal.c |
| DRV 层 | 驱动适配层 | 管理定时器资源分配,提供统一的定时器句柄和回调机制 | timer_drv.c |
| SVC 层 | 服务接口层 | 面向业务提供「开启一个周期定时器」「获取当前 tick」等高级接口 | timer_svc.c |
嗯,这里要注意:HAL 层是平台相关的,DRV 层是平台无关的,SVC 层是业务相关的。换芯片时,你只需要重写 HAL 层,上面两层基本不动。
4.3 HAL 层实现:最简硬件抽象
HAL 层只做一件事:把芯片原厂的定时器操作封装成几个标准函数。以 STM32 为例,我通常会这样写:
// timer_hal.h
#ifndef __TIMER_HAL_H__
#define __TIMER_HAL_H__
#include <stdint.h>
typedef struct {
uint32_t tim_base_addr; // 定时器基地址
uint32_t prescaler; // 预分频值
uint32_t period; // 自动重装载值
uint8_t irq_priority; // 中断优先级
} timer_hal_cfg_t;
int timer_hal_init(timer_hal_cfg_t *cfg);
void timer_hal_start(void);
void timer_hal_stop(void);
void timer_hal_set_period(uint32_t period);
uint32_t timer_hal_get_counter(void);
void timer_hal_irq_handler(void);
#endif
你看,这个接口里没有任何业务概念。什么「1ms 定时」「PWM 占空比」统统没有。只有寄存器层面的配置:预分频、周期、中断优先级。
我在项目中遇到过一个问题:某次把定时器 HAL 层的 timer_hal_set_period() 实现成了直接写 ARR 寄存器,结果在定时器正在运行时修改,导致一次异常中断。后来加了个判断:
void timer_hal_set_period(uint32_t period) {
// 等待更新事件完成
while (TIM->SR & TIM_SR_UIF) {}
TIM->ARR = period;
// 重新生成更新事件
TIM->EGR |= TIM_EGR_UG;
}
这个坑,大家移植的时候一定要注意。
4.4 DRV 层实现:资源管理与回调
DRV 层是三层里最核心的。它要解决几个问题:
- 多个业务模块同时申请定时器怎么办?
- 定时器中断来了,该通知谁?
- 怎么做到平台无关?
我个人的做法是:用静态数组管理定时器资源,每个定时器实例绑定一个回调函数。
// timer_drv.c
#define TIMER_DRV_MAX_INSTANCES 8
typedef struct {
uint8_t in_use;
uint32_t interval_ms;
void (*callback)(void *arg);
void *arg;
uint32_t remaining_ticks;
} timer_drv_instance_t;
static timer_drv_instance_t s_instances[TIMER_DRV_MAX_INSTANCES];
static uint32_t s_tick_ms = 1; // 系统 tick 周期,默认 1ms
int timer_drv_register(uint32_t interval_ms, void (*cb)(void *), void *arg) {
for (int i = 0; i < TIMER_DRV_MAX_INSTANCES; i++) {
if (!s_instances[i].in_use) {
s_instances[i].in_use = 1;
s_instances[i].interval_ms = interval_ms;
s_instances[i].callback = cb;
s_instances[i].arg = arg;
s_instances[i].remaining_ticks = interval_ms / s_tick_ms;
return i; // 返回句柄
}
}
return -1; // 资源不足
}
void timer_drv_tick(void) {
for (int i = 0; i < TIMER_DRV_MAX_INSTANCES; i++) {
if (s_instances[i].in_use) {
if (--s_instances[i].remaining_ticks == 0) {
s_instances[i].remaining_ticks = s_instances[i].interval_ms / s_tick_ms;
if (s_instances[i].callback) {
s_instances[i].callback(s_instances[i].arg);
}
}
}
}
}
这个设计有个好处:所有定时器实例共享同一个硬件定时器中断。HAL 层的定时器中断里只调用 timer_drv_tick(),然后 DRV 层负责分发。
小技巧:如果某个定时器要求高精度(比如 PWM 的周期),就不要走 DRV 层的软件定时器机制,直接通过 HAL 层配置硬件定时器。DRV 层只管理那些对精度要求不高的「周期性回调」。
4.5 SVC 层实现:面向业务的高级接口
SVC 层就简单了,它封装 DRV 层的接口,让业务代码调用起来更顺手。
// timer_svc.h
typedef void (*timer_svc_cb_t)(void);
int timer_svc_start_periodic(uint32_t ms, timer_svc_cb_t cb);
void timer_svc_stop(int handle);
uint32_t timer_svc_get_tick(void);
void timer_svc_delay_ms(uint32_t ms);
业务代码里,开启一个 100ms 的定时器就变成了一行:
int h = timer_svc_start_periodic(100, my_callback);
嗯,这里要注意:SVC 层不要做太多事情。我见过有人把 PID 控制算法写在 SVC 层里,结果换平台时 SVC 层也得跟着改,这就违背了分层的初衷。
4.6 动态时钟配置
动态时钟配置,说白了就是运行时修改系统时钟源和频率。这在低功耗设计中特别重要:
- 正常运行时:用 HSI 或 HSE,跑 64MHz
- 轻度休眠时:降频到 8MHz,用内部振荡器
- 深度休眠时:关闭主时钟,只留 LSI 跑 32kHz
我设计了一个时钟配置结构体,用来描述当前时钟状态:
typedef struct {
uint8_t clk_source; // 0:HSI, 1:HSE, 2:LSI, 3:LSE
uint32_t freq_hz; // 当前频率
uint8_t pll_enabled; // PLL 是否开启
uint8_t voltage_scale; // 电压等级(影响功耗)
} clk_cfg_t;
int clk_switch(clk_cfg_t *new_cfg);
切换时钟时,要按这个顺序来:
- 先配置目标时钟源并等待稳定
- 切换系统时钟到目标源
- 关闭原时钟源(如果有必要)
- 调整外设时钟分频(因为频率变了)
- 更新定时器 HAL 层的 tick 基准
我曾经在切换时钟时忘记调整定时器的预分频值,结果系统 tick 从 1ms 变成了 0.125ms,所有软件定时器都跑飞了。排查了半天才发现是时钟切换后 HAL 层的 s_tick_ms 没更新。
避坑指南:动态切换时钟时,一定要先确保所有外设已经停止工作,或者外设时钟分频能适应新的系统频率。特别是定时器、UART、ADC 这些对时钟敏感的外设。
4.7 整体架构图
下面这张图展示了定时器驱动分层与动态时钟配置的整体关系:
4.8 移植适配要点
最后,总结一下移植这套定时器驱动时的关键点:
| 移植步骤 | 需要修改的文件 | 注意事项 |
|---|---|---|
| 1. 实现 HAL 层 | timer_hal.c |
对照芯片参考手册,实现 5 个基础函数 |
| 2. 配置系统 tick | timer_drv.c |
设置 s_tick_ms,与 HAL 层中断周期一致 |
| 3. 实现时钟切换 | clk_switch.c |
注意切换顺序,先开后关 |
| 4. 校准定时器 | timer_hal.c |
时钟切换后重新计算预分频和周期 |
| 5. 测试验证 | 所有文件 | 用逻辑分析仪抓定时器输出波形验证精度 |
我个人习惯在移植完成后,写一个简单的测试用例:开启 3 个不同周期的定时器,分别控制 LED 闪烁,再用逻辑分析仪看波形。如果三个 LED 的闪烁频率都准确,那移植就算通过了。
嗯,这一章的内容就到这里。定时器分层和动态时钟配置,说白了就是把变化的部分隔离在 HAL 层,把稳定的部分留在上层。这样不管换什么芯片,你的业务代码都能稳稳地跑起来。