
1. 项目概述为什么定时器是STM32的“心脏”如果你刚开始玩STM32特别是经典的STM32F103也就是我们常说的“蓝桥杯神板”或“最小系统板”你可能会觉得GPIO点灯、串口打印已经够用了。但当你真正想做一个能“动”起来的项目比如让电机转起来、让蜂鸣器唱歌、或者精准地每隔一段时间采集一次传感器数据时你会发现一个绕不开的核心外设——定时器。定时器你可以把它理解成单片机内部一个精准的“秒表”或者“闹钟”。它不像我们人脑计时那样模糊而是由精确的时钟源驱动可以做到微秒甚至纳秒级的计时精度。在STM32F103里定时器家族非常庞大有基本定时器TIM6, TIM7、通用定时器TIM2-TIM5和高级定时器TIM1, TIM8。我们今天这个实验主要聚焦在通用定时器上因为它功能最全也最常用。通过配置它我们能实现延时、产生PWM波、测量输入信号的频率和脉宽、甚至触发ADC采样等一系列高级功能。可以说掌握了定时器你才算是真正摸到了STM32嵌入式开发的门槛。这个实验的目标很明确不依赖任何现成的HAL_Delay或者SysTick我们亲手从寄存器级别开始配置一个通用定时器让它能够精准地定时并在定时时间到达时通过中断的方式通知CPU同时用LED灯的闪烁来直观地展示定时效果。我会带你从原理图分析、寄存器配置一路写到中断服务函数把每个比特位的作用都讲清楚。过程中我会穿插我早期调试时踩过的坑比如时钟源选错导致定时不准、中断标志忘了清除导致程序卡死等等。无论你是刚拿到一块STM32F103最小系统板的新手还是想深入理解定时器原理的进阶者这篇内容都能让你有实实在在的收获。2. 核心原理与定时器框图深度拆解在动手写代码之前我们必须先搞清楚定时器是怎么工作的。如果直接对着库函数瞎调出了问题你根本不知道从哪里查起。STM32的通用定时器功能强大结构也相对复杂但我们可以把它拆解成几个核心部分来理解。2.1 定时器的“动力源”时钟树与预分频器定时器要计数首先得有“心跳”。这个心跳就来自于STM32的时钟系统。对于TIM2-TIM5这些通用定时器它们的时钟源通常是APB1总线时钟PCLK1。这里有个关键点在标准库的默认配置下如果APB1的预分频系数不为1那么定时器实际得到的时钟频率会是PCLK1的2倍。比如系统主频72MHzAPB1预分频器设为2分频那么PCLK1是36MHz但定时器的时钟CK_INT会是72MHz。这个细节很多资料不会强调但它直接影响你后续计算定时周期的准确性。时钟信号进入定时器后第一个迎接它的是预分频器PSC, Prescaler。这是一个16位的寄存器作用就是把高速的时钟频率降下来变成适合我们计数的“滴答”频率。计算公式是定时器时钟频率 CK_INT / (PSC 1)。为什么是PSC1因为预分频器是从0开始计数的写入0代表1分频频率不变写入1代表2分频以此类推。假设CK_INT是72MHz我们想让定时器每1微秒计数一次那么就需要72MHz / 1MHz 72分频所以PSC应该设置为71。2.2 计数的“尺子”自动重装载寄存器与计数模式经过预分频后的时钟驱动着计数器CNT这个核心部件进行递增或递减。计数器能数到多少由自动重装载寄存器ARR, AutoReload Register来决定。ARR也是一个16位寄存器对于TIM2和TIM5是32位它设定了计数的上限。通用定时器支持几种计数模式最常用的是向上计数模式计数器从0开始每个时钟滴答加1一直加到ARR的值这时会产生一个“更新事件”并且计数器清零重新开始。这样从0加到ARR就是一个完整的定时周期。定时的时间长度很容易计算定时周期 (ARR 1) * (PSC 1) / CK_INT。这里的1同样是因为寄存器从0开始计数。举个例子我们要实现一个1秒的定时假设LED闪烁用。CK_INT72MHz我们设定PSC7199ARR9999。那么 定时周期 (99991) * (71991) / 72,000,000 10000 * 7200 / 72,000,000 1秒。 这样每过1秒就会产生一次更新事件。2.3 关键的“通知机制”中断与更新事件定时器默默地计数我们怎么知道它“时间到了”呢这就需要中断机制。当计数器溢出达到ARR值产生更新事件时硬件会置位一个叫做“更新中断标志位”的寄存器位。如果我们提前在中断控制寄存器中开启了“更新中断”那么这个标志位就会向内核的NVIC嵌套向量中断控制器申请中断。CPU响应中断后就会跳转到我们预先写好的中断服务函数中执行代码比如在这里翻转LED的电平。这里有一个极其重要的步骤也是新手最容易忽略导致程序卡死的地方必须在中断服务函数中手动清除那个更新中断标志位。如果不清除硬件会认为中断一直存在CPU就会不停地跳进中断服务函数导致主程序根本无法运行。在标准库中通常用TIM_ClearITPendingBit(TIMx, TIM_IT_Update)来实现。注意更新事件和更新中断是两个相关但不同的概念。事件是硬件层面的状态变化它可以触发中断如果中断使能也可以不触发中断而直接触发DMA请求等其他操作。我们实验中使用的是中断模式。3. 硬件连接与工程环境搭建理论清楚了我们来看看手头需要准备些什么以及怎么把电路连起来。这个实验对硬件要求极低非常适合验证。3.1 所需材料清单与电路连接STM32F103C8T6最小系统板这是主角核心板自带晶振和复位电路即可。ST-Link V2下载调试器用于程序下载和在线调试。连接时注意四根线3.3V、GND、SWDIO、SWCLK。一颗LED灯和一个220Ω限流电阻用于显示定时效果。我们将LED的正极长脚通过电阻连接到3.3V负极短脚连接到STM32的一个GPIO引脚例如PA0。这种接法叫做“共阳极接法”当GPIO输出低电平时LED两端形成电压差灯亮输出高电平时电压差为0灯灭。你也可以用常见的共阴极接法LED负极接GND正极通过电阻接GPIO那么逻辑就反过来了。杜邦线若干。电脑与开发环境我强烈建议新手从标准外设库开始而不是一上来就用HAL库或CubeMX。标准库更贴近硬件寄存器能让你更好地理解原理。你可以从ST官网下载STM32F10x_StdPeriph_Lib或者使用已经集成好库的IDE如Keil MDK。连接示意图ST-Link的3.3V、GND、SWDIO、SWCLK分别接最小系统板的对应引脚。LED的阳极经电阻接板子的3.3V引脚。LED的阴极接MCU的PA0引脚或其他任意你选择的GPIO如PC13注意有些板子的用户LED已经接在PC13。3.2 软件工程创建与关键配置在Keil中新建一个工程选择STM32F103C8对于C8T6芯片。在管理运行时环境RTE或手动添加库文件时确保包含STM32F10x_StdPeriph_Driver下的misc.cstm32f10x_gpio.cstm32f10x_rcc.cstm32f10x_tim.c。CMSIS下的核心支持文件。自己编写的main.cstm32f10x_it.c中断服务函数文件和对应的头文件。在main.c开始之前有一项全局配置必须做修改stm32f10x.h文件中的宏定义或者在你的工程预处理器符号中定义。对于最常见的STM32F103C8T6你需要定义USE_STDPERIPH_DRIVERSTM32F10X_MDMD代表中等容量。这个STM32F10X_MD定义了芯片的Flash容量范围它决定了头文件中对寄存器映射和中断向量表的正确配置如果选错可能导致程序跑飞或中断无法响应。4. 从零开始定时器初始化代码逐行解析好了硬件连好工程建妥现在我们进入最核心的编码环节。我会把代码分成几个功能块并逐行解释其作用。4.1 第一步开启外设的时钟“总闸”STM32为了省电所有外设的时钟默认是关闭的。使用任何外设GPIO、TIM、USART等前必须先打开对应的时钟。这就像给这个外设接通了电源。// 1. 开启GPIOA和TIM2的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // GPIOA挂在APB2总线上 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // TIM2挂在APB1总线上这里要注意总线区别GPIOA属于高速的APB2总线而TIM2属于低速的APB1总线。开错时钟总线是常见错误之一。4.2 第二步配置LED指示灯对应的GPIO我们把PA0用作推挽输出驱动LED。GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; // 推挽输出模式 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; // 输出速度50MHz足够 GPIO_Init(GPIOA, GPIO_InitStructure); // 初始状态设为高电平LED灭假设共阳极接法 GPIO_SetBits(GPIOA, GPIO_Pin_0);4.3 第三步精细配置TIM2定时器参数这是重头戏我们初始化一个定时器结构体TIM_TimeBaseInitTypeDef。TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; // 计算并设置预分频值。系统时钟72MHzAPB1预分频为2所以TIM2时钟为72MHz。 // 我们希望定时器计数频率为10KHz即每个计数0.1ms。 // PSC 72MHz / 10KHz - 1 7200 - 1 7199 TIM_TimeBaseStructure.TIM_Prescaler 7199; // 设置自动重装载值。我们希望定时周期为500ms。 // 计数频率是10KHz即每秒计数10000次。500ms需要计数5000次。 // ARR 5000 - 1 4999 TIM_TimeBaseStructure.TIM_Period 4999; // 设置时钟分频因子这里不分频直接使用内部时钟CK_INT TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; // 设置计数模式为向上计数 TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; // 初始化TIM2 TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure);关键点解析TIM_Prescaler和TIM_Period的计算是定时器应用的基础务必理解公式。TIM_ClockDivision这个参数与输入捕获滤波器有关在基本的定时功能里设为DIV1即可它不影响定时周期。这里我们配置的是500ms定时这样LED就能以1Hz的频率闪烁亮500ms灭500ms。4.4 第四步使能定时器更新中断光有定时器溢出还不够必须告诉NVIC我们关心这个中断。// 使能TIM2的更新中断 TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); // 配置NVIC嵌套向量中断控制器 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; // TIM2的中断通道 NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; // 抢占优先级 NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; // 子优先级 NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure);优先级设置心得对于简单的单一定时器应用优先级可以随意设置。但在复杂的多中断系统中你需要合理规划抢占优先级和子优先级。抢占优先级高的可以打断低的相同抢占优先级下子优先级高的先响应。通常系统滴答定时器SysTick的优先级会设得很低避免它打断重要的硬件外设中断。4.5 第五步启动定时器万事俱备只欠东风。TIM_Cmd(TIM2, ENABLE); // 使能TIM2计数器开始计数执行完这行代码定时器的计数器CNT就会开始从0递增我们的定时之旅就正式开始了。5. 中断服务函数事件响应的核心当定时器计数到ARR值4999时更新中断标志置位CPU会暂停主循环while(1)里的代码跳转到中断向量表指定的位置执行。在标准库工程中这个函数通常写在stm32f10x_it.c文件里。找到TIM2_IRQHandler函数并编写如下内容void TIM2_IRQHandler(void) { // 1. 检查中断来源是否是“更新中断” if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { // 2. 清除中断标志位至关重要 TIM_ClearITPendingBit(TIM2, TIM_IT_Update); // 3. 执行我们的任务翻转LED状态 GPIO_WriteBit(GPIOA, GPIO_Pin_0, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_0))); } }逐行解读与避坑指南判断中断源一个定时器可能有多种中断源更新、捕获/比较、触发等。进入中断后第一件事就是用TIM_GetITStatus检查具体是哪个标志位触发的。这是一个好习惯尤其在复杂应用中。清除中断标志这是本函数中最重要的一行TIM_ClearITPendingBit的作用就是将硬件中那个表示“有更新中断发生”的标志位手动清零。如果不清零中断状态会一直保持CPU就会不断重复进入这个中断服务函数导致主程序“饿死”现象就是程序好像卡死了只有中断里的代码在疯狂运行。我早期调试时至少有一半的奇怪问题都是因为忘了清标志位。执行用户代码在确保标志位清除后就可以安全地执行我们的逻辑了。这里用了一个小技巧来翻转引脚电平先读取当前输出值然后用1减去它实现0和1的切换。6. 主函数与整体程序流程最后我们把main函数补充完整它主要就是调用上面的初始化函数然后进入一个空循环把所有工作交给中断。int main(void) { // 系统时钟初始化标准库中SystemInit()通常已在启动文件调用默认72MHz // 如果你的工程没有可能需要手动调用或配置。 LED_GPIO_Config(); TIM2_Configuration(); // 这个函数封装了上述所有TIM2初始化步骤 while (1) { // 主循环里什么都不做或者可以做一些低优先级的后台任务 // 定时翻转LED的任务完全由TIM2中断接管 } }整个程序的流程非常清晰上电启动初始化系统时钟。初始化GPIO和TIM2配置好500ms的定时并开启中断。启动定时器计数器开始工作。CPU执行主循环while(1)。每过500msTIM2计数器溢出触发更新中断。CPU暂停主循环跳转到TIM2_IRQHandler。在中断里清除标志位翻转LED状态。中断返回CPU回到主循环继续执行。 如此周而复始LED便开始稳定地闪烁。7. 调试技巧与常见问题实录代码写完了下载到板子上如果LED没有按预期闪烁别着急我们一步步来排查。下面是我总结的几个最常见的问题和调试方法。7.1 问题一LED完全不亮或常亮不闪排查思路检查硬件这是第一步也是最容易的一步。用万用表测量PA0引脚在程序运行时的电压是否在高低电平之间变化。如果不变化可能是软件问题如果变化但LED不亮检查LED是否焊反、限流电阻是否过大或开路、共阳/共阴接法是否与程序逻辑匹配。检查时钟定时器的时钟源对吗用RCC_GetClocksFreq函数打印一下系统时钟和APB1时钟频率确认是否是你预期的72MHz和36MHz。如果时钟不对定时周期会完全错乱。检查中断是否进入在TIM2_IRQHandler函数的第一行设置一个断点或者在里面加一句串口打印如果串口已初始化。下载程序并全速运行看调试器是否会停在断点或者串口是否有输出。如果中断根本没进去问题可能出在NVIC配置错误中断通道TIM2_IRQn是否正确优先级配置是否极端中断标志未清除历史遗留如果之前实验遗留了未清除的中断标志可能会阻塞新的中断。可以在初始化定时器后、开启中断前手动清除一次所有中断标志TIM_ClearITPendingBit(TIM2, TIM_IT_Update);全局中断未开启Cortex-M内核上电后全局中断是关闭的。标准库的SystemInit()函数一般会调用__enable_irq()来开启。但有些自定义的启动流程可能会漏掉。可以在main函数开头加一句__enable_irq();试试。7.2 问题二LED闪烁频率不对太快或太慢排查思路复核计算这是最可能的原因。仔细检查PSC和ARR的计算公式和代入值。记住公式定时时间 (ARR1) * (PSC1) / TIMx_CLK。确保TIMx_CLK是你认为的那个值。确认时钟源频率如同问题一所述用代码确认实际时钟频率。APB1预分频系数会影响TIMx_CLK。检查TIM_Period和TIM_Prescaler的赋值这两个寄存器都是16位的最大值65535。如果你的计算值超过这个范围需要调整分频比或者使用定时器的溢出多次来合成更长的定时。7.3 问题三程序运行一段时间后卡死排查思路中断标志未清除99%的可能性请再次确认你的中断服务函数里在执行完用户代码后是否调用了TIM_ClearITPendingBit。中断服务函数执行时间过长中断服务函数应该尽可能短小精悍。如果你的中断函数里做了复杂的运算、调用了大量函数或进行了浮点操作未使用硬件FPU时可能导致中断执行时间超过定时周期。当下一个中断到来时上一个还没执行完就可能引发不可预知的行为。对于需要复杂处理的任务建议在中断里只设置一个标志位然后在主循环里检查这个标志位并执行具体任务。堆栈溢出如果中断函数或其中调用的函数使用了很大的局部数组可能导致堆栈溢出。可以适当增大启动文件如startup_stm32f10x_md.s中定义的堆栈大小。7.4 进阶调试使用逻辑分析仪或示波器如果你有逻辑分析仪甚至示波器调试起来就直观多了。将探头连接到LED的控制引脚PA0你可以看到精确的方波波形。测量方波的高电平或低电平时间看它是不是准确的500ms。如果不是就能直接证明定时计算或时钟有问题。这是最权威的验证手段。8. 从基础定时到高级应用PWM输出实战掌握了基本的定时中断我们就可以玩点更高级的了。通用定时器另一个杀手级应用是生成PWM脉冲宽度调制信号。简单说PWM就是一个周期固定、但高电平占空比可调的方波。它被广泛用于控制LED亮度、电机速度、舵机角度等。8.1 PWM原理与定时器通道在向上计数模式下PWM生成过程可以这样理解定时器计数器CNT从0加到ARR。我们另外设置一个捕获/比较寄存器CCR。硬件会不断比较CNT和CCR的值。当CNT CCR时PWM输出高电平或低电平取决于极性设置。当CNT CCR时PWM输出相反的电平。 这样通过改变CCR的值就能改变一个周期内高电平的时间即占空比。ARR决定了PWM的频率CCR决定了占空比。STM32的通用定时器每个都有多个独立的通道TIM2/3/4有4个TIM5有2个每个通道都可以独立配置为PWM输出模式。8.2 配置TIM3通道1输出PWM代码示例假设我们想用PA6TIM3_CH1输出一个频率1KHz占空比50%的PWM波。void TIM3_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 1. 开启时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); // 2. 配置GPIO为复用推挽输出PWM是定时器的功能引脚需复用 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; // 复用推挽输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 配置定时器时基单元决定PWM频率 // PWM频率 TIMx_CLK / ((PSC1)*(ARR1)) // 假设TIMx_CLK72MHz要得到1KHz频率取PSC71ARR999 // 计算72,000,000 / ((711)*(9991)) 72,000,000 / 72,000 1000Hz TIM_TimeBaseStructure.TIM_Period 999; // ARR TIM_TimeBaseStructure.TIM_Prescaler 71; // PSC TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); // 4. 配置PWM模式输出比较模式 TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; // 模式1 TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; // 使能输出 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; // 输出极性高 // 设置占空比CCR值。占空比 CCR / (ARR1) // 50%占空比CCR (ARR1) * 0.5 1000 * 0.5 500 TIM_OCInitStructure.TIM_Pulse 500; // 这就是CCR值 TIM_OC1Init(TIM3, TIM_OCInitStructure); // 初始化通道1 // 5. 使能预装载寄存器保证CCR值在更新事件时才生效避免中途变化导致毛刺 TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM3, ENABLE); // 也使能ARR的预装载 // 6. 启动定时器 TIM_Cmd(TIM3, ENABLE); }关键参数解析TIM_OCMode_PWM1在向上计数时当CNTCCR为有效电平由极性决定CNT≥CCR为无效电平。模式2则逻辑相反。TIM_OCPolarity_High定义“有效电平”为高电平。如果你接的负载是低电平有效比如共阳极LED可以设为Low。TIM_Pulse直接设置捕获/比较寄存器CCR的值即脉宽。预装载功能这是一个重要技巧。使能后你对CCR或ARR的修改会先写入一个缓冲寄存器直到下次更新事件发生时才会真正传输到工作寄存器中。这可以防止在一个PWM周期中间改变参数导致输出波形出现毛刺或断裂。8.3 动态调节PWM占空比初始化完成后我们可以随时在程序中修改CCR的值来改变占空比从而动态调节LED亮度或电机转速。// 将TIM3通道1的占空比改为75% TIM_SetCompare1(TIM3, 750); // CCR 1000 * 0.75 750这个函数会安全地更新CCR的值如果使能了预装载则写入缓冲寄存器。通过这个PWM实验你将定时器的应用从简单的“定时通知”扩展到了“信号生成”这为你后续控制舵机、驱动直流电机、实现呼吸灯效果打下了坚实的基础。理解了一个外设就能解锁一系列应用场景这正是嵌入式开发的乐趣所在。