当前位置: 代码迷 >> 综合 >> LWN 翻译:Atomic Mode Setting 设计简介(下)
  详细解决方案

LWN 翻译:Atomic Mode Setting 设计简介(下)

热度:43   发布时间:2023-12-16 11:55:55.0

!!!声明!!!
本文章转自:何小龙
链接:https://blog.csdn.net/hexiaolong2009/article/details/88075520
转载只是为了学习备份。

译者注

紧接上篇文章,本篇翻译起来有难度,同时对读者的技术背景有一定要求,适合深入研究 DRM 驱动的开发人员阅读。通过阅读本文,你将了解如下内容:

  • DRM_MODE_ATOMIC_ALLOW_MODESET 标志位的由来及其作用
  • 驱动中随处可见的 ww_lock 到底是什么鬼?有什么作用?
  • atomic helper 与 legacy helper 相比,都做了哪些优化?
  • Atomic 框架中的 DPMS 为什么只有“ON”和“OFF”这两种状态?


原文链接:https://lwn.net/Articles/653466/

Atomic mode setting design overview, part 2

如今的图形应用程序需要完美帧的显示,同时也要利用嵌入式 GPU 中专用的、省电的硬件。为此,kernel 必须能够以原子的方式更新图形硬件的状态,即一次操作改变多个参数,这样用户就不会在参数修改过程中看到异常画面了。本系列的第一篇文章探讨了为什么需要使用 Atomic API、它是如何产生的、以及主要驱动程序接口是什么样子的。本篇,我们将基于前面的内容,深入探究更多的细节。

不过,在正式讲解之前,还是先简单地回顾一下主要的接口吧。Atomic 显示更新是通过对 display objects(如 plane、CRTC)进行 duplicate state (状态复制)并更新复制后的内容来实现的,这些内容统一收集在 struct drm_atomic_state 中。一旦完成复制内容的更新,新的 state 就会被完整地发送到驱动程序的 ->atomic_check() 回调接口中进行检查。如果检查通过,则最终会通过 ->atomic_commit() 接口将其提交给硬件。我们也可以使用 check-only 模式只对更新参数进行检查,这样就可以提前确定该操作是否可行了。

原子更新(Atomic Update)与核操作(Nuclear Option)

与 Android ADF 相比,upstream atomic mode-setting 的一个重大变化,就是 upstream 允许通过 atomic ioctl() 修改任意 display state,而 ADF 的 atomic 操作只允许修改 plane state,至于输出链路和 mode-set (显示模式)修改则需要在 ADF 的 userspace 侧单独调用。至少大致看下来, plane 更新和 mode-set 修改确实是两个不同的操作。一方面,atomic plane update 操作需要在下一帧显示完成时才会执行结束;另一方面,atomic mode-set 更新操作通常需要耗时几百毫秒,而且可能会出现短暂的黑屏,用户体验十分不友好。但是不管怎样,这两种更新方式都是以“要么全有或要么全无”的原子方式来更新显示状态的,并且这两种方式都可以使用同一个“property change list”进行 ioctl() 操作。因此,单从用户空间和 kernel 之间传递的数据结构组织形式来看(而不是从发起调用请求的语义来看),将它们视为同一种操作也是说得通的。

在讨论 upstream atomic 计划时,这两种更新操作之间的耦合关系造成了许多概念上的混淆。最终只是通过将 full mode set 称为“atomic mode set”,而将 plane update 称为“nuclear page flip”来纠正这一概念上的混淆。但是当你仔细观察时,full mode set 和需要同步到下一帧的 plane update 之间的区别很快就变得不那么清晰了。通常,板载 LCD 的 display mode 修改,仅仅只是需要重新调整一下 scaler 模块的缩放倍数,就可以将 buffer 中的显示区域最终显示到屏上。大多数的硬件都能实现这一功能,只需在下一帧进行一次小的修改,而不需要做 full mode set 和灭屏操作。而对于某些硬件而言,需要重新分配 FIFO 空间才能进行 plane 参数的更新,因此只能在 display pipeline 关闭时才能进行这一操作,这看起来反而很像一个 mode set 操作。

此外,用户空间通常希望将 full mode set 的更新操作推迟到更合适的时间点再执行。例如,平板电脑和智能手机的休眠唤醒实在太频繁了,以至于几乎所有的 mode set 操作都可以被延期执行,而不会因为一直保持着次优配置运行到下一次休眠而造成过多的电量浪费。因此,那些需要借助 full mode set 来修改 plane 参数的操作,在未经用户空间程序授权的情况下是不允许被执行的,以免给用户带来“不太完美”的视觉体验。

在对这个问题进行了大量探讨之后,最终合入的解决方案是增加 DRM_MODE_ATOMIC_ALLOW_MODESET 标志位。这样,用户空间就可以表明它是否允许接受一个完整的 mode set(缺点是可能会引入短暂的黑屏,并且可能需要较长的执行时间)。当然,这个标志也适用于 test-only 模式。其实大多数驱动程序不需要操心该标志位,因为 atomic helper 会帮你去 check 该标志位 —— 只要驱动程序正确地告诉 helper 代码它是否需要在上面某种特殊场景下执行 full mode set。

神奇的锁 —— 等待/缠绕互斥锁(wait/wound mutex)

接下来是关于如何处理并发更新的问题。不久前,DRM 为每个 CRTC 添加了一把各自独立的锁(per-CRTC lock),允许在不同的 display pipeline 上进行并发更新和修改操作。而反过来,我们也需要为每个 plane 添加一把锁(per-plane lock),因为 plane 可以切换它们当前所要连接的 CRTC(如果硬件支持的话)。另外,还有一个锁是用来保护所有的输出链路和 connector 状态的 —— 这些状态的修改通常需要至少几百毫秒,因此无论如何都会造成时间上的延迟(stall)。不过这种操作通常发生的频率较低,且是一种系统级的操作,因此更细粒度的锁保护只会造成资源的浪费。

但是,atomic ioctl 面临的一个问题是,用户空间有可能会按任意顺序传递参数更新列表(以 object_idproperty_idvalue 三元组的形式)。如果按列表的顺序来处理,则很容易导致死锁,因为不同的 object 需要不同的锁。用户空间可以通过创建两个更新操作来故意引发 AB-BA 死锁,这两个更新操作需要使用相同的两个 object,但顺序不同。

首先,我们可以通过按 object 对整个三元组列表进行排序来解决该问题,但不幸的是,这只能推迟问题发生的时间。在上一篇文章中曾提到过 atomic state-checking 代码只允许查看或访问正在更新操作中的 object state,这保证了并发更新不会互相干扰。不幸的是,这也意味着驱动程序必须要在它的 ->atomic_check() 回调中的 drm_atomic_state 结构中添加任意 object 及其 state。这将允许回调函数检查跨对象限制(cross-object limits),并确保共享资源没有被用尽。因为硬件是疯狂的,这不能在驱动程序代码之外完成,因为每个硬件都有自己特有的共享状态。仅仅提前获取所有可能需要的锁也不是一个好主意,因为这样只会使所有更新操作以同步方式进行,从而使所有细粒度的锁操作变得毫无意义。

这意味着我们需要一种可以按任意顺序获取锁的持锁机制,幸运的是,内核为我们提供了 wait/wound 互斥锁(顺便说一句,这也是 GPU 驱动骇客们为了对复那些讨厌的内存管理问题而添加的)。wait/wind 互斥锁本身是一个很大的话题,真的,但简而言之,它们允许你以任意顺序获取锁,能够可靠地检测死锁,并能够从死锁中退出,而不会在窗口期丢失公平性,也不需要耗费大量的自旋时间。除此之外,ww 锁还相当复杂。

没有人愿意强迫驱动程序编写人员去使用复杂的锁操作,因此锁操作都很好地隐藏在了 atomic core 所提供的函数中。当你想要把某个 plane 切换到其它 CRTC 上时,只需调用 drm_atomic_set_crtc_for_plane(),并将所有错误码正确地返回给调用端即可。同样的,如果驱动程序需要在其 state-validation 代码中查询其他 state object(例如检查共享资源的限制),它可以将任何想要查询的状态复制(duplicate)到所需的 atomic update 操作中,并在后台正确地持锁。这里唯一的重点就是能正确的返回 -EDEADLK 错误码,并始终使用 Atomic 框架提供的函数来复制和更新 state 结构体。有了它,魔法就产生了。当然啦,前提是驱动程序的 atomic check 代码编写正确,且不需要访问硬件状态或任何常驻在 state 结构体之外的东西。

对于 DRM-internal atomic 接口的用户来说,情况要稍微复杂一点。我们使用 drm_modeset_acquire_ctx 结构体来跟踪 wait/wound 的锁定状态,该结构体在 drm_modeset_acquire_init() 中进行初始化。在将 state object 复制到 atomic update 过程中,为了确保所有隐藏的 lock-taking 都能生效,该结构体也同时保存在了 drm_atomic_state->ctx 中。和驱动程序一样,在 duplicate state object 过程中,将隐式地获取所有必须的锁;而唯一需要显式处理的便是以 -EDEADLK 错误码发出的死锁信号。

由于死锁回退(deadlock backoff)逻辑将会释放锁,已经复制的 state object 可能不再是最新状态,因此需要先用 drm_atomic_state_clear() 对其进行释放操作,然后调用 drm_modeset_backoff() 来删除所有已获得的 mode-set lock,并一直 block 在当前发生竞争的锁上(contended lock),直到这些锁变得可用为止。一旦再次获取到这些锁,并且在 acquire context 初始化完成后,整个序列需要立即重新启动(acquire context 不能被重新初始化,因为它包含了 queue ticket,用于反映 wait/wound 锁定算法的当前进展)。最后,当所有操作都顺利完成后,就可以调用 drm_modeset_drop_locks() 来删除锁,并使用 drm_modeset_acquire_fini() 来结束 acquire context 的使用。

顺便提一下,测试所有这些特定的回退代码其实非常简单:开启 lock debug 机制并使能 CONFIG_DEBUG_WW_MUTEX_SLOWPATH,将会在 wait/wound 互斥锁代码中嵌入虚假的死锁条件。这样,所有的死锁回退代码都可以用单线程测试用例进行完整的测试。当然,wait/wind 互斥锁完全支持 Linux 锁的所有调试手段和测试工具,比如 lockdep。

Helper 库的设计

另一个困扰我们多年的问题也终于得到了解决,它就是驱动程序 helper (辅助)库的设计。像 ADF 那样加入一个中间层(mid-layer)的方案是不可取的,因为硬件有太多的特殊使用情况,无法创建这样一个不太复杂的框架。不管怎样,大多数驱动程序都已经使用了大量的 helper 函数,所以最好是驱动程序在转换为 Atomic 更新操作时,不必重写所有的 callback 和 support 代码。直接重用现有回调接口仅仅只是完成了概念验证(proof-of-concept)层面的转换,但从根本上讲,现有的 helper 库及其驱动回调接口还是存在不少问题的:

  • 他们并没有真正支持跨对象约束(cross-object constraint)的检查,而原子操作的一个主要目标就是正确地检查出这些约束条件。通过将 DRM_MODE_ATOMIC_TEST_ONLY 标志位导出给用户空间,来检查这种任意的跨设备约束(cross-device constraint),这在上一篇文章中有提到过。

  • 而且没有哪个驱动的 plane 支持 Atomic 更新操作,对于具有锁定位(lockout bit)的高级硬件(只有当所有需要更新的参数全部写入寄存器后,更新操作才开始生效),这很容易通过提交前(pre-commit)和提交后(post-commit)的回调接口来实现。但总的来说,硬件需要更大的灵活性。同时也没有什么规范将 plane check 与 plane update 的 commit 阶段区分开来。

  • 任何输出链路的修改都需要更新并使能 primary plane,通常这不是我们所期望的。例如,为 letterbox 或 pillarbox 显示黑色背景的视频叠加层(video overlay)时,根本不需要什么 primary plane。硬件可以做到这一点,所以软件就应该支持这样的能力。

  • 最后,legacy helper 是为第一个 XRandR 实现而设计的。在过去的十年里, DRM 开发人员积累了许多经验教训,包括哪些可以正常工作,哪些应该被简化(通过对 helper 提供更严格的保证),以及在哪些方面需要更大的灵活性来让 helper 变得更有用。

总之,完全重写 helper 代码是合理的,同时也让驱动的转换变得相当容易。当然,最大的变化是 helper 库不仅为每个回调接口提供了整体式模板函数(monolithic template function),而且还提供了局部的独立函数,例如只处理 plane update 或者只处理输出配置修改。除了 Atomic 本身在语义上的一些限制外,它们还可以按任意顺序进行调用,这样才能最大化适应不同的硬件行为。这样驱动程序就可以在 helper 代码和他们自己实现的代码之间进行混合调用并相互匹配,或者根据需要扩充 helper 代码(例如用于检查或修改全局状态)。

那些支持 Runtime PM(运行时电源管理)的驱动程序就是一个很好的例子,在 output disable 和(使用新的配置参数) enable 之间,老的 legacy helper 会去更新 plane 参数。atomic helper 为了保持最好的向后兼容性也是这么做的,但这对于支持 Runtime PM 的驱动程序来说并不是最好的操作流程。当 display pipeline 关闭时,这些驱动程序将 disable 硬件,因此如果在此期间进行更新操作,plane 的更新就会不起作用。所以在提交更新到硬件寄存器时,最好是将 plane 的更新操作作为整个流程的最后一步,事实上 atomic helper 是可以做到这一点的。

新 helper 库的另一个实用特点是,任何用于引导控制流程的派生状态都存储在 state 结构体中 —— 由于 check 和 commit 流程已经被拆分开来,因此它必须存储在 state 结构体中。它允许驱动程序直接覆盖和控制 helper 程序的细节,例如,当需要调整某些全局共享资源时,驱动程序可以强制发起一次 full mode set(以及所需的状态预计算),即使 helper 本身不需要执行 full mode set。由于 helper 库所提供的 check 函数是幂等的 (译者注:所谓幂等,即任意多次执行所产生的效果均与一次执行的效果相同),因此当后面的步骤发现需要在原子更新中包含更多 object 时(同样是由于资源共享),可以重新运行这些 check 函数。

当发现 plane 更新需要一个 full mode set 操作时,这个方法非常管用。但反过来一个 full mode set,可能需要重新计算 plane state,进而产生一个循环,而将 helper 函数设置成幂等和可随意调用的形式将有助于打破这种循环。为了避免发生意外,默认的 check 函数 drm_atomic_helper_check() 会进一步调用下一级的 helper 来检查 plane 更新参数和 mode set 参数变化。但是,对于在这两种类型的更新操作之间具有复杂依赖关系的硬件(比如前面的例子, plane 更新可能需要一个 full mode set,甚至还会发生在不相关的 CRTC 上),可以通过多次调用 helper 函数来应对这一情况,直到预计算状态(precomputed state)达到稳定状态。请注意,这只是为了方便状态校验(state validation),实际提交到硬件的原子更新操作仍应一次性完成,而不需要任何迭代步骤。

此外,所有使用 atomic 接口实现的 legacy 回调函数都已被导出,并可以被明确调用。那些不需要专门处理 legacy 入口函数的驱动程序,只需将当前提供的 helper 函数直接写入 DRM 驱动程序的 ops 函数表中即可。这样驱动程序就可以仍然使用旧平台的代码来保留一些只支持 legacy 接口的功能。这对于那些迭代了十年的硬件驱动来说是非常实用的,在这种情况下,再去为每种老的特殊用法实现对应的 atomic 支持是毫无意义的。

另一个大的变化是驱动程序的语义变得更简单了。由于原子更新,新的 helper 函数可以更好地跟踪 state 的变化,并保证 enable/disable 回调接口不会被冗余调用,而老的 helper 函数则通常会碰到这种问题。新的 helper 函数还屏蔽了许多 legacy 接口潜在的灵活性,比如用于 Runtime PM 输出的四种不同的显示电源管理信号(DPMS)状态 —— atomic helper 只有“ON”和“OFF”两种状态,这是因为现代硬件不再支持 DPMS 中间状态了。除了一些骨灰级的硬件,在任何其他硬件上,驱动程序都只是将 DPMS 中间状态强制转换为 disable 状态。我们还允许 atomic helper 程序重用 Runtime PM 的 enable 和 disable 接口,就像正常 enable/disable output 一样,这简化了驱动程序的编写。当然我们仍然支持 ->dpms() 这样的回调接口,以便简化驱动程序从 legacy mode setting 转换为 atomic 版本的开发过程,但我们还是不推荐使用这些接口。转换后的驱动程序应该只需要实现 ->enable()->disable() 接口即可,而且应该可以完全删除 DPMS 处理代码了。

最后,我们还提供了 transitional helper(过渡型 helper),用来处理 legacy helper 和新的 atomic helper 之间 plane 操作不匹配的问题。这其中包含像 drm_helper_crtc_mode_set_base() 这样的 helper 函数,它使用新的 atomic helper 来实现 legacy helper 的回调。这样可以让驱动程序编写者切换到新的回调接口,从而在内部进行 plane 更新操作,同时又能使用相同的代码和完整的控制流程。这有助于大型驱动程序的转换过程,允许转换发生在多个阶段,每个阶段只处理驱动程序的一部分。还有一个“how to”包含了 atomic 转换的所有细节,内容包括“需要做什么”以及“怎么做”。

总之,有许多驱动程序已经转换到了 atomic helper 上,而且新的 helper 看起来运行得很不错。虽然有时需要对回调接口做一些小的修改,但到目前为止,atomic 语义本身并没有出现什么大的问题。其中一个很重要的原因就是,新开发的 atomic 代码其实是基于 i915.ko 所特有的 mode-set 框架为原型的,该框架是在过去几年里被开发出来的,目的就是为了实现原子更新操作。

从 i915.ko 中吸取到的重大经验教训,促成了 Atomic 代码与 i915 的主要差异:原子更新操作在真正提交到硬件之前,到底是如何组成的。在 i915.ko 中,所有更新参数都暂存到各个 mode-setting object 内嵌的一组 new_ foo 成员变量中,这些成员变量都事先拷贝了一份现有的数据结构和指针。这种方法的主要缺点是,回滚更新操作(可能是因为参数超出了限制条件,也可能是因为用户空间只想使用 TEST_ONLY 模式来检查约束限制)需要小心地撤消所有这些更改。将更新参数暂存到 object 中同样会让多个并发更新操作变得更加困难。在已合入的 atomic 框架中,更新操作都是从完全独立的 state object 中组合而成的,在将 state 真正提交给硬件之前,是不会接触到任何 mode-setting object 的,这使得回滚和并发更新操作变得更加简单。

当前 helper 中还未解决的断层问题就是 non-blocking 更新的处理。到目前为止,驱动程序需要为原子更新的异步 tail 操作管理自己的工作队列,并且当更新依赖于全局 state 时,会进行适当的同步操作,就像他们不得不使用 legacy ioctl() 进行 primary plane page flip 操作一样。目前关于在 atomic helper 中去实现这一方案的想法有很多,但是暂时还没有找到一种方法既能非常简单地改善现状,又能对许多驱动仍然保持实用。

结论

经过三年多的努力,upstream kernel 终于为驱动程序添加了 mode-setting 接口和基础框架的支持。它涵盖了所有的使用场景,从需要专用 plane 硬件的低功耗片上系统(SoCs),到需要支持每帧的完美更新,再到支持多屏的超大桌面系统。这是第一次,因为最初的 kernel mode setting 几乎把嵌入式系统留给了 fbdev。但考虑到正在开发或已经合入的 atomic 转换接口,以及支持该转换的全新驱动程序的数量,看起来面向 SoC 的 upstream graphics 终于出现了,而且一定会持续下去,好吧,至少在 GPU 的 mode-setting 方面会是这样。但即便如此,对于 Android 厂商 kernel tree 的所有 upstreaming 工作来说,这也将是一次巨大的改变和伟大的进步!

让我们一起拥抱 atomic mode-setting 时代吧!美好的未来从现在开始 !


上一篇:LWN 翻译:Atomic Mode Setting 设计简介(上)

文章汇总: DRM (Direct Rendering Manager) 学习简介

  相关解决方案