DMA 与并发安全
DMA 是硬件后台搬运数据的引擎,也是最容易出并发 bug 的地方。Rust 如何用所有权把缓冲区"借"给 DMA、用 'static 约束防止悬垂、用类型系统禁止传输期间访问缓冲区。
DMA 是硬件后台搬运数据的引擎,也是最容易出并发 bug 的地方。Rust 如何用所有权把缓冲区"借"给 DMA、用 'static 约束防止悬垂、用类型系统禁止传输期间访问缓冲区。
buf)按值 move 进传输对象 Transfer。此刻 buf 的所有权归 DMA 传输所有,原变量失效——你想在传输中访问它,编译器直接报错。传输完成后调用 transfer.wait() 把缓冲区所有权还给你,此时才能安全读取。'static(活得和程序一样久):static mut 数组、Box<[u8]>(泄漏成 'static)或全局静态。编译器用生命周期约束把"悬垂缓冲区"挡在门外。ReadBuffer(DMA 从中读,即发送)与 WriteBuffer(DMA 往里写,即接收)提供 as_read_buffer()/as_write_buffer() 返回裸指针 + 长度给 DMA。它们是 unsafe trait,实现者要保证指针在传输期间稳定、且满足 'static/对齐要求。HAL 的 DMA API 就是泛型于这两个 trait。is_done() 查询、wait() 阻塞等完成、或注册完成回调。传输的三个资源都被这个句柄"锁"住,直到 wait() 把它们解构还回——这就是所有权保证并发安全的体现。CircBuffer 之类的类型封装,提供 peek()/read() 安全地读取"已搬好的那一半",避免读到 DMA 正在写的区域。理解 DMA 安全的关键,是把"缓冲区所有权"看成一根接力棒。它在 CPU 和 DMA 之间传递,同一时刻只有一方持有:
C 里 DMA 缓冲区是一块"大家都能访问的内存",传输中误碰它是最隐蔽的 bug 之一——编译器不会警告,运行时才偶发数据损坏。Rust 把缓冲区变成"有主人的值":交给 DMA 后你根本拿不到它,直到 wait() 归还。这类竞态在 Rust 里连编译都过不了,是"用类型系统消灭一整类 bug"的典范。
用 DMA 把一块缓冲区通过串口发出去,CPU 在 wait() 前可以去做别的事。注意缓冲区是 'static:
#![no_std] #![no_main] use panic_halt as _; use cortex_m_rt::entry; use stm32f4xx_hal::{pac, prelude::*, dma::{StreamsTuple, Transfer, config::DmaConfig}}; // 'static 缓冲区:活得和程序一样久,DMA 可安全持有它 static mut TX_BUF: [u8; 8] = [0; 8]; #[entry] fn main() -> ! { let dp = pac::Peripherals::take().unwrap(); let rcc = dp.RCC.constrain(); let clocks = rcc.cfgr.freeze(); // ...配置 USART1 的 tx + DMA2 Stream7 Channel4...(省略细节) let streams = StreamsTuple::new(dp.DMA2); let tx_stream = streams.7; // 取一个指向 static 缓冲区的 'static 可变引用(新版本推荐 &raw mut) let buf: &'static mut [u8; 8] = unsafe { &raw mut TX_BUF }.as_mut().unwrap(); buf.copy_from_slice(b"hello\r\n\0"); // 构造 Transfer:buf、stream、外设的所有权都 move 进去 let mut transfer = Transfer::init_memory_to_peripheral( tx_stream, /* usart_tx */ todo!(), buf, None, DmaConfig::default().memory_increment(true), ); transfer.start(|_tx| {}); // 启动后台传输,CPU 可去干别的 // buf.copy_from_slice(...); ❌ 编译错误:buf 已被 move 进 transfer transfer.wait(); // 阻塞等传输完成,缓冲区所有权归还 // 现在可以复用 buf 发下一批(wait 返回时可解构拿回) loop {} }
假设你写 let mut buf = [0u8; 64]; 然后启动 DMA 就函数返回——栈帧被回收,buf 那块内存可能被后续调用复用,而 DMA 还在往那儿写,直接破坏别的数据。这就是 DMA API 要求 'static 的根本原因:缓冲区必须在整个传输(甚至更久)期间保持有效。用 static mut、全局静态、或 Box::leak 得到的 'static 引用。编译器用生命周期把这个错误挡在编译期。
连续采样场景(音频、传感器流)用循环 DMA + 双缓冲,让 DMA 和 CPU 完全并行、永不丢样本:
// 循环模式:DMA 到末尾自动绕回,配合 CircBuffer 安全读"稳定的那一半" use stm32f4xx_hal::dma::{Transfer, config::DmaConfig}; static mut ADC_BUF: [u16; 256] = [0; 256]; // 两半各 128 // 用 double-buffer 模式启动:DMA 在两个缓冲间自动切换 let mut transfer = Transfer::init_peripheral_to_memory( adc_stream, adc_periph, first_buf, Some(second_buf), DmaConfig::default() .memory_increment(true) .double_buffer(true) .transfer_complete_interrupt(true), ); transfer.start(|adc| adc.start_conversion()); // 在 DMA 完成中断里:切到"刚写满的那块",安全处理它 // 硬件保证中断触发时,CPU 拿到的是 DMA 已经写完、当前不再触碰的缓冲
| 方式 | CPU 占用 | 吞吐 | 适用场景 |
|---|---|---|---|
| 轮询逐字节 | 100%(全程忙等) | 低 | 极少量数据、调试 |
| 中断驱动 | 每字节一次中断,中等 | 中 | 低速外设、按需收发 |
| DMA 单次 | 仅启动+完成两次 | 高 | 成帧收发、刷屏 |
| DMA 循环+双缓冲 | 仅每半缓冲一次 | 最高 | 音频、连续采样流 |
在带数据 Cache 的 MCU(如 Cortex-M7)上,DMA 直接读写主存、绕过 CPU Cache,可能导致"CPU 看到的是旧 Cache、DMA 写的是新主存"的不一致。需要在启动前 clean cache、完成后 invalidate cache,并用 cortex_m::asm::dsb() 等内存屏障保证顺序。Cortex-M0/M3/M4 无数据 Cache 通常不涉及此问题,但 volatile 访问与编译器重排仍需注意——HAL 的 DMA 封装一般已处理好屏障。
DMA 是独立于 CPU 的硬件搬运引擎,把 CPU 从逐字节搬运中解放,用于成帧收发、连续采样、刷屏等高吞吐场景。它的最大风险是传输期间 CPU 与 DMA 同时访问缓冲区造成数据竞争。Rust 用所有权根治:启动传输时把缓冲区 move 进 Transfer 句柄(同时绑定通道与外设),传输中原变量失效、CPU 访问会编译报错,wait() 完成后所有权归还才可读。DMA 硬件不懂作用域,故要求缓冲区是 'static(禁止栈临时缓冲悬垂),用 static mut/全局/Box::leak 满足。embedded-dma 的 ReadBuffer/WriteBuffer(unsafe trait)抽象缓冲区、返回裸指针+长度给 HAL 的泛型 DMA API。连续流用循环模式 + 双缓冲:DMA 写一块时 CPU 处理另一块,靠半/全传输中断切换,两块不重叠故无竞争。带 Cache 的 M7 还需 clean/invalidate + dsb 内存屏障保证一致性。核心口诀:缓冲区所有权像接力棒,同一时刻只有 CPU 或 DMA 一方持有。