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);

切换时钟时,要按这个顺序来:

  1. 先配置目标时钟源并等待稳定
  2. 切换系统时钟到目标源
  3. 关闭原时钟源(如果有必要)
  4. 调整外设时钟分频(因为频率变了)
  5. 更新定时器 HAL 层的 tick 基准

我曾经在切换时钟时忘记调整定时器的预分频值,结果系统 tick 从 1ms 变成了 0.125ms,所有软件定时器都跑飞了。排查了半天才发现是时钟切换后 HAL 层的 s_tick_ms 没更新。

避坑指南:动态切换时钟时,一定要先确保所有外设已经停止工作,或者外设时钟分频能适应新的系统频率。特别是定时器、UART、ADC 这些对时钟敏感的外设。

4.7 整体架构图

下面这张图展示了定时器驱动分层与动态时钟配置的整体关系:

定时器驱动分层与动态时钟配置架构 SVC 服务接口层 timer_svc_start_periodic() / timer_svc_delay_ms() DRV 驱动适配层 定时器资源管理 / 回调分发 / 软件定时器调度 timer_drv_register() / timer_drv_tick() HAL 硬件抽象层 timer_hal_init() / timer_hal_start() / timer_hal_irq_handler() 硬件定时器 (TIM1/TIM2/...) 动态时钟配置 clk_switch() HSI/HSE LSI/LSE PLL 电压调节 分频更新 tick 校准 更新 tick 基准 换芯片时只需重写 HAL 层,DRV 和 SVC 层完全复用

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 层,把稳定的部分留在上层。这样不管换什么芯片,你的业务代码都能稳稳地跑起来。