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_2(2bit抢占+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的完整位定义在手册里有详细表格,这里只挑几个实际配置中必须理解的位来说。
CEN(bit 0):计数器使能。写1启动计数,写0停止。但要注意,停止时计数器不会自动清零,CNT保持当前值。下次使能时从该值继续。如果需要每次启动都从0开始,要先手动写CNT=0。
UDIS(bit 1):更新禁止。这个位很多人不用,但在需要精确控制更新事件时很有价值。置1后,更新事件不会产生,但计数器溢出时CNT和影子寄存器仍然会重载。可以用来在配置阶段避免不必要的更新中断。
OPM(bit 3):单脉冲模式。置1后,计数器在下一个更新事件时自动停止(CEN被硬件清零)。做精确单次延时或者单脉冲输出时非常方便,省去了在中断里手动关定时器的代码。
DIR(bit 4):计数方向。0为向上计数,1为向下计数。在中心对齐模式下,这个位由硬件管理,不要手动改。
CMS(bit 6:5):对齐模式。00为边沿对齐,01/10/11为中央对齐的几种变体。PWM输出时,边沿对齐和中央对齐的波形对称性不同,电机控制里通常用中央对齐来减少谐波。
配置示例(向上计数、边沿对齐、使能更新中断):
// 假设TIM2时钟已使能 TIM2->CR1 = 0; // 先清零,确保干净状态 TIM2->CR1 |= (0 << 6); // CMS=00,边沿对齐 TIM2->CR1 |= (0 << 4); // DIR=0,向上计数 TIM2->CR1 |= (0 << 3); // OPM=0,非单脉冲 TIM2->CR1 |= (0 << 1); // UDIS=0,允许更新事件 // CEN先不置位,等PSC和ARR配好再启动注意:直接对CR1整体赋值前,最好先读一次再改,避免把硬件已经设置的位覆盖掉。虽然复位后CR1初值已知,但在运行中动态调整时,读-改-写是更安全的做法。
3.2 TIMx_PSC与TIMx_ARR:频率计算的实际操作
定时器频率的计算公式看起来简单:
计数频率 = 定时器时钟 / (PSC + 1) 更新频率 = 计数频率 / (ARR + 1)但实际配置时有几个细节:
PSC和ARR都是16位寄存器,所以PSC+1的范围是1到65536,ARR+1同理。这意味着单级分频最大只能到65536。如果需要更长的定时周期,要么用级联定时器,要么在中断里累加计数。
写入顺序有讲究。PSC和ARR都有影子寄存器,写入的值在下一个更新事件时才生效。如果你先写ARR再写PSC,而更新事件在两者之间发生,就会出现一个周期的异常频率。稳妥的做法是:
- 先停止计数器(CEN=0)
- 写PSC和ARR
- 手动产生一个更新事件(UG=1)
- 清除更新标志
- 启动计数器(CEN=1)
这样能保证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); // UG=1,手动产生更新事件 TIM2->SR &= ~(1 << 0); // 清除UIF标志 TIM2->CR1 |= (1 << 0); // CEN=1,启动实操心得:ARR的值不要设成0。ARR=0时,计数器在每个时钟周期都产生更新事件,中断频率等于计数频率,CPU会被中断淹没。我见过有人想产生最高频中断,结果系统直接卡死。
3.3 TIMx_DIER与TIMx_SR:中断使能与标志清除的配合
DIER和SR是一对必须一起理解的寄存器。DIER控制哪些事件能产生中断,SR记录哪些事件已经发生。
DIER的关键位:
- UIE(bit 0):更新中断使能
- CC1IE~CC4IE(bit 1~4):捕获/比较中断使能
- TIE(bit 6):触发中断使能
- UDE(bit 8):更新DMA请求使能
SR的关键位:
- UIF(bit 0):更新中断标志
- CC1IF~CC4IF(bit 1~4):捕获/比较标志
- TIF(bit 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和ARR
TIM3时钟为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); // UIE=1第五步:配置NVIC
TIM3中断号是29。
NVIC->IP[29] = (1 << 4); // 抢占1,子优先级0 NVIC->ISER[0] |= (1 << 29); // 使能TIM3中断第六步:启动定时器
TIM3->CR1 |= (1 << 0); // CEN=1第七步:编写中断服务函数
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); // EXTTRIG=1 ADC1->CR2 |= (0 << 17); // EXTSEL=000,选择EXTI线0 ADC1->CR2 |= (1 << 0); // ADON=1,开启ADC这样配置后,PA0上的上升沿会直接触发ADC采样,全程不需要CPU参与。采样完成后如果使能了EOC中断,再进中断读数据即可。
实操心得:事件触发路径的延迟比中断路径短得多,但调试起来更麻烦,因为你看不到“事件发生了”这个动作。建议先用中断方式验证功能逻辑,确认无误后再改成事件触发来优化性能。
5. 常见问题与排查技巧实录
5.1 中断进不去或者只进一次
这是最常遇到的问题,排查顺序如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全不进中断 | NVIC未使能 | 读NVIC->ISER确认对应位为1 |
| 完全不进中断 | 外设中断未使能 | 读TIMx_DIER确认UIE=1 |
| 完全不进中断 | 全局中断未开 | 确认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。公式里是(PSC+1)和(ARR+1),但配置时写的是PSC和ARR的值。比如要72分频,PSC应该写71,不是72。
影子寄存器未更新。改了PSC或ARR后没有产生更新事件,新值没加载。解决方法是在改完之后手动置UG位。
5.3 中断嵌套行为不符合预期
如果高优先级中断打断了低优先级中断,但低优先级中断恢复后行为异常,检查:
- 优先级分组是否全局一致
- 低优先级中断的ISR里是否使用了会被高优先级中断修改的全局变量(需要volatile和临界区保护)
- 高优先级中断是否执行时间过长,导致低优先级中断被延迟太久
避坑技巧:在调试中断嵌套时,可以在ISR入口和出口各翻转一个空闲GPIO,用示波器看两个中断的波形重叠情况。这个方法比打印日志直观得多,而且不影响中断时序。
5.4 寄存器写入不生效
有时候写了寄存器但读回来还是旧值,可能的原因:
- 外设时钟未使能(写进去也没用)
- 寄存器有写保护(比如某些配置寄存器需要先解锁)
- 写入的值超出了有效范围(硬件自动截断或忽略)
- 影子寄存器机制导致读回的是旧值
排查时先确认时钟,再确认写保护,最后用读-改-写方式操作。
6. 几个容易被忽略的细节与个人经验
6.1 中断服务函数的执行时间预算
定时器中断频率越高,ISR执行时间越要短。一个粗略的估算方法:如果中断频率是10kHz,周期是100us,ISR执行时间最好控制在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做呼吸灯,最后用输入捕获测量一个外部信号的脉宽。这三步走完,定时器就算真正入门了。每一步都会遇到不同的问题,而解决问题的过程就是理解寄存器的最好方式。