3. 嵌入式Rust基础回顾

中断,说白了就是让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
我的习惯:在Rust中,我会用#[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是个折中。

配置方法:在Rust中,通过cortex_m::peripheral::SCB的set_priority_group方法设置。记得在系统初始化时调用,中断使能之前。

3.5 中断延迟分析:从硬件到软件的全链路

中断延迟,指的是从中断信号到达CPU到ISR第一条指令执行的时间。这个指标直接决定了系统的实时响应能力。

中断延迟由以下几个部分组成:

  1. 硬件延迟(固定部分):CPU检测中断、压栈(xPSR, PC, LR, R12, R3-R0共8个寄存器)、取向量表地址。Cortex-M3/M4大约12个时钟周期。
  2. 优先级裁决:NVIC比较当前中断与正在执行中断的优先级,决定是否抢占。约1-2个时钟周期。
  3. 软件延迟(可变部分):如果CPU正在执行高优先级中断或关中断的临界区,延迟会无限延长。
  4. 总线延迟:如果向量表在Flash中,而Flash有等待周期,延迟会增加。建议把向量表放在SRAM中。

下面这张图展示了中断响应的完整流程:

Cortex-M中断响应流程 中断信号到达 硬件压栈(8个寄存器) 取向量表地址 NVIC优先级裁决 跳转ISR执行 总延迟 = 硬件压栈(12 clk) + 取向量表(3-5 clk) + 优先级裁决(2 clk) + 软件等待(可变) 典型Cortex-M3/M4无等待状态下:约16-20个时钟周期

实战数据:我在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优化。