STM32中断系统详解:NVIC、EXTI与SysTick寄存器级配置指南
1. 从裸机到中断:为什么第五章绕不开NVIC、EXTI和SysTick
翻到寄存器手册的第五章,很多人会卡在同一个地方:前面几章讲GPIO、RCC、时钟树,虽然枯燥但至少能一行行对着寄存器位去配。到了中断这一块,NVIC、EXTI、SysTick三个东西一起涌上来,手册里全是“使能”“挂起”“优先级分组”这类词,看完之后脑子里只剩一团浆糊。
这一章要解决的核心问题其实很朴素:怎么让CPU在正确的时间、以正确的优先级、跳进正确的函数里干活。GPIO那章解决的是“电平怎么变”,中断这章解决的是“事件怎么被响应”。你写按键扫描、串口收数据、定时器周期任务,底层全靠这套机制在撑。
我见过太多人用HAL库点完灯之后,想自己写个按键中断,结果卡在HAL_NVIC_SetPriority和HAL_NVIC_EnableIRQ这两行上,不知道为什么要传那两个参数。也见过有人SysTick配了半天,SysTick_Handler死活不进。这些问题的根子,都在于没把NVIC、EXTI、SysTick三者的分工和寄存器操作搞清楚。
这篇内容适合两类人:一类是刚学完GPIO、想往中断方向走的初学者,另一类是用了很久库函数、想回头把寄存器层面补上的开发者。我会按“先讲清楚每个模块管什么,再拆寄存器位,最后给可直接抄的配置代码”这个顺序来写,中间穿插我自己踩过的坑和实测数据。所有代码基于STM32F1系列(Cortex-M3内核),F4/H7的寄存器地址和位定义会有差异,但思路完全一致,我会在关键处标注。
提示:本章涉及的寄存器操作全部基于CMSIS头文件
stm32f10x.h中已有的结构体定义,不涉及自己算地址。如果你用的是其他系列,头文件名字不同,但结构体成员名基本一致。
2. NVIC:中断的总调度室,不是“使能一下就完事”
2.1 NVIC到底管什么,为什么它和内核绑定
NVIC全称Nested Vectored Interrupt Controller,嵌套向量中断控制器。注意“嵌套”和“向量”这两个词——嵌套意味着高优先级中断可以打断低优先级中断,向量意味着每个中断有自己固定的入口地址。它不属于外设,而是Cortex-M内核的一部分,所以你在STM32参考手册里找不到NVIC的章节,得去翻《Cortex-M3权威指南》。
STM32F103有60个可屏蔽中断通道,每个通道在NVIC里对应一组寄存器位。NVIC的寄存器基地址是0xE000E100,这个地址在所有Cortex-M3芯片上都是一样的,因为它是内核定义的,不是ST定义的。
CMSIS把NVIC寄存器封装成了结构体:
typedef struct { __IO uint32_t ISER[8]; // 中断使能寄存器 uint32_t RESERVED0[24]; __IO uint32_t ICER[8]; // 中断清除使能寄存器 uint32_t RESERVED1[24]; __IO uint32_t ISPR[8]; // 中断挂起置位寄存器 uint32_t RESERVED2[24]; __IO uint32_t ICPR[8]; // 中断挂起清除寄存器 uint32_t RESERVED3[24]; __IO uint32_t IABR[8]; // 中断活跃状态寄存器 uint32_t RESERVED4[56]; __IO uint8_t IP[240]; // 中断优先级寄存器(8位粒度) uint32_t RESERVED5[644]; __O uint32_t STIR; // 软件触发中断寄存器 } NVIC_Type;ISER有8个32位寄存器,总共256位,对应256个中断通道。STM32F103只用了前60个,所以实际只用到ISER[0]和ISER[1]的低28位。每个中断号n对应ISER[n/32]的第n%32位。比如EXTI0_IRQn的值是6,那它就在ISER[0]的第6位。
2.2 优先级分组:一个让无数人翻车的设计
Cortex-M3的优先级寄存器是8位的,但STM32只实现了高4位,低4位读出来是0。这4位又被分成“抢占优先级”和“子优先级”两部分,分法由SCB->AIRCR的PRIGROUP字段决定。
| 分组 | PRIGROUP值 | 抢占优先级位数 | 子优先级位数 | 抢占级数 | 子级数 |
|---|---|---|---|---|---|
| 0 | 0x700 | 0 | 4 | 1 | 16 |
| 1 | 0x600 | 1 | 3 | 2 | 8 |
| 2 | 0x500 | 2 | 2 | 4 | 4 |
| 3 | 0x400 | 3 | 1 | 8 | 2 |
| 4 | 0x300 | 4 | 0 | 16 | 1 |
分组0意味着没有抢占,全是子优先级,中断之间不能互相打断。分组4意味着全是抢占,没有子优先级,数值越小优先级越高。最常用的是分组2,兼顾了嵌套和同级管理。
我踩过的坑:早期用NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)之后,给两个中断都设了抢占优先级1、子优先级1和2,结果发现子优先级高的并不能打断低的——因为子优先级只在同时挂起时决定谁先执行,不参与嵌套。这个点手册里写得很清楚,但第一次看很容易忽略。
配置优先级的寄存器是IP[240],每个中断一个字节。CMSIS提供了NVIC_SetPriority函数,但如果你想直接写寄存器:
// 设置EXTI0_IRQn(中断号6)的优先级为抢占1、子1 // 分组2下,高4位中高2位是抢占,低2位是子 uint8_t prio = (1 << 6) | (1 << 4); // 0x50 NVIC->IP[6] = prio << 4; // 左移4位放到高4位注意IP寄存器只用了高4位,所以写入的值要左移4位。这个细节在直接操作寄存器时经常被忘记,导致优先级设了跟没设一样。
2.3 使能中断的完整寄存器操作链路
以EXTI0为例,从零开始使能它的完整步骤:
// 1. 设置优先级分组(整个系统只需一次) SCB->AIRCR = (0x5FA << 16) | (2 << 8); // 分组2 // 2. 设置EXTI0的优先级 NVIC->IP[6] = (1 << 6) | (1 << 4); // 抢占1,子1 // 3. 使能EXTI0中断 NVIC->ISER[0] = (1 << 6); // 4. 配置EXTI和GPIO(后面章节讲)SCB->AIRCR的写入需要先写0x5FA到高16位作为密钥,否则写入无效。这是ARM防止误写做的保护机制。很多人直接写SCB->AIRCR = 0x200,结果分组没生效,就是因为没写密钥。
注意:优先级分组一旦设定,整个系统统一使用,不要在运行过程中反复改。我见过有人在每个中断初始化里都调一次分组配置,虽然值一样不会出错,但这是坏习惯。
3. EXTI:把引脚电平变化翻译成中断信号
3.1 EXTI和NVIC的关系:一个是传感器,一个是调度员
EXTI全称External Interrupt/Event Controller,外部中断/事件控制器。它管的是“哪个引脚发生了什么边沿事件”,NVIC管的是“这个事件要不要被CPU响应、以什么优先级响应”。
信号链路是这样的:GPIO引脚检测到边沿 -> EXTI边沿检测电路 -> EXTI挂起寄存器置位 -> 如果中断屏蔽位使能,向NVIC发中断请求 -> NVIC根据优先级决定是否打断当前执行流 -> CPU跳转到中断向量表对应的入口。
EXTI有20条线,EXTI0到EXTI15对应GPIO的Pin0到Pin15,另外4条给PVD、RTC、USB唤醒和以太网唤醒。关键限制:EXTI0只能选PA0、PB0、PC0...中的一个,不能同时选两个。这个选择通过AFIO_EXTICR寄存器配置。
3.2 边沿检测的寄存器级配置
EXTI的寄存器不多,但每个都有明确的用途:
| 寄存器 | 作用 | 操作方式 |
|---|---|---|
| EXTI_IMR | 中断屏蔽 | 写1使能对应线中断 |
| EXTI_EMR | 事件屏蔽 | 写1使能对应线事件 |
| EXTI_RTSR | 上升沿触发选择 | 写1使能上升沿 |
| EXTI_FTSR | 下降沿触发选择 | 写1使能下降沿 |
| EXTI_SWIER | 软件中断事件 | 写1触发一次中断 |
| EXTI_PR | 挂起寄存器 | 写1清除挂起位 |
配置PA0下降沿触发中断的完整代码:
// 1. 使能AFIO和GPIOA时钟 RCC->APB2ENR |= RCC_APB2ENR_AFIOEN | RCC_APB2ENR_IOPAEN; // 2. 配置PA0为上拉输入(按键接GND,按下为低) GPIOA->CRL &= ~(0xF << 0); GPIOA->CRL |= (0x8 << 0); // 上拉/下拉输入 GPIOA->ODR |= (1 << 0); // 上拉 // 3. 选择EXTI0的源为PA0 AFIO->EXTICR[0] &= ~(0xF << 0); // 清零 AFIO->EXTICR[0] |= (0x0 << 0); // 选PA0 // 4. 配置EXTI0下降沿触发 EXTI->IMR |= (1 << 0); // 使能中断 EXTI->FTSR |= (1 << 0); // 下降沿 EXTI->RTSR &= ~(1 << 0); // 关闭上升沿 // 5. 配置NVIC NVIC->IP[6] = (1 << 6) | (1 << 4); NVIC->ISER[0] = (1 << 6);AFIO->EXTICR有4个寄存器,每个管4条线。EXTICR[0]管EXTI0-3,EXTICR[1]管EXTI4-7,以此类推。每个线占4位,值0-7对应PA-PH。
3.3 中断服务函数里必须做的事:清挂起
EXTI的挂起寄存器EXTI->PR在边沿触发时由硬件置1,必须软件写1清除,否则会反复进中断。这个清除操作要在中断服务函数里做:
void EXTI0_IRQHandler(void) { if (EXTI->PR & (1 << 0)) { EXTI->PR = (1 << 0); // 写1清除 // 你的业务代码 } }写1清除这个设计初看反直觉,但它是为了原子操作——读-改-写会有竞态,直接写1清除不会影响其他位。
我实测过一个坑:如果不清PR,中断会一直触发,CPU占用率直接拉满。用调试器看EXTI->PR一直是1,但代码里明明清了——后来发现是清的位置不对,在业务代码之后才清,而业务代码里有耗时操作,导致中断嵌套时又置位了。正确做法是进中断第一件事就清PR。
3.4 按键消抖:硬件和软件的分工
机械按键的抖动时间通常在5-20ms,EXTI会在抖动期间触发多次中断。常见做法是在中断里做软件消抖:
void EXTI0_IRQHandler(void) { if (EXTI->PR & (1 << 0)) { EXTI->PR = (1 << 0); static uint32_t last_tick = 0; uint32_t now = GetTick(); if (now - last_tick > 20) { // 20ms消抖 last_tick = now; // 有效按键处理 } } }GetTick()可以用SysTick的计数值来算,这就引出了下一节。硬件消抖则是在按键两端并一个0.1uF电容,成本几分钱,但能省掉软件消抖的代码。我一般建议初学者先用软件消抖,因为能直观看到抖动现象,理解中断触发的频率。
4. SysTick:Cortex-M内核自带的“心跳发生器”
4.1 SysTick的定位:不是普通定时器,是系统节拍
SysTick是Cortex-M内核里的一个24位递减计数器,专门用来产生系统节拍。它和TIM2、TIM3那些外设定时器有本质区别:SysTick的寄存器在0xE000E010,属于内核私有外设,所有Cortex-M芯片都有,移植性好;外设定时器每个芯片型号不同,寄存器地址和功能都有差异。
SysTick有4个寄存器:
| 寄存器 | 地址偏移 | 作用 |
|---|---|---|
| CTRL | 0x00 | 控制寄存器 |
| LOAD | 0x04 | 重装载值 |
| VAL | 0x08 | 当前计数值 |
| CALIB | 0x0C | 校准值(一般不用) |
CTRL寄存器的位定义:
- bit0 ENABLE:使能计数器
- bit1 TICKINT:计数到0时产生中断
- bit2 CLKSOURCE:0=外部时钟(AHB/8),1=内核时钟(AHB)
- bit16 COUNTFLAG:计数到0时硬件置1,读CTRL自动清除
4.2 1ms节拍的精确计算
假设系统时钟72MHz,要产生1ms中断:
// 重装载值 = 时钟频率 / 中断频率 - 1 // 72,000,000 / 1000 - 1 = 71999 SysTick->LOAD = 71999; SysTick->VAL = 0; // 清当前值 SysTick->CTRL = (1 << 2) | (1 << 1) | (1 << 0); // CLKSOURCE=1, TICKINT=1, ENABLE=1为什么减1?因为计数器从LOAD值递减到0,然后下一个周期重新加载。从71999到0一共72000个计数,正好1ms。
如果CLKSOURCE选0,时钟变成72MHz/8=9MHz,那LOAD要设成8999。选内核时钟还是8分频,取决于你的系统时钟配置。我一般直接用内核时钟,因为算起来简单,而且精度更高。
4.3 SysTick中断服务函数和延时函数的关系
SysTick的中断服务函数名是固定的SysTick_Handler,在启动文件startup_stm32f10x_md.s里已经弱定义好了。你只需要在C文件里重新实现它:
volatile uint32_t g_systick_ms = 0; void SysTick_Handler(void) { g_systick_ms++; } uint32_t GetTick(void) { return g_systick_ms; } void Delay_ms(uint32_t ms) { uint32_t start = g_systick_ms; while ((g_systick_ms - start) < ms); }g_systick_ms必须加volatile,因为它在中断里被修改,在主循环里被读取,编译器不加volatile可能会优化掉读取操作,导致死循环。
这个延时函数在中断里调用会有问题——如果中断优先级高于SysTick,g_systick_ms不增加,就死等了。所以中断里不要用这个延时,要用就先用SysTick->VAL做忙等待。
4.4 SysTick和FreeRTOS的配合
如果你用FreeRTOS,SysTick会被RTOS接管作为系统节拍。这时候SysTick_Handler里会调用xPortSysTickHandler,你不能自己再写一个。FreeRTOS的vTaskDelay就是基于SysTick实现的。
我遇到过一个典型问题:在FreeRTOS任务里调用Delay_ms,结果任务卡死。原因是FreeRTOS的SysTick中断优先级被设成了最低,而Delay_ms在等g_systick_ms变化,但任务调度器在SysTick中断里切换任务,导致g_systick_ms的更新被延迟。正确做法是用vTaskDelay,不要自己造轮子。
5. 三个模块联调:一个完整的按键计数案例
5.1 需求拆解和方案设计
需求:PA0接按键,按下后计数加1,每1秒通过串口打印一次计数值。
方案:EXTI0检测按键下降沿,SysTick产生1ms节拍用于消抖和计时,主循环判断1秒到了就打印。NVIC负责配置两个中断的优先级——SysTick优先级要高于EXTI0,否则按键中断里的消抖计时可能不准。
优先级分配:SysTick抢占优先级0,EXTI0抢占优先级1。这样SysTick可以打断EXTI0的中断服务函数,保证g_systick_ms始终在更新。
5.2 完整寄存器配置代码
#include "stm32f10x.h" volatile uint32_t g_systick_ms = 0; volatile uint32_t g_key_count = 0; void SysTick_Handler(void) { g_systick_ms++; } void EXTI0_IRQHandler(void) { if (EXTI->PR & (1 << 0)) { EXTI->PR = (1 << 0); static uint32_t last = 0; if (g_systick_ms - last > 20) { last = g_systick_ms; g_key_count++; } } } void System_Init(void) { // 时钟配置略,假设72MHz // NVIC分组2 SCB->AIRCR = (0x5FA << 16) | (2 << 8); // SysTick优先级0,EXTI0优先级1 NVIC->IP[15] = (0 << 6) << 4; // SysTick中断号15 NVIC->IP[6] = (1 << 6) << 4; // EXTI0中断号6 // 使能EXTI0中断 NVIC->ISER[0] = (1 << 6); // SysTick配置1ms SysTick->LOAD = 71999; SysTick->VAL = 0; SysTick->CTRL = (1 << 2) | (1 << 1) | (1 << 0); // GPIO和EXTI配置 RCC->APB2ENR |= RCC_APB2ENR_AFIOEN | RCC_APB2ENR_IOPAEN; GPIOA->CRL &= ~(0xF << 0); GPIOA->CRL |= (0x8 << 0); GPIOA->ODR |= (1 << 0); AFIO->EXTICR[0] &= ~(0xF << 0); EXTI->IMR |= (1 << 0); EXTI->FTSR |= (1 << 0); } int main(void) { System_Init(); uint32_t last_print = 0; while (1) { if (g_systick_ms - last_print >= 1000) { last_print = g_systick_ms; // 串口打印g_key_count } } }5.3 实测中的三个意外情况
第一个意外:按键按下时计数偶尔加2。原因是按键抖动期间,虽然20ms消抖过滤了大部分,但如果在20ms边界上刚好有一次抖动,就会多计一次。解决办法是把消抖时间加到30ms,或者在中断里先禁用EXTI,延时后再使能。我实测30ms能过滤掉99%的机械按键抖动。
第二个意外:SysTick中断里如果执行时间过长,会影响主循环的计时精度。g_systick_ms++本身很快,但如果以后在SysTick里加了其他代码,要注意总执行时间不能超过1ms。用示波器测过,g_systick_ms++大概占用十几个时钟周期,72MHz下不到1us,完全没问题。
第三个意外:用调试器单步调试时,SysTick中断会不断打断,导致单步很难走。解决办法是在调试时先关掉SysTick的TICKINT位,调完再打开。或者用硬件断点,不要用单步。
6. 那些手册不会告诉你的实操细节
6.1 中断向量表的位置和重映射
STM32F103的中断向量表默认在Flash的0x08000000处,但如果你用Bootloader,可能需要重映射到其他地址。SCB->VTOR寄存器控制向量表偏移:
SCB->VTOR = 0x08004000; // 向量表偏移到16KB处这个操作要在使能任何中断之前做。我见过有人在Bootloader跳转到App后,中断进不去,就是因为VTOR没设对。
6.2 中断嵌套的实际限制
Cortex-M3支持中断嵌套,但嵌套层数受栈深度限制。每个中断压栈8个寄存器(32字节),如果嵌套太深会栈溢出。实际项目中,我一般把中断嵌套控制在3层以内。另外,尾链优化(Tail-chaining)可以让连续的中断不用重复压栈弹栈,这是硬件自动做的,不用管。
6.3 优先级反转和临界区
如果两个中断共享一个变量,高优先级中断修改时低优先级中断可能读到中间状态。解决办法是在访问共享变量时关中断:
__disable_irq(); // 临界区代码 __enable_irq();但关中断时间要尽可能短,否则会影响系统实时性。我一般控制在几十个时钟周期以内。
6.4 用DWT做微秒级计时
SysTick只能做到1ms精度,如果要微秒级,可以用DWT(Data Watchpoint and Trace)的CYCCNT寄存器:
// 使能DWT CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; // 读微秒 uint32_t us = DWT->CYCCNT / 72; // 72MHz下DWT的CYCCNT是32位,72MHz下大概59秒溢出一次。做短时间测量非常方便,不需要占用任何外设。
6.5 中断服务函数的编写规范
中断服务函数要尽可能短,只做标志位设置和数据搬运,复杂处理放到主循环。我见过有人在串口中断里做协议解析,结果高波特率下丢数据。正确做法是中断里只把数据存入环形缓冲区,主循环再解析。
另外,中断服务函数里不要调用printf,因为printf可能阻塞,而且不可重入。如果一定要在中断里输出调试信息,用ITM(Instrumentation Trace Macrocell)或者简单的GPIO翻转。
7. 从寄存器到库函数:理解HAL在背后做了什么
7.1 HAL_NVIC_SetPriority的寄存器映射
HAL库的HAL_NVIC_SetPriority函数最终就是写NVIC->IP寄存器:
void HAL_NVIC_SetPriority(IRQn_Type IRQn, uint32_t PreemptPriority, uint32_t SubPriority) { uint32_t prioritygroup = NVIC_GetPriorityGrouping(); uint32_t priority = NVIC_EncodePriority(prioritygroup, PreemptPriority, SubPriority); NVIC_SetPriority(IRQn, priority); }NVIC_EncodePriority根据分组把抢占和子优先级拼成一个字节。理解了寄存器操作,再看这层封装就很清晰了。
7.2 HAL_EXTI和寄存器操作的对应
HAL库把EXTI封装成了GPIO_InitTypeDef的一部分,通过GPIO_MODE_IT_FALLING这样的宏来配置。底层调用的是EXTI_Init,最终还是写EXTI->IMR、EXTI->FTSR这些寄存器。知道寄存器怎么操作,调试HAL代码时就能直接看寄存器状态,不用猜。
7.3 什么时候该用寄存器,什么时候该用库
我的经验:学习阶段用寄存器,能真正理解硬件;项目开发用库,效率高、可移植性好。但用库的时候要保留看寄存器的能力,出问题时能直接查寄存器状态。两者不是对立的,而是互补的。
8. 常见问题排查清单
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 中断进不去 | NVIC未使能 | 读NVIC->ISER确认对应位为1 |
| 中断反复进 | EXTI->PR未清除 | 在中断入口处读PR并写1清除 |
| 优先级不生效 | 分组未设置或密钥未写 | 读SCB->AIRCR确认PRIGROUP值 |
| SysTick不计数 | CLKSOURCE或ENABLE未置位 | 读SysTick->CTRL确认bit0和bit2 |
| 按键触发多次 | 抖动未消 | 示波器看波形,加长消抖时间 |
| 中断嵌套异常 | 栈溢出 | 增大栈空间,减少嵌套层数 |
| 调试时中断乱跳 | 单步调试干扰 | 调试时关闭TICKINT,用硬件断点 |
这张表里的每一条都是我实际遇到过的。特别是“中断反复进”和“优先级不生效”这两个,新手几乎必踩。排查的时候不要靠猜,直接读寄存器,硬件不会骗你。
9. 我个人的几条经验
寄存器操作最忌讳的就是“抄了代码能跑就不管了”。我早期也是这样,EXTI配置能进中断就完事,直到有一次项目里两个中断优先级冲突,查了两天才发现是分组设错了。从那以后,我养成了一个习惯:每配置一个外设,就用调试器把相关寄存器的值读出来,和手册对照一遍。这个习惯帮我省了无数调试时间。
另一个体会是,SysTick虽然简单,但它是整个系统的时间基准。如果SysTick不准,所有基于时间的逻辑都会出问题。我一般会在系统启动后先用示波器测一下SysTick中断的频率,确认是1ms再往下做。这个验证步骤只要一分钟,但能避免后面很多莫名其妙的时序问题。
最后说一个细节:volatile关键字在中断相关代码里几乎无处不在,但很多人只是机械地加,不知道为什么要加。简单说,编译器看到volatile就不会把变量缓存到寄存器里,每次访问都从内存读。中断里修改的变量、主循环里读取的变量,都必须加volatile,否则编译器优化后可能永远读到旧值。这个坑我在早期项目中踩过,变量在中断里明明改了,主循环里就是不变,查了半天才发现是没加volatile。