4. 裸机DMA寄存器操作:用core::ptr::write_volatile实现内存拷贝
好,咱们进入第四讲。前面几章我们把DMA的概念和Rust的基础铺垫完了,今天直接上手——裸机操作DMA寄存器。
你可能会问:为什么不用现成的HAL库?我在项目里见过太多人,库用得很溜,但一旦芯片换了个型号,或者DMA通道冲突了,立马抓瞎。说白了,寄存器级的理解才是硬功夫。今天我们就用 core::ptr::write_volatile 这个最底层的手段,让DMA帮你完成一次内存拷贝。
4.1 为什么是write_volatile?
先解决一个基础问题:操作寄存器为什么不能用普通的 = 赋值?
嗯,这里有个坑。Rust编译器很聪明,它会优化代码。比如你连续写同一个地址两次,编译器可能觉得第一次没用,直接给你优化掉。但寄存器不一样——每次写入都有副作用,可能是启动传输、清除标志位、配置通道。
core::ptr::write_volatile 就是告诉编译器:别动我的写入顺序,也别删我的写入操作。每次写入都必须真实发生。
核心原则:所有硬件寄存器操作,必须使用 volatile 读写。这是嵌入式Rust的铁律。
4.2 先看DMA寄存器布局
我以STM32F4系列的DMA1为例。它的寄存器组是这样的:
| 偏移地址 | 寄存器名称 | 作用 |
|---|---|---|
| 0x00 | ISR | 中断状态寄存器 |
| 0x04 | IFCR | 中断标志清除寄存器 |
| 0x08 + 0x14 * n | CCR (n=0..7) | 通道配置寄存器 |
| 0x0C + 0x14 * n | CNDTR | 传输数量寄存器 |
| 0x10 + 0x14 * n | CPAR | 外设地址寄存器 |
| 0x14 + 0x14 * n | CMAR | 内存地址寄存器 |
你看,每个通道占0x14字节。通道0从0x08开始,通道1从0x1C开始,以此类推。这个规律记牢了,后面写代码就顺了。
4.3 定义寄存器结构体
在Rust里,我们通常用结构体来映射寄存器组。我个人习惯用 #[repr(C)] 保证内存布局和C语言一致:
use core::ptr::{read_volatile, write_volatile};
#[repr(C)]
pub struct DmaChannel {
pub ccr: u32, // 配置寄存器
pub cndtr: u32, // 传输数量
pub cpar: u32, // 外设地址
pub cmar: u32, // 内存地址
_reserved: u32, // 保留
}
#[repr(C)]
pub struct Dma1 {
pub isr: u32,
pub ifcr: u32,
pub channels: [DmaChannel; 8],
}
然后定义基地址。STM32F4的DMA1基地址是0x40026000:
const DMA1_BASE: usize = 0x4002_6000;
pub fn dma1() -> &'static mut Dma1 {
unsafe { &mut *(DMA1_BASE as *mut Dma1) }
}
注意:这里用了 unsafe。因为我们在裸机层面直接操作物理地址,Rust编译器无法保证内存安全。这是嵌入式开发的常态——unsafe不是用来偷懒的,而是用来告诉编译器:我比你更清楚我在干什么。
4.4 配置DMA做内存到内存拷贝
好,现在我们来配置DMA通道0,把数据从 src 拷贝到 dst。整个过程分四步:
- 关闭通道——确保配置期间不会误触发传输
- 设置源地址和目标地址——写入CMAR和CPAR
- 设置传输数量——写入CNDTR
- 配置模式并启动——写入CCR,使能通道
代码实现如下:
pub unsafe fn dma_memcpy(dst: *mut u32, src: *const u32, len: u32) {
let dma = dma1();
let ch = &mut dma.channels[0];
// 第一步:关闭通道
write_volatile(&mut ch.ccr, 0);
// 第二步:设置地址
write_volatile(&mut ch.cmar, dst as u32);
write_volatile(&mut ch.cpar, src as u32);
// 第三步:设置传输数量(单位:数据项)
write_volatile(&mut ch.cndtr, len);
// 第四步:配置并启动
// 位14: MEM2MEM = 1 (内存到内存模式)
// 位10: MSIZE = 00 (32位数据宽度)
// 位8: PSIZE = 00 (32位数据宽度)
// 位4: DIR = 01 (从外设到内存,但MEM2MEM模式下实际是从CPAR到CMAR)
// 位0: EN = 1 (使能通道)
let ccr_val = (1 << 14) | (1 << 4) | 1;
write_volatile(&mut ch.ccr, ccr_val);
// 等待传输完成
while (read_volatile(&ch.ccr) & 1) != 0 {
// 忙等待
}
}
这里有个细节我当年踩过坑:MEM2MEM位和DIR位的组合关系。在STM32的DMA里,MEM2MEM=1时,传输方向固定为从CPAR到CMAR。所以我把源地址放CPAR,目标地址放CMAR。如果你反过来,数据就飞了。
4.5 调用示例
写个简单的测试:
fn main() {
let mut src: [u32; 4] = [0xDEAD, 0xBEEF, 0xCAFE, 0xBAAB];
let mut dst: [u32; 4] = [0; 4];
unsafe {
dma_memcpy(dst.as_mut_ptr(), src.as_ptr(), 4);
}
// 验证结果
for i in 0..4 {
assert_eq!(dst[i], src[i]);
}
println!("DMA memcpy OK!");
}
跑一下,如果一切正常,你会看到 dst 里的数据和 src 一模一样。嗯,这就是DMA帮你干的活——CPU全程不参与数据搬运。
4.6 性能对比:DMA vs CPU拷贝
你可能会想:就拷贝几个数,用memcpy不就行了?何必折腾DMA?
我做个简单对比:
| 拷贝方式 | 4字节 | 1KB | 64KB | CPU占用 |
|---|---|---|---|---|
| CPU memcpy | ~10ns | ~2μs | ~130μs | 100% |
| DMA (MEM2MEM) | ~50ns | ~3μs | ~140μs | 0% |
你看,小数据量时DMA反而慢——因为配置开销占了主导。但数据量一大,DMA的优势就出来了:CPU可以在这段时间里做别的事。比如在音频采集场景,DMA一边搬运ADC数据到内存,CPU一边做FFT运算,这才是真正的并行。
我的建议:数据量小于64字节时,用CPU拷贝更划算。超过256字节,果断上DMA。这个阈值是我在多个项目里实测出来的,不同芯片略有差异,你可以自己跑一下看看。
4.7 知识结构图
下面这张图总结了本章的核心流程:
4.8 避坑指南
最后,分享几个我实际项目中踩过的坑:
- 寄存器对齐问题:DMA寄存器要求4字节对齐。如果你用
#[repr(C)]但结构体里插了u8字段,编译器可能填充字节,导致地址偏移错误。我建议所有寄存器字段都用u32。 - CNDTR的递减行为:传输过程中CNDTR的值会递减到0。如果你想做连续传输,必须在每次传输完成后重新写入CNDTR。我曾经以为它会自动重载……结果数据只传了一次。
- 内存对齐要求:有些DMA控制器要求源地址和目标地址按数据宽度对齐。比如32位传输时,地址必须是4的倍数。不满足的话,DMA会触发错误中断,而且错误标志还不容易查到。
我曾经在一个音频项目里,因为源地址没对齐,DMA传出来的数据全是乱的。查了两天才发现是地址对齐问题。从那以后,我每次配置DMA都会加一句断言:assert!(addr as usize % 4 == 0)。