4. 定时器驱动分层与PWM输出:HAL_TIM的封装,PWM频率/占空比的分层控制接口

定时器,说白了就是单片机的“心跳发生器”。做嵌入式这些年,我见过太多工程师直接在应用层调用 HAL_TIM_PWM_Start(),然后满世界找 bug。其实问题不在 HAL 库,在于你没给它套一层“外衣”。

这一章,我们就来聊聊怎么给 HAL 定时器驱动做分层封装。重点解决两个问题:频率和占空比怎么分层控制,以及封装后怎么快速定位故障。

4.1 为什么需要分层?——我踩过的坑

先讲个真实案例。几年前我做一款电机驱动板,用了 TIM2 输出两路 PWM。代码写得很“直接”:

// 应用层直接操作 HAL 句柄
htim2.Instance = TIM2;
htim2.Init.Prescaler = 71;
htim2.Init.Period = 999;
HAL_TIM_PWM_Init(&htim2);
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);

当时觉得挺爽,后来需求变了——要支持 3 种不同频率的 PWM。我不得不满文件找 Prescaler 和 Period 的赋值位置,改一处漏三处。更崩溃的是,另一个同事接手后,直接改了 htim2 的全局变量,导致两路 PWM 频率全乱套。

这就是不分层的代价。你想想看,如果我把定时器配置封装成“频率 + 占空比”两个接口,底层怎么算 Prescaler、怎么选时钟源,应用层根本不用关心。是不是清爽多了?

4.2 分层架构设计:三层模型

我个人习惯把定时器驱动拆成三层,每层只干一件事:

层次 职责 典型接口
HAL 层 直接操作寄存器,完成最底层的初始化、启动、停止 HAL_TIM_PWM_Init(), HAL_TIM_PWM_Start()
驱动封装层 封装 HAL 调用,提供“频率/占空比”设置函数,隐藏 Prescaler/Period 计算 DRV_PWM_SetFreq(), DRV_PWM_SetDuty()
应用接口层 面向业务逻辑,提供“电机转速 50%”、“LED 亮度 128”等语义接口 APP_Motor_SpeedSet(), APP_LED_Brightness()

嗯,这里要注意:驱动封装层是核心。它既要屏蔽 HAL 的复杂性,又要保留足够的灵活性。我见过有人把 Prescaler 写死在封装层里,结果换了个晶振频率,整个项目重算——这就矫枉过正了。

4.3 驱动封装层的具体实现

先看头文件,我习惯这样定义接口:

/* pwm_drv.h */
typedef struct {
    uint32_t tim_instance;  // TIM1, TIM2, ...
    uint32_t channel;       // TIM_CHANNEL_1 ~ 4
    uint32_t freq_hz;       // 目标频率,单位 Hz
    uint16_t duty_percent;  // 占空比,0~1000(千分比,精度更高)
} PWM_Config_t;

int32_t DRV_PWM_Init(PWM_Config_t *cfg);
int32_t DRV_PWM_SetFreq(PWM_Config_t *cfg, uint32_t freq_hz);
int32_t DRV_PWM_SetDuty(PWM_Config_t *cfg, uint16_t duty_percent);
int32_t DRV_PWM_Start(PWM_Config_t *cfg);
int32_t DRV_PWM_Stop(PWM_Config_t *cfg);

注意返回值类型。我统一用 int32_t,0 表示成功,负数表示错误码。这样上层可以统一做错误处理,而不是到处检查 HAL_StatusTypeDef。

再看实现里的核心计算:

/* pwm_drv.c */
#define TIMER_CLOCK_HZ   84000000  // 假设 APB1 时钟 84MHz

static uint32_t _calc_prescaler(uint32_t freq_hz, uint32_t *period) {
    // 先算理论分频系数
    uint32_t psc = (TIMER_CLOCK_HZ / freq_hz / 65536) + 1;
    if (psc < 1) psc = 1;
    *period = (TIMER_CLOCK_HZ / (psc * freq_hz)) - 1;
    return psc;
}

int32_t DRV_PWM_SetFreq(PWM_Config_t *cfg, uint32_t freq_hz) {
    uint32_t period;
    uint32_t psc = _calc_prescaler(freq_hz, &period);
    
    // 更新 HAL 句柄
    __HAL_TIM_SET_PRESCALER(&htim[cfg->tim_instance], psc - 1);
    __HAL_TIM_SET_AUTORELOAD(&htim[cfg->tim_instance], period);
    
    cfg->freq_hz = freq_hz;
    return 0;
}

这里有个细节:_calc_prescaler 函数保证了 Period 不超过 65535,同时 Prescaler 尽量小,以获得更高的 PWM 分辨率。我在项目中遇到过 Prescaler 设得太大,导致占空比只能调 0%、50%、100% 三档——那就是分辨率不够了。

4.4 占空比的分层控制

占空比控制,说白了就是设置 CCR 寄存器。但直接暴露 CCR 值给应用层?不,那样又回到老路了。

我建议用千分比作为占空比的单位:

int32_t DRV_PWM_SetDuty(PWM_Config_t *cfg, uint16_t duty_percent) {
    if (duty_percent > 1000) return -1;  // 错误:超出范围
    
    TIM_HandleTypeDef *htim = &htim[cfg->tim_instance];
    uint32_t period = __HAL_TIM_GET_AUTORELOAD(htim);
    uint32_t ccr = (period * duty_percent) / 1000;
    
    __HAL_TIM_SET_COMPARE(htim, cfg->channel, ccr);
    cfg->duty_percent = duty_percent;
    return 0;
}

为什么用千分比而不是百分比?因为 0~1000 的精度,在电机控制、LED 调光场景下刚好够用。如果你需要更高精度,可以改成 0~10000(万分比),但要注意 CCR 寄存器的位宽限制。

我的习惯:在驱动封装层,所有参数都用“物理意义”的单位(Hz、千分比),而不是寄存器值。这样应用层工程师不需要知道定时器原理,也能正确调用。

4.5 故障定位:分层后的调试技巧

分层之后,故障定位反而更清晰了。我总结了一个“三层排查法”:

  1. 应用层问题:检查传入的频率/占空比是否合理。比如传了 0Hz 或者占空比 2000(超过 1000),那肯定是应用层 bug。
  2. 驱动封装层问题:检查 Prescaler 和 Period 的计算结果。我习惯在调试时加一句打印:printf("PSC=%lu, ARR=%lu\n", psc, period);,一眼就能看出计算是否溢出。
  3. HAL 层问题:检查 HAL_TIM_PWM_Start 的返回值。如果返回 HAL_ERROR,多半是通道没使能或者 GPIO 配置不对。

我曾经遇到一个诡异的 bug:PWM 输出频率是对的,但占空比怎么调都是 50%。排查了半天,发现是驱动封装层里 __HAL_TIM_SET_COMPARE 的通道参数写错了——传了 TIM_CHANNEL_1 但实际用的是通道 2。这就是分层没做好的典型:底层错误被封装层“吞掉”了,上层完全不知道。

避坑指南:驱动封装层的每个接口,一定要做参数合法性检查。我曾经因为没检查 duty_percent 的范围,导致 CCR 写入了一个大于 Period 的值,PWM 输出直接反相了——那个 bug 查了我整整两天。

4.6 分层架构的 SVG 结构图

下面这张图,是我做培训时经常画的。它清晰地展示了三层之间的调用关系和故障排查路径:

定时器驱动分层架构与故障排查路径 应用接口层 APP_Motor_SpeedSet(50%) 驱动封装层 DRV_PWM_SetDuty(500) → 计算CCR HAL 层 __HAL_TIM_SET_COMPARE() 排查方向 应用层 → 封装层 → HAL层 常见故障点 参数越界 / 计算溢出 / 通道错误 图4-1:定时器驱动三层架构及故障排查路径

4.7 实际项目中的封装经验

最后分享几个我在项目中沉淀下来的经验:

  • 句柄管理:不要把 HAL 的 TIM_HandleTypeDef 暴露给应用层。我习惯在驱动封装层维护一个静态数组 static TIM_HandleTypeDef htim[8],用 tim_instance 索引访问。这样既安全又高效。
  • 错误码定义:统一错误码,比如 -1 表示参数错误,-2 表示硬件未初始化,-3 表示资源冲突。应用层根据错误码就能快速定位问题。
  • 调试预留:在驱动封装层留一个“调试模式”开关。打开后,每次设置频率/占空比都会通过串口打印关键寄存器值。这个习惯救过我很多次。

核心原则:每一层只做一件事,每一层只依赖下一层。应用层不直接调 HAL,HAL 层不处理业务逻辑。这样,任何一个 PWM 相关的 bug,最多查两层就能定位。

嗯,这一章的内容就到这里。定时器分层封装看起来简单,但真正做到“应用层无感、调试层清晰”,还是需要一些实践积累的。希望你能在自己的项目里试试这套方法——相信我,下次遇到 PWM 输出异常,你会感谢今天做的封装。