英语

(如何避免) 搞砸 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 纳秒来指定时间。它不是最方便的时间规格,但它基本上是标准。

  • 检查输入时间值是否已规范化,如果不是则拒绝。请注意,内核原生的 struct ktime 在秒和纳秒上都有一个有符号整数,所以这里要小心。

  • 对于超时,使用绝对时间。如果你是一个好人,并且使你的 ioctl 可重启,那么相对超时往往过于粗略,并且可能由于每次重启的舍入而无限期延长你的等待时间。特别是如果你的参考时钟是非常慢的,例如显示帧计数器。戴上规范律师的帽子,这不算一个bug,因为超时总是可以延长的——但如果你的漂亮动画因此开始卡顿,用户肯定会恨你。

  • 考虑放弃任何带超时的同步等待 ioctl,而是在可轮询文件描述符上传递异步事件。它更适合事件驱动应用程序的主循环。

  • 为边缘情况提供测试用例,特别是检查已完成事件、成功等待和超时等待的返回值是否都正常且符合您的需求。

资源泄露,绝不

一个功能完备的 drm 驱动程序本质上实现了一个小型操作系统,但专用于给定的 GPU 平台。这意味着驱动程序需要向用户空间公开大量用于不同对象和其他资源的句柄。正确地做到这一点本身就有一系列陷阱。

  • 始终将动态创建资源的生命周期附加到文件描述符的生命周期。如果您的资源需要在进程间共享,请考虑使用 1:1 映射——通过 Unix 域套接字进行 fd 传递也简化了用户空间的生命周期管理。

  • 始终支持 O_CLOEXEC。

  • 确保不同客户端之间有足够的隔离。默认情况下选择私有的每 fd 命名空间,这会强制任何共享都必须显式完成。仅当对象真正是设备唯一时,才使用更全局的每设备命名空间。drm 模式设置接口中的一个反例是,每设备模式设置对象(如连接器)与帧缓冲区对象共享命名空间,而帧缓冲区对象大多根本不共享。为帧缓冲区提供一个独立的、默认私有的命名空间会更合适。

  • 思考用户空间句柄的唯一性要求。例如,对于大多数 drm 驱动程序来说,在同一命令提交 ioctl 中两次提交同一对象是用户空间的一个 bug。但是,如果对象可共享,用户空间就需要知道它是否已经看到过从另一个进程导入的对象。我还没有亲自尝试过,因为缺少一类新的对象,但可以考虑使用共享文件描述符上的 inode 号作为唯一标识符——这也是区分真实文件的方式。不幸的是,这需要在内核中实现一个完整的虚拟文件系统。

最后,但并非最不重要

并非所有问题都需要一个新的 ioctl。

  • 仔细思考你是否真的需要一个驱动程序私有接口。当然,推动一个驱动程序私有接口要比为更通用的解决方案进行冗长的讨论快得多。偶尔,为了开创一个新概念,私有接口是必需的。但最终,一旦通用接口出现,你将不得不维护两个接口。无限期地。

  • 考虑 ioctl 之外的其他接口。对于每个设备的设置,或者对于生命周期相当静态的子对象(例如 drm 中带有所有检测覆盖属性的输出连接器),sysfs 属性要好得多。或者也许只有你的测试套件需要这个接口,那么 debugfs 及其关于没有稳定 ABI 的免责声明会更好。

最后,游戏的关键是在第一次尝试时就做对,因为如果你的驱动程序很受欢迎,并且你的硬件平台寿命很长,那么你将基本上永远被某个 ioctl 困住。你可以在硬件的较新迭代中尝试废弃糟糕的 ioctl,但通常需要数年才能完成。然后又是数年,直到最后一个能够抱怨回归的用户也消失。