1. 从“嘀嗒”到“交响乐”:理解STM32定时器的核心价值
如果你刚开始接触STM32,可能会觉得定时器(Timer)不过就是个“嘀嗒嘀嗒”计数的东西,用来做个延时或者定时中断。但当你真正深入项目,比如想用PWM驱动一个电机让它平滑转动,或者想精准测量一个外部信号的频率和脉宽时,你就会发现,这个看似简单的“嘀嗒”声,其实是整个微控制器系统里最复杂、最强大、也最考验功力的“交响乐指挥家”。
我最初用定时器,也就是在main函数里写个HAL_Delay(1000),让LED灯闪一下。后来做四轴飞行器,需要同时控制四个电机的转速(PWM)、读取遥控器接收机的信号(输入捕获)、还要以固定频率运行姿态解算算法(定时中断),那一刻我才真正体会到,把定时器玩明白,是把STM32从“单片机”升级到“微控制器”的关键一步。它不再是孤立的模块,而是协调整个系统时序、实现复杂控制逻辑的核心枢纽。
STM32的定时器家族庞大,从基本定时器(TIM6, TIM7)、通用定时器(TIM2-TIM5, TIM9-TIM14)到高级定时器(TIM1, TIM8),功能逐级增强。今天,我们就围绕定时中断、PWM输出和输入捕获这三个最核心、最常用的功能,抛开枯燥的寄存器手册,从实际应用的角度,把它们彻底讲透。我会结合我踩过的无数个坑,告诉你配置时的关键细节、原理背后的“为什么”,以及如何让它们协同工作。无论你是正在做智能小车、机械臂、还是任何需要精确定时和信号处理的项目,这篇内容都能让你少走弯路。
2. 定时中断:系统节拍器的精准配置与避坑指南
定时中断是定时器最基础的功能,相当于给系统安装了一个高精度的“节拍器”。它的核心思想很简单:让定时器以一个固定的频率(比如1kHz)自动计数,每次计满(或计到某个特定值)就产生一个中断,我们在中断服务函数里执行一些需要周期性处理的任务,比如扫描按键、更新显示、运行控制算法。
2.1 定时器时钟源与分频:一切精度的起点
配置定时中断,第一步不是去写代码,而是先搞清楚你的定时器“心跳”有多快。这里最容易出错的就是时钟源和分频系数的计算。
以STM32F103系列常见的72MHz系统时钟为例。假设我们使用通用定时器TIM2,它的时钟源通常是APB1总线时钟。在标准库或HAL库的默认初始化后,如果APB1预分频系数不为1,定时器时钟会倍频。对于F103,APB1时钟最高36MHz,但定时器会得到72MHz的时钟。这一点至关重要,很多人的定时不准,根源就在这里。
假设我们需要一个1ms(1kHz)的定时中断。计算步骤如下:
- 确定定时器时钟(CK_CNT): 确认是72MHz。
- 确定计数模式: 通常使用向上计数模式。
- 设定自动重装载值(ARR): 这是我们希望计数的最大值。
- 设定预分频器值(PSC): 用于对CK_CNT进行分频,得到实际的计数频率。
计算公式为:定时周期 = (PSC + 1) * (ARR + 1) / CK_CNT
我们的目标是定时周期 = 0.001秒。CK_CNT = 72,000,000 Hz。这里有两个变量PSC和ARR,我们可以先固定其中一个来简化。通常,为了让ARR的值范围更直观(比如设定为1000就代表1ms),我们会先设定PSC。
让(PSC + 1) / CK_CNT = 1us,即PSC + 1 = CK_CNT / 1,000,000 = 72。所以PSC = 71。
此时,计数器的计数频率 = CK_CNT / (PSC+1) = 72MHz / 72 = 1MHz,即每1us计一个数。
那么,要产生1ms的中断,就需要计数1000次。所以ARR = 1000 - 1 = 999。(因为从0开始计数)
注意: 在HAL库或标准库中,我们填入的参数就是PSC和ARR的寄存器值,即71和999。
(PSC+1)和(ARR+1)是公式中的概念,对应寄存器写入的值就是PSC和ARR。这一点理解偏差会导致定时时间差一倍,务必小心。
2.2 HAL库与CubeMX配置实战
使用STM32CubeMX可以极大简化配置过程,但理解其生成的代码同样重要。
在CubeMX中: 选择TIM2,设置为“Internal Clock”。在Parameter Settings页:
- Prescaler (PSC - 16 bits value): 71
- Counter Mode: Up
- Counter Period (AutoReload Register - 16 bits value): 999
- auto-reload preload: Enable (推荐,可避免更新ARR时的毛刺)
- 在NVIC Settings中勾选“TIM2 global interrupt”使能中断。
生成的代码分析: CubeMX会在
main.c中调用HAL_TIM_Base_Init()和HAL_TIM_Base_Start_IT(&htim2)。重点在于中断服务函数和回调函数。- 中断向量
TIM2_IRQHandler在stm32f1xx_it.c中,它内部调用了HAL_TIM_IRQHandler(&htim2)。 - 我们用户需要重写的,是定时器周期更新中断的回调函数
HAL_TIM_PeriodElapsedCallback()。这个函数是一个弱定义(__weak)在HAL库中,我们需要在main.c或自己的用户文件中重新实现它。
- 中断向量
/* 用户代码实现回调函数 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) // 判断是哪个定时器产生的中断 { // 在这里执行你的1ms周期任务 // 例如:翻转一个测试引脚电平,或者执行任务调度 HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); // 假设PA5接LED } }2.3 避坑心得:中断响应性与系统负载
中断服务函数要短: 切记不要在
HAL_TIM_PeriodElapsedCallback或任何中断服务函数中执行耗时操作(如HAL_Delay、复杂的浮点运算、等待式通信)。这会导致其他中断被延迟响应,系统实时性变差。正确的做法是,在中断里只设置标志位,在主循环或由RTOS任务根据标志位去处理具体业务。多个定时器中断的优先级: 如果系统中有多个定时器中断或其他中断(如串口),需要在CubeMX的NVIC配置中合理设置它们的“Preemption Priority”和“SubPriority”。优先级高的中断可以打断优先级低的中断。对于系统关键节拍(如电机控制的PWM更新中断),应赋予较高的抢占优先级。
滴答定时器(SysTick)与通用定时器: SysTick是Cortex-M内核自带的简易定时器,通常用于操作系统的心跳节拍(如FreeRTOS的
vTaskDelay)。它使用方便,但功能单一(仅基本中断)。对于复杂的、需要PWM或捕获功能的定时任务,必须使用片上外设定时器(TIMx)。两者可以共存,互不干扰。定时精度验证: 配置好后,最直接的验证方法就是用上述代码翻转一个GPIO,然后用逻辑分析仪或示波器测量方波的周期。理论是1ms,实际测量可能是1.0001ms或0.999ms,这取决于外部晶振的精度和时钟树的配置。对于大多数应用这已足够。如果对精度要求极高(如高精度计时),需要考虑使用外部高精度有源晶振,并校准RTC时钟。
3. PWM输出:从原理到实战,驱动电机与调光的艺术
PWM(Pulse Width Modulation,脉冲宽度调制)是定时器输出比较功能最典型的应用。它通过调节一个周期固定方波的“高电平时间”(脉宽)占整个周期的比例(占空比),来等效地模拟一个连续变化的模拟量。比如,用50%占空比的3.3V PWM驱动一个LED,其平均电压就是1.65V,亮度介于全亮和全灭之间。
3.1 PWM生成的底层原理:比较与匹配
通用定时器生成PWM,通常使用其输出比较模式。核心是三个寄存器:计数器寄存器(CNT)、自动重装载寄存器(ARR)和捕获/比较寄存器(CCRx)。
- ARR: 决定了PWM的周期。计数器CNT从0开始向上计数,达到ARR值后清零,重新开始,形成一个周期。
- CCRx: 这是我们控制脉宽的关键。定时器硬件会实时比较CNT和CCRx的值。
- 工作流程: 假设我们设置PWM模式为“向上计数,CNT<CCRx时输出有效高电平”。
- CNT从0开始递增。
- 当CNT < CCRx时,对应的输出通道(如CH1)保持高电平。
- 当CNT >= CCRx时,输出翻转为低电平。
- CNT继续递增到ARR,然后清零,输出又重新变为高电平,开始下一个周期。
因此,PWM的周期由ARR和时钟频率决定,而占空比则由CCRx的值决定。占空比 = CCRx / (ARR + 1)。
3.2 使用CubeMX配置PWM的详细步骤与参数解读
我们以TIM3的通道1(CH1)输出一个1kHz,占空比可调的PWM为例。
时钟与模式选择: 在CubeMX中启用TIM3,时钟源选择“Internal Clock”。在“Combined Channels”下拉菜单中选择“PWM Generation CH1”。
参数配置(Parameter Settings):
- Prescaler (PSC): 分频系数。假设系统时钟72MHz,我们希望计数器时钟为1MHz(这样每个计数是1us)。则PSC = 72 - 1 = 71。
- Counter Mode: Up(向上计数)。
- Counter Period (ARR): PWM周期。1kHz对应周期1ms。计数器时钟1MHz,1ms需要计数1000次。所以ARR = 1000 - 1 = 999。
- Pulse (CCR1): 初始占空比对应的比较值。例如,想要初始占空比50%,则CCR1 = 500。
- CH Polarity: High。表示“有效电平”为高。即CNT<CCR1时输出高,否则输出低。你也可以选择Low,逻辑相反。
GPIO设置: CubeMX会自动将TIM3_CH1映射到的GPIO(如PA6)配置为复用推挽输出模式。你可以在Pinout视图确认。
生成代码与API调用: 生成代码后,在
main.c的用户代码区,需要启动PWM输出,并学会动态修改占空比。
/* 启动TIM3的通道1输出PWM */ HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); /* 在程序运行中动态修改占空比为75% */ __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, 750); // CCR1 = 0.75 * (999+1)__HAL_TIM_SET_COMPARE这个宏是直接操作寄存器,速度极快,适合在需要快速响应的控制循环(如PID计算后更新电机PWM)中使用。
3.3 高级话题:互补PWM与死区插入
当你需要驱动一个全桥电路(如直流无刷电机、步进电机驱动器)时,同一桥臂的上下两个开关管不能同时导通,否则会造成短路直通,烧毁器件。这就需要用到定时器的互补输出和死区插入功能,这通常是高级定时器(TIM1, TIM8)的专属。
- 互补输出: 你可以配置CH1输出主波形,同时CH1N输出一个完全反相(互补)的波形。
- 死区插入: 在互补波形切换的瞬间(一个从高变低,另一个从低变高),硬件会自动插入一段两者都为低电平的“死区时间”,确保上下管绝对没有同时导通的可能。
在CubeMX中配置高级定时器的互补PWM时,会多出一个“Dead Time”的参数,单位可以是纳秒或时钟周期,需要根据你使用的功率管(MOSFET/IGBT)的开关特性来谨慎设置。死区时间太短起不到保护作用,太长则会降低有效电压利用率,导致波形畸变。
3.4 实战避坑:电机控制中的PWM常见问题
频率选择: 驱动有刷直流电机,PWM频率通常在1kHz到20kHz之间。频率太低(如几百Hz)电机会有可闻的啸叫声;频率太高,开关损耗会增大,且可能超出驱动电路的响应能力。驱动舵机(Servo)则是固定的50Hz(周期20ms),通过脉宽(0.5ms-2.5ms)来控制角度。
占空比与电机启动: 很多直流电机有启动死区,即占空比低于某个值(如10%)时,产生的电压不足以克服静摩擦力,电机不转。此时如果持续施加小占空比,电机线圈相当于短路,容易发热烧毁。解决方案是软件设定一个最小启动占空比,或者采用开环速度斜坡启动。
PWM引脚的重映射: 有时候默认的引脚被其他功能占用,需要查看芯片数据手册的“Alternate function mapping”表格,使用重映射功能(AFIO)将定时器通道输出到其他引脚上。CubeMX的Pinout视图可以直观地完成这个操作。
测量PWM输出: 务必使用示波器验证你输出的PWM波形。确认频率、占空比、幅值(应该是3.3V或5V)是否符合预期。这是硬件调试中最直接有效的手段。
4. 输入捕获:精准测量频率与脉宽的关键技术
输入捕获是定时器另一个强大的功能,用于测量外部信号的频率或脉冲宽度。其核心原理是:当检测到指定引脚上的特定边沿(上升沿或下降沿)时,硬件会自动将当前计数器(CNT)的值锁存到捕获/比较寄存器(CCRx)中,并可以产生中断。通过记录两次捕获的CNT值之差,结合计数器的时钟周期,就能计算出时间间隔。
4.1 测量脉宽的标准方法:双沿捕获
要测量一个高电平脉冲的宽度,最常用的方法是上升沿触发捕获一次,下降沿触发再捕获一次。
初始化配置:
- 将定时器通道配置为输入捕获模式,通常先设置为上升沿捕获。
- 开启捕获中断。
- 设置一个足够大的ARR值(如0xFFFF),让计数器在测量期间不会溢出(或处理好溢出情况)。
- 启动定时器。
中断服务程序逻辑:
- 第一次中断(上升沿): 记录下CCRx的值,存入变量
Capture_Val1。然后立刻将捕获边沿改为下降沿(通过修改CCER寄存器的CCxP位或调用HAL库函数)。 - 第二次中断(下降沿): 记录下CCRx的值,存入变量
Capture_Val2。然后立刻将捕获边沿改回上升沿,为下一次测量做准备。 - 计算脉宽: 高电平时间
PulseWidth = (Capture_Val2 - Capture_Val1) * Timer_CLK_Period。需要考虑计数器溢出的情况,如果Capture_Val2 < Capture_Val1,则PulseWidth = (ARR + 1 + Capture_Val2 - Capture_Val1) * Timer_CLK_Period。
- 第一次中断(上升沿): 记录下CCRx的值,存入变量
4.2 使用HAL库实现输入捕获的代码框架
HAL库提供了输入捕获的API,但流程需要我们自己组织。以下是一个测量高电平脉宽的简化框架:
uint32_t uwIC2Value1 = 0, uwIC2Value2 = 0; uint32_t uwDiffCapture = 0; uint8_t IsFirstCaptured = 0; // 是否是第一次捕获标志 // 启动输入捕获中断 HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1); // 输入捕获中断回调函数 void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) // 判断通道 { if(IsFirstCaptured == 0) // 第一次捕获,是上升沿 { uwIC2Value1 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); // 读取CCR1 IsFirstCaptured = 1; // 设置标志 // 改变触发边沿为下降沿 __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); } else // 第二次捕获,是下降沿 { uwIC2Value2 = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); // 计算差值,注意处理溢出 if (uwIC2Value2 > uwIC2Value1) { uwDiffCapture = uwIC2Value2 - uwIC2Value1; } else { uwDiffCapture = (0xFFFF - uwIC2Value1) + uwIC2Value2 + 1; // 假设ARR=0xFFFF } // 这里可以根据时钟频率计算出实际时间 // uint32_t pulse_width_ns = uwDiffCapture * (1e9 / Timer_Clock_Freq); // 重置,准备下一次测量 IsFirstCaptured = 0; // 改变触发边沿为上升沿 __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); } } }4.3 测量频率的方法:测周法与测频法
- 测周法: 适用于低频信号。即测量一个完整周期的时间T,频率 f = 1 / T。可以用上述双沿捕获测量一个周期(两次上升沿之间的时间)来实现。精度高,但信号频率越低,测量所需时间越长。
- 测频法: 适用于高频信号。在固定的闸门时间(比如1秒)内,用外部信号触发计数器计数(定时器可以配置为外部时钟模式),计数值即为频率。这种方法在闸门时间内完成测量,速度固定,但低频时可能计数太少,精度下降。
对于STM32,更通用和精确的方法是测周法,配合定时器的溢出中断来处理长周期信号。
4.4 输入捕获的进阶技巧与避坑
滤波与分频: 如果输入信号有毛刺,可以在定时器输入通道配置数字滤波器(Input Filter)和分频器(Prescaler)。例如,设置分频为2,则每2个有效边沿才触发一次捕获,可以抑制高频噪声。但要注意,这也会降低对快速边沿的响应能力。
处理计数器溢出: 当信号周期很长,计数器(CNT)在两次捕获之间可能已经溢出多次。为了准确测量,必须在定时器的更新中断(溢出中断)中对一个溢出计数器进行累加。最终时间 = (溢出次数 * (ARR+1) + 两次捕获的CNT差值) * 时钟周期。这是输入捕获测量长周期时的必备逻辑。
引脚输入模式: 输入捕获的GPIO必须配置为浮空输入或上拉/下拉输入模式,具体取决于外部电路。如果信号源是开漏输出,MCU这边通常需要使能内部上拉。
多通道同时捕获: 有些定时器支持多个通道独立捕获。例如,可以用两个通道分别测量PWM信号的高电平和低电平时间,效率更高。需要仔细阅读参考手册,配置对应的从模式触发(Slave Mode)和触发选择(TS)。
性能考量: 输入捕获中断是硬件触发的,对于极高频率的信号(接近定时器时钟频率的一半),频繁的中断可能消耗大量CPU资源。此时可以考虑使用DMA将捕获到的CCRx值直接搬运到内存数组中,再进行批量处理,解放CPU。
5. 项目集成:定时器综合应用案例——简易数字示波器前端
为了将定时中断、PWM和输入捕获串联起来,我们设想一个简单的项目:一个基于STM32的简易数字示波器前端。它需要完成以下功能:
- 信号发生: 用PWM产生一个可调频率和占空比的方波,作为自测试信号源。
- 信号测量: 用输入捕获测量外部输入信号的频率和占空比。
- 系统调度: 用定时中断产生1ms的系统节拍,用于按键扫描、测量结果刷新显示等。
5.1 系统架构与资源分配
- TIM1(高级定时器): 用于产生高精度、带死区的PWM信号(如果驱动需要),这里我们仅用其CH1输出可调测试方波。
- TIM2(通用定时器): 配置为输入捕获模式,CH1通道用于测量外部信号的频率和脉宽。开启更新中断和捕获中断以处理长周期测量和溢出。
- TIM3(通用定时器): 配置为1ms定时中断,作为系统时基。
- TIM4(通用定时器): 备用,或用于产生另一路PWM。
5.2 核心代码逻辑与协同
在main.c的初始化部分,依次启动各个定时器:
// 启动系统节拍定时器中断 HAL_TIM_Base_Start_IT(&htim3); // 启动PWM输出,默认输出1kHz,50%占空比 HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); // 启动输入捕获中断 HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_1); HAL_TIM_Base_Start(&htim2); // 也需要启动定时器基础计数在1ms定时中断(TIM3)的回调函数中,执行非实时性要求高的任务:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { static uint16_t ms_cnt = 0; if (htim->Instance == TIM3) { // 每1ms执行一次 Key_Scan(); // 按键扫描,用于调整PWM参数 if (++ms_cnt >= 500) { ms_cnt = 0; // 每500ms更新一次显示,显示测量到的频率和占空比 Update_Display(measured_freq, measured_duty); } } }在输入捕获中断(TIM2)的回调函数中,实现之前提到的双沿捕获逻辑,并加入溢出处理。计算出的脉宽和周期值,存入全局变量measured_pulse_width和measured_period,供显示函数使用。
在主循环中,响应按键事件,动态修改TIM1的ARR(改频率)和CCR1(改占空比):
while (1) { if (key_event == KEY_FREQ_UP) { current_arr += 10; __HAL_TIM_SET_AUTORELOAD(&htim1, current_arr); // 注意:改变ARR后,为了保持占空比不变,CCR也应等比例调整 current_ccr = (current_ccr * (current_arr+1)) / (old_arr+1); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, current_ccr); } // ... 处理其他按键事件 HAL_Delay(10); // 主循环小延时 }5.3 调试与优化经验
中断冲突与优先级: TIM2的输入捕获中断(测量信号)是随机触发的,实时性要求高,应赋予较高的抢占优先级。TIM3的1ms系统中断优先级可以设低一些。TIM1的PWM更新中断(如果需要)优先级设置视应用而定。
测量精度与范围: 输入捕获的精度直接取决于定时器的时钟频率。72MHz下,每个计数约13.9ns,测量精度很高。测量范围受ARR值限制。为了兼顾宽范围和高精度,可以采用“自动量程”思想:先用一个较快的时钟(不分频)测量,如果测得的计数值太小(精度不够),则动态切换到一个分频后的慢时钟重新测量。
信号完整性: 作为示波器前端,输入信号的调理电路(衰减、放大、限幅)至关重要,必须确保进入STM32引脚的电平在0-3.3V之间,且边沿干净。可以直接在捕获引脚并联一个几十皮法的小电容来滤除高频毛刺。
使用DMA减轻CPU负担: 如果测量频率非常高,输入捕获中断会极其频繁。可以考虑使用定时器的DMA请求功能,将每次捕获的CCR值通过DMA循环存储到一个数组中。主程序只需要定期去检查这个数组的数据,就能计算出频率和占空比,从而将CPU从频繁的中断中解放出来。这是实现高性能测量的关键技巧。
通过这个综合案例,你可以看到,定时器的三大功能是如何在一个实际项目中各司其职又相互配合的。PWM负责“输出”控制信号,输入捕获负责“感知”外部世界,定时中断则为整个系统提供稳定的“心跳”节奏。掌握它们,你就掌握了STM32实时控制的精髓。