中断与 RTIC 框架
从裸 NVIC 中断处理,到 critical-section 临界区,再到 RTIC——用硬件优先级实现无锁、编译期无数据竞争的并发框架。
从裸 NVIC 中断处理,到 critical-section 临界区,再到 RTIC——用硬件优先级实现无锁、编译期无数据竞争的并发框架。
cortex-m crate 的 NVIC API 或 HAL 封装来配置它。#[interrupt] 属性宏标注,函数名必须与中断向量名完全一致(如 EXTI0、TIM2、USART1),cortex-m-rt 据此把它填入向量表。ISR 应尽量短小,避免长时间阻塞其他中断。EXTIx 中断即时响应。配置流程:选中引脚、设置触发边沿、在 NVIC 使能对应中断线、在 ISR 里清除挂起标志(Pending Bit,否则会反复触发)。critical-section crate 提供跨平台的 critical_section::with(|cs| {...}),闭包内保证不被中断,闭包结束自动恢复。Mutex(配合 critical-section)里。编译器通过 Sync 约束强制这一点——不加保护的共享代码根本无法编译,把竞态挡在编译期。#[rtic::app] 组织代码。它把逻辑拆成 #[init]、#[idle]、硬件任务(绑定中断)、软件任务,共享数据放在 #[shared],独占数据放 #[local]。框架编译期分析访问关系,自动生成最小化的临界区,保证无死锁、无数据竞争。先看不用框架、手动处理中断时如何安全共享数据。假设 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()); } }
C 里常写 static volatile int counter; 让 ISR 和 main 共享,但这在多字节非原子读写、编译器重排下暗藏竞态。Rust 里 static mut 访问必须写 unsafe,且新版本已强烈不推荐甚至禁止裸引用。正确做法是 Mutex<Cell<_>> 或原子类型 AtomicU32——前者靠临界区,后者靠硬件原子指令。编译器通过 Sync 约束把不安全的共享挡在门外。
手动管理临界区和向量表容易出错。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... } }
调用 shared.threshold.lock(|t| ...) 时,RTIC 并非简单关全局中断,而是根据 SRP 算法,只把中断屏蔽阈值抬到"所有访问该资源的任务里最高的优先级"。这意味着更高优先级、不碰这个资源的中断仍能正常抢占——临界区被压到最小。而对同优先级任务间的共享资源,框架甚至能在编译期证明无需任何锁,直接省掉。这是手写代码极难做对的优化。
| 维度 | 裸 #[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 把并发安全从"靠自觉"变成"编译器保证"。