Rust in Everything
基于 embassy-sync 0.8.0

中断里用 Noop / ThreadMode:BorrowMutError、ThreadModeMutex can only be locked from thread mode

主线位置:跨任务主线的变体「中断处理函数里 try_send」(Channel 的「在中断里:try_send」)。三种 RawMutex 的实现和一个线程 / 中断抢 channel 的实验见原始互斥;本页讲用错了以后在什么时候、以什么样子暴露出来。

flowchart TB
  isr["中断上下文 try_send"] --> m{"RawMutex"}
  m -->|ThreadMode| p1["第一次就 panic"]
  m -->|"Noop 加 unsafe Sync"| r{"打断了临界区吗"}
  r -->|没有| silent["看起来正常"]
  r -->|打断了| p2["BorrowMutError"]
  m -->|CriticalSection| ok["正确"]

实验 crate exp-embassy/pita-qemu,bin irq_thread、irq_noop、irq_noop_fast、irq_cs(共用 src/irq_mutex.rs)和 irq_exec、irq_exec_cs;thumbv7em-none-eabihf,release;QEMU 11.1.2 b-l475e-iot01a,前四个加 -icount shift=0;embassy-stm32 用 exp-embassy/qemu-patched(和 0.6.0 只差注释掉 rcc/bd.rs 里的 while !cr.read().dbp() {},这几个程序只用它的中断向量表,不调 init)。

现象

用户写的中断处理函数。 一个 embassy 任务 CH.receive().await 收 20000 个递增的序号,SysTick 每隔一段周期进一次中断,在处理函数里 try_send 下一个序号;为了让 NoopRawMutex 版本能放进 static,channel 包在一个 unsafe impl Sync 的结构体里(src/irq_mutex.rs):

struct Shared(Channel<$m, u32, 8>);
// Lets a NoopRawMutex channel into a static; a no-op for the Sync mutexes.
unsafe impl Sync for Shared {}

#[exception]
fn SysTick() {
  let n = SENT.load(Relaxed) + 1;
  if CH.0.try_send(n).is_ok() {
    SENT.store(n, Relaxed);
  }
}
mutex embassy_sync::blocking_mutex::raw::thread_mode::ThreadModeRawMutex, SysTick every 4000 cycles
panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-sync-0.8.0/src/blocking_mutex/raw.rs:120:13:
ThreadModeMutex can only be locked from thread mode.
mutex embassy_sync::blocking_mutex::raw::NoopRawMutex, SysTick every 4000 cycles
received 5000
received 10000
received 15000
received 20000
received 20000 in order
mutex embassy_sync::blocking_mutex::raw::NoopRawMutex, SysTick every 300 cycles
panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-sync-0.8.0/src/channel.rs:845:38:
unwrap of `rc.try_borrow_mut()` failed: BorrowMutError
  • ThreadModeRawMutex:第一个中断里第一次加锁就 panic,错误一定会被看到。
  • NoopRawMutex,每 4000 个周期一次中断:20000 个序号全部按序收到,不加 -icount 再跑三次也一样。中断总是在任务睡在 WFE 里时到来,没有一次打断 receive 的临界区。
  • 同一个程序,每 300 个周期一次中断:不到 5000 个就 panic 在 channel.rs:845。中断密到一定程度,才会恰好落进任务持锁的那几十条指令里。真实的板子上,中断时刻还受其他中断、DMA、时钟抖动影响,这个坑可以在实验室里跑过一夜、到现场才出现。

任务挪到 InterruptExecutor。 不写中断处理函数也会碰上:为了降低延迟,把一个任务从线程模式执行器挪进 InterruptExecutor(借 UART4 的向量),它和留在线程模式的任务共用一个 Channel<ThreadModeRawMutex, u32, 4>(irq_exec,不加 -icount):

high: sending
panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/embassy-sync-0.8.0/src/blocking_mutex/raw.rs:120:13:
ThreadModeMutex can only be locked from thread mode.

InterruptExecutor 在中断处理函数里 poll 任务(各平台的执行器),它上面的任务都运行在处理模式(handler mode)。代码一行没改,只是换了一个执行器,就从「正确」变成了「第一次 send 就 panic」。

出处

ThreadModeRawMutex::lock 是一个运行期断言,不在线程模式就 panic:

        fn lock<R>(&self, f: impl FnOnce() -> R) -> R {
            assert!(in_thread_mode(), "ThreadModeMutex can only be locked from thread mode.");

            f()
        }

in_thread_mode 在 Cortex-M 上读 ICSR(0xE000ED04)的 VECTACTIVE 位段,为 0 才算线程模式;开了 embassy-sync 的 std feature 时改成判断当前线程名是不是 "main"。

NoopRawMutex::lock 直接调闭包,不挡中断。挡住同时访问的只剩 Channel 里那层 RefCell,Channel::lock:

    fn lock<R>(&self, f: impl FnOnce(&mut ChannelState<T, N>) -> R) -> R {
        self.inner.lock(|rc| f(&mut *unwrap!(rc.try_borrow_mut())))
    }

线程模式的任务在闭包里时 RefCell 已经借出;中断打断它、再进 lock,第二次 try_borrow_mut 失败,unwrap! panic,位置正是 channel.rs:845。没打断时两次借用前后错开,RefCell 什么也看不出来。unsafe impl Sync 关掉的正是编译期本来会给的那条错误(*mut () cannot be shared between threads safely)。

修法

中断处理函数或 InterruptExecutor 的任务碰到的东西,一律 CriticalSectionRawMutex。同一个 irq_mutex.rs,$m 换成它,中断加密到每 300 个周期一次(irq_cs):

mutex embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex, SysTick every 300 cycles
received 5000
received 10000
received 15000
received 20000
received 20000 in order

irq_exec_cs 只把 irq_exec 里的 ThreadModeRawMutex 换成 CriticalSectionRawMutex:

high: sending
high: sent
low: got 1

CriticalSectionRawMutex 在单核 Cortex-M 上存 PRIMASK 再 cpsid i,临界区里中断被推迟到闭包返回,两边轮流拿到 ChannelState。代价是持锁期间中断延迟变长;embassy-sync 的闭包都很短,不含 .await。

处理函数里只用 try_send / try_receive / Signal::signal 这类同步方法,不要在中断里 embassy_futures::block_on 一个 send(..):channel 满时它在中断里一直转,而能腾出空位的任务在线程模式,永远得不到运行。irq_blockon 用的是正确的 CriticalSectionRawMutex,处理函数改成 block_on(CH.send(n)),任务先忙 cortex_m::asm::delay(20_000_000) 让 8 格的 channel 填满再开始收:15 秒后被 runq 杀掉,连忙完之后的第一行 start receiving 都没打印出来。把忙等缩短到 2_000_000,channel 没满,程序正常打印 received 20, sent 20。

怎么确认没中招

  • 列出所有中断处理函数(#[interrupt]、#[exception]、HAL 的 Handler 实现)和 InterruptExecutor 上的任务,追它们碰到的每个 embassy-sync 对象,M 必须是 CriticalSectionRawMutex。
  • 把任务从线程模式执行器挪进 InterruptExecutor 之前,grep -rn 'ThreadModeRawMutex' src/,同样处理。
  • 测试 NoopRawMutex 有没有用错时,没出 panic 不说明什么:上面每 4000 个周期一次中断跑了 20000 次都没事。能证明的只有代码审查和 !Sync 带来的编译错误,所以不要用 unsafe impl Sync 关掉它。

小结

  • 中断处理函数和 InterruptExecutor 的任务都不在线程模式:ThreadModeRawMutex 第一次加锁就 panic ThreadModeMutex can only be locked from thread mode.
  • NoopRawMutex 加 unsafe impl Sync 后,只有中断恰好打断临界区才 BorrowMutError;每 4000 个周期一次中断时 20000 次全过,每 300 个周期一次时不到 5000 次就 panic。
  • 中断碰到的东西用 CriticalSectionRawMutex;中断里只调同步的 try_* 方法。

💬 关联讨论

发起讨论
加载中...