前言

在嵌入式开发的世界里,定时器(Timer)堪称单片机的"瑞士军刀"。从简单的延时函数、周期性任务调度,到复杂的电机驱动控制、高精度脉冲测量、通信时序生成,定时器的身影无处不在。对于 STM32 这样的 Cortex-M 架构微控制器来说,定时器外设的丰富程度和灵活性,更是其区别于普通 8 位单片机的核心优势之一。

然而,很多开发者对 STM32 定时器的使用仅仅停留在"定时中断"这个最基础的层面——配置好自动重装载值,使能中断,然后在中断服务函数里翻转一下 LED。对于定时器的高级功能,如 PWM 输出、输入捕获、编码器接口等,要么知之甚少,要么只会通过 CubeMX 生成代码后"照着例子抄",遇到问题时根本不知道从何排查。

根据我多年的嵌入式开发经验,真正拉开单片机开发者水平差距的,往往不是会不会用某个外设,而是能不能把外设的性能发挥到极致。一个只会用定时器做延时的工程师,和一个能熟练运用编码器接口做闭环控制的工程师,其解决问题的能力和项目贡献度完全不在一个量级。

本文将从实际应用出发,系统讲解 STM32 定时器的四大高级功能:PWM 输出、输入捕获、编码器接口和输出比较。我们不会停留在寄存器层面的理论讲解,而是通过大量可直接运行的代码示例、实测数据和常见问题排查指南,带你真正掌握定时器的高级应用技巧。无论你是刚接触 STM32 的新手,还是有多年经验的老司机,这篇文章都会帮助你构建完整的定时器应用知识体系。

STM32 定时器架构与工作模式

一、STM32 定时器家族概览

在深入具体应用之前,我们首先需要搞清楚 STM32 到底有多少种定时器,它们各自的特点是什么。很多初学者看到 STM32 的数据手册里 TIM1、TIM2、TIM8…一大堆定时器型号就头大,不知道该怎么选择。其实只要掌握了分类方法,一切就都清晰了。

1.1 定时器的分类与特点

STM32 的定时器按照功能复杂度可以分为四大类:

高级控制定时器(TIM1、TIM8):这是功能最强大的定时器,通常挂载在 APB2 总线上。除了基本定时功能外,还具备互补输出、死区时间插入、刹车输入等高级功能,专门为电机控制和开关电源设计。如果你需要做三相 BLDC 电机的 FOC 控制,或者需要带死区的 PWM 输出,高级定时器是唯一选择。

通用定时器(TIM2-TIM5,TIM9-TIM14):这是使用最广泛的一类定时器,挂载在 APB1 或 APB2 总线上。通用定时器具备完整的四大功能:定时中断、PWM 输出、输入捕获、编码器接口。其中 TIM2 和 TIM5 是 32 位计数器,其余是 16 位。对于 90% 以上的应用场景,通用定时器都是最佳选择。

基本定时器(TIM6、TIM7):功能最简单,只能做基本定时和 DAC 触发,没有输入输出通道。优点是资源占用小,中断优先级配置简单。通常用于周期性任务调度、ADC 采样触发等场景。

低功耗定时器(LPTIM):专为低功耗应用设计,可以在停止模式下继续运行,使用 LSE 或 LSI 作为时钟源。适合电池供电设备的周期性唤醒场景。

1.2 定时器资源快速选择表

功能需求推荐定时器备注
简单定时中断TIM6/TIM7资源开销最小
PWM 输出(单路)TIM2-TIM5任意通用定时器
PWM 输出(多通道同步)TIM1/TIM8高级定时器同步性更好
脉冲宽度/频率测量TIM2-TIM5输入捕获功能
旋转编码器解码TIM2-TIM5需要 TI1+TI2 双通道
电机控制(带死区)TIM1/TIM8必须用高级定时器
长周期定时(>10秒)TIM2/TIM532位计数器优势明显

1.3 定时器时钟树解析

很多人配置定时器时最容易踩的坑就是时钟计算错误。STM32 的定时器时钟来源并不是简单的等于系统时钟,而是有一套复杂的时钟树机制。

以最常用的 STM32F4 系列为例:

  • APB1 总线时钟(PCLK1)最大 42MHz
  • APB2 总线时钟(PCLK2)最大 84MHz
  • 如果 APB 预分频器 = 1,定时器时钟 = PCLKx
  • 如果 APB 预分频器 > 1,定时器时钟 = 2 × PCLKx

这意味着在默认配置下(PCLK1=42MHz,PCLK2=84MHz):

  • APB1 上的定时器(TIM2-TIM7)时钟是 84MHz
  • APB2 上的定时器(TIM1、TIM8-TIM11)时钟是 168MHz

这是一个非常容易忽略的细节。我见过太多开发者按照 42MHz 计算 TIM3 的预分频值,结果实际定时时间差了一倍,排查了好几天才发现问题。记住这个简单的规则:STM32F4/F7/H7 系列中,只要 APB 预分频不是 1,定时器时钟就是总线时钟的两倍

定时器溢出频率计算公式:

更新事件频率 = 定时器时钟 / (PSC + 1) / (ARR + 1)

其中 PSC 是预分频器值(0-65535),ARR 是自动重装载值(16位或32位)。记住两个都是 “+1”,因为计数器是从 0 开始计数的。

举个例子:要产生 1ms 的定时周期,定时器时钟 84MHz:

目标频率 = 1000 Hz
PSC + 1 = 84 → PSC = 83
ARR + 1 = 1000 → ARR = 999
验证:84,000,000 / 84 / 1000 = 1000 Hz ✓

这个计算方法适用于所有定时器模式,无论是定时中断、PWM 还是输入捕获,都是基于这个最基础的公式。

二、PWM 输出模式:从点亮 LED 到电机控制

PWM(脉冲宽度调制)是定时器最常用的功能,没有之一。从简单的 LED 呼吸灯效果,到复杂的伺服电机角度控制、直流电机调速、开关电源稳压,PWM 都是核心技术手段。

2.1 PWM 工作原理

PWM 的本质是通过控制高电平时间在一个周期内的比例(占空比),来模拟出不同的平均电压。这个原理说起来简单,但真正用好却有很多讲究。

STM32 定时器的 PWM 输出基于"比较匹配"机制:

  1. 计数器 CNT 从 0 开始递增计数
  2. 当 CNT < CCR(捕获比较寄存器)时,输出高电平
  3. 当 CNT >= CCR 时,输出低电平
  4. 当 CNT 达到 ARR 时,计数器清零,开始下一个周期

改变 CCR 的值,就可以改变占空比;改变 ARR 的值,就可以改变 PWM 频率。

PWM 模式有两种:PWM 模式 1PWM 模式 2。两者的区别只是电平极性相反。99% 的情况下我们使用 PWM 模式 1,配合有效的极性选择,就可以实现任何需要的波形。

2.2 HAL 库配置与常见坑点

用 CubeMX 配置 PWM 非常简单,但自动生成的代码往往不是最优的,而且隐藏了很多容易踩的坑。

这是 CubeMX 生成的标准 PWM 初始化代码:

/* 注意:这是 CubeMX 生成的代码,存在优化空间 */
static void MX_TIM3_Init(void)
{
  TIM_ClockConfigTypeDef sClockSourceConfig = {0};
  TIM_MasterConfigTypeDef sMasterConfig = {0};
  TIM_OC_InitTypeDef sConfigOC = {0};

  htim3.Instance = TIM3;
  htim3.Init.Prescaler = 83;
  htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
  htim3.Init.Period = 999;
  htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
  htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
  if (HAL_TIM_Base_Init(&htim3) != HAL_OK)
  {
    Error_Handler();
  }
  sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;
  if (HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig) != HAL_OK)
  {
    Error_Handler();
  }
  if (HAL_TIM_PWM_Init(&htim3) != HAL_OK)
  {
    Error_Handler();
  }
  sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
  sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
  if (HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig) != HAL_OK)
  {
    Error_Handler();
  }
  sConfigOC.OCMode = TIM_OCMODE_PWM1;
  sConfigOC.Pulse = 500;
  sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
  sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
  if (HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1) != HAL_OK)
  {
    Error_Handler();
  }
  HAL_TIM_MspPostInit(&htim3);
}

这段代码可以工作,但有几个严重的问题:

问题一:AutoReloadPreload 被禁用

TIM_AUTORELOAD_PRELOAD_DISABLE 意味着当你在运行时修改 ARR 值时,新值会立即生效,而不是等到当前周期结束。这会导致在修改的瞬间出现一个异常的 PWM 周期,严重时会造成电机抖动、LED 闪烁。

修复方法:

htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;

问题二:没有使能 CCR 预装载

CubeMX 默认不会使能 CCR 预装载,也就是说修改占空比时新值也是立即生效的。对于电机控制这种对平滑性要求高的场景,这绝对是个灾难。

修复方法:

sConfigOC.OCPreload = TIM_OC_PRELOAD_ENABLE;  // 加上这一行!

问题三:PWM 启动方式不对

很多人用 HAL_TIM_PWM_Start() 启动 PWM,但如果需要在中断里修改占空比,这个函数是不够的。正确的做法是先启动定时器基准,再启动 PWM 通道。

正确的启动方式:

HAL_TIM_Base_Start(&htim3);       // 先启动定时器基准
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);  // 再启动 PWM 通道

这三个坑是 90% 的 PWM 问题的根源。如果你遇到"占空比修改时电机抖一下"、“LED 呼吸时有闪烁"之类的问题,先检查这三个配置项,90% 的情况下问题就在这里。

2.3 运行时动态调整占空比

调整占空比看似简单,就是写个寄存器而已,但里面也有讲究。

最常见的错误写法:

// 不好的写法:每层 HAL 调用都有开销
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, new_duty);

这个宏定义看起来简单,但在 HAL 库内部其实有多层函数调用和参数检查。对于低速应用来说无所谓,但如果是在 10kHz 的中断里频繁调用,这点开销就不能忽略了。

推荐的高效写法:

// 高效写法:直接操作寄存器
TIM3->CCR1 = new_duty;

直接写寄存器没有任何额外开销,一条汇编指令就搞定了。只要你明确知道自己在操作哪个定时器的哪个通道,这就是最安全最高效的方式。

占空比边界检查:

// 必须做边界检查,否则会出现异常波形
uint32_t set_pwm_duty(TIM_HandleTypeDef *htim, uint32_t channel, uint32_t duty)
{
    uint32_t arr = htim->Instance->ARR;
    
    // 边界检查:占空比不能超过周期值
    if (duty > arr) {
        duty = arr;
    }
    
    switch (channel) {
        case TIM_CHANNEL_1: htim->Instance->CCR1 = duty; break;
        case TIM_CHANNEL_2: htim->Instance->CCR2 = duty; break;
        case TIM_CHANNEL_3: htim->Instance->CCR3 = duty; break;
        case TIM_CHANNEL_4: htim->Instance->CCR4 = duty; break;
        default: return HAL_ERROR;
    }
    
    return HAL_OK;
}

永远不要信任输入参数的合法性。如果 duty 值大于 ARR,会导致 PWM 永远输出高电平,相当于 100% 占空比,但这通常不是程序期望的行为。加上简单的边界检查,可以避免很多难以排查的问题。

三、输入捕获模式:高精度脉冲测量

输入捕获是定时器的另一项核心功能,用于精确测量外部脉冲的频率、占空比和周期。相比于用 GPIO 中断加软件计时的方案,硬件级的输入捕获精度要高出几个数量级。

3.1 输入捕获的工作原理

输入捕获的基本原理很简单:当外部输入引脚检测到指定的边沿(上升沿、下降沿或双边沿)时,定时器会自动将当前的计数器值锁存到 CCR 寄存器中,同时可以选择产生中断。

通过两次捕获之间的计数器差值,我们就可以精确计算出脉冲的时间参数:

脉冲周期 = (第二次捕获值 - 第一次捕获值) × 计数周期
脉冲频率 = 1 / 脉冲周期
占空比 = 高电平时间 / 脉冲周期 × 100%

输入捕获有一个非常重要的参数:输入滤波器。STM32 定时器的每个输入通道都内置了一个数字滤波器,可以有效滤除输入信号上的毛刺。滤波器的采样频率和采样次数可以配置,最高支持 f_DTS / 32 的采样频率和 8 次采样验证。

对于机械按键、编码器等有抖动的输入源,滤波器是必不可少的。但要注意:滤波器会引入延迟,采样次数越多延迟越大。对于高速信号测量,应该使用较浅的滤波深度。

3.2 频率测量的两种方案

测量频率有两种基本方案,各有优缺点:

方案一:脉冲计数法(适合高频)

在固定的闸门时间(比如 1 秒)内统计脉冲个数。

优点:实现简单,高频时精度高 缺点:低频时误差大,闸门时间越长精度越高但响应越慢

频率 = 脉冲个数 / 闸门时间

方案二:周期测量法(适合低频)

测量单个脉冲的周期,然后计算频率。

优点:低频时精度高,响应速度快 缺点:高频时误差增大

频率 = 定时器时钟 / (PSC + 1) / 脉冲周期计数值

最佳实践:自适应方案

在实际项目中,我推荐使用自适应方案:

  • 当频率 > 1kHz 时,使用脉冲计数法
  • 当频率 ≤ 1kHz 时,使用周期测量法

这样可以在全频率范围内都获得较好的测量精度。

3.3 输入捕获完整代码实现

下面是一个完整的输入捕获例程,支持同时测量频率和占空比,使用 TIM2 的通道 1:

/* 输入捕获状态结构体 */
typedef struct {
    uint32_t capture_start;    /* 第一次捕获值 */
    uint32_t capture_end;      /* 第二次捕获值 */
    uint32_t period;           /* 周期计数值 */
    uint32_t high_time;        /* 高电平时间 */
    uint8_t  capture_done;     /* 捕获完成标志 */
    uint8_t  edge_state;       /* 边沿状态:0-等待上升沿,1-等待下降沿 */
} Capture_HandleTypeDef;

Capture_HandleTypeDef cap_handle = {0};

/**
 * @brief  输入捕获中断回调函数
 * @note   这个函数在 HAL_TIM_IRQHandler 中被调用
 */
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM2) {
        if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) {
            uint32_t capture_val = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1);
            
            if (cap_handle.edge_state == 0) {
                /* 上升沿:记录起始时间 */
                cap_handle.capture_start = capture_val;
                cap_handle.edge_state = 1;
                
                /* 切换为下降沿捕获 */
                __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, 
                    TIM_INPUTCHANNELPOLARITY_FALLING);
            } else {
                /* 下降沿:计算高电平时间 */
                if (capture_val >= cap_handle.capture_start) {
                    cap_handle.high_time = capture_val - cap_handle.capture_start;
                } else {
                    /* 处理计数器溢出 */
                    cap_handle.high_time = (0xFFFFFFFF - cap_handle.capture_start) 
                        + capture_val + 1;
                }
                
                /* 切换为上升沿捕获,等待下一个周期 */
                cap_handle.edge_state = 0;
                cap_handle.capture_done = 1;
                cap_handle.period = cap_handle.high_time;
                
                __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, 
                    TIM_INPUTCHANNELPOLARITY_RISING);
            }
        }
    }
}

/**
 * @brief  获取测量的频率和占空比
 * @retval HAL status
 */
HAL_StatusTypeDef capture_get_measurement(float *frequency, float *duty_cycle)
{
    if (cap_handle.capture_done == 0) {
        return HAL_BUSY;
    }
    
    /* 禁止中断,保证数据一致性 */
    __disable_irq();
    uint32_t period = cap_handle.period;
    uint32_t high_time = cap_handle.high_time;
    cap_handle.capture_done = 0;
    __enable_irq();
    
    if (period == 0) {
        return HAL_ERROR;
    }
    
    /* 计算频率:TIM2 时钟 84MHz,PSC = 83 → 计数周期 1us */
    *frequency = 1000000.0f / period;
    
    /* 计算占空比 */
    *duty_cycle = (float)high_time / period * 100.0f;
    
    return HAL_OK;
}

/**
 * @brief  初始化输入捕获
 */
void capture_init(void)
{
    /* 配置 TIM2 为输入捕获模式 */
    htim2.Instance = TIM2;
    htim2.Init.Prescaler = 83;        /* 84MHz / 84 = 1MHz → 1us 计数 */
    htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
    htim2.Init.Period = 0xFFFFFFFF;   /* 32位最大周期 */
    htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
    HAL_TIM_IC_Init(&htim2);
    
    /* 配置通道 1 */
    TIM_IC_InitTypeDef sConfigIC = {0};
    sConfigIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING;
    sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI;
    sConfigIC.ICPrescaler = TIM_ICPSC_DIV1;
    sConfigIC.ICFilter = 8;           /* 8 次采样滤波,消除抖动 */
    HAL_TIM_IC_ConfigChannel(&htim2, &sConfigIC, TIM_CHANNEL_1);
    
    /* 启动捕获,使能中断 */
    HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1);
    
    /* 初始化状态 */
    cap_handle.edge_state = 0;
    cap_handle.capture_done = 0;
}

3.4 输入捕获常见问题排查

问题一:测量值跳动很大

  • 检查输入滤波器是否开启,滤波深度是否足够
  • 确认信号线是否有干扰,必要时加上拉/下拉电阻
  • 检查定时器时钟是否稳定,避免使用 HSI 做高精度测量

问题二:测量高频时误差大

  • 减小预分频值,提高计数分辨率
  • 考虑切换到脉冲计数法
  • 使用 32 位定时器(TIM2/TIM5)避免频繁溢出

问题三:中断响应不及时导致丢包

  • 提高定时器中断优先级
  • 减小中断服务函数的执行时间
  • 对于高频信号,考虑使用 DMA 传输捕获值

四、编码器接口模式:硬件级正交解码

如果你做过机器人、伺服电机或者任何需要精确位置反馈的项目,就一定接触过旋转编码器。编码器的输出通常是 A、B 两路正交信号,相位差 90 度,通过判断相位差的正负来确定旋转方向。

很多新手处理编码器信号的方式是:两个外部中断,分别检测 A、B 相的边沿,然后在中断里判断方向、计数。这种方案在低速时还能用,但转速一高就会大量丢脉冲,而且占用大量 CPU 时间。

STM32 的定时器提供了硬件编码器接口,完全不需要 CPU 干预就可以自动完成正交解码和计数,这才是处理编码器信号的正确方式。

4.1 编码器接口工作原理

STM32 的编码器接口使用定时器的 TI1 和 TI2 两个输入通道,支持三种计数模式:

编码器模式 1:仅在 TI2 边沿时,根据 TI1 的电平进行计数

编码器模式 2:仅在 TI1 边沿时,根据 TI2 的电平进行计数

编码器模式 3:TI1 和 TI2 边沿都计数(4 倍频模式)

模式 3 是最常用的,也是分辨率最高的。在这种模式下,A 相和 B 相的每个上升沿和下降沿都会触发计数,所以实际计数值是编码器线数的 4 倍。

举个例子:一个 1000 线的编码器,使用模式 3 时,每转一圈 CNT 计数器会增加 4000。

方向判断是自动完成的:

  • 正转时(A 相超前 B 相 90 度),计数器递增
  • 反转时(B 相超前 A 相 90 度),计数器递减

4.2 编码器接口配置步骤

配置编码器接口比想象中简单得多,只需要几个关键步骤:

/**
 * @brief  定时器编码器模式初始化
 * @param  htim: 定时器句柄
 * @param  max_count: 最大计数值(通常是编码器线数×4)
 * @retval HAL status
 */
HAL_StatusTypeDef encoder_init(TIM_HandleTypeDef *htim, uint32_t max_count)
{
    TIM_Encoder_InitTypeDef sConfig = {0};
    TIM_MasterConfigTypeDef sMasterConfig = {0};
    
    /* 1. 配置定时器基本参数 */
    htim->Init.Prescaler = 0;                    /* 不分频,最高分辨率 */
    htim->Init.CounterMode = TIM_COUNTERMODE_UP;
    htim->Init.Period = max_count - 1;           /* 自动重装载值 */
    htim->Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
    htim->Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
    
    /* 2. 配置编码器接口 */
    sConfig.EncoderMode = TIM_ENCODERMODE_TI12;  /* 模式 3:4 倍频 */
    sConfig.IC1Polarity = TIM_ICPOLARITY_RISING; /* TI1 上升沿触发 */
    sConfig.IC1Selection = TIM_ICSELECTION_DIRECTTI;
    sConfig.IC1Prescaler = TIM_ICPSC_DIV1;       /* 不分频 */
    sConfig.IC1Filter = 10;                      /* 输入滤波,消除机械抖动 */
    sConfig.IC2Polarity = TIM_ICPOLARITY_RISING; /* TI2 上升沿触发 */
    sConfig.IC2Selection = TIM_ICSELECTION_DIRECTTI;
    sConfig.IC2Prescaler = TIM_ICPSC_DIV1;
    sConfig.IC2Filter = 10;
    
    if (HAL_TIM_Encoder_Init(htim, &sConfig) != HAL_OK) {
        return HAL_ERROR;
    }
    
    /* 3. 配置主从模式 */
    sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
    sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
    HAL_TIMEx_MasterConfigSynchronization(htim, &sMasterConfig);
    
    /* 4. 启动编码器接口 */
    HAL_TIM_Encoder_Start(htim, TIM_CHANNEL_ALL);
    
    /* 5. 计数器清零,从零开始 */
    htim->Instance->CNT = 0;
    
    return HAL_OK;
}

就是这么简单!初始化完成后,你什么都不用做,定时器会自动处理 A、B 相信号,CNT 寄存器的值就是当前的位置。读取位置只需要一句话:

int32_t position = (int32_t)htim2.Instance->CNT;

注意这里我用了 int32_t 强制类型转换,因为反转时 CNT 会变成负数,直接读取 uint32_t 会得到一个很大的正数。

4.3 编码器应用的高级技巧

技巧一:Z 相归零处理

大多数增量式编码器还有一个 Z 相(零位信号),每转一圈输出一个脉冲。可以用这个信号来做绝对位置校准:

/* 配置 Z 相为外部中断,上升沿触发 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == ENCODER_Z_Pin) {
        /* Z 相信号到来,计数器归零 */
        htim2.Instance->CNT = 0;
    }
}

技巧二:速度测量

编码器不仅可以测位置,还可以高精度测速。有两种方法:

  1. M 法:固定时间间隔读取 CNT 差值
/* 每 10ms 调用一次 */
float encoder_get_speed(void)
{
    static int32_t last_cnt = 0;
    int32_t current_cnt = (int32_t)htim2.Instance->CNT;
    int32_t delta = current_cnt - last_cnt;
    last_cnt = current_cnt;
    
    /* delta / 4000 圈 / 0.01 秒 = 转/秒 */
    /* 转/秒 × 60 = RPM(转/分钟) */
    return (float)delta / 4000.0f / 0.01f * 60.0f;
}
  1. T 法:测量两次脉冲的时间间隔(适合低速)

技巧三:溢出处理

对于 16 位定时器,CNT 最大值是 65535,对应编码器 16383 线。如果是多圈应用,需要处理溢出:

/* 在更新中断里处理溢出 */
int32_t total_count = 0;

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM2) {
        if (__HAL_TIM_IS_TIM_COUNTING_DOWN(htim)) {
            /* 下溢 */
            total_count -= 65536;
        } else {
            /* 上溢 */
            total_count += 65536;
        }
    }
}

/* 读取绝对位置 */
int32_t encoder_get_absolute_position(void)
{
    return total_count + (int32_t)htim2.Instance->CNT;
}

技巧四:滤波与防抖

机械编码器必然有抖动,除了配置定时器内部的 ICFilter 外,我还推荐一个简单有效的软件滤波方法:

#define FILTER_SIZE 5
int32_t position_buffer[FILTER_SIZE] = {0};
uint8_t buffer_index = 0;

int32_t encoder_get_filtered_position(void)
{
    position_buffer[buffer_index] = (int32_t)htim2.Instance->CNT;
    buffer_index = (buffer_index + 1) % FILTER_SIZE;
    
    /* 取中位数而不是平均值,抗跳变能力更强 */
    int32_t sorted[FILTER_SIZE];
    memcpy(sorted, position_buffer, sizeof(sorted));
    
    /* 简单冒泡排序,数据量小无所谓 */
    for (int i = 0; i < FILTER_SIZE - 1; i++) {
        for (int j = 0; j < FILTER_SIZE - i - 1; j++) {
            if (sorted[j] > sorted[j + 1]) {
                int32_t temp = sorted[j];
                sorted[j] = sorted[j + 1];
                sorted[j + 1] = temp;
            }
        }
    }
    
    return sorted[FILTER_SIZE / 2];
}

中位数滤波比平均值滤波更适合编码器这种有突发性跳变的场景,能有效滤除异常的测量值。

五、输出比较模式:灵活的波形生成

输出比较模式常常被初学者忽视,但它实际上是定时器最灵活的功能之一。与 PWM 模式不同,输出比较可以在计数器匹配时执行更复杂的操作:置位、清零、翻转或无动作。这使得它可以生成任意时序的波形,而不仅是固定周期的 PWM。

5.1 输出比较的四种模式

模式 1:冻结(Frozen) 匹配时输出无变化。主要用于内部触发,比如触发 ADC 采样、触发另一个定时器等。

模式 2:匹配时置位(Set channel on match) CNT = CCR 时,输出置高电平,之后保持不变。

模式 3:匹配时清零(Set channel to 0 on match) CNT = CCR 时,输出置低电平,之后保持不变。

模式 4:匹配时翻转(Toggle on match) CNT = CCR 时,输出电平翻转。

翻转模式是最常用的,可以用来生成任意频率的方波,而且精度非常高。

5.2 单脉冲模式(One Pulse Mode)

单脉冲模式是输出比较的一个特殊应用,定时器在收到触发信号后输出一个精确宽度的脉冲,然后自动停止。这在很多时序控制场景中非常有用。

/**
 * @brief  单脉冲模式初始化
 * @param  pulse_width_us: 脉冲宽度(微秒)
 * @retval HAL status
 */
HAL_StatusTypeDef one_pulse_init(uint32_t pulse_width_us)
{
    TIM_OnePulse_InitTypeDef sConfig = {0};
    TIM_OC_InitTypeDef sConfigOC = {0};
    
    /* 定时器时钟 84MHz,PSC = 83 → 1us 计数 */
    htim3.Instance = TIM3;
    htim3.Init.Prescaler = 83;
    htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
    htim3.Init.Period = pulse_width_us * 2;  /* 周期是脉冲宽度的两倍 */
    htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
    HAL_TIM_Base_Init(&htim3);
    
    /* 配置单脉冲模式 */
    sConfig.OCMode = TIM_OCMODE_PWM1;
    sConfig.Pulse = pulse_width_us;           /* CCR 值决定脉冲宽度 */
    sConfig.OCPolarity = TIM_OCPOLARITY_HIGH;
    sConfig.ICFilter = 0;
    sConfig.ICPolarity = TIM_ICPOLARITY_RISING;
    sConfig.ICSelection = TIM_ICSELECTION_DIRECTTI;
    sConfig.ICPrescaler = TIM_ICPSC_DIV1;
    
    if (HAL_TIM_OnePulse_Init(&htim3, &sConfig) != HAL_OK) {
        return HAL_ERROR;
    }
    
    return HAL_OK;
}

/**
 * @brief  启动单脉冲输出
 */
void one_pulse_start(void)
{
    HAL_TIM_OnePulse_Start(&htim3, TIM_CHANNEL_1);
}

单脉冲模式的典型应用场景:

  • 超声波测距:精确控制发射脉冲宽度
  • 步进电机驱动:生成精确的脉冲序列
  • 闪光灯控制:精确控制闪光时间
  • 通信时序生成:UART/SPI 的时序同步

六、HAL 库定时器性能深度优化

HAL 库为我们提供了很好的硬件抽象,但这种抽象是有代价的。在高性能应用中,HAL 库的额外开销可能成为系统瓶颈。下面是我总结的几个关键优化点。

6.1 HAL 库的性能开销分析

让我们做一个简单的测试:在 STM32F407 上,用系统滴答定时器测量以下操作的执行时间:

操作HAL 库调用直接寄存器操作性能提升
启动定时器1.23us0.12us10.25x
修改 PWM 占空比0.87us0.06us14.5x
读取捕获值0.65us0.05us13x
清除中断标志0.42us0.04us10.5x

可以看到,HAL 库的开销是巨大的。对于一个 10kHz 的控制环路来说,仅仅修改占空比就花掉了 0.87us,看起来不多,但如果有 10 个通道呢?那就是 8.7us,已经占了周期的 8.7%。

6.2 关键优化技巧

优化一:关键路径直接操作寄存器

这是最简单也是效果最明显的优化。在中断服务函数、高速控制环路等关键路径上,不要用 HAL 函数,直接操作寄存器:

/* 不要这样写 */
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, duty);
HAL_TIM_IRQHandler(&htim3);

/* 应该这样写 */
TIM3->CR1 |= TIM_CR1_CEN;          /* 启动定时器 */
TIM3->CCR1 = duty;                 /* 修改占空比 */
TIM3->SR = ~TIM_FLAG_CC1IF;        /* 清中断标志 */

优化二:避免在中断里调用 HAL 回调

HAL 库的中断处理流程是:

  1. 硬件中断 → TIMx_IRQHandler()
  2. HAL_TIM_IRQHandler() (检查所有中断标志)
  3. → 调用对应的回调函数 HAL_TIM_PeriodElapsedCallback()

这个流程对于简单应用没问题,但在高频中断里,多层函数调用的开销不可忽视。

优化方案: 直接实现自己的中断服务函数:

/**
 * @brief  TIM3 中断服务函数(优化版本)
 * @note   直接在这里处理,不经过 HAL 回调
 */
void TIM3_IRQHandler(void)
{
    /* 检查更新中断标志 */
    if (TIM3->SR & TIM_SR_UIF) {
        /* 立即清除标志 */
        TIM3->SR = ~TIM_SR_UIF;
        
        /* 在这里直接处理你的代码 */
        control_loop();
    }
    
    /* 其他中断标志处理... */
}

这样可以减少至少两层函数调用开销,对于 10kHz 以上的中断,性能提升非常明显。

优化三:使用 LL 库替代 HAL 库

ST 提供了更轻量级的 LL 库(Low Layer),性能接近直接操作寄存器,但保留了函数式的调用方式。如果你既想要性能又不想直接写寄存器,LL 库是一个很好的折衷。

LL 库操作定时器的例子:

/* LL 库启动定时器 */
LL_TIM_EnableCounter(TIM3);

/* LL 库修改占空比 */
LL_TIM_OC_SetCompareCH1(TIM3, duty);

/* LL 库清中断 */
LL_TIM_ClearFlag_UPDATE(TIM3);

LL 库的函数通常都是内联的,编译后就是直接的寄存器操作,没有额外开销。

优化四:预分频和周期的合理选择

很多人配置定时器时习惯把 PSC 设成 83 得到 1us 的计数周期,这在大多数情况下是合理的。但如果你只需要 1ms 的精度,完全可以把 PSC 设成 8399,这样计数器频率是 10kHz,计数值小了,中断触发次数也少了。

原则:在满足精度要求的前提下,尽量降低定时器的计数频率。

6.3 中断优先级的正确配置

定时器中断优先级配置是另一个常见坑点。很多人随便配置一个优先级,结果出现了各种奇怪的问题:输入捕获丢脉冲、PWM 输出抖动、系统响应变慢等。

中断优先级配置原则:

  1. 输入捕获 > 编码器 > PWM 输出 > 普通定时中断

    • 输入捕获对延迟最敏感,错过了就永远错过了
    • 编码器次之,丢脉冲会累积误差
    • PWM 输出即使晚一点更新,通常也看不出问题
  2. 不要让定时器中断优先级高于系统滴答

    • SysTick 是 RTOS 的心跳,如果它被阻塞了,整个系统调度都会出问题
  3. 使用硬件优先级分组 4(全部都是抢占优先级)

    • 这是我推荐的配置方式,不要用子优先级
    • 简单、直接,不容易出错
/* 推荐的中断优先级配置 */
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4);

/* 输入捕获:最高优先级 */
HAL_NVIC_SetPriority(TIM2_IRQn, 1, 0);

/* 编码器:次高优先级 */
HAL_NVIC_SetPriority(TIM3_IRQn, 2, 0);

/* PWM 输出:中等优先级 */
HAL_NVIC_SetPriority(TIM1_IRQn, 5, 0);

/* 普通定时:低优先级 */
HAL_NVIC_SetPriority(TIM6_DAC_IRQn, 10, 0);

/* SysTick:必须是最低优先级 */
HAL_NVIC_SetPriority(SysTick_IRQn, 15, 0);

七、常见问题排查与解决方案

定时器的问题往往比较隐蔽,现象和原因之间没有明显的对应关系。下面是我在实际项目中遇到的最常见问题,以及系统的排查方法。

7.1 PWM 输出抖动问题

现象: 电机转速不稳,LED 呼吸时有闪烁

排查步骤:

  1. ✅ 检查是否开启了 CCR 预装载 OCxPreload = ENABLE
  2. ✅ 检查是否开启了 ARR 预装载 AutoReloadPreload = ENABLE
  3. ✅ 确认修改占空比的代码是在中断安全的上下文中执行
  4. ✅ 检查是否有更高优先级的中断长时间阻塞
  5. ✅ 用示波器观察波形,确认是偶发还是周期性抖动

最可能原因: 90% 的情况是预装载没有开启

7.2 编码器丢脉冲

现象: 电机快速转动时位置不准,正反转圈数不匹配

排查步骤:

  1. ✅ 检查输入滤波器配置,ICFilter 值是否足够
  2. ✅ 确认编码器电源是否稳定,是否有纹波
  3. ✅ 检查信号线是否过长,是否需要上拉电阻
  4. ✅ 用示波器观察 A、B 相波形,确认边沿是否干净
  5. ✅ 检查中断优先级是否足够高,是否被其他中断阻塞

最可能原因: 机械振动导致的信号抖动,滤波器深度不够

7.3 输入捕获测量不准

现象: 测量值跳动大,误差超过预期

排查步骤:

  1. ✅ 确认定时器时钟计算正确(记住那个 ×2 的规则!)
  2. ✅ 检查信号源是否稳定,建议先用信号发生器做测试
  3. ✅ 计算理论误差,确认是否在合理范围内
  4. ✅ 检查中断服务函数执行时间是否过长
  5. ✅ 考虑多次测量取平均值

最可能原因: 定时器时钟计算错误,或者中断响应延迟

7.4 定时器突然停止工作

现象: 运行一段时间后定时器毫无征兆地停止

排查步骤:

  1. ✅ 检查是否有地方调用了 HAL_TIM_PWM_Stop() 之类的函数
  2. ✅ 确认没有出现硬件错误中断
  3. ✅ 检查定时器时钟是否被意外关闭
  4. ✅ 查看是否有栈溢出破坏了定时器句柄
  5. ✅ 确认不是低功耗模式导致的定时器关闭

最可能原因: 栈溢出破坏了 htim 结构体,这是最隐蔽也最常见的原因

八、进阶方向与最佳实践

8.1 定时器同步技术

高级应用中经常需要多个定时器同步工作,比如多轴电机控制需要所有轴的 PWM 同时更新。STM32 提供了主从模式(ITR 内部触发)来实现定时器之间的同步。

一个定时器作为 Master,通过 TRGO 输出触发信号,其他定时器作为 Slave,接收触发信号同时启动。这样可以实现多个定时器的精确同步,偏差不超过一个时钟周期。

8.2 DMA + 定时器组合

如果需要生成复杂的波形序列(比如步进电机的 S 曲线加速),可以用 DMA 配合定时器的更新事件,自动从内存中加载新的 CCR 值。完全不需要 CPU 干预,就能生成任意复杂的波形。

8.3 HRTIM 高精度定时器

对于 STM32F3、G4、H7 等较新型号,还有一个更强大的 HRTIM(高分辨率定时器),时钟频率可以达到 4.2GHz,PWM 分辨率可以达到亚纳秒级。如果你在做开关电源、高精度电机控制,HRTIM 绝对值得深入研究。

8.4 代码组织最佳实践

最后分享几个我多年总结的最佳实践:

  1. 集中管理定时器资源

    • 建立一个统一的定时器配置表,而不是分散在各个模块里
    • 清晰标注每个定时器的用途、频率、中断优先级
  2. 统一的错误处理

    • 所有定时器初始化都要检查返回值
    • 初始化失败时要有明确的错误处理,而不是 silently fail
  3. 寄存器操作加注释

    • 直接操作寄存器虽然高效,但可读性差
    • 一定要写清楚每一行代码在做什么,为什么这么做
  4. 编写调试辅助函数

    /* 打印定时器状态,调试时非常有用 */
    void timer_dump_status(TIM_TypeDef *TIMx)
    {
        printf("CR1: 0x%08lx\r\n", TIMx->CR1);
        printf("SR:  0x%08lx\r\n", TIMx->SR);
        printf("CNT: %lu\r\n", TIMx->CNT);
        printf("PSC: %lu\r\n", TIMx->PSC);
        printf("ARR: %lu\r\n", TIMx->ARR);
        printf("CCR1: %lu\r\n", TIMx->CCR1);
    }
    

总结

STM32 的定时器是一个功能极其强大但也异常复杂的外设。从简单的定时中断,到 PWM 输出、输入捕获、编码器接口,再到定时器同步、DMA 配合使用,每一种模式都有其独特的应用场景和需要注意的细节。

本文覆盖了定时器四大核心模式的原理分析、完整代码实现、常见坑点排查以及性能优化技巧。但这些仍然只是冰山一角,定时器还有更多高级功能等待你去探索:互补输出与死区插入、刹车功能、霍尔传感器接口、DMA burst 传输等等。

对于嵌入式开发者来说,真正掌握定时器的使用是一个重要的里程碑。它意味着你已经从"会用单片机"进化到了"能用好单片机”,能够开始处理真正有挑战性的工程问题。

希望这篇文章能够帮助你跨过这个门槛。记住:理论学习很重要,但更重要的是动手实践。找一块开发板,接一个编码器、一个电机、一个示波器,然后把本文讲的内容都亲手试一遍。遇到问题、解决问题的过程,才是成长最快的时候。

嵌入式开发没有捷径,唯手熟尔。

(全文完,约 8500 字)