
1. 从“轮询”到“中断”为什么定时器中断是STM32的必修课如果你刚开始玩STM32点亮LED、读取按键用的多半是“轮询”的方式。比如你想让一个LED灯每秒闪烁一次你可能会写一个死循环里面用HAL_Delay(1000)来阻塞等待。这在简单任务里没问题但当你需要同时处理按键、刷新屏幕、读取传感器数据时问题就来了CPU被HAL_Delay这类函数“绑死”了什么事都干不了整个程序效率极低响应迟钝。这就像你一边烧水一边必须死死盯着水壶水开之前你没法去洗菜、切菜。定时器中断就是解决这个问题的核心钥匙。它让定时器这个硬件外设在设定的时间到达时主动“打断”CPU当前的工作让CPU先去执行一段特定的处理程序中断服务函数处理完再回来继续原来的工作。这样CPU就从枯燥的“等待”中解放了出来可以高效地处理多项任务。对于STM32F103这类资源有限的单片机掌握定时器中断意味着你能写出更高效、更可靠、更接近实际产品需求的程序。它不仅仅是让灯闪起来更是实现精确计时、PWM生成、输入捕获、甚至简易操作系统任务调度的基础。今天我们就彻底搞懂在STM32F103上如何配置和使用定时器中断。2. 定时器家族概览与TIM2基础定时器选型STM32F103的定时器是个大家族功能强弱和复杂度各不相同。在入门时选择一个结构清晰、功能基础的定时器来学习中断机制是最合适的。这里我们选择TIM2作为示例。为什么是TIM2首先我们需要了解定时器的分类。在STM32F103中定时器主要分为高级控制定时器TIM1, TIM8功能最全带死区插入的互补PWM输出常用于电机控制。通用定时器TIM2, TIM3, TIM4, TIM5功能均衡具备基本的定时、PWM输出、输入捕获、输出比较功能是使用最广泛的类型。基本定时器TIM6, TIM7功能最简单仅用于最基本的定时和DAC触发。没有输入捕获和输出比较通道。TIM2是一个通用定时器它具备我们学习中断所需的所有基本要素同时又不像高级定时器那样复杂。它挂在APB1总线下。在标准库或HAL库中其驱动代码结构具有代表性学会了TIM2其他通用定时器的操作也大同小异。一个定时器要工作离不开几个核心概念它们共同决定了中断何时发生时钟源定时器计数的“心跳”。对于TIM2通常使用系统时钟SYSCLK经过APB1预分频器后的时钟PCLK1。如果APB1预分频系数不为1则定时器时钟会是PCLK1的2倍。例如系统时钟72MHzAPB1预分频系数为2则PCLK136MHz而TIM2的时钟CK_INT 36MHz * 2 72MHz。预分频器PSC用来对时钟源进行分频。因为时钟频率可能很高如72MHz直接计数的话数值变化太快。预分频器是一个16位寄存器0-65535实际分频系数 PSC 1。例如PSC71则分频后驱动计数器的时钟频率 72MHz / (711) 1MHz。自动重装载寄存器ARR这是定时器计数的目标值。计数器从0开始每个时钟周期加1当计数器的值等于ARR中设定的值时就会产生一个“更新事件”并且计数器清零重新开始。这个“更新事件”就可以触发中断。计数器CNT16位或32位TIM2是32位的寄存器实时记录当前的计数值。中断周期即定时时间的计算公式是定时时间 (ARR 1) * (PSC 1) / Tclk其中Tclk是定时器的输入时钟频率。举个例子我们要配置一个1ms0.001秒的定时中断。假设Tclk72MHz。 我们可以先设定PSC71这样计数器时钟 72MHz / 72 1MHz即每个计数周期是1微秒。 然后要得到1ms的定时就需要计数1000个周期。所以ARR 1000 - 1 999。 代入公式定时时间 (9991) * (711) / 72,000,000 1000 * 72 / 72,000,000 0.001秒。3. 使用STM32CubeMX图形化配置TIM2中断对于初学者我强烈推荐从STM32CubeMX开始。它通过图形界面生成初始化代码能让你直观地理解各个参数是如何关联的避免直接面对寄存器带来的恐惧感。以下是详细步骤。3.1 创建工程与定时器基础配置首先打开STM32CubeMX选择你的芯片型号例如STM32F103C8T6。在Pinout Configuration界面左侧找到Timers分类点击TIM2。时钟源在Clock Source中选择Internal Clock内部时钟。这意味着TIM2使用前述的APB1总线时钟。参数配置切换到Parameter Settings选项卡。Prescaler (PSC - 16 bits value)这里填入我们计算好的预分频值71。Counter Mode选择Up向上计数模式这是最常用的模式从0计数到ARR。Counter Period (AutoReload Register - 32 bits value)填入自动重装载值999。注意这里CubeMX填入的是ARR的值。Internal Clock Division (CKD)选择No Division。auto-reload preload建议使能Enable。这样对ARR的修改会在下次更新事件时才生效防止在计时中途修改ARR导致当前周期时间错乱。中断使能这是最关键的一步。切换到NVIC Settings选项卡找到TIM2 global interrupt勾选后面的Enabled复选框。这样CubeMX才会在生成的代码中帮我们配置好中断优先级并启用中断。3.2 时钟树配置与代码生成定时器的时钟依赖于系统时钟。你需要配置好整个系统的时钟。通常对于F103我们使用外部8MHz晶振HSE通过PLL倍频到72MHz作为系统时钟SYSCLK。在Clock Configuration选项卡中可以进行此配置。确保APB1总线的时钟PCLK1被正确设置为36MHz此时TIM2的时钟自动为72MHz。配置完成后点击Project Manager设置好工程名称、路径、IDE如MDK-ARM V5在Code Generator里选择“生成独立的.c/.h文件”。最后点击GENERATE CODE生成工程。3.3 生成代码结构解析打开生成的工程你会看到CubeMX已经为我们完成了所有底层初始化tim.c文件中MX_TIM2_Init()函数配置了TIM2的所有参数。main.c中在main()函数里调用了MX_TIM2_Init()和HAL_TIM_Base_Start_IT(htim2)。后者尤其重要它启动了定时器并使能了更新中断。没有这行代码定时器不会工作也不会产生中断。4. 编写中断服务函数与用户回调函数生成的代码搭建好了舞台但中断发生后具体要执行什么任务需要我们自己来“唱戏”。这里涉及到HAL库的中断处理机制。4.1 中断服务函数的“幕后”工作在HAL库中我们通常不需要直接编写像TIM2_IRQHandler()这样的中断服务函数。CubeMX和HAL库已经为我们写好了这个“幕后”函数在stm32f1xx_it.c文件中。它的主要工作是判断中断来源是否是“更新中断”。清除中断标志位防止中断持续触发。调用一个名为HAL_TIM_IRQHandler(htim2)的通用中断处理函数。这个HAL_TIM_IRQHandler函数会根据具体的中断类型再去调用对应的回调函数。对于我们使能的更新中断它会调用HAL_TIM_PeriodElapsedCallback()函数。4.2 用户回调函数的实现与注意事项我们的任务就是在自己的代码文件中重写Override这个回调函数。这是你放置中断处理逻辑的地方。在main.c的/* USER CODE BEGIN 4 */和/* USER CODE END 4 */之间或者你自己新建的源文件中添加以下函数/** * brief 定时器更新中断回调函数 * param htim: 定时器句柄指针 * retval None */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { // 判断是哪个定时器产生的中断 if (htim-Instance TIM2) { // 在这里编写你的中断处理代码 // 例如翻转一个LED灯的状态 HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); // 假设LED接在PA5 } }这里有几个至关重要的经验点是新手最容易栽跟头的地方注意1中断服务函数必须“快进快出”。中断处理代码要尽可能简短。避免在中断里使用HAL_Delay()、进行复杂的数学运算或调用可能阻塞的函数如某些传感器读取函数。长时间占用中断会导致其他中断无法及时响应甚至可能使主程序“饿死”。如果任务复杂正确的做法是在中断里只设置一个标志位如volatile uint8_t tim2_flag 1;然后在主循环里检查这个标志位并执行耗时任务。注意2操作全局变量需使用volatile关键字。在中断服务函数和主循环之间共享的变量比如上面提到的标志位必须用volatile修饰。这告诉编译器不要对这个变量进行优化确保每次读取都从内存中获取最新值。例如volatile uint32_t g_interrupt_count 0;在回调函数里g_interrupt_count。注意3清除中断标志位是HAL库自动完成的。在标准库中我们需要在中断函数里手动写TIM_ClearITPendingBit(TIM2, TIM_IT_Update);来清除标志位。但在HAL库的机制下HAL_TIM_IRQHandler()已经帮我们做了这件事。如果你在回调函数里又去手动清除通常不会出错但也不是必须的。理解库的机制能让你在出问题时知道从哪里排查。5. 进阶话题中断优先级NVIC与调试技巧当你开始使用多个中断如定时器中断、串口中断、外部按键中断时它们之间可能会“打架”这就需要设置优先级。5.1 NVIC优先级管理浅析在CubeMX的NVIC Configuration界面你可以看到TIM2 global interrupt的优先级设置包含抢占优先级Preemption Priority和子优先级Subpriority。抢占优先级高抢占优先级的中断可以打断正在执行的低抢占优先级的中断。就像急诊病人可以打断普通门诊。子优先级当两个中断的抢占优先级相同时子优先级高的先执行。如果连子优先级也相同则比较它们的硬件中断编号。对于STM32F103优先级数值越小优先级越高。通常系统关键任务如看门狗设为最高优先级而像定时器这种周期性任务可以设为较低的优先级。在入门阶段可以暂时使用默认设置但必须理解这个概念。5.2 调试当定时器中断不工作时你的代码编译下载了但LED灯就是不闪。别慌按照以下思路排查检查硬件连接LED的GPIO口配置对了吗CubeMX里是否将对应引脚如PA5设置为了GPIO_Output检查定时器是否启动确认main()函数中调用了HAL_TIM_Base_Start_IT(htim2)。只初始化不启动是常见错误。检查中断是否使能在CubeMX的NVIC设置里确认TIM2 global interrupt已被勾选。生成的代码stm32f1xx_it.c中应该有TIM2_IRQHandler函数。使用调试器这是最强大的工具。在IDE如Keil中进入调试模式。在HAL_TIM_PeriodElapsedCallback函数内设置一个断点。如果中断发生程序会停在这里。如果没有停检查定时器的计数器CNT是否在增加。在观察窗口查看htim2.Instance-CNT的值。如果不增加说明定时器时钟或配置有问题。查看中断状态寄存器。观察TIM2-SR寄存器的UIF位更新中断标志位是否被置1。如果置1了但没进中断可能是NVIC配置或中断服务函数向量表有问题。检查系统时钟配置确保系统时钟确实配置到了72MHz并且APB1总线时钟正确。可以在main()开始时用SystemCoreClock变量打印或查看。6. 从定时中断到实际应用构建一个多任务系统框架掌握了基本的1ms定时中断后我们就可以用它来做一些更有意义的事情构建一个简单的时间片轮询系统框架。这是在没有RTOS实时操作系统的情况下让单片机“同时”处理多个周期性任务的常用方法。思路是在1ms定时中断的回调函数里对一个全局的毫秒计数器进行累加。然后在主循环中基于这个计数器的值来判断是否该执行某个任务了。首先定义全局变量和任务函数原型// 在文件顶部定义 volatile uint32_t g_systick_ms 0; // 系统运行时间由中断修改 // 任务函数原型 void Task_LED_Blink_500ms(void); void Task_Scan_Key_20ms(void); void Task_Sensor_Update_100ms(void);然后修改定时器中断回调函数void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { g_systick_ms; // 每1ms增加一次 } }最后在主循环while (1)中实现任务调度while (1) { // 任务1每500ms执行一次 if ((g_systick_ms % 500) 0) { Task_LED_Blink_500ms(); } // 任务2每20ms执行一次按键扫描需要消抖 if ((g_systick_ms % 20) 0) { Task_Scan_Key_20ms(); } // 任务3每100ms执行一次如读取温湿度传感器 if ((g_systick_ms % 100) 0) { Task_Sensor_Update_100ms(); } // 其他非周期性的任务或低优先级任务可以放在这里 // ... // 注意这里没有延时主循环一直在快速运行 }这里的精妙之处在于所有任务的计时基准都依赖于同一个精确的1ms中断。g_systick_ms不断累加主循环通过取余运算来判断任务周期是否到达。由于主循环跑得很快微秒级而任务周期是毫秒级所以任务的执行时机非常精准。这比在每个任务里用HAL_Delay要高效得多CPU利用率接近100%。当然这个框架很简单任务之间没有优先级如果一个任务执行时间过长会影响到其他任务的准时执行。但在很多实际应用中只要合理划分任务和确保每个任务执行时间足够短这个框架就非常有效且稳定。这也是你从“点亮LED”迈向“实际项目”的关键一步。通过这个1ms的定时中断心跳你的STM32程序就有了“时间”的概念能够有条不紊地处理复杂事务了。