Chapter 10 · Rust 嵌入式开发

DMA 与并发安全

DMA 是硬件后台搬运数据的引擎,也是最容易出并发 bug 的地方。Rust 如何用所有权把缓冲区"借"给 DMA、用 'static 约束防止悬垂、用类型系统禁止传输期间访问缓冲区。

课程进度83%

关键概念

DMA (Direct Memory Access)
一个独立于 CPU 的硬件搬运引擎。配置好"从哪搬、搬到哪、搬多少"后,它在后台自动把数据在外设与内存之间传输,完成后触发中断。典型用途:ADC 连续采样直接入内存、串口成帧收发、SPI 刷屏。它把 CPU 从"逐字节搬运"里解放出来,是高吞吐外设的必备手段。
DMA 与 CPU 的并发风险
DMA 传输期间,硬件正在后台读写那块缓冲区。如果此时 CPU 也去读写它,就是典型的数据竞争:可能读到搬了一半的脏数据、或覆盖掉正在搬的内容。C 里这靠程序员自觉;Rust 里则用所有权把缓冲区"移交"给 DMA,传输期间编译器禁止你碰它。
缓冲区所有权转移
Rust 的核心思路:启动 DMA 时,把缓冲区(buf)按值 move 进传输对象 Transfer。此刻 buf 的所有权归 DMA 传输所有,原变量失效——你想在传输中访问它,编译器直接报错。传输完成后调用 transfer.wait() 把缓冲区所有权还给你,此时才能安全读取。
'static 生命周期约束
DMA 硬件不理解 Rust 的作用域——如果你把一个栈上的临时缓冲区交给 DMA,函数返回后栈被回收,而 DMA 还在往那块已失效的内存写,就是灾难。所以 DMA API 要求缓冲区是 'static(活得和程序一样久):static mut 数组、Box<[u8]>(泄漏成 'static)或全局静态。编译器用生命周期约束把"悬垂缓冲区"挡在门外。
embedded-dma trait(ReadBuffer / WriteBuffer)
抽象 DMA 缓冲区的标准 trait。ReadBuffer(DMA 从中读,即发送)与 WriteBuffer(DMA 往里写,即接收)提供 as_read_buffer()/as_write_buffer() 返回裸指针 + 长度给 DMA。它们是 unsafe trait,实现者要保证指针在传输期间稳定、且满足 'static/对齐要求。HAL 的 DMA API 就是泛型于这两个 trait。
Transfer 句柄
代表"一次正在进行的 DMA 传输"的类型。它拥有缓冲区、DMA 通道、外设三者的所有权,把它们绑成一个整体。你只能对它调用 is_done() 查询、wait() 阻塞等完成、或注册完成回调。传输的三个资源都被这个句柄"锁"住,直到 wait() 把它们解构还回——这就是所有权保证并发安全的体现。
双缓冲 / 乒乓缓冲 (Double / Ping-Pong Buffer)
用两块缓冲区轮流工作:DMA 往 A 里搬时,CPU 处理已搬满的 B;A 满了切换,DMA 搬 B、CPU 处理 A。这样数据流永不中断、CPU 处理与 DMA 传输完全并行。适合音频、连续 ADC 采样等不能丢样本的场景。硬件常有"半传输中断 + 传输完成中断"配合实现。
循环模式 (Circular Mode)
DMA 传输到缓冲区末尾后自动绕回开头,无限循环搬运,无需 CPU 重新配置。配合双缓冲/半中断,是采集连续数据流的常用模式。Rust HAL 用 CircBuffer 之类的类型封装,提供 peek()/read() 安全地读取"已搬好的那一半",避免读到 DMA 正在写的区域。

核心思想:把缓冲区"借"给 DMA

理解 DMA 安全的关键,是把"缓冲区所有权"看成一根接力棒。它在 CPU 和 DMA 之间传递,同一时刻只有一方持有:

DMA 缓冲区所有权的接力 ═══════════════════════════════════════════════════ CPU 持有 buf │ 填数据 / 准备 ▼ dma.write(buf) ── buf 被 move 进 Transfer ──▶ DMA 持有 │ │ │ 此刻 CPU 再碰 buf → 编译错误 │ 后台搬运 │ (value moved,原变量已失效) │ │ ▼ │ 传输完成中断 ▼ │ let (buf, dma) = transfer.wait() ◀── 所有权还回 ──┘ │ ▼ CPU 安全读取搬好的数据(此时 DMA 已停手) 同一时刻只有一个持有者 → 从根源杜绝读写竞争
这正是所有权模型的高光时刻

C 里 DMA 缓冲区是一块"大家都能访问的内存",传输中误碰它是最隐蔽的 bug 之一——编译器不会警告,运行时才偶发数据损坏。Rust 把缓冲区变成"有主人的值":交给 DMA 后你根本拿不到它,直到 wait() 归还。这类竞态在 Rust 里连编译都过不了,是"用类型系统消灭一整类 bug"的典范。

阻塞式 DMA 传输:串口发送

用 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 {}
}
为什么栈缓冲区不能给 DMA

假设你写 let mut buf = [0u8; 64]; 然后启动 DMA 就函数返回——栈帧被回收,buf 那块内存可能被后续调用复用,而 DMA 还在往那儿写,直接破坏别的数据。这就是 DMA API 要求 'static 的根本原因:缓冲区必须在整个传输(甚至更久)期间保持有效。用 static mut、全局静态、或 Box::leak 得到的 'static 引用。编译器用生命周期把这个错误挡在编译期。

循环模式 + 双缓冲:连续 ADC 采样

连续采样场景(音频、传感器流)用循环 DMA + 双缓冲,让 DMA 和 CPU 完全并行、永不丢样本:

乒乓双缓冲工作时序 ═══════════════════════════════════════════════════ 时间 ──────────────────────────────────────────▶ DMA: 写入 A写入 B写入 A写入 B ▲ ▲ ▲ │半/全中断 │ │ CPU: (空闲) │ 处理 A处理 B处理 A 关键:DMA 写 B 时 CPU 读 A,两块缓冲互不重叠 → 无竞争 若只用单缓冲,CPU 处理速度稍慢就会读到 DMA 正在覆盖的数据
// 循环模式: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 循环+双缓冲仅每半缓冲一次最高音频、连续采样流
DMA 缓冲区与 Cache / 内存屏障

在带数据 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 用所有权根治:启动传输时把缓冲区 moveTransfer 句柄(同时绑定通道与外设),传输中原变量失效、CPU 访问会编译报错wait() 完成后所有权归还才可读。DMA 硬件不懂作用域,故要求缓冲区是 'static(禁止栈临时缓冲悬垂),用 static mut/全局/Box::leak 满足。embedded-dmaReadBuffer/WriteBuffer(unsafe trait)抽象缓冲区、返回裸指针+长度给 HAL 的泛型 DMA API。连续流用循环模式 + 双缓冲:DMA 写一块时 CPU 处理另一块,靠半/全传输中断切换,两块不重叠故无竞争。带 Cache 的 M7 还需 clean/invalidate + dsb 内存屏障保证一致性。核心口诀:缓冲区所有权像接力棒,同一时刻只有 CPU 或 DMA 一方持有。