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),但你可以只映射你需要的范围
我的习惯:映射大小我通常会多映射一些,比如外设寄存器范围是0x100字节,我直接映射0x1000。这样万一后续要扩展,不用改代码。但别太浪费,映射越大越耗内核虚拟地址空间。

4.2.2 为什么不能用普通指针?

你可能会问:我直接*(volatile uint32_t *)virt_addr = val不行吗?

不行。原因有二:

  1. 缓存问题:普通内存访问会经过CPU缓存,但外设寄存器必须禁止缓存。ioremap默认映射为非缓存、非推测的属性。
  2. 访问顺序:编译器可能优化你的代码,打乱读写顺序。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手动同步缓存。适合一次性的数据传输,比如音频数据流。优点是灵活,但需要你记得同步。
注意:流式DMA的同步操作不能省。我曾经在做一个视频采集驱动时,忘了在CPU读取数据前调用dma_sync_single_for_cpu,结果画面全是花屏。查了半天才发现是缓存没刷新。嗯,这坑我替你们踩过了。

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的中断子系统,这中间的门道也不少。到时候见。