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 控制器、流、通道、外设请求之间的关系画得很清楚:
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。这样两个流走不同的总线矩阵,完全不会互相干扰。
通道映射黄金法则:
- 优先使用 DMA2:DMA2 挂载在 AHB 总线矩阵的高性能端口上,适合高速外设(如 SPI、ADC)。
- 避免同控制器内竞争:如果必须使用同一个 DMA 控制器,把高带宽的流放在低编号(Stream 0-3),低带宽的放在高编号(Stream 4-7)。
- 双缓冲场景用循环模式:对于音频、ADC 连续采样等场景,开启
circular模式并配合双缓冲,可以做到零拷贝。 - 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 分钟,但能帮你避免后面几个小时的调试痛苦。