USART 与 DMA
主线位置:中断主线的另一个实例。
BufferedUart的write把字节放进环形缓冲、挂起 USART 中断;处理函数一个字节一个字节写TDR,最后一个字节发完时唤醒等flush的任务。结构和 GPIO 与 EXTI 一样:驱动登记 waker、打开中断,处理函数干活并唤醒。DMA 版把「一个字节一个中断」换成「整段一个中断」。
flowchart TB task["任务 write"] --> txbuf["tx_buf 环形缓冲"] task --> pend["USART1 pend"] pend --> isr["on_interrupt"] isr --> txbuf isr --> tdr["TDR 一个字节"] tdr --> txe["TXE 中断"] txe --> isr rdr["RDR"] --> isr isr --> rxbuf["rx_buf 环形缓冲"] isr --> wake["rx_waker tx_waker"] wake --> task
usart/ 三个文件:mod.rs 2199 行、buffered.rs 1060 行、ringbuffered.rs 415 行;dma/ 4425 行,L4 用的 dma_bdma.rs 1539 行;hal-internal 的 atomic_ring_buffer.rs 643 行(含测试)、drop.rs 56 行;embassy-embedded-hal 0.6.0 共 1910 行。实验在 QEMU 11.1.2 的 b-l475e-iot01a 上:exp-embassy/hal-qemu 用模板的补丁(注释掉 rcc/bd.rs 里 while !cr.read().dbp() {});exp-embassy/hal-uart 用我在这之上再加的补丁(exp-embassy/hal-qemu-patched/embassy-stm32,见最后一节),USART1 接 -serial,release。宿主是 M2 Max、macOS 27、rustc 1.99.0。
三种 UART
| 类型 | 怎么搬字节 | 构造要的绑定 | 等待时谁唤醒 |
|---|---|---|---|
Uart<'d, Blocking> | CPU 忙等 TXE / RXNE | 无 | 不等待,原地循环 |
BufferedUart | 每个字节一次中断,经两个环形缓冲 | USART1 => BufferedInterruptHandler | USART 中断处理函数 |
Uart<'d, Async> | DMA 直接在内存和 TDR / RDR 之间搬 | USART 加两条 DMA 通道 | DMA 中断(传输完成) |
RingBufferedUartRx | 循环模式 DMA 一直往缓冲里收 | 由 UartRx<Async> 转换而来 | DMA 半传输 / 传输完成中断、线路空闲中断 |
阻塞版的 blocking_write 就是:
for &b in buffer {
while !sr(r).read().txe() {}
unsafe { tdr(r).write_volatile(b) };
}
tdr() 返回 *mut u8,所以这是一次 8 位写。这个细节在最后一节会变得重要。
BufferedUart:中断加两个环形缓冲
构造时用户交给它两块 &'d mut [u8],分别做发送和接收缓冲;它们被登记进 State 里的两个 RingBuffer。这个环形缓冲是单生产者、单消费者的,四个字段都是原子量,任务和中断处理函数之间不用锁:
pub struct RingBuffer {
#[doc(hidden)]
pub buf: AtomicPtr<u8>,
len: AtomicUsize,
// start and end wrap at len*2, not at len.
// This allows distinguishing "full" and "empty".
// full is when start+len == end (modulo len*2)
// empty is when start == end
//
// This avoids having to consider the ringbuffer "full" at len-1 instead of len.
// The usual solution is adding a "full" flag, but that can't be made atomic
// ...
pub start: AtomicUsize,
#[doc(hidden)]
pub end: AtomicUsize,
}
下标在 0..2*len 里绕圈,取模 len 才是数组位置。这样 32 字节的缓冲能真的装满 32 字节,又不用一个和下标分开更新、因而无法原子化的「满」标志。发送方向:任务是写者(Writer::push_slice / push_done 只动 end),处理函数是读者(Reader::pop_slice / pop_done 只动 start);接收方向反过来。
处理函数按「收、发送完成、发」三段写(删去了错误打印):
unsafe fn on_interrupt(r: Regs, state: &'static State) {
// RX
let sr_val = sr(r).read();
// ...
let dr = if sr_val.rxne() || cfg!(any(usart_v1, usart_v2)) && (sr_val.ore() || sr_val.idle()) {
Some(rdr(r).read_volatile())
} else {
None
};
clear_interrupt_flags(r, sr_val);
// ...
if sr_val.rxne() {
let mut rx_writer = state.rx_buf.writer();
let buf = rx_writer.push_slice();
if !buf.is_empty() {
if let Some(byte) = dr {
buf[0] = byte;
rx_writer.push_done(1);
}
} else {
// FIXME: Should we disable any further RX interrupts when the buffer becomes full.
}
// ... eager_reads 或半满时 rx_waker.wake()
}
// ...
if sr_val.tc() && r.cr1().read().tcie() {
// ...
state.tx_done.store(true, Ordering::Release);
state.tx_waker.wake();
}
// TX
if sr(r).read().txe() {
let mut tx_reader = state.tx_buf.reader();
let buf = tx_reader.pop_slice();
if !buf.is_empty() {
r.cr1().modify(|w| {
w.set_txeie(true);
});
// Enable transmission complete interrupt when last byte is going to be sent out.
if buf.len() == 1 {
r.cr1().modify(|w| {
w.set_tcie(true);
});
}
// ...
tdr(r).write_volatile(buf[0].into());
tx_reader.pop_done(1);
} else {
// Disable interrupt until we have something to transmit again.
r.cr1().modify(|w| {
w.set_txeie(false);
});
}
}
}
- 收:读到一个字节就推进
rx_buf;缓冲满了这个字节被丢掉(源码里留着FIXME)。读者默认在缓冲半满或线路空闲(IDLE)时才被唤醒,eager_reads配置可以改成「有 N 个字节就唤醒」。 - 发:只要
TXE(发送数据寄存器空)就从tx_buf取一个字节写进TDR,并保持TXEIE打开,下一个TXE再进来;缓冲空了就关TXEIE。还剩最后一个字节时打开TCIE,等移位寄存器真正发完(TC)才置tx_done、唤醒等flush的任务。 - 每个字节都是一次中断。115200 波特率、8N1 时一个字节约 87 µs,中断频率约 11.5 kHz。
0.6.0 的 write:缓冲不够时丢了唤醒
async fn write(&self, buf: &[u8]) -> Result<usize, Error> {
poll_fn(move |cx| {
let state = self.state;
state.tx_done.store(false, Ordering::Release);
let empty = state.tx_buf.is_empty();
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;
}
let mut written: usize = 0;
let mut tx_writer = unsafe { state.tx_buf.writer() };
while written != buf.len() {
let data = tx_writer.push_slice();
if data.is_empty() {
state.tx_waker.register(cx.waker());
return Poll::Pending;
}
// ... 拷贝
}
if empty {
self.info.interrupt.pend();
}
Poll::Ready(Ok(written))
})
.await
}
它要么整段放进缓冲、要么不放。两个后果:
buf比整个发送缓冲还长,直接返回Err(BufferTooLong),而不是像embedded_io_async::Write通常那样先写一部分。- 剩余空间不够整段时返回
Pending,但这条路径没有登记 waker。处理函数只在TC时tx_waker.wake(),AtomicWaker里如果没有这个任务,它就不会再被 poll。
在 QEMU 上复现(hal-uart/src/bin/lostwake.rs):32 字节的发送缓冲,先在 NVIC 里屏蔽 USART1,让缓冲排不空;写 20 字节,再写 20 字节(只剩 12 字节空间);另一个任务 10 ms 后解除屏蔽:
unmask USART1 at 11 ms
first Ok(20), second Ok(Ok(20)) after 500 ms
40-byte write into a 32-byte buffer: Ok(Err(BufferTooLong))
串口上收到 aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbb。第一段在 11 ms 解除屏蔽后很快发完,第二个 write 却到 500 ms 才完成:是外面那层 with_timeout 的定时器到期时 poll 了它一次,它才发现有空间了。去掉 with_timeout(lostwake_forever.rs):
unmask USART1 at 10 ms
[runq: killed after 4s]
串口上只有 20 个 a,任务永远停在第二个 write 上。真实硬件上第一段要 1.7 ms 才能发完,任何一次「上一段还没发完又写一段」都会走到这条路径,只是如果这个任务之前调过 flush 或走过 data.is_empty() 分支,AtomicWaker 里恰好存着它自己的 waker,看起来就正常。
write 在 0.6.0 里刚被改过,main 上又改回去了PR #5170(「Fix of the buffered version of STM32 UART」,提交 d07e6001d「Proper use of the ring buffer in writing methods」,首次进入 embassy-stm32 v0.6.0)之前,write 只调一次 push_slice:有多少空间写多少,返回实际写入的字节数;没有空间就登记 waker、返回 Pending。这个 PR 把它改成上面的「整段或不写」,加了 BufferTooLong 和那个不登记 waker 的提前返回。0.6.0 的 CHANGELOG 没有提这个行为变化。tag 之后的 main 上,PR #6535(「stm32/uart: fix embedded_io::Write impl for BufferedUartTx」,2026-07)又改回「有多少写多少、没空间就登记 waker」,删掉了 BufferTooLong 检查;这个修复还没有进任何发布版本。
DMA 版:Uart<'d, Async>
Uart::new 多两个 DMA 通道令牌,绑定参数要同时满足三条:
tx_dma: Peri<'d, D1>,
rx_dma: Peri<'d, D2>,
_irq: impl interrupt::typelevel::Binding<T::Interrupt, InterruptHandler<T>>
+ interrupt::typelevel::Binding<D1::Interrupt, crate::dma::InterruptHandler<D1>>
+ interrupt::typelevel::Binding<D2::Interrupt, crate::dma::InterruptHandler<D2>>
+ 'd,
用户写成一个 Irqs:
bind_interrupts!(struct Irqs {
USART1 => usart::InterruptHandler<peripherals::USART1>;
DMA1_CHANNEL4 => dma::InterruptHandler<peripherals::DMA1_CH4>;
DMA1_CHANNEL5 => dma::InterruptHandler<peripherals::DMA1_CH5>;
});
DMA1_CH4 能不能当 USART1 的 TX 通道由生成的 dma_trait_impl!(crate::usart::TxDma, USART1, DMA1_CH4, 2u8, {}) 决定(芯片数据与代码生成)。漏掉 DMA1_CHANNEL5 那一行,rustc 的报错把已有的两条绑定都列了出来(hal-qemu/errs/dma_missing.txt):
error[E0277]: the trait bound `Irqs: Binding<DMA1_CHANNEL5, InterruptHandler<DMA1_CH5>>` is not satisfied
...
help: `Irqs` implements trait `Binding<I, H>`
--> src/bin/err_dma.rs:13:1
...
| |__`Binding<DMA1_CHANNEL4, InterruptHandler<DMA1_CH4>>`
| `Binding<USART1, InterruptHandler<USART1>>`
0.5.0 的 Uart::new 只要 Binding<T::Interrupt, InterruptHandler<T>>,DMA 通道的中断处理函数由 build.rs 自动生成:对每个 DMA 向量输出一个 #[crate::interrupt] unsafe fn DMA1_CHANNEL4() { .. ::on_irq(); }。PR #5315(「Allow users to define custom DMA interrupt handler functions」,首次进入 embassy-stm32 v0.6.0)去掉了这段生成,加了 dma::InterruptHandler<T> 和对绑定的静态检查(提交「static checking for bound dma interrupts」),随后合入的 #5327(「stm32: add DMA Channel driver API.」)又把通道驱动改写成现在的 ChannelInstance。好处和 EXTI 那次一样:HAL 不再占住所有 DMA 向量,用户可以自己处理某个向量。代价是升级到 0.6.0 的每个 DMA 驱动都要改 bind_interrupts!,而 0.6.0 的 CHANGELOG 里没有这一条。
UartTx::write 打开 USART 的 DMAT(发送 DMA 请求),然后:
// If we don't assign future to a variable, the data register pointer
// is held across an await and makes the future non-Send.
let transfer = unsafe { ch.write(buffer, tdr(r), Default::default()) };
transfer.await;
ch.write 配好通道、返回一个 Transfer。它的 poll 先登记通道的 waker,再看通道是否还在跑;DMA 传输完成中断里唤醒。被 drop 时(例如在 select 里输了)由 Drop 停掉通道并忙等它真的停下:
impl<'a> Drop for Transfer<'a> {
fn drop(&mut self) {
self.request_reset();
while self.is_running() {}
// "Subsequent reads and writes cannot be moved ahead of preceding reads."
fence(Ordering::SeqCst);
}
}
这是 DMA 驱动里最要紧的一条:DMA 在后台往 buffer 里写,buffer 是调用者借来的;future 被丢掉之后如果通道还在跑,它会写进已经被别人重用的内存。Drop 保证 future 消失时 DMA 已经停了。接收方向的 inner_read_run 还要恢复 USART 自己的状态,用的是 hal-internal 的 OnDrop:
// make sure USART state is restored to neutral state when this future is dropped
let on_drop = OnDrop::new(move || {
// clear all interrupts and DMA Rx Request
r.cr1().modify(|w| {
// disable RXNE interrupt
w.set_rxneie(false);
// disable parity interrupt
w.set_peie(false);
// disable idle line interrupt
w.set_idleie(false);
});
r.cr3().modify(|w| {
// disable Error Interrupt: (Frame error, Noise error, Overrun error)
w.set_eie(false);
// disable DMA Rx Request
w.set_dmar(false);
});
});
OnDrop 存一个闭包,Drop 时调用;正常完成的路径上调 defuse()(mem::forget)跳过它。两层清理各管一摊:Transfer 管 DMA 通道,OnDrop 管 USART 的中断使能和 DMA 请求位。
RingBufferedUartRx 由 UartRx<Async>::into_ring_buffered(dma_buf) 得到:DMA 以循环模式一直往 dma_buf 里收,read 从里面取,没有数据时等 DMA 的半传输 / 传输完成中断(DMA 写到缓冲中间那个字节或最后一个字节时触发)或 USART 的线路空闲中断。它的文档注释算了一笔账:2400 波特率、8N1、32 字节缓冲时,最坏要等 16 个字节才触发一次,第一个字节约 62.46 ms 后才能被读到。
QEMU 上的 DMA:立刻「成功」
QEMU 里 L475 的 DMA 是未实现设备。hal-uart/src/bin/uart_dma.rs 用上面的 Irqs 建 Uart,写 5 个字节:
DMA write returned Ok(())
串口文件是空的,0 字节。-d unimp 日志里 DMA1 的访问按顺序是:
1 DMA1: unimplemented device read (size 4, offset 0x0a8)
1 DMA1: unimplemented device write (size 4, offset 0x0a8, value 0x00002000)
1 DMA1: unimplemented device write (size 4, offset 0x004, value 0x0000e000)
1 DMA1: unimplemented device write (size 4, offset 0x04c, value 0x40013828)
1 DMA1: unimplemented device write (size 4, offset 0x050, value 0x08003ccc)
1 DMA1: unimplemented device write (size 4, offset 0x048, value 0x00000005)
1 DMA1: unimplemented device write (size 4, offset 0x044, value 0x0000309a)
1 DMA1: unimplemented device read (size 4, offset 0x044)
1 DMA1: unimplemented device write (size 4, offset 0x044, value 0x00000001)
2 DMA1: unimplemented device read (size 4, offset 0x044)
1 DMA1: unimplemented device write (size 4, offset 0x044, value 0x0000000a)
4 DMA1: unimplemented device read (size 4, offset 0x044)
(grep DMA1 qlog-dma.txt | uniq -c,行首是连续重复的次数。)对着 L4 的 DMA 寄存器读:CSELR(0xa8)给通道 4 选请求号 2;IFCR(0x04)清通道 4 的四个标志;CPAR4(0x4c)= 0x40013828,USART1 的 TDR;CMAR4(0x50)指向 flash 里的字符串;CNDTR4(0x48)= 5;CCR4(0x44)= 0x309a(内存到外设、内存地址自增、传输完成和出错中断、最高优先级)。接着 start() 对 CCR4 做读改写置 EN:读回 0,写进去的只有 0x1。然后 Transfer::poll 读 CCR4.EN,还是 0,判定「不在跑」,返回 Ready。write 返回 Ok(()),Transfer 被 drop,request_reset 写 0xa(只留 TCIE、TEIE,EN = 0)。
驱动没有办法分辨「DMA 已经做完」和「DMA 根本不存在」:两种情况下 EN 都读回 0。这和 init 与时钟里的死等是一体两面:那边等一个永远不来的「就绪」,这边把「读回 0」当成「完成」。
Uart::write 换成 Uart::blocking_write(同一个 Uart<Async> 也有这个方法),在打了补丁的 hal-uart 上,串口文件里会有几个字节?提示:同一个驱动、同一块 QEMU,只换了搬字节的方式。查清 QEMU 上 USART 为什么不出字
编辑留下的 base-qemu/broken/ 里,Uart::blocking_write 和 BufferedUart::write_all 在 QEMU 上都「卡住」。限时排查的结果是两个独立的原因,都在 QEMU 的 stm32l4x5_usart 模型和 embassy-stm32 的访问方式之间。
一、TDR / RDR 只接受 32 位访问。 对 uart_blocking 用 -d exec,nochain 看,最后 2000 条记录全是同一个地址 0x08002e48,那是 cortex-m-rt 默认 HardFault 处理函数里的 b 0x8002e48(死循环),不是在等 TXE。加 -d int 找到进 HardFault 前的那一步:
Trace 0: 0x700005c780 [00800408/00000000080003ec/00000010/ff031201] _RNvCs31mE9o2P3Ud_13uart_blocking18___cortex_m_rt_main
Taking exception 4 [Data Abort] on CPU 0
...at fault address 0x40013828
...with CFSR.PRECISERR and BFAR 0x40013828
...taking pending nonsecure exception 3
0x080003ec 处的指令是 strb r6, [r0],源码行是 usart/mod.rs:615,即上面 blocking_write 里的 tdr(r).write_volatile(b);0x40013828 是 USART1 的 TDR。QEMU 对这个 8 位写回了总线错误。BufferedUart 的接收路径也一样:喂进字节后 -d int 记到 fault address 0x40013824(RDR)。
二、写 ICR 会清掉 TXE。 只修第一点之后,BufferedUart 的 write_all 返回了,flush 却等不到。读寄存器:
[after new] ISR=0x026000c0 CR1=0x0000003d BRR=35
write_all: true
flush: false
[after flush] ISR=0x00000000 CR1=0x0000003d BRR=35
ISR 从 0x026000c0(TXE、TC、TEACK、REACK 等)变成了 0,CR1 里也没有 TXEIE。处理函数开头的 clear_interrupt_flags(r, sr_val) 在 v3 / v4 上是 r.icr().write(|w| *w = regs::Icr(sr.0)),把读到的整个 ISR 值写进 ICR。单独测一下 QEMU 对 ICR 的处理(hal-uart/src/bin/icr.rs):
ISR before 0x026000c0
ICR = TCCF -> 0x02600080
ICR = old ISR -> 0x00000000
只写 TCCF(第 6 位)时只清了 TC,是对的;把旧 ISR 整个写进去,QEMU 把 TXE(第 7 位)、TEACK、REACK 也清了。stm32-metapac 21.0.0 的 usart_v3.rs 里 Icr 只定义了第 0、1、2、3、4、6、8、9、11、12、17、20 位,第 7、21、22、25 位是保留位。于是处理函数清完标志再读 ISR,TXE 已是 0,不写 TDR、不开 TXEIE,发送从此停住。
两点都是 QEMU 模型和真芯片的差异:真芯片上 embassy 的 8 位访问和「把 ISR 原样写进 ICR」都在大量项目里正常工作(本机没有开发板,这一点没有实测)。不过第二点里 embassy 往 ICR 的保留位写 1,严格说不符合「保留位保持复位值」的写法。
补丁和结果。 exp-embassy/hal-qemu-patched/embassy-stm32 是模板补丁的副本,再改 7 行:TDR 的三处写、RDR 的三处读改成 32 位((tdr(r) as *mut u32).write_volatile(b as u32) 一类,只对 v3 / v4 成立),clear_interrupt_flags 改成 regs::Icr(sr.0 & 0x0012_1b5f)(只保留 ICR 定义了的位)。之后:
write Ok(())
isr 0x26000c0 cr1 0xd
串口文件里是 blocking hello\r\n;BufferedUart(标准输入喂 abcde):
[after new] ISR=0x026000c0 CR1=0x0000003d BRR=35
hello from BufferedUart
write_all: true
flush: true
[after flush] ISR=0x02600080 CR1=0x0000003d BRR=35
read: true Ok("abcde")
BRR = 35 是 4 MHz ÷ 115200 ≈ 34.7 取整。本页上面 lostwake 的结果也都是在这个补丁上跑的。
共享总线:embassy-embedded-hal 的 SpiDevice
USART 是点对点的,SPI 和 I2C 常常一条总线挂几个设备。embassy-embedded-hal 0.6.0 的 shared_bus 把一个总线驱动包进 embassy-sync 的 async Mutex,每个设备持有 &Mutex 和自己的片选引脚。SpiDevice 的 transaction:
let mut bus = self.bus.lock().await;
self.cs.set_low().map_err(SpiDeviceError::Cs)?;
let cs_drop = OnDrop::new(|| {
// This drop guard deasserts CS pin if the async operation is cancelled.
// Errors are ignored in this drop handler, as there's nothing we can do about them.
// If the async operation is completed without cancellation, this handler will not
// be run, and the CS pin will be deasserted with proper error handling.
let _ = self.cs.set_high();
});
锁住总线、拉低片选,然后逐个执行读写操作;任何一步出错都先 flush 再拉高片选。又是 OnDrop:事务的 future 被取消时,锁会随 bus 一起释放,片选由这个守卫拉高,总线不会停在「某个设备被选中」的状态。Mutex 的类型参数 M: RawMutex 决定这把锁能不能跨执行器、跨中断用(见原始互斥)。
动脑筋答案
uart_dma_blocking.rs 就是把 uart_dma.rs 里的 uart.write(b"dma\r\n").await 换成 uart.blocking_write(b"dma\r\n"):
blocking_write returned Ok(())
$ wc -c serial-dmab.txt serial-dma.txt
5 serial-dmab.txt
0 serial-dma.txt
5 total
- 5 个字节,
od -c是d m a \r \n。阻塞写不经过 DMA,CPU 自己忙等TXE、写TDR,在打了 32 位访问补丁的版本上和uart_blocking一样能发出去。 - DMA 版是 0 个字节,同样返回
Ok(())。差别只在谁去搬字节:QEMU 模拟了 CPU 能直接访问的 USART,没有模拟 DMA 控制器。
小结
- 三种 UART:阻塞版忙等
TXE;BufferedUart每字节一次中断,经两个无锁的单生产者单消费者环形缓冲;DMA 版整段交给 DMA,构造时要 USART 和两条 DMA 通道三条绑定(0.6.0 起)。 - 环形缓冲的下标在
0..2*len里绕圈,满和空可区分、不需要额外标志。 - 0.6.0 的
BufferedUartTx::write整段或不写:比缓冲长返回BufferTooLong,空间不够时返回Pending却不登记 waker,QEMU 上复现为永远挂起;main 上已改回,尚未发布。 - DMA 的
Transfer在 drop 时停通道并等它停下,OnDrop负责恢复 USART 的中断和 DMA 请求位;共享 SPI 总线用同样的守卫在取消时拉高片选。 - QEMU 上 USART 不出字有两个原因:
TDR/RDR的 8 位访问触发总线错误(表现为卡在HardFault),写进ICR的旧ISR值清掉了TXE;打补丁后阻塞和缓冲版都能收发。DMA 在 QEMU 上没有模型,DMA 写立刻返回Ok(())而什么也没发。