Rust in Everything
基于 embassy-stm32 0.6.0

BufferedUart 第二次 write 卡住(上游 bug,main 已修)

主线位置:中断主线的发送方向。BufferedUart 的环形缓冲、中断处理函数和这段 write 的来历见 USART 与 DMA。

sequenceDiagram
  participant T as 任务
  participant W as write
  participant I as USART1 中断
  T->>W: 写 19 字节
  W-->>T: Ready
  T->>W: 写 16 字节
  Note over W: 只剩 13 字节空间
  W-->>T: Pending 没登记 waker
  I->>I: 发完 TC
  I->>I: tx_waker.wake 无人可叫
  Note over T: 永远不再被 poll

实验 crate exp-embassy/pitb-qemu,bins p23_stall、p23_flush(thumbv7em-none-eabihf,release)。QEMU 11.1.2 b-l475e-iot01a;embassy-stm32 经 [patch.crates-io] 指向本目录里 exp-embassy/hal-qemu-patched 的副本:0.6.0 加三处改动,rcc/bd.rs 注释掉 while !cr.read().dbp() {}(QEMU 没有 PWR 模型),USART 的 TDR/RDR 改成 32 位访问、clear_interrupt_flags 只写 ICR 定义的位(QEMU 的 USART 模型只接受 32 位访问,并且会清掉写进 ICR 的任何 ISR 位)。write 本身没有改。时间是模板的 1 kHz SysTick 驱动给的毫秒数。

现象

32 字节的发送缓冲,连着发两行。QEMU 的串口几乎瞬间发完,为了让「第一行还在发、第二行就来了」稳定出现,先在 NVIC 里屏蔽 USART1,10 ms 后由另一个任务解除:

NVIC::mask(Interrupt::USART1);
spawner.spawn(release_irq_later().unwrap()); // 10 ms 后 NVIC::unmask(USART1)
let t0 = Instant::now();
let r1 = uart.write(b"temperature=21.5C\r\n").await; // 19 bytes
hprintln!("first write {:?} at {} ms", r1, t0.elapsed().as_millis());
let r2 = uart.write(b"humidity=40.0%\r\n").await; // 16 bytes, only 13 free
hprintln!("second write {:?} at {} ms", r2, t0.elapsed().as_millis());
first write Ok(19) at 0 ms
unmask USART1 at 11 ms
[runq: killed after 4s]

串口上(-serial file:)只有第一行 temperature=21.5C。第一行在 11 ms 发完,缓冲区早就空了,第二个 write 却再也没有返回,直到 4 s 后被 kill。真实硬件上不需要屏蔽中断:115200 波特率下 19 字节要约 1.6 ms,任何「上一段没发完又写一段、且剩余空间不够整段」都会走到这里。

出处

0.6.0 的 BufferedUartTx::write:

            if state.tx_buf.len() < buf.len() {
                return Poll::Ready(Err(Error::BufferTooLong));
            }

            if state.tx_buf.len() - state.tx_buf.available() < buf.len() {
                return Poll::Pending;
            }

第二个 if 在剩余空间不够整段时返回 Pending,但没有 state.tx_waker.register(cx.waker())。中断处理函数在发送完成(TC)时做的是 state.tx_done.store(true, ..) 加 state.tx_waker.wake()(on_interrupt),AtomicWaker 里没有这个任务的 waker,叫不醒它。于是缓冲区空了,任务也不知道。

如果外面包着 with_timeout,超时定时器到期时会顺带 poll 一次,write 这才发现有空间,看起来是「偶尔慢 500 ms」(USART 与 DMA 里的 lostwake.rs 量的就是这种)。另一个掩盖条件:任务之前调过 flush 或走过 write 里登记 waker 的那条路,AtomicWaker 里恰好留着自己的 waker,wake() 就能叫醒它。

上游状态:这是 embassy-stm32 0.6.0 的 bug。main 上 PR #6535(提交 560ad7e91「stm32: fix embedded_io::Write impl for BufferedUartTx」,2026-07-16)把 write 改成「有多少空间写多少,没有空间才登记 waker 返回 Pending」,BufferTooLong 检查也删了。它带出的一个后续问题(缓冲区绕回时 flush() 可能在最后几个字节发出前返回)由 PR #7282(提交 fe45c6e7a「stm32/usart: fix early BufferedUart flush」,2026-10-15)修掉。两者都还没有进任何 embassy-stm32 发布版本。

修法

在 0.6.0 上,每次 write 之前先 flush().await(p23_flush):

let r1 = uart.write(b"temperature=21.5C\r\n").await;
uart.flush().await.unwrap(); // workaround: wait until the buffer has drained
let r2 = uart.write(b"humidity=40.0%\r\n").await;
first write Ok(19) at 0 ms
unmask USART1 at 11 ms
flush done at 11 ms
second write Ok(16) at 11 ms

串口上两行都在。flush 在 tx_done 为假时登记 waker,TC 中断叫醒它;返回时缓冲区是空的,下一段只要不超过缓冲区大小就一定能整段放进去。代价是每段之间都要等上一段完全发完,吞吐比连续写低。另外:

  • 单次 write 的长度不能超过发送缓冲区,否则得到 Err(BufferTooLong)(embedded_io_async::Write::write_all 不会帮你切块)。把缓冲区开得比最长的一条消息大。
  • 用 git 依赖或 [patch.crates-io] 拿 main 上修过的版本也行,但 main 上 embassy-stm32 的 API 也变了(例如 130d33053「stm32: align to api guidelines」),不只是这一个修复。

怎么确认没中招

  • 任务停在 BufferedUart(或 BufferedUartTx)的 write 上,串口已经安静了:就是这个。调试器里看 state.tx_buf 为空、tx_done 为真。
  • 外面有 with_timeout 时的症状是写入偶尔恰好晚一个超时时长。
  • grep -rn '\.write(' src/ 找所有对 BufferedUart 的写;连续两次写之间没有 flush 的都有风险。

小结

  • embassy-stm32 0.6.0 的 BufferedUartTx::write 在剩余空间不够整段时返回 Pending 却不登记 waker,TC 中断叫不醒它,任务永远挂起。
  • QEMU 上用 NVIC 屏蔽 10 ms 稳定复现;with_timeout 和残留的 waker 会掩盖它。
  • 绕法:每次 write 前 flush().await,单段不超过缓冲区;main 上已由 #6535(及后续 #7282)修复,尚未发版。

💬 关联讨论

发起讨论
加载中...