3. 硬件平台选型:STM32F4系列DMA控制器介绍、DMA通道与请求映射

好,我们直接进入正题。STM32F4 系列,说实话是我个人用得最多的 Cortex-M4 平台之一。它的 DMA 控制器设计得相当成熟,尤其是多通道、双 AHB 总线矩阵的架构,让它在处理高速外设数据搬运时几乎不占 CPU 周期。今天我们就把它掰开揉碎了讲清楚。

3.1 STM32F4 DMA 控制器架构概览

STM32F4 的 DMA 控制器有两个:DMA1 和 DMA2。每个控制器有 8 个独立的流(Stream),每个流又对应 8 个通道(Channel)。你想想看,这相当于你有 16 条独立的数据搬运流水线,每条都可以配置不同的源地址、目的地址、传输宽度和优先级。

我刚开始接触这个架构时,总觉得“流”和“通道”的概念有点绕。后来在调试一个 SPI 全双工通信的项目时,才真正理解了它们的区别——流是执行单元,通道是请求来源的映射。

核心概念速记:

  • 流(Stream):DMA 的硬件执行单元,负责实际的读写操作。每个流有独立的 FIFO(4 字深)和仲裁逻辑。
  • 通道(Channel):流的输入选择器,决定当前流监听哪个外设的 DMA 请求。
  • 请求(Request):外设发出的数据传输触发信号,比如 USART 的 RXNE、ADC 的 EOC。

下面这张图是我在课程里反复用的结构图,它把 DMA 控制器、流、通道、外设请求之间的关系画得很清楚:

STM32F4 DMA 控制器架构图 DMA1 控制器 DMA2 控制器 Stream 0 Stream 1 Stream 2 Stream 3 Stream 4 Stream 5 Stream 0 Stream 1 Stream 2 Stream 3 Stream 4 Stream 5 通道选择器 通道选择器 通道选择器 通道选择器 通道选择器 通道选择器 通道选择器 通道选择器 通道选择器 通道选择器 通道选择器 通道选择器 USART1 SPI1 ADC1 TIM1 I2C1 DAC 每个 Stream 通过通道选择器映射到唯一的外设请求源,同一时间一个 Stream 只能服务一个外设

3.2 DMA 通道与请求映射表

每个流可以配置为监听 8 个通道之一。但具体哪个通道对应哪个外设,这是由硬件固定好的。我建议你把这个映射表存一份在项目文档里,调试时翻出来看一眼比翻手册快得多。

DMA 控制器 流 (Stream) 通道 0 通道 1 通道 2 通道 3 通道 4 通道 5 通道 6 通道 7
DMA1 Stream 0 SPI3 — SPI2 SPI2 — — — —
Stream 1 I2C1 — — — SPI2 — — —
Stream 2 — — I2S3 — — — — —
Stream 3 — — — — — SPI2 SPI3 SPI3
Stream 4 USART1 — — — — — — —
Stream 5 — — — — USART1 — — —
Stream 6 — — — — — — — —
Stream 7 — — — — — — — —
DMA2 Stream 0 ADC1 — — — — — — —
Stream 1 — — — — — — — —
Stream 2 — — — — — — — —
Stream 3 — — — — — — — —
Stream 4 — — — — — — — —
Stream 5 — — — — — — — —
Stream 6 — — — — — — — —
Stream 7 — — — — — — — —

我的经验: 上表只列出了常用外设的映射。完整映射请参考 STM32F4 参考手册的 DMA 章节。我个人习惯在项目初期就把所有用到的外设-流-通道对应关系画在一张表里,贴在工位旁边。调试时能省下大量翻手册的时间。

3.3 在 Rust 中配置 DMA 通道

我们用 stm32f4xx-hal 来演示。这个 HAL 把 DMA 的配置封装得比较优雅,但底层逻辑还是那些寄存器操作。

下面是一个典型的 DMA 配置流程,用于将 USART1 接收到的数据自动搬运到内存缓冲区:

use stm32f4xx_hal::{
    pac,
    dma::{Dma, DmaConfig, Transfer, Priority},
    serial::{Serial, config::Config},
};

// 获取外设实例
let dp = pac::Peripherals::take().unwrap();
let mut rcc = dp.RCC.constrain();

// 初始化 DMA1
let mut dma = Dma::new(dp.DMA1);

// 配置 DMA 流(Stream 4,通道 0 对应 USART1_RX)
let dma_stream = dma.stream4;

// 设置传输配置
let dma_config = DmaConfig::default()
    .memory_increment(true)      // 内存地址递增
    .peripheral_increment(false) // 外设地址固定
    .priority(Priority::High)    // 高优先级
    .circular(false);            // 非循环模式

// 创建传输描述符
let mut transfer = Transfer::new(
    dma_stream,
    usart1.rx,           // 外设端
    rx_buffer,           // 内存端
    dma_config,
);

注意: 在 Rust 中,DMA 的流和通道配置是在编译期通过类型系统保证的。比如 stream4 只能绑定到特定通道的外设。如果你试图把一个 SPI 外设绑定到 USART 的流上,编译器会直接报错。这比 C 语言的手动配置安全得多。

3.4 通道映射的避坑指南

我曾经在一个项目里踩过一个坑:同时使用 USART1 的 TX 和 RX 两个 DMA 通道。我一开始把 TX 放在 Stream 4、RX 放在 Stream 5,看起来没问题。但后来发现 Stream 4 和 Stream 5 共享同一个仲裁器,当两个流同时请求总线时,优先级低的那个会被挂起。结果就是高速通信时偶尔丢数据。

解决办法其实很简单:把 TX 和 RX 分配到不同的 DMA 控制器上。比如 TX 用 DMA1 Stream 4,RX 用 DMA2 Stream 0。这样两个流走不同的总线矩阵,完全不会互相干扰。

通道映射黄金法则:

  1. 优先使用 DMA2:DMA2 挂载在 AHB 总线矩阵的高性能端口上,适合高速外设(如 SPI、ADC)。
  2. 避免同控制器内竞争:如果必须使用同一个 DMA 控制器,把高带宽的流放在低编号(Stream 0-3),低带宽的放在高编号(Stream 4-7)。
  3. 双缓冲场景用循环模式:对于音频、ADC 连续采样等场景,开启 circular 模式并配合双缓冲,可以做到零拷贝。
  4. FIFO 阈值要匹配:FIFO 阈值(1/4、1/2、3/4、满)必须与外设的数据产生速率匹配。阈值太低会导致频繁的总线请求,太高可能造成数据溢出。

3.5 实战:用 Rust 实现 DMA 通道映射的初始化

下面是一个完整的 DMA 初始化函数,它封装了通道映射、流配置和中断使能。我在多个项目中复用这个模式,效果很好。

/// 初始化 DMA 传输
/// 
/// # 参数
/// - `stream`: DMA 流实例
/// - `peripheral`: 外设 DMA 接口
/// - `buffer`: 内存缓冲区
/// - `config`: DMA 配置
/// 
/// # 返回
/// 返回一个 `Transfer` 对象,用于控制传输生命周期
pub fn setup_dma_transfer<S, P, B, const N: usize>(
    stream: S,
    peripheral: P,
    buffer: &mut B,
    config: DmaConfig,
) -> Transfer<S, P, B>
where
    S: DmaStream,
    P: DmaPeripheral,
    B: AsMut<[u8]> + 'static,
{
    // 创建传输
    let transfer = Transfer::new(stream, peripheral, buffer, config);
    
    // 启动传输
    transfer.start(|_| {});
    
    transfer
}

小技巧: 在 Rust 中,DMA 的 Transfer 对象拥有所有权的转移语义。这意味着一旦你启动了传输,就不能再直接访问缓冲区,直到传输完成或你主动停止它。这个设计虽然一开始有点不习惯,但它从根本上杜绝了数据竞争——这在 C 语言的 DMA 编程里可是个老大难问题。

3.6 总结一下

STM32F4 的 DMA 控制器,说白了就是一套精心设计的硬件数据搬运工。理解流、通道、请求映射这三者的关系,是后续配置 DMA 传输的基石。我个人建议你在开始写代码之前,先在纸上画出你的系统里有哪些外设需要 DMA,它们分别应该挂到哪个控制器的哪个流上。这一步花不了 10 分钟,但能帮你避免后面几个小时的调试痛苦。