4、内存管理:GEM与DMA-BUF、页表构建、IOMMU配置

内存管理,说白了就是给GPU喂数据。你想想看,CPU和GPU是两个独立的处理器,它们各自有各自的内存空间。CPU跑在系统内存(DDR)上,GPU跑在显存(VRAM)上。怎么让它们高效地共享数据?这就是我们这一章要啃的硬骨头。

我个人习惯把GPU内存管理分成三个层次:分配(谁管内存)、映射(怎么访问)、保护(谁不能访问)。GEM和DMA-BUF解决的是分配和共享问题,页表构建解决的是映射问题,IOMMU配置解决的是保护问题。咱们一个一个来。

4.1 GEM:图形执行管理器

GEM(Graphics Execution Manager)是DRM框架里最核心的内存分配器。我在项目中遇到过不少新手,上来就问“为什么不用malloc给GPU分配内存?”嗯,这个问题问得好。因为malloc分配的是虚拟地址,而GPU需要的是物理连续的或者至少是IOMMU能处理的内存块。

GEM的核心数据结构是struct drm_gem_object。每个GEM对象代表一块GPU可访问的内存。它的生命周期是这样的:

// 分配一个GEM对象
struct drm_gem_object *obj = drm_gem_object_alloc(dev, size);
// 用户态通过handle访问
uint32_t handle;
drm_gem_handle_create(file_priv, obj, &handle);
// 使用完毕后关闭handle
drm_gem_handle_delete(file_priv, handle);

这里有个坑:GEM对象本身不包含物理页。它只是一个“票据”,真正的物理内存分配是在drm_gem_object_init()或者驱动自定义的gem_create_object回调里完成的。我曾经调试过一个bug,驱动在gem_create_object里忘了调用drm_gem_object_init,结果内核直接Oops了。

注意:GEM对象的生命周期由引用计数管理。用户态持有handle时,内核会维护一个引用。如果用户态崩溃了没释放handle,内核会在文件描述符关闭时自动清理。但如果你在内核态自己拿了引用忘了放,那就是内存泄漏。

4.2 DMA-BUF:跨设备共享的桥梁

GEM解决了单个GPU的内存分配问题。但现实场景中,数据经常要在多个设备间流转。比如摄像头采集的数据要给GPU做处理,处理完再给显示控制器输出。这时候就需要DMA-BUF出场了。

DMA-BUF本质上是一个文件描述符,它指向一块物理内存。任何设备驱动,只要拿到了这个fd,就能通过dma_buf_get()获取到对应的dma_buf结构体,然后映射到自己的IOMMU地址空间。

我建议你在设计驱动时,把DMA-BUF的导出和导入接口做得通用一些。比如这样:

// 导出端:从GEM对象创建DMA-BUF
struct dma_buf *dmabuf = drm_gem_prime_export(dev, obj, flags);
int fd = dma_buf_fd(dmabuf, O_CLOEXEC);
// 把fd传给另一个进程或设备

// 导入端:从fd获取GEM对象
struct dma_buf *dmabuf = dma_buf_get(fd);
struct drm_gem_object *obj = drm_gem_prime_import(dev, dmabuf);
dma_buf_put(dmabuf);

这里有个容易忽略的点:DMA-BUF的缓存一致性。不同设备对内存的访问模式不一样。GPU可能用写结合(write-combine),CPU用回写(write-back)。如果两者共享同一块内存,必须处理好同步。我记得有一次,CPU写了一个纹理数据,GPU读出来全是乱的。查了半天,发现是忘了调用dma_buf_begin_cpu_access()dma_buf_end_cpu_access()

经验之谈:如果你的GPU和CPU共享内存频繁,可以考虑使用dma_buf_attach()dma_buf_map_attachment()来建立长期映射。这样每次访问时不需要重新建立页表,性能会好很多。

4.3 页表构建:GPU的虚拟内存

GPU也有自己的MMU,叫IOMMU(或者SMMU)。它和CPU的MMU原理类似,都是把虚拟地址翻译成物理地址。但GPU的页表结构通常更简单,层级更少。

以我调试过的某款嵌入式GPU为例,它的页表是两级结构:

  • 一级页表(L1):包含1024个条目,每个条目指向一个二级页表
  • 二级页表(L2):包含1024个条目,每个条目映射一个4KB的物理页

这样算下来,一个进程能管理的虚拟地址空间是:1024 × 1024 × 4KB = 4GB。够用了。

页表构建的核心函数大概长这样:

static int gpu_iommu_map_pages(struct gpu_device *gpu,
                               uint64_t iova,
                               struct page **pages,
                               size_t size)
{
    // 计算需要的页表层级
    uint64_t l1_index = (iova >> 22) & 0x3FF;
    uint64_t l2_index = (iova >> 12) & 0x3FF;
    
    // 如果L1页表项为空,分配L2页表
    if (!gpu->l1_table[l1_index]) {
        gpu->l1_table[l1_index] = alloc_l2_table(gpu);
    }
    
    // 填充L2页表项
    for (int i = 0; i < size / PAGE_SIZE; i++) {
        uint64_t pte = page_to_phys(pages[i]);
        pte |= PTE_VALID | PTE_WRITABLE | PTE_READABLE;
        gpu->l1_table[l1_index]->entries[l2_index + i] = pte;
    }
    
    // 刷新TLB
    gpu_tlb_flush(gpu);
    return 0;
}

你可能会问:“为什么每次映射完都要刷新TLB?”嗯,这是因为GPU的TLB是硬件缓存的页表项。如果不刷新,GPU可能还在用旧的映射访问内存,导致访问到错误的数据甚至触发页错误。

核心要点:页表构建的关键是保证物理连续性。GPU通常要求大块内存是物理连续的,否则页表项会太多,TLB命中率下降。如果你的硬件支持大页(比如2MB),尽量用大页映射,性能能提升不少。

4.4 IOMMU配置:给内存上把锁

IOMMU的作用,说白了就是隔离。没有IOMMU时,GPU可以访问整个物理地址空间。万一驱动有bug,GPU写错了地址,可能把内核数据给覆盖了。有了IOMMU,GPU只能访问它被允许的地址范围。

配置IOMMU通常分三步:

  1. 注册IOMMU设备:在设备树或ACPI中声明IOMMU硬件
  2. 创建地址空间:为每个进程或上下文分配一个独立的IOMMU域
  3. 建立映射:把GEM对象的物理页映射到IOMMU域中

代码示例:

// 创建IOMMU域
struct iommu_domain *domain = iommu_domain_alloc(bus);
if (!domain) {
    dev_err(dev, "Failed to allocate IOMMU domain\n");
    return -ENOMEM;
}

// 将设备绑定到IOMMU域
int ret = iommu_attach_device(domain, dev);
if (ret) {
    dev_err(dev, "Failed to attach device to IOMMU\n");
    iommu_domain_free(domain);
    return ret;
}

// 映射内存
ret = iommu_map(domain, iova, phys_addr, size, prot);
if (ret) {
    dev_err(dev, "IOMMU map failed\n");
    iommu_detach_device(domain, dev);
    iommu_domain_free(domain);
    return ret;
}

我曾经踩过一个坑:IOMMU的页大小必须和GPU的页表大小一致。有一次我用了4KB的IOMMU页,但GPU硬件只支持16KB的页表粒度。结果映射上去后,GPU访问时总是报页错误。查了两天才发现是页大小不匹配。

避坑指南:配置IOMMU时,一定要检查iommu_domain_alloc()返回的pgsize_bitmap。这个位图告诉你能用哪些页大小。如果硬件不支持你想要的页大小,映射会失败。我曾经因为没检查这个,浪费了整整一个下午。

4.5 总结与实战建议

好了,我们来捋一捋。内存管理这块,GEM是基础,DMA-BUF是桥梁,页表是映射,IOMMU是保护。四者缺一不可。

我个人建议你在实际项目中,按这个顺序来调试:

  • 先确保GEM能正常分配和释放内存(用drm_mm或者gen_pool管理物理内存)
  • 再验证DMA-BUF的导出和导入(写一个简单的测试程序,在两个设备间传数据)
  • 然后构建页表(从最简单的单级页表开始,逐步过渡到多级)
  • 最后配置IOMMU(先关掉IOMMU跑通基本功能,再打开IOMMU做隔离)

这样一步步来,出问题了也容易定位。别一上来就把所有功能都打开,否则出了问题你都不知道是GEM的锅还是IOMMU的锅。

嗯,这一章就到这里。下一章我们会讲命令提交和调度,那是GPU驱动的“大脑”部分,敬请期待。