基于 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 Numbers 中列出
- nr
一个 8 位数字,用于标识特定命令,对于给定的 “type” 值是唯一的
- 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,以便使用回退转换为本机命令。由于现在所有子系统都负责自己处理 compat 模式,因此不再需要这样做,但在将错误修复向后移植到旧内核时,考虑这一点可能很重要。
时间戳¶
传统上,时间戳和超时值作为 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() 回调中,最后一个参数是一个无符号长整数,可以根据命令解释为指针或标量。如果它是标量,则不得使用 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
成员可以是带符号的也可以是无符号的,具体取决于体系结构,因此 __u8 和 __s8 类型应用于 8 位整数值,尽管 char 数组对于固定长度的字符串更清晰。
信息泄漏¶
不得将未初始化的数据复制回用户空间,因为这可能会导致信息泄漏,这可以用来破坏内核地址空间布局随机化 (KASLR),从而有助于攻击。
因此(以及为了兼容性支持),最好避免数据结构中存在任何隐式填充。如果现有结构中存在隐式填充,内核驱动程序必须在将结构实例复制到用户空间之前小心地完全初始化该实例。这通常通过在赋值给各个成员之前调用 memset()
来完成。
子系统抽象¶
虽然一些设备驱动程序实现自己的 ioctl 函数,但大多数子系统为多个驱动程序实现相同的命令。理想情况下,子系统具有一个 .ioctl() 处理程序,该处理程序从用户空间复制参数,并通过正常的内核指针将它们传递给子系统特定的回调函数。
这在多个方面有所帮助
如果用户空间 ABI 中没有细微的差异,则为一个驱动程序编写的应用程序更有可能在同一子系统中的另一个驱动程序上工作。
用户空间访问和数据结构布局的复杂性在一个地方完成,从而减少了实现错误的潜在可能性。
当 ioctl 在多个驱动程序之间共享时,更有可能由经验丰富的开发人员进行审查,他们可以发现接口中的问题,而不是仅在一个驱动程序中使用时。
ioctl 的替代方案¶
在许多情况下,ioctl 不是解决问题的最佳方案。替代方案包括
对于与物理设备无关或不受字符设备节点的 文件系统权限约束的系统范围功能,系统调用是更好的选择
netlink 是通过套接字配置任何网络相关对象的首选方式。
debugfs 用于调试功能的临时接口,这些功能不需要作为稳定的接口暴露给应用程序。
sysfs 是公开与文件描述符无关的内核对象状态的好方法。
configfs 可用于比 sysfs 更复杂的配置
自定义文件系统可以通过简单的用户界面提供额外的灵活性,但会增加实现的复杂性。