4、MMU与内存管理:GPU页表机制、IOMMU/SMMU配置、连续内存分配器(CMA)与DMA-BUF

内存管理这块,说实在的,是GPU驱动开发里最容易出“玄学”bug的地方。你想想看,CPU和GPU各自有各自的内存视角,中间还隔着一层MMU,一旦映射错了,轻则花屏,重则系统直接死给你看。我个人习惯把这一章的内容看作是整个GPU驱动的地基——地基不稳,上层再漂亮的渲染管线也是白搭。

4.1 GPU页表机制:从虚拟到物理的“翻译官”

GPU和CPU一样,现代架构都支持虚拟内存。GPU看到的是一个连续的虚拟地址空间,但背后对应的物理内存可能东一块西一块。这个翻译工作,就是由GPU内部的MMU(内存管理单元)和页表来完成的。

说白了,GPU页表就是一个多级查找表。为什么需要多级?因为单级页表太费内存了。以我调试过的一个移动GPU为例,它的虚拟地址空间是32位,如果每个页是4KB,单级页表就需要一百多万个条目,每个条目4字节,光页表就要占掉4MB内存。这还不算,每个进程都要一份,太奢侈了。

所以,实际硬件都采用两级或三级页表。比如典型的ARM Mali GPU,用的是两级页表:

  • 一级页表(L1):也叫页目录,每个条目覆盖一个大块区域(比如1MB或2MB)。
  • 二级页表(L2):也叫页表,每个条目对应一个4KB的物理页。

我在项目中遇到过一个问题:GPU访问某个地址时频繁触发缺页中断。查了半天,发现是一级页表的条目被错误地标记成了“无效”。嗯,这里要注意,页表条目里的权限位和有效位,一个都不能错。

核心要点:GPU页表的核心数据结构就是页表条目(PTE)。每个PTE通常包含:

  • 物理页帧号(PFN)
  • 访问权限(读/写/执行)
  • 缓存属性(是否可缓存、是否可共享)
  • 有效位(Valid bit)

4.2 IOMMU/SMMU配置:给外设也装上MMU

CPU有MMU,那GPU这种外设呢?它也有,叫做IOMMU(Input-Output MMU),在ARM体系里更常叫SMMU(System MMU)。

为什么要给GPU配SMMU?原因很简单:安全性和灵活性。没有SMMU的时候,GPU直接访问物理地址,一个野指针就能把整个系统搞崩。有了SMMU,GPU看到的是IO虚拟地址(IOVA),和物理地址隔了一层。

配置SMMU,我个人觉得最关键的几个步骤是:

  1. 初始化SMMU硬件:使能时钟、复位、配置流表(Stream Table)。
  2. 创建地址空间:为每个GPU上下文分配一个SMMU上下文(Context Bank)。
  3. 建立页表:将GPU的页表基地址写入SMMU的上下文描述符中。
  4. 配置流标识:每个GPU发起的DMA请求都带有一个Stream ID,SMMU根据这个ID找到对应的上下文。

我曾经踩过一个坑:SMMU的页表大小配置错了。GPU用的是4KB页,但我不小心把SMMU的页面粒度设成了64KB。结果呢?GPU访问一个地址,SMMU把它解释成了另一个地址,数据全写歪了。那次调试花了我整整两天,最后发现是寄存器里一个bit没设对。

警告:SMMU的页表格式和GPU内部的页表格式可能不同。有些GPU有自己的私有页表,SMMU只是做第二层翻译。这种情况下,你需要配置两套页表,并且保证它们的映射关系一致。否则,就会出现“双重翻译”错误。

4.3 连续内存分配器(CMA):解决大块连续内存的难题

GPU有个“坏毛病”:它经常需要大块的连续物理内存。比如 framebuffer、display buffer,还有某些硬件的descriptor ring。但系统跑久了,物理内存会碎片化,很难分配到大块连续区域。

CMA(Contiguous Memory Allocator)就是干这个用的。它的思路很巧妙:在系统启动时预留一大块内存,平时这块内存可以给普通进程用(可移动的页面),但当驱动需要连续内存时,就把这些页面“赶走”,腾出一块连续区域。

配置CMA,我建议你注意以下几点:

  • 预留大小:根据你的GPU分辨率、帧缓冲数量来算。比如1080p需要约8MB,4K需要约32MB。留少了不够用,留多了浪费。
  • 对齐要求:很多GPU要求CMA区域按2MB或1GB对齐。这个在设备树里要配好。
  • 使用时机:CMA分配可能引起页面迁移,这是耗时操作。不要在中断上下文里调用cma_alloc(),否则你会后悔的。

小技巧:调试CMA问题时,可以查看 /proc/cma 和 /sys/kernel/debug/cma/ 下的信息。我曾经通过看cma_alloc的失败次数,发现是另一个驱动偷偷占用了CMA区域,导致GPU分配不到内存。

4.4 DMA-BUF:共享内存的“快递员”

现代SoC里,GPU不是孤立的。它要和显示控制器、视频编解码器、ISP等模块共享数据。如果每个模块都自己搞一套内存管理,那数据拷贝的开销就太大了。

DMA-BUF就是Linux内核里解决这个问题的标准机制。它本质上是一个文件描述符(fd),背后关联着一块物理内存。你可以把这个fd传给其他驱动,大家共享同一块内存,零拷贝。

使用DMA-BUF的典型流程是这样的:

  1. 分配:驱动A调用dma_buf_alloc(),得到一块内存和一个fd。
  2. 导出:驱动A把fd传给用户态,用户态再传给驱动B。
  3. 导入:驱动B通过fd拿到dma_buf对象,然后调用dma_buf_attach()和dma_buf_map_attachment(),得到设备端的虚拟地址。
  4. 同步:使用dma_buf_begin_cpu_access()和dma_buf_end_cpu_access()来管理缓存一致性。

嗯,这里有个容易忽略的点:缓存一致性。GPU和CPU对内存的缓存策略不同。GPU写了一块内存,CPU去读,如果不做缓存同步,读到的可能是旧数据。我建议你在每次传输前后都显式调用dma_sync_single_for_device()和dma_sync_single_for_cpu()。

实战经验:我在调试一个视频播放场景时,发现画面有撕裂感。查到最后,是DMA-BUF的exporter和importer之间没有正确传递缓存标志。GPU以uncached方式访问,但显示控制器以为内存是cached的。解决方案是在dma_buf_attachment上设置正确的dma_attr。

4.5 避坑指南与调试技巧

最后,分享几个我这些年积累的调试技巧:

  • 页表调试:在驱动里加一个debugfs接口,可以dump出GPU的页表内容。对比物理地址和虚拟地址,一眼就能看出映射对不对。
  • SMMU错误处理:SMMU有专门的错误中断和状态寄存器。一旦触发,先读SMMU_ESR(错误状态寄存器)和SMMU_FAULT_ADDR(错误地址),能快速定位是哪个地址访问出问题了。
  • CMA分配失败:如果cma_alloc返回NULL,先检查预留大小是否足够,再看是否有其他驱动长期占用了CMA页面。可以用echo 1 > /sys/kernel/debug/cma/xxx/alloc_pages_success来触发一次强制分配测试。
  • DMA-BUF泄漏:DMA-BUF的fd如果不关闭,会导致内存泄漏。我习惯在代码里用get_dma_buf()和dma_buf_put()的配对检查,配合kmemleak工具来定位泄漏点。

说白了,MMU和内存管理这块,没有捷径。就是多看datasheet,多写测试代码,多踩坑。你想想看,我当年第一次配SMMU的时候,光一个Stream ID的映射就折腾了一周。但一旦搞懂了,后面就顺了。

下一章我们会讲GPU的命令提交和调度,那是另一个容易出幺蛾子的地方。先把内存这块吃透,后面会轻松很多。