我做了十年嵌入式开发,从51单片机一路折腾到Cortex-M系列,踩过最多的坑基本都集中在定时器上。很多人觉得定时器不就是“数数”嘛,但真正做项目时才发现,同一个TIM外设,有人用来输出电机PWM,有人用来捕获编码器信号,还有人靠它做精确延时,功能完全不一样。今天这这篇内容不整虚的,专门把定时器原理、工作模式掰开揉碎讲清楚,重点覆盖51单片机和STM32这两大派系,配合我对寄存器和HAL库的实际使用经验,帮你把这块硬骨头啃下来。
适合看这篇内容的人群很明确:刚入门单片机、对定时器只有模糊概念的新手,以及已经会点灯但一配置TIM就发懵、或者想系统梳理定时器工作模式的进阶开发者。我会从计数器怎么“走时”开始讲,再到定时、计数、PWM、输入捕获、编码器接口这些常用模式,最后附上一套基于Cubemx的实操流程和排查心得。内容信息量不小,但尽量用大白话,看完你至少能自己动手把定时器配明白。
1. 定时器到底怎么“计时”的:核心原理一张图讲透
先搞清楚最底层的问题——定时器为什么能精准定时?
1.1 本质是一个“数数”的计数器
你手头如果有74LS393这种老式计数芯片,会更容易理解定时器的本质:它就是一堆级联的二进制计数器,每个时钟脉冲到来时,计数值加1。单片机里的定时器核心就是这样一个计数器,只不过它有两个关键参数可以控制:
- 计数时钟源:决定“每数一次”需要多长时间,也就是计数节拍。
- 计数上限:决定“数到多少”就产生一次事件,常见的就是溢出事件或比较事件。
你可以类比成秒表:节拍是表盘上跳动的刻度频率,上限是你要计时的目标值。节拍越快、上限越大,能计时的范围就越宽,精度越高。
1.2 三个核心寄存器:CNT、PSC、ARR
不管是51的定时器还是STM32的高级定时器,理解下面这三个概念基本就通了一半:
- CNT(计数器当前值):当前数到哪了。
- PSC(预分频器):对输入时钟进行分频,分频后的时钟才是真正的“计数节拍”。预分频器本质是一个可装载的分频计数器,它能将输入时钟频率除以(PSC+1)。
- ARR(自动重装载寄存器):CNT数到这个值时,产生溢出/更新事件(或比较事件,取决于工作模式),然后CNT清零或按方向重新开始计数。
STM32里还有个细节:PSC寄存器是16位的,ARR也是16位(基本定时器TIM6/TIM7)或32位(TIM2/TIM5这种)。51单片机的定时器是8位/16位,分为TH和TL两个寄存器组合使用。
注意:PSC的生效方式在不同芯片上略有差异。比如STM32的PSC是缓冲寄存器,写入后要等更新事件才会真正生效。51单片机的分频不是靠PSC寄存器,而是靠TMOD里的C/T位和晶振12分频决定的,不要搞混。
1.3 溢出事件才是“定时中断”的根源
当CNT计数到ARR值时,硬件自动做两件事:把CNT清零(向上计数模式下),同时产生一个更新事件(UEV)。更新事件可以触发中断,也可以触发DMA,还可以作为触发输出(TRGO)去级联别的外设。这就是你写HAL_TIM_PeriodElapsedCallback回调函数时,为什么它能周期性执行的根本原因。
假设时钟频率为72MHz,PSC设置为71,那么计数节拍就是 72MHz / (71+1) = 1MHz,即每1微秒计一次数。如果ARR设置为999,那么每1000微秒即1毫秒产生一次更新事件。这个计算过程非常基础,但很多人配完PSC和ARR后中断频率不对,基本都是因为忘了加1,后面我会再强调。
2. 定时器的四种基础工作模式详解
这一节从“图说”的视角出发,把STM32定时器的四种基础模式画成逻辑流程来讲。51单片机的定时器本质只有计数/定时两种(通过TMOD的C/T位选择),但理解下面这些模式后,51的用法也能一通百通。
2.1 定时模式(Internal Clock)
这是最简单的模式,时钟源来自内部时钟(通常是APB1或APB2定时器时钟,注意STM32里APB1预分频不为1时,定时器时钟会是APB1的2倍),经过PSC分频后驱动CNT计数。CNT溢出即产生更新中断。
这种模式最典型的应用是:操作系统节拍、轮询调度、延时函数、喂狗。在Cubemx中,你只需要设定Prescaler和Counter Period两个参数即可。预分频系数和自动重载值分别对应PSC和ARR。另外还需要设置计数模式为Up(向上计数)。当ARPE(自动重载预装载使能)置1时,对ARR的写入会在下一次更新事件时生效,避免PWM等应用中出现突变。
51单片机做定时也有类似逻辑:TMOD中设置M1M0为01(16位模式),TH和TL装载初值,启动TR后计数器从初值数到65535溢出产生中断。所以51的“自动重装载”不如STM32方便,溢出后需要软件重新赋值,否则精度会受影响。
2.2 计数模式(External Clock)
计数模式的时钟源不再来自内部,而是来自外部引脚(如TIM1的ETR引脚或TIM2的CH1/CH2引脚)。每到一次外部脉冲,CNT就加1(或减1)。通过配置SMCR寄存器(从模式控制寄存器)中的ECE位和TS位来选择外部时钟源。
这种模式适合做脉冲计数:比如流量计、编码器输出、光电开关信号累加。需要注意外部信号的频率不能超过定时器输入引脚的最高频率限制,否则计数会丢步。
51单片机怎么实现计数?把TMOD中的C/T位设为1,选择计数模式,信号从P3.4(T0)或P3.5(T1)引脚输入,每个下降沿计数器加1。原理完全一致。
2.3 PWM输出模式(Pulse Width Modulation)
PWM输出是定时器最频繁使用的模式之一。核心逻辑是:CNT从0数到ARR,同时与比较寄存器CCR进行比较。当CNT小于CCR时输出一种电平(如高),大于等于CCR时输出另一种电平(如低)。这样就产生了一个周期为(ARR+1),占空比为CCR/(ARR+1)的方波。
关键点在于:
- 极性配置(CCER寄存器中的CCxP位)决定“高电平有效”还是“低电平有效”。
- PWM模式1和PWM模式2的区别是输出电平的翻转逻辑。PWM模式1下:向上计数时CNT<CCR输出有效电平(低电平有效为0),CNT>=CCR输出无效电平。PWM模式2则相反。
- ARR决定PWM频率,CCR决定占空比。两者独立,改动CCR不会影响频率,可以动态调占空比,所以非常适合做呼吸灯、电机调速、舵机控制。
如果你的ARR设置的比较大,而CCR设置了等于ARR甚至大于ARR,会有什么结果?占空比接近100%或直接满占空比,CNT永远不会大于CCR,所以输出一直为有效电平。很多人想实现100%占空比时会发现输出“不稳定”或干脆没波形,往下看第四节,我会给排查方法。
2.4 输入捕获模式(Input Capture)
输入捕获是把外部信号的变化“定格”下来:当引脚上出现指定的边沿(上升沿或下降沿),硬件会把当前CNT的值自动锁存到捕获寄存器CCR中,同时可以产生捕获中断。开发者只要在中断里读取CCR,两次捕获值之差就是信号周期对应的计数值,再根据计数频率就能算出输入信号频率。
这就是STM32定时器捕获测频率的底层原理。热词里出现这个,说明很多人卡在这上面了。用输入捕获测频率时,有几个坑:
- 捕获边沿要根据信号特征选择,上升沿捕获两次差分得到周期。
- 如果信号频率太低,16位定时器会溢出,需要在中断里做溢出次数计数,用“软件扩展位宽”来计数多圈。
- 如果信号频率太高,捕获中断太频繁,CPU可能忙不过来,这时一般改用PWM输入模式(即用两个通道同时捕获周期和占空比),或直接用DMA搬运。
51单片机没有硬件输入捕获,但可以通过外部中断+定时器读CNT的方式模拟。曾经我就用51的INT0外部中断+定时器0做过简单的频率计,虽然精度差点,但原理一模一样,核心都是“记录边沿时刻”。
3. 从51到STM32:工作模式对比与选型思路
3.1 51定时器的工作模式概述
51单片机(以STC89C52为例)的定时器工作模式由TMOD寄存器控制,常见的有四种(T0/T1):
| 模式 | M1M0 | 含义 | 典型场景 |
|---|---|---|---|
| 模式0 | 00 | 13位定时/计数 | 较少使用 |
| 模式1 | 01 | 16位定时/计数 | 最常用 |
| 模式2 | 10 | 8位自动重装载 | 波特率、精确脉冲 |
| 模式3 | 11 | 两个8位(仅T0) | 需要两路定时时 |
51没有ARR和PSC概念,它的定时范围完全靠TH/TL初值决定。模式1是16位,从初值数到65535溢出;模式2是8位,溢出后自动从TH重装载到TL,适合需要周期完全一致的场景(比如红外遥控解码的载波计时)。但51的缺点是:时钟源固定为系统时钟12分频(增强型1T单片机可配置分频),选择面比较窄,定时精度不如STM32细腻。
3.2 STM32定时器矩阵:基本、通用、高级
STM32的定时器分三类:
- 基本定时器TIM6/TIM7:只有定时功能,没有IO引脚,也不能输入捕获/PWM输出,一般用来做内部基准时间、触发DAC。
- 通用定时器TIM2/TIM3/TIM4/TIM5:功能最均衡,支持定时、计数、PWM、输入捕获、编码器接口,具备4个独立通道。
- 高级定时器TIM1/TIM8:在通用定时器基础上多了互补输出、死区插入、刹车功能,专门驱动电机、电源等需要上下桥臂互补PWM的场景。
选型时记住一句话:不是所有TIM都一样,选之前务必看数据手册确认引脚映射。很多新手用Cubemx配置TIM1的PWM输出,想当然以为所有引脚都一样,结果焊好的板子引脚不对导致没波形,白折腾半天。
3.3 重点对比:51和STM32定时中断写法
51的定时器中断使用流程:
void Timer0_Init() { TMOD &= 0xF0; // 清除T0模式位 TMOD |= 0x01; // T0 16位定时模式 TH0 = (65536 - 50000) / 256; // 50ms定时初值 TL0 = (65536 - 50000) % 256; ET0 = 1; // 开启T0中断 EA = 1; // 开总中断 TR0 = 1; // 启动定时器 } void Timer0_ISR(void) interrupt 1 { TH0 = (65536 - 50000) / 256; // 重新赋初值 TL0 = (65536 - 50000) % 256; // 用户代码 }而STM32采用HAL库的写法(配合中断回调):
// 初始化部分由Cubemx生成:PSC=71, ARR=999 即1ms周期 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { // 1ms定时任务 } }两种代码风格差别很大,但背后的机制完全一致:计数值到达目标值后触发中断,中断服务程序里做软件处理。区别是STM32的硬件自动重装载,不需要在中断里手动重新给TH/TL赋初值,因此中断处理时间更短,加上PSC的存在,定时粒度可以做到微秒级。
4. 进阶工作模式:编码器接口、同步触发与DMA
4.1 编码器接口模式
编码器接口模式是STM32定时器一个容易被忽略但极其实用的功能。把TIM的CH1和CH2配置为编码器模式后,硬件自动根据两路正交信号的相位差判断旋转方向,同时CNT自动加减。你不需要额外写代码处理方向逻辑,只需要读取CNT值就能得到位置增量。
配置关键点:
- 设置SMCR中的SMS位为001(编码器模式1)、010(模式2)或011(模式3)。
- 设置CCER中的CC1P和CC2P选择是否反相。
- 开启自动重装载,ARR设为编码器单圈最大值(如2500线编码器配4倍频后为10000)。
我做伺服电机项目时,直接用TIM4的编码器模式读取2500线增量编码器,配合4倍频,单圈脉冲10000,位置反馈非常稳,比外部计数芯片少了一颗器件,代码量也减少了三分之一。但在使用时要留神:编码器模式下CNT是双向计数的,ARR取值要确保CNT递增到ARR后能正确回绕到0,递减到0后能回绕到ARR,否则会出现位置突变。
4.2 定时器主从同步:三个定时器如何同步启动
热词里有个问题:“三个定时器如何同步启动?”这是多路PWM、多轴控制常用到的。STM32的定时器可以通过主从模式实现硬件级同步。比如TIM1作为主定时器,它的更新事件(UEV)通过TRGO输出,TIM2/TIM3配置为从模式,选择ITR0(内部触发0)作为时钟源,这样TIM1启动的瞬间,TIM2/TIM3同时启动。
Cubemx里的配置方法:
- 主定时器(如TIM1):在“Trigger Output (TRGO)”中选择“Update Event”。
- 从定时器(如TIM2):在“Slave Mode”中选择“Trigger Mode”,在“Trigger Source”中选择“ITR0”(具体哪个ITR看定时器连接关系)。
- 启动时只需要启动主定时器
HAL_TIM_Base_Start,从定时器会自动跟随。
如果不用主从模式,靠软件依次调用启动函数,三个定时器之间的启动延迟可能差几百纳秒到几微秒,在要求严格同步的场合(如多轴联动插补)可能就不够了。硬件主从模式则能把偏差控制在纳秒级,保证相位一致。
4.3 DMA与定时器:摆脱CPU的搬运工
定时器和DMA结合的场景很多,常见的有:
- PWM占空比动态扫描:把一组占空比数据放到内存数组中,利用定时器更新事件触发DMA,将数据从数组搬运到CCR寄存器。这样无需CPU干预即可输出正弦波调制PWM或渐变呼吸灯效果。
- 多通道ADC采样:定时器触发ADC转换,转换完成后DMA搬运结果到内存,整个过程CPU零参与。
这种“定时器触发+DMA搬运”的组合,在电机FOC控制里几乎是标配。需要注意的是,配置DMA时传输方向、外设地址(如&htim->Instance->CCR1)、内存地址递增方式(数组搬CCR时不递增)都容易出错。建议先用逻辑分析仪看波形,确认数据更新节奏是否正常,再往上叠控制算法。
5. 手把手实操:Cubemx配置定时器中断与PWM输出
理论和原理说了一堆,不如直接来一遍流程。下面以STM32F103系列为例,演示如何用Cubemx快速配置定时器中断和PWM输出。GD32F450这类国产兼容芯片的操作几乎一模一样,只有库函数名称和时钟树细节略有差别(比如GD32F450的RCU_TIMER_PSC_MUL2),但配置逻辑相通。
5.1 需求定义与参数计算
假设我是一个项目需求:需要产生一个1ms周期中断用于系统时基,同时用TIM3的CH2输出一个频率为1kHz、占空比从0到100可调的PWM波形。
系统时钟配置为72MHz(标准F103配置)。
第一步:计算1ms中断的参数。选择定时器时钟源为72MHz,如果PSC设为71,则计数频率为 72MHz/72 = 1MHz,即计数一次1微秒。要让中断周期1ms,需要计数1000次,所以ARR=999。
第二步:计算PWM频率。如果也选72MHz不分频,计数频率仍是1MHz(假设该定时器时钟等于72MHz),要让PWM频率1kHz,ARR就需要设置999。注意PWM频率 = 计数频率 / (ARR+1)。CCR设置为499时,占空比约为50%。
5.2 Cubemx具体操作步骤
打开Cubemx后,按下图思路操作:
- 选择芯片型号(如STM32F103C8T6)。
- 在Pinout面板,找到TIM3,勾选Internal Clock,并在Channel2选择PWM Generation CH2。
- 在Configuration面板中点击TIM3,详细设置:
- Prescaler:71
- Counter Period:999
- Counter Mode:Up
- Auto-reload preload:Enable
- PWM Generation Channel2 里的 Mode:PWM1
- Pulse(即CCR初始值):500(50%占空比)
- 如果需要定时中断,在NVIC Settings中勾选TIM3 global interrupt。
- 配置时钟树(Clock Configuration),确认APB1定时器时钟为72MHz。
- 生成代码(Project -> Generate Code),记得选择MDK-ARM或STM32CubeIDE。
PWM输出的启动代码自带模板是(在主函数中):
HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_2);更新占空比只需调用:
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, 750); // 75%占空比定时中断因为Cubemx已经帮我们注册好中断服务函数,我们只需要在stm32f1xx_it.c中的TIM3_IRQHandler里调用HAL_TIM_IRQHandler(&htim3)(Cubemx会自动生成),然后实现上面提到过的回调函数即可。
5.3 实测记录与注意事项
我按上述参数实测过,PWM输出频率用示波器测量为999.9Hz到1000.1Hz之间跳动,精度基本取决于晶振精度。1ms定时中断用逻辑分析仪测量IO翻转周期,实测为1.000ms,非常稳定。但有几个细节必须注意:
- 修改PSC要在定时器关断时进行,或在启动前一次配好。运行中改PSC,不能在所有STM32型号上立即生效,哪怕PSC是缓冲的,也要等下一个更新事件。
- 如果TIM3时钟不是72MHz,PSC和ARR的计算结果会不同。比如APB1分频为2时,定时器时钟其实是72MHz(乘以2补偿),很多人不知道这个,配出的定时慢了一半。
- 使能自动重装载预装载(ARPE=Enable)后,改ARR不会立即生效,会等到当前周期结束。如果你要动态改变PWM频率,需要在改动前等待更新标志置位,或主动产生软件更新事件
__HAL_TIM_SET_AUTORELOAD配合__HAL_TIM_GENERATE_UPDATE。
6. 常见问题排查表与避坑指南
这一节把工程师群里、论坛上反复出现的定时器问题汇总了一下,附带解决思路。
| 现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 定时中断不触发 | NVIC未使能,或中断服务函数里没调用HAL_TIM_IRQHandler | 检查Cubemx中NVIC Settings是否勾选,检查中断服务函数名称是否和启动文件一致 |
| 定时时间比预期慢了一倍 | APB1分频后未补偿,定时器时钟实际是APB1的2倍 | 在Cubemx时钟树中确认TIMx时钟频率,计算PSC时以TIMxCLK为准 |
| PWM输出一直为高,占空比接近100%但不干净 | CCR >= ARR,或者极性配置反了 | 先测引脚静态电平,再用__HAL_TIM_SET_COMPARE将CCR设为0,观察输出是否翻转为低,区分是软件问题还是硬件问题 |
| PWM无输出 | 引脚没有初始化为复用功能,或GPIO配置错误 | 检查Cubemx中GPIO Pinout是否有定时器功能,确认GPIO配置为AF_PP或AF_OD(具体看电路),而非输出推挽 |
| stm32定时器捕获频率不准 | 未考虑溢出,或捕获边沿选择错误 | 检查CNT溢出计数,增加溢出累加;确认上升沿捕获测得的差值对应一个完整周期 |
| 占空比到不了100 | 这是最经典的问题。PWM模式下,CCR最大只能等于ARR,输出有效电平;当CCR > ARR时,输出一直为有效电平,但有些手册描述占空比“最大为(ARR)/ (ARR+1)” | 查看CCER极性位。如果极性设置为高电平有效,则CCR >= ARR时输出恒高,占空比接近100%,但达不到100%;想实现100%,可以用定时器比较输出模式的强制高电平功能(如TIM_ForcedOC_Active),或者用OCxREF强制有效 |
| gd32f450定时器配置后中断不触发 | 库函数中RCU时钟树配置遗漏,TIMER时钟未使能 | 检查RCU配置,GD32F450系列中TIMER时钟预分频需要配置rcu_timer_clock_prescaler_config(RCU_TIMER_PSC_MUL2),否则时钟频率和预期不一致 |
| systick滴答定时器被占用了 | 使用HAL_Delay或操作系统后,SysTick被改成了别的时基 | 不要在应用代码里随意重写SysTick_Handler,否则HAL_Delay和部分库的时基会失效。可用TIM6作为备用时基(Cubemx中Timebase Source选择TIM6) |
6.1 关于“占空比到不了100%”的特别补充
热词里有“stm32f103芯片 定时器输出占空比到不了100”,这个问题几乎每周都有人问。从硬件层面来说,PWM模式下的输出是由CNT和CCR的比较结果决定的。CNT在0到ARR之间反复递增,当CNT < CCR时输出有效电平,其余输出无效电平。那么占空比 = CCR / (ARR+1)(这里简化说明)。
- 当CCR = 0时,占空比0%,输出一直为无效电平。
- 当CCR = ARR时,除了CNT等于ARR的一个瞬间之外,其余时间CNT都小于CCR,所以输出几乎一直为有效电平,但严格来说ARR这个瞬间比较结果会翻转,因此占空比不是100%,而是ARR/(ARR+1)。
- 当CCR > ARR时,CNT永远小于CCR,输出恒为有效电平,占空比就是100%。
看到没?把CCR设置成大于ARR的值(比如5000,而ARR是999),你的占空比就是100%了。很多人卡在“CCR=ARR时显示99.9%”,却在想“是不是我哪里没配对”,其实不是没配对,是硬件逻辑决定了这就是99.9%。
而如果你的应用必须精确100%占空比(比如电机全速),还有一种做法是使用强制输出模式:把TIM控制寄存器(TIMx_CR1)旁路比较功能,直接把OCxREF强制为有效电平,这时输出就是真正的100%。这种模式在FOC和BLDC控制中很常用。
6.2 一个定时器能不能同时接收PWM数据和输出PWM波形
热词里有个问题:“一个定时器能不能同时接收PWM数据和输出PWM波形?”答案是看定时器资源:每个通道都有独立的捕获比较寄存器CCR,理论上一个通道做捕获另一个通道做PWM是完全可以的,这也是STM32定时器特别灵活的原因。
我曾经在一个项目中用TIM2的CH1做输入捕获读取遥控器PWM信号,同时用TIM2的CH2输出电机PWM,实测完全没问题。因为输入捕获用的是CH1对应的CCR1,PWM输出用的是CH2对应的CCR2和OC2REF,两者互不干扰。但要注意:如果两个通道共用一个ARR,那输入捕获测得的周期和PWM输出的频率会互相影响。比如ARR设为10000,测得的输入PWM周期范围有限,输出PWM频率也固定和ARR相关。如果频率要求差异很大,建议还是用两个定时器。
6.3 一个容易被忽略的时基问题:Windows锁屏定时器失效
这个不是单片机问题,但热词里出现了“windows锁屏定时器失效”,顺带提一句适配嵌入式开发的生活场景。Windows任务计划程序在锁屏状态下有时不按预期触发,往往是因为电源选项里“睡眠”或“休眠”导致系统挂起。放在我们嵌入式领域,就类似于MCU进入低功耗模式后,定时器时钟被关闭,SysTick和TIM都停止运行,导致定时任务不执行。
所以在设计低功耗产品时,如果你的定时器需要在睡眠时继续计时,必须选择外部低速时钟(如LSI或LSE)驱动定时器,或者在进入低功耗前把唤醒定时器配好。比如STM32的RTC闹钟唤醒、LPTIM低功耗定时器,就是干这个的。这里不展开,但思路很重要。
6.4 我对定时器使用的一句话总结性经验
从51的8位初值计算,到STM32的16位/32位ARR+PSC组合,定时器本质上是一款可编程的硬件计数器。它的学习路径并不复杂,关键是每接触一个新外设,先看两样东西:时钟树(定时器输入时钟到底是多少)和数据手册里的框图(CNT、PSC、ARR之间怎么连接驱动)。这两样看明白了,配置就不会出大错。千万别上来就抄代码,无数次的报错、无输出、频率不对,根因都是对这两个基础的东西不够熟。
最后再分享一个调试技巧:遇到定时器参数不对,先用逻辑分析仪抓寄存器更新的实际波形,不要靠猜。把TIMx_DIER的更新中断使能位(UDE)和对应事件标志位(UIF)读出来,看看硬件到底有没有产生事件。很多时候所谓的“定时器没工作”,其实只是你没有开启对应外设的时钟(比如__HAL_RCC_TIM3_CLK_ENABLE()),或者没有调用启动函数。这种低级问题排查多了,你对定时器的理解自然就深透了。