基于 ioctl 的接口¶
ioctl() 是应用程序与设备驱动程序交互最常用的方式。它灵活且易于通过添加新命令进行扩展,并可通过字符设备、块设备以及套接字和其他特殊文件描述符传递。
然而,ioctl 命令定义也极易出错,并且在不破坏现有应用程序的情况下很难修复,因此本文档旨在帮助开发者正确地定义。
命令编号定义¶
命令编号(或请求编号)是传递给 ioctl 系统调用的第二个参数。虽然它可以是唯一标识特定驱动程序操作的任何 32 位数字,但有一些围绕定义它们的约定。
include/uapi/asm-generic/ioctl.h
提供了四个宏用于定义遵循现代约定的 ioctl 命令:_IO
、_IOR
、_IOW
和 _IOWR
。所有新命令都应使用这些宏,并提供正确的参数。
- _IO/_IOR/_IOW/_IOWR
宏名称指定了参数将如何使用。它可能是一个指向要传入内核(_IOW)、传出内核(_IOR)或双向(_IOWR)的数据的指针。_IO 可以表示没有参数的命令,或者传递整数值而非指针的命令。建议仅将 _IO 用于无参数命令,并使用指针来传递数据。
- 类型(type)
一个 8 位数字,通常是字符字面量,特定于某个子系统或驱动程序,并列在Ioctl 编号中。
- 编号(nr)
一个 8 位数字,用于标识特定命令,对于给定的“类型”值是唯一的。
- 数据类型(data_type)
参数所指向的数据类型的名称,命令编号以 13 位或 14 位整数编码
sizeof(data_type)
值,导致参数的最大大小限制为 8191 字节。注意:不要将 sizeof(data_type) 类型传递给 _IOR/_IOW/IOWR,因为那将导致编码 sizeof(sizeof(data_type)),即 sizeof(size_t)。_IO 没有 data_type 参数。
接口版本¶
一些子系统在数据结构中使用版本号来通过不同的参数解释来重载命令。
这通常不是一个好主意,因为对现有命令的更改往往会破坏现有应用程序。
一个更好的方法是添加一个带有新编号的新 ioctl 命令。旧命令仍需要在内核中实现以保持兼容性,但这可以是新实现的一个包装器。
返回码¶
ioctl 命令可以返回 errno(3) 中记录的负错误码;这些错误码在用户空间中转换为 errno 值。成功时,返回码应为零。返回正的“long”值也是可能的,但不推荐。
当 ioctl 回调以未知命令编号调用时,处理程序返回 -ENOTTY 或 -ENOIOCTLCMD,这也导致系统调用返回 -ENOTTY。某些子系统出于历史原因在此处返回 -ENOSYS 或 -EINVAL,但这是错误的。
在 Linux 5.5 之前,compat_ioctl 处理程序必须返回 -ENOIOCTLCMD 才能使用回退转换为原生命令。由于所有子系统现在都负责自行处理兼容模式,因此不再需要这样做,但在将错误修复反向移植到旧内核时,这可能需要考虑。
时间戳¶
传统上,时间戳和超时值以 struct timespec
或 struct timeval
传递,但由于转换为 64 位 time_t 后用户空间中这些结构的不兼容定义,它们存在问题。
当需要单独的秒/纳秒值时,可以使用 struct __kernel_timespec
类型嵌入到其他数据结构中,或者直接传递给用户空间。但这仍然不理想,因为该结构既不完全匹配内核的 timespec64,也不完全匹配用户空间的 timespec。可以使用 get_timespec64()
和 put_timespec64()
辅助函数来确保布局与用户空间保持兼容,并且填充得到正确处理。
由于将秒转换为纳秒很廉价,但反向转换需要昂贵的 64 位除法,因此一个简单的 __u64 纳秒值可能更简单、更高效。
超时值和时间戳理想情况下应使用 CLOCK_MONOTONIC 时间,如 ktime_get_ns()
或 ktime_get_ts64()
所返回。与 CLOCK_REALTIME 不同,这使得时间戳不受闰秒调整和 clock_settime() 调用导致时间前后跳动的影响。
ktime_get_real_ns()
可用于需要跨重启或在多台机器之间保持持久的 CLOCK_REALTIME 时间戳。
32 位兼容模式¶
为了支持在 64 位机器上运行的 32 位用户空间,每个实现 ioctl 回调处理程序的子系统或驱动程序也必须实现相应的 compat_ioctl 处理程序。
只要遵循数据结构的所有规则,这就像将 .compat_ioctl 指针设置为 compat_ptr_ioctl() 或 blkdev_compat_ptr_ioctl() 等辅助函数一样简单。
compat_ptr()¶
在 s390 架构上,31 位用户空间的数据指针表示具有歧义,高位被忽略。当在兼容模式下运行此类进程时,必须使用 compat_ptr() 辅助函数清除 compat_uptr_t 的高位并将其转换为有效的 64 位指针。在其他架构上,此宏仅执行向 void __user *
指针的类型转换。
在 compat_ioctl() 回调中,最后一个参数是 unsigned long,它可以根据命令被解释为指针或标量。如果它是标量,则不能使用 compat_ptr(),以确保 64 位内核对于设置了高位的参数,其行为与 32 位内核相同。
对于仅接受指向兼容数据结构指针的参数的驱动程序,compat_ptr_ioctl() 辅助函数可以代替自定义的 compat_ioctl 文件操作。
结构体布局¶
兼容的数据结构在所有架构上都具有相同的布局,避免了所有有问题成员。
long
和unsigned long
的大小与寄存器相同,因此它们可以是 32 位或 64 位宽,不能用于可移植的数据结构。固定长度的替代品是__s32
、__u32
、__s64
和__u64
。指针也存在同样的问题,此外还需要使用 compat_ptr()。最好的解决方案是使用
__u64
代替指针,这需要在用户空间中进行到uintptr_t
的类型转换,并在内核中使用 u64_to_user_ptr() 将其转换回用户指针。在 x86-32 (i386) 架构上,64 位变量的对齐只有 32 位,但它们在包括 x86-64 在内的大多数其他架构上都是自然对齐的。这意味着像下面这样的结构:
struct foo { __u32 a; __u64 b; __u32 c; };
在 x86-64 上,a 和 b 之间有四个字节的填充,末尾还有四个字节的填充,但在 i386 上没有填充,它需要一个 compat_ioctl 转换处理程序来在这两种格式之间进行转换。
为避免此问题,所有结构体的成员都应自然对齐,或者在隐式填充的位置添加显式保留字段。
pahole
工具可用于检查对齐情况。在 ARM OABI 用户空间中,结构体被填充到 32 位倍数,如果它们不以 32 位边界结束,则某些结构与现代 EABI 内核不兼容。
在 m68k 架构上,结构成员不保证具有大于 16 位的对齐,这在依赖隐式填充时是一个问题。
位字段和枚举通常按预期工作,但它们的一些属性是实现定义的,因此最好在 ioctl 接口中完全避免使用它们。
char
成员可以是 signed 或 unsigned,取决于架构,因此 8 位整数值应使用 __u8 和 __s8 类型,尽管对于固定长度字符串,字符数组更清晰。
信息泄露¶
未初始化的数据不得复制回用户空间,因为这可能导致信息泄露,可用于击败内核地址空间布局随机化(KASLR),从而助长攻击。
因此(也为了兼容性支持),最好避免数据结构中任何隐式填充。对于现有结构中存在隐式填充的情况,内核驱动程序必须小心在将其复制到用户空间之前,完全初始化该结构的一个实例。这通常通过在分配给单个成员之前调用 memset()
来完成。
子系统抽象¶
虽然有些设备驱动程序实现自己的 ioctl 函数,但大多数子系统为多个驱动程序实现相同的命令。理想情况下,子系统有一个 .ioctl() 处理程序,它将参数从用户空间复制到用户空间,并通过普通内核指针将它们传递给子系统特定的回调函数。
这在多方面有所帮助:
如果用户空间 ABI 没有细微差别,为某个驱动程序编写的应用程序更可能在同一子系统中的另一个驱动程序上工作。
用户空间访问和数据结构布局的复杂性集中在一个地方,减少了实现错误的潜力。
当 ioctl 在多个驱动程序之间共享时,比仅在单个驱动程序中使用时,更有可能被经验丰富的开发人员审查,从而发现接口中的问题。
ioctl 的替代方案¶
在许多情况下,ioctl 并非解决问题的最佳方案。替代方案包括:
系统调用是更佳选择,适用于不依赖于物理设备或不受字符设备节点文件系统权限限制的系统级功能。
netlink 是通过套接字配置任何网络相关对象的首选方式。
debugfs 用于调试功能,这些功能无需作为稳定接口暴露给应用程序。
sysfs 是暴露不与文件描述符关联的内核对象状态的好方法。
configfs 可用于比 sysfs 更复杂的配置。
自定义文件系统可以通过简单的用户界面提供额外的灵活性,但会大大增加实现的复杂性。