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场景下主要做三件事:
- 获取裸指针——绕过借用检查器,直接操作内存地址
- 声明内存稳定性——告诉编译器「这块内存在DMA期间不会被移动或释放」
- 实现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交互流程
实际项目中的避坑指南
我整理了几个常见问题,都是我在项目中真实遇到过的:
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 缓冲区被优化掉 | 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版本要可靠得多。