1. 中断系统全景速览:从NVIC到EXTI再到SysTick
搞STM32寄存器开发,绕不开中断这套东西。很多人学标准库或者HAL库的时候,中断配置就是调几个函数,NVIC_Init、EXTI_Init、SysTick_Config,填填结构体就完事了。但一旦切换到寄存器版本,或者遇到中断不触发、优先级混乱、中断嵌套出问题的时候,如果对底层寄存器没有清晰的认识,排查起来会非常痛苦。这一章我打算把NVIC、EXTI、SysTick这三个中断相关的核心模块串起来讲,从寄存器层面把中断的来龙去脉说清楚。
先说结论:STM32的中断系统可以分成三个层次来理解。最外层是NVIC(Nested Vectored Interrupt Controller),它是Cortex-M内核自带的中断控制器,负责管理所有中断源的使能、优先级和嵌套。中间层是EXTI(External Interrupt/Event Controller),专门处理外部引脚的电平变化事件,把GPIO的边沿信号转换成中断请求送给NVIC。最内层是SysTick,它是内核自带的一个24位递减计数器,主要用于产生系统时基,也可以作为普通定时中断来用。
这三个东西的关系,打个比方:NVIC就像公司的人事部门,负责决定谁能进门、谁先说话;EXTI像前台接待,外部有人来访(引脚电平变化)时,它负责登记并通知人事部门;SysTick则像公司内部的打卡钟,定期响铃提醒。理解了这个层次关系,后面看寄存器就不会迷路。
这一章的内容适合已经了解STM32基本GPIO操作、能看懂寄存器手册的读者。如果你之前一直用库函数开发,想深入理解中断底层机制,或者正在做寄存器版本的项目,那这一章的内容应该能帮到你。我会尽量用白话把每个寄存器的关键位讲清楚,同时给出可以直接参考的配置代码和实操中容易踩的坑。
2. NVIC寄存器深度拆解与优先级实战
2.1 NVIC到底管什么:中断使能与优先级分组
NVIC在Cortex-M3/M4/M7中的寄存器组是标准化的,地址固定在0xE000E000附近。它管理的中断源包括内核异常(比如SysTick、HardFault等)和外部中断(比如EXTI0、USART1等)。每个中断源在NVIC中都有对应的使能位和优先级字段。
先看几个关键寄存器:
| 寄存器 | 地址偏移 | 作用 |
|---|---|---|
| ISER[n] | 0x100 + 0x04*n | 中断使能置位寄存器 |
| ICER[n] | 0x180 + 0x04*n | 中断使能清除寄存器 |
| ISPR[n] | 0x200 + 0x04*n | 中断挂起置位寄存器 |
| ICPR[n] | 0x280 + 0x04*n | 中断挂起清除寄存器 |
| IABR[n] | 0x300 + 0x04*n | 中断活跃状态寄存器 |
| IPR[n] | 0x400 + 0x04*n | 中断优先级寄存器 |
以STM32F103为例,它用了NVIC的ISER[0]到ISER[2],因为外部中断线有60个左右。每个ISER寄存器32位,每一位对应一个中断号。比如EXTI0的中断号是6,那么使能EXTI0就是设置ISER[0]的bit6。
优先级寄存器IPR每个中断占8位,但STM32只实现了高4位,低4位读出来是0。这4位又分为抢占优先级和子优先级,具体怎么分由AIRCR寄存器的PRIGROUP字段决定。
2.2 优先级分组:一个容易搞混但必须搞懂的概念
Cortex-M的优先级分组通过AIRCR(Application Interrupt and Reset Control Register)的bit[10:8]来设置。STM32中常用的分组方式有5种:
| 分组 | PRIGROUP值 | 抢占优先级位数 | 子优先级位数 | 抢占级数 | 子级数 |
|---|---|---|---|---|---|
| 0 | 0b011 | 4 | 0 | 16 | 1 |
| 1 | 0b100 | 3 | 1 | 8 | 2 |
| 2 | 0b101 | 2 | 2 | 4 | 4 |
| 3 | 0b110 | 1 | 3 | 2 | 8 |
| 4 | 0b111 | 0 | 4 | 1 | 16 |
抢占优先级决定中断能否嵌套。高抢占优先级的中断可以打断低抢占优先级的中断服务函数。子优先级只在多个同抢占优先级中断同时挂起时决定谁先执行,不能嵌套。
我个人的习惯是分组2(2位抢占+2位子优先级),这样抢占级有4级,子优先级也有4级,对于大多数项目够用了。分组0虽然抢占级最多,但没有子优先级,多个同抢占级中断同时来的时候顺序就不确定了。
设置分组的寄存器操作:
// 设置优先级分组为2:2位抢占,2位子优先级 #define SCB_AIRCR (*(volatile uint32_t *)0xE000ED0C) #define VECTKEY 0x05FA0000 SCB_AIRCR = VECTKEY | (0x05 << 8); // PRIGROUP = 0b101注意VECTKEY必须写0x05FA到高16位,否则写入无效。这个细节在手册里写得很清楚,但实际写代码时容易忘。
2.3 中断使能与优先级的实操配置
假设我们要配置EXTI0中断,抢占优先级1,子优先级2,分组2的情况下:
// 1. 设置EXTI0优先级 // EXTI0中断号是6,IPR寄存器中每个中断占8位 // IPR[1]的bit[15:8]对应中断号6 #define NVIC_IPR1 (*(volatile uint32_t *)0xE000E404) // 优先级值:抢占1子2 -> 二进制 01 10 -> 0x60 NVIC_IPR1 = (NVIC_IPR1 & ~(0xFF << 8)) | (0x60 << 8); // 2. 使能EXTI0中断 #define NVIC_ISER0 (*(volatile uint32_t *)0xE000E100) NVIC_ISER0 = (1 << 6);这里有个细节:优先级值写进去的时候,硬件会自动把它放到高4位。比如你写0x60,实际生效的是bit[7:4]的值,也就是6。抢占优先级是6>>2=1,子优先级是6&0x3=2。这个移位关系一定要搞清楚,否则优先级会设错。
注意:NVIC的ISER寄存器是写1使能,写0无效。ICER是写1清除,写0无效。不要试图用“读-改-写”的方式操作ISER,直接赋值对应位为1就行。
2.4 中断挂起与清除:那些年我们追过的“中断不响应”
有时候中断使能了、优先级也设了,但就是不进中断服务函数。这时候要查ISPR寄存器,看中断是否处于挂起状态。如果挂起位是1但一直不执行,可能是优先级配置有问题,或者全局中断被关了。
还有一种情况:中断服务函数执行完了,但挂起位没有自动清除,导致重复进入中断。对于外部中断,通常需要在ISR中手动清除EXTI的挂起位。对于NVIC层面的挂起位,大多数情况下硬件会自动清除,但某些场景下需要手动写ICPR清除。
// 手动清除EXTI0的NVIC挂起位 #define NVIC_ICPR0 (*(volatile uint32_t *)0xE000E280) NVIC_ICPR0 = (1 << 6);我遇到过一个问题:在中断服务函数中调用了某个耗时操作,导致中断返回后立刻又进了一次。排查发现是外设的中断标志没有清除,EXTI的PR寄存器还挂着。所以记住一个原则:进中断先清标志,再干活。清标志的时机很重要,太早可能丢事件,太晚可能重复进中断。
3. EXTI外部中断控制器:从引脚到中断的完整链路
3.1 EXTI的架构:边沿检测与信号分发
EXTI的核心功能是监测GPIO引脚的边沿变化,产生中断或事件。STM32F103有19条EXTI线,其中EXTI0到EXTI15对应GPIO的Pin0到Pin15,另外三条分别对应PVD输出、RTC闹钟和USB唤醒。
每条EXTI线可以独立配置为上升沿触发、下降沿触发或双边沿触发。触发信号经过边沿检测电路后,可以产生两种输出:一种是中断请求,送给NVIC;另一种是事件脉冲,可以触发其他外设(比如ADC、TIM)。
EXTI的关键寄存器:
| 寄存器 | 作用 |
|---|---|
| IMR | 中断屏蔽寄存器,1=允许中断 |
| EMR | 事件屏蔽寄存器,1=允许事件 |
| RTSR | 上升沿触发选择寄存器 |
| FTSR | 下降沿触发选择寄存器 |
| SWIER | 软件中断事件寄存器 |
| PR | 挂起寄存器,写1清除 |
3.2 GPIO与EXTI的映射关系:AFIO的角色
EXTI线本身不直接连到GPIO,中间要经过AFIO(Alternate Function I/O)的EXTICR寄存器来选择。每个EXTICR寄存器管理4条EXTI线,每条线用4位来选择端口。
比如要把PA0映射到EXTI0:
// 使能AFIO时钟 #define RCC_APB2ENR (*(volatile uint32_t *)0x40021018) RCC_APB2ENR |= (1 << 0); // 配置AFIO_EXTICR1,EXTI0对应bit[3:0],选择PA #define AFIO_EXTICR1 (*(volatile uint32_t *)0x40010008) AFIO_EXTICR1 &= ~(0xF << 0); // 清除 AFIO_EXTICR1 |= (0x0 << 0); // 0x0 = PA如果要映射PB0,就是0x1;PC0就是0x2,以此类推。这里有个坑:同一个EXTI线不能同时映射到多个端口。比如EXTI0只能选PA0、PB0、PC0中的一个,不能同时用。如果你需要PA0和PB0都产生中断,只能一个用EXTI0,另一个用EXTI1。
3.3 边沿触发配置与中断服务函数编写
配置EXTI0为上升沿触发:
// 1. 配置GPIO为输入模式(以PA0为例) #define GPIOA_CRL (*(volatile uint32_t *)0x40010800) GPIOA_CRL &= ~(0xF << 0); GPIOA_CRL |= (0x8 << 0); // 输入模式,默认下拉/浮空 // 2. 配置AFIO映射 AFIO_EXTICR1 &= ~(0xF << 0); // PA0 // 3. 配置EXTI #define EXTI_IMR (*(volatile uint32_t *)0x40010400) #define EXTI_RTSR (*(volatile uint32_t *)0x40010408) #define EXTI_FTSR (*(volatile uint32_t *)0x4001040C) #define EXTI_PR (*(volatile uint32_t *)0x40010414) EXTI_IMR |= (1 << 0); // 允许EXTI0中断 EXTI_RTSR |= (1 << 0); // 上升沿触发 EXTI_FTSR &= ~(1 << 0); // 关闭下降沿 // 4. 配置NVIC NVIC_IPR1 = (NVIC_IPR1 & ~(0xFF << 8)) | (0x60 << 8); NVIC_ISER0 = (1 << 6);中断服务函数:
void EXTI0_IRQHandler(void) { // 检查挂起位 if (EXTI_PR & (1 << 0)) { // 清除挂起位(写1清除) EXTI_PR = (1 << 0); // 执行用户代码 // ... } }注意:EXTI_PR是写1清除,不是写0清除。这个和很多寄存器相反,容易搞错。如果你写EXTI_PR &= ~(1<<0),挂起位不会被清除,中断会一直触发。
3.4 按键消抖与中断触发的实战经验
用EXTI做按键中断的时候,机械按键的抖动会导致一次按下产生多次中断。我试过几种消抖方案:
第一种是在中断服务函数里加延时,比如延时20ms再读引脚状态。这种方法简单但不好,因为中断里延时会影响系统实时性。
第二种是用定时器做消抖,中断触发后启动一个定时器,定时器到期后再读引脚。这种方法比较优雅,但需要额外的定时器资源。
第三种是在硬件上加RC滤波电路,软件不做消抖。这种方法最省CPU,但需要改硬件。
我个人的做法是:如果按键数量少,用定时器消抖;如果按键多且对实时性要求不高,在中断里做简单延时也可以接受。但要注意,中断服务函数的执行时间要尽量短,一般不要超过100us。
还有一个经验:EXTI的挂起位清除要放在中断服务函数的开头。如果你先执行用户代码再清挂起位,用户代码执行期间如果又来了一个边沿,挂起位会被置1,但你的清除操作会把它清掉,导致丢事件。所以顺序是:清挂起位 -> 执行用户代码。
4. SysTick系统定时器:内核自带的精准时基
4.1 SysTick的寄存器与工作原理
SysTick是Cortex-M内核自带的一个24位递减计数器,地址在0xE000E010。它有4个寄存器:
| 寄存器 | 地址 | 作用 |
|---|---|---|
| CTRL | 0xE000E010 | 控制与状态 |
| LOAD | 0xE000E014 | 重装载值 |
| VAL | 0xE000E018 | 当前计数值 |
| CALIB | 0xE000E01C | 校准值 |
CTRL寄存器的关键位:
- bit0:ENABLE,使能计数器
- bit1:TICKINT,计数到0时产生中断
- bit2:CLKSOURCE,时钟源选择(0=外部时钟,1=内核时钟)
- bit16:COUNTFLAG,计数到0时硬件置1,读CTRL时自动清除
SysTick的工作流程:计数器从LOAD值开始递减,减到0时产生中断(如果TICKINT=1),同时COUNTFLAG置1,然后自动重装载LOAD值,继续递减。
4.2 精准延时:SysTick的经典用法
用SysTick做延时函数是最常见的用法。假设内核时钟72MHz,要延时1us:
#define SYSTICK_CTRL (*(volatile uint32_t *)0xE000E010) #define SYSTICK_LOAD (*(volatile uint32_t *)0xE000E014) #define SYSTICK_VAL (*(volatile uint32_t *)0xE000E018) void delay_us(uint32_t us) { SYSTICK_LOAD = 72; // 72个时钟周期 = 1us SYSTICK_VAL = 0; // 清当前值 SYSTICK_CTRL = 0x05; // 使能,内核时钟,不产生中断 while (us--) { while (!(SYSTICK_CTRL & (1 << 16))); // 等待COUNTFLAG置1 } SYSTICK_CTRL = 0; // 关闭SysTick }这里有个细节:LOAD值最大是0xFFFFFF,也就是16777215。如果延时时间太长,比如延时1秒,72MHz下需要72000000个周期,超过了24位范围。这时候需要分多次延时,或者用其他定时器。
注意:SysTick的COUNTFLAG在读取CTRL时自动清除。所以上面的代码中,每次循环读CTRL都会清掉COUNTFLAG,下一次循环又能正常等待。
4.3 SysTick中断:RTOS的心跳与系统时基
SysTick中断最常见的用途是给RTOS提供系统时基。比如FreeRTOS默认用SysTick产生1ms的周期中断,在中断服务函数中做任务调度和延时管理。
配置SysTick中断:
void SysTick_Init(uint32_t ticks) { SYSTICK_LOAD = ticks - 1; // 重装载值 SYSTICK_VAL = 0; SYSTICK_CTRL = 0x07; // 使能,中断,内核时钟 } void SysTick_Handler(void) { // 系统时基处理 // ... }如果内核时钟72MHz,要产生1ms中断,ticks = 72000。
这里有个容易忽略的点:SysTick的优先级设置。SysTick属于内核异常,它的优先级通过SHPR3寄存器设置,而不是NVIC的IPR。SHPR3的bit[31:24]对应SysTick优先级。
#define SCB_SHPR3 (*(volatile uint32_t *)0xE000ED20) // 设置SysTick优先级为最低(0xF0) SCB_SHPR3 = (SCB_SHPR3 & ~(0xFF << 24)) | (0xF0 << 24);在RTOS中,SysTick优先级通常设为最低,这样其他中断可以打断系统时基处理,保证实时性。
4.4 SysTick与DWT:微秒级时间戳的两种方案
除了SysTick,Cortex-M3/M4/M7还有一个DWT(Data Watchpoint and Trace)单元,其中的CYCCNT计数器可以提供CPU周期级的计时。DWT的CYCCNT是32位,在72MHz下大约60秒溢出一次。
用DWT获取微秒级时间戳:
#define DWT_CTRL (*(volatile uint32_t *)0xE0001000) #define DWT_CYCCNT (*(volatile uint32_t *)0xE0001004) #define DEMCR (*(volatile uint32_t *)0xE000EDFC) void DWT_Init(void) { DEMCR |= (1 << 24); // 使能DWT DWT_CYCCNT = 0; // 清计数器 DWT_CTRL |= (1 << 0); // 使能CYCCNT } uint32_t DWT_GetUs(void) { return DWT_CYCCNT / 72; // 72MHz下,72个周期=1us }DWT的优势是精度高、不占用中断资源,适合做性能分析和精确计时。缺点是32位计数器溢出较快,需要软件处理溢出。SysTick的优势是硬件自动重装载,适合做周期中断。两者可以配合使用:SysTick做系统时基,DWT做精细计时。
5. 中断优先级冲突与常见问题排查
5.1 优先级反转与中断嵌套的坑
中断优先级配置不当会导致一些奇怪的现象。比如两个中断A和B,A的抢占优先级高于B。如果B正在执行,A来了可以打断B。但如果A和B共享某个资源(比如全局变量),就可能出现数据竞争。
我遇到过一个问题:串口接收中断和定时器中断共享一个缓冲区。串口中断优先级高,定时器中断优先级低。定时器中断正在写缓冲区的时候,串口中断来了,读走了半截数据。后来把两个中断的抢占优先级设为相同,用子优先级区分,并且在临界区关中断,问题才解决。
经验:如果两个中断共享资源,要么设成相同抢占优先级(不能嵌套),要么在访问共享资源时关中断。不要依赖子优先级来保护共享资源,因为子优先级不能阻止嵌套。
5.2 中断不触发的排查清单
中断不触发是新手最常遇到的问题。我整理了一个排查清单:
| 排查项 | 检查方法 |
|---|---|
| 时钟是否使能 | 检查RCC寄存器对应位 |
| GPIO模式是否正确 | 输入模式,必要时配置上下拉 |
| AFIO映射是否正确 | 检查EXTICR寄存器 |
| EXTI使能是否打开 | 检查IMR寄存器 |
| 边沿触发是否配置 | 检查RTSR/FTSR |
| NVIC使能是否打开 | 检查ISER寄存器 |
| 优先级分组是否设置 | 检查AIRCR |
| 全局中断是否开启 | 检查PRIMASK |
| 中断服务函数名是否正确 | 对照启动文件 |
| 挂起位是否清除 | 检查PR寄存器 |
其中最容易忽略的是AFIO时钟。很多人记得开GPIO时钟,但忘了AFIO时钟。没有AFIO时钟,EXTICR配置不生效,中断自然不触发。
5.3 中断响应时间与优化建议
中断响应时间包括:中断请求到CPU响应的时间、入栈时间、跳转到ISR的时间。Cortex-M3/M4的中断响应通常是12个时钟周期,加上入栈(8个寄存器)和取向量,总共大约16到20个周期。
如果要优化中断响应时间:
- 减少ISR中的代码量,把耗时操作放到主循环
- 提高中断优先级,减少等待时间
- 避免在ISR中调用函数,直接操作寄存器
- 使用尾链优化(Tail-chaining),连续中断时硬件自动跳过入栈出栈
我实测过,在72MHz下,一个空的中断服务函数从触发到执行第一条指令大约需要0.3us。如果ISR中有浮点运算或者函数调用,时间会显著增加。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 中断只进一次 | 挂起位未清除 | 在ISR中清除PR寄存器 |
| 中断频繁触发 | 边沿抖动或标志未清 | 加消抖,检查清除顺序 |
| 中断不触发 | 时钟/使能/映射问题 | 按排查清单逐项检查 |
| 中断嵌套异常 | 优先级分组错误 | 检查AIRCR和IPR |
| SysTick不计数 | CTRL未使能或时钟源错误 | 检查CTRL的bit0和bit2 |
| 延时不准 | LOAD值计算错误 | 确认内核时钟频率 |
| 优先级不生效 | 分组未设置或值写错 | 检查AIRCR和IPR的移位 |
6. 寄存器版中断配置的完整实操案例
6.1 项目需求:按键中断+系统时基+串口接收
假设我们要做一个简单的项目:PA0按键上升沿触发中断,在中断中翻转LED;SysTick产生1ms时基,用于软件延时;USART1接收中断,收到数据后回显。
这个案例涵盖了EXTI、SysTick和NVIC的配置,比较有代表性。
6.2 完整代码实现
#include "stm32f10x.h" // 寄存器定义 #define RCC_APB2ENR (*(volatile uint32_t *)0x40021018) #define GPIOA_CRL (*(volatile uint32_t *)0x40010800) #define GPIOA_ODR (*(volatile uint32_t *)0x4001080C) #define AFIO_EXTICR1 (*(volatile uint32_t *)0x40010008) #define EXTI_IMR (*(volatile uint32_t *)0x40010400) #define EXTI_RTSR (*(volatile uint32_t *)0x40010408) #define EXTI_PR (*(volatile uint32_t *)0x40010414) #define NVIC_ISER0 (*(volatile uint32_t *)0xE000E100) #define NVIC_IPR1 (*(volatile uint32_t *)0xE000E404) #define SCB_AIRCR (*(volatile uint32_t *)0xE000ED0C) #define SYSTICK_CTRL (*(volatile uint32_t *)0xE000E010) #define SYSTICK_LOAD (*(volatile uint32_t *)0xE000E014) #define SYSTICK_VAL (*(volatile uint32_t *)0xE000E018) volatile uint32_t g_ms_tick = 0; void System_Init(void) { // 1. 使能GPIOA和AFIO时钟 RCC_APB2ENR |= (1 << 2) | (1 << 0); // 2. 配置PA0为输入(按键) GPIOA_CRL &= ~(0xF << 0); GPIOA_CRL |= (0x8 << 0); // 3. 配置PA1为推挽输出(LED) GPIOA_CRL &= ~(0xF << 4); GPIOA_CRL |= (0x3 << 4); // 4. 配置AFIO映射PA0到EXTI0 AFIO_EXTICR1 &= ~(0xF << 0); // 5. 配置EXTI0上升沿触发 EXTI_IMR |= (1 << 0); EXTI_RTSR |= (1 << 0); // 6. 设置优先级分组为2 SCB_AIRCR = 0x05FA0000 | (0x05 << 8); // 7. 设置EXTI0优先级(抢占1,子2) NVIC_IPR1 = (NVIC_IPR1 & ~(0xFF << 8)) | (0x60 << 8); // 8. 使能EXTI0中断 NVIC_ISER0 = (1 << 6); // 9. 配置SysTick 1ms中断 SYSTICK_LOAD = 72000 - 1; SYSTICK_VAL = 0; SYSTICK_CTRL = 0x07; } void EXTI0_IRQHandler(void) { if (EXTI_PR & (1 << 0)) { EXTI_PR = (1 << 0); // 清除挂起位 // 翻转LED GPIOA_ODR ^= (1 << 1); } } void SysTick_Handler(void) { g_ms_tick++; } void delay_ms(uint32_t ms) { uint32_t start = g_ms_tick; while ((g_ms_tick - start) < ms); } int main(void) { System_Init(); while (1) { // 主循环 delay_ms(500); } }6.3 关键配置解析与参数计算
SysTick的LOAD值计算:内核时钟72MHz,要产生1ms中断,需要72000个时钟周期。LOAD = 72000 - 1 = 71999。为什么减1?因为SysTick是从LOAD值递减到0,然后产生中断。如果LOAD=72000,实际是72001个周期。减1后正好72000个周期。
EXTI0的NVIC中断号是6。ISER0的bit6对应EXTI0。IPR1的bit[15:8]对应中断号6。优先级值0x60表示抢占优先级1(0x60>>6=1),子优先级2((0x60>>4)&0x3=2)。
注意:优先级值写入IPR时,硬件只取高4位。所以0x60写入后,实际生效的是bit[7:4]=6。抢占优先级是6>>2=1,子优先级是6&0x3=2。这个计算关系要记清楚。
6.4 实测效果与调试记录
这个代码在STM32F103C8T6上实测通过。按键按下时LED翻转,SysTick每1ms产生一次中断,g_ms_tick递增。用逻辑分析仪测过,中断响应时间大约0.5us,LED翻转的延迟在微秒级。
调试过程中遇到一个问题:按键按下时LED偶尔会翻转两次。排查发现是按键抖动导致的。后来在硬件上加了0.1uF电容,问题解决。如果不想改硬件,可以在ISR中加一个简单的软件消抖:检测到边沿后延时1ms再读引脚状态。
还有一个细节:SysTick中断优先级默认是最低的(0xF0)。如果EXTI0中断正在执行,SysTick中断不会打断它。这个行为是符合预期的,因为系统时基不需要抢占按键中断。
7. 中断系统的扩展应用与进阶思路
7.1 用中断实现串口不定长数据接收
串口接收不定长数据是一个经典问题。常见方案是用USART的IDLE中断配合接收中断。IDLE中断在总线空闲时触发,可以用来判断一帧数据接收完毕。
配置思路:使能USART的RXNE中断和IDLE中断,在RXNE中断中把数据存入缓冲区,在IDLE中断中置标志位表示一帧结束。主循环检测到标志位后处理数据。
这个方案的关键是IDLE中断的清除。IDLE标志的清除需要先读SR寄存器再读DR寄存器。顺序不能错,否则标志清不掉。
7.2 中断与DMA的配合
DMA可以在不占用CPU的情况下搬运数据。把DMA和中断结合起来,可以实现高效的数据传输。比如ADC连续采样,用DMA搬运到缓冲区,搬够一定数量后触发中断,在中断中处理数据。
这种方案的优势是CPU占用率极低。我做过一个项目,ADC以1MHz采样,DMA搬运,每1000个点触发一次中断。CPU占用率不到5%,效果很好。
7.3 中断优先级的动态调整
某些场景下需要动态调整中断优先级。比如系统启动阶段,某些中断需要高优先级;运行阶段,优先级可以降低。NVIC的IPR寄存器可以在运行时修改,但要注意修改优先级可能会影响正在执行的中断。
我个人的建议是:尽量不要动态调整优先级,除非有明确的需求。优先级配置在初始化时确定好,运行时不改动,这样系统行为更可预测。
7.4 低功耗模式下的中断唤醒
STM32的低功耗模式(Sleep、Stop、Standby)可以通过中断唤醒。在Stop模式下,SysTick停止工作,但EXTI仍然可以工作。配置EXTI中断唤醒Stop模式,可以实现按键唤醒功能。
唤醒后的处理需要注意:Stop模式唤醒后,系统时钟会切换回HSI,需要重新配置时钟。这个细节容易忽略,导致唤醒后串口波特率不对。
8. 寄存器手册阅读技巧与调试工具推荐
8.1 如何快速定位关键寄存器
STM32的参考手册有上千页,不可能从头读到尾。我的方法是:先看目录找到对应外设的章节,然后直接跳到寄存器描述部分。每个外设的寄存器描述通常有寄存器映射表和每个寄存器的位定义。
读寄存器描述的时候,重点关注:复位值、读写属性、关键位的功能。复位值可以告诉你寄存器的默认状态,读写属性决定你能不能修改。比如ISER是只写寄存器,读出来是0,不要试图读它来判断中断是否使能。
8.2 用调试器查看寄存器状态
Keil MDK和STM32CubeIDE都支持在调试时查看外设寄存器。在Keil中,打开View -> System Viewer,可以选择要查看的外设。调试时可以看到寄存器的实时值,非常方便排查问题。
我常用的调试技巧:在中断服务函数中设置断点,查看NVIC的ISPR和IABR寄存器,确认中断是否被正确挂起和响应。如果ISPR有挂起位但IABR没有活跃位,说明中断没有被CPU响应,可能是优先级或全局中断的问题。
8.3 逻辑分析仪与示波器的使用
逻辑分析仪是调试中断的利器。把按键信号和LED信号同时接到逻辑分析仪上,可以直观地看到从按键触发到LED翻转的延迟。如果延迟过大,说明中断响应有问题。
示波器适合看模拟信号和电源噪声。如果中断偶尔误触发,可能是电源噪声导致的。用示波器看电源纹波,如果纹波过大,加滤波电容。
8.4 常见调试误区与经验总结
第一个误区:只看代码不看寄存器。代码写得再对,寄存器配置错了也没用。调试时一定要结合寄存器状态分析。
第二个误区:忽略时钟配置。STM32的时钟树比较复杂,外设时钟没使能,配置寄存器也没用。我习惯在初始化函数开头先把所有需要的时钟使能,避免遗漏。
第三个误区:中断服务函数名写错。启动文件中的中断向量表定义了每个中断的函数名,如果名字写错,链接时会报错或者中断不响应。建议直接从启动文件中复制函数名。
第四个误区:优先级分组设置太晚。如果在配置中断优先级之后才设置分组,之前的优先级配置可能会被重置。正确的顺序是:先设置分组,再配置优先级。
这些经验都是我在实际项目中踩过坑之后总结的。寄存器开发虽然麻烦,但一旦掌握了底层机制,排查问题和优化性能都会变得更有底气。希望这一章的内容能帮你少走一些弯路。