1. 为什么GPU驱动中最怕“资源打架”——显存同步与保护的底层逻辑
1.1 从CPU世界到GPU世界的同步思维转变
很多人学GPU驱动开发,第一步往往是去啃硬件手册、看命令行、写第一个hello world驱动,但真正让你半夜抓狂的,往往是并发问题。CPU编程里的锁、信号量、原子操作,放到GPU KMD场景里,一下子变得不“香”了。原因很简单:CPU侧的同步只需要管好几个核、几个线程,但GPU KMD要面对的是一张包含数千个计算单元、几十个硬件队列、异步DMA引擎以及用户态不定时提交命令的巨型并发网络。任何一个环节没有做好同步保护,轻则产生撕裂的画面,重则直接导致设备挂起、系统蓝屏,甚至烧坏一块价值不菲的显卡。
我记得自己第一次调试KMD资源同步时,犯过一个特别蠢的错误:在CPU侧用一个自旋锁保护一个显存分配表,觉着很稳妥,可是当GPU侧在某个内核线程还在用这块显存时,我却在另一个线程把它释放掉了。结果就是GPU访问了已经归还给内存管理器的物理页,随即触发页面错误,整个图形设备被操作系统强制重置。那次debug花了整整三天,后来才明白,KMD里的“同步”绝不只是CPU线程之间的互斥,而是要管理CPU与GPU之间、GPU不同引擎之间、甚至多个上下文之间的执行时序。你必须站在“生产者—消费者”的视角去看待每一块显存的生命周期,而不只是把它当成一个指针。
所以在进具体原语前,你得先接受一个关键转变:GPU KMD同步的对象不是变量,而是“执行流”和“资源状态”。你在保护的不是一个内存地址,而是“某块显存当前正被哪个执行引擎引用,以及什么时候可以安全回收”。
1.2 KMD中资源同步保护的三个核心维度:时序、互斥、生命周期
如果把KMD的资源同步和保护拆开来看,我觉得有三根柱子,缺一不可。
第一根是时序(Ordering)。GPU的命令执行是异步的,命令提交到环形缓冲区后,CPU不会干等GPU干完。这时候CPU如果要读取GPU的运算结果,或者释放GPU正在使用的显存,就必须知道“GPU已经执行到哪条命令了”。这个“知道”的过程就是时序同步。硬件上一般通过Fence(栅栏)实现:CPU往某个地址写入一个递增序列号,GPU在执行完特定命令后更新这个序列号,CPU轮询或等待中断,直到看到目标序列号才继续。这根柱子如果没立好,你会在驱动里遇到各种“随机”崩溃,因为GPU执行速度时快时慢,竞争窗口极不稳定。
第二根是互斥(Mutual Exclusion)。显存资源不像CPU内存那样可以随意共享,很多硬件状态(比如显存堆的全局配置寄存器、页表基地址、某些只能被单一引擎独占的DMA通道)天生没法并发访问。这时候你就必须用CPU侧的锁去串行化访问。互斥维度最容易犯的错是“锁不住硬件”。你锁了软件层面的分配结构,但没锁硬件层面的队列提交,结果两个上下文同时向同一个引擎提交命令,硬件直接行为未定义。
第三根是生命周期(Lifetime)。这是新手最容易忽略的一块。显存资源在用户态进程退出、上下文销毁、驱动卸载时,必须保证没有命令还在引用它。否则你回收了物理内存,GPU后面才执行到那条命令,就会访问无效地址。生命周期保护不完全靠锁,更多靠引用计数、状态机以及GPU上下文追踪。你可以类比成:你借给别人一本书,别人还没看完你就把书卖了,那你得确保买书的人恰好就是借书的人,否则就得等还回来。现实是你不可能知道GPU“什么时候看完”,所以你只能通过Fence记录“预计看完的时间点”,在这个时间点之前绝不释放书。
这三个维度交织在一起,构成了KMD资源同步保护的主干。后面所有工具、代码、调试手段,本质都是在为这三根柱子添砖加瓦。
2. 同步原语选型:从自旋锁到栅栏,如何为GPU场景挑正确的工具
2.1 自旋锁与互斥锁:CPU侧的保护边界
在KMD里,最底层的同步工具就是自旋锁和互斥锁。它们的用法跟普通内核驱动差不多,但边界条件要更苛刻。
自旋锁(Spinlock)适用于保护极短的临界区,比如一个几行的链表节点删除、一个标志位的翻转。因为自旋锁在等待时会一直占用CPU核心,所以临界区里绝对不能有耗时操作,更不能睡眠。我见过有人把一次可能需要跟GPU硬件交互的MMIO读取放进自旋锁保护段里,结果其他核心全在那儿空转,性能直接雪崩。还有更严重的:如果持锁代码路径恰好在等待GPU某个状态寄存器变化,而那个寄存器需要GPU调度到某个引擎才能更新,而那个引擎所在的线程又想拿同一把锁,这就成了典型的死锁——只不过死锁双方一边是CPU,一边是硬件状态机。
互斥锁(Mutex)则允许睡眠,适合保护那些需要等待资源、遍历大表、甚至调用其他可能睡眠的函数的长临界区。但代价是它涉及线程调度、唤醒,延迟比自旋锁高一个数量级。在实时渲染或者计算场景,一次互斥锁的争用可能让命令提交错过vsync,导致帧率抖动。所以KMD设计者的核心技巧是:把临界区拆成三级,最短的用原子操作或者自旋锁,中等的用互斥锁,最长的用工作队列加状态机。不要指望一把大锁锁住所有东西。
我在代码里常用一个约定:任何锁保护的区域,如果里面出现了wait_event、msleep、或者调用其他驱动组件的接口,必须改用互斥锁,并且在注释里写明“此处可能睡眠”。自旋锁区域只允许原子操作和纯内存读写。这个约定救了我很多次,因为后续维护的人不会莫名在某天加一个居然能睡眠的函数到你的自旋锁临界区里。
2.2 GPU栅栏(Fence)与硬件同步原语
GPU侧的同步不能靠锁,因为GPU没有线程上下文去等一把锁。硬件提供的标准机制就是Fence,也就是栅栏。我把它理解成一组带序号的“打卡器”。每个提交的命令缓冲区里可以包含一个指令:“执行到这里时,把某个全局内存地址的值更新为N”。CPU侧则通过向指定地址写入一个期望的序号M,并等待该地址的值不小于M,来判断GPU是否已经执行过了对应的点。
在Linux内核的KMD中,Fence往往用dma_fence表示。它不只是等待工具,还承担了内存回收的凭据角色。当一个命令缓冲区提交时,驱动会为它关联一个Fence对象。当GPU执行完包含该命令的硬件队列时,中断处理程序会回调该Fence的回调函数,然后唤醒所有等待者。显存资源管理器在尝试回收一块显存时,会先查看“最后一个引用这块显存的Fence序号是什么”,把这个序号插入到当前GPU队列作为“软 fence”,然后等它发出信号,再真正释放物理页。
这里有个非常关键的实操点:Fence的创建与信号必须严格配对。如果在错误的时间提前发出信号,GPU可能还没真正用完资源,回收就会爆炸。如果信号发晚了,只是性能损失,不会出错。所以很多驱动工程师在写Fence处理时都遵循“宁晚勿早”原则。我自己调试过某跨平台系统在特定GPU上频繁出现页面错误,最后定位到是某个DMA引擎的中断处理代码在硬件没有完全写完内存时提前发了Fence信号。用修“多等一个引擎空闲检测周期”的办法解决,虽然损失了一点点吞吐,但换来了稳定。
除了通用Fence,现代GPU还提供了更细粒度的硬件同步原语。比如NVIDIA的Tegra有syncpoint,AMD的GFX有PM4的WaitRegMem指令,还有Vulkan里的semaphore和barrier。这些本质上是硬件实现的Fence变种,区别在于等待条件可以是某个寄存器值、某个内存值、或者某个引擎的bit clear。在小节里不展开每一种,但你只要理解了Fence模型,其他都能触类旁通。
2.3 如何选择:锁类型与性能、复杂的权衡
选择同步原语时需要综合考核延迟、功耗、复杂度。我通常会画一张决策表,在脑子里快速过一遍:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 修改资源句柄表 | 自旋锁或RCU | 操作微秒级,不能睡眠,RCU可无锁读 |
| 更新显存分配器空闲链表头 | 原子操作+自旋锁 | 临界区只有几行,不涉及硬件交互 |
| 向硬件队列提交命令缓冲区 | 互斥锁+内存屏障 | 可能涉及唤醒GPU调度线程,且需要保证内存可见性 |
| 等待GPU内存写回完成 | DMA fence + completion | 标准异步等待机制,支持多等待者 |
| 驱动卸载时回收所有资源 | 互斥锁+工作队列+状态机 | 操作耗时极长,必须允许睡眠和外部事件循环 |
一句话总结:CPU侧的“锁”负责保护CPU眼中的数据结构,而GPU侧的“Fence”负责保护硬件执行流中的资源引用。两者经常要配合使用。比如你先拿互斥锁把一块显存从某个文件私有池移到全局池,然后创建一个新的Fence让它接管这块显存的生命周期,最后释放互斥锁。这期间,Fence并不关心你用的是哪把锁,它只关心“GPU映像是否已经执行完资源引用点”。
我见过有的驱动为了省事,直接用一把全局大锁来保护所有资源状态,结果多上下文跑起来时,GPU利用率只能勉强达到一半。后来把锁粒度拆细到每个资源对象,效率提升明显。但拆细锁也会带来死锁风险,所以必须设计稳定的加锁次序。通常的次序是:先锁对象池、再锁对象本体、最后锁硬件队列。反向加锁就可能出问题。
3. 显存资源管理的保护机制——驱逐、重定位与多GPU场景
3.1 资源引用计数与句柄验证
显存资源不像CPU内存那样可以靠虚拟地址在内部管理,因为它涉及用户态句柄、内核态对象、硬件地址的映射链。一个资源从创建到销毁,要经过:用户态分配请求、内核创建对象、关联物理内存、建立页表映射、提交给GPU队列使用、等待GPU完成、解除映射、释放物理页。任何一步出错,都可能产生use-after-free。
保护机制的核心是一套引用计数体系。每个显存对象在创建时引用计数为1,用户态每多一个句柄引用,计数加一;每次GPU命令提交引用该对象时,也要增加一个“硬件引用”,这个硬件引用会随Fence信号而被释放。只有当CPU引用和硬件引用都归零时,对象才能被真正销毁。这样的设计用一句话概括:没有人(包括CPU和GPU)在用了,才允许销毁。
句柄验证也很关键。KMD会维护一张从用户态句柄到内核对象的映射表。用户态可以提交任意整数作为句柄,驱动必须校验它确实映射到一个有效对象,而不是被释放后重新复用了同一片内核内存。如果映射表里存的是对象指针,释放后指针可能被重新分配给其他对象。所以成熟的驱动会在释放对象时把映射项标记为dead,并把指针清零,防止ABA问题。在内核里,还会给每个对象加一个magic number,验证时先检查magic,再检查指针,双重保险。
我维护过一个图像处理Demo驱动,早期没有用magic number,结果某次用户态故意传了一个已经释放掉的句柄,映射表刚好错乱,让驱动访问了一块已经重分配为其他用途的内核内存,当场导致系统崩溃。后来加上magic number并在每次访问前校验,这类问题再也没有出现。哪怕有轻微性能开销,也值。
3.2 资源同步在内存驱逐(Eviction)与重定位中的关键作用
现代GPU显存容量有限,当多个应用共同占用显存时,必须把一些暂时不用的资源从显存驱逐出去,腾空间给新资源。驱逐流程是这样:
- 首先对要驱逐的资源加“驱逐锁”,阻止新的命令提交引用它。
- 然后为当前所有正在引用该资源的GPU队列插入一个Fence,等待信号,确保GPU不再访问该资源。
- 再更新页表,让GPU的虚拟地址不再映射到这块物理显存,必要时拷贝到系统内存作为备份。
- 最后释放物理显存页,把它放回空闲池。
- 当资源再次被访问时,驱动可能需要重新分配显存并恢复映射,这个过程叫重定位。
在驱逐过程中,同步保护的核心就是那个Fence。因为新命令可能已经提交了,虽然在软件层面加锁禁止了新引用,但硬件队列里可能已经排着某个尚未执行的命令缓冲区,里面仍然包含对该资源的引用。你即使立刻回滚,也得等硬件把这个命令执行完。所以Fence必须被引用计数机制管理:在你尝试驱逐资源时,先尝试“冻结”它的硬件状态。如果发现还有引用,就等待对应Fence信号;如果Fence已经信号过了,但又有新引用进来,那就说明你的冻结逻辑有漏洞,必须在提交新命令时检查资源状态。
另外,驱逐过程中经常会涉及两个引擎同时访问同一个资源。假设引擎A正在用资源R生成中间结果,引擎B需要读取这个结果。那么驱动必须在引擎B的命令里插入一个等待引擎A的Fence,确保引擎B不会提前读到脏数据。否则驱逐与重定序的操作一旦交错,可能把还没写完的内存内容映射到新地址,导致渲染错误。
从实际经验来看,驱逐中的同步最容易犯的错是漏掉了“某个间接引用”,比如资源被嵌在另一个命令缓冲区里作为参数,而那个命令缓冲区已经提交,但缓冲区本身的Fence还没信号。简化套路:给每个命令缓冲区也挂一个Fence,并且在驱逐资源时遍历所有已提交但未完成的命令缓冲区,检查它们是否引用目标资源。如果数量少,暴力遍历是可行的;如果数量多,就得为每个资源维护一个“反向引用列表”,有点类似内核里的rmap。
3.3 多GPU与上下文切换下的同步挑战
当系统里有多个GPU(或者一块GPU多个实例),或者一个GPU上运行多个虚拟上下文时,同步维度会更复杂。多GPU环境下,某块显存可能被两个GPU共同访问,比如使用跨设备互连做零拷贝传输。此时不能只依赖单GPU的Fence,因为一个GPU发出的信号只会更新它自己的地址空间。驱动需要采用全局Fence或者跨设备事件,等待所有相关GPU都完成访问后再释放资源。
上下文切换时,问题则主要体现在命令缓冲区的排空。为了实现运行中的多上下文上下文切换,硬件可能把当前正在执行的引擎暂停,保存状态,然后让另一个上下文进入。如果旧上下文还有资源引用未完成,切换后,旧上下文再次恢复执行时,其内部命令队列的尾部仍然会访问某些显存。KMD必须确保这些显存在上下文挂起期间没有被释放或重定位。做法通常是:在提交命令时,记录下该命令会引用的资源集合,并提升这些资源的引用计数,直到该上下文的Fence全部信号后才能释放。这也意味着“Fence信号”的时机不一定是命令结束,而可能是上下文保存完成。
碰到过的一个现实场景:某款硬件支持多GPU直接内存访问,驱动里用了一个简单计数器跟踪每个GPU引擎上的未完成命令数。可是当两个GPU的DMA操作互相依赖时,计数器没法表达依赖次序,导致一个引擎提前认为自己可以释放源内存,实际上另一个引擎还在读。后来改成每条命令都携带一个“依赖Fence列表”,在软件层解析依赖后再提交硬件,问题才解决。
4. 死锁、活锁与优先级反转:驱动开发中的并发陷阱实录
4.1 真实案例:一块被“锁死”的GPU
某次调试中,我遇到一块GPU在正常使用一段时间后突然整个显卡驱动重置。日志里没有任何错误码,只有超时。抓了ftrace和硬件状态寄存器,发现GPU的某个计算引擎一直停在一个特定的内存等待指令上。而该内存地址对应的Fence信号迟迟不来。顺着代码查下去,发现这个Fence信号需要CPU侧在中断处理函数里更新,但CPU侧在等一个互斥锁,这个互斥锁被另一个线程持有,而那个线程正在等待GPU队列有空位,所以去睡眠了。中断线程拿不到锁,Fence永远不被更新,GPU就死等一个永远不会发生的信号。
这就是一个典型的死锁:CPU线程在等GPU资源,GPU在等CPU更新Fence,而Fence更新的路径被CPU线程的锁阻塞。从理论上讲,这种锁不到底的链条应该靠设计规避,但现实是它确实发生了。原因就是早期设计时把“等待Fence”放在了持锁的临界区里。我当时是在某个资源回收函数里,先拿了一个全局互斥锁,然后调用dma_fence_wait,等待GPU完成一条命令。同时,GPU完成这条命令后发送的中断处理函数要更新一条统计信息,而这个统计信息的更新也需要拿同一把全局互斥锁。中断处理在软中断上下文,没法阻塞,直接尝试拿锁失败,只好把更新Fence的工作延迟到工作队列,而工作队列又不巧被卡住。于是整个链条绕成环。
教训很明确:不能在持锁的情况下等待Fence。因为持锁等待Fence时,你无法确保Fence信号路径是否需要同一把锁。如果万不得已必须等待,一定要确保等待路径中可能调用的所有处理器函数都不依赖你手上这把锁。其实最安全的做法是一开始就把锁释放掉,让资源进入一个“正在回收”状态,把等待Fence放到工作队列中异步执行,再在完成回调里重新拿锁做后续清理。这个案例也印证了之前说的:互斥锁负责保护状态机,Fence负责异步完成事件,两者要在不同时间片上工作。
4.2 死锁检测与预防策略
KMD开发中预防死锁有几个实用策略,我列一下:
第一,明确加锁顺序。所有代码必须约定同一个资源获取顺序,比如全局锁、池锁、对象锁、队列锁,永远按这个顺序拿。如果两个线程反向拿锁,死锁风险立即产生。在代码评审里,我会特意检查每个函数加锁的顺序。
第二,使用锁的嵌套层级不要过深。超过两层锁的代码就要警惕,三层以上基本是设计问题。可以用一个自定义的锁调试器,在lockdep开启的内核里直接跑,它会帮你自动检测可能的环形等待。Linux内核的lockdep对KMD特别有用,只要早期测试阶段带着锁验证开启跑一遍,就能暴露出大多数锁序问题。
第三,对于必须等待可能长时间完成的事件(如GPU命令执行,DMA传输),不要在临界区内等待。这听起来简单,但很多人会忘记中断上下文、软中断上下文里不能睡眠,甚至不能在自旋锁里调用任何能阻塞的操作。核心方法:把等待转成异步callback。一个Fence注册一个回调函数,当信号到达时,回调函数在受信上下文里继续处理。这样你不需要主动等待,也就不会占着锁等待。
第四,为GPU超时设置看门狗。每个提交的命令缓冲区关联一个超时时间,如果超过一定时间还没有完成,驱动就进入错误恢复路径,强制重置引擎,避免死锁永远卡住系统。虽然看门狗不能阻止死锁,但能把死锁转化成可诊断的错误,让你能抓到现场。
我之前在写某个模拟项目X时,就把lockdep和看门狗都加上,死锁概率降为零。真实项目里,大家也常用“动态debug + 环形历史记录”来追踪最后的锁事件,方便崩溃后分析。
4.3 使用锁的黄金法则
我把在KMD里使用同步锁的经验总结成五条黄金法则,适合贴在工位上:
- 能不用锁就不用锁:优先考虑原子变量、内存屏障、RCU,或者通过设计消除临界区。比如用不可变数据结构、每CPU缓存、单向队列等。
- 锁的范围尽可能小:只在真正共享的那几行代码上加锁,不要为了图省事锁住整个函数。
- 锁的粒度尽可能细:一把大锁保护所有资源,一旦并发稍微起来就会成为瓶颈。但也要防止死锁,细粒度锁与固定加锁顺序是配套的。
- 持锁时间尽可能短:把耗时操作移出临界区,比如分配内存、等待Fence、打印日志,这些都不该出现在持锁代码块里。
- 加锁必须成对且释放路径统一:不要让同一个函数在两个分支里各自unlock,那样极易遗漏;尽量用goto统一出口,或者封装成作用域对象,在离开作用域时自动解锁。
这五条是我踩过无数坑后总结的。如果你的驱动代码在review时有人指出“这里锁范围太大”,别觉得丢面子,赶紧改。因为性能问题很多时候就出在这些看起来无害的大锁上。
5. 从零开始写一个资源同步保护模块:伪代码与实操要点
5.1 核心数据结构设计
为了把前面的理论落下来,我们用一段类似内核风格的伪代码来展示一个简单的显存资源对象,重点在同步与保护。假设这个驱动管理的内存资源称为“GpuBuffer”。
先定义基础对象:
struct gpu_buffer { atomic_t refcount; u32 magic; struct list_head node; // 掛在全局对象链表上 struct mutex lock; // 保护自身状态 enum buffer_state state; // FREE, ACTIVE, PINNED, EVICTING... dma_addr_t phys_addr; size_t size; struct dma_fence *fence; // 最后一次访问该资源的fence struct dma_fence_cb cb; // 用于异步回调 void *cpu_ptr; // 映射的虚拟地址 };这里refcount用于CPU侧的引用计数,用原子操作管理;fence是最后一个引用该资源的硬件fence。当fence信号后,可以触发一系列动作,比如释放内存。
另外需要管理映射表,可以用一个红黑树,以用户句柄为键,指向gpu_buffer指针:
struct handle_table { struct mutex table_lock; struct rb_root root; }; static struct gpu_buffer *get_gpu_buffer(struct handle_table *ht, u32 handle) { struct gpu_buffer *buf; mutex_lock(&ht->table_lock); buf = lookup_handle(ht, handle); if (buf && buf->magic == GPU_BUFFER_MAGIC) { if (atomic_inc_not_zero(&buf->refcount)) { buf = buf; // 有效 } else { buf = NULL; } } mutex_unlock(&ht->table_lock); return buf; }注意,在获取对象时,要先校验magic,再用atomic_inc_not_zero增加引用计数。atomic_inc_not_zero能够避免在对象正在被销毁时引用计数已经从0变为-1的情况。如果返回0,说明对象正处在销毁路径,不要继续使用。
释放时,先原子减计数,若减到0,则进入销毁流程。销毁流程会等待fence信号,但绝不持锁等待。
static void release_gpu_buffer(struct gpu_buffer *buf) { if (atomic_dec_and_test(&buf->refcount)) { // 进入销毁流程,可能异步 schedule_destroy_work(buf); } }5.2 同步与保护流程实现
下面模拟一个“提交命令并引用一块资源”的流程。这个流程发生在用户态请求执行一个渲染操作时。
int submit_command_wait_resources(struct drm_file *file, u32 handle) { struct gpu_buffer *buf = get_gpu_buffer(file->handle_table, handle); if (!buf) return -EINVAL; // 防止资源被驱逐,将其状态标记为PINNED(固定) mutex_lock(&buf->lock); if (buf->state != EVICTING) { buf->state = PINNED; } else { mutex_unlock(&buf->lock); release_gpu_buffer(buf); return -EBUSY; // 正在驱逐,无法新命令引用 } mutex_unlock(&buf->lock); // 创建命令缓冲区并提交到硬件队列 struct dma_fence *fence = submit_to_hw_queue(gpu, buf->phys_addr); // 记录该资源最后一个fence,便于以后等待 spin_lock(&gpu->fence_lock); if (buf->fence) dma_fence_put(buf->fence); buf->fence = dma_fence_get(fence); spin_unlock(&gpu->fence_lock); // 现在命令提交完成,可以移除PINNED状态,但fence仍保存引用 mutex_lock(&buf->lock); buf->state = ACTIVE; mutex_unlock(&buf->lock); dma_fence_put(fence); release_gpu_buffer(buf); return 0; }这里的关键是:在命令提交前,你已通过get_gpu_buffer增加了CPU引用,因此资源不会在提交过程中被析构。同时,通过设置PINNED状态,防止资源被驱逐。提交后保留fence,表示硬件还在使用它。当fence信号时,需要释放这个硬件引用:
void gpu_fence_callback(struct dma_fence *fence, struct dma_fence_cb *cb) { struct gpu_buffer *buf = container_of(cb, struct gpu_buffer, cb); // 此时GPU已执行完引用该资源的命令 mutex_lock(&buf->lock); buf->fence = NULL; if (buf->state == EVICTING) { // 资源正在等待被驱逐,这个callback只是通知等待者 complete(&buf->evict_comp); } mutex_unlock(&buf->lock); dma_fence_put(fence); }驱逐流程就利用这个callback:
int evict_gpu_buffer(struct gpu_buffer *buf) { mutex_lock(&buf->lock); if (buf->state == PINNED || buf->state == ACTIVE) { buf->state = EVICTING; buf->evict_fence = dma_fence_get(buf->fence); } else { mutex_unlock(&buf->lock); return 0; } mutex_unlock(&buf->lock); if (buf->evict_fence) { // 异步注册回调,而不是阻塞等待 init_completion(&buf->evict_comp); dma_fence_add_callback(buf->evict_fence, &buf->cb, gpu_fence_callback); wait_for_completion(&buf->evict_comp); dma_fence_put(buf->evict_fence); } // 安全释放物理内存 release_backing_pages(buf); return 0; }注意,在evict过程中,我们不持buf->lock等待completion,否则就触犯了前面说的“持锁等待”禁忌。另外要检查一个竞态:在注册callback之后、等待之前,如果fence已经信号,callback可能被立刻调用并complete;这里由于we注册回调前已经持有buf->lock,且callback里也试图拿同一把锁,如果不小心就会死锁。实际上dma_fence_add_callback的设计允许信号后注册回调会立即调用,但此时回调是在当前线程上下文执行,若此时还持有buf->lock,回调里再拿buf->lock就会死锁。解决办法:在调用dma_fence_add_callback之前释放buf->lock。上面代码里我们在注册回调前已没再持有buf->lock,所以是安全的。但需要保证buf对象在注册期间不会被析构,因此调用evict_gpu_buffer时,调用方已经持有该对象的引用。
上面的伪代码已经可以跑通基本流程。实际驱动中还有更多细节,比如处理fence已经信号时callback的同步执行路径,处理被中断打断的wait等。但这些核心骨架是真实KMD代码的影子。
5.3 测试与验证:如何用压力测试逼出并发问题
写完了同步代码,一定要在并发场景下测试。我建议从三个层次来压测:
第一层是原子与锁的正确性。在Linux内核里可以打开CONFIG_PROVE_LOCKING,也就是lockdep。它会检测锁序错误、死锁风险、在原子上下文中睡眠等问题。KMD开发环境中,尽量在调试内核里开着它跑图形负载。如果你运气不好,lockdep会在死锁真正发生前就报出警告,能救你一命。
第二层是功能级别的压力测试。用几个用户态进程同时创建/销毁大量buffer,每个buffer分配不同尺寸,并在多个线程里反复为同一buffer提交命令。还要模拟“提交命令后立即释放buffer”的坏行为。你可以在用户态写一个恶意测试程序,持续循环做:创建buffer,提交draw call,立刻销毁buffer,看驱动会不会崩溃。真正的KMD必须优雅地处理这种情况,让命令仍然能够安全执行,即使资源已被销毁。这要求驱动在用户态销毁buffer时,不立刻回收物理内存,而是等fence信号后再回收。如果驱动崩溃,多半是忘了等fence。
第三层是长时间运行的稳定性测试。跑一个生成高分辨率场景的渲染循环,运行数小时。同时穿插内存紧张的条件:在测试过程中用memtester故意消耗系统内存,迫使显存管理进入驱逐路径。这样可以暴露一些只在内存压力下才出现的竞态。我测试时习惯在循环里每几分钟触发一次全屏窗口最大化/最小化,诱导上下文切换和资源驱逐。很多同步问题都要靠这样的长时间混合负载才能暴露出来。
还要学会抓现场。当驱动挂起时,不要急着重启。先通过串口或kdump保存寄存器状态、硬件队列的head/tail指针、所有fence的sequence与信号时间。记录下哪些fence尚未信号,哪些线程在等待。这些数据能直接告诉你是不是某个fence没被正确触发,还是硬件真卡住了。我会在驱动里写一个debugfs接口,可以随时dump这些状态。这比事后看log有用得多。
6. 关于显存同步保护,我最后想补充的一些经验
前面讲了很多结构、伪代码和测试方法,其实我自己在实际写KMD时,最大的体会是:同步设计是一种“先想好后写码”的工作。拿到一个需求,不要立刻开始加锁。先画一张资源状态的转换图,列出每个状态迁移需要哪些条件,哪些迁移会被哪个引擎的事件触发。然后检查是否有环:状态A要等B,B要等C,C又要等A。如果有环,就必须用状态机加异步回调的方式打破。
再就是,显存资源同步保护不能只靠一两把锁,而是要靠完整的协议。这个协议包括:命令提交前必须增加引用计数;提交时必须记录fence;释放对象必须等在最后一个fence之后;驱逐操作必须进入状态机;多GPU必须使用跨设备同步;锁与fence绝对不能混在同一临界区。对新手来说,与其到处加锁,不如把这些协议做成辅助函数、封装成宏,强制所有人按协议写代码。比如提交命令时强制调用一个“gpu_submit_with_ref”函数,它内部自动完成引用计数与fence关联。这样即使后面的人不太理解,也会因为用了这个函数而避免犯大错。
我还想分享一个小技巧:在KMD里,打印日志本身也是一种同步操作,因为printk在原子上下文里可能导致递归死锁。调试时最好用tracepoint或者专用的lockfree缓冲,把日志存成一个环形数组,崩溃后再导出。这个技巧在staging驱动里特别有用,因为崩溃时靠printk往往来不及输出。我在代码里每个关键点都记录一条trace event,包括获取锁、提交fence、信号fence、进入驱逐、完成驱逐等。出现问题后,通过trace timeline就能一眼看出卡在哪个环节。
显存同步保护这块内容还有很多细枝末节,比如内存屏障(smp_mb、dma_wmb)怎么用、原子变量与stdatomic在GPU驱动中的差异、用户态同步与内核态同步的对接。不过只要掌握了我前面说的“CPU锁”和“GPU fence”两个核心工具,再把锁顺序和异步等待的规则刻进脑子里,大部分问题都能迎刃而解。等你真的把某一版驱动打磨到稳定、高效,再回头看那些让你抓狂过的死锁和use-after-free,会有一种“不过如此”的释然。但实际上,这些教训才是驱动开发中最宝贵的资产。希望这篇分享能帮你少踩几个坑,多留一些头发。