4. Buffer Object 管理:GEM 核心 API、BO 的创建、映射与销毁生命周期

Buffer Object(BO)是 DRM/GEM 框架中 GPU 可访问内存的最小管理单元。每个 BO 代表一段连续的物理或虚拟地址空间,承载帧缓冲、纹理、顶点数据、命令缓冲区等图形负载。本章深入 GEM 核心 API 的设计哲学,剖析 BO 从创建、映射到销毁的完整生命周期,并揭示内存带宽优化在 BO 管理中的关键作用。

4.1 GEM 核心 API 概览

GEM(Graphics Execution Manager)提供一组 ioctl 接口,用户态驱动通过它们与内核协同管理 BO。下表列出最核心的 API 及其语义:

API 名称 ioctl 命令 核心功能 返回/参数
gem_create DRM_IOCTL_GEM_CREATE 分配指定大小的 BO,返回全局唯一的 handle 输入 size,输出 handle
gem_open DRM_IOCTL_GEM_OPEN 通过全局 name 获取已存在 BO 的 handle(跨进程共享) 输入 name,输出 handle、size
gem_close DRM_IOCTL_GEM_CLOSE 释放当前进程对 BO 的引用(handle) 输入 handle
gem_mmap DRM_IOCTL_GEM_MMAP 将 BO 映射到用户态虚拟地址空间 输入 handle、offset,返回 addr
gem_set_domain DRM_IOCTL_GEM_SET_DOMAIN 显式声明 CPU/GPU 访问域,触发缓存一致性操作 输入 handle、read/write domain
gem_wait DRM_IOCTL_GEM_WAIT 等待 BO 上的 GPU 操作完成(fence 同步) 输入 handle、timeout_ns
设计原则: GEM 采用“句柄-引用”模型。用户态只操作整数 handle,内核维护 BO 的引用计数和物理内存映射。这种间接层使得内核可以透明地执行迁移、压缩、缓存着色等优化,而不影响用户态接口的稳定性。

4.2 BO 创建:从用户态请求到物理内存分配

BO 创建是生命周期的起点。用户态驱动调用 gem_create 时,内核经历以下关键步骤:

  1. 参数校验: 检查 size 是否在合法范围(通常对齐到 page size),权限是否足够。
  2. 分配内核对象: 创建 struct drm_gem_object,初始化引用计数为 1,关联到当前 file_priv。
  3. 物理内存分配: 调用 GPU 驱动注册的 gem_create_object 回调,分配实际的物理页面(scatterlist)或预留显存。
  4. 生成 handle: 通过 idr 机制分配一个唯一的整数 handle,插入到进程的 handle 表。
  5. 返回 handle: 用户态获得 handle,后续所有操作均通过此 handle 引用 BO。
// 用户态调用示例(libdrm 封装)
struct drm_gem_create req = {
    .size   = 4096,          // 4KB BO
    .handle = 0,
};
int ret = ioctl(fd, DRM_IOCTL_GEM_CREATE, &req);
if (ret == 0) {
    uint32_t bo_handle = req.handle;
    // 现在 bo_handle 代表一个 4KB 的 BO
}
带宽优化要点: 创建时指定 size 对齐到 GPU 缓存行(通常 64B 或 128B)可避免非对齐访问导致的带宽浪费。对于频繁读写的 BO,建议使用 drm_gem_cma_create(连续内存分配器)减少 TLB 抖动。

4.3 BO 映射:CPU 与 GPU 的地址空间桥接

映射阶段将 BO 的物理内存暴露给 CPU 或 GPU 的虚拟地址空间。GEM 提供两种映射路径:

映射类型 API 地址空间 缓存策略 典型场景
CPU 映射 gem_mmap 用户态虚拟地址 可配置(WC/UC/Cached) CPU 上传纹理、读取回读结果
GPU 映射 gem_execbuf + 偏移 GPU 虚拟地址(GTT) 通常为 WC(写合并) GPU 着色器访问顶点/索引缓冲

CPU 映射的缓存策略直接影响内存带宽:

  • WC(Write-Combine): 适合 GPU 写入、CPU 读取的场景(如帧缓冲),避免 CPU 缓存污染。
  • UC(Uncached): 适合 MMIO 区域,但带宽极低,应避免用于数据 BO。
  • Cached: 适合 CPU 频繁读写的 staging BO,但需要手动调用 drm_gem_set_domain 保证一致性。
// CPU 映射示例
struct drm_gem_mmap mmap_req = {
    .handle = bo_handle,
    .offset = 0,
};
int ret = ioctl(fd, DRM_IOCTL_GEM_MMAP, &mmap_req);
if (ret == 0) {
    void *cpu_addr = mmap(NULL, size, PROT_READ | PROT_WRITE,
                          MAP_SHARED, fd, mmap_req.offset);
    // cpu_addr 现在指向 BO 的 CPU 映射
}
常见陷阱: 不要假设 gem_mmap 返回的 offset 与文件偏移相同。内核可能为每个 BO 分配独立的偏移空间。必须使用 ioctl 返回的 offset 进行 mmap,否则会导致映射到错误的内存区域。

4.4 BO 生命周期与引用计数

BO 的生命周期由引用计数精确控制。下图展示从创建到销毁的完整状态机:

BO 生命周期状态机 创建 (ref=1) 活跃 (ref>1) 空闲 (ref=1) 销毁 (ref=0) gem_open / 提交执行 所有 fence 完成 gem_close (handle 归零) 关键规则: • 每个 gem_open 增加 ref,每个 gem_close 减少 ref • 只有当 ref=0 时,内核才释放物理内存和 drm_gem_object 结构体

引用计数的变化规则:

  • gem_create: 初始 ref = 1(对应进程的 handle)。
  • gem_open: 跨进程共享时,目标进程获得新 handle,ref +1。
  • 提交到 execbuf: 内核内部增加 ref,防止 BO 在执行期间被释放。
  • gem_close: 释放当前进程的 handle,ref -1。
  • execbuf 完成: 内核释放内部引用,ref 回到用户态计数。

4.5 BO 销毁:安全释放与延迟回收

销毁并非简单的内存释放。GEM 采用“延迟回收”策略以优化带宽和性能:

  1. 引用归零触发: 当最后一个 gem_close 使 ref 降为 0,内核标记 BO 为“待销毁”。
  2. 检查 GPU 活跃性: 如果 BO 仍有未完成的 GPU 访问(通过 fence 检查),则推迟销毁,将 BO 加入“僵尸列表”。
  3. 异步回收: 内核工作队列在 GPU 空闲后执行实际的内存释放,归还给分配器。
  4. 物理内存释放: 调用 GPU 驱动的 gem_free_object 回调,释放 scatterlist 或显存页面。
// 内核侧销毁路径(简化)
void drm_gem_object_free(struct kref *kref) {
    struct drm_gem_object *obj = container_of(kref, ...);
    // 1. 检查是否还有 GPU 引用
    if (obj->pending_fence) {
        list_add_tail(&obj->zombie_entry, &zombie_list);
        schedule_work(&zombie_work);  // 延迟回收
        return;
    }
    // 2. 直接释放
    obj->dev->driver->gem_free_object(obj);
    kfree(obj);
}
带宽优化洞察: 延迟回收机制允许 GPU 在 BO 销毁后仍然通过 DMA 访问其内存,避免了不必要的同步等待。对于流式纹理上传场景,这种“写后即忘”模式可以显著提升吞吐量,因为 CPU 不需要等待 GPU 完成就能分配新 BO。

4.6 内存带宽优化实践:BO 池化与重用

频繁的 BO 创建/销毁会导致分配器碎片化和 TLB 刷新,严重降低有效带宽。业界最佳实践是引入 BO 池:

策略 实现方式 带宽收益 适用场景
固定大小池 预分配 2MB/4MB/8MB 的 BO 列表,按需取出、归还 减少分配延迟 60%+ 帧缓冲、交换链
slab 分配器 内核侧使用 kmem_cache 管理相同大小的 BO 减少页表重建 小尺寸 BO(<64KB)
延迟重用 BO 销毁后不释放物理内存,仅清空内容 避免 TLB 刷新 频繁分配/释放的临时 BO
实践建议: 在用户态驱动中维护一个 BO 缓存池,当应用请求新 BO 时优先从池中获取;当 BO 不再使用时,不是销毁而是归还到池中。配合 gem_set_domain 清空缓存行,可以避免 90% 以上的分配开销,同时保持缓存一致性。