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值抢占优先级位数子优先级位数抢占级数子级数00x7000411610x600132820x500224430x400318240x30040161分组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-3EXTICR[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-20msEXTI会在抖动期间触发多次中断。常见做法是在中断里做软件消抖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. SysTickCortex-M内核自带的“心跳发生器”4.1 SysTick的定位不是普通定时器是系统节拍SysTick是Cortex-M内核里的一个24位递减计数器专门用来产生系统节拍。它和TIM2、TIM3那些外设定时器有本质区别SysTick的寄存器在0xE000E010属于内核私有外设所有Cortex-M芯片都有移植性好外设定时器每个芯片型号不同寄存器地址和功能都有差异。SysTick有4个寄存器寄存器地址偏移作用CTRL0x00控制寄存器LOAD0x04重装载值VAL0x08当前计数值CALIB0x0C校准值一般不用CTRL寄存器的位定义bit0 ENABLE使能计数器bit1 TICKINT计数到0时产生中断bit2 CLKSOURCE0外部时钟AHB/81内核时钟AHBbit16 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); // CLKSOURCE1, TICKINT1, ENABLE1为什么减1因为计数器从LOAD值递减到0然后下一个周期重新加载。从71999到0一共72000个计数正好1ms。如果CLKSOURCE选0时钟变成72MHz/89MHz那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可能会优化掉读取操作导致死循环。这个延时函数在中断里调用会有问题——如果中断优先级高于SysTickg_systick_ms不增加就死等了。所以中断里不要用这个延时要用就先用SysTick-VAL做忙等待。4.4 SysTick和FreeRTOS的配合如果你用FreeRTOSSysTick会被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抢占优先级0EXTI0抢占优先级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优先级0EXTI0优先级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精度如果要微秒级可以用DWTData 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可能阻塞而且不可重入。如果一定要在中断里输出调试信息用ITMInstrumentation 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。