
1. 从零开始为什么通用定时器是STM32的“心脏”如果你刚开始接触STM32可能会觉得点灯、串口打印这些基础操作已经够用了。但当你真正想做一个能“动”起来的项目比如让电机按固定速度旋转、每隔100毫秒采集一次传感器数据或者生成一个精确的PWM信号来控制LED亮度时你会发现没有定时器这一切都无从谈起。通用定时器特别是像TIM2、TIM3、TIM4、TIM5这些可以说是STM32单片机里最核心、最灵活的外设之一它就像是整个系统的节拍器和计时员。很多新手会困惑HAL库已经提供了HAL_Delay()函数为什么还要折腾定时器这里有个关键区别HAL_Delay()是阻塞的。当程序执行到这个函数时CPU就卡在这里“空转”数毫秒什么也干不了。这在简单的演示程序中没问题但在实际项目中这简直是灾难——你的系统无法响应其他事件传感器数据可能丢失通信会超时。而通用定时器是独立于CPU工作的它自己有个计数器在后台“嘀嗒嘀嗒”地走时间到了就触发一个中断或者设置一个标志位通知CPU“时间到了该干活了”。在这个过程中CPU完全可以去执行其他任务实现真正的“并行”处理。TIM2在STM32F1系列比如经典的STM32F103C8T6和F4系列中都是一个非常典型的通用定时器。它功能全面最基本的定时、输入捕获用来测频率或脉宽、输出比较用来生成PWM或单脉冲甚至还能做简单的编码器接口。我们今天先从最基础的定时功能入手这是理解所有高级应用的地基。我会基于STM32CubeMX这个官方图形化配置工具和HAL库带你一步步把TIM2用起来过程中我会穿插很多我早期踩过的坑和总结的经验让你少走弯路。2. CubeMX工程创建与TIM2基础参数配置首先打开STM32CubeMX新建一个工程。选择你的芯片型号比如STM32F103C8Tx。在图形化界面的左侧“Pinout Configuration”选项卡中找到“Timers”分类下面就能看到TIM2。点击TIM2会展开配置界面。第一个要配置的是“Clock Source”时钟源。对于定时功能我们通常选择“Internal Clock”内部时钟。这意味着TIM2的计数时钟来自于APB1总线时钟。这里有一个非常重要的细节在STM32中如果APB1的预分频系数不为1那么连接到定时器的实际时钟频率会是APB1时钟的2倍。例如你的系统主频是72MHzAPB1预分频器设为2分频那么APB1总线时钟是36MHz但定时器TIM2挂在APB1上的时钟源会自动倍频到72MHz。CubeMX会在“Clock Configuration”标签页帮你算好你只需要知道最终TIM2的输入时钟频率是多少就行这个频率我们称之为TIMx_CLK。接下来是“Parameter Settings”标签页这里是我们配置定时器核心参数的地方Prescaler (PSC - 预分频器)这是定时器的第一级分频。定时器计数器CNT的计数时钟CK_CNTTIMx_CLK/ (PSC 1)。PSC是一个16位寄存器范围0~65535。为什么是PSC1因为硬件设计如此预分频器有一个0的偏移。如果你想得到1MHz的计数频率即每微秒计数一次而TIMx_CLK是72MHz那么PSC应该设置为 (72MHz / 1MHz) - 1 71。关键经验PSC的值在这里直接填写71而不是72。很多新手在这里填错导致定时不准。Counter Mode (计数模式)对于基础定时选择“Up”向上计数即可。计数器从0开始加到我们设定的重装载值然后产生溢出事件再回到0重新开始。Counter Period (ARR - 自动重装载寄存器)这是定时器的周期值。当计数器CNT的值达到ARR时就会产生一个“更新事件”Update Event并且CNT复位到0或根据计数模式复位。定时器中断就是由这个更新事件触发的。ARR也是一个16位寄存器对于TIM2范围0~65535。auto-reload preload (自动重装载预装载)这个选项建议使能Enable。它的作用是当你软件修改了ARR的值时新的值不会立即生效而是要等到下一次更新事件发生时才生效。这可以防止在计数器运行中途修改周期导致不可预测的计数行为。对于固定周期的定时使能它更安全。Trigger Event Selection (TRGO) (触发事件选择)这里我们先不涉及保持默认。那么如何计算定时时间呢公式是定时时间 T (ARR 1) * (PSC 1) / TIMx_CLK举个例子我想实现一个1毫秒0.001秒的定时中断。假设TIMx_CLK 72MHz。我们先设定PSC 71这样CK_CNT 72MHz / (711) 1MHz即1微秒计数一次。要实现1毫秒定时就需要计数1000次。所以ARR 1000 - 1 999。代入公式T (9991) * (711) / 72,000,000 1000 * 72 / 72,000,000 0.001秒。在CubeMX中我们就在Prescaler里填71在Counter Period里填999。注意PSC和ARR的“1”操作是硬件行为我们在计算和配置时填写的值就是公式里的PSC和ARRCubeMX和HAL库的底层驱动会处理好这个偏移。你只需要记住你填的PSC值实际分频系数是PSC1你填的ARR值计数器是从0数到这个值包含总共ARR1个计数。配置好参数后别忘了开启中断。在CubeMX的“NVIC Settings”标签页或者直接在TIM2配置页找中断使能勾选“TIM2 global interrupt”。这样当定时器溢出时才会触发中断服务函数。最后根据你的需要配置系统时钟在“Clock Configuration”标签页确保TIMx_CLK是你预期的频率。然后生成代码Project Manager标签页设置好工程名、路径、IDE等点击“GENERATE CODE”。3. HAL库定时器中断代码详解与编程框架CubeMX生成代码后我们打开工程以Keil MDK为例。在main.c文件中CubeMX生成的初始化代码集中在MX_TIM2_Init()函数里。这个函数配置了TIM2的时钟、预分频、周期等基本参数并且调用了HAL_TIM_Base_Init()进行初始化。最关键的一步是它接着调用了HAL_TIM_Base_Start_IT(htim2)。这个函数做了两件事启动定时器__HAL_TIM_ENABLE(htim2)和使能更新中断__HAL_TIM_ENABLE_IT(htim2, TIM_IT_UPDATE)。真正的“魔法”发生在中断服务程序ISR里。HAL库为每个外设中断设计了一个统一的回调函数机制。对于TIM2的更新中断流程是这样的中断发生程序跳转到TIM2_IRQHandler()在stm32f1xx_it.c中。这个函数内部调用HAL_TIM_IRQHandler(htim2)。HAL_TIM_IRQHandler会判断是哪种中断类型更新、捕获、比较等如果是更新中断TIM_IT_UPDATE它会先清除中断标志位然后调用一个名为HAL_TIM_PeriodElapsedCallback()的弱定义__weak函数。这就是我们要编写用户代码的地方我们不需要也不应该去修改stm32f1xx_it.c里的内容而是在main.c或者自己的用户文件中重新实现这个回调函数。/* 在main.c的 /* USER CODE BEGIN 4 */ 和 /* USER CODE END 4 */ 之间添加 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { /* 判断是哪个定时器触发的回调 */ if (htim-Instance TIM2) { /* 这里是你的1ms中断任务 */ // 例如翻转一个LED灯 HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); // 或者让一个软件计数器递增 static uint32_t ms_counter 0; ms_counter; if(ms_counter 1000) // 每1000ms即1秒执行一次 { ms_counter 0; // 执行每秒一次的任务比如打印信息 // printf(One second passed.\r\n); } } /* 如果你的工程还有其他定时器如TIM3, TIM4可以在这里继续判断 */ }这就是使用HAL库处理定时器中断的标准范式。几个关键经验和避坑点回调函数名必须一字不差是HAL_TIM_PeriodElapsedCallback不是PeriodElapsedCallback也不是HAL_TIM_PWM_PeriodElapsedCallback。写错了函数就不会被调用。中断服务要短小精悍中断函数里应该只做最必要、最快速的操作比如设置标志位、递增计数器、清除状态等。绝对避免在中断里使用HAL_Delay()、进行复杂的数学运算或调用可能阻塞的函数如某些未优化好的printf。繁重的任务应该放到main函数的while(1)循环中通过中断里设置的标志位来触发。注意变量的共享访问如果中断函数和主循环都会读写同一个全局变量比如上面的ms_counter对于8位、16位、32位等单次访问就能完成的变量在Cortex-M内核上通常是安全的原子操作。但如果变量类型是64位或者主循环里对其有“读-改-写”的多步操作就需要考虑使用临界区保护__disable_irq()/__enable_irq()或者信号量等机制来防止数据错乱。别忘了启动定时器CubeMX生成的MX_TIM2_Init函数末尾已经调用了HAL_TIM_Base_Start_IT。如果你后续在代码中手动停止了定时器HAL_TIM_Base_Stop_IT要重新开启它必须再次调用HAL_TIM_Base_Start_IT。4. 进阶应用利用定时器实现微秒级延迟与精准任务调度掌握了基础定时中断后我们可以玩点更高级的。HAL_Delay()基于系统滴答定时器SysTick通常是毫秒级而且会阻塞。我们可以用通用定时器实现一个不阻塞的微秒级延时函数这在驱动某些对时序要求严格的器件如DS18B20、DHT11、WS2812灯带时非常有用。思路是利用定时器的计数器CNT寄存器它是一个自由运行的时钟。我们记录下起始时刻的CNT值然后在一个while循环中不断读取当前的CNT值计算差值直到差值达到我们需要的延时计数。首先确保TIM2被初始化为最基本的定时模式不分频或高分频让CNT跑得足够快并且以非中断方式启动即调用HAL_TIM_Base_Start(htim2)只启动计数器不开中断。然后实现微秒延时函数/** * brief 微秒级延时阻塞式 * param us: 要延时的微秒数 * note 此函数依赖于TIM2的计数器TIM2的时钟应配置为1MHz1us计数一次 * 使用前需确保已调用 HAL_TIM_Base_Start(htim2) */ void delay_us(uint16_t us) { uint32_t start_tick __HAL_TIM_GET_COUNTER(htim2); /* 考虑计数器溢出的情况ARR通常设置为最大值0xFFFF us应小于65535 */ /* 计算目标计数值 */ uint32_t target_tick start_tick us; /* 如果目标值超过了ARR假设ARR0xFFFF需要处理溢出 */ if (target_tick __HAL_TIM_GET_AUTORELOAD(htim2)) { target_tick - (__HAL_TIM_GET_AUTORELOAD(htim2) 1); /* 等待计数器溢出 */ while (__HAL_TIM_GET_COUNTER(htim2) start_tick) {;} /* 等待计数器增加到目标值 */ while (__HAL_TIM_GET_COUNTER(htim2) target_tick) {;} } else { /* 无溢出直接等待 */ while (__HAL_TIM_GET_COUNTER(htim2) target_tick) {;} } }这个delay_us函数仍然是阻塞的但它精度更高可达微秒级。注意函数中的循环会占用CPU所以它只适合在短时间延时或对时序要求极其严格的底层驱动中使用不能替代基于中断的非阻塞延时架构。更优雅的做法是结合定时器中断实现一个非阻塞的精准任务调度器。我们可以在1ms中断回调函数里维护一个系统时基例如一个32位的system_tick变量每1ms加1。volatile uint32_t system_tick 0; // 加volatile防止编译器优化 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { system_tick; // 系统时基每1ms加1 } } /** * brief 非阻塞延时检查 * param start_tick: 开始时刻的系统tick值 * param delay_ms: 需要延时的毫秒数 * retval 1: 延时时间到0: 延时未到 */ uint8_t check_delay_nonblocking(uint32_t start_tick, uint32_t delay_ms) { /* 处理system_tick溢出的情况约49.7天溢出一次 */ if((system_tick - start_tick) delay_ms) { return 1; } return 0; }在主循环中你可以这样使用uint32_t task1_last_run 0; uint32_t task2_last_run 0; while (1) { // 任务1每100ms执行一次 if(check_delay_nonblocking(task1_last_run, 100)) { task1_last_run system_tick; // 执行任务1比如读取ADC // ADC_Value HAL_ADC_GetValue(hadc1); } // 任务2每500ms执行一次 if(check_delay_nonblocking(task2_last_run, 500)) { task2_last_run system_tick; // 执行任务2比如通过串口发送数据 // printf(Task2 running at tick: %lu\r\n, system_tick); } // 其他非周期任务或低优先级任务可以放在这里 // ... }这种架构就是基于时间片的裸机任务调度它让CPU得以在“等待”期间执行其他任务极大地提高了系统效率。这是从“学会用定时器”到“用好定时器”的关键一步。5. 调试技巧与常见问题排查实录即使配置看起来正确定时器也可能不按预期工作。下面是我在项目中总结的几个常见问题及其排查步骤你可以像查清单一样逐一核对。问题1定时器中断根本没触发。检查步骤NVIC配置在CubeMX生成的main.c的MX_TIM2_Init函数后是否调用了HAL_NVIC_SetPriority和HAL_NVIC_EnableIRQCubeMX通常会自动生成这些但有时手动修改代码可能导致遗漏。确认stm32f1xx_it.c中存在TIM2_IRQHandler函数。定时器是否启动确认在main函数中初始化之后调用了HAL_TIM_Base_Start_IT(htim2)。CubeMX生成的初始化函数MX_TIM2_Init末尾通常会包含这个调用但如果你在别处停止了它需要重新启动。中断回调函数你的HAL_TIM_PeriodElapsedCallback函数实现了吗是否放在了正确的位置如main.c的USER CODE BEGIN 4区域函数名拼写是否正确时钟是否使能虽然CubeMX生成的代码会自动开启外设时钟但如果你在main函数非常靠前的位置比如在SystemClock_Config之前就尝试启动定时器时钟可能还未开启。确保定时器初始化在系统时钟配置之后。问题2定时时间不准确比预期快很多或慢很多。检查步骤系统时钟源你的芯片使用的外部晶振HSE频率是多少在CubeMX的“Clock Configuration”中是否正确配置了PLL倍频系数最终的系统时钟SYSCLK和你计算时假设的一致吗一个常见错误是代码基于8MHz HSE编写但实际板载晶振是12MHz或25MHz导致所有定时都不准。APB1预分频如前所述APB1预分频系数会影响定时器的输入时钟。在“Clock Configuration”界面仔细查看APB1 Timer clocks这一项的频率这才是TIMx_CLK。PSC和ARR计算再次核对PSC和ARR的计算公式。记住(PSC1)和(ARR1)。用逻辑分析仪、示波器或者一个简单的GPIO翻转在中断回调里翻转一个引脚来测量实际周期。假设目标是1ms中断你可以翻转一个LED用示波器测量翻转的周期应该是2ms因为每次中断翻转一次一个完整方波周期是两次翻转。中断服务函数耗时如果你的中断服务函数里执行了太多代码耗时超过了一个定时周期那么下一次中断来临时上一次中断还没处理完会导致实际的中断间隔变长。用简单的方法测试在中断回调函数里只做一条GPIO翻转语句看周期是否准确。如果准确了就说明是你的中断服务太“重”了。问题3程序运行一段时间后定时器似乎“卡住”不中断了。检查步骤中断标志位未清除这是最可能的原因。HAL库的HAL_TIM_IRQHandler函数通常会帮我们清除更新中断标志位TIM_SR_UIF。但如果你在回调函数里进行了非常规操作或者自己编写了中断服务函数一定要确保在退出前清除了对应的中断标志位否则会连续不断地进入中断或者标志位一直挂着导致不再触发新中断。变量溢出如果你在中断里使用了static或全局变量进行计数并且这个变量在递增到最大值后溢出归零你的逻辑判断可能会出问题。例如if(counter 1000)当counter从65535对于16位变量溢出到0时这个条件在很长一段时间内都不会成立。其他高优先级中断阻塞如果系统中存在更高优先级的中断并且该中断服务程序执行时间非常长它会抢占TIM2中断导致TIM2中断响应被延迟看起来就像“卡顿”。调试小技巧使用GPIO引脚辅助调试在中断回调函数的入口和出口用HAL_GPIO_WritePin拉高、拉低一个空闲的GPIO然后用示波器观察这个引脚的高电平脉宽这就是你中断服务函数的执行时间。这是衡量中断是否过“重”的最直观方法。利用断点和变量观察在调试模式下在中断回调函数开始处设置断点查看是否能进来。同时观察htim2.Instance-SR状态寄存器的值看看更新中断标志位TIM_SR_UIF是否被置位。6. 从TIM2定时到其他定时器与外设的关联思考当你熟练掌握了TIM2的定时功能后你会发现STM32的定时器世界远不止于此。同一个TIM2通过不同的配置可以变身成其他角色PWM输出在CubeMX中将TIM2的某个通道如Channel 1模式改为“PWM Generation CHx”。然后配置Pulse脉冲值即比较寄存器CCR的值就可以生成占空比可调的方波。这在控制电机速度、舵机角度、LED调光时必不可少。HAL库提供了HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1)来启动PWM输出。输入捕获将通道模式改为“Input Capture direct mode”可以测量外部脉冲的高电平宽度或周期。结合定时器的计数能实现高精度的频率计或脉宽测量。HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1)可以启动输入捕获并开启中断在捕获回调函数HAL_TIM_IC_CaptureCallback中读取捕获值。编码器模式在“Combined Channels”中选择“Encoder Mode”TIM2可以直接连接正交编码器自动计算转动的方向和步数非常适合电机位置反馈。更重要的是定时器是其他复杂功能的基础。例如FreeRTOS的时基很多人在CubeMX中配置FreeRTOS时会发现它默认使用SysTick作为时基。但在一些高精度或低功耗场景下你可以将其改为使用一个通用定时器如TIM2作为时基源以获得更灵活的控制。ADC的定时触发想让ADC以固定的、精确的采样率比如10kHz自动采样而不需要CPU干预可以将TIM2的更新事件UEV连接到ADC的触发源。在CubeMX的ADC配置中选择“External Trigger Conversion Source”为“Timer 2 Update Event”。这样TIM2每次溢出就自动触发一次ADC转换配合DMA可以实现极其流畅的数据流采集。DMA脉冲计数如果你需要产生非常精确数量的PWM脉冲例如步进电机控制中的步数可以将TIM2的更新事件通过DMA来递减一个脉冲计数变量当变量减到0时停止定时器这样就实现了指定脉冲个数的输出。理解了一个通用定时器TIM2的定时核心你就拿到了打开STM32定时器宝库的钥匙。其他通用定时器TIM3, TIM4, TIM5用法几乎一模一样只是总线时钟可能不同TIM2/3/4/5通常在APB1而TIM1、TIM8等高级定时器在APB2。基本定时器TIM6, TIM7更简单只有最基本的定时功能。高级定时器TIM1, TIM8则增加了互补输出、死区插入等用于电机控制的复杂功能。从点亮一个定时闪烁的LED到构建一个多任务调度的复杂应用系统通用定时器是你最可靠的伙伴。我建议你在吃透TIM2定时中断的基础上务必动手尝试一下PWM输出和输入捕获这是两个最常使用的功能。实践过程中遇到的每一个问题都会让你对STM32的时钟系统和外设协同工作有更深的理解。