先说我自己的体会:STM32CubeMX 这种图形化配置工具,带来的改变真的不是一星半点。以前用标准外设库配一个定时器中断,要先翻数据手册查寄存器,自己算预分频和重装载值,再写启动代码,折腾下来没个半小时真搞不定。现在通过 CubeMX 把引脚、时钟、外设都可视化配置好,生成工程骨架,再补几行用户逻辑,TIM1 做 1ms 定时中断,配合 LED 闪烁 Demo,5 分钟绝对能跑起来。这篇文章我就拿 TIM1 定时器 1ms 中断作为例子,把从 CubeMX 配置、代码编写到 Keil 烧录验证的完整流程拆开揉碎讲一遍,适合刚接触 STM32 想搞懂定时器中断和 CubeMX 使用流程的读者,已经会用标准库点灯但一直没摸过 CubeMX 的老朋友也可以对照看看两种开发方式的差别。
1. 项目到底在做什么:几个关键概念先理清
在正式开始点鼠标配置之前,我强烈建议先把几个基础概念想明白。否则就是照着截图点一遍,下次换个定时器、换个周期还是不会。
1.1 STM32CubeMX 到底帮你省了什么
STM32CubeMX 是 ST 官方出的图形化初始化代码生成工具。它的核心逻辑很简单:你用图形界面把芯片型号、引脚复用、时钟树、外设参数全部配置好,它帮你生成一套完整的 HAL 库工程代码,里面包括了时钟初始化、GPIO 初始化、外设初始化、中断向量配置这些底层逻辑。
比如我们这个 Demo 里要做的 TIM1 初始化,如果手写的话,要操作 RCC 时钟使能、配置 PSC 预分频寄存器、配置 ARR 自动重装载寄存器、设置中断优先级、写中断服务函数,每一步都要对着参考手册查寄存器地址和位定义。用 CubeMX 就是点几下鼠标的事,配置完直接生成代码,生成出来的初始化函数结构清晰,逻辑都是验证过的。
这里要说清楚,CubeMX 生成的是项目骨架,不是全部代码。它解决的是"外设怎么初始化"这个问题,至于"初始化之后要干什么",比如我们要做的 LED 闪烁逻辑,还是得自己写在用户代码区。这也是很多新手第一次用 CubeMX 时最容易困惑的地方:以为代码生成完就能直接跑出效果,其实不是,你还需要主动去启动定时器、写中断回调函数。
1.2 TIM1 是什么定时器,为什么偏偏选它
STM32 里的定时器资源很丰富,基础定时器 TIM6/TIM7,通用定时器 TIM2/3/4/5,高级定时器 TIM1/TIM8,还有一些更基础的看门狗定时器、系统滴答定时器。
TIM1 属于高级定时器,挂在 APB2 总线上,它比通用定时器多了互补输出、刹车输入、重复计数这些功能,常用于电机控制的 PWM 死区补偿场景。不过在我们这个 Demo 里,用 TIM1 做基础定时中断和用通用定时器没有本质区别,都是让计数器从 0 递增到自动重装载值,触发更新事件,进入中断回调函数。
为什么我推荐在入门阶段用 TIM1?一方面是因为高级定时器在学生板和入门教程里出现频率高,你先把它玩明白了,后面再用 TIM1 输出带死区的 PWM 驱动电机,或者接霍尔传感器做输入捕获,起点就不一样了;另一方面是 TIM1 挂在 APB2 总线上,在不少人用的 F103 系列里,APB2 最大可以到 72MHz,理解时钟路径之后,计算中断频率会更顺手。
1.3 1ms 中断是什么概念,用来干什么
1ms 中断,说白了就是定时器每隔 1 毫秒触发一次中断,也就是中断频率为 1kHz,每秒触发 1000 次。这个时间粒度在嵌入式里非常常用,很多实时性要求不高的系统都用它做时间基准,比如按键消抖、LED 闪烁、传感器定时采样、任务调度心跳。
为什么不用 delay 延时来实现闪烁?因为 HAL_Delay 是阻塞式的,程序在延时期间干不了别的事。而定时器中断是异步的,主循环该干嘛干嘛,到时间了硬件自动跳进中断服务函数处理一下,处理完再回到主循环,这种"后台时间片"的模式才是做嵌入式项目该有的思路。
这里直接预告一个坑:如果你让定时器每 1ms 中断一次,然后在中断里直接翻转 LED,LED 会以 500Hz 的频率翻转,人眼根本看不出闪烁,看到的只是一个半亮状态的灯。所以这个 Demo 里我们不能每 1ms 翻转一次,而是要在中断里做个计数,累计到一定次数再翻转,这样 LED 才能肉眼可见地闪起来。这个细节我后面写代码的时候会专门讲。
2. 动手前的准备:环境、接线与思路
代码写起来其实不多,但环境准备和思路理清是必须的一步。我把这部分放在前面,省得你跟着教程走到一半发现工具链没配置好,然后卡壳。
2.1 软硬件清单
硬件方面,我用的是最常见的 STM32F103C8T6 最小系统板,也就是大家常说的"蓝板"。这个板子性价比极高,网上十几二十块就能买到,引脚全部引出,非常适合入门。调试器用一个 ST-Link V2,十几块的国产货就够用。
硬件清单如下:
- STM32F103C8T6 最小系统板一块
- ST-Link V2 调试器一个
- 面包板一块、杜邦线若干
- 5mm LED 一个、220Ω 或 1kΩ 电阻一个
- 数据线(USB 转 micro USB,给板子供电)
软件方面,需要安装 STM32CubeMX、Keil MDK-ARM、ST-Link 驱动这三个东西。CubeMX 从 ST 官网下载安装就能用,Java 环境新版已经内置了,不用额外配置。这里顺便说一句,网上很多人纠结 CubeMX 的汉化问题,我的建议是没必要折腾,界面就那么几个关键区域,英文反而更容易和教程、手册对应起来,而且汉化只是界面语言变了,配置流程没有任何区别。
2.2 开发板接线与原理
LED 的接法很简单:用杜邦线把 PA5 引脚接到面包板上,串一个电阻,再接到 LED 阳极,LED 阴极接地。原理图就是下面这个逻辑。
| PA5 引脚 | 电阻 | LED | 接地 |
|---|---|---|---|
| 输出高电平时 | 限流 | 点亮 | 低电平 |
电阻用 220Ω 到 1kΩ 之间都行。以红色 LED 为例,正向压降大概 1.8V,工作在 5mA 左右,如果供电是 3.3V,电阻按 (3.3V - 1.8V) / 5mA ≈ 300Ω 选,我习惯用 470Ω,亮度适中,稳定性也够。为什么不直接用板载的 PC13 LED?因为很多最小系统板上 PC13 的 LED 是低电平点亮,逻辑和常规推挽输出相反,新手容易搞混,用 PA5 外接一个 LED 可以避开这个坑。
2.3 Demo 整体运行流程
整个程序跑起来的流程是这样的:系统上电后,CubeMX 生成的 SystemClock_Config 先把系统时钟配到 72MHz,然后 MX_GPIO_Init 把 PA5 初始化成推挽输出、初始电平为低,接着 MX_TIM1_Init 把 TIM1 配成 1ms 中断周期,NVIC 配好中断优先级。我们在 main 函数里调用 HAL_TIM_Base_Start_IT 启动定时器,之后 TIM1 每 1ms 触发一次更新中断,中断服务函数内部调用 HAL_TIM_IRQHandler,再分发到 HAL_TIM_PeriodElapsedCallback 回调函数。在回调函数里用 static 变量做计数,计到 500 次也就是 500ms,翻转一次 PA5,LED 就按 1Hz 频率闪烁。
需要说明的是,主循环 while(1) 里我们可以什么都不干,或者只放一个空的循环。真实项目里主循环可以处理按键、通信、显示这些任务,定时器中断负责时间相关的逻辑,两者互不干扰。这就是中断带来的"并行"能力。
3. 5分钟实战:CubeMX 配置全流程
这一节是整个教程的核心操作部分。我按照实际操作的顺序一步一步来,每一步都会说明为什么这么配,避免你只是机械地点鼠标。
3.1 新建工程与芯片选型
打开 CubeMX,在主页选择 "Access to MCU Selector",进入芯片选型界面。在左侧搜索框输入 STM32F103C8,在 MCU List 里找到 STM32F103C8Tx 这一项,双击进入工程配置界面。
选芯片的时候注意一个小细节:F103C8T6 是 48 脚的 LQFP 封装,Flash 容量是 64KB,属于中等容量产品,MCU List 里显示的型号是很具体的。如果你用的板子是 C6T6 或者 CBT6,选型逻辑是一样的,只要封装和 Flash 容量对应上就行。
进入工程界面后,右侧是芯片引脚图,左侧是外设列表。这个界面里你先不用急着点引脚,按照下面几个章节的顺序来配置就好。
3.2 时钟树配置:72MHz 这个参数为什么关键
点开 "System Core -> RCC",在右侧的 HSE 选项里选择 "Crystal/Ceramic Resonator",表示使用外部晶振作为高速时钟源。这是最常用也是精度最高的做法,因为外部晶振比芯片内部 RC 振荡器稳定得多,USB 通信、CAN 通信都依赖它。
然后切到 "Clock Configuration" 标签页,这就是时钟树配置界面。先看一眼HSE的值,F103C8T6 最小系统板上一般焊接的是 8MHz 晶振,如果你的板子是 12MHz 晶振,这里要填 12,不然后面所有时间参数全都会偏。确认 HSE 是 8MHz 后,把PLL Source选为 HSE,PLL Multiplier (x)设为 9,这样 PLL 输出就是 72MHz,在系统时钟源System Clock Mux里选择 PLLCLK,SYSCLK 就变成 72MHz。
最关键的一步来了,看 APB1 和 APB2 的预分频器设置。APB1 最大只能跑 36MHz,所以要设成 /2,APB2 可以跑 72MHz,设成 /1。这里有一个绝大多数人都会忽略的规则:APB1 上的定时器时钟是 APB1 时钟的 2 倍,也就是说 APB1 分频后是 36MHz,但挂在 APB1 上的 TIM2/3/4/5 实际时钟是 72MHz。APB2 分频系数是 1,所以挂在 APB2 上的 TIM1 时钟直接就是 72MHz。这个 72MHz 就是后面计算定时周期的关键参数。
3.3 配置 TIM1 定时器:PSC 和 ARR 的计算方法
在左侧外设列表里找到 "Timers -> TIM1",点进去。在 "TIM1 Mode and Configuration" 页面,先看 "Mode" 标签,把Clock Source设置为Internal Clock,表示使用内部时钟源,也就是刚才时钟树里确定的 72MHz。
然后切到 "Configuration -> Parameter Settings",这里有三个关键参数:
| 参数 | 配置值 | 说明 |
|---|---|---|
| Prescaler (PSC) | 71 | 预分频系数 |
| Counter Mode | Up | 向上计数模式 |
| Counter Period (ARR) | 999 | 自动重装载值 |
| Auto-reload preload | Enable | 使能自动重装载预装载 |
| auto-reload preload | Enable | 使能自动重装载预装载 |
为什么 PSC 取 71,ARR 取 999?这里有一个核心公式:
定时器中断频率 = 定时器时钟 / (PSC + 1) / (ARR + 1)
代入数值:72MHz / (71 + 1) / (999 + 1) = 72MHz / 72 / 1000 = 1kHz,也就是 1ms 触发一次中断。PSC 的作用是把 72MHz 分频到 1MHz,也就是 1 微秒计数一次,ARR 再计 1000 个数到溢出,合起来就是 1 毫秒。这样理解起来就直观了:先分频到你要的时间粒度,再计多少个粒度到你要的周期。
这个公式一定要吃透,因为换任何定时器、任何周期都是这么算的。我整理了一个常用配置对照表,同样在 72MHz 时钟下:
| 目标周期 | PSC | ARR | 计算过程 |
|---|---|---|---|
| 1us | 71 | 0 | 72MHz / 72 / 1 = 1MHz |
| 1ms | 71 | 999 | 72MHz / 72 / 1000 = 1kHz |
| 10ms | 71 | 9999 | 72MHz / 72 / 10000 = 100Hz |
| 100ms | 719 | 9999 | 72MHz / 720 / 10000 = 10Hz |
| 1s | 7199 | 9999 | 72MHz / 7200 / 10000 = 1Hz |
注意,这里的 ARR 和 PSC 在 16 位定时器里最大只能到 65535,如果要配很长的周期,优先加大 PSC,让计数粒度粗一点,而不是硬拉 ARR。
在 "NVIC Settings" 标签页里,勾选TIM1 update interrupt的 Enabled 复选框,这步很容易漏掉。如果不勾,CubeMX 不会生成 TIM1_UP_IRQHandler 中断服务函数,后面定时器溢出时根本进不了中断。
3.4 配置 LED 引脚 GPIO
在左侧外设列表或者直接在右侧芯片引脚图上,找到 PA5,点击后选择 "GPIO_Output"。然后点开左侧 "System Core -> GPIO",在 GPIO 配置页面里可以看到 PA5 的详细参数。
| 参数 | 配置值 |
|---|---|
| GPIO output level | Low |
| GPIO mode | Output Push Pull |
| Pull-up/Pull-down | No pull-up and no pull-down |
| Maximum output speed | Low |
GPIO output level 设成 Low 的意图很明确:上电瞬间 PA5 是低电平,LED 不亮,等程序初始化完、中断开启、计数到 500 次时才第一次翻转变成高电平,LED 点亮。这样可以避免上电瞬间乱闪。
Maximum output speed 选 Low 就够,LED 闪烁对翻转速率要求极低,没必要开到 High 去增加功耗和噪声。这个参数主要是给高速通信引脚用的。
这里也可以给 PA5 加一个 User Label,比如填 "LED",这样生成的代码里会用宏定义替代硬编码的 GPIO_PIN_5,代码可读性会好很多。
3.5 生成代码与工程组织
上面都配好后,点菜单 "Project -> Generate Code",第一次会让填工程名和路径。Project Name 填 LedTimerDemo,Toolchain 选 MDK-ARM,Toolchain version 选 V5,其他保持默认,点 Generate。
生成后 CubeMX 会创建一个完整的工程目录,核心文件有这么几个:
Core/Src/main.c:主函数,用户代码区Core/Src/tim.c:TIM1 初始化函数 MX_TIM1_InitCore/Src/gpio.c:GPIO 初始化函数 MX_GPIO_InitCore/Src/stm32f1xx_it.c:中断服务函数Core/Inc/main.h:头文件和用户标签宏定义
用 Keil 打开项目里的.uvprojx工程文件前,我先提醒一句:CubeMX 生成的代码是可以反复再生成的,但你手动修改过的代码只应该写在 USER CODE 注释区间内。main.c 里有各种USER CODE BEGIN和USER CODE END注释,这些区域之间的内容是 CubeMX 下次生成时保留的区域,而区间外的内容会被覆盖。所有自己写的逻辑,启动代码、回调函数、自定义变量,都要放在这些标记内。
4. 核心代码解析:真正跑起来的部分
代码生成之后,严格来说 Demo 还没跑起来,因为你还没有"启动"定时器中断,也没有写中断里要执行的逻辑。这一节专门说清楚代码要补在哪、为什么要这样补。
4.1 启动定时器中断的几行代码
打开Core/Src/main.c,先看 main 函数里的结构。CubeMX 生成的 main 函数依次调用了 HAL_Init、SystemClock_Config、MX_GPIO_Init、MX_TIM1_Init 这些初始化函数。注意,初始化函数只是配置了定时器的寄存器,并没有启动定时器,Timer 的计数器这时候还是静止状态。
需要我们在USER CODE BEGIN 2区域添加一行:
/* USER CODE BEGIN 2 */ HAL_TIM_Base_Start_IT(&htim1); /* USER CODE END 2 */这行代码的作用是启动 TIM1 的计数功能,并使能更新中断。为什么必须加这行?HAL 库把 "初始化" 和 "启动" 分成了两个阶段:MX_TIM1_Init 只是设置寄存器参数,相当于把闹钟调好了但还没上发条;HAL_TIM_Base_Start_IT 才真正让闹钟走起来,并且让闹钟响铃时能触发中断。
和它对应的还有一个 HAL_TIM_Base_Start,不带 IT 后缀,这种模式只启动定时器但不开中断,适用于查询溢位标志位的轮询方式。初学者最容易犯的错误是只调 HAL_TIM_Base_Start 或者干脆什么都不调,结果程序跑起来毫无反应。
4.2 __weak 回调函数机制
定时器中断的"脏活累活"其实已经被 HAL 库做好了:TIM1 溢出时,硬件会触发 TIM1_UP_IRQHandler 中断服务函数,这个函数在 stm32f1xx_it.c 里已经生成好,内部调用HAL_TIM_IRQHandler(&htim1),HAL 库在中断处理函数里判断确实是更新事件后,会调用一个回调函数HAL_TIM_PeriodElapsedCallback。
关键点来了:HAL 库里给这个回调函数加了__weak修饰符,也就是"弱定义"。库里面有一个默认的弱实现,函数体是空的,但允许你在自己的代码里重新定义一个同名同参的"强定义"函数,链接时强定义会覆盖弱定义。
这意味着,你不用修改 HAL 库的任何源码,只需要在自己的文件里写出完整版的 HAL_TIM_PeriodElapsedCallback,这个回调就会被系统"劫持"。真正的用户逻辑就写在这里面。注意回调函数的命名不能写错,Ben 的 B 字母大写,是 Period 不是 Periodic,很多人在这上面栽过跟头,编译时报错找不到函数声明,或者一直没反应。
4.3 LED 闪烁逻辑:每 1ms 做一次计数,500ms 翻转一次
现在就到了写核心逻辑的时候。理论上回调函数每 1ms 被调用一次,如果你在回调里直接写HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin),LED 会以 500Hz 频率翻转,人眼完全看不出来,只会觉得灯变暗了。所以正确做法是加一个计数器变量,累计到 500 次再翻转。
在USER CODE BEGIN 4区域添加回调函数:
/* USER CODE BEGIN 4 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { static uint16_t ledCounter = 0; if (htim->Instance == TIM1) { ledCounter++; if (ledCounter >= 500) { ledCounter = 0; HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); } } } /* USER CODE END 4 */为什么用 static 变量?因为 ledCounter 需要在多次中断调用之间保持值不变,如果声明在函数内部且不加 static,每次函数退出值就丢了;如果放成全局变量,会污染命名空间。static 局部变量在程序启动时只初始化一次,而且只能在当前 .c 文件内部访问,是最合适的方案。
为什么要判断htim->Instance == TIM1?因为回调函数是全局的,如果工程里同时使用了 TIM1、TIM2 等多个定时器,它们的中断最终都会进到同一个回调函数。通过判断句柄里的 Instance 字段,就可以区分当前中断来自哪个定时器,做到互不干扰。在这个 Demo 里虽然只有一个定时器,但这种写法是工程上必须养成的习惯。
每次进回调 ledCounter 加 1,加到 500 说明过了 500ms,翻转一次 LED,清计数器,循环往复。最终 LED 亮 500ms、灭 500ms,周期正好 1Hz,肉眼看起来就是"滴答滴答"的呼吸感和闪烁感。如果想改成亮 1 秒、灭 1 秒,把阈值从 500 改成 1000 就行。
那段HAL_GPIO_TogglePin是直接翻转引脚电平的快捷函数,比先读 ODR 寄存器再写 ODR 要简洁很多,HAL 库在 GPIO 这层做了很好的封装,直接用即可。
4.4 编译烧录与验证
用 Keil 打开工程后,先把编译生成目标改成"Use Linker Script"或者直接默认,在魔术棒(Options for Target)里:
- Device 标签页确认芯片是 STM32F103C8
- Debug 标签页选择 ST-Link Debugger,点 Settings,确认能够识别到 SW Device
- Flash Download 标签页勾选 "Reset and Run"
- 右上角编译按钮旁边的下拉框选 "STM32F103C8" 对应 Flash 大小(一般是默认)
接线的时候,ST-Link 和板子的 SWD 接口只需要接四根线:SWDIO、SWCLK、GND、3.3V。接反或者松了都会导致找不到芯片,这是下载问题里最高发的原因。
烧录之后,如果一切正常,LED 会以大约 1Hz 的频率闪烁。如果没反应,先别急着怀疑代码,按顺序排查:PA5 是否接对了杜邦线?LED 极性有没有焊反?ST-Link 驱动是否正常?这些低级错误在嵌入式调试里发生的概率比想象中高得多。
5. 踩坑实录与排查技巧
这一节是我自己这些年做 STM32 项目过程中实实在在踩过的坑,比代码逻辑本身更有价值。我按问题的发生频率从高到低整理了一遍,每个问题都给出排查思路。
5.1 定时器中断不触发,该从哪查起
这是新手遇到最多的问题。现象是程序跑起来,主循环正常执行,但 LED 不闪,进了调试器打断点发现中断服务函数根本没到。
排查顺序可以按下面这个来:
- 检查 HAL_TIM_Base_Start_IT 是否调用。这是第一位的。很多人初始化完定时器忘了启动,或者用的 Hal_TIM_Base_Start 看不到效果,因为查询模式下没有回调。
- 检查 CubeMX 里 NVIC 是否正确勾选。如果只配了 TIM1 的 Mode 参数,忘了在 NVIC Settings 里勾选 TIM1 update interrupt,CubeMX 生成代码时是不会生成 TIM1_UP_IRQHandler 的。
- 检查回调函数是否真的被覆盖了。如果回调函数的拼写有误,比如把 PeriodElapsed 写成 PeriodicElapsed,编译器会认为你在定义一个新函数,HAL 库里的弱回调函数仍然是空的,中断进来了但什么都不干。
- 检查回调函数里有没有死循环。比如在中断里调用了 printf,而 printf 依赖串口且串口没初始化好,程序会卡死在中断里,表现就是 LED 不闪但程序也不死。
5.2 周期不准,时间不对
如果 LED 闪烁的频率跟计算值对不上,比如配 500ms 结果实际是 750ms,问题八成出在时钟源上。
最常见的是晶振频率配错。CubeMX 默认可能认为外部晶振是 25MHz 或者 8MHz,但你的板子实际焊接的是 12MHz 或者 8MHz,时钟树里 HSE 速度没改,整个系统时钟就全偏了。有个小技巧:用调试器查看 SystemClock_Config 函数里设置的 RCC_OscInitStruct 结构体,看 HSEValue 字段是不是和你板子晶振一致。
第二个常见原因是APB 预分频设置影响了定时器时钟。按照前面说的规则,如果 APB1 设成 /2,定时器时钟是 APB1 的 2 倍,这个细节忘了的话,你算出来的周期就会比自己预期的少一半或者慢一半。不同系列的规则不完全一样,有的芯片没有定时器倍频逻辑,所以拿一个例子套所有芯片是行不通的。
第三个原因是 PSC 和 ARR 填反了。PSC 填了 999,ARR 填了 71,看似乘积一样,但分频粒度和溢出计数的逻辑就完全反了,虽然最终周期可能一样,后期想做捕获比较时会非常别扭。
5.3 下载失败与烧录问题
Keil 报 "No Target Connected" 或者 "RDDI-DAP Error" 这类错误时,硬件问题的权重比较大。
- ST-Link 的 SWDIO/SWCLK 是否接反,这是最常见的低级错误。有的杜邦线颜色一样,接的时候容易搞混,最好用万用表量一下。
- ST-Link 的 3.3V 有没有输出,ST-Link V2 从 USB 取电,一般能输出 3.3V 给目标板供电,但有的山寨版在功率不足时可能供电不稳。
- 如果芯片之前烧过别的程序,把 SWD 引脚复用成了普通 GPIO,也会导致无法连接。这时候需要把 BOOT0 拉高进 ISP 模式,重新上电后用 STM32CubeProgrammer 擦除整个 Flash,再拉回 BOOT0 就能正常下载了。
- Keil 的 Debug 界面里,ST-Link Debugger 的 Port 要选 SW,而不是 JTAG,速度尽量调低到 1MHz 左右会更稳。
5.4 常见问题速查表
| 症状 | 可能原因 | 快速处理 |
|---|---|---|
| LED 完全不闪 | 没调 HAL_TIM_Base_Start_IT 或 NVIC 没勾 | 补启动代码,重新生成工程 |
| LED 常亮或半亮 | 回调里直接翻转,频率太高 | 加计数器阈值,不要每次中断都翻转 |
| LED 闪烁频率不对 | 晶振频率设错或 APB 分频忘了定时器倍频 | 核对时钟树参数,用调试器看 RCC 配置 |
| 进中断就卡死 | 中断里调用 printf/delay 等阻塞函数 | 中断里只做置标志位或简单操作 |
| 编译报找不到回调函数声明 | 函数名拼写错误 | 对照 HAL 库函数名复制粘贴 |
| 下载时报 No Target | ST-Link 接线或驱动问题 | 重新插拔、检测 SWD 线序 |
还有一个我不太会写在纸上面但实际很重要的经验:回调函数里尽量只做置标志位、计数、翻转 IO 这类极短的操作,千万不要在里面调用 HAL_Delay 或者 printf。中断里面的时间非常宝贵,执行时间过长会导致主循环饿死,如果两个中断互相抢占还可能造成死锁。这个习惯越早建立越好。
6. 后续还能怎么玩
Demo 跑通之后,这只是一个起点。TIM1 定时中断可以扩展出很多玩法,我按从易到难的顺序给你列几个方向,都是我在实际项目中验证过的思路。
6.1 让闪烁时间随心变
最简单的扩展是改周期。把 CubeMX 里 PSC 和 ARR 改一下,重新生成代码,闪烁节奏就变了。我这里推荐一个通用做法:在改参数之前,先用铅笔在纸上写一下公式:定时器时钟 / (PSC+1) / (ARR+1) = 期望频率,再反推 PSC 和 ARR 的取值。算好再填,不要凭感觉乱填。
也可以不改定时器参数,只改回调里的计数阈值。比如阈值 500 是 500ms,改成 1000 就是 1 秒,改成 50 就是 50ms,这个灵活度比改定时器寄存器高得多,适合在程序运行中动态调整闪烁频率。
6.2 多个定时器协作的实用场景
TIM1 做 1ms 时基,TIM2 做 PWM 输出,TIM3 做输入捕获解码编码器信号,这是非常经典的组合。CubeMX 里配好几个定时器后,回调函数里通过htim->Instance判断来源,就可以分别处理不同定时器的事件。
这种做法在项目里最常见的场景是:一个定时器提供 1ms 系统时钟,一个定时器输出呼吸灯 PWM,一个定时器定时启动 ADC 采集传感器数据。代码结构清晰,每个外设负责自己的一摊事,互不干扰。
6.3 中断优先级与资源协调
虽然裸机 Demo 里中断优先级看起来不重要,但如果你把系统从裸机往 FreeRTOS 上迁移,或者同时开了串口、ADC、PWM 多个中断,优先级设计就成了绕不开的问题。
我的经验是:需求明确前,中断优先级可以先统一设成默认,但工程结构上一定要保留 CubeMX 生成的 NVIC 配置入口,别手动去寄存器里乱改。等系统复杂到一定程度,再在 CubeMX 的 NVIC 界面里按"时效性高、处理时间短"的原则调整优先级,让定时器这种时间敏感的中断优先级高一些,而串口接收这种排队容忍度高的中断优先级低一些。这样动态调整能力就掌握了。
多说一句,把中断处理函数写短这个习惯,越早养成越好。你自己想想看,如果 1ms 中断一次,回调函数里代码执行超过 1ms,那下一个中断就来了,要么丢中断,要么嵌套混乱,程序跑起来时好时坏,排查起来极其痛苦。
我个人的体会是,CubeMX 最大的价值不是帮你省了敲代码的时间,而是逼着你在图形界面里把外设配置、时钟树、引脚复用全部想清楚。以前我用标准库,经常是抄一段定时器初始化代码就跑,完全不理解为什么设置这个值。现在每配置一个外设,都要在图形界面里面对所有选项,逼着自己去弄懂每个参数的含义,反而把基础打得扎实了。这个项目做完之后,建议你在同一个 .ioc 文件上继续改,把 PWM、输入捕获、编码器模式都试一遍,你会发现 STM32 定时器这部分的底层逻辑是相通的,一通百通。