3.3 从用户态的两步调用出发,讲了伪偏移如何分配、mmap 如何按偏移找到对象。本文进入细节,把这一切背后的组件drm_vma_offset_manager当作主角来进行剖析。
全篇可以用"二三四"概括:
- 两个职责:偏移分配、访问控制;
- 三个结构:manager、node、file;
- 四组接口:窗口、区间、查找、访问控制。
此外,第 2 节交代它所依赖的drm_mm,第 5 节分析查找与对象释放的竞争。
1. 职责与边界
drm_vma_manager.c组件的定位归纳起来是两件事:
- 偏移分配:在设备
address_space的一段窗口里,为每个对象分配互不重叠、按页对齐的区间,并能按偏移反查对象。 - 访问控制:为每个区间记录哪些
drm_file可以映射它。没有被允许的drm_file用这个偏移调用mmap(),会得到-EACCES。
还有三个细节值得注意:
- 只管 mmap 偏移,不管内存放置。名字里的 VMA 指的是用户态的线性映射,与对象放在 VRAM 还是 GTT 无关,驱动不应该用它来管理显存。
- 一个
address_space只能对应一个 manager。否则偏移空间不再线性,unmap_mapping_range()无法正确撤销映射。DRM 中这个address_space就是设备的dev->anon_inode->i_mapping(见 3.1.1),manager 就是dev->vma_offset_manager。 - 以页为单位。所有参数和返回值都是页号或页数,只有
drm_vma_node_offset_addr()返回给用户态的字节偏移。
2. vma manager 用到的 drm_mm
drm_vma_offset_manager本身只有一把锁和一个drm_mm,区间分配全部交给drm_mm完成。drm_mm是 DRM 的通用范围分配器,功能远多于这里用到的部分。理解本文只需要知道下面四点:
| vma manager 中的位置 | 用到的 drm_mm | 需要知道什么 |
|---|---|---|
drm_vma_offset_manager_init()/drm_vma_offset_manager_destroy() | drm_mm_init()/drm_mm_takedown() | drm_mm管理一段[start, start + size)的抽象区间,单位由使用者决定,这里是页。takedown 时若还有节点未删除,drm_mm会报告泄漏 |
drm_vma_offset_add() | drm_mm_node_allocated()、drm_mm_insert_node() | 节点结构由调用者提供,drm_mm不分配内存。drm_mm_insert_node()不要求对齐、不区分颜色,按 best-fit 找空洞,成功后填好节点的start与size |
drm_vma_offset_remove() | drm_mm_remove_node() | 删除后 vma manager 把节点清零,于是"节点是否已分配"可以反复判断 |
drm_vma_offset_lookup_locked() | 直接遍历mm->interval_tree与节点的rb | drm_mm把已分配的节点挂在一棵按start排序的红黑树上。它没有"按地址找所属节点"的接口,vma manager 自己遍历这棵树 |
drm_mm的空洞管理、对齐与颜色、各种插入模式、scan 驱逐等内容,vma manager 都用不到,见 drm_mm 范围分配器:设计与接口 与 drm_mm 实现分析。
3. 三个结构
vma manager 由三个结构组成,按粒度从大到小:
- 每个设备一个
drm_vma_offset_manager; - 每个对象一个
drm_vma_offset_node; - 每个对象上每个被允许的
drm_file一个drm_vma_offset_file。
structdrm_vma_offset_manager{rwlock_tvm_lock;structdrm_mmvm_addr_space_mm;};structdrm_vma_offset_node{rwlock_tvm_lock;structdrm_mm_nodevm_node;structrb_rootvm_files;void*driver_private;};structdrm_vma_offset_file{structrb_nodevm_rb;structdrm_file*vm_tag;unsignedlongvm_count;};| 结构 | 成员 | 作用 |
|---|---|---|
drm_vma_offset_manager(每设备一个) | vm_lock | 保护drm_mm的读写锁,分工见第 5 节 |
vm_addr_space_mm | 管理整个偏移窗口的drm_mm | |
drm_vma_offset_node(每对象一个,即obj->vma_node) | vm_lock | 只保护本节点的vm_files |
vm_node | 本对象在窗口中的区间。start、size以页为单位,全为 0 表示尚未分配 | |
vm_files | 允许映射本对象的drm_file集合,是一棵以drm_file指针为键的红黑树 | |
driver_private | 留给驱动挂私有数据,GEM 核心不使用 | |
drm_vma_offset_file(每个被允许的drm_file一个) | vm_tag | 被允许的drm_file |
vm_count | 允许次数,见第 4.4 节 |
三层内嵌关系是理解这些接口的关键:drm_gem_object内嵌vma_node(struct drm_vma_offset_node),vma_node又内嵌vm_node(struct drm_mm_node)。所以 mmap 时在drm_mm的树上找到一个drm_mm_node,经过两次container_of()就能取回 GEM 对象,不需要任何额外的映射表。
4. 四组接口
4.1 窗口:初始化与销毁
| 接口 | 说明 |
|---|---|
drm_vma_offset_manager_init(mgr, page_offset, size) | 初始化读写锁与drm_mm。GEM 在drm_gem_init()中以DRM_FILE_PAGE_OFFSET_START和DRM_FILE_PAGE_OFFSET_SIZE调用它,64 位系统上窗口从 4 GiB 开始,大小约 1 TiB |
drm_vma_offset_manager_destroy(mgr) | 调用drm_mm_takedown()。调用前必须删除全部节点。GEM 通过 drmm 托管动作在设备释放时调用它 |
4.2 区间:分配、删除与读取
| 接口 | 说明 |
|---|---|
drm_vma_node_reset(node) | 把节点清零并初始化锁和树,节点第一次使用前必须调用。不能对已分配的节点调用,否则区间和允许列表都会泄漏 |
drm_vma_offset_add(mgr, node, pages) | 为节点分配pages页的区间 |
drm_vma_offset_remove(mgr, node) | 删除节点的区间并把节点清零 |
drm_vma_node_start()/drm_vma_node_size() | 返回区间起点与大小,未分配时返回 0 |
drm_vma_node_offset_addr() | 返回start << PAGE_SHIFT,即交给用户态做 mmap 的字节偏移 |
drm_vma_node_unmap(node, mapping) | 对该区间调用unmap_mapping_range(),撤销所有进程中映射到它的页表项 |
有三点值得注意:
- add 和 remove 都是幂等的。对已分配的节点 add 直接返回 0,所以 3.3 说"同一对象多次取偏移得到同一个值";对未分配的节点 remove 什么也不做。add 失败后不需要清理,可以重试。
pages可以不等于对象大小。注释写明它只限制用户态能映射的长度。GEM 的drm_gem_create_mmap_offset_size()就是为虚拟大小与对象大小不同的情况准备的,常规路径用drm_gem_create_mmap_offset(),按obj->size分配。drm_vma_node_reset()的一个隐蔽用途。TTM 在ttm_bo_util.c中创建 ghost 对象时,用结构体拷贝复制原 BO,随后立即对 ghost 的vma_node调用 reset。否则 ghost 会带着原 BO 已分配的drm_mm_node,两个对象指向同一个区间。
在 GEM 与 TTM 中,这组接口的调用者如下:
| 动作 | GEM 通用实现 | TTM |
|---|---|---|
| reset | drm_gem_private_object_init() | 创建 ghost 对象时 |
| add | 首次取偏移时,drm_gem_create_mmap_offset() | ttm_bo_init_reserved()中,对ttm_bo_type_device与ttm_bo_type_sg立即分配 |
| remove | drm_gem_object_release()中调用drm_gem_free_mmap_offset() | BO 释放时 |
| unmap | shmem helper 在 purge 时 | ttm_bo_unmap_virtual(),BO 搬迁或换出前 |
TTM 只为 device 与 sg 两类 BO 分配区间,ttm_bo_type_kernel类型的内核 BO 没有偏移,用户态也就无法映射它们,原因见 为什么你的内核 BO 永远不该出现在 vma_manager 里。
4.3 查找:按偏移找节点
| 接口 | 说明 |
|---|---|
drm_vma_offset_lock_lookup(mgr)/drm_vma_offset_unlock_lookup(mgr) | 对 manager 的vm_lock加、解读锁 |
drm_vma_offset_lookup_locked(mgr, start, pages) | 找到覆盖[start, start + pages)的节点,start可以落在节点中间 |
drm_vma_offset_exact_lookup_locked(mgr, start, pages) | 同上,但要求start恰好是节点起点 |
drm_vma_offset_lookup_locked()的算法很直接:在interval_tree上找起点不大于start的最大节点,再检查它的终点是否覆盖start + pages,不覆盖就返回NULL。
GEM 的 mmap 路径用的是 exact 版本。这意味着通过 DRM 设备 fd 映射 GEM 对象时,mmap()的 offset 必须恰好是对象区间的起点,长度不能超过区间大小,不能只映射对象的后半段。不满足时查找失败,drm_gem_mmap()返回-EINVAL。
查找接口要求调用者先持读锁,是为了在锁内取得对象引用,见第 5 节。
4.4 访问控制:允许列表
| 接口 | 说明 |
|---|---|
drm_vma_node_allow(node, file) | 把file加入允许列表。已在列表中时vm_count加一 |
drm_vma_node_allow_once(node, file) | 同上,但不计数,已在列表中时什么也不做 |
drm_vma_node_revoke(node, file) | vm_count减一,减到 0 时从列表删除 |
drm_vma_node_is_allowed(node, file) | 持读锁查询file是否在列表中 |
drm_vma_node_verify_access(node, file) | is_allowed的包装,返回 0 或-EACCES,供 TTM 风格的回调使用 |
为什么要计数。GEM 在drm_gem_handle_create_tail()中调用drm_vma_node_allow(),在drm_gem_object_release_handle()中调用drm_vma_node_revoke(),每个 handle 各一次。同一个drm_file可能对同一对象持有多个 handle,计数保证关闭其中一个 handle 不会让这个drm_file失去映射权限,全部关闭后才真正撤销。allow_once不计数,只能配一次revoke,目前只有 i915 使用。
允许列表不一定跟着 handle。任何内核代码都可以为某个drm_file授权。KFD 就是一例:KFD 分配的 BO,其 GEM handle 建在 KFD 内部的 client file 上,而用户态要通过自己打开的 render node 映射它。所以amdgpu_amdkfd_gpuvm_alloc_memory_of_gpu()在创建 BO 后,对用户进程的drm_file(drm_priv)显式调用drm_vma_node_allow(),释放时再revoke。
revoke 不撤销已有映射。DOC 注释写明,撤销权限后已经建立的映射需要调用者自行处理。所以关闭 handle 之后,进程里已有的 mmap 依然有效,这与 3.3 第 5 节"映射与 handle 的关系"一致。
分配放在锁外。vma_node_allow()在加写锁之前先kmalloc一个drm_vma_offset_file,锁内只做查找和链接,用不上就在解锁后释放。这是"持锁时不做可能睡眠的分配"的典型写法,见 持锁与内存分配:关键路径的预分配模式。
5. 并发:查找与释放的竞争
两把读写锁分工明确,互不嵌套:
| 锁 | 写者 | 读者 |
|---|---|---|
mgr->vm_lock | drm_vma_offset_add()、drm_vma_offset_remove() | 查找接口(经drm_vma_offset_lock_lookup()) |
node->vm_lock | allow、allow_once、revoke | is_allowed |
需要细看的是 mmap 查找与对象释放之间的竞争。查找只拿到节点指针,并不持有对象引用;而对象的refcount可能恰好在此时归零,释放路径正在进行。GEM 的drm_gem_object_lookup_at_offset()这样处理:
drm_vma_offset_lock_lookup(dev->vma_offset_manager);node=drm_vma_offset_exact_lookup_locked(dev->vma_offset_manager,start,pages);if(likely(node)){obj=container_of(node,structdrm_gem_object,vma_node);if(!kref_get_unless_zero(&obj->refcount))obj=NULL;}drm_vma_offset_unlock_lookup(dev->vma_offset_manager);这段代码成立依赖两个事实:
- 释放路径必须经过
drm_gem_object_release()→drm_gem_free_mmap_offset()→drm_vma_offset_remove(),而 remove 要拿写锁。只要查找方还持着读锁,释放方就停在 remove 之前,对象内存不会被回收,container_of()得到的指针可以安全访问。 kref_get_unless_zero()在计数已为 0 时失败。此时对象虽然还在树上,但已进入释放流程,查找方把它当作不存在,返回-EINVAL。
由此也能理解读锁的两条使用限制(见drm_vma_offset_lock_lookup()的注释):它是自旋型读写锁,持有期间处于原子上下文,不能睡眠;锁内只能调用*_locked查找接口,不能调用其他 vma manager 接口,否则 add、remove 会在同一把锁上死锁。
另外,drm_vma_node_unmap()本身不加锁,调用者要保证它与drm_vma_offset_remove()不会并发。
6. 小结
用"二三四"回顾全篇:
- 两个职责:在设备
address_space的窗口里分配偏移区间;记录哪些drm_file可以映射每个区间。二者独立,允许列表可以先于区间建立,区间删除后也会保留。 - 三个结构:每设备一个
drm_vma_offset_manager(读写锁加drm_mm),每对象一个drm_vma_offset_node(内嵌drm_mm_node与允许列表),每个被允许的drm_file一个带计数的drm_vma_offset_file。drm_gem_object⊃vma_node⊃vm_node的内嵌,让查到的drm_mm_node经两次container_of()回到对象。 - 四组接口:窗口(init、destroy);区间(reset、add、remove、unmap,add 与 remove 幂等);查找(持读锁调用,GEM 用 exact 版本);访问控制(allow 与 revoke 按次计数,可由 KFD 这样的内核代码独立授权,撤销权限不撤销已有映射)。
3.3.2 将以 amdgpu 为例,看这些接口如何与 TTM 的缺页、迁移配合,完成一个 BO 从取得偏移到被 CPU 访问、再到搬迁后重新缺页的全过程。