4. Framebuffer高级特性与局限
好,咱们进入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 分配流程
用户态发起分配请求的典型路径是这样的:
- 用户态调用
drmIoctl(DRM_IOCTL_MODE_CREATE_DUMB)或DRM_IOCTL_GEM_CREATE - 内核进入
drm_gem_create_ioctl()或drm_mode_create_dumb() - 调用驱动注册的
.gem_create_object回调,分配drm_gem_object实例 - 调用
drm_gem_handle_create()为用户态创建句柄 - 返回句柄给用户态
对于大多数嵌入式显示驱动,我建议直接用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对象的整个生命周期,我画了一张图:
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代码都会跑一遍,能省去很多手动调试的时间。