2. 嵌入式系统与Rust基础

好,咱们进入第二章。上一章聊了DMA的基本概念,这一章我们聚焦Rust生态里最核心的DMA抽象层——embedded-dma库。说实话,我第一次接触这个库的时候,也被它的trait体系绕得有点晕。但用熟了之后你会发现,这套设计其实非常精巧。

为什么需要embedded-dma?

直接操作DMA寄存器行不行?当然行。我在项目早期就这么干过——直接读写STM32的DMA_CCR寄存器,手动配置源地址、目的地址、传输长度。结果呢?代码耦合严重,换个芯片型号就得重写大半。更头疼的是,缓存一致性问题全靠人肉保证,稍不留神就踩坑。

embedded-dma库要解决的就是这两个痛点:

  • 硬件抽象:提供统一的trait接口,屏蔽不同MCU的DMA外设差异
  • 安全保证:在类型层面约束缓冲区的所有权和访问权限,从根源上避免缓存一致性问题

核心思想:把DMA传输的“谁可以读”、“谁可以写”、“什么时候完成”这些规则,编码到Rust的类型系统里。编译器帮你检查,而不是靠运行时断言。

DMA trait体系全景

我们先看整体结构,再逐个拆解。下面这张图是我自己整理的,每次给团队新人讲DMA抽象时都会拿出来用。

embedded-dma trait 体系结构 DmaDevice DmaChannel DmaBuffer SingleChannel / MultiChannel ChannelPair (TX+RX) ReadBuffer / WriteBuffer ReadWriteBuffer (组合) DmaTransfer (传输句柄) is_done() / wait() 所有权转移:Buffer → Transfer → 完成回调 → Buffer 归还

核心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最大的优势——不是性能,而是正确性由编译器保障。