STM32F103寄存器实战:定时器中断与NVIC优先级配置详解
1. 为什么第七章值得单独拎出来讲搞STM32F103的人绕不开寄存器。库函数用久了会有一种错觉觉得配置外设就是调几个HAL_GPIO_Init的事。但真到了要抠时序、压中断延迟、或者排查一个“明明配置对了却不工作”的诡异问题时最后还是得回到寄存器手册里一行一行对。这套“寄存器白话手册”写到第七章正好进入了一个分水岭。前面几章大多在讲GPIO、时钟树、NVIC这些“基础设施”从第七章开始内容开始往定时器、中断优先级分组、事件与中断的联动这些真正影响系统行为的地方走。换句话说前六章是让你能把灯点亮第七章是让你能控制灯什么时候亮、亮多久、亮完之后干什么。我见过太多人卡在这个阶段GPIO会配了串口能打印了但一碰到定时器中断和主循环抢资源、或者两个中断嵌套时行为不符合预期就彻底懵了。问题往往不在代码写错了而在于对寄存器层面的使能顺序、标志位清除时机、优先级分组没有建立起清晰的模型。这一章的核心价值在于它把“配置”和“行为”之间的那层窗户纸捅破了。你不再只是往寄存器里写值而是开始理解每一个bit为什么在那个位置、写下去之后硬件会怎么反应、反应完之后状态怎么回读。适合已经能跑通基础例程、但想真正把芯片用明白的人。如果你还在纠结GPIO模式怎么选建议先回头补前几章如果你已经能独立完成串口收发那这一章就是下一步该啃的硬骨头。2. 第七章涉及的核心寄存器与设计逻辑2.1 从“配置思维”切换到“状态机思维”很多人看寄存器手册的方式是查表想实现某个功能翻到对应表格找到那一位置1完事。这种方式在简单外设上没问题但到了定时器和中断控制器这里就会翻车。原因很简单——这些外设本质上是状态机不是静态配置项。以STM32F103的通用定时器为例一个TIMx外设内部至少有这几个关键寄存器在联动寄存器作用容易踩的坑TIMx_CR1控制使能、计数方向、对齐模式使能位CEN的置位时机影响首次计数TIMx_DIER中断和DMA使能使能中断前必须先清标志TIMx_SR状态标志清除方式因标志类型而异TIMx_CNT当前计数值运行中读写需注意同步TIMx_PSC预分频写入后在下个更新事件生效TIMx_ARR自动重载值影子寄存器的存在导致写入不立即生效这张表里最容易被忽略的是最后两行。PSC和ARR都有影子寄存器你写进去的值不会立刻改变当前计数行为而是要等到下一个更新事件UEV才真正加载。这个设计是为了保证PWM输出的周期和占空比在边界处同步更新避免产生毛刺。但如果你不知道这件事就会遇到“我明明改了ARR怎么频率没变”的困惑。2.2 中断优先级分组一个被严重低估的配置项第七章另一个重头戏是NVIC的优先级管理。STM32F103的中断优先级用4个bit表示但这4个bit怎么分配成“抢占优先级”和“子优先级”是由SCB-AIRCR寄存器中的PRIGROUP字段决定的。这里有一个非常关键的认知优先级分组是全局的一旦设定所有中断都按这个规则解释。你不能给定时器中断设一种分组给串口中断设另一种。很多人在项目里混用不同库的初始化代码结果一个库把分组设成NVIC_PriorityGroup_2另一个设成NVIC_PriorityGroup_4最后中断行为完全乱套。分组的选择逻辑其实不复杂需要中断嵌套高优先级打断低优先级的场景抢占优先级要留够bit不需要嵌套、只排先后顺序的场景子优先级多分一点更灵活如果系统里中断源不多直接用Group_22bit抢占2bit子优先级是最稳妥的起点我个人的习惯是在main函数最开始、任何外设初始化之前先把分组定死并且写一行注释说明为什么选这个分组。后面所有NVIC配置都基于这个前提不再改动。2.3 事件与中断的区别不只是“要不要进ISR”第七章还会涉及EXTI和定时器的事件生成。这里有一个概念上的分水岭事件Event和中断Interrupt在寄存器层面是两条路。以EXTI为例当边沿检测电路捕获到信号后会同时产生一个“事件脉冲”和一个“中断请求”。事件脉冲可以直接连到其他外设比如触发ADC采样、启动定时器不需要CPU介入中断请求则送到NVIC等待CPU响应。对应的寄存器操作也不同要产生事件配置EXTI_EMR事件屏蔽寄存器要产生中断配置EXTI_IMR中断屏蔽寄存器两者都需要两个都配这个区别在实际项目里非常有用。比如你要做一个“外部信号触发ADC采集”的功能用事件路径可以做到零CPU延迟比中断里再启动ADC快得多。但如果你不知道EMR的存在就只会用中断白白浪费了硬件联动能力。3. 关键寄存器的逐位拆解与实操配置3.1 TIMx_CR1不只是使能位TIMx_CR1的完整位定义在手册里有详细表格这里只挑几个实际配置中必须理解的位来说。CENbit 0计数器使能。写1启动计数写0停止。但要注意停止时计数器不会自动清零CNT保持当前值。下次使能时从该值继续。如果需要每次启动都从0开始要先手动写CNT0。UDISbit 1更新禁止。这个位很多人不用但在需要精确控制更新事件时很有价值。置1后更新事件不会产生但计数器溢出时CNT和影子寄存器仍然会重载。可以用来在配置阶段避免不必要的更新中断。OPMbit 3单脉冲模式。置1后计数器在下一个更新事件时自动停止CEN被硬件清零。做精确单次延时或者单脉冲输出时非常方便省去了在中断里手动关定时器的代码。DIRbit 4计数方向。0为向上计数1为向下计数。在中心对齐模式下这个位由硬件管理不要手动改。CMSbit 6:5对齐模式。00为边沿对齐01/10/11为中央对齐的几种变体。PWM输出时边沿对齐和中央对齐的波形对称性不同电机控制里通常用中央对齐来减少谐波。配置示例向上计数、边沿对齐、使能更新中断// 假设TIM2时钟已使能 TIM2-CR1 0; // 先清零确保干净状态 TIM2-CR1 | (0 6); // CMS00边沿对齐 TIM2-CR1 | (0 4); // DIR0向上计数 TIM2-CR1 | (0 3); // OPM0非单脉冲 TIM2-CR1 | (0 1); // UDIS0允许更新事件 // CEN先不置位等PSC和ARR配好再启动注意直接对CR1整体赋值前最好先读一次再改避免把硬件已经设置的位覆盖掉。虽然复位后CR1初值已知但在运行中动态调整时读-改-写是更安全的做法。3.2 TIMx_PSC与TIMx_ARR频率计算的实际操作定时器频率的计算公式看起来简单计数频率 定时器时钟 / (PSC 1) 更新频率 计数频率 / (ARR 1)但实际配置时有几个细节PSC和ARR都是16位寄存器所以PSC1的范围是1到65536ARR1同理。这意味着单级分频最大只能到65536。如果需要更长的定时周期要么用级联定时器要么在中断里累加计数。写入顺序有讲究。PSC和ARR都有影子寄存器写入的值在下一个更新事件时才生效。如果你先写ARR再写PSC而更新事件在两者之间发生就会出现一个周期的异常频率。稳妥的做法是先停止计数器CEN0写PSC和ARR手动产生一个更新事件UG1清除更新标志启动计数器CEN1这样能保证PSC和ARR同时生效第一个周期就是正确的。计算实例假设TIM2挂在APB1总线上APB1时钟为36MHz但定时器时钟是APB1的2倍因为APB1预分频系数不为1所以TIM2时钟为72MHz。要产生1kHz的更新中断目标更新频率 1000Hz 计数频率 1000 * (ARR 1) 取ARR 999则计数频率 1000 * 1000 1MHz PSC 1 72MHz / 1MHz 72 PSC 71配置代码TIM2-PSC 71; // 72分频 TIM2-ARR 999; // 1000计数后更新 TIM2-EGR | (1 0); // UG1手动产生更新事件 TIM2-SR ~(1 0); // 清除UIF标志 TIM2-CR1 | (1 0); // CEN1启动实操心得ARR的值不要设成0。ARR0时计数器在每个时钟周期都产生更新事件中断频率等于计数频率CPU会被中断淹没。我见过有人想产生最高频中断结果系统直接卡死。3.3 TIMx_DIER与TIMx_SR中断使能与标志清除的配合DIER和SR是一对必须一起理解的寄存器。DIER控制哪些事件能产生中断SR记录哪些事件已经发生。DIER的关键位UIEbit 0更新中断使能CC1IE~CC4IEbit 1~4捕获/比较中断使能TIEbit 6触发中断使能UDEbit 8更新DMA请求使能SR的关键位UIFbit 0更新中断标志CC1IF~CC4IFbit 1~4捕获/比较标志TIFbit 6触发标志这里有一个必须遵守的顺序先清除SR中对应的标志位再在DIER中使能中断最后在NVIC中使能对应的中断通道如果顺序反了比如先使能了NVIC此时SR里恰好有一个未清除的标志CPU会立刻响应一次中断而这次中断可能发生在你的初始化代码还没跑完的时候导致不可预期的行为。清除标志的方式也要注意大部分标志是“写0清除”还是“写1清除”要查手册。STM32F103的TIMx_SR寄存器是“写0清除”rc_w0也就是说你要往对应位写0才能清掉它。但有些外设的标志是“写1清除”rc_w1。混用会导致标志清不掉或者误清其他标志。// 正确的清除方式读SR对要清的位写0其他位保持 TIM2-SR ~(1 0); // 清除UIF其他位不变 // 或者更直观的 TIM2-SR ~(1 0); // 读-改-写清除UIF注意TIM2-SR ~(1 0)这种写法会把其他所有位都写成1虽然对rc_w0类型的位写1无影响但如果寄存器里有rc_w1类型的位就会误清。所以读-改-写永远是最安全的。3.4 NVIC相关寄存器ISER、ICER、IPR的实际操作NVIC的寄存器在CMSIS里已经封装好了但了解底层地址和位操作有助于理解中断使能的本质。ISER中断使能寄存器写1使能对应中断。STM32F103有多个ISER每个管32个中断。比如TIM2中断号是28那么它在ISER[0]的bit 28。ICER中断清除使能寄存器写1禁止对应中断。IPR中断优先级寄存器每个中断占8个bit但STM32F103只用高4位。优先级数值越小优先级越高。// 使能TIM2中断中断号28 NVIC-ISER[0] (1 28); // 设置TIM2优先级为抢占1、子优先级0假设分组为Group_2 // IPR[28/4]的对应字节高4位是抢占低4位是子优先级 NVIC-IP[28] (1 4) | (0 0); // 抢占1子优先级0实操心得直接操作NVIC寄存器时ISER的写1使能是“置位”操作不是赋值。如果你写NVIC-ISER[0] (1 28)会把其他位清零导致其他中断被禁止。正确做法是NVIC-ISER[0] | (1 28)。这个坑我在早期项目里踩过调试了半天才发现是使能一个中断时把另一个中断关掉了。4. 完整配置流程与现场记录4.1 从零配置一个定时器中断的完整步骤下面以TIM3为例走一遍从时钟使能到中断响应的完整流程。假设目标是每500ms触发一次中断在中断里翻转一个GPIO。第一步使能TIM3时钟TIM3挂在APB1总线上需要使能RCC_APB1ENR的bit 1。RCC-APB1ENR | (1 1); // 使能TIM3时钟第二步配置NVIC优先级分组在系统初始化阶段完成这里假设已经设为Group_2。SCB-AIRCR (0x5FA 16) | (2 8); // 分组2需要写入密钥第三步配置TIM3的PSC和ARRTIM3时钟为72MHz目标500ms更新周期即2Hz。计数频率 2 * (ARR 1) 取ARR 9999计数频率 2 * 10000 20kHz PSC 1 72MHz / 20kHz 3600 PSC 3599TIM3-PSC 3599; TIM3-ARR 9999; TIM3-EGR | (1 0); // 产生更新事件加载PSC和ARR TIM3-SR ~(1 0); // 清除更新标志第四步使能更新中断TIM3-DIER | (1 0); // UIE1第五步配置NVICTIM3中断号是29。NVIC-IP[29] (1 4); // 抢占1子优先级0 NVIC-ISER[0] | (1 29); // 使能TIM3中断第六步启动定时器TIM3-CR1 | (1 0); // CEN1第七步编写中断服务函数void TIM3_IRQHandler(void) { if (TIM3-SR (1 0)) { // 检查UIF TIM3-SR ~(1 0); // 清除UIF // 翻转GPIO GPIOB-ODR ^ (1 0); } }注意中断服务函数里必须先检查标志再清除而且清除要在处理逻辑之前或之后根据需求决定。如果处理逻辑耗时较长建议先清除标志再处理避免处理期间又有新事件导致标志被覆盖。4.2 用示波器验证定时器行为配置完成后怎么确认定时器真的按预期工作光看LED闪烁不够精确。有条件的话用示波器测GPIO翻转波形。我实测下来上面配置产生的方波周期应该是1秒500ms高500ms低频率1Hz。如果测出来是2Hz说明中断里翻转了两次可能是标志没清干净导致重复进中断。如果周期偏差较大检查PSC和ARR的计算是否用错了时钟频率。还有一个快速验证方法在中断里对一个变量累加主循环里每秒钟打印一次该变量。如果每次打印值都是2说明500ms中断正常。这个方法不需要额外仪器但精度受主循环打印时机影响。4.3 事件触发ADC的配置实例前面提到事件路径可以硬件联动这里给一个具体配置。目标外部信号通过EXTI线0触发ADC1的规则通道转换。EXTI配置// 使能EXTI0事件不产生中断 EXTI-EMR | (1 0); // 事件屏蔽允许事件脉冲 EXTI-RTSR | (1 0); // 上升沿触发 // 不配置IMR不使能NVICADC配置// 使能ADC1时钟 RCC-APB2ENR | (1 9); // 配置ADC外部触发为EXTI线0 ADC1-CR2 | (1 20); // EXTTRIG1 ADC1-CR2 | (0 17); // EXTSEL000选择EXTI线0 ADC1-CR2 | (1 0); // ADON1开启ADC这样配置后PA0上的上升沿会直接触发ADC采样全程不需要CPU参与。采样完成后如果使能了EOC中断再进中断读数据即可。实操心得事件触发路径的延迟比中断路径短得多但调试起来更麻烦因为你看不到“事件发生了”这个动作。建议先用中断方式验证功能逻辑确认无误后再改成事件触发来优化性能。5. 常见问题与排查技巧实录5.1 中断进不去或者只进一次这是最常遇到的问题排查顺序如下现象可能原因排查方法完全不进中断NVIC未使能读NVIC-ISER确认对应位为1完全不进中断外设中断未使能读TIMx_DIER确认UIE1完全不进中断全局中断未开确认PRIMASK为0只进一次标志未清除读TIMx_SR确认UIF在ISR后为0只进一次中断被更高优先级持续抢占检查是否有更高优先级中断未清标志我遇到过一次“只进一次”的情况查了半天发现是中断服务函数里清除标志的写法有问题写的是TIM3-SR 0把整个SR清零了但SR里有些位是rc_w0写0清除有些位写0无影响结果UIF确实清了但其他状态位被意外改变导致后续行为异常。改成TIM3-SR ~(1 0)后正常。5.2 定时周期不对周期偏差通常来自三个地方时钟频率搞错。APB1预分频系数为1时TIMx时钟等于APB1时钟不为1时TIMx时钟是APB1的2倍。很多人直接拿SystemCoreClock去算忽略了这层关系。PSC和ARR的1。公式里是(PSC1)和(ARR1)但配置时写的是PSC和ARR的值。比如要72分频PSC应该写71不是72。影子寄存器未更新。改了PSC或ARR后没有产生更新事件新值没加载。解决方法是在改完之后手动置UG位。5.3 中断嵌套行为不符合预期如果高优先级中断打断了低优先级中断但低优先级中断恢复后行为异常检查优先级分组是否全局一致低优先级中断的ISR里是否使用了会被高优先级中断修改的全局变量需要volatile和临界区保护高优先级中断是否执行时间过长导致低优先级中断被延迟太久避坑技巧在调试中断嵌套时可以在ISR入口和出口各翻转一个空闲GPIO用示波器看两个中断的波形重叠情况。这个方法比打印日志直观得多而且不影响中断时序。5.4 寄存器写入不生效有时候写了寄存器但读回来还是旧值可能的原因外设时钟未使能写进去也没用寄存器有写保护比如某些配置寄存器需要先解锁写入的值超出了有效范围硬件自动截断或忽略影子寄存器机制导致读回的是旧值排查时先确认时钟再确认写保护最后用读-改-写方式操作。6. 几个容易被忽略的细节与个人经验6.1 中断服务函数的执行时间预算定时器中断频率越高ISR执行时间越要短。一个粗略的估算方法如果中断频率是10kHz周期是100usISR执行时间最好控制在10us以内留90%的时间给主循环。如果ISR里要处理的事情多考虑用DMA或者把处理逻辑放到主循环ISR只置标志。我见过一个项目定时器中断里做浮点运算和串口打印结果主循环几乎跑不动。后来改成ISR里只置一个标志主循环检测标志后再处理系统立刻流畅了。6.2 优先级分组的“一次定终身”原则前面提过分组是全局的这里再强调一次在系统初始化时确定分组之后不再改动。如果确实需要调整必须在所有中断都关闭的情况下改改完后重新配置所有中断的优先级。有些库的初始化函数会偷偷改分组比如某个外设库为了自己的中断能正常工作在初始化时把分组设成它需要的值。如果你混用多个库一定要检查每个库的初始化代码有没有动SCB-AIRCR。6.3 用寄存器手册的“读-改-写”原则对任何有多个功能位的寄存器除非你确定要整体覆盖否则一律用读-改-写。C语言里就是uint32_t tmp TIM3-CR1; tmp ~(1 3); // 清除OPM位 tmp | (1 0); // 设置CEN位 TIM3-CR1 tmp;这样比直接TIM3-CR1 0x01安全得多尤其是在运行中动态调整时。6.4 调试时先关中断再单步用调试器单步执行时如果定时器中断还开着可能会在单步过程中不断进ISR导致调试体验极差。建议在调试初始化代码时先把NVIC里对应的中断禁止等配置逻辑验证完再打开。这个习惯帮我省了很多时间。早期我经常在单步时被中断打断以为是代码逻辑问题后来发现只是中断在捣乱。6.5 保留一份寄存器配置的“快照”在项目里我习惯在初始化完成后把关键寄存器的值读出来存到一个结构体里方便后续对比。比如typedef struct { uint32_t cr1; uint32_t psc; uint32_t arr; uint32_t dier; } tim_snapshot_t; tim_snapshot_t tim3_snap; tim3_snap.cr1 TIM3-CR1; tim3_snap.psc TIM3-PSC; tim3_snap.arr TIM3-ARR; tim3_snap.dier TIM3-DIER;当出现异常时把当前值和快照对比能快速定位是哪个寄存器被意外修改了。这个方法在排查“运行一段时间后行为异常”类问题时特别有效。6.6 关于第七章的后续扩展第七章的内容往下走自然会接触到PWM输出、输入捕获、编码器接口这些更复杂的定时器应用。这些功能本质上都是在今天讲的寄存器基础上增加配置项。把CR1、PSC、ARR、DIER、SR这几个寄存器的行为模型建立起来后面再看CCMR、CCER、CCR这些寄存器就不会觉得突兀。我个人建议的练习路径是先用定时器中断做一个精确的软件延时再用PWM输出驱动一个LED做呼吸灯最后用输入捕获测量一个外部信号的脉宽。这三步走完定时器就算真正入门了。每一步都会遇到不同的问题而解决问题的过程就是理解寄存器的最好方式。