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了。
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()。
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可能还在用旧的映射访问内存,导致访问到错误的数据甚至触发页错误。
4.4 IOMMU配置:给内存上把锁
IOMMU的作用,说白了就是隔离。没有IOMMU时,GPU可以访问整个物理地址空间。万一驱动有bug,GPU写错了地址,可能把内核数据给覆盖了。有了IOMMU,GPU只能访问它被允许的地址范围。
配置IOMMU通常分三步:
- 注册IOMMU设备:在设备树或ACPI中声明IOMMU硬件
- 创建地址空间:为每个进程或上下文分配一个独立的IOMMU域
- 建立映射:把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_domain_alloc()返回的pgsize_bitmap。这个位图告诉你能用哪些页大小。如果硬件不支持你想要的页大小,映射会失败。我曾经因为没检查这个,浪费了整整一个下午。
4.5 总结与实战建议
好了,我们来捋一捋。内存管理这块,GEM是基础,DMA-BUF是桥梁,页表是映射,IOMMU是保护。四者缺一不可。
我个人建议你在实际项目中,按这个顺序来调试:
- 先确保GEM能正常分配和释放内存(用
drm_mm或者gen_pool管理物理内存) - 再验证DMA-BUF的导出和导入(写一个简单的测试程序,在两个设备间传数据)
- 然后构建页表(从最简单的单级页表开始,逐步过渡到多级)
- 最后配置IOMMU(先关掉IOMMU跑通基本功能,再打开IOMMU做隔离)
这样一步步来,出问题了也容易定位。别一上来就把所有功能都打开,否则出了问题你都不知道是GEM的锅还是IOMMU的锅。
嗯,这一章就到这里。下一章我们会讲命令提交和调度,那是GPU驱动的“大脑”部分,敬请期待。