网关要同时收 Modbus、跑 MQTT、喂看门狗,关节驱动要跑电流环,中断一多就丢数据、丢步——问题往往不在代码,而在对 Cortex-M3 异常模型和 NVIC 的不了解。本文基于 Cortex-M3 技术参考手册,讲清异常类型与优先级、PRIGROUP 八档优先级分组、末尾连锁 6 周期与迟来抢占、位带 原子操作映射公式、NVIC 寄存器映射与配置代码,附 WFI 低功耗与落地踩坑。
很多嵌入式项目到了集成阶段才发现:串口偶发丢包、电机电流环偶尔丢步、低功耗终端被莫名唤醒——查到最后,常常不是外设配置错,而是没搞懂内核的异常模型。结论先给三条:第一,Cortex-M3 的异常是"硬件自动压栈、硬件自动取向量",响应快是因为硬件替你做了这些事,你要做的是别把它挡住;第二,中断多的时候真正决定响应快慢的是优先级分组和末尾连锁——理解它们,才知道为什么两个中断背靠背只要 6 个周期;第三,多任务共享的标志位要么用位带原子操作,要么用临界区,别指望普通读改写是原子的。下面按异常类型、优先级、分组、末尾连锁、位带、寄存器、低功耗、落地七步讲透。
先看两个真实约束。物联网网关的典型任务是:一路 Modbus RTU 收下行、一路 4G 模块收发、MQTT 保活、按键和看门狗——这些全是中断源,任何一个被拖慢,表现就是丢帧或掉线。机器人关节的 FOC 电流环更苛刻,要求 8~20kHz 的固定周期采样与计算,中断响应抖动直接变成转矩脉动。这两类场景对"中断响应有多快、多确定"的要求,都落在同一套硬件机制上。
Cortex-M3 是 ARMv7-M 架构,专为嵌入式实时控制设计,它的异常处理有几个关键设计:硬件自动保存/恢复寄存器状态、紧耦合的 NVIC、可配置 3~8 位的优先级、支持末尾连锁和迟来中断。这套机制把传统 MCU 里靠软件保存现场、跳转向量表、判断优先级的开销全部硬件化,从而把中断响应压到十几个周期量级、把两个中断之间的切换压到 6 个周期。
Cortex-M3 的"异常"是一个比"中断"更大的集合,包含内核异常和外部中断。每个异常在向量表里都有一个固定位置(字偏移),数字越小的优先级越高,其中复位、NMI、硬故障这三个负优先级异常是写死的、不可被软件优先级覆盖。
| 异常类型 | 向量表位置 | 优先级 | 触发方式 |
|---|---|---|---|
| 复位(Reset) | 1 | −3(最高) | 异步,上电/热复位 |
| 不可屏蔽中断(NMI) | 2 | −2 | 异步,不能被除复位外的异常占先 |
| 硬故障(HardFault) | 3 | −1 | 同步,所有被禁止上抛的故障汇总 |
| 存储器管理(MemManage) | 4 | 可调 | 同步,MPU 不匹配 |
| 总线故障(BusFault) | 5 | 可调 | 同步或不精确时异步 |
| 使用故障(UsageFault) | 6 | 可调 | 同步,未定义指令/非法状态转换 |
| SVCall | 11 | 可调 | 同步,SVC 指令调用系统服务 |
| PendSV | 14 | 可调 | 异步,仅由软件挂起 |
| SysTick | 15 | 可调 | 异步,系统节拍定时器 |
| 外部中断 IRQn | 16 及以上 | 可调 | 异步,经 NVIC 输入,共 240 个 |
这张表最该记住的是最后一行:外部中断从位置 16 开始,共 240 个(INTISR[239:0])。软件优先级通过中断优先级寄存器里 8 位的 PRI_N 区设定,取值 0~255,0 最高、255 最低。一个容易被忽略的规则是:硬件优先级随中断号增加而降低,但一旦你指定了软件优先级,硬件优先级就失效了——比如把 INTISR[0] 设成优先级 1、INTISR[31] 设成优先级 0,那 INTISR[31] 反而比 INTISR[0] 高。另外,软件优先级对复位、NMI、硬故障无效,它们永远比外部中断高。如果两个中断优先级相同,则由硬件优先级(中断号小的)决定先后。
中断一多,光有 0~255 的高低不够用,因为很多中断之间只是"谁先响应"的差别,并不需要互相抢占。NVIC 用优先级分组解决这个问题:通过应用中断和复位控制寄存器(0xE000ED0C)里的 PRIGROUP 区,把每个 PRI_N 的 8 位劈成占先优先级区(组优先级)和次优先级区。同组优先级的中断之间不会互相抢占,只在同组内按次优先级排队——这正是 RTOS 和复杂网关需要的层次。
| PRIGROUP[2:0] | 二进制点位置 | 占先区 | 次优先级区 | 占先级数 / 次级数 |
|---|---|---|---|---|
| b000 | xxxxxxx.y | [7:1] | [0] | 128 / 2 |
| b001 | xxxxxx.yy | [7:2] | [1:0] | 64 / 4 |
| b010 | xxxxx.yyy | [7:3] | [2:0] | 32 / 8 |
| b011 | xxxx.yyyy | [7:4] | [3:0] | 16 / 16 |
| b100 | xxx.yyyyy | [7:5] | [4:0] | 8 / 32 |
| b101 | xx.yyyyyy | [7:6] | [5:0] | 4 / 64 |
| b110 | x.yyyyyyy | [7] | [6:0] | 2 / 128 |
| b111 | .yyyyyyyy | 无 | [7:0] | 0 / 256 |
怎么用?如果系统里中断不多、希望任意两个中断都能互相抢占,就选 b000(128 级占先);如果希望一部分中断"只排队不抢占"来避免嵌套过深,就选中间的档位,比如 b011(16 占先 / 16 次)或 b100(8 / 32)。RTOS 常用的做法是留出足够占先级给硬实时中断,其余按次优先级排序。还有个细节:如果处理器只用少于 8 位来配优先级,寄存器的低位恒为 0——比如用 4 位配置时 PRI_N[7:4] 有效、PRI_N[3:0] 恒为 0,所以写优先级值时要左对齐。
中断响应快,一半靠硬件压栈,另一半靠两套加速机制。
末尾连锁(Tail-chaining)解决的是"一个 ISR 刚结束、另一个中断已经挂着"的场景。正常退出 ISR 要出栈 8 个寄存器、进入新 ISR 又要压栈 8 个寄存器;但如果挂起中断的优先级比所有被压栈的异常都高,处理器就跳过这 8 个寄存器的出栈和压栈,直接取出挂起中断的向量。从上一个 ISR 返回到新 ISR 开始执行,中间只隔 6 个周期——这就是"背靠背"处理中断的开销。
迟来(Late-arriving)解决的是"压栈还没完成、更高优先级中断就来了"的场景。如果前一个 ISR 还没进入执行阶段,而新中断优先级更高,处理器会转去处理优先级更高的中断,重新取向量。关键在于:迟来中断不重复保存状态,因为状态保存已经被最初的中断做过了,两个中断的保存内容一样,压栈继续执行不会被打断。
| 机制 | 触发时机 | 省掉了什么 | 典型收益 |
|---|---|---|---|
| 占先(Pre-emption) | 新异常优先级高于当前流程 | —(正常嵌套) | 高优先级事件及时响应 |
| 末尾连锁 | 退出 ISR 时有更高优先级挂起 | 8 个寄存器的出栈 + 压栈 | 两中断切换仅约 6 个周期 |
| 迟来 | 压栈过程中来了更高优先级中断 | 重复的状态保存 | 抢占几乎不额外花时间 |
理解这两条,就能解释很多"看起来矛盾"的实测结果:为什么两个中断背靠背跑,总耗时远小于"两倍单次中断开销";为什么高优先级中断几乎能"插队"进来而不增加抖动。它们都建立在同一个前提上——中断服务程序足够短,否则再快的切换机制也救不了长 ISR 带来的延迟。
网关里一个标志位(比如"收到新帧")经常同时被主循环读、被中断改,普通 `flag |= 1;` 是读-改-写三步,可能被打断。Cortex-M3 的位带(bit-banding)提供了硬件级原子操作:把 SRAM 和外设各 1MB 的最低区域,映射成两个 32MB 的别名区,对别名区某个字的一次写操作,等价于对目标位做原子读-修改-写。
映射公式是(对应手册 4.2 节):
bit_word_offset = (byte_offset × 32) + (bit_number × 4)
bit_word_addr = bit_band_base + bit_word_offset
其中 byte_offset 是目标字节相对 bit-band 区基址的偏移,bit_number 是目标位(0~7),bit_band_base 是别名区基址。写别名区的字,只有位 0 起作用:写 0x01 与写 0xFF 效果相同(置 1),写 0x00 与写 0x0E 效果相同(清 0);读别名区返回 0x01(置位)或 0x00(清零)。两个别名区的地址范围如下。
| 区域 | bit-band 区(1MB) | 别名区(32MB) |
|---|---|---|
| SRAM | 0x20000000 – 0x200FFFFF | 0x22000000 – 0x23FFFFFF |
| 外设 | 0x40000000 – 0x400FFFFF | 0x42000000 – 0x43FFFFFF |
把公式写成宏,用起来就非常顺手:
/* Cortex-M3 位带别名地址计算(手册 4.2 节公式)
* bit_word_addr = bit_band_base + byte_offset*32 + bit_number*4
*/
#define BITBAND_SRAM_BASE 0x22000000UL
#define BITBAND_PERIPH_BASE 0x42000000UL
#define BITBAND_SRAM(addr, bit) \
(*(volatile unsigned long *)(BITBAND_SRAM_BASE + \
(((unsigned long)(addr) - 0x20000000UL) * 32UL) + ((bit) * 4UL)))
#define BITBAND_PERIPH(addr, bit) \
(*(volatile unsigned long *)(BITBAND_PERIPH_BASE + \
(((unsigned long)(addr) - 0x40000000UL) * 32UL) + ((bit) * 4UL)))
/* 例:把 SRAM 中某字节的 bit2 置 1,一条 STR 指令完成,天然原子 */
volatile unsigned char g_flags;
#define FLAG_RX_NEW 2
void isr_set_rx_flag(void)
{
BITBAND_SRAM(&g_flags, FLAG_RX_NEW) = 1; /* 原子置位 */
}
int main_loop_poll(void)
{
if (BITBAND_SRAM(&g_flags, FLAG_RX_NEW)) { /* 原子读取 */
BITBAND_SRAM(&g_flags, FLAG_RX_NEW) = 0;
/* ... 处理新帧 ... */
}
return 0;
}
注意一个手册里的硬性提醒:采用大端格式时,对 bit-band 别名区的访问必须以字节方式进行,否则访问值不可预知。另外,位带只覆盖 SRAM 和外设最低的 1MB,超出范围的地址不能套用别名区。
NVIC 空间按功能划分得很规整(手册第 8 章):0xE000E000–0xE000E00F 是中断类型寄存器,0xE000E010–0xE000E0FF 是 SysTick 系统定时器,0xE000E100–0xE000ECFF 是 NVIC 本体,0xE000ED00–0xE000ED8F 是系统控制模块,0xE000EF00–0xE000EF0F 是软件触发异常寄存器。常用的几个寄存器地址背下来,排障时直接看内存就能判断状态。
| 寄存器 | 地址 | 作用 |
|---|---|---|
| SysTick 控制与状态 | 0xE000E010 | 系统节拍定时器控制 |
| Irq0~31 使能设置 | 0xE000E100 | 写 1 使能对应外部中断 |
| Irq0~31 使能清除 | 0xE000E180 | 写 1 关闭对应外部中断 |
| Irq0~31 挂起设置 | 0xE000E200 | 软件挂起对应中断 |
| Irq0~31 挂起清除 | 0xE000E280 | 清除挂起状态 |
| Irq0~31 优先级 | 0xE000E400 | 每字节一个中断的 PRI_N |
| 向量表偏移(VTOR) | 0xE000ED08 | 重定位向量表(Bootloader/OTA 常用) |
| 应用中断/复位控制 | 0xE000ED0C | 含 PRIGROUP 优先级分组 |
| 系统控制寄存器 | 0xE000ED10 | SLEEPONEXIT / SLEEPDEEP |
| CPUID 基址寄存器 | 0xE000ED00 | 只读,用于识别内核型号 |
下面这段直接操作寄存器,设置优先级分组为 b011(16 占先 / 16 次),并把两个中断源配成可嵌套:
/* 网关:配置 NVIC 优先级分组与两个中断的占先优先级
* 目标分组 PRIGROUP = b011 → 16 级占先 / 16 级次优先级
*/
#define NVIC_ISER0 (*(volatile unsigned long *)0xE000E100UL)
#define NVIC_IPR ((volatile unsigned char *)0xE000E400UL)
#define SCB_AIRCR (*(volatile unsigned long *)0xE000ED0CUL)
#define SCB_AIRCR_PRIGROUP_Pos 8
#define SCB_AIRCR_PRIGROUP_Msk (7UL << SCB_AIRCR_PRIGROUP_Pos)
#define SCB_AIRCR_VECTKEY (0x5FAUL << 16)
void nvic_priority_group_set(void)
{
unsigned long reg = SCB_AIRCR;
reg &= ~(SCB_AIRCR_PRIGROUP_Msk | (0xFFFFUL << 16));
reg |= SCB_AIRCR_VECTKEY | (3UL << SCB_AIRCR_PRIGROUP_Pos); /* b011 */
SCB_AIRCR = reg; /* 写 AIRCR 需要 0x5FA 访问钥匙 */
}
/* IRQn 是外部中断号;prio 为 8 位 PRI_N 值,数值越小优先级越高 */
void nvic_set_irq_priority(unsigned char irqn, unsigned char prio)
{
NVIC_IPR[irqn] = prio; /* 每字节一个中断优先级 */
}
void nvic_enable_irq(unsigned char irqn)
{
NVIC_ISER0 = (1UL << (irqn & 0x1F)); /* 写 1 使能,避免读改写 */
}
写 AIRCR 有个坑必须记住:高 16 位要写入访问钥匙 0x5FA,否则写操作被忽略——这是内核防误写的机制,很多人配了 PRIGROUP 却没生效,就是漏了钥匙。使能中断时用"使能设置寄存器写 1"而不是读改写,天然避免了多任务竞争。
电池供电的物联网终端大部分时间在睡觉,异常模型和低功耗是连在一起的。Cortex-M3 支持三种睡眠机制:
| 睡眠机制 | 触发方式 | 退出条件 |
|---|---|---|
| 立即睡眠 | 执行 WFI 或 WFE 指令 | NVIC 收到新的中断/事件即退出 |
| 退出时睡眠(SLEEPONEXIT) | 系统控制寄存器 SLEEPONEXIT 置位 | 退出最低优先级 ISR 后自动进入,等价于自动 WFI |
| 深度睡眠(SLEEPDEEP) | 系统控制寄存器 SLEEPDEEP 置位 | 与前两种共用,指示系统可进入更深低功耗态 |
处理器导出两个信号指示睡眠状态:SLEEPING 在立即睡眠或退出时睡眠模式下有效,表示处理器时钟可以停;收到新中断后 NVIC 会让它失效,把内核唤醒。SLEEPDEEP 在 SLEEPDEEP 位置位时有效,送给时钟管理器用来门控处理器和锁相环。用 SLEEPING 去门控 HCLK 是低功耗终端省电的关键一步。要注意 WFI 的一个使用纪律:不要用 WFI 去"探测"异常是否发生——即使没有异常被激活,WFI 也会执行,它通常放在线程模式的空闲循环里。
把上面这些机制用进真实项目,有几个高频坑值得记下来。第一,优先级值要左对齐:芯片实际实现的优先级位数可能少于 8 位,写 PRI_N 时低位会被硬件忽略,所以习惯上把优先级值左移到高位再写。第二,别把中断全设成同一优先级:那样末尾连锁和迟来就失去了意义,高实时事件会被普通事件拖住;用 PRIGROUP 把"必须抢占"和"排队即可"分开。第三,ISR 要短:末尾连锁省的是寄存器出入栈,省不了长 ISR 本身的执行时间,重活要丢给主循环或 PendSV。第四,共享变量要么位带要么临界区:`flag++` 这类操作在多中断环境下必然出问题,位带只解决单个位的置位/清零,多字节结构的保护还得靠 PRIMASK 临界区或 RTOS 互斥。第五,OTA 升级要动 VTOR:向量表偏移寄存器(0xE000ED08)是 Bootloader 跳转 App 的关键,App 的向量表基址必须与链接脚本一致,否则中断会飞到错误地址。
把 STM32 的中断做快做稳,逻辑可以压成三步:先理解异常模型——复位/NMI/硬故障是写死的最高优先级,外部中断从向量表位置 16 开始、共 240 个,软件优先级 0 最高;再配好优先级与分组——用 PRIGROUP 把占先级和次优先级分开,让实时事件能抢占、普通事件只排队,别把所有中断塞在同一级;最后守住两个细节——多任务共享标志位用位带原子操作或临界区,低功耗终端用 WFI 配合 SLEEPING/SLEEPDEEP 门控时钟。
工程上再记三件事:写 AIRCR 别忘了 0x5FA 访问钥匙、ISR 尽量短、OTA 跳转时确认 VTOR 与链接脚本一致。把这三件事做扎实,一套在网关和关节驱动里都扛得住的中断系统就立起来了。
STM32 中断响应之所以能压到十几个周期、两个中断背靠背只要 6 个周期,靠的是硬件自动压栈取向量加 NVIC 的末尾连锁与迟来;软件要做的不是"更快地保存现场",而是别挡住硬件、把优先级分组配对、让共享数据原子化。
本文基于《Cortex-M3 技术参考手册》等技术资料整理优化,并结合物联网网关与机器人关节驱动等嵌入式项目的工程实践进行了系统化梳理。
沧州艾诺威电子 — 国家高新技术企业,20+项国家专利
嵌入式系统开发 · 物联网方案 · AI智能硬件 · 一站式交付
电话:13930711029 | 邮箱:tech@czinv.com | 24小时内响应