Rust嵌入式DMA抽象:embedded-dma库
好,咱们进入第二章。上一章聊了DMA的基本概念,这一章我们聚焦Rust生态里最核心的DMA抽象层——embedded-dma库。说实话,我第一次接触这个库的时候,也被它的trait体系绕得有点晕。但用熟了之后你会发现,这套设计其实非常精巧。
为什么需要embedded-dma?
直接操作DMA寄存器行不行?当然行。我在项目早期就这么干过——直接读写STM32的DMA_CCR寄存器,手动配置源地址、目的地址、传输长度。结果呢?代码耦合严重,换个芯片型号就得重写大半。更头疼的是,缓存一致性问题全靠人肉保证,稍不留神就踩坑。
embedded-dma库要解决的就是这两个痛点:
- 硬件抽象:提供统一的trait接口,屏蔽不同MCU的DMA外设差异
- 安全保证:在类型层面约束缓冲区的所有权和访问权限,从根源上避免缓存一致性问题
核心思想:把DMA传输的“谁可以读”、“谁可以写”、“什么时候完成”这些规则,编码到Rust的类型系统里。编译器帮你检查,而不是靠运行时断言。
DMA trait体系全景
我们先看整体结构,再逐个拆解。下面这张图是我自己整理的,每次给团队新人讲DMA抽象时都会拿出来用。
核心trait详解
1. DmaDevice
这是最顶层的trait,代表一个具备DMA能力的设备。说白了,就是告诉系统“我有DMA控制器,可以分配通道”。每个MCU的DMA外设驱动都会实现这个trait。
pub trait DmaDevice {
type Channel: DmaChannel;
fn channel(self) -> Self::Channel;
}
嗯,这里要注意——channel()方法会消费设备所有权。也就是说,一个DMA设备实例只能分配一次通道。这种设计是为了防止多个传输任务同时抢占同一个DMA控制器。
2. DmaChannel
代表一个具体的DMA通道。不同的MCU可能有不同数量的通道,比如STM32F4有8个,STM32H7有16个。这个trait主要提供通道的配置接口:
pub trait DmaChannel {
/// 设置源地址
fn set_source(&mut self, addr: u32, inc: bool);
/// 设置目的地址
fn set_destination(&mut self, addr: u32, inc: bool);
/// 设置传输长度(字节数)
fn set_length(&mut self, len: u16);
/// 启动传输
fn start(&mut self);
/// 检查传输是否完成
fn is_done(&self) -> bool;
}
个人经验:我习惯在初始化阶段就把通道配置好,运行时只改地址和长度。这样可以减少寄存器写操作的次数,提升性能。尤其是高频触发的DMA传输,每次重新配置整个通道的开销不可忽视。
3. Buffer trait体系
这部分是embedded-dma的精髓。它把缓冲区分为三种角色:
| trait | 角色 | 典型场景 |
|---|---|---|
ReadBuffer |
DMA从该缓冲区读取数据(源) | 内存→外设(如SPI发送) |
WriteBuffer |
DMA向该缓冲区写入数据(目的) | 外设→内存(如ADC采集) |
ReadWriteBuffer |
同时支持读写 | 内存↔内存传输 |
每个trait都关联一个指针类型,用于告诉DMA控制器实际的物理地址:
pub trait ReadBuffer {
type Word: 'static;
fn read_buffer(&self) -> (*const Self::Word, usize);
}
pub trait WriteBuffer {
type Word: 'static;
fn write_buffer(&mut self) -> (*mut Self::Word, usize);
}
返回的元组是(指针, 长度)。指针就是DMA控制器要用的物理地址,长度是缓冲区能容纳的元素个数。
我曾经踩过的坑:这里的长度是元素个数,不是字节数!如果你的Word是u32,长度是100,那实际传输的是400字节。我有一回在SPI驱动里传图像数据,长度填了1024,结果只传了256个像素——因为Word默认是u8,而我实际用的是u32缓冲区。排查了整整一个下午。
缓冲区类型与约束
不是所有内存都能给DMA用。有些MCU的DMA只能访问特定的内存区域,比如SRAM1,不能访问SRAM2或Flash。所以embedded-dma引入了内存区域标记:
pub unsafe trait DmaRegion {
/// 该区域是否支持DMA访问
fn is_dma_capable() -> bool;
}
// 标记SRAM1为DMA可用
pub struct Sram1;
unsafe impl DmaRegion for Sram1 {
fn is_dma_capable() -> bool { true }
}
// 标记SRAM2为DMA不可用(某些MCU)
pub struct Sram2;
unsafe impl DmaRegion for Sram2 {
fn is_dma_capable() -> bool { false }
}
然后,缓冲区类型会关联这个区域标记:
pub struct DmaBuf<T, R: DmaRegion> {
data: &'static mut [T],
_region: PhantomData<R>,
}
impl<T, R: DmaRegion> ReadBuffer for DmaBuf<T, R> {
type Word = T;
fn read_buffer(&self) -> (*const T, usize) {
(self.data.as_ptr(), self.data.len())
}
}
这样,如果你试图把一个位于SRAM2的缓冲区传给DMA,编译器直接报错——根本编译不过去。我在项目中用这个机制堵住了好几次潜在的硬件错误。
传输生命周期管理
DMA传输是异步的,启动后CPU可以干别的事。但缓冲区怎么办?CPU改了数据,DMA还在读旧的——这就是缓存一致性问题的一个来源。embedded-dma通过所有权转移来解决:
pub trait DmaTransfer {
type Buffer: ReadBuffer + WriteBuffer;
/// 等待传输完成,返回缓冲区所有权
fn wait(self) -> Self::Buffer;
/// 非阻塞检查
fn is_done(&self) -> bool;
}
关键点:wait()消费了DmaTransfer对象,返回缓冲区。在传输完成之前,你无法访问缓冲区。这就从类型系统层面保证了:
- 传输进行中,CPU不会意外修改缓冲区
- 传输完成后,缓冲区一定是最新数据
- 不会出现“DMA还在写,CPU已经开始读”的竞态条件
一句话总结:embedded-dma把DMA传输的“谁拥有缓冲区”这个核心问题,用Rust的所有权模型表达得清清楚楚。你不需要在运行时加锁、不需要手动同步、不需要担心缓存一致性问题——只要代码能编译通过,传输就是安全的。
实际使用示例
最后给一个完整的SPI DMA发送示例,看看这些trait怎么配合:
// 1. 获取DMA设备
let dma = pac.DMA1.split();
let channel = dma.channel();
// 2. 准备缓冲区(必须位于DMA可达区域)
let buf: DmaBuf<u8, Sram1> = DmaBuf::new(&mut TX_DATA);
// 3. 配置SPI使用DMA发送
let spi = pac.SPI1.enable();
spi.set_tx_dma(channel);
// 4. 启动传输(所有权转移给transfer)
let transfer = spi.transfer(buf).unwrap();
// 5. CPU可以干其他事...
// 6. 等待传输完成,拿回缓冲区
let (spi, buf) = transfer.wait();
// 此时buf又可以继续使用了
看到没?整个流程里没有一行手动同步代码。所有安全保证都由类型系统在编译期完成。我个人觉得,这才是嵌入式Rust相比C最大的优势——不是性能,而是正确性由编译器保障。