首页 / 资讯中心 / 文章详情

Cortex-M3异常与NVIC中断控制:从优先级配置到调试实践全解析

Cortex-M3异常与NVIC中断控制:从优先级配置到调试实践全解析 ★ FEATURED ARTICLE
搞嵌入式开发这么多年Cortex-M3算是我用得最顺手也最想吐槽的一颗内核。说它顺手是因为NVIC把中断管理做得足够清晰向量表一套走天下说想吐槽是因为越往深处用越发现很多人其实只停留在“能点灯、能进中断”的阶段一旦碰上优先级配错、中断进不去、偶发卡死这类问题就开始瞎试。这篇文章我就想以Cortex-M3的异常类型为切入点把NVIC和中断控制这条线彻底捋一遍。这篇文章适合谁看刚接触Cortex-M3的初学者、已经写过几个外设驱动但没系统梳理过中断机制的人以及被某个诡异中断问题折磨过的人。我会把异常类型、优先级模型、向量表映射、实际配置步骤、还有那些调试中踩过的坑全部用大白话串起来讲。看完之后你至少能清楚地回答这几个问题Cortex-M3到底有多少种异常NVIC怎么决定先响应谁裸机环境下如何把中断优先级配置得明明白白1. 先理清楚Cortex-M3里到底有哪些“异常”1.1 异常类型全景先从一张表看懂全部异常很多人一上来就分不清“异常”和“中断”这两个词。在Cortex-M3的世界里中断其实是异常的其中一种。异常指的是一切打断CPU正常执行流程的事件而中断专指由外部信号或外设触发的那些异常。为了不绕晕我们直接看异常向量表。Cortex-M3的异常通道总共可以支持最多256个但实际按编号分成两大块编号0到15是系统异常编号16及之后是外部中断IRQ。注意0号并不是真正的异常它只是初始堆栈指针的存储位置。下面是完整的异常类型列表异常编号类型优先级默认说明0栈顶地址-初始SP值不是异常1Reset-3最高上电复位或按键复位后进入2NMI-2不可屏蔽中断通常接外部紧急事件3HardFault-1所有fault的统一入口优先级最高4MemManage可配置存储器管理异常MPU违规时触发5BusFault可配置总线错误取指或访问数据时地址错误6UsageFault可配置未定义指令、非法状态转换等7-10保留--11SVCall可配置系统服务调用由SVC指令触发12DebugMonitor可配置调试监视异常13保留--14PendSV可配置可挂起的系统服务常用于RTOS任务切换15SysTick可配置系统节拍定时器用于OS心跳或延时16外部IRQ可配置外设中断如串口、定时器、GPIO等这张表帮你看明白一件事Cortex-M3并不是把所有中断都一股脑交给NVIC系统异常和外部中断在优先级上天然存在高低之分。Reset和NMI的优先级是负数意思是谁也挡不住它们。HardFault的优先级是-1仅次于NMI这意味着如果你的代码触发了某种无法处理的fault最终都会落到HardFault手里。1.2 系统异常里的“冷门角色”在系统异常里SVCall、PendSV和SysTick这三兄弟值得单独拎出来说因为你一旦跑RTOS这三者就是最核心的依赖。SVCall编号11是由软件主动触发的一次“系统调用”它依赖SVC汇编指令。《Cortex-M3权威指南》里有个经典比喻用户程序通过SVC指令“敲门”让操作系统帮忙做特权操作。裸机环境下SVC用得少但在RTOS里任务切换、进入临界区都离不开它。PendSV编号14则像一个“可延后的系统服务请求”。为什么需要它设想一个场景在某个中断里你调用了任务切换函数但此时系统正处于中断处理中如果立刻切换任务中断返回后的上下文会混乱。PendSV提供了一个机制先置挂起位等所有更高优先级中断处理完毕后再由它执行真正的上下文切换。RTOS的上下文切换几乎都是靠PendSV完成的原因就在这。SysTick编号15我就不多说了它本身就是一个24位递减计数器而它的中断被设计为RTOS的节拍心跳也常被用来做软件延时。它的优先级默认在系统异常里算低的这样RTOS调度不会被它频繁打断。还有三个fault异常MemManage、BusFault、UsageFault。它们可以分别独立使能也可以配置为全部归并到HardFault。开发调试时我强烈建议单独使能它们并且写一个能打印fault状态寄存器的处理函数。很多时候hardfault_handler一进去就是死循环你根本不知道哪里出了问题但如果把MemManage、BusFault、UsageFault的独立入口都打开配合SCB寄存器里的Fault Status Registers就能精确定位到是取指错误、还是非对齐访问、还是除零。1.3 外部中断IRQ最常用的那部分外部中断IRQ是从编号16开始的具体数量取决于芯片厂商实现的NVIC位数。有些芯片是16个外部中断有些是60个、80个甚至更多。每个中断对应一个外设事件比如串口接收完成、定时器溢出、DMA传输完成、外部GPIO电平跳变。这里有个很常见的理解误区中断号不等于异常号。你在写NVIC配置函数时传入的参数通常是“IRQn”这个值是异常号减16。比如某颗芯片的第0个外部中断异常号是16但你在代码里看到的IRQn是0。所以如果哪天你在中断向量表里看到某个处理函数挂在了异常号20的位置别惊讶那对应的是IRQn4的外部中断。还有一个需要刻在脑子里的点外部中断的优先级全部由NVIC管理而系统异常里除Reset、NMI、HardFault之外的优先级由SCB系统控制块的SHPR寄存器配置。这两套配置入口是分开的。换句话说你给外部中断配置优先级要用NVIC那套寄存器给SysTick配置优先级得去操作SHPR别搞混了。2. NVIC核心机制解读它凭什么做到“嵌套”和“向量”2.1 NVIC到底做了什么NVIC的全称是Nested Vectored Interrupt Controller翻译过来就是嵌套向量中断控制器。三个关键词每个都有讲究。“嵌套”是指高优先级中断可以打断低优先级中断的执行。这个特性是硬件直接支持的不用你在软件里去保存和恢复现场压栈出栈由自动完成。如果你的代码里出现过“中断A还没执行完又被中断B打断B跑完再回来接着跑A”这种场景那就是NVIC的嵌套在起作用。“向量”则指中断处理函数的跳转方式。Cortex-M3不像一些老架构那样用一个统一的中断服务入口然后在软件里判断是哪个中断触发的而是直接把每个中断对应的处理函数地址放在向量表里。当中断触发时硬件根据中断号从向量表取地址然后直接跳转。这个动作也是硬件自动完成的不需要软件参与。“中断控制器”本质是一堆寄存器集合。NVIC内部主要以下几组功能寄存器使能与除能寄存器SETENA/CLRPENA、挂起与清挂起寄存器SETPEND/CLRPEND、活跃状态寄存器ACTIVE、优先级寄存器PRI。它们分工明确后面实操环节我会一个个演示。2.2 优先级模型抢占优先级和子优先级怎么配合Cortex-M3的优先级模型应该是整颗内核里最值得细抠的部分大多数初学者都在这里栽跟头。每个可配置异常都有一个优先级寄存器宽8位。但实际实现时大多数芯片并不会把所有8位全部用满常见的是只使用高3位、高4位或高5位剩下的位读出来是0。这就是为什么你配置优先级时明明写了一个数实际生效的可能是另一个值。更关键的模型在于优先级分组。通过修改SCB-AIRCR寄存器里的PRIGROUP字段可以把8位优先级拆成两部分高位是抢占优先级低位是子优先级。抢占优先级决定这个中断能不能打断另一个正在处理的中断数字越小优先级越高。如果两个中断的抢占优先级相同那它们之间不能互相打断只能比较子优先级来决定谁先被响应如果抢占优先级和子优先级都相同那就比较中断编号编号小的先响应。我举个例子你就明白了。假设某芯片使用4位优先级PRIGROUP配置为拆成2位抢占、2位子优先级。中断A抢占优先级0、子优先级0中断B抢占优先级0、子优先级3。如果A正在执行B来了B等不了A必须等A执行完才能进因为抢占优先级相同。反过来如果A的抢占优先级是0B的抢占优先级是1数字上B更大但优先级更低所以也A可以打断B。5组优先级分组定义PRIGROUP抢占优先级位数子优先级位数可表示的抢占级数量0全部8-x0最高抢占能力171-262-353-444常见535-626-717-有经验的做法是一上电就把分组配好后面永远别变。因为一旦改了分组所有中断的抢占和子优先级含义全部重排你之前精心配置的优先级表瞬间作废而且不会报错只会表现为某种“说不清”的时序问题。2.3 向量表中断响应的“跳转地图”向量表是一块驻留在Flash或RAM中的地址表每个条目4字节存的是对应异常处理函数的入口地址。向量表基址在Cortex-M3里默认是0x00000000但可以通过SCB-VTOR寄存器把它重定位。做Bootloader或者OTA升级时重定位向量表几乎是必经之路。举个例子你的Bootloader在0x08000000应用程序在0x08008000那App启动后第一件事就是把VTOR设置为0x08008000否则中断一进来CPU还在0x08000000附近找向量表App的中断全乱套。对应关系很简单向量表偏移 异常号 × 4存的内容 处理函数地址。一个外部中断比如USART1的IRQn是37那它的异常号是371653向量表地址就是基址53×4。在启动文件里你会看到一大串中断处理函数名比如USART1_IRQHandler、TIM2_IRQHandler。这些名字不是随便起的它们对应被链接器放进向量表特定位置。如果你在C代码里写了一个同名函数链接器就会让向量表指向它如果没写向量表通常指向一个默认的weak空函数。很多“进不了中断”的问题根源就是函数名拼错或没实现导致跳进了一个空循环。3. 中断控制实操从寄存器到代码的完整配置路径3.1 第一步优先级分组一次配好全系统前面的原理讲再多最后都要落到代码上。我在实际项目里的配置顺序是这样的。第一步永远是配置优先级分组。我习惯把整个系统的优先级分组固定为第4组也就是4位抢占优先级、4位子优先级。/* 配置优先级分组4位抢占优先级 4位子优先级 */ #define NVIC_PRIORITYGROUP_4 ((uint32_t)0x00000003) void System_PriorityGroup_Config(void) { /* 修改AIRCR寄存器时需要写入VECTKEY */ SCB-AIRCR (SCB-AIRCR ~(SCB_AIRCR_PRIGROUP_Msk)) | (NVIC_PRIORITYGROUP_4 SCB_AIRCR_PRIGROUP_Pos) | (0x5FA SCB_AIRCR_VECTKEY_Pos); }这里必须强调一点AIRCR的高16位有一个VECTKEY字段写的时候要填0x05FA读的时候不受影响。很多人第一次操作AIRCR时直接赋值整个寄存器结果VECTKEY不对要么写不进去要么进了hardfault。正确的做法是“读-改-写”把旧的PRIGROUP字段清掉再填新值。为什么我坚持用第4组因为在实际项目中抢占优先级往往只需要两三个等级就够了比如外部紧急中断抢占级最高、外设中断中等、SysTick和PendSV最低。剩下的位作为子优先级可以用来细化同一个抢占级别下的多个中断顺序。如果分组用反了比如把抢占优先级压缩成1位那系统中所有中断只能分为高低两档很多场景下根本不够用。3.2 第二步使能与配置中断优先级配置好系统级的分组后就可以逐个使能外部中断、设置它的优先级了。NVIC使能中断的核心操作是向ISER寄存器写1。ISER按位对应中断通道第0位对应IRQ0第1位对应IRQ1依此类推。当芯片外设中断数量超过32个时ISER会有多个寄存器ISER0、ISER1等。下面是一个典型的外设中断配置过程。假设我们要使能一个串口接收中断它的IRQ号是37void USART_IRQ_Enable(void) { uint32_t irq_number 37; /* 假设串口中断IRQn是37 */ /* 1. 设置中断优先级先写抢占优先级再写子优先级 */ uint32_t preempt_prio 1; /* 抢占优先级1数值越小越高 */ uint32_t sub_prio 0; /* 子优先级0 */ uint8_t priority_value ((preempt_prio 0xF) 4) | (sub_prio 0xF); /* 注意如果分组为444位抢占在高四位4位子优先级在低四位 */ NVIC_SetPriority(irq_number, priority_value); /* 2. 使能中断 */ NVIC_EnableIRQ(irq_number); }这里我强烈建议你直接使用CMSIS封装函数而不是自己去读写寄存器。CMSIS是ARM官方的软件抽象层提供了NVIC_EnableIRQ、NVIC_SetPriority等一组标准函数不同厂商的芯片都能复用。自己操作寄存器不是不行但代码可移植性会差很多而且容易踩寄存器位偏移的坑。我曾经在一颗自己不熟悉的芯片上把ISER和ISPR看花了眼中断没使能成反而把挂起位给置了现象是程序一跑就立刻进中断一脸懵。还有一个细节NVIC_SetPriority这个函数对于外部中断来说传IRQ号注意是IRQn不是异常号对于系统异常来说传的是负异常号比如SysTick传-1因为SysTick异常号是15。不同CMSIS版本对负号的处理可能略有差异用之前看一眼头文件里的实现。3.3 第三步编写中断服务函数如果使用标准启动文件中断服务函数的函数名是固定的。比如串口的处理函数名字必须叫USART1_IRQHandler链接器才能把它填进向量表。void USART1_IRQHandler(void) { if (USART1-SR USART_SR_RXNE) /* 接收数据寄存器非空 */ { uint8_t data USART1-DR; /* 读取数据同时会清除RXNE标志 */ ring_buffer_push(data); } /* 如果开启了发送完成中断记得处理TC标志 */ if (USART1-SR USART_SR_TC) { /* 做一些发送完成后的处理 */ } }中断服务函数里有几条铁律我每次带人都会反复强调第一不要在中断服务函数里做耗时操作比如大数组拷贝、软件延时、复杂的浮点计算。中断里占用的时间越长其他中断被阻塞的概率越大嵌套深度越深栈溢出的风险也越大。能用标志位通知主循环处理的就别在中断里干重活。第二凡是电平触发型的中断进中断后一定要手动清除标志位。很多外部中断EXTI和通信外设都要求软件清除中断标志如果漏掉中断会反复触发看起来就像程序卡死在中断里。第三中断服务函数返回值必须是void参数必须是空。写错了编译器不一定报错但链接阶段向量表格式会错程序跑飞是迟早的事。4. 深入中断响应过程压栈、尾链与晚到机制4.1 进入中断时硬件自动做了什么很多人写中断服务函数时并不会意识到CPU在进入函数之前已经替你完成了一大堆工作。这堆工作俗称“压栈”也就是把被打断现场的寄存器集体保存到当前栈中。Cortex-M3在响应异常时硬件会自动压栈这8个寄存器xPSR、PC、LR、R12、R3、R2、R1、R0。注意压栈的顺序是反着的R0在栈顶。为什么是8个因为中断服务函数通常是一个标准C函数它可能要用到R0-R3作为参数寄存器也可能用R12做临时寄存器LR要保存返回地址PC当然要保存当前执行的位置xPSR要保存运算状态标志。压栈完成后硬件会给LR赋一个特殊值EXC_RETURN用于标识中断返回模式和使用的栈指针。等中断服务函数执行完毕执行“异常返回”指令时硬件会自动出栈这8个寄存器把现场恢复。这一整套动作完全由硬件完成不需要软件干预。这也是Cortex-M3被称作“硬实时友好”的原因之一。栈空间的大小直接决定了你看不到却不得不防的“压栈崩溃”问题。函数嵌套调用本身要占栈中断压栈又要占栈如果再发生中断嵌套每一层嵌套都额外压一次8寄存器。我做过一个项目主循环里有个深层的函数递归同时开了两个中断且允许嵌套结果栈溢出表现是程序无缘无故跳进HardFault。后来我把启动文件里的Stack_Size从0x400改成0x1000问题消失。所以提醒你栈大小别省省事的代价就是排查玄学问题。4.2 尾链和晚到NVIC的两个“隐藏技能”在中断返回的过程中有一个经典场景中断A正在返回但此时中断B已经挂起等待。普通的处理方式是A完全出栈然后B再压栈进入一来一回两次栈操作浪费时间。Cortex-M3对此有一个硬件优化叫尾链Tail-Chaining。简单说如果A在返回时发现B已经挂起硬件不会恢复A的现场再给B压栈而是直接跳过A的出栈拿B的向量地址开始B的处理。这样省了一次完整的内存读写序列在频繁中断场景下能显著降低中断延迟。这个特性完全是硬件自动的但你在设计优先级时知道它的存在就行。另一个隐藏技能是晚到Late-Arriving。当一个中断开始压栈但还没完成压栈时如果来一个更高优先级的中断硬件会果断“转投”高优先级中断低优先级那个保持挂起状态。晚到特性保证了高优先级中断的响应速度不会被正在压栈的低优先级中断拖慢。这两个特性带来的实际影响体现在B、A两个连续中断的总处理时间指标上。在做音频、电机控制这种对时间敏感的项目时如果中断总处理时间出问题先检查是不是两个频繁触发的中断设计成了互相嵌套然后考虑能否调整优先级让它们变成尾链关系而不是嵌套关系。4.3 中断延迟实测代码层面可以做的优化中断延迟是指从异常事件发生到第一条中断处理指令开始执行的这段时间。Cortex-M3的中断延迟通常只有12个时钟周期左右大部分时间花在压栈上。如果再加上Flash等待周期和取指延迟实际值可能到20多个周期。代码层面能优化的空间有限但有一个点常常被忽略把最频繁使用的几个中断服务函数放到RAM里或加上__attribute__((section(.itcm)))这类属性如果芯片有可紧耦合内存可以减少取指延迟。另一点只在中断里访问你需要的数据不要从头到尾扫一遍外设寄存器那会白白增加总线访问时间。还有BASEPRI寄存器。它是一个掩码寄存器可以让优先级数值大于等于某个值的中断全部被屏蔽。在进入临界区时用__set_BASEPRI(threshold)而不是__disable_irq()是一个高明的做法。因为关键区只是不想被低优先级打断却可以让高优先级事件畅行无阻。这在实时系统里是个常见优化点。5. 中断开发中常见的坑与排查技巧5.1 进不了中断从根因开始逐层排查进不了中断是新手问得最多的问题。这个问题分成几层每一层都有对应检查点。第一层中断有没有真正的使能检查外设本身的中断使能位比如串口的RXNEIE以及NVIC的使能寄存器对应位。外设中断和NVIC中断是两个独立开关必须都打开。第二层中断服务函数写没写对检查函数名和启动文件中的向量名是否完全一致拼写和大小写都不能错。曾经有人在某款芯片的启动文件里看到的是USART1_IRQHandler自己写成了UART1_IRQHandler直接失效。第三层中断标志有没有在之前就被置上了有些外设的标志位是上电默认置位的如果你没清掉就开中断中断会立刻进来一次有时你把它当作异常。处理方法是先清一次标志再开中断。第四层是不是被优先级更高的中断一直在阻塞如果系统里存在一个高优先级中断它的服务函数执行时间非常长那么低优先级中断可能一直处于挂起状态看起来像“失效”。排查方法很简单先把高优先级中断的服务函数改成空函数测试一下如果低优先级中断能进来那就是高优先级中断占用时间太长。5.2 优先级反转一个很隐蔽的“逻辑陷阱”嵌入式里提到“优先级反转”通常想到的是RTOS里基于信号量的场景其实在裸机中断里也有类似问题。考虑这个场景高优先级中断A在等待一个数据标志该标志由中优先级中断B在稍后一点置为但B正在被低优先级中断C阻塞。此时A虽然抢占优先级最高但它实际上是在等B而B又进不来这就形成了一个低优先级C拖住高优先级A的“反转”局面。裸机下最常见的对策是任务关键的处理尽量放在中断里直接完成而不是中断里只置标志、主循环去轮询等待。如果设计上必须有这种跨中断的依赖那就要保证优先级设置时把参与协作的中断优先级尽量靠拢不要跨度拉得过大。另一个办法是把B的优先级设得比A高让B先跑完、处理完数据A再跑就不会有反转问题了。5.3 从HardFault里找出真正的肇事者Cortex-M3的fault机制是排查崩溃类问题的关键入口。当你看到程序跳进HardFault_Handler时第一步不是去看代码而是读取SCB-HFSR、SCB-CFSR和SCB-MMFAR、SCB-BFAR这几个寄存器看看是哪个fault引起的、发生在什么地址。以CFSR寄存器为例它实际上分成三段MMFSRMemManage Fault Status Register、BFSRBusFault Status Register、UFSRUsageFault Status Register。对应位的含义分别是MMFSR的IACCVIOL指令访问违规、DACCVIOL数据访问违规、MSTKERR异常压栈时的内存管理错误BFSR的IBUSERR指令总线错误、PRECISERR精确数据总线错误、IMPRECISERR非精确数据总线错误UFSR 的UNDEFINSTR未定义指令、INVSTATE无效状态、NOCP无协处理器、DIVBYZERO除零调试fault最理想的做法是在HardFault_Handler里放一段代码直接把CFSR内容和两个地址寄存器通过串口打出来或者保存到一个内存区域供调试器查看。这一步做得好很多无头绪的崩溃问题能在几分钟内锁定。如果使用的是RTOSHardFault往往发生在任务切换或者中断嵌套的时刻这时候栈里的现场信息比fault状态寄存器更关键。找出当前栈指针用调试器按Cortex-M3的压栈格式解读出R0、R1、R2、R3、R12、LR、PC、xPSR才能看到crash发生时到底在跑哪段代码。中断嵌套时要注意栈指针可能已经指向中断栈或任务栈不同的栈要分开看。5.4 调试中断的实用工具和思路逻辑分析仪到什么时候都是调中断时序的头号工具。有条件的就用带硬件断点的调试器ITM/SWO输出也是个好方法我这些年用得最多的还是SWO的printf重定向可以在不打断程序的情况下把关键变量实时打出来。另外观察IABRActive Bit Register寄存器也是个很实用的手段。它实时反映当前有哪些中断正在活跃状态。如果在调试器里发现某个中断位一直置1且状态寄存器显示它快要返回但始终不返回那多半是这个中断服务函数死循环了。对应查代码一定有一个while死等循环在里面。调试时建议关掉优化再编译一次特别是调试断点、单步时。优化开-O2或-O3变量被寄存器化现场恢复会变得非常难追踪。有些人喜欢Release下调试中断结果断点在奇怪的地址白白浪费一天时间。写在最后的一点个人体会把这些内容又梳理了一遍之后我自己也有一种温故知新的感觉。Cortex-M3的中断控制其实是一套高度统一、设计精良的硬件方案它把向量跳转、现场压栈、优先级仲裁全部硬件化让开发者把精力集中在业务逻辑上。可恰恰因为硬件做得太自动很多人出问题反而会忽略“读懂底层”的价值。我见过太多同行在一个看似莫名其妙的中断嵌套问题上熬了几个通宵最后发现不过是优先级分组被谁偷偷改过。所以我的切身建议是拿到任何一颗Cortex-M3内核的芯片先把异常向量表、优先级分组、NVIC寄存器这三个基础点吃透再开始写外设驱动。这份功夫不会白费它能帮你在未来所有基于这内核的项目里省下成倍的时间。
阅读完成 · 觉得有帮助?
咨询建站