嵌入式开发里,定时器大概是那种“入门时觉得简单、用久了才发现深不见底”的外设。很多人第一次点灯、第一次延时用的是它,后来做电机控制、做传感器测速、做舵机驱动,绕一圈发现还是它。STM32 的定时器尤其典型:同一个硬件模块,既能当计数器用,又能输出 PWM,还能反过来测外部信号的频率和占空比,甚至直接对接增量式编码器做位置解算。标题里提到的三块内容——PWM 输出、输入捕获、编码器模式——基本覆盖了定时器在实际项目里 80% 以上的用法,也是面试和实战里被问得最多的部分。
这篇东西我打算按自己平时做项目的思路来写:先把定时器这套东西的整体设计逻辑讲清楚,再逐个拆 PWM、输入捕获、编码器模式三个方向,每个方向都给到参数计算过程、配置要点和踩过的坑。适合已经能跑通点灯和串口、想真正把定时器用起来的同学,也适合做过一些但总觉得“配置靠抄、参数靠试”的朋友。文中涉及的具体芯片型号我会用通用说法,寄存器名和 HAL 库接口以常见系列为准,你套到自己手上的板子时按手册对一遍就行。
1. 定时器这套东西到底该怎么理解
1.1 先搞清楚你手上的是哪一类定时器
STM32 的定时器不是一种东西,而是一个家族。以常见的中高端系列为例,通常分基本定时器、通用定时器、高级定时器三类,外加独立的看门狗和系统滴答。很多人配置出错,第一步就错在没分清自己用的是哪一类。
基本定时器(比如 TIM6、TIM7)功能最素,只有向上计数和更新中断,主要拿来当时间基准或者给 DAC 触发,它没有外部通道,做不了 PWM 也做不了捕获。通用定时器(TIM2 到 TIM5 这类)是主力,带 4 个独立通道,PWM、输入捕获、编码器接口都支持,绝大多数项目用的就是它。高级定时器(TIM1、TIM8)在通用定时器基础上多了互补输出、死区插入、刹车输入这些,专门给电机三相桥、大功率驱动准备的。
我见过不少人拿着 TIM6 想输出 PWM,调了半天寄存器发现根本没通道,这就是没先确认定时器类型。所以动手前第一件事:翻参考手册的定时器章节开头那张功能对比表,确认你要用的定时器有没有对应的通道和模式。这个动作花两分钟,能省掉后面两小时的困惑。
1.2 时基单元:PWM 频率和分辨率都从这来
定时器的核心是时基单元,说白了就是一个不断累加(或递减)的计数器,配合预分频器和自动重装载寄存器决定“数多快”和“数到多少归零”。三个关键参数:
- 预分频器 PSC:把定时器时钟先分频,实际计数频率 = 定时器时钟 / (PSC + 1)。
- 自动重装载寄存器 ARR:计数器数到这个值就归零(向上计数模式),一个完整周期的计数值就是 ARR + 1。
- 计数模式:向上、向下、中央对齐。PWM 常用向上或中央对齐,编码器模式有专门的计数逻辑。
这里有个特别容易被忽略的点:PSC 和 ARR 都是“减一”生效的。你想要 100 分频,PSC 要写 99;你想要周期 1000 个计数,ARR 要写 999。新手照着公式算完直接填进去,频率总是差一点点,八成就是这个原因。
举个实际算例。假设定时器挂在 72MHz 的 APB1 上(注意 APB1 预分频不为 1 时定时器时钟会倍频,这个后面细说),我想要 20kHz 的 PWM,分辨率希望尽量高。先定 PSC:如果 PSC = 0,计数频率 72MHz,20kHz 周期对应 3600 个计数,ARR = 3599,分辨率约 11.8 位,够用。如果我想让分辨率更高,可以把 PSC 设小、ARR 设大,但 ARR 最大 16 位是 65535,所以 72MHz 下 20kHz 最多也就 3600 个计数,分辨率上不去。想要更高分辨率就得降频或者换 32 位定时器。这个权衡在做高精度调光、音频 PWM 时特别关键。
提示:APB1 上的定时器时钟有个“潜规则”——当 APB1 预分频系数不为 1 时,定时器时钟是 APB1 时钟的 2 倍。比如系统 72MHz、APB1 分频为 2 得到 36MHz,但定时器实际跑 72MHz。算频率前一定先确认这个,不然算出来差一倍。
1.3 通道、捕获比较寄存器与影子寄存器
每个定时器通道背后都有一个捕获比较寄存器 CCR,PWM 输出时它决定占空比,输入捕获时它锁存捕获到的计数值。CCR 和 ARR 一样,往往带影子寄存器(预装载寄存器),写入的值不是立刻生效,而是在下一个更新事件时才搬过去。这个机制是为了防止 PWM 在周期中间被改占空比导致波形毛刺。
实际配置里,HAL 库的__HAL_TIM_ENABLE_OCxPRELOAD就是控制这个预装载使能的。做 LED 调光、电机调速这种需要动态改占空比的场景,建议开启预装载,波形干净;如果只是固定输出,开不开影响不大。但做输入捕获时要注意,CCR 是被硬件写入的,别手动去写它。
2. PWM 输出:从参数计算到动态调占空比
2.1 PWM 模式一和模式二到底差在哪
STM32 的 PWM 输出分模式一和模式二,区别就一句话:模式一在计数值小于 CCR 时输出有效电平,模式二反过来。所谓“有效电平”又是另一个可配的极性(高有效或低有效)。这两个维度一组合,就有四种波形形态。
很多人被这个绕晕,其实抓住本质:模式决定“什么时候翻转”,极性决定“翻转成什么电平”。做呼吸灯、驱动 MOS 管这类应用,我一般用模式一加高有效,逻辑最直观——CCR 越大,高电平时间越长,占空比越大。驱动共阳 LED 或者低电平有效的使能信号时,改成低有效就行,不用去动模式。
配置代码大致是这样(以通用定时器通道 1 为例,HAL 库):
TIM_HandleTypeDef htim2; TIM_OC_InitTypeDef sConfigOC = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; // 72MHz / 72 = 1MHz 计数频率 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 999; // 1MHz / 1000 = 1kHz PWM htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(&htim2); sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 500; // 初始占空比 50% sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);这段配置算下来:计数频率 1MHz,周期 1000 个计数,PWM 频率 1kHz,初始占空比 500/1000 = 50%。参数之间是环环相扣的,改一个要回头验算另外两个。
2.2 动态调占空比的正确姿势
运行中改占空比,标准做法是改 CCR:
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, new_pulse);这里有个细节值得说。如果你开了 CCR 预装载,新值会在下一个更新事件生效,波形不会在周期中间突变;如果没开,写入立即生效,可能出现半个周期的畸形波形。做电机调速时我强烈建议开预装载,否则每次改占空比都可能给驱动桥一个窄脉冲,长期下来对功率管不友好。
还有一个坑:改 CCR 时不要超过 ARR。CCR 大于 ARR 时,模式一下输出会一直保持有效电平(因为计数值永远小于 CCR),表现为“满占空比”,看起来像卡死了。做渐变算法时如果步进没做边界保护,很容易冲过头。我一般会在设置前加一句钳位:
if (new_pulse > __HAL_TIM_GET_AUTORELOAD(&htim2)) { new_pulse = __HAL_TIM_GET_AUTORELOAD(&htim2); }2.3 呼吸灯实战:用更新中断做渐变
呼吸灯是最经典的 PWM 练手项目,但很多人写的呼吸灯要么线性变化看着生硬,要么用HAL_Delay阻塞导致整个系统卡住。我的做法是用更新中断驱动,每次更新事件改一次 CCR,主循环完全不受影响。
思路是这样:PWM 频率设 1kHz,更新中断也是 1kHz。在中断里维护一个方向和步进,每次给 CCR 加减一个步长,到边界就反向。为了让亮度变化符合人眼感知,步长可以按正弦或者查表来给,而不是线性。人眼对亮度的感知近似对数关系,线性改占空比会觉得“暗的时候变化快、亮的时候变化慢”,用 Gamma 校正表能明显改善观感。
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { static int16_t duty = 0; static int8_t dir = 1; if (htim->Instance == TIM2) { duty += dir * 8; if (duty >= 1000) { duty = 1000; dir = -1; } if (duty <= 0) { duty = 0; dir = 1; } __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, duty); } }注意:中断里只做轻量操作,别在里面调用带锁或阻塞的函数。改 CCR 是寄存器级操作,很快,适合放中断。但如果你在中断里还要做串口打印调试,那就得另想办法,否则中断耗时过长会影响其他任务。
2.4 互补输出与死区:高级定时器的专属能力
做半桥、全桥驱动时,上下两个管子不能同时导通,否则直通烧管。高级定时器(TIM1、TIM8)的互补输出加死区插入就是干这个的:一个通道同时输出 CHx 和 CHxN 两路互补信号,中间强制插入一段两者都为低(或按配置)的死区时间。
死区时间由 BDTR 寄存器里的 DTG 位段配置,具体算法跟定时器时钟和 DTG 编码有关,参考手册里有张表。举个常见配置:定时器时钟 72MHz,想要约 1 微秒死区,DTG 值需要按手册公式算,大致落在某个区间。这里不展开公式细节,因为不同系列编码方式略有差异,但思路是:先确定定时器时钟周期,再用目标死区时间除以时钟周期得到需要的计数值,最后对照手册的 DTG 编码表选最接近的档位。
我踩过的坑是:死区设太小等于没设,设太大又影响效率、增加谐波。实际调试时用示波器看两路波形的交越点,确认死区真实存在且不过宽,比纯算更靠谱。另外,互补输出默认是关闭的,要记得使能主输出(__HAL_TIM_MOE_ENABLE或 HAL 里对应配置),否则 CHxN 一直是无效电平,很多人卡在这以为配置没生效。
3. 输入捕获:把外部信号的频率和占空比测出来
3.1 输入捕获的基本原理
输入捕获干的事很直接:外部信号在通道引脚上发生边沿跳变时,硬件把当前计数器的值锁存进 CCR,同时可以触发中断。你拿到这个计数值,结合两次捕获的差值,就能算出信号周期,进而得到频率。
关键概念是捕获边沿:可以配上升沿、下降沿或双边沿。测频率一般固定捕上升沿,两次上升沿之间的计数差就是一个周期。测占空比则需要一次上升沿、一次下降沿,两个差值一除就是占空比。
这里有个精度问题必须提前想清楚:捕获的计数值是定时器计数频率下的值,计数频率越高,时间分辨率越高,但计数器 16 位容易溢出。比如 1MHz 计数频率下,16 位计数器最多数 65.5ms 就溢出,测 10Hz 以下的低频信号就会溢出。解决办法要么降计数频率,要么用捕获溢出计数(在更新中断里累加溢出次数),要么换 32 位定时器。做低频测量时我一般优先选 32 位定时器,省心。
3.2 测频率:两次捕获差值法
最朴素的测频思路:第一次捕获记下值 A,第二次捕获记下值 B,周期计数 = B - A(考虑溢出则加上溢出补偿),频率 = 计数频率 / 周期计数。
配置上,通道设为输入捕获模式,边沿选上升沿,开启捕获中断。中断里读 CCR:
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { static uint32_t last_cap = 0; static uint32_t overflow_cnt = 0; if (htim->Instance == TIM3 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { uint32_t cap = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); uint32_t period = (overflow_cnt << 16) + cap - last_cap; last_cap = cap; overflow_cnt = 0; // 频率 = 计数频率 / period,注意 period 为 0 的保护 } }这段代码里overflow_cnt需要在更新中断里累加,用来处理计数器溢出。实际写的时候要小心:捕获中断和更新中断可能同时挂起,读溢出计数和读 CCR 的顺序会影响结果,严谨的做法是先读 CCR 再读溢出标志,或者用中断优先级保证原子性。我见过因为顺序问题导致偶尔测出离谱值的案例,排查了半天。
3.3 测占空比:单通道双边沿还是双通道配合
测占空比有两条路。第一条是单通道配双边沿捕获:上升沿时记下起点,下降沿时记下高电平宽度,下一次上升沿记下周期,两者相除。缺点是中断频繁,且边沿切换逻辑要处理好。第二条是用两个通道,一个捕上升沿、一个捕下降沿,硬件同时锁存,软件配对计算,适合高频信号。
我一般根据信号频率选:低频(几 kHz 以下)用单通道双边沿,逻辑简单;高频用双通道,减少中断压力。双通道方案要注意两个通道的捕获值要配对,不能拿错周期的上升沿和下降沿去算,通常用通道 1 的捕获作为周期基准,通道 2 的捕获值减去最近一次通道 1 的值就是高电平时间。
3.4 输入捕获的滤波与预分频
引脚上的毛刺会直接导致误捕获,所以输入捕获通道带数字滤波器(ICF 位段),可以设置采样频率和连续采样次数。信号噪声大时把滤波开强一点,但滤波太强会吃掉窄脉冲,测高频时尤其明显。
还有个输入预分频(ICPSC),可以对捕获事件再分频,比如每 8 个边沿才捕获一次。测高频信号时用得上,能降低中断频率。但要注意,预分频后你测到的不是每个周期,而是 N 个周期,算频率时记得除以 N。
提示:调试输入捕获时,先用信号发生器给一个已知频率的方波,从低频到高频扫一遍,看测量值是否线性跟随。这一步能快速暴露溢出、滤波、预分频配置的问题,比直接上真实信号瞎猜高效得多。
4. 编码器模式:增量式编码器的硬件解码
4.1 编码器接口的工作原理
增量式编码器输出 A、B 两路正交信号,相位差 90 度。正转时 A 领先 B,反转时 B 领先 A。STM32 的编码器模式就是把这两路信号接到定时器的两个通道上,硬件根据两路的边沿组合自动加减计数,软件只管读计数值,不用写任何解码逻辑。
这个硬件解码的好处是省 CPU、抗抖动(配合滤波器)、响应快。软件解码正交信号在中高转速下很容易丢步,硬件方案基本不会。做电机测速、旋钮输入、位置反馈,只要编码器是增量式的,优先用这个模式。
计数模式有三种:只在 A 相边沿计数(2 倍频)、只在单个边沿计数、A、B 双边沿都计数(4 倍频)。4 倍频分辨率最高,是默认选择。计数值随转向自动增减,正转到某个值、反转到另一个值,读出来就是相对位置。
4.2 配置要点与计数值读取
配置上,把定时器设为编码器模式,通道 1 和通道 2 都配成输入,极性按需设置(一般上升沿),然后使能。HAL 库大致这样:
TIM_Encoder_InitTypeDef sConfig = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 0; htim3.Init.Period = 65535; // 16 位满量程 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; sConfig.EncoderMode = TIM_ENCODERMODE_TI12; // 4 倍频 sConfig.IC1Polarity = TIM_ICPOLARITY_RISING; sConfig.IC2Polarity = TIM_ICPOLARITY_RISING; sConfig.IC1Selection = TIM_ICSELECTION_DIRECTTI; sConfig.IC2Selection = TIM_ICSELECTION_DIRECTTI; sConfig.IC1Prescaler = TIM_ICPSC_DIV1; sConfig.IC2Prescaler = TIM_ICPSC_DIV1; sConfig.IC1Filter = 6; // 滤波,按噪声调 sConfig.IC2Filter = 6; HAL_TIM_Encoder_Init(&htim3, &sConfig); HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL);读计数值用__HAL_TIM_GET_COUNTER(&htim3)。这里有个必须处理的问题:计数器会溢出回绕。16 位计数器从 65535 加到 0,或者从 0 减到 65535,直接读会得到跳变的值。做位置解算时要用差值法:保存上一次读数,本次减上次,考虑回绕补偿,累加到一个 32 位或 64 位的总位置变量里。
int16_t delta = (int16_t)(now - last); // 利用有符号转换自动处理回绕 total_position += delta; last = now;把差值强转成有符号 16 位,回绕就自动处理了,这是个小技巧但很实用。前提是两次采样之间编码器转动不超过半圈(32767 个计数),正常采样频率下都满足。
4.3 测速:M 法、T 法与低速处理
编码器测速有两种经典方法。M 法是在固定时间内数编码器脉冲数,适合高速;T 法是测两个脉冲之间的时间,适合低速。低速时 M 法在固定窗口内可能只数到几个脉冲,量化误差大;高速时 T 法脉冲间隔太短,计时精度不够。
实际项目里我常用变策略:转速高用 M 法,转速低切 T 法,或者干脆用 M/T 混合法。如果只是做旋钮输入这种对速度精度要求不高的场景,固定周期读位置差再除以时间就够了,不用搞太复杂。
注意:编码器信号线长、环境噪声大时,滤波一定要开,必要时外加硬件 RC 滤波和上拉。我遇到过编码器线跟电机线捆在一起走,计数乱跳的情况,分开走线加滤波后立刻正常。这是硬件层面的坑,软件滤波只能补救,不能根治。
5. 三个模式共通的坑与调试心得
5.1 时钟配置永远是第一嫌疑
定时器行为不对,先查时钟。除了前面说的 APB1 倍频规则,还要确认定时器是否真的使能了时钟(__HAL_RCC_TIMx_CLK_ENABLE),以及引脚是否配置成了正确的复用功能。我见过引脚配成普通 GPIO 却纳闷为什么没波形的情况,也见过复用功能选错定时器通道的。这些基础项排查一遍,能过滤掉大半“玄学问题”。
5.2 中断优先级与回调归属
多个定时器同时用中断时,优先级要规划好。PWM 更新中断、捕获中断、编码器溢出中断如果优先级混乱,可能出现捕获值被延迟读取导致误差。捕获类中断一般给较高优先级,因为它对时序敏感;PWM 渐变这类可以低一些。
另外 HAL 库的回调函数是共用的,HAL_TIM_IC_CaptureCallback会被所有定时器的捕获事件调用,里面必须用htim->Instance判断来源,否则一个定时器的捕获会触发另一个的逻辑,现象非常诡异。
5.3 用示波器和逻辑分析仪验证,别靠猜
PWM 频率占空比对不对、死区有没有、捕获边沿准不准,这些用示波器一看便知。我调试定时器相关功能时,第一步永远是把波形打出来看,而不是盯着代码推理。逻辑分析仪抓编码器 A/B 相和计数值变化,能直观看到解码是否正确。工具到位,调试效率差好几倍。
5.4 常见问题速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| PWM 无输出 | 定时器/通道时钟未使能、引脚复用未配、主输出未使能(高级定时器) | 查 RCC 使能、GPIO 复用、MOE 位 |
| PWM 频率差一倍 | APB1 倍频规则没算进去 | 确认定时器实际时钟源频率 |
| 占空比改不动 | CCR 超过 ARR、预装载未触发更新 | 钳位 CCR、确认更新事件 |
| 捕获值乱跳 | 无滤波、中断优先级冲突、溢出未处理 | 开滤波、理优先级、加溢出计数 |
| 编码器计数反向 | A/B 相接线反了或极性配置反了 | 交换接线或改 IC 极性 |
| 编码器偶尔跳变 | 计数器回绕未补偿、信号干扰 | 差值法处理回绕、加硬件滤波 |
这张表是我自己调试时攒下来的,遇到问题先对一遍,多数情况能快速定位。真正难缠的往往是硬件接线和电源噪声,软件配置反而没那么容易错——前提是你每一步都按手册确认过。
6. 一点个人体会
定时器这三个模式,表面看是三套独立配置,底层其实是同一套时基加通道逻辑的不同用法:PWM 是“按比较值决定输出”,输入捕获是“按边沿锁存计数值”,编码器模式是“按两路边沿组合增减计数”。把时基单元、CCR、预装载、中断这几块吃透,三个模式就是同一棵树上的三根枝。
我自己踩得最深的坑反而不是配置本身,而是“想当然”——想当然以为时钟是系统频率、想当然以为计数器不会溢出、想当然以为中断会按顺序来。每次出问题回头查,几乎都能归到某个“想当然”上。所以现在我做定时器相关的东西,习惯先把时钟树和计数范围在纸上算一遍,再动手写代码,反而快。
后面如果要把这套东西再往前推一步,可以研究用 DMA 配合定时器做批量捕获(比如测多个通道的频率而不占 CPU),或者用高级定时器的刹车功能做安全保护。这些属于进阶玩法,等基础模式用熟了再碰会顺很多。