Chapter 06 · Rust 嵌入式开发

中断与 RTIC 框架

从裸 NVIC 中断处理,到 critical-section 临界区,再到 RTIC——用硬件优先级实现无锁、编译期无数据竞争的并发框架。

课程进度50%

关键概念

NVIC(嵌套向量中断控制器)
Cortex-M 内置的中断管理单元。它管理所有外设中断的使能、挂起、优先级,支持中断嵌套(高优先级可打断低优先级 ISR)。每个中断有独立优先级寄存器,数字越小优先级越高。Rust 通过 cortex-m crate 的 NVIC API 或 HAL 封装来配置它。
ISR(中断服务程序)
中断触发时 CPU 自动跳转执行的函数。Rust 用 #[interrupt] 属性宏标注,函数名必须与中断向量名完全一致(如 EXTI0TIM2USART1),cortex-m-rt 据此把它填入向量表。ISR 应尽量短小,避免长时间阻塞其他中断。
EXTI(外部中断)
把 GPIO 引脚的电平变化(上升沿/下降沿)配置成中断源。按键按下不再需要轮询,而是触发 EXTIx 中断即时响应。配置流程:选中引脚、设置触发边沿、在 NVIC 使能对应中断线、在 ISR 里清除挂起标志(Pending Bit,否则会反复触发)。
临界区 (Critical Section)
一段执行期间不能被中断打断的代码,用于安全地访问 main 与 ISR 共享的数据。裸机实现是临时关闭全局中断(PRIMASK),执行完再恢复。Rust 用 critical-section crate 提供跨平台的 critical_section::with(|cs| {...}),闭包内保证不被中断,闭包结束自动恢复。
数据竞争与 Send/Sync
main 循环和 ISR 就像两个"线程",共享全局变量若不保护就会数据竞争(一方读到另一方写一半的值)。Rust 不允许直接用可变全局,必须包在 Mutex(配合 critical-section)里。编译器通过 Sync 约束强制这一点——不加保护的共享代码根本无法编译,把竞态挡在编译期。
RTIC (Real-Time Interrupt-driven Concurrency)
一个基于 Cortex-M 中断硬件的并发框架,用宏 #[rtic::app] 组织代码。它把逻辑拆成 #[init]#[idle]、硬件任务(绑定中断)、软件任务,共享数据放在 #[shared],独占数据放 #[local]。框架编译期分析访问关系,自动生成最小化的临界区,保证无死锁、无数据竞争。
SRP(栈资源策略)与优先级天花板
RTIC 的理论基础。它给每个共享资源计算一个"优先级天花板"(所有访问它的任务中最高的优先级),访问资源时把当前中断屏蔽阈值临时提到该天花板,从而无锁地保证互斥。相比传统信号量,SRP 无死锁、访问是常数时间、且不需要额外内存。
任务优先级 (Priority)
RTIC 任务映射到 NVIC 中断优先级。数字越大优先级越高(RTIC 约定,与裸 NVIC 相反)。高优先级任务可抢占低优先级任务。同优先级任务不会互相抢占,因此它们之间共享数据无需保护——这是 RTIC 编译期分析能优化掉临界区的关键。

裸机中断:共享数据的正确姿势

先看不用框架、手动处理中断时如何安全共享数据。假设 EXTI0(PA0 按键)中断里要递增一个计数器,main 里读它:

#![no_std]
#![no_main]
use panic_halt as _;
use cortex_m_rt::entry;
use core::cell::Cell;
use critical_section::Mutex;
use stm32f4xx_hal::{pac, prelude::*, interrupt};

// 共享计数器:Mutex 保证访问必须在临界区内,Cell 允许内部可变。
// static 全局要求 Sync —— Mutex<Cell<u32>> 满足,裸 u32 不满足(编译报错)。
static COUNTER: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    // ...省略 EXTI0 上升沿 + NVIC 使能配置...

    loop {
        // 进入临界区读取共享值:闭包期间中断被屏蔽
        let n = critical_section::with(|cs| COUNTER.borrow(cs).get());
        // defmt::info!("count = {}", n);
        let _ = n;
    }
}

// 中断服务程序:函数名必须等于向量名 EXTI0
#[interrupt]
fn EXTI0() {
    critical_section::with(|cs| {
        let c = COUNTER.borrow(cs);
        c.set(c.get().wrapping_add(1));
    });
    // 必须清除 EXTI 挂起标志,否则中断反复触发
    unsafe { (*pac::EXTI::ptr()).pr.write(|w| w.pr0().set_bit()); }
}
为什么裸 static mut 是禁区

C 里常写 static volatile int counter; 让 ISR 和 main 共享,但这在多字节非原子读写、编译器重排下暗藏竞态。Rust 里 static mut 访问必须写 unsafe,且新版本已强烈不推荐甚至禁止裸引用。正确做法是 Mutex<Cell<_>> 或原子类型 AtomicU32——前者靠临界区,后者靠硬件原子指令。编译器通过 Sync 约束把不安全的共享挡在门外。

中断处理流程

EXTI 按键中断的完整路径 PA0 引脚出现下降沿 │ ▼ EXTI 检测到边沿 → 置 Pending Bit │ ▼ NVIC 判断优先级 → 允许则打断当前代码 │ ▼ CPU 保存现场 → 跳转向量表中 EXTI0 项 │ ▼ ┌──────────────────────────────────┐ │ #[interrupt] fn EXTI0() │ │ ① critical_section 更新共享数据 │ │ ② 清除 Pending Bit(关键!) │ └──────────────┬───────────────────┘ │ ▼ CPU 恢复现场 → 返回被打断处继续执行 若忘记清 Pending Bit → ISR 立即再次触发 → 死循环

RTIC:让框架管理并发

手动管理临界区和向量表容易出错。RTIC 把这一切结构化:你只声明任务、优先级和资源,框架编译期自动生成正确且最小的临界区。下面是一个双任务示例——定时器任务采样、按键中断改配置,共享一个阈值:

#![no_std]
#![no_main]
use panic_halt as _;

#[rtic::app(device = stm32f4xx_hal::pac, dispatchers = [SPI1])]
mod app {
    use stm32f4xx_hal::{pac, prelude::*, gpio::*};

    // 多任务共享、需要互斥保护的数据
    #[shared]
    struct Shared {
        threshold: u32,
    }

    // 每个任务独占、无需保护的数据
    #[local]
    struct Local {
        led: PC13<Output<PushPull>>,
        button: PA0<Input<PullUp>>,
    }

    // 初始化:返回 Shared 与 Local,框架接管后调度任务
    #[init]
    fn init(cx: init::Context) -> (Shared, Local) {
        let dp: pac::Peripherals = cx.device;
        // ...配置时钟、GPIO、EXTI、TIM 中断...(省略)
        let gpioc = dp.GPIOC.split();
        let gpioa = dp.GPIOA.split();
        let led = gpioc.pc13.into_push_pull_output();
        let button = gpioa.pa0.into_pull_up_input();

        (Shared { threshold: 100 }, Local { led, button })
    }

    // 硬件任务:绑定 TIM2 中断,优先级 1,独占 led,共享 threshold
    #[task(binds = TIM2, priority = 1, shared = [threshold], local = [led])]
    fn sample(mut cx: sample::Context) {
        // lock 自动进入最小临界区访问共享资源
        let th = cx.shared.threshold.lock(|t| *t);
        if th > 50 {
            cx.local.led.toggle();  // local 资源无需 lock
        }
    }

    // 硬件任务:按键中断,优先级 2(更高,可抢占 sample)
    #[task(binds = EXTI0, priority = 2, shared = [threshold], local = [button])]
    fn on_button(mut cx: on_button::Context) {
        cx.shared.threshold.lock(|t| *t = 200);
        // ...清除 EXTI Pending Bit...
    }
}
RTIC 的 lock 有多聪明

调用 shared.threshold.lock(|t| ...) 时,RTIC 并非简单关全局中断,而是根据 SRP 算法,只把中断屏蔽阈值抬到"所有访问该资源的任务里最高的优先级"。这意味着更高优先级、不碰这个资源的中断仍能正常抢占——临界区被压到最小。而对同优先级任务间的共享资源,框架甚至能在编译期证明无需任何锁,直接省掉。这是手写代码极难做对的优化。

裸中断 vs RTIC

维度裸 #[interrupt]RTIC
共享数据手写 Mutex + critical_section#[shared] + lock,自动最小临界区
优先级管理手动配 NVIC 优先级寄存器task 属性声明,框架配置
数据竞争靠自觉,易漏保护编译期分析保证无竞争
死锁可能(嵌套锁)SRP 保证不可能死锁
任务调度无,只有中断软件任务 + 消息传递 spawn
学习曲线直接但繁琐需理解框架,长期更省心
本章小结

Cortex-M 的中断由 NVIC 管理(优先级、嵌套、挂起),ISR 用 #[interrupt] 标注且函数名须等于向量名,EXTI 把 GPIO 边沿变成中断(ISR 中必须清 Pending Bit,否则反复触发)。main 与 ISR 共享数据不能用裸 static mut(Rust 靠 Sync 约束禁止),正确做法是 Mutex<Cell<_>> + critical_section::with(临界区屏蔽中断)或 AtomicU32(硬件原子)。RTIC#[rtic::app] 把并发结构化:#[init]/#[idle]、硬件任务(binds 中断)、软件任务,共享数据放 #[shared](用 lock 访问)、独占放 #[local](免锁)。它基于 SRP/优先级天花板算法,编译期分析访问关系、生成最小临界区,保证无死锁、无数据竞争,同优先级共享甚至可免锁。相比手写中断,RTIC 把并发安全从"靠自觉"变成"编译器保证"。