Rust与DMA:所有权模型如何影响DMA操作

DMA(直接存储器访问)说白了就是让外设自己搬数据,不占用CPU。但在Rust里,这事儿没那么简单。Rust的所有权模型天生就不喜欢「多个地方同时访问一块内存」——而DMA恰恰需要这种能力。

我刚开始用Rust做嵌入式时,就踩过这个坑。当时想用DMA把ADC采样数据直接送到内存,结果编译器死活不让我编译。嗯,今天我们就来聊聊这背后的逻辑。

所有权模型与DMA的冲突点

Rust的核心规则很简单:每个值只有一个所有者。但DMA操作需要满足三个条件:

  • 外设能直接读写某块内存
  • CPU在DMA传输期间不能碰这块内存(至少不能乱碰)
  • 传输完成后,CPU要能安全地拿回控制权

你看,这跟Rust的「单一所有者」规则天然矛盾。DMA相当于在CPU之外又开了一个「隐形所有者」,Rust编译器看不到它,自然就不允许你随便共享内存。

核心矛盾:Rust的借用检查器在编译期保证内存安全,但DMA是运行时行为,编译器无法静态验证DMA控制器不会乱写内存。

为什么必须用unsafe

我经常被问到:「能不能不用unsafe做DMA?」答案是:不能。至少目前不能。

unsafe在DMA场景下主要做三件事:

  1. 获取裸指针——绕过借用检查器,直接操作内存地址
  2. 声明内存稳定性——告诉编译器「这块内存在DMA期间不会被移动或释放」
  3. 实现Send/Sync trait——让DMA缓冲区可以在中断和主线程间传递

举个例子,一个典型的DMA缓冲区声明:

// 静态分配,保证地址固定
static mut DMA_BUF: [u8; 1024] = [0u8; 1024];

fn setup_dma() {
    unsafe {
        // 获取裸指针,绕过借用检查
        let buf_ptr = DMA_BUF.as_mut_ptr();
        // 配置DMA寄存器...
        DMA_SRC_ADDR.write(buf_ptr as u32);
        DMA_DST_ADDR.write(0x4000_0000); // 外设地址
        DMA_CTRL.write(ENABLE | TRANSFER_SIZE);
    }
}

这里用了static mut,本身就是unsafe的。为什么?因为编译器无法保证中断和主循环不会同时访问它。

注意:千万不要在DMA传输过程中让Rust的引用指向缓冲区。一旦Rust的引用存在,编译器可能做各种优化(比如把变量放到寄存器里),导致DMA写的是物理内存,而CPU读的是寄存器缓存——数据不一致就来了。

所有权转移 vs DMA传输

Rust的所有权转移是编译期确定的,DMA传输是运行时发生的。这两者的时间尺度完全不同。

特性 Rust所有权 DMA传输
发生时间 编译期 运行时
检查方式 静态检查 硬件执行
内存访问 单一所有者 CPU + DMA控制器
错误后果 编译失败 数据损坏或系统崩溃

我个人习惯把DMA缓冲区看作「被托管的内存」——所有权暂时交给DMA控制器,传输完成后再还给Rust。这种思路在编写安全抽象时很有用。

安全抽象:把unsafe关进笼子里

虽然底层必须用unsafe,但我们可以封装出安全的API。我做过的一个项目里,是这样设计的:

pub struct DmaBuffer<T> {
    buf: &'static mut [T],
    in_use: AtomicBool,
}

impl<T> DmaBuffer<T> {
    pub fn new(buf: &'static mut [T]) -> Self {
        DmaBuffer {
            buf,
            in_use: AtomicBool::new(false),
        }
    }

    pub fn start_transfer(&self) -> Result<(), DmaError> {
        if self.in_use.swap(true, Ordering::Acquire) {
            return Err(DmaError::Busy);
        }
        unsafe {
            // 这里unsafe是安全的,因为:
            // 1. 缓冲区生命周期是'static
            // 2. in_use标志防止并发访问
            // 3. 传输完成后会清除in_use
            start_dma_hw(self.buf.as_ptr(), self.buf.len());
        }
        Ok(())
    }

    pub fn transfer_done(&self) {
        self.in_use.store(false, Ordering::Release);
    }
}

你看,unsafe被限制在start_dma_hw这个内部函数里。外部使用者看到的全是安全接口。这就是Rust社区推荐的做法——封装unsafe,而不是扩散unsafe。

我的经验:写DMA驱动时,先画清楚「谁在什么时候能访问哪块内存」。然后针对每个访问路径,问自己:「Rust编译器能验证这个路径的安全性吗?」如果不能,就用unsafe,但必须用注释写清楚安全前提。

DMA传输的内存生命周期管理

这块特别容易出问题。我曾经在调试一个SPI DMA驱动时,花了整整两天才找到bug——缓冲区被提前释放了。

Rust的所有权模型要求:引用的生命周期不能超过被引用的数据。但DMA传输是异步的,传输完成回调可能发生在任意时刻。如果缓冲区在传输完成前被drop了,DMA还在往那块地址写数据——恭喜你,拿到了一个use-after-free漏洞。

解决方案通常有三种:

  • 静态分配:用static mut,生命周期等于程序运行时间
  • 引用计数:用Arc<Mutex<...>>,但嵌入式环境要小心动态分配
  • 所有权转移:把缓冲区所有权交给DMA驱动,传输完成后再还回来

我个人偏爱第三种,因为它最符合Rust的设计哲学。下面是一个简化示例:

pub fn dma_transfer<T>(buf: &'static mut [T]) -> DmaHandle<T> {
    unsafe {
        // 配置DMA,把缓冲区地址写入硬件寄存器
        // 返回一个句柄,句柄drop时会等待传输完成
    }
    DmaHandle { buf, completed: false }
}

impl<T> Drop for DmaHandle<T> {
    fn drop(&mut self) {
        // 等待DMA传输完成
        while !self.completed {}
        // 此时可以安全释放缓冲区
    }
}

这样,缓冲区的生命周期就和DMA传输绑定了。句柄不drop,缓冲区就不会被释放。

SVG:Rust所有权与DMA交互流程

Rust所有权模型与DMA交互流程 Rust所有权系统 编译期检查 单一所有者 生命周期验证 冲突:unsafe必要性 运行时行为 多访问者 编译器不可见 DMA传输操作 裸指针操作 硬件寄存器配置 异步传输完成 无法直接满足 必须使用 安全抽象层:封装unsafe,暴露安全API 静态分配 'static生命周期 引用计数 Arc + Mutex 所有权转移 句柄生命周期绑定 核心原则:unsafe只用于与硬件交互的底层 上层API必须保证内存安全,不泄露unsafe细节

实际项目中的避坑指南

我整理了几个常见问题,都是我在项目中真实遇到过的:

问题 现象 原因 解决方案
缓冲区被优化掉 DMA写入后读不到数据 编译器认为缓冲区未被使用,优化掉了 使用volatile或core::ptr::read_volatile
中断中访问缓冲区 随机崩溃 中断和主循环同时访问static mut 使用Atomic标志或关中断保护
缓冲区地址不对齐 DMA传输异常 某些DMA要求缓冲区地址对齐到4字节或16字节 使用#[repr(align(N))]属性
生命周期标注错误 编译失败 DMA回调中引用了栈上变量 使用'static生命周期或Arc

我曾经踩过的坑:有一次在STM32上做USART DMA接收,缓冲区定义在main函数的栈上。DMA配置时传了地址,但main函数返回后栈空间被回收。DMA继续往那块地址写数据,直接写崩了堆内存。从那以后,我所有DMA缓冲区都用static mut或者从全局分配器申请。

小结

Rust的所有权模型和DMA操作确实存在矛盾,但这个矛盾不是不可调和的。关键点就三个:

  • 理解为什么需要unsafe——因为编译器看不到DMA控制器这个「隐形访问者」
  • 学会封装unsafe——把不安全的硬件操作关在底层,上层提供安全接口
  • 管理好缓冲区生命周期——确保DMA传输期间内存不会被释放或移动

说白了,Rust的严格检查反而帮我们避开了很多C语言里常见的DMA bug。只要你理解了这套规则,写出来的DMA驱动比C版本要可靠得多。