第三章:内存映射核心概念

做BSP开发这些年,我最大的体会就是——内存映射这关过不了,后面全是坑。你想想看,CPU要访问外设寄存器、要读写DDR,总得知道地址在哪吧?但这里头有个关键问题:CPU用的是虚拟地址,硬件只认物理地址。怎么把这两者对上?这就是我们今天要聊的核心。

3.1 物理地址与虚拟地址

先说个最基础的概念。物理地址就是硬件实实在在的地址,比如你板子上挂了个UART,它的寄存器就在0x10000000这个物理位置。虚拟地址呢?是CPU给软件看的假地址。每个进程都以为自己独占4GB空间,其实都是假的。

我记得刚入行那会儿,调试一个DMA驱动,死活传不对数据。查了两天,最后发现是DMA控制器只能访问物理地址,而我传了个虚拟地址进去。嗯,这种低级错误,犯过一次就再也不会忘了。

关键区别:

  • 物理地址:硬件总线上的真实地址,由SoC设计决定
  • 虚拟地址:CPU MMU翻译后的地址,每个进程独立
  • 总线地址:外设视角看到的地址,有时和物理地址不同

在设备树里,我们通常用reg属性来描述物理地址范围。比如:

uart@10000000 {
    compatible = "ns16550";
    reg = <0x0 0x10000000 0x0 0x1000>;
    interrupts = <0 33 4>;
};

这里的0x10000000就是物理地址,长度0x1000。驱动里通过of_iomap()拿到的是虚拟地址,但背后MMU已经帮你做好了映射。

3.2 MMU工作原理

MMU说白了就是个地址翻译器。CPU发出虚拟地址,MMU查页表,找到对应的物理地址,然后去访问内存。这个过程叫「地址翻译」。

我画了个简图,帮你理解这个流程:

MMU地址翻译流程 CPU 发出虚拟地址 VA MMU 查页表 权限检查 页表 (内存中) PA 物理内存 外设寄存器 TLB (快表) 缓存最近翻译结果 VA: 虚拟地址 PA: 物理地址 TLB: Translation Lookaside Buffer

这里有个关键点:MMU不是每次翻译都去查内存里的页表,那样太慢了。它有个小缓存叫TLB,专门存最近用过的翻译结果。我调试过一个性能问题,就是TLB miss率太高,后来通过修改内核页表大小解决了。

我的经验:在BSP开发中,你不需要完全搞懂MMU的每个细节,但必须知道:

  • 设备树里配的地址是物理地址
  • 驱动里访问的是虚拟地址
  • DMA必须用物理地址

3.3 内存映射的层次结构

Linux内核把虚拟地址空间分成了几个区。我习惯这么记:

区域 虚拟地址范围 用途
用户空间 0x00000000 - 0xBFFFFFFF 应用程序、libc、堆栈
内核空间 0xC0000000 - 0xFFFFFFFF 内核代码、驱动、页表
vmalloc区 0xF8000000 - 0xFFFFFFFF 动态映射、大块内存
固定映射区 0xFFFF0000 - 0xFFFF0FFF 中断向量表等

在设备树里,我们经常要处理「内存映射的层次」。比如一个GPU驱动,它可能需要:

  • 映射一大片连续物理内存给GPU用(DMA内存)
  • 映射几个控制寄存器(MMIO)
  • 映射中断控制器(GIC)

这些映射在设备树里就是不同的reg条目:

gpu@18000000 {
    compatible = "arm,mali-midgard";
    reg = <0x0 0x18000000 0x0 0x4000>,   // 寄存器
          <0x0 0x20000000 0x0 0x10000000>; // GPU内存
    interrupts = <0 34 4>, <0 35 4>, <0 36 4>;
};

我曾经遇到一个坑:某个外设的寄存器地址在0x18000000,但它的DMA缓冲区却在0x20000000。驱动里如果混用这两个地址,数据就全乱了。所以,一定要搞清楚每个reg条目对应什么。

3.4 Cache与Buffer策略

Cache和Buffer,这两个东西搞不清楚,调试驱动时会疯掉。我简单说说:

  • Cache:CPU内部的高速缓存,存的是数据副本。读的时候先看Cache,没有才去内存。
  • Buffer:一般是写操作的缓冲,比如写合并、写缓冲。

在设备树里,我们通过dma-coherent属性或者dma-ranges来控制Cache策略。举个例子:

// 这个设备需要Cache一致性
ethernet@20000000 {
    compatible = "snps,dwmac";
    reg = <0x0 0x20000000 0x0 0x2000>;
    dma-coherent;  // 告诉内核:硬件自己维护Cache一致性
};

// 这个设备不需要Cache
uart@10000000 {
    compatible = "ns16550";
    reg = <0x0 0x10000000 0x0 0x1000>;
    // 没有dma-coherent,默认使用non-cacheable映射
};

注意:如果外设和CPU共享内存(比如网卡的DMA缓冲区),必须处理好Cache一致性问题。否则会出现:CPU写了一个数据,但DMA读到的还是旧数据。我曾经调试一个网络丢包问题,查了三天才发现是Cache没刷。

我个人习惯的做法是:

  1. 对于寄存器映射,一律用ioremap(),它默认是non-cacheable的
  2. 对于DMA缓冲区,用dma_alloc_coherent(),它保证Cache一致性
  3. 如果必须用Cacheable映射,记得在DMA传输前后调用dma_map_single()和dma_unmap_single()

嗯,这里还要提一下页表属性。在ARM64上,页表条目里有个字段叫AttrIndx,它决定了这块内存的Cache策略。设备树里的dma-coherent最终就是通过修改这个字段来实现的。

总结一下我的经验:

  • 物理地址是硬件的,虚拟地址是软件的,MMU是桥梁
  • 设备树里配地址,驱动里用地址,中间MMU帮你翻译
  • Cache策略搞错了,轻则性能差,重则数据错乱
  • 调试内存问题,先看设备树,再看驱动,最后查MMU配置

说实话,内存映射这块内容,光看书是学不会的。我建议你找个开发板,自己改改设备树,看看不同映射策略对性能的影响。踩过几个坑,自然就懂了。

可可精品资料库