第4章:定时器抽象与PWM/输入捕获——HAL_TIM的通用接口封装
定时器在嵌入式系统里,就像人的心跳。没有它,很多功能都转不起来。PWM输出、输入捕获、频率测量、脉冲计数……这些基础操作,几乎每个项目都会用到。
但问题是,STM32的定时器资源很丰富——基本定时器、通用定时器、高级定时器,每个系列的寄存器还不完全一样。如果每次用定时器都直接操作寄存器,代码复用性会非常差。我早期做项目时就吃过这个亏,换了个芯片型号,定时器驱动几乎重写了一遍。
所以这一章,我们来设计一个定时器管理器。它要能统一封装HAL_TIM的接口,让上层应用不用关心底层用的是TIM2还是TIM5,是PWM模式还是输入捕获模式。
4.1 定时器管理器的分层设计
先看整体架构。我习惯把定时器驱动分成三层:
- 硬件抽象层(HAL层):直接调用STM32 HAL库的HAL_TIM_xxx函数,处理中断、DMA等底层细节。
- 定时器管理器(Manager层):维护一个定时器资源表,提供统一的初始化、启动、停止、参数配置接口。
- 应用接口层(API层):面向业务逻辑,比如“设置PWM占空比50%”、“测量输入信号频率”。
说白了,Manager层就是中间人。它把HAL库那些又长又像的函数名,包装成简洁、统一的接口。上层应用只需要说“我要一个PWM通道,频率1kHz,占空比30%”,Manager层自动分配硬件资源。
4.2 定时器资源表设计
管理器核心是一个资源表。它记录了每个定时器通道当前被谁占用、工作在什么模式、回调函数是什么。
我见过不少项目,定时器资源管理全靠程序员脑子记。结果就是:TIM2的CH1被PWM用了,TIM3的CH2又被PWM用了,两个地方各写各的初始化代码,最后改频率时发现冲突了。
用资源表就能避免这种问题。来看结构体定义:
/* timer_mgr.h */
typedef enum {
TIMER_MODE_IDLE = 0, // 空闲
TIMER_MODE_PWM, // PWM输出
TIMER_MODE_INPUT_CAPTURE, // 输入捕获
TIMER_MODE_ENCODER // 编码器模式(预留)
} timer_mode_t;
typedef struct {
TIM_HandleTypeDef *htim; // HAL定时器句柄指针
uint32_t channel; // 通道号 (TIM_CHANNEL_1 ~ 4)
timer_mode_t mode; // 当前工作模式
uint32_t freq_hz; // 当前频率/计数频率
uint32_t duty_percent; // 占空比百分比 (仅PWM模式有效)
void (*callback)(void *arg); // 事件回调函数
void *callback_arg; // 回调参数
uint8_t in_use; // 占用标志
} timer_channel_t;
#define TIMER_CHANNEL_MAX 16 // 最大管理通道数
typedef struct {
timer_channel_t channels[TIMER_CHANNEL_MAX];
uint8_t channel_count;
} timer_manager_t;
4.3 PWM输出接口封装
PWM输出是最常用的功能。驱动舵机、调LED亮度、控制电机速度,都离不开它。
封装后的接口长这样:
/* timer_mgr_pwm.h */
int timer_mgr_pwm_init(uint8_t ch_index, uint32_t freq_hz, uint32_t duty_percent);
int timer_mgr_pwm_set_freq(uint8_t ch_index, uint32_t freq_hz);
int timer_mgr_pwm_set_duty(uint8_t ch_index, uint32_t duty_percent);
int timer_mgr_pwm_start(uint8_t ch_index);
int timer_mgr_pwm_stop(uint8_t ch_index);
每个函数内部都会先查资源表,确认通道状态,再调用HAL库接口。比如设置占空比的实现:
int timer_mgr_pwm_set_duty(uint8_t ch_index, uint32_t duty_percent)
{
timer_channel_t *ch = &mgr.channels[ch_index];
// 参数校验
if (ch_index >= mgr.channel_count) return -1;
if (ch->mode != TIMER_MODE_PWM) return -2;
if (duty_percent > 100) duty_percent = 100;
ch->duty_percent = duty_percent;
// 计算比较值:ARR * duty / 100
uint32_t pulse = (ch->htim->Init.Period + 1) * duty_percent / 100;
__HAL_TIM_SET_COMPARE(ch->htim, ch->channel, pulse);
return 0;
}
这里有个细节:__HAL_TIM_SET_COMPARE是HAL库的宏,直接写寄存器,效率很高。我刚开始用HAL库时,总习惯调用HAL_TIM_PWM_SetPulse,后来发现这个宏更快,而且不会触发不必要的状态检查。
4.4 输入捕获与频率测量
频率测量,说白了就是数脉冲。输入捕获模式可以精确记录外部信号的上升沿或下降沿时间戳,两次捕获的时间差就是周期。
我封装了两种测量方式:
| 测量方式 | 原理 | 适用场景 | 精度 |
|---|---|---|---|
| 周期测量法 | 捕获两个连续上升沿,计算时间差 | 低频信号(<100kHz) | 高(受定时器分辨率限制) |
| 频率测量法 | 在固定闸门时间内计数脉冲个数 | 高频信号(>100kHz) | 中等(受闸门时间影响) |
接口设计如下:
/* timer_mgr_capture.h */
int timer_mgr_capture_init(uint8_t ch_index, uint32_t expected_freq);
int timer_mgr_capture_start(uint8_t ch_index);
int timer_mgr_capture_stop(uint8_t ch_index);
uint32_t timer_mgr_capture_get_freq(uint8_t ch_index);
周期测量法的核心代码在中断回调里:
// 在HAL_TIM_IC_CaptureCallback中调用
void timer_mgr_capture_isr(TIM_HandleTypeDef *htim)
{
uint8_t idx = find_channel_by_htim(htim);
if (idx == 0xFF) return;
timer_channel_t *ch = &mgr.channels[idx];
uint32_t capture = HAL_TIM_ReadCapturedValue(htim, ch->channel);
static uint32_t last_capture = 0;
uint32_t period_ticks;
if (capture > last_capture) {
period_ticks = capture - last_capture;
} else {
// 处理定时器溢出
period_ticks = (ch->htim->Init.Period + 1) - last_capture + capture;
}
last_capture = capture;
// 计算频率:定时器时钟 / (PSC+1) / period_ticks
uint32_t timer_clock = HAL_RCC_GetPCLK1Freq(); // 假设APB1
uint32_t psc = ch->htim->Init.Prescaler + 1;
uint32_t freq = timer_clock / psc / period_ticks;
// 保存结果,供上层查询
ch->freq_hz = freq;
// 调用用户回调
if (ch->callback) {
ch->callback(ch->callback_arg);
}
}
4.5 初始化流程与配置表
管理器初始化时,需要知道每个通道对应哪个定时器、哪个引脚。我习惯用一张配置表来定义:
/* timer_mgr_config.c */
#include "timer_mgr.h"
const timer_channel_config_t timer_config_table[] = {
// 索引0: TIM2_CH1 -> PA0, PWM输出
{
.timer_instance = TIM2,
.channel = TIM_CHANNEL_1,
.gpio_port = GPIOA,
.gpio_pin = GPIO_PIN_0,
.default_mode = TIMER_MODE_PWM,
.default_freq = 1000,
.default_duty = 50
},
// 索引1: TIM2_CH2 -> PA1, 输入捕获
{
.timer_instance = TIM2,
.channel = TIM_CHANNEL_2,
.gpio_port = GPIOA,
.gpio_pin = GPIO_PIN_1,
.default_mode = TIMER_MODE_INPUT_CAPTURE,
.default_freq = 10000,
.default_duty = 0
},
// ... 更多通道配置
};
const uint8_t timer_config_count = sizeof(timer_config_table) / sizeof(timer_channel_config_t);
初始化函数遍历这张表,逐个调用HAL_TIM_PWM_Init或HAL_TIM_IC_Init,并填充资源表:
int timer_mgr_init(void)
{
mgr.channel_count = timer_config_count;
for (uint8_t i = 0; i < timer_config_count; i++) {
const timer_channel_config_t *cfg = &timer_config_table[i];
timer_channel_t *ch = &mgr.channels[i];
ch->channel = cfg->channel;
ch->mode = cfg->default_mode;
ch->freq_hz = cfg->default_freq;
ch->duty_percent = cfg->default_duty;
ch->in_use = 1;
// 初始化GPIO为复用功能
// 初始化定时器句柄
// 调用HAL_TIM_xxx_Init()
}
return 0;
}
4.6 使用示例:PWM呼吸灯 + 频率测量
最后看一个完整的使用例子。假设我们要实现:
- 通道0输出PWM,驱动LED呼吸灯
- 通道1输入捕获,测量外部信号频率
- 当频率超过阈值时,自动调整PWM占空比
/* main.c */
#include "timer_mgr.h"
void freq_callback(void *arg)
{
uint8_t ch_index = (uint8_t)(uint32_t)arg;
uint32_t freq = timer_mgr_capture_get_freq(ch_index);
// 频率越高,LED越亮
uint32_t duty = (freq > 50000) ? 100 : (freq * 100 / 50000);
timer_mgr_pwm_set_duty(0, duty);
}
int main(void)
{
HAL_Init();
SystemClock_Config();
// 初始化定时器管理器
timer_mgr_init();
// 启动PWM通道0,频率1kHz,占空比50%
timer_mgr_pwm_init(0, 1000, 50);
timer_mgr_pwm_start(0);
// 启动输入捕获通道1,注册回调
timer_mgr_capture_init(1, 10000);
timer_mgr_capture_set_callback(1, freq_callback, (void *)1);
timer_mgr_capture_start(1);
while (1) {
// 主循环做其他事情
// 频率测量和PWM调整在中断回调中自动完成
}
}
你看,上层代码完全不知道底层用的是哪个定时器、哪个通道、哪个引脚。这就是抽象的魅力。换芯片?改配置表就行。