4、DRM GEM内存管理:GEM基础概念、drm_gem_object结构体、GEM句柄与FD、内存分配与释放

好,咱们进入DRM驱动开发里最绕不开的一个话题——GEM内存管理。

说实话,我早年做FBDEV驱动的时候,内存管理基本就是kmalloc加dma_alloc_coherent一把梭。但到了DRM时代,这套玩法就不够看了。为什么?因为DRM要面对复杂的多进程共享、GPU/Display硬件访问、以及零拷贝的需求。GEM(Graphics Execution Manager)就是为解决这些问题而生的。

4.1 GEM基础概念

GEM说白了,就是DRM框架里的一套内存对象管理机制。它不关心底层内存怎么分配(是CMA、是IOMMU映射、还是系统RAM),它只关心怎么把这些内存对象安全高效地暴露给用户态。

我个人的理解,GEM的核心价值就三点:

  • 共享:同一个内存对象可以在多个进程、多个硬件设备间共享
  • 安全:用户态不能直接操作物理地址,必须通过句柄或FD间接访问
  • 生命周期管理:引用计数自动管理,谁用完了谁释放

你想想看,如果没有GEM,用户态程序要显示一帧图像,得先申请内核内存,再通过ioctl把地址传下去,还得自己操心同步问题。有了GEM,用户态只需要拿到一个整数句柄,剩下的内核帮你搞定。

核心要点:GEM不是一种具体的内存分配策略,而是一套对象化的内存管理接口。底层可以是CMA、System RAM、VRAM,甚至是你自己实现的私有内存池。

4.2 drm_gem_object结构体

GEM的一切都围绕drm_gem_object这个结构体展开。它是所有GEM内存对象的基类。我刚开始看这个结构体的时候,觉得它字段不多,但每个字段背后都有故事。

struct drm_gem_object {
    struct kref refcount;       // 引用计数
    unsigned int handle_count;  // 句柄引用计数
    struct drm_device *dev;     // 所属DRM设备
    struct file *filp;          // 关联的匿名文件(用于FD导出)
    struct drm_vma_offset_node vma_node; // VMA映射节点
    size_t size;                // 对象大小
    int name;                   // 全局名称(用于GEM flink)
    struct dma_buf *dma_buf;    // 关联的dma-buf
    struct list_head list;      // 链表节点
};

我来挑几个重点说说:

  • refcount:这是GEM的生命线。每次用户态打开句柄、每次内核内部引用,都会增加计数。当计数归零时,对象被销毁。我曾经在调试一个内存泄漏问题时,就是靠打印refcount的值定位到某个驱动模块忘记释放引用。
  • handle_count:这个和refcount不一样。它专门记录用户态句柄的数量。当handle_count降到0时,内核会关闭该对象的所有用户态句柄。
  • filp:这个字段是实现GEM与dma-buf互操作的关键。通过它,GEM对象可以导出为一个文件描述符,从而实现跨设备共享。
  • vma_node:当用户态需要把GEM对象映射到自己的地址空间时,就是通过这个节点完成的。内核会利用它来管理VMA(虚拟内存区域)。

经验之谈:写驱动时,千万别直接操作drm_gem_object的引用计数。请使用drm_gem_object_get()和drm_gem_object_put()这两个封装函数。我见过有人直接kref_get导致计数混乱的惨案。

4.3 GEM句柄与FD

这是GEM里最容易让人混淆的地方。句柄(Handle)和文件描述符(FD)都能用来引用一个GEM对象,但它们的使用场景完全不同。

特性 GEM Handle GEM FD (dma-buf)
作用域 进程内 全局(跨进程、跨设备)
生命周期 随进程销毁 随FD关闭销毁
传递方式 通过DRM ioctl 通过Unix域套接字传递FD
典型用途 同一进程内多个DRM上下文共享 不同进程间、不同设备间共享
内核实现 基于idr映射 基于dma_buf + 匿名文件

我举个例子你就明白了。假设你有一个显示驱动和一个GPU驱动,它们都在同一个DRM设备下。用户态程序分配了一个GEM对象作为帧缓冲,然后通过句柄传给显示驱动去扫描输出。这是句柄的典型用法。

但如果你的GPU驱动和显示驱动是独立的两个DRM设备呢?句柄就传不过去了。这时候就需要把GEM对象导出为一个FD,然后通过进程间通信把FD传给另一个进程,另一个进程再把这个FD导入为自己的GEM对象。

嗯,这里要注意:导出和导入是有代价的。每次导出会创建一个新的dma-buf实例,增加一次引用计数。如果频繁导出导入,性能会受影响。我在一个项目中就遇到过这种问题——每帧都导出导入,结果帧率直接腰斩。后来改成只在初始化时导出一次,后面复用,问题就解决了。

4.4 内存分配与释放

GEM本身不负责具体的内存分配,它只提供框架。真正的分配工作由驱动实现的gem_create_object回调完成。但内核提供了一套标准的辅助函数,大部分驱动直接用就行。

4.4.1 分配流程

用户态发起分配请求的典型路径是这样的:

  1. 用户态调用drmIoctl(DRM_IOCTL_MODE_CREATE_DUMB)或DRM_IOCTL_GEM_CREATE
  2. 内核进入drm_gem_create_ioctl()或drm_mode_create_dumb()
  3. 调用驱动注册的.gem_create_object回调,分配drm_gem_object实例
  4. 调用drm_gem_handle_create()为用户态创建句柄
  5. 返回句柄给用户态

对于大多数嵌入式显示驱动,我建议直接用drm_gem_cma_helper提供的API。它封装了CMA内存的分配和释放,省心省力。

// 使用CMA helper分配GEM对象
struct drm_gem_object *gem_obj;
gem_obj = drm_gem_cma_create(dev, size);
if (IS_ERR(gem_obj))
    return PTR_ERR(gem_obj);

// 获取对应的CMA对象(包含物理地址等信息)
struct drm_gem_cma_object *cma_obj = to_drm_gem_cma_obj(gem_obj);
dma_addr_t phys_addr = cma_obj->paddr;  // 物理地址
void *vaddr = cma_obj->vaddr;           // 内核虚拟地址

4.4.2 释放流程

释放是自动的,你不需要手动调用free函数。当引用计数归零时,内核会调用drm_gem_object_free(),进而触发驱动的.gem_free_object回调。

但这里有个坑:别忘了在驱动卸载时清理所有GEM对象。我早期写驱动时就犯过这个错——模块卸载时还有GEM对象没释放,结果系统直接Oops。正确的做法是在lastclose或unload回调中遍历所有GEM对象并强制释放。

避坑指南:我曾经在一个项目中,因为drm_gem_object的handle_count和refcount不一致,导致用户态关闭句柄后内核对象没有被释放。排查了两天才发现是某个路径下多调了一次drm_gem_object_get。建议你在调试阶段,在gem_free_object回调里加个打印,确认对象是否被正确释放。

4.5 GEM对象生命周期全景图

为了让你更直观地理解GEM对象的整个生命周期,我画了一张图:

GEM对象生命周期流程图 用户态调用GEM_CREATE 内核分配drm_gem_object 创建用户态句柄(handle) 对象使用中(引用计数>0) 导出为FD dma-buf导出 引用计数归零,释放对象 关键点:句柄关闭 ≠ 对象释放,只有refcount归零才会触发gem_free_object回调

4.6 驱动开发中的实用建议

最后,我总结几条实战经验:

  • 优先使用CMA helper:除非你有特殊需求(比如VRAM、IOMMU),否则drm_gem_cma_helper能覆盖90%的场景。我见过有人非要自己实现GEM分配器,结果bug一堆。
  • 注意内存对齐:GEM对象的大小通常需要按PAGE_SIZE对齐。有些硬件还有额外的对齐要求(比如128字节、256字节)。不满足对齐会导致DMA传输失败。
  • 调试手段:/sys/kernel/debug/dri/*/gem目录下可以查看所有GEM对象的状态。我调试内存泄漏时,经常用cat /sys/kernel/debug/dri/0/gem来确认对象数量是否在增长。
  • 性能考量:GEM对象的分配和释放是相对重的操作。不要在热路径上频繁分配释放。如果每帧都需要新的缓冲区,建议使用对象池或双缓冲机制。

小技巧:如果你在写一个新的DRM驱动,不确定GEM部分是否正确,可以先跑一遍igt-gpu-tools里的gem_basic和gem_close测试用例。这些测试能覆盖大部分GEM操作的边界情况。我每次写完GEM代码都会跑一遍,能省去很多手动调试的时间。