1. 为什么选择定时器中断作为STM32入门的第一个实战项目
STM32F103C8T6这颗芯片,搞嵌入式的基本没有不知道的。LQFP48封装,72MHz主频,64KB Flash,20KB SRAM,两个高级定时器、四个通用定时器、两个基本定时器,资源给得相当大方。但很多人拿到最小系统板之后,点个灯就跑,跑完就不知道该干嘛了。我见过太多人卡在“下一步学什么”这个坎上。
定时器中断是我个人认为最值得作为第二个实战项目的内容。原因很简单:它承上启下。往上,它连接着GPIO操作、时钟树配置、NVIC中断管理;往下,它是PWM输出、输入捕获、编码器接口、DMA触发这些高级功能的基础。你把定时器中断吃透了,后面学PWM就是换个配置的事,学输入捕获也是同样的框架。
而且定时器中断有个特别实在的好处——它让你第一次真正感受到“单片机在后台自己干活”这件事。之前点灯,你写个while(1)里面翻转GPIO,CPU全程在那儿空转。但定时器中断不一样,你配置好之后,主循环该干嘛干嘛,时间到了中断自动触发,执行完中断服务函数再回到主循环。这种“并行”的感觉,是嵌入式开发思维的一次重要跃迁。
Proteus仿真在这个环节的价值也值得说一说。很多人觉得仿真没用,不如直接上硬件。但我的经验是,定时器中断这种涉及精确时序的内容,仿真反而比硬件更方便调试。你可以在Proteus里直接看到引脚电平的变化,可以精确测量中断周期,不用示波器就能验证定时器配置对不对。特别是对于手头没有示波器的初学者,Proteus的虚拟示波器简直是救命稻草。
这篇文章我会从定时器的基本原理讲起,然后一步步带你完成Proteus仿真电路搭建、Keil代码编写、联调验证,最后把我这些年踩过的坑和总结的技巧都倒出来。不管你是刚学完点灯的新手,还是想复习一下定时器配置的老手,应该都能找到有用的东西。
2. STM32F103C8T6定时器资源全解析
2.1 定时器家族成员与各自分工
STM32F103C8T6内部一共有7个定时器,很多人用了很久也分不清它们之间的区别。我先把这张表列出来,你一看就明白了:
| 定时器 | 类型 | 位数 | 计数方向 | 通道数 | 特殊功能 |
|---|---|---|---|---|---|
| TIM1 | 高级 | 16位 | 向上/向下/中央对齐 | 4 | 死区控制、刹车输入、互补输出 |
| TIM2 | 通用 | 16位 | 向上/向下/中央对齐 | 4 | 输入捕获、输出比较、PWM |
| TIM3 | 通用 | 16位 | 向上/向下/中央对齐 | 4 | 同TIM2 |
| TIM4 | 通用 | 16位 | 向上/向下/中央对齐 | 4 | 同TIM2 |
| TIM5 | 通用 | 16位 | 向上/向下/中央对齐 | 4 | 同TIM2 |
| TIM6 | 基本 | 16位 | 向上 | 0 | 仅定时中断、DAC触发 |
| TIM7 | 基本 | 16位 | 向上 | 0 | 同TIM6 |
对于定时器中断这个需求,其实用哪个都行。但如果你后面还想扩展PWM输出或者输入捕获,建议直接用TIM2、TIM3或TIM4,这样不用换定时器重新配置。我一般习惯用TIM2,因为它挂在APB1总线上,时钟配置相对简单,而且引脚PA0-PA3正好在最小系统板的一侧,接线方便。
TIM1是高级定时器,功能最强但也最复杂,它挂在APB2总线上,时钟频率和APB1不一样。如果你只是做定时中断,没必要用TIM1,杀鸡用牛刀反而容易出问题。TIM6和TIM7是基本定时器,功能最纯粹,只有计数和中断,没有引脚输出。如果你纯粹只需要一个定时中断,用TIM6或TIM7反而最省事,配置项最少。
2.2 时钟树与定时器频率的关系
这是很多人第一个卡住的地方:定时器的时钟到底是多少?为什么我算出来的时间和实际不一样?
STM32F103C8T6的标准配置是外部8MHz晶振,经过PLL倍频到72MHz作为系统时钟。但定时器的时钟并不是直接等于72MHz,这里有个APB预分频器的关系。
系统时钟72MHz出来之后,经过AHB预分频器(通常不分频,72MHz),然后分给APB1和APB2。APB1的最高频率是36MHz,APB2是72MHz。关键来了:当APB预分频系数不为1时,定时器的时钟会是APB时钟的2倍。
具体来说,标准配置下APB1预分频系数为2,所以APB1时钟是36MHz,但TIM2、TIM3、TIM4的时钟是72MHz。APB2预分频系数为1,所以APB2时钟是72MHz,TIM1的时钟也是72MHz。
这个“倍频”机制是STM32的一个特色,很多人看时钟树图的时候会忽略这个细节。你只要记住:在标准72MHz配置下,所有通用定时器和高级定时器的时钟都是72MHz,基本定时器TIM6和TIM7挂在APB1上,时钟也是72MHz。
2.3 定时时间的计算方法
定时器本质上就是一个计数器,它从0开始数数,数到我们设定的值(ARR寄存器的值)就产生一个事件。数数的速度由时钟频率和预分频器(PSC寄存器)决定。
计算公式是这样的:
定时时间 = (PSC + 1) × (ARR + 1) / 定时器时钟频率
为什么都要加1?因为PSC和ARR都是从0开始计数的。PSC=0表示不分频,PSC=71表示72分频。ARR=0表示计数1次就溢出,ARR=999表示计数1000次溢出。
举个例子,我们要定时1ms,定时器时钟72MHz:
- 先确定PSC:72MHz / 72 = 1MHz,所以PSC=71
- 再确定ARR:1MHz下,1ms需要计数1000次,所以ARR=999
代入公式验证:(71+1) × (999+1) / 72000000 = 72 × 1000 / 72000000 = 0.001秒 = 1ms。没问题。
这里有个经验:PSC和ARR的分配要合理。如果ARR太小,中断太频繁,CPU开销大;如果PSC太大,ARR太小,定时精度会受影响。一般来说,让ARR在1000到65535之间比较合适,这样既有足够的精度,中断频率也不会太高。
3. Proteus仿真电路搭建与关键设置
3.1 元件选取与电路连接
Proteus里面仿真STM32F103C8T6,首先要在元件库里面找到这颗芯片。打开Proteus 8 Professional,点击左侧的“P”按钮,在搜索框输入“STM32F103C8T6”,一般能找到“STM32F103C8”这个型号。注意Proteus的元件库更新比较慢,有些版本可能只有STM32F103C8而没有带T6后缀的,选C8就行,仿真上没区别。
选好芯片放到原理图里面之后,需要连接几个关键部分:
电源部分:STM32F103C8T6的VDD引脚有多个(引脚9、24、36、48),全部接到+3.3V。VSS引脚(8、23、35、47)全部接地。VBAT(引脚1)可以接+3.3V也可以悬空,仿真里面悬空不影响。VDDA(引脚9)和VSSA(引脚8)是模拟电源,仿真里面直接接数字电源就行。
复位电路:NRST(引脚7)接一个10K上拉电阻到+3.3V,再接一个100nF电容到地。Proteus里面如果不接复位电路,芯片可能无法正常启动。
晶振电路:PD0和PD1(引脚5和6)接8MHz晶振,两端各接一个20pF电容到地。Proteus里面晶振的频率属性要设置成8MHz,否则时钟不对。
BOOT配置:BOOT0(引脚44)接10K下拉电阻到地,BOOT1(引脚20)也接下拉。这样芯片从主Flash启动,也就是从我们烧录的程序启动。
LED指示电路:我一般用PA0接一个LED到地,串联一个220Ω限流电阻。这样定时器中断里面翻转PA0,就能直观看到中断在运行。Proteus里面LED的驱动电流可以设置,默认的10mA就行,STM32的GPIO在推挽输出模式下可以驱动。
虚拟示波器:Proteus里面有个“OSCILLOSCOPE”元件,放在原理图里面,把PA0接到通道A,这样运行仿真的时候就能看到方波波形,直接读出周期。
3.2 关键设置:加载HEX文件
Proteus仿真STM32最关键的一步是加载编译好的HEX文件。双击STM32芯片,在弹出的属性对话框里面找到“Program File”一栏,点击文件夹图标,选择Keil编译生成的HEX文件。
这里有个坑:Keil默认不生成HEX文件,需要在项目选项里面勾选“Create HEX File”。具体路径是:Options for Target → Output → 勾选“Create HEX File”。如果你编译完在Objects文件夹里面找不到.hex文件,就是这个选项没勾。
还有一个坑:Proteus加载HEX文件之后,如果你在Keil里面重新编译了,Proteus不会自动重新加载。你需要手动在Proteus里面重新选择一次HEX文件,或者关闭仿真重新开始。我一般习惯在Keil里面改完代码编译通过之后,在Proteus里面点一下停止,再点开始,这样它会重新加载HEX。
另外,Proteus 8.15版本对STM32的仿真支持比较好,如果你用的是更老的版本,可能会遇到仿真跑不起来或者外设不工作的问题。建议至少用Proteus 8.9以上版本。
3.3 仿真速度与实时性的取舍
Proteus仿真STM32的时候,默认的仿真速度是“实时”模式,也就是尽量模拟真实的时间。但STM32跑72MHz,Proteus是软件模拟,不可能真的跑到72MHz。所以实际仿真速度会比真实硬件慢很多。
你可以在Proteus的“System”菜单里面找到“Set Animation Options”,调整“Animation Timestep”和“Simulation Speed”。但我不建议调太快,太快了虚拟示波器可能抓不到波形。一般用默认设置就行,虽然慢一点,但波形看得清楚。
如果你只是验证定时器中断的周期对不对,可以看虚拟示波器的测量值,那个是准的,不受仿真速度影响。但如果你要测中断响应时间这种微秒级的东西,Proteus就力不从心了,还是得上真实硬件加示波器。
4. Keil代码编写与定时器配置详解
4.1 标准库工程搭建
我这里的代码基于ST的标准外设库(Standard Peripheral Library),不是HAL库。为什么用标准库?因为标准库的寄存器操作更直观,适合理解底层原理。HAL库封装太厚,初学者容易知其然不知其所以然。而且Proteus对标准库的兼容性更好,HAL库有时候会有一些奇怪的仿真问题。
工程搭建的步骤:
- 新建Keil工程,选择STM32F103C8
- 添加启动文件startup_stm32f10x_md.s(中容量)
- 添加标准库文件:stm32f10x_gpio.c、stm32f10x_rcc.c、stm32f10x_tim.c、misc.c
- 添加头文件路径
- 在stm32f10x.h里面定义USE_STDPERIPH_DRIVER
- 在system_stm32f10x.c里面确认SYSCLK_FREQ_72MHz已定义
这些步骤看起来繁琐,但做一次之后后面就快了。我建议你把常用的标准库文件放在一个固定的文件夹里面,新建工程的时候直接复制,省得每次找。
4.2 定时器初始化代码逐行解析
先看完整的定时器初始化函数:
void TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructure.TIM_Period = 999; TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); TIM_Cmd(TIM2, ENABLE); }逐行拆解:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE):使能TIM2的时钟。STM32的外设默认时钟是关闭的,不使能时钟,后面所有配置都不会生效。这是新手最容易忘的一步,忘了这步,程序跑起来什么反应都没有。
TIM_Period = 999:这是ARR寄存器的值,决定计数到多少溢出。999表示计数1000次。
TIM_Prescaler = 71:这是PSC寄存器的值,决定预分频系数。71表示72分频。
TIM_ClockDivision = TIM_CKD_DIV1:时钟分割,这个参数只影响数字滤波器,不影响定时器时钟。一般填DIV1就行。
TIM_CounterMode = TIM_CounterMode_Up:向上计数模式,从0数到ARR,然后溢出重新从0开始。
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure):把上面的配置写入TIM2的寄存器。
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE):使能更新中断。更新中断就是计数器溢出时产生的中断。
NVIC配置:设置中断优先级。STM32F103的中断优先级分为抢占优先级和响应优先级。抢占优先级高的可以打断抢占优先级低的中断服务函数。这里设置抢占优先级0,响应优先级1。对于只有一个中断的简单应用,优先级随便设都行,但养成好习惯,重要的中断给高优先级。
TIM_Cmd(TIM2, ENABLE):启动定时器。这步之后,TIM2开始计数。
4.3 中断服务函数编写要点
中断服务函数的名字不能随便起,必须和启动文件里面的中断向量表名字一致。TIM2的中断服务函数名字是TIM2_IRQHandler,写在stm32f10x_it.c里面,或者你自己新建一个文件也行。
void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); GPIO_WriteBit(GPIOA, GPIO_Pin_0, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_0))); } }这个函数里面做了三件事:
检查中断标志:TIM_GetITStatus(TIM2, TIM_IT_Update)确认是更新中断触发的。虽然这个例子里只有更新中断,但养成检查标志的习惯,后面多个中断源的时候就不会出问题。
清除中断标志:TIM_ClearITPendingBit(TIM2, TIM_IT_Update)清除更新中断标志。这步非常关键,不清除的话,中断会一直触发,程序就卡在中断里面出不来了。我见过很多人忘了清标志,结果LED闪烁频率完全不对,查半天查不出来。
执行实际任务:翻转PA0引脚。这里用GPIO_ReadOutputDataBit读取当前输出状态,然后取反写回去。也可以用GPIO_SetBits和GPIO_ResetBits配合一个静态变量来实现,但读-改-写的方式更简洁。
4.4 主函数与整体流程
int main(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM2_Init(); while (1) { } }主函数里面先初始化GPIOA的时钟,然后配置PA0为推挽输出。注意GPIO的时钟挂在APB2上,而TIM2的时钟挂在APB1上,两个都要使能。
初始化完成之后,进入空的while(1)循环。所有的LED翻转都在中断里面完成,主循环什么都不用做。这就是中断的魅力——主程序解放了,可以去做其他事情。
5. 仿真验证与波形分析
5.1 运行仿真与观察现象
代码编译通过生成HEX文件之后,在Proteus里面加载HEX,点击左下角的运行按钮。如果一切正常,你应该能看到PA0上的LED开始闪烁,频率是500Hz——因为1ms中断一次,每次翻转,所以周期是2ms,频率500Hz。
用虚拟示波器观察PA0的波形,应该看到占空比50%的方波,周期2ms。如果周期不对,比如是1ms或者4ms,那就要检查PSC和ARR的计算。
我实测下来,Proteus 8.15仿真STM32F103C8T6的定时器中断,波形周期和理论计算值基本一致,误差在微秒级别。这个精度对于验证定时器配置足够了。
5.2 用示波器测量中断周期
Proteus的虚拟示波器有测量功能。把PA0接到通道A,运行仿真,在示波器界面点击“Measure”按钮,可以看到频率、周期、占空比等参数。我一般直接看频率,500Hz对应2ms周期,和理论值对比一下就知道对不对。
如果你想验证中断服务函数的执行时间,可以在中断里面翻转另一个引脚,用双通道示波器同时看两个引脚。两个上升沿之间的时间差就是中断响应时间。不过Proteus的仿真时间精度有限,这个方法只能看个大概。
5.3 常见仿真异常与处理
LED不亮:首先检查HEX文件加载了没有,然后检查GPIO配置对不对,再检查时钟使能了没有。Proteus里面如果芯片没有正常启动,所有引脚都是低电平。
波形频率不对:检查PSC和ARR的值,确认定时器时钟是72MHz。如果你用的是TIM1,检查APB2的预分频系数。
仿真跑不起来:检查Proteus版本,8.9以下对STM32支持不好。检查芯片型号选对了没有,STM32F103C8和C6的Flash大小不一样,但仿真上区别不大。
中断只触发一次:99%是忘了清除中断标志。检查TIM_ClearITPendingBit有没有调用。
6. 避坑指南:我踩过的那些坑
6.1 时钟配置的隐形陷阱
第一个大坑:SystemInit函数。Keil工程里面默认会调用SystemInit,这个函数在system_stm32f10x.c里面,负责配置系统时钟。如果你没有正确定义SYSCLK_FREQ_72MHz,系统时钟可能不是72MHz,那定时器的时钟也就不对了。
检查方法:在main函数开头调用RCC_GetClocksFreq获取系统时钟,通过串口打印出来看。或者更简单,在调试模式下面看RCC->CFGR寄存器的值。
还有一个坑:如果你在SystemInit之后又手动配置了RCC,可能会覆盖掉之前的配置。我一般建议要么全用SystemInit的默认配置,要么全手动配置,不要混着来。
6.2 中断优先级分组设置
STM32F103的中断优先级分为4位,可以分成不同的组。默认情况下是NVIC_PriorityGroup_0,也就是0位抢占优先级,4位响应优先级。但很多人不知道要设置优先级分组,直接用NVIC_Init配置,结果抢占优先级设了也没用。
正确的做法是在main函数开头调用NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2),设置为2位抢占优先级、2位响应优先级。这样抢占优先级可以设0-3,响应优先级也可以设0-3。
如果你只有一个中断,不设优先级分组也能跑。但一旦你有多个中断,优先级分组没设对,中断嵌套行为就会和你预期的不一样。
6.3 Proteus元件库的版本兼容问题
Proteus的元件库版本和软件版本是绑定的。如果你从别人那里拿到的Proteus工程打不开,提示缺少元件,大概率是元件库版本不一致。
解决办法:在Proteus里面点击“Library”菜单,选择“Library Manager”,看看有没有可以更新的元件库。或者直接在自己电脑上重新画一遍原理图,反正STM32最小系统的电路很简单,几分钟的事。
另外,Proteus 8.15的元件库里面STM32F103C8的模型和8.9版本有区别,8.15的模型更完善,支持的外设更多。如果你用8.9打开8.15的工程,可能会有一些奇怪的仿真问题。
6.4 HEX文件加载的常见错误
前面提过HEX文件加载的问题,这里再强调几个细节:
- Keil里面要勾选“Create HEX File”,否则Objects文件夹里面只有.axf文件,没有.hex文件
- Proteus里面加载HEX文件之后,如果修改了Keil代码重新编译,需要重新加载HEX文件
- HEX文件的路径最好不要有中文,Proteus对中文路径的支持不太好
- 如果Proteus提示“Unable to load HEX file”,检查文件是不是被其他程序占用了
6.5 定时器中断频率的实测偏差
理论上1ms中断一次,但实际测出来可能有微小偏差。这个偏差主要来自两个方面:
一是晶振频率偏差。Proteus里面的晶振是理想元件,频率精确。但真实硬件的晶振有±20ppm的偏差,8MHz晶振的实际频率可能是7.99984MHz到8.00016MHz之间。这个偏差会累积,长时间运行之后定时会偏。
二是中断响应延迟。从中断触发到中断服务函数第一条指令执行,需要经过压栈、取向量、跳转等过程,大概需要12个时钟周期。72MHz下就是约167ns。这个延迟在1ms的周期里面占0.017%,基本可以忽略。
如果你需要非常精确的定时,可以考虑用定时器的自动重装载特性,或者在中断里面补偿响应延迟。但对于大多数应用,1ms定时偏差几个微秒完全够用。
7. 从定时器中断延伸出去的实战方向
7.1 多定时器协同工作
一个定时器中断玩明白了,可以试试多个定时器同时工作。比如TIM2做1ms系统滴答,TIM3做10ms任务调度,TIM4做100ms状态检测。三个定时器各司其职,互不干扰。
多定时器协同的关键是中断优先级要分配好。1ms的定时器优先级最高,10ms次之,100ms最低。这样1ms中断可以打断10ms中断,保证系统滴答的准确性。
7.2 定时器中断触发ADC采样
这是热词里面提到的“定时器中断触发ADC”的典型应用。用定时器中断定期启动ADC转换,转换完成后再触发DMA搬运数据。这样CPU完全不参与,ADC以精确的固定频率采样,适合音频采集、振动分析等场景。
实现思路:TIM2配置为1kHz中断,中断里面调用ADC_SoftwareStartConvCmd启动ADC。ADC配置为外部触发模式,触发源选择TIM2的TRGO事件。这样定时器溢出时自动触发ADC,不需要CPU干预。
7.3 定时器中断实现软件PWM
如果GPIO的硬件PWM通道不够用,可以用定时器中断实现软件PWM。原理很简单:定时器以远高于PWM频率的速率中断,在中断里面维护一个计数器,计数器的值和占空比比较,决定GPIO输出高还是低。
比如要输出1kHz的PWM,定时器中断频率设为100kHz,也就是10us中断一次。中断里面计数器从0加到99,计数值小于占空比就输出高,否则输出低。这样就能在任意GPIO上实现PWM输出。
不过软件PWM会占用大量CPU时间,100kHz中断意味着CPU有相当一部分时间在处理中断。如果PWM通道不多,还是建议用硬件PWM。
7.4 低功耗模式下的定时器唤醒
STM32F103C8T6支持睡眠、停止、待机三种低功耗模式。在停止模式下,所有时钟都停止,功耗降到微安级别。但定时器可以在停止模式下继续运行,定时器中断可以把MCU从停止模式唤醒。
这个特性在电池供电的设备里面非常有用。比如你做一个温度记录仪,每分钟采集一次温度,其他时间MCU都在停止模式。定时器中断唤醒MCU,采集完温度再进入停止模式。平均功耗可以做到几十微安,一颗纽扣电池能用好几个月。
实现要点:停止模式下要用LSI或LSE作为定时器时钟,因为HSI和HSE都停了。TIM2的时钟源可以切换到LSI,但LSI的频率是40kHz左右,精度不高。如果需要精确唤醒,可以用LSE 32.768kHz。
8. 调试技巧与工具链优化
8.1 Keil调试模式下的定时器观察
Keil的调试模式可以实时查看TIM2的寄存器值。在调试模式下,打开“Peripherals”菜单,选择“System Viewer”里面的“TIM2”,可以看到CR1、SR、CNT、ARR、PSC等寄存器的实时值。
我最常用的两个观察点:一是CNT寄存器,看它是不是在正常计数;二是SR寄存器的UIF位,看更新中断标志有没有置位。如果CNT不动,说明定时器没启动或者时钟没使能。如果UIF一直置位不清除,说明中断标志清除有问题。
8.2 用GPIO翻转法测量中断执行时间
在中断服务函数开头翻转一个GPIO,在结尾再翻转一次,用示波器测量两个翻转之间的时间,就是中断服务函数的执行时间。
这个方法简单粗暴但非常有效。我实测过,一个只做GPIO翻转的中断服务函数,执行时间大概1-2us。如果中断服务函数里面做了浮点运算或者调用了库函数,执行时间可能到几十微秒。中断服务函数越短越好,这是嵌入式开发的基本原则。
8.3 标准库与HAL库的取舍
标准库和HAL库的争论一直没停过。我的看法是:学习阶段用标准库,理解底层原理;项目开发用HAL库或者LL库,开发效率高。
标准库的优点是代码直观,寄存器操作一目了然。缺点是ST已经不再维护了,新芯片不支持。HAL库的优点是跨系列兼容,ST主推,工具链完善。缺点是封装太厚,代码效率略低。
对于STM32F103C8T6这颗芯片,标准库和HAL库都支持。如果你只是做课程设计或者练手,标准库足够了。如果你要做产品,建议直接上HAL库,后面换芯片不用重写代码。
8.4 代码优化与中断响应速度
中断服务函数的执行时间直接影响系统的实时性。几个优化技巧:
- 中断服务函数里面不要用浮点运算,STM32F103没有硬件浮点单元,浮点运算靠软件模拟,非常慢
- 中断服务函数里面不要调用printf,串口输出是阻塞的,会严重拖慢中断响应
- 中断服务函数里面不要做延时,需要延时的话用计数器在多次中断里面累加
- 中断服务函数尽量短,复杂的处理放到主循环里面,中断里面只设置标志位
我见过有人在中断里面做FFT运算,结果整个系统卡死。中断服务函数的原则是:能不在中断里面做的事,就不在中断里面做。
9. 从仿真到硬件的过渡要点
9.1 最小系统板选购建议
从Proteus仿真过渡到真实硬件,你需要一块STM32F103C8T6最小系统板。市面上最小系统板的价格从十几块到几十块不等,主要区别在晶振、稳压芯片和USB转串口芯片。
我建议选带8MHz晶振和32.768kHz晶振的版本,两个晶振都有,后面做RTC实验不用再换板子。稳压芯片选AMS1117-3.3,这个最常用,输入5V输出3.3V。USB转串口芯片选CH340或者CP2102,用来烧录程序和串口调试。
9.2 程序烧录方式对比
STM32F103C8T6支持三种烧录方式:
串口烧录:用USB转TTL模块连接PA9和PA10,BOOT0接高电平,复位后进入Bootloader,用FlyMcu或者STM32CubeProgrammer烧录。这种方式最便宜,但每次烧录都要手动切换BOOT0,比较麻烦。
ST-Link烧录:用ST-Link调试器连接SWD接口(PA13和PA14),可以在Keil里面直接点下载按钮,支持在线调试。这是最推荐的方式,ST-Link克隆版只要十几块钱,但体验好太多。
DAPLink烧录:DAPLink是开源的调试器方案,支持SWD和JTAG,价格和ST-Link差不多。如果你用Keil MDK,ST-Link和DAPLink都支持。
9.3 硬件调试的常见问题
从仿真到硬件,最容易出问题的地方:
晶振不起振:检查晶振的负载电容是不是匹配,8MHz晶振一般配20pF。如果不起振,可以试着减小电容值,或者换一个晶振。
复位电路问题:NRST引脚的上拉电阻和电容值要合适,10K上拉加100nF电容是标准配置。如果复位电路有问题,芯片可能反复复位。
电源纹波:最小系统板的稳压芯片输出纹波要小,如果纹波太大,MCU可能工作不稳定。可以在3.3V和地之间加一个10uF钽电容和一个100nF陶瓷电容。
BOOT引脚配置:BOOT0和BOOT1的电平决定了启动模式。从Flash启动时,BOOT0=0,BOOT1任意。如果BOOT0悬空,芯片可能从系统存储器启动,程序就跑不起来。
9.4 仿真验证与硬件验证的差异
Proteus仿真和真实硬件有几个重要差异,你需要心里有数:
时序差异:Proteus是软件模拟,时序和真实硬件有偏差。特别是中断响应时间,Proteus里面可能比真实硬件快或者慢。
电气特性差异:Proteus不模拟GPIO的驱动能力、输入阻抗、上下拉电阻等电气特性。真实硬件里面,GPIO驱动LED需要限流电阻,Proteus里面不加电阻也能亮。
外设差异:Proteus对STM32外设的仿真支持有限,ADC、DAC、USB等外设在Proteus里面可能无法正常工作。定时器、GPIO、串口这些基本外设仿真没问题。
我的建议是:Proteus用来验证逻辑和基本时序,真实硬件用来验证电气特性和外设功能。两者结合使用,效率最高。
10. 个人实操心得与后续进阶建议
10.1 定时器中断配置的检查清单
每次配置定时器中断,我都会按这个清单过一遍:
- 时钟使能了吗?RCC_APB1PeriphClockCmd或RCC_APB2PeriphClockCmd
- PSC和ARR算对了吗?定时时间 = (PSC+1)×(ARR+1)/时钟频率
- 中断使能了吗?TIM_ITConfig
- NVIC配置了吗?NVIC_Init
- 中断服务函数名字对吗?TIM2_IRQHandler
- 中断标志清了吗?TIM_ClearITPendingBit
- 定时器启动了吗?TIM_Cmd
这七步任何一步漏了,定时器中断都不会正常工作。我刚开始学的时候,经常忘了第1步或者第6步,查半天查不出来。后来养成习惯,每次按清单检查,效率高很多。
10.2 从定时器中断到RTOS的过渡
定时器中断玩熟了之后,你可能会觉得裸机编程越来越力不从心。多个任务需要不同的时间间隔,任务之间有优先级,任务之间需要通信——这些都是RTOS擅长的事情。
从定时器中断到RTOS的过渡很自然。RTOS的系统滴答就是用一个定时器中断实现的,任务调度、延时、信号量、消息队列,底层都依赖定时器中断。你把定时器中断理解透了,学RTOS的时候就会觉得顺理成章。
我建议的进阶路线:定时器中断 → 多定时器协同 → 定时器触发ADC/DMA → 移植FreeRTOS → 基于FreeRTOS的多任务开发。每一步都建立在前一步的基础上,循序渐进。
10.3 我个人的几个小技巧
最后分享几个我平时用的小技巧:
用宏定义管理定时时间:不要直接在代码里面写999和71,用宏定义,比如#define TIM2_PERIOD 999,这样改定时时间的时候不用满代码找。
中断服务函数里面用static变量:如果需要在多次中断之间保持状态,用static变量,不要用全局变量。static变量作用域限于函数内部,不会和其他文件冲突。
用逻辑分析仪代替示波器:如果你有逻辑分析仪,看定时器中断的波形比示波器更方便。逻辑分析仪可以同时抓多个通道,还能解码协议。
Proteus里面加电压探针:在Proteus原理图里面放电压探针,运行仿真的时候可以看到引脚电压的实时值,比示波器更直观。
代码版本管理:从第一个STM32工程开始就用Git管理代码,每次调通一个功能就提交一次。后面代码出问题了,可以回退到上一个可用版本。我吃过没有版本管理的亏,代码改乱了想回退都回不去。
定时器中断这个内容,看起来简单,但真正吃透需要动手实践。你看十遍教程不如自己写一遍代码,调一遍仿真。遇到问题不要怕,一个一个排查,排查的过程就是学习的过程。我到现在调定时器中断,偶尔还会犯低级错误,但排查的速度比以前快多了,这就是经验的价值。