ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Pico NVIC中断详解:向量表、NMI与优先级嵌套实战

Pico NVIC中断详解:向量表、NMI与优先级嵌套实战 之前在调一个 Pico 控制多路舵机的项目PWM 输出、UART 接收、DMA 搬运混在一起跑结果发现高优先级中断总能打断低优先级中断而且打断的时机和次数直接影响了协议解析的耗时。起初我以为是代码写得不够仔细后来把树莓派 Pico 的 NVIC 中断控制器完整翻了一遍才发现这里面涉及的中断向量表、NMI 合并、优先级嵌套每一个都值得单独拿出来讲清楚。这篇文章就按我排查的顺序来写主角是 RP2040 的 Cortex-M0 内核但其中大部分机制对其它 M0/M0 芯片同样适用适合正在用 Pico 做裸机开发、想彻底搞懂中断内部逻辑的读者。1. 从向量表开始Pico 上所有中断的“总目录”1.1 异常和外部中断其实共用一张表Cortex-M0 和传统单片机不太一样它把“复位”、“NMI”、“HardFault”、“SVCall”、“PendSV”、“SysTick”这些内核异常以及 GPIO、UART、Timer 等外设触发的外部中断统统放在同一张表里管理这张表就是中断向量表。RP2040 是标准 ARMv6-M 架构所以它的向量表格式也是固定的偏移 0x00 放初始栈指针偏移 0x04 放复位向量偏移 0x08 放 NMI 向量偏移 0x0C 放 HardFault 向量再往后是其它系统异常最后从偏移 0x40 开始连续 32 个外部中断向量对应 NVIC 的 IRQ0 到 IRQ31。向量号偏移地址异常/中断说明00x00初始 SP上电后从这一项加载主栈指针10x04Reset上电或复位后执行的第一段代码20x08NMI不可屏蔽中断入口只有一个30x0CHardFault各类硬件错误默认都进到这里110x2CSVCallSVC 指令触发140x38PendSV可挂起的系统服务调用150x3CSysTick系统节拍定时器160x40IRQ0~IRQ31外设中断比如 GPIO、UART、Timer、DMA很多教程会把“中断”和“异常”分开讲但在 M0 上没必要分那么死。它们本质都是“处理器根据向量号去向量表里找入口地址然后跳过去执行”唯一区别只是触发来源不同优先级配置规则不同。1.2 复位后第一件事CPU 去哪拿栈顶和第一条指令Pico 上电后CPU 复位硬件自动从 VTOR 指向的地址读取向量表第一项作为主栈指针再读第二项作为复位后要跳转的地址。默认情况下Cortex-M0 的 VTOR 值是 0x00000000所以处理器就是从 0x00000000 开始读向量表。这里有个容易混淆的点。RP2040 内部有一段 BootROMBootROM 本身也在 0x00000000 附近但 BootROM 完成引导后会通过地址重映射把 0x00000000 别名到外部 Flash 的 XIP 空间。也就是说当你的用户程序跑起来之后CPU 按默认 VTOR 读到的是 Flash 里的向量表而不是 BootROM 的内容。另外Pico SDK 的启动文件 crt0.S 在复位 handler 开头还会主动把 VTOR 指向__vectors这一步的目的是让程序无论从哪条路径跳进来都能保证向量表指针正确。很多人在 Pico 上做程序跳转时发现中断不工作多半就是跳过去之后没有重新设 VTOR。1.3 32 个外设中断号怎么和外设对应RP2040 的 NVIC 只有 32 个外部中断号但芯片上的外设远不止 32 个所以一个 IRQ 号上会挂多个外设中断源。比如 IO_BANK0 的 GPIO 中断全都合并到 IRQ7DMA 的四个通道共用 IRQ11UART0 和 UART1 各自独占 IRQ20 和 IRQ21Timer0 和 Timer1 分别挂在 IRQ26 和 IRQ27。这是理解 Pico 中断的第一个关键外设产生中断标志后先在各自外设的 INTR 寄存器里置位然后通过中断使能 INTEn 送到 NVIC 的某个 IRQ 输入。NVIC 里只有一个 IRQ 位所以要判断具体是哪个 GPIO、哪个 DMA 通道触发的必须进到中断回调里读外设状态寄存器去细分。2. 向量表在 Flash 里出生为什么要搬到 RAM2.1 链接脚本和 crt0 对向量表的默认处理在 Pico SDK 的默认工程里向量表由汇编文件 crt0.S 生成代码段被链接到 Flash 的最开头。链接脚本会在 Flash 起始位置定义__vectors符号然后把.isr_vectors段放进去。你可以直接在自己代码里声明extern uint32_t __vectors[]; extern uint32_t __vectors_end[];用调试器看 Flash 0x10000000 开头的数据就是这张表第一个 32 位整数是初始栈地址第二个是 Reset 函数地址。Pico SDK 的默认内存布局里 Flash 从 0x10000000 开始所以__vectors的值通常就是 0x10000000。向量表放在 Flash 的好处是省 RAM不需要在启动时做任何拷贝CPU 每次响应中断直接去 Flash 里查表取地址速度也没有问题。缺点是在运行时修改向量表不方便因为 Flash 不能像 RAM 一样随意写。比如你要做 OTA想先跳到 RAM 里的新代码再把中断向量指向新的处理函数改 Flash 就比较麻烦。2.2 用 VTOR 把向量表搬到 SRAM具体怎么操作Cortex-M0 提供了 VTOR 寄存器地址是 0xE000ED08专门用来设置向量表基地址。M0 内核没有这个寄存器M0 有这是 Pico 能做向量表重定位的基础。操作分三步第一步在 RAM 里准备一块 256 字节对齐的内存长度至少覆盖用到的向量。RP2040 完整向量表有 48 个入口也就是 48 个 32 位字192 字节按 256 对齐多留点余量更安全#define VECTOR_TABLE_WORDS 48 __attribute__((aligned(256))) static uint32_t vector_table_ram[VECTOR_TABLE_WORDS];第二步把 Flash 里的原表复制到 RAMvoid relocate_vector_table_to_ram(void) { uint32_t i; uint32_t count __vectors_end - __vectors; if (count VECTOR_TABLE_WORDS) { count VECTOR_TABLE_WORDS; } for (i 0; i count; i) { vector_table_ram[i] __vectors[i]; } // 写 VTOR 前先关中断避免重定位过程中产生中断 uint32_t irq_mask __disable_irq(); *(volatile uint32_t *)0xE000ED08 (uint32_t)vector_table_ram; __enable_irq(); }第三步验证。写完后可以先读回 VTOR确认低 8 位是 0因为 M0 的 VTOR 要求向量表基地址按 256 字节对齐。之前的代码里__attribute__((aligned(256)))已经保证了这一点但如果你是从某个结构体里取的地址没加对齐属性这一处很容易踩坑。2.3 向量表搬到 RAM 后有什么好处有什么代价最常见的好处是运行时可改写。你想动态更换某个中断的处理函数直接在 RAM 向量表里改对应表项就行不需要重新烧 Flash。第二个好处是某些低功耗场景下可以调用 ROM 或 bootrom 里的特殊功能需要临时切换向量表时也更灵活。代价是 RAM 被占了一块并且如果向量表在 RAM 里程序刚启动、RAM 还没初始化的时候不能立即反应中断所以要选一个合适的时机完成重定位。另外拷贝过程中如果来了中断处理器会从新地址读表而新地址里数据还没写完行为就会混乱所以写 VTOR 前必须暂时屏蔽中断或者干脆把拷贝和写 VTOR 放到进入主循环之前去做。3. NMI 合并一个入口对应多个不可屏蔽事件3.1 M0 的 NMI 和 M3/M4 有本质区别很多熟悉 STM32 的开发者第一次用 Pico 时会下意识找 NMI 引脚但 RP2040 没有独立 NMI 引脚。Cortex-M0 的 NMI 异常向量只有一个处理器把所有不可屏蔽事件源合并成一个 NMI 请求再送进 NVIC。这一点跟 M3/M4 的 NMI 引脚模型很不一样。更重要的一点是M0 上的 NMI 优先级固定为 -2高于包括 HardFault-1在内的所有异常和外部中断而且它不受 PRIMASK 屏蔽。也就是说你执行__disable_irq()之后NMI 依然可以进来。如果你没有提供 NMI handlerRP2040 默认会跳进一个空循环表现出来就是“板子像死了一样”在调试时极容易误判为硬件死机。Pico SDK 在 crt0.S 里给 NMI 向量填了一个弱符号isr_nmi默认实现就是死循环。所以你在实际工程里最好主动覆盖它哪怕里面只有一个 while(1)也比被默认实现吞掉强——至少你能在调试器里知道是 NMI 触发的。3.2 RP2040 里哪些情况会走到同一个 NMI 入口在 RP2040 上能触发 NMI 的信号通常包括调试请求、内核栈访问错误等紧急事件。这些信号在进入处理器之前已经被硬件合并成一根 NMI 线所以进入 NMI handler 之后你没法简单地通过某个固定寄存器读出“刚才到底是哪个硬件源”。不过NMI 是可以软件触发的。Cortex-M0 的系统控制块里有一个中断控制状态寄存器 ICSR地址 0xE000ED04它的第 31 位是 NMIPENDSET。向这一位写 1可以让 NMI 进入 pending 状态。由于 NMI 优先级最高一旦 pending当前指令执行完后就会立刻响应#define SCB_ICSR (*(volatile uint32_t *)0xE000ED04) #define ICSR_NMIPENDSET (1u 31) void trigger_nmi(void) { SCB_ICSR ICSR_NMIPENDSET; }我用这个方法做过一个“软件 NMI 合并”的实验把掉电检测、看门狗警告、温度过高这几个紧急事件都作用到同一个标志位上再统一触发一次 NMI进入同一套系统级紧急处理流程。这样做的价值在于NMI 不会被普通中断屏蔽即使主程序关中断关到一半紧急事件也能被响应。3.3 在 NMI handler 里怎么区分来源硬件层面合并之后软件层面必须自己区分。我的做法是在触发源对应的普通中断 ISR 里先记录一个事件标志再触发 NMIvolatile uint32_t nmi_event_flags; void gpio_emergency_isr(void) { // GPIO 掉电检测等紧急事件 nmi_event_flags | 1u 0; trigger_nmi(); } void wdt_isr(void) { // 看门狗警告 nmi_event_flags | 1u 1; trigger_nmi(); } void isr_nmi(void) { if (nmi_event_flags (1u 0)) { // 掉电处理 } if (nmi_event_flags (1u 1)) { // 看门狗处理 } // 处理完清标志 nmi_event_flags 0; }这里有一个技术细节要提醒NMI 一旦进入ICSR 里的 NMIPENDSET 位会被硬件清掉所以你不能在 NMI handler 里靠读这一位来区分“是谁触发的 NMI”。想区分就必须在软件层自己存标志而且这个标志的更新最好用原子操作避免在中断里被打断导致标志错乱。3.4 NMI 处理函数里的禁忌NMI 是最后的安全网但也是最容易把系统搞死的网。我建议在 NMI handler 里只做这些事读取关键寄存器并保存到断电不丢的区域、拉高一个紧急 GPIO、记录故障原因、然后复位或进入安全停机状态。不要在 NMI 里做这些事调用 printf 串口打印串口驱动本身可能就依赖中断、操作需要关中断保护的复杂临界区、在 NMI 里再次触发 NMI。M0 的压栈机制在嵌套时会占更多主栈空间如果主栈已经因为异常被压得很深NMI 里再做复杂操作栈溢出风险会成倍上升。4. 优先级嵌套Pico 上只有 2 位有效优先级4.1 RP2040 的优先级寄存器为什么说“只有 0~3”Cortex-M0 的每个外部中断都有一个优先级寄存器RP2040 里这些寄存器挂在 NVIC 的 IPR0 到 IPR7。每个寄存器是 32 位里面有 4 个 8 位优先级字节每个中断占一个字节。但是 M0 内核实际只实现其中一个子集。RP2040 的实现只用了每个优先级字节的高 2 位低 6 位读出来是 0写进去也不会生效。所以理论上你能配置的优先级范围是 0~255但真正有效的只有 0、1、2、3 四个档位。数值越小优先级越高。Pico SDK 的irq_set_priority函数接收 0 到 255 的整数但它内部只会把这个值的高 2 位写进硬件。你写 3、67、131 这些值硬件优先级其实都是同一个等级。搞清楚这一点能省掉很多排查问题的时间。4.2 M0 没有子优先级所以嵌套是“纯抢占嵌套”Cortex-M3/M4 上有一个优先级分组功能通过 AIRCR.PRIGROUP 可以把优先级位拆成抢占优先级和子优先级。子优先级只在同抢占优先级的中断同时 pending 时用做排队顺序不能抢占。这也让一部分教程里的“优先级嵌套”描述变得复杂。但 Cortex-M0 不支持优先级分组。ARMv6-M 架构里根本没有 PRIGROUP 配置位所有可用优先级位都是抢占优先级位。也就是说Pico 上的 0~3 档优先级全部具有抢占能力优先级 0 的中断可以打断正在执行的优先级 1、2、3 的中断优先级 1 可以打断 2 和 3以此类推。同优先级之间不能互相抢占只能排队。这一点对写中断代码有直接影响。如果你想让某个中断“绝对不被其它中断打断”只能把它设为 0但优先级 0 之间也会存在排队如果两个中断都是 0先到先执行后到的要等。没有“子优先级”来给它们排序。4.3 一次完整的中断嵌套过程到底发生了什么假设主程序正在执行一个低优先级的外设中断 A 触发过程如下NVIC 检测到 IRQ A pending且当前没有更高优先级中断活动于是向 CPU 申请中断。CPU 完成当前指令后压栈 8 个寄存器xPSR、PC、LR、R12、R3~R0把主栈指针指向栈顶然后从向量表取出 IRQ A 的入口地址。进入 ISR A 执行。此时处理器内部记录“当前活动中断优先级”为 A 的优先级。ISR A 还没执行完更高优先级 IRQ B 触发。NVIC 比较后认为 B 优先级更高允许抢占。CPU 再次压栈 8 个寄存器注意这次压的栈地址还是主栈相当于在 ISR A 的调用栈上又叠了一层上下文。取出 IRQ B 的入口地址进入 ISR B 执行。ISR B 执行完毕弹出压栈的上下文此时 CPU 返回的地址不是主程序而是 ISR A 被打断的那条指令。ISR A 继续执行直到完成再弹出第一层上下文真正回到主程序。这个过程中如果 B 的 ISR 很短整个抢占耗时主要来自第二次压栈和出栈。M0 支持 tail-chaining也就是如果 A 刚结束、B 还 pending处理器不会先出栈再压栈而是直接切到 B 的入口继续执行可以省掉一次压栈出栈。延时更小但逻辑上仍然是嵌套关系。4.4 设置优先级的 API 和常见误区Pico SDK 给外部中断设置优先级很简单irq_set_priority(GPIO_IRQ_BANK0, 0); // GPIO 中断最高 irq_set_priority(TIMER_IRQ_0, 2); // Timer0 次之 irq_set_priority(UART0_IRQ, 3); // UART0 最低需要注意的误区有三个。第一不要在中断回调里频繁调用irq_set_priority。它操作的是 NVIC 寄存器虽然耗时不大但如果在低优先级中断里去改另一个中断的优先级可能在热点代码里引入额外的不确定性。第二Pico SDK 里有不少库函数内部会临时屏蔽中断。比如i2c_write_blocking在某些实现里会调用save_and_disable_interrupts如果你在一个优先级较低的中断里调用这类函数而更高优先级中断同时到来虽然硬件可以抢占但高优先级中断里如果也访问同一个外设就可能出现互斥问题。第三不要把优先级设置为 255 然后指望它“最低”。前面说过有效档位只有 0~3255 的高 2 位折算下来是 192对应的硬件档位其实是 3和 3 的效果一样。阅读代码时不要被数值迷惑。5. 实战验证PWM 打断 UART观察真正的抢占现场5.1 目标与工程准备为了验证优先级嵌套我做了一个最小实验硬件定时器 Timer0 以 1 kHz 频率触发中断每次进入时拉高一个 GPIO、翻转一次模拟高优先级周期性任务GPIO 上升沿中断模拟低优先级突发任务在回调里故意跑一个约 200 微秒的耗时循环用来观察高优先级定时器中断能不能打断它以及低优先级任务的执行时间会不会被拉长。工程上只需要在 Pico SDK 工程里包含两个头文件#include pico/stdlib.h #include hardware/irq.h #include hardware/timer.h #include hardware/gpio.h5.2 编写可复现的嵌套 Demo先编写两个中断回调。Timer0 的中断优先级设为 0GPIO 中断优先级设为 2static volatile uint32_t timer_count; static volatile uint32_t nested_count; void timer0_isr(void) { timer_hw-intr 1u 0; // 清中断标志 // 高优先级任务翻转 GPIO0 gpio_put(0, !gpio_get(0)); // 如果当前有低优先级 ISR 正在执行说明发生了嵌套抢占 if (in_low_prio_isr) { nested_count; } timer_count; } static volatile bool in_low_prio_isr; void gpio_isr(void) { uint32_t events gpio_get_irq_event_mask(22); if (events GPIO_IRQ_EDGE_RISE) { gpio_acknowledge_irq(22, GPIO_IRQ_EDGE_RISE); in_low_prio_isr true; // 用一段耗时循环模拟低优先级协议解析 volatile uint32_t delay 2000; while (delay--) { __asm volatile (nop); } in_low_prio_isr false; } }然后在 main 函数里配置中断优先级和使能int main(void) { stdio_init_all(); gpio_init(0); gpio_set_dir(0, GPIO_OUT); gpio_init(22); gpio_set_dir(22, GPIO_IN); gpio_pull_up(22); // 配置 GPIO22 上升沿触发 gpio_set_irq_enabled(22, GPIO_IRQ_EDGE_RISE, true); irq_set_priority(GPIO_IRQ_BANK0, 2); irq_set_enabled(GPIO_IRQ_BANK0, true); // 配置 Timer01 kHz timer_hw-timer_hw 0; timer_hw-alarm[0] timer_hw-timerawl 1000; irq_set_priority(TIMER_IRQ_0, 0); irq_set_enabled(TIMER_IRQ_0, true); while (1) { tight_loop_contents(); } }这里要提醒timer_hw-intr清中断标志必须放在回调最前面否则中断会被连续触发导致你观察到的时间基准完全失真。另外低优先级和高优先级中断回调里都最好别用printf串口输出本身优先级低而且会引入其它中断干扰实测数据。5.3 实测现象和调整优先级之后的结果实测时我用逻辑分析仪同时抓 GPIO0 和 GPIO22 的波形。GPIO0 是 1 kHz 方波GPIO22 是按键触发的一次低优先级任务。GPIO22 处于高电平的过程中可以清楚看到 GPIO0 的电平穿插翻转说明高优先级 Timer0 确实打断了正在执行的 GPIO 中断回调。如果把 Timer0 优先级改成 3和 GPIO 中断同优先级甚至更低GPIO0 的翻转就被迫等到 GPIO22 处理完整个嵌套现象消失但低优先级任务的响应时间变长了。这个实验说明了两个关键点。第一M0 的抢占是真的高优先级中断可以嵌套到低优先级中断中间而低优先级中断自身并没有感知。第二代码里必须对“被中断打断”有心理预期不能假设某个中断回调从开始到结束是不被打断的。如果低优先级中断里维护一个多字节状态变量而高优先级中断也会改这个变量需要加入临界区保护否则低优先级中断恢复后看到的中间状态可能完全不一致。6. 调试与避坑记录6.1 优先级看起来没生效的几种原因最常见的“优先级没生效”其实是中断源搞错了。比如 GPIO 中断的 IRQ 号是 IRQ7也就是GPIO_IRQ_BANK0不是某个具体的 GPIO 引脚号。你给GPIO_IRQ_BANK0设置了优先级但代码里又用 SDK 的高级 API 重新注册了一次回调可能又把优先级覆盖了。第二个常见原因是 SDK 的共享中断处理器机制。Pico SDK 支持irq_add_shared_handler多个回调可以挂在同一个 IRQ 上。共享处理器的分发顺序由注册顺序决定但它们的优先级都取决于这个 IRQ 的优先级。如果你以为每个回调能单独配优先级那就错了。第三个原因是irq_set_priority对系统异常和外部中断的处理不同。SDK 内部对不同范围的参数做了不同分支外部中断通过 NVIC IPR 寄存器设置系统异常则走 SCB 的 SHPR 寄存器。你把系统异常当成外部中断配置数值可能写进了错误的地方。6.2 NMI 进来之后板子像死了一样怎么定位如果程序突然没有任何输出而且调试器暂停后发现 PC 停在一个死循环里第一件事就是看当前 PC 是不是落在默认 NMI handler 上。在调试器里查看向量表偏移 0x08 处的地址再对比当前 PC。如果 PC 等于这个地址十有八九是 NMI 触发了。处理方法分两步。第一步先屏蔽 NMI 源检查看门狗、调试请求、软件触发的 NMIPENDSET 位把可疑来源处理掉。第二步再补齐自己的 NMI handler哪怕先放一个空的 while(1)也可以避免被默认实现吞掉。6.3 向量表搬到 RAM 后中断跑飞只要出现“搬完向量表随便一个中断就 HardFault”的情况优先检查对齐。M0 的 VTOR 要求基地址按 256 字节对齐如果我用__attribute__((aligned(256)))定义数组肯定没问题。但有人会用malloc分配内存或者把向量表嵌进某个结构体地址就很难保证对齐。另一个隐蔽问题在双核工程里。RP2040 的两个 M0 内核各有独立的 VTOR你在 core0 上重定位了向量表core1 如果不单独设置它仍然指向旧地址。多核程序里任何内核自己的中断都走自己的向量表外设中断输入给哪个核由外设的中断目标寄存器决定不要混为一谈。6.4 中断回调里调用 SDK 函数导致的隐性问题最后分享一个我踩过很多次的坑在中断回调里调用printf、I2C、SPI这类带轮询等待的函数。Pico SDK 的不少底层驱动会临时屏蔽中断如果在低优先级中断里调用而高优先级中断需要访问同一外设可能产生死锁或数据错乱。我的惯例是中断回调里只做最快速度的标志记录和数据搬运真正的协议解析放到主循环或任务级代码里做。Pico 的 NVIC 虽然允许高优先级中断嵌套低优先级中断但工程上能用一个标志解决问题的时候就不要用打断来“炫技”。这轮把 Pico 的向量表、NMI、优先级全部拆完并实测之后我最大的感受是Cortex-M0 的 NVIC 看起来简单只有 32 个外部中断、2 位有效优先级但正因为简单它的行为反而很直白。你只要把握住“向量表决定去哪”、“优先级决定谁先谁后”、“NMI 是最后防线”这三件事绝大多数中断问题都能一眼定位。以后再做 Pico 上的复杂外设调度我会先把中断优先级表和向量表重定位方案画出来再开始动手写代码。
返回列表