第4章:定时器抽象与PWM/输入捕获——HAL_TIM的通用接口封装

定时器在嵌入式系统里,就像人的心跳。没有它,很多功能都转不起来。PWM输出、输入捕获、频率测量、脉冲计数……这些基础操作,几乎每个项目都会用到。

但问题是,STM32的定时器资源很丰富——基本定时器、通用定时器、高级定时器,每个系列的寄存器还不完全一样。如果每次用定时器都直接操作寄存器,代码复用性会非常差。我早期做项目时就吃过这个亏,换了个芯片型号,定时器驱动几乎重写了一遍。

所以这一章,我们来设计一个定时器管理器。它要能统一封装HAL_TIM的接口,让上层应用不用关心底层用的是TIM2还是TIM5,是PWM模式还是输入捕获模式。

本章核心目标: 构建一个轻量级的定时器抽象层,支持PWM输出与频率测量两大功能,且切换硬件平台时只需修改底层配置表。

4.1 定时器管理器的分层设计

先看整体架构。我习惯把定时器驱动分成三层:

  • 硬件抽象层(HAL层):直接调用STM32 HAL库的HAL_TIM_xxx函数,处理中断、DMA等底层细节。
  • 定时器管理器(Manager层):维护一个定时器资源表,提供统一的初始化、启动、停止、参数配置接口。
  • 应用接口层(API层):面向业务逻辑,比如“设置PWM占空比50%”、“测量输入信号频率”。

说白了,Manager层就是中间人。它把HAL库那些又长又像的函数名,包装成简洁、统一的接口。上层应用只需要说“我要一个PWM通道,频率1kHz,占空比30%”,Manager层自动分配硬件资源。

定时器管理器三层架构 应用接口层 (API) pwm_set_duty() / freq_measure_start() 定时器管理器 (Manager) 资源表管理 | 通道分配 | 参数校验 | 中断分发 timer_mgr_init() / timer_mgr_config() 硬件抽象层 (HAL) HAL_TIM_PWM_Init() | HAL_TIM_IC_Init() | 中断回调 HAL_TIM_PWM_Start() / HAL_TIM_IC_Start_IT() TIM2 / TIM3 / TIM5 硬件外设
我的习惯: Manager层不直接操作寄存器,全部通过HAL库中转。这样如果以后要移植到其他MCU(比如GD32、AT32),只需要替换HAL层,Manager和API层基本不用动。

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;
注意: 资源表的大小要根据具体芯片的定时器通道数来定。STM32F103C8T6有4个定时器共16个通道,TIMER_CHANNEL_MAX设为16刚好。如果芯片资源更多,记得调整。

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,后来发现这个宏更快,而且不会触发不必要的状态检查。

经验之谈: 在PWM频率切换时,建议先停止定时器,修改完ARR和PSC后再重新启动。否则在修改过程中,定时器可能输出一个异常宽度的脉冲,导致电机抖动或LED闪烁。

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);
    }
}
我曾经踩过的坑: 输入捕获中断里千万不要做printf、延时、或者复杂的浮点运算。中断服务函数要短平快,否则会丢失后续的捕获事件。我一般只做数据采集和状态更新,计算频率的工作放到任务级去处理。

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;
}
我的建议: 配置表放在单独的文件里,不同硬件平台只需要换这个文件。比如开发板用TIM2,量产板用TIM3,改一行配置表就行,上层代码完全不用动。

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调整在中断回调中自动完成
    }
}

你看,上层代码完全不知道底层用的是哪个定时器、哪个通道、哪个引脚。这就是抽象的魅力。换芯片?改配置表就行。

总结一下: 定时器管理器的核心价值在于“资源统一管理”和“接口标准化”。它把HAL_TIM那套繁琐的初始化流程、中断处理、参数计算,都封装成了简洁的API。项目越大,这种分层带来的好处就越明显。