4、内存映射与DMA:从裸机直接操作物理地址到Linux下的ioremap与DMA API
做嵌入式开发的朋友,对「直接操作物理地址」这事儿肯定不陌生。裸机时代,我们想控制一个GPIO、读写一个FIFO,直接往某个地址写值就行。但到了Linux下,这套玩法就行不通了——MMU一开,虚拟地址和物理地址就分家了。今天我就聊聊,怎么从裸机的「直来直去」,平滑过渡到Linux下的内存映射与DMA编程。
4.1 裸机时代的「暴力」操作
我记得最早做单片机开发时,控制外设就是一行代码的事:
// 裸机下直接操作物理地址
#define GPIO_BASE 0x40020000
#define GPIO_ODR (*(volatile uint32_t *)(GPIO_BASE + 0x14))
GPIO_ODR |= (1 << 5); // 拉高GPIO5
简单、粗暴、有效。为什么能这么干?因为没有MMU,CPU发出的地址就是物理地址。你写0x40020000,总线上的设备就收到了0x40020000。
但到了Linux下,每个进程都有自己的虚拟地址空间。你直接拿物理地址去访问,轻则段错误,重则系统崩溃。我有个同事刚转Linux时,直接在驱动里写了个物理地址指针去读寄存器,结果内核直接panic了。嗯,这坑我当年也踩过。
4.2 Linux下的内存映射:ioremap
Linux内核提供了一套机制,让你能把物理地址映射到内核虚拟地址空间。核心API就是ioremap。
4.2.1 ioremap 的基本用法
#include <linux/io.h>
void __iomem *base_addr;
// 将物理地址0x40020000映射到内核虚拟地址空间
base_addr = ioremap(0x40020000, 0x1000);
if (!base_addr) {
printk(KERN_ERR "ioremap failed\n");
return -ENOMEM;
}
// 通过映射后的地址访问寄存器
writel(0x20, base_addr + 0x14); // 写操作
val = readl(base_addr + 0x14); // 读操作
// 使用完后解除映射
iounmap(base_addr);
这里有几个关键点:
void __iomem *是内核用来标记IO内存地址的类型,别直接用普通指针readl/writel系列函数保证了对寄存器的原子访问- 映射大小一般按页对齐(4KB),但你可以只映射你需要的范围
4.2.2 为什么不能用普通指针?
你可能会问:我直接*(volatile uint32_t *)virt_addr = val不行吗?
不行。原因有二:
- 缓存问题:普通内存访问会经过CPU缓存,但外设寄存器必须禁止缓存。ioremap默认映射为非缓存、非推测的属性。
- 访问顺序:编译器可能优化你的代码,打乱读写顺序。readl/writel包含了内存屏障,保证访问顺序。
我曾经在调试一个网卡驱动时,发现数据包偶尔会丢。查了两天,最后发现是用了普通指针访问DMA描述符,缓存导致数据不一致。换成readl/writel后问题解决。这教训够深刻吧?
4.3 DMA:从裸机到Linux的进化
DMA(直接内存访问)在裸机和Linux下的实现思路完全不同。裸机下你直接配寄存器,Linux下你得用内核提供的DMA API。
4.3.1 裸机下的DMA操作
裸机时代,配置DMA大概是这样:
// 伪代码:裸机DMA配置
DMA_SRC_ADDR = 0x20000000; // 源地址(内存)
DMA_DST_ADDR = 0x40000000; // 目的地址(外设FIFO)
DMA_CTRL = (SIZE_1024 | INC_SRC | NO_INC_DST | START);
简单直接,但有个大问题:你用的是物理地址。如果系统有MMU,应用程序看到的虚拟地址和物理地址不一样,你传给DMA控制器的地址必须是物理地址。
4.3.2 Linux下的DMA API
Linux内核把DMA操作封装成了几个标准API。我常用的有这些:
| API | 作用 | 使用场景 |
|---|---|---|
dma_alloc_coherent |
分配一致性DMA缓冲区 | 不需要考虑缓存一致性的场景 |
dma_map_single |
映射一块已有的内存给DMA使用 | 流式DMA,需要手动处理缓存 |
dma_unmap_single |
解除DMA映射 | DMA传输完成后调用 |
dma_sync_single_for_cpu |
同步缓存给CPU | CPU读取DMA数据前调用 |
dma_sync_single_for_device |
同步缓存给设备 | CPU写数据给DMA前调用 |
4.3.3 一致性DMA vs 流式DMA
这两个概念初学者容易搞混。我简单说说:
- 一致性DMA(Coherent DMA):分配时保证CPU和设备看到的缓存是一致的。适合长时间使用的缓冲区,比如网卡的描述符环。缺点是分配开销大,而且可能占用稀缺的一致性内存。
- 流式DMA(Streaming DMA):使用已有的内存,通过API手动同步缓存。适合一次性的数据传输,比如音频数据流。优点是灵活,但需要你记得同步。
4.3.4 实战:一个简单的DMA传输示例
#include <linux/dma-mapping.h>
#include <linux/slab.h>
struct device *dev; // 你的设备结构体
dma_addr_t dma_handle;
void *cpu_addr;
size_t size = 1024;
// 分配一致性DMA缓冲区
cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);
if (!cpu_addr) {
dev_err(dev, "Failed to allocate DMA buffer\n");
return -ENOMEM;
}
// cpu_addr 是CPU看到的虚拟地址
// dma_handle 是设备看到的物理地址(总线地址)
// 把dma_handle配置给DMA控制器
write_dma_register(DMA_SRC_ADDR, dma_handle);
write_dma_register(DMA_CTRL, START);
// 等待DMA完成
wait_for_completion(&dma_done);
// 使用完释放
dma_free_coherent(dev, size, cpu_addr, dma_handle);
这段代码里,dma_alloc_coherent同时返回了CPU虚拟地址和DMA总线地址。你拿CPU地址读写数据,拿DMA地址配给硬件。内核帮你搞定了缓存一致性问题。
4.4 从裸机思维到Linux思维的转变
说白了,从裸机到Linux,最大的变化就是:你不能直接碰物理地址了。所有对硬件的访问,都得通过内核提供的接口。
我总结了几条原则,供你参考:
- 访问寄存器:用ioremap + readl/writel
- 分配DMA缓冲区:用dma_alloc_coherent或dma_map_single
- 传递地址给硬件:用dma_handle(总线地址),不是CPU虚拟地址
- 同步数据:流式DMA记得调用sync函数
核心思想:Linux内核把硬件访问抽象成了API。你写驱动时,别想着「我要操作这个物理地址」,而是想「我要用哪个内核API来完成这个操作」。习惯了这个思维,写Linux驱动就顺了。
好了,这一章的内容就到这儿。下一章我会聊聊中断处理——从裸机的中断向量表到Linux的中断子系统,这中间的门道也不少。到时候见。