(如何避免)搞砸 ioctl¶
来自:https://blog.ffwll.ch/2013/11/botching-up-ioctls.html
作者:Daniel Vetter,版权所有 © 2013 Intel Corporation
过去几年,内核图形黑客们获得的一个清晰认识是,试图为完全不同的 GPU 管理执行单元和内存而提出一个统一的接口是徒劳的。因此,现在每个驱动程序都有自己的一组 ioctl 来分配内存并将工作提交给 GPU。这很好,因为不再有伪通用但实际上只使用一次的接口形式的疯狂行为了。但明显的缺点是,搞砸事情的可能性更大。
为了避免重复所有相同的错误,我写下了一些在搞砸 drm/i915 驱动程序工作时吸取的教训。其中大多数只涵盖技术细节,而不是像命令提交 ioctl 到底应该是什么样子这样的大局问题。学习这些教训可能是每个 GPU 驱动程序都必须自己做的事情。
先决条件¶
首先是先决条件。如果没有这些,你已经失败了,因为你将需要添加一个 32 位兼容层
只使用固定大小的整数。为了避免与用户空间中的 typedef 冲突,内核有特殊的类型,例如 __u32、__s64。请使用它们。
将所有内容对齐到自然大小并使用显式填充。32 位平台不一定将 64 位值对齐到 64 位边界,但 64 位平台会。因此,我们始终需要填充到自然大小才能使其正确。
如果结构包含 64 位类型,则将整个结构填充为 64 位的倍数 - 否则,结构大小在 32 位和 64 位上会有所不同。当将结构数组传递给内核,或者内核检查结构大小时(例如,drm 核心会这样做),具有不同的结构大小会造成损害。
指针是 __u64,在用户空间端从/转换为 uintptr_t,在内核中从/转换为 void __user *。尽量不要延迟此转换,或者更糟糕的是,在代码中摆弄原始的 __u64,因为这会降低像 sparse 这样的检查工具可以提供的检查能力。内核可以使用宏 u64_to_user_ptr 来避免关于不同大小的整数和指针的警告。
基础¶
避免了编写兼容层的乐趣,我们可以看看基本错误。忽略这些会使向后和向前兼容性变得非常痛苦。而且,由于第一次尝试就出错是必然的,因此你将对任何给定的接口进行第二次迭代或至少扩展。
为用户空间提供一种明确的方式来确定给定的内核是否支持你的新 ioctl 或 ioctl 扩展。如果你不能依赖旧内核拒绝新的标志/模式或 ioctl(因为过去这样做搞砸了),那么你需要在某处使用驱动程序功能标志或修订号。
制定一个计划,通过在结构的末尾添加新标志或新字段来扩展 ioctl。drm 核心会检查每个 ioctl 调用传入的大小,并零扩展内核和用户空间之间的任何不匹配。这有所帮助,但不是一个完整的解决方案,因为旧内核上的较新用户空间不会注意到末尾新添加的字段被忽略了。因此,这仍然需要新的驱动程序功能标志。
检查所有未使用的字段和标志以及所有填充是否为 0,如果不是,则拒绝 ioctl。否则,你关于未来扩展的好计划将彻底失败,因为有人会在尚未使用的部分中提交带有随机堆栈垃圾的 ioctl 结构。这会将 ABI 写入,这些字段永远不能用于除垃圾之外的其他用途。这也是你必须显式填充所有结构的原因,即使你永远不会在数组中使用它们 - 编译器可能插入的填充可能包含垃圾。
为上述所有内容提供简单的测试用例。
错误路径的乐趣¶
现在,我们没有任何理由再让 drm 驱动程序成为简洁的 root 漏洞。这意味着我们需要完整的输入验证和可靠的错误处理路径 - GPU 最终会在最奇怪的角落案例中死掉
ioctl 必须检查数组溢出。它还需要检查一般整数值的溢出/下溢和钳制问题。常见的例子是直接输入到硬件中的精灵定位值,硬件只有 12 位左右。在一些奇怪的显示服务器不自己进行钳制之前,它工作得很好,并且光标会环绕屏幕。
在你的 ioctl 中为每个输入验证失败案例提供简单的测试用例。检查错误代码是否符合你的预期。最后,确保你在每个子测试中只测试一个错误路径,方法是提交其他完全有效的数据。如果没有这样做,较早的检查可能会已经拒绝了 ioctl 并掩盖了你实际要测试的代码路径,从而隐藏了错误和回归。
使你的所有 ioctl 可重启。首先,X 非常喜欢信号,其次,这将允许你通过不断地用信号中断你的主测试套件来测试 90% 的所有错误处理路径。感谢 X 对信号的热爱,你几乎可以免费为图形驱动程序获得所有错误路径的出色基础覆盖。此外,要始终如一地处理 ioctl 重启 - 例如,drm 在其用户空间库中有一个小的 drmIoctl 辅助函数。i915 驱动程序使用 set_tiling ioctl 搞砸了这一点,现在我们永远都必须在内核和用户空间中忍受一些神秘的语义。
如果你无法使给定的代码路径可重启,至少要使被卡住的任务可终止。GPU 只是会死掉,如果你挂起他们的整个机器(通过一个不可终止的 X 进程),你的用户不会更喜欢你。如果状态恢复仍然过于棘手,请使用超时或挂起检查安全网作为最后手段,以防硬件出现故障。
为你的错误恢复代码中真正棘手的角落案例提供测试用例 - 在你的挂起检查代码和等待者之间创建死锁太容易了。
时间、等待和错过它¶
GPU 大部分都是异步执行的,因此我们需要对操作进行计时并等待未完成的操作。这是一件非常棘手的事情;目前,drm/i915 支持的所有 ioctl 都无法完全正确地做到这一点,这意味着这里还有更多的教训要学习。
始终使用 CLOCK_MONOTONIC 作为你的参考时间。这是 alsa、drm 和 v4l 现在默认使用的。但是要让用户空间知道哪些时间戳来自不同的时钟域,例如你的主系统时钟(由内核提供)或某处其他独立的硬件计数器。如果你看得足够仔细,时钟会不匹配,但是如果性能测量工具拥有此信息,它们至少可以进行补偿。如果你的用户空间可以获得某些时钟的原始值(例如,通过命令流中的性能计数器采样指令),请考虑也将其公开。
使用 __s64 秒加上 __u64 纳秒来指定时间。这不是最方便的时间规范,但它基本上是标准。
检查输入时间值是否已标准化,如果未标准化,则拒绝它们。请注意,内核本机结构 ktime 的秒和纳秒都有一个有符号整数,因此请注意这里。
对于超时,请使用绝对时间。如果你是一个好人,并且使你的 ioctl 可重启,则相对超时往往过于粗糙,并且由于每次重启的舍入,可能会无限期地延长你的等待时间。特别是如果你的参考时钟是非常慢的东西,例如显示帧计数器。从规范律师的角度来看,这不是一个错误,因为超时总是可以延长的 - 但是如果他们的简洁动画由于这种情况而开始断断续续,用户肯定会讨厌你。
考虑放弃任何带有超时的同步等待 ioctl,只需在可轮询的文件描述符上交付一个异步事件。它更适合事件驱动应用程序的主循环。
为角落案例提供测试用例,特别是已完成事件、成功等待和超时等待的返回值是否都合理且符合你的需求。
泄漏资源,不是¶
一个成熟的 drm 驱动程序基本上实现了一个小型的操作系统,但专门用于给定的 GPU 平台。这意味着驱动程序需要向用户空间公开大量用于不同对象和其他资源的句柄。正确地执行此操作会带来其自身的一些陷阱
始终将动态创建的资源的生命周期附加到文件描述符的生命周期。如果你的资源需要在进程之间共享,请考虑使用 1:1 映射 - 通过 unix 域套接字传递 fd 也可以简化用户空间的生命周期管理。
始终支持 O_CLOEXEC。
确保在不同的客户端之间有足够的隔离。默认情况下,选择一个私有的每 fd 命名空间,该命名空间强制任何共享都必须显式完成。只有当对象是真正设备唯一的,才使用更全局的每设备命名空间。drm 模式设置接口中的一个反例是,每个设备的模式设置对象(如连接器)与帧缓冲区对象共享一个命名空间,而帧缓冲区对象通常根本不共享。默认情况下,对于帧缓冲区使用单独的私有命名空间会更合适。
考虑用户空间句柄的唯一性要求。例如,对于大多数 DRM 驱动程序,在同一个命令提交 ioctl 中两次提交相同的对象是用户空间错误。但是,如果对象是可共享的,那么用户空间需要知道它是否已经看到从不同进程导入的对象。由于缺乏新的对象类别,我还没有亲自尝试过,但是可以考虑使用共享文件描述符上的 inode 号作为唯一标识符——这也是真实文件彼此区分的方式。不幸的是,这需要在内核中建立一个完整的虚拟文件系统。
最后,但同样重要¶
并非所有问题都需要新的 ioctl
仔细考虑你是否真的需要驱动程序私有接口。当然,推出驱动程序私有接口比参与冗长的讨论以寻求更通用的解决方案要快得多。有时,为了引领新概念,确实需要使用私有接口。但最终,一旦通用接口出现,你最终将维护两个接口。并且是无限期地维护。
考虑 ioctl 之外的其他接口。对于每个设备的设置,或者对于生命周期相对静态的子对象(例如 DRM 中的输出连接器以及所有检测覆盖属性),sysfs 属性要好得多。或者,也许只有你的测试套件需要此接口,那么声明没有稳定 ABI 的 debugfs 可能会更好。
最后,关键是要在第一次尝试时就做对,因为如果你的驱动程序被证明很受欢迎,并且你的硬件平台生命周期很长,那么你基本上会永远被一个给定的 ioctl 困住。你可以在较新版本的硬件上尝试弃用可怕的 ioctl,但这通常需要数年才能完成。然后,又要过几年,直到最后一个能够抱怨回归的用户也消失为止。