第3章 中断系统入门:Cortex-M中断向量表与NVIC配置
中断,说白了就是让CPU放下手头的事,先去处理更紧急的任务。我在做嵌入式开发的头两年,总觉得中断就是个“回调函数”,后来被坑了几次才明白——中断系统的设计,直接决定了你的实时性能不能达标。
这一章,我们聚焦Cortex-M系列的中断机制。从向量表布局,到NVIC控制器的寄存器操作,再到优先级分组和中断延迟的量化分析。嗯,内容有点硬,但都是实战中绕不开的。
3.1 中断向量表:CPU的“紧急通讯录”
Cortex-M的向量表,本质上是一张地址表。每个中断源对应一个表项,表项里存放的是中断服务函数(ISR)的入口地址。CPU响应中断时,硬件自动从向量表里取出地址,跳转执行。
这张表固定在存储器的起始位置,通常是0x00000000。表的前16个条目是系统异常,从第16个条目开始才是外部中断。
| 偏移地址 | 异常/中断号 | 功能描述 |
|---|---|---|
| 0x00 | - | 栈顶指针(MSP初始值) |
| 0x04 | 1 | Reset |
| 0x08 | 2 | NMI |
| 0x0C | 3 | HardFault |
| 0x10 - 0x3C | 4-15 | 其他系统异常 |
| 0x40 + (n-16)*4 | 16+n | 外部中断IRQn |
#[link_section = ".vector_table"]属性把向量表放到正确的位置。别用C的弱符号那一套,Rust的显式声明更安全。
3.2 NVIC控制器:中断的“调度中心”
NVIC(Nested Vectored Interrupt Controller)是Cortex-M内置的中断控制器。它负责中断的使能、挂起、优先级裁决和嵌套管理。说白了,所有外部中断的“生杀大权”都在NVIC手里。
NVIC的核心寄存器就那么几个,我列出来你感受一下:
- ISER(Interrupt Set-Enable Register):写1使能中断,读回当前使能状态。
- ICER(Interrupt Clear-Enable Register):写1屏蔽中断。
- ISPR(Interrupt Set-Pending Register):软件触发中断,用于调试或软中断。
- ICPR(Interrupt Clear-Pending Register):清除挂起状态。
- IPR(Interrupt Priority Register):每个中断8位优先级,但实际使用的位数由优先级分组决定。
关键点:NVIC的寄存器都是32位宽,每个位对应一个中断号。比如ISER[0]的bit 0对应IRQ 0,bit 1对应IRQ 1,以此类推。操作时务必用volatile访问,编译器优化会坑你。
3.3 使能与屏蔽中断:Rust中的NVIC操作
在Rust中操作NVIC,我推荐用cortex-m和cortex-m-rt这两个crate。它们封装好了寄存器操作,你直接调函数就行。
下面是一个使能外部中断的例子:
use cortex_m::peripheral::NVIC;
use cortex_m_rt::entry;
// 假设我们使用USART1的中断,IRQ号为37
const USART1_IRQ: u16 = 37;
#[entry]
fn main() -> ! {
// 获取NVIC外设实例
let mut nvic = unsafe { NVIC::new() };
// 使能USART1中断
NVIC::enable(USART1_IRQ);
// 设置优先级(数值越小优先级越高)
// 这里设为2,假设优先级分组为4位抢占+0位子优先级
NVIC::set_priority(USART1_IRQ, 2);
loop {
// 主循环
}
}
// 中断服务函数
#[interrupt]
fn USART1() {
// 处理USART1中断
// 注意:这里不能有耗时操作
}
println!。串口打印本身可能触发中断,导致递归中断,栈溢出。后来我改用DMA+环形缓冲区,才彻底解决。记住:ISR里只做最轻量的操作,比如置标志位、读数据、清中断标志。
3.4 优先级分组:抢占与子优先级的博弈
Cortex-M的优先级分为抢占优先级和子优先级。抢占优先级决定能否打断当前中断,子优先级决定同抢占优先级下的响应顺序。
优先级分组由SCB->AIRCR寄存器的PRIGROUP字段控制。常见的分组方式如下:
| PRIGROUP值 | 抢占优先级位数 | 子优先级位数 | 优先级个数 |
|---|---|---|---|
| 0 | 7 | 1 | 128个抢占级,每个2个子优先级 |
| 1 | 6 | 2 | 64个抢占级,每个4个子优先级 |
| 2 | 5 | 3 | 32个抢占级,每个8个子优先级 |
| 3 | 4 | 4 | 16个抢占级,每个16个子优先级 |
| 4 | 3 | 5 | 8个抢占级,每个32个子优先级 |
| 5 | 2 | 6 | 4个抢占级,每个64个子优先级 |
| 6 | 1 | 7 | 2个抢占级,每个128个子优先级 |
| 7 | 0 | 8 | 1个抢占级,256个子优先级 |
我个人习惯用分组3(4位抢占+4位子优先级)。这样有16个抢占级,每个抢占级下16个子优先级,够用且清晰。你想想看,如果抢占级太多,嵌套深度控制不住;如果太少,实时性又不够。分组3是个折中。
cortex_m::peripheral::SCB的set_priority_group方法设置。记得在系统初始化时调用,中断使能之前。
3.5 中断延迟分析:从硬件到软件的全链路
中断延迟,指的是从中断信号到达CPU到ISR第一条指令执行的时间。这个指标直接决定了系统的实时响应能力。
中断延迟由以下几个部分组成:
- 硬件延迟(固定部分):CPU检测中断、压栈(xPSR, PC, LR, R12, R3-R0共8个寄存器)、取向量表地址。Cortex-M3/M4大约12个时钟周期。
- 优先级裁决:NVIC比较当前中断与正在执行中断的优先级,决定是否抢占。约1-2个时钟周期。
- 软件延迟(可变部分):如果CPU正在执行高优先级中断或关中断的临界区,延迟会无限延长。
- 总线延迟:如果向量表在Flash中,而Flash有等待周期,延迟会增加。建议把向量表放在SRAM中。
下面这张图展示了中断响应的完整流程:
实战数据:我在STM32F407上实测过,72MHz主频下,从GPIO中断触发到ISR第一条指令执行,逻辑分析仪测得约280ns。换算下来大约20个时钟周期,和理论值吻合。
3.6 中断嵌套与临界区保护
中断嵌套,就是高优先级中断打断低优先级中断。Cortex-M硬件自动处理嵌套,你不需要手动保存上下文。但有一个问题:共享数据的保护。
如果ISR和主循环共享一个变量,主循环在读取时被ISR打断并修改了变量,主循环读到的是不一致的数据。这就是临界区问题。
Rust中保护临界区,我推荐两种方式:
- 关中断:使用
cortex_m::interrupt::free闭包,在闭包内关中断,执行完再开。适合短操作。 - 原子操作:使用
core::sync::atomic模块,对整数类型进行原子读写。适合标志位、计数器。
use core::sync::atomic::{AtomicU32, Ordering};
// 全局计数器,ISR和主循环共享
static COUNTER: AtomicU32 = AtomicU32::new(0);
#[interrupt]
fn TIM2() {
// 原子递增,无需关中断
COUNTER.fetch_add(1, Ordering::Relaxed);
}
fn main() -> ! {
loop {
// 原子读取
let val = COUNTER.load(Ordering::Relaxed);
// 处理val
}
}
3.7 中断延迟的优化技巧
如果你对实时性有极致要求,下面几个优化方向可以参考:
- 向量表放SRAM:SRAM零等待,比Flash快。启动时从Flash拷贝到SRAM,然后设置
SCB->VTOR指向SRAM地址。 - ISR用RAM函数:把ISR放在SRAM执行,避免Flash等待周期。Rust中可以用
#[ram]属性(需要cortex-m-rt支持)。 - 减少压栈量:如果ISR只用少量寄存器,可以手动用
__attribute__((naked))(C语言)或Rust的内联汇编控制压栈。但风险高,不推荐新手尝试。 - 优先级分组合理:不要让抢占级太多,否则嵌套深度不可控,延迟抖动大。
我的经验:对于99%的应用,默认的向量表位置和Flash执行就够了。别为了那几百纳秒的优化把代码搞复杂。只有像电机FOC控制、音频采样这种硬实时场景,才值得做SRAM优化。