1. 命令字不是随便挑的数字:先搞懂 ioctl 的编码规则
写过字符设备驱动的人,几乎都绕不开 ioctl 这套东西。而_IO、_IOR、_IOW、_IOWR这四个宏,就是 ioctl 世界里最基础也最容易被糊弄过去的一层。我见过太多代码,头文件里一排#define XXX_CMD 0x1234,内核里 switch 一下,能跑,测试也过,然后换个平台、换个编译器、来个 32 位用户态程序,问题就冒出来了。所以我一直觉得,这四个宏不值得"会用就行",得把它背后的位布局吃透,否则你调试时看到ioctl(fd, 0x1234, ...)返回-1而errno是ENOTTY,根本不知道从哪下手。
这篇就聊透这四兄弟:它们怎么把一个命令拆成四段位域、方向位到底该站谁的视角、sizeof里到底填什么、以及实际工程里那些踩一次能记一辈子的坑。内容偏底层驱动和系统编程,但只要你写过任何跟设备打交道的代码,哪怕是应用层调ioctl,里面关于命令字兼容性、结构体对齐、错误码判断的经验都用得上。我会给出一套可以直接抄的头文件模板和内核分发骨架,也会把我自己常用的命令字反解脚本放出来。
1.1 一个具体场景:为什么不能用裸数字
设想一个采集卡驱动,用户态要设采样率、读状态、发起一次数据搬运。最省事的写法是定义三个数字:1、2、3,内核 switch 判断。这在小 Demo 里没问题,但工程一变大就麻烦。第一,多个驱动共用同一个 fd 类型或者走同一套 ioctl 通道时,数字会撞车,撞了之后表现是"调 A 驱动的命令,B 驱动返回了成功",极难查。第二,内核没法在入口处做统一校验,你得在每个 case 里手动检查参数长度,漏一个就是越界拷贝。第三,工具链层面完全看不出这个数字代表什么,strace 打出来就是ioctl(3, 0x1, 0x7ffc...),等于没有信息。
_IO系列宏解决的正是这三件事:给每条命令打上"归属哪个驱动(type)"、"第几条(nr)"、"数据往哪个方向走(dir)"、"数据多大(size)"的标签。这四个信息被压缩进一个 32 位无符号整数里,也就是 ioctl 的第二个参数cmd。内核在分发之前,用_IOC_TYPE(cmd)就能判断这条命令是不是发给自己的,用_IOC_SIZE(cmd)就能知道调用方期望的数据长度,甚至可以和结构体实际大小做交叉校验。这些能力不是锦上添花,是驱动入口处第一道防线。
注意:命令字在用户态是
unsigned long(ioctl 原型第二参数),在内核unlocked_ioctl里是unsigned int。32 位内核上两者同宽,64 位内核上用户态是 64 位、内核接的是截断后的 32 位。别在用户态往上塞超过 32 位的信息,会被静默截掉。
1.2 32 位里塞进了四段信息
通用架构下这套布局定义在asm-generic/ioctl.h里,四段的宽度是固定的:方向 2 位、数据大小 14 位、类型 8 位、序号 8 位。加起来正好 32 位,位序从高到低依次是 dir、size、type、nr。
| 位段 | 名称 | 宽度 | 位移 | 掩码 | 含义 |
|---|---|---|---|---|---|
| bit 31-30 | dir | 2 | 30 | 0x3 | 数据流向,0 无、1 写、2 读、3 读写 |
| bit 29-16 | size | 14 | 16 | 0x3fff | 关联结构体的字节数,最大 16383 |
| bit 15-8 | type | 8 | 8 | 0xff | 驱动专属魔数,通常取一个 ASCII 字符 |
| bit 7-0 | nr | 8 | 0 | 0xff | 该驱动内部的命令序号,最大 255 |
宏的本体其实就是一次移位或运算:
#define _IOC(dir, type, nr, size) \ (((dir) << 30) | ((type) << 8) | ((nr) << 0) | ((size) << 16))四个便利宏在此基础上把 dir 和 size 固定住,其中_IO的 size 直接写 0,另外三个用sizeof取实参类型的大小。理解这个布局之后,你看到任意一个0x40206b01这样的十六进制数,就能心算出它是"方向写、32 字节、魔数 0x6b、序号 1"。
为什么方向只给 2 位、序号只给 8 位?这是历史设计留下的紧约束。序号 255 条对一个驱动来说通常够用,真不够可以拆成两个魔数;而 14 位的大小限制(16383 字节)就更容易撞墙,很多做图像、做批量传输的驱动会把大块数据拆成多次调用,或者干脆在 ioctl 里只传一个描述符,真正的数据走 mmap 或 read/write。这不是妥协,是让命令字保持紧凑可读的代价。
1.3 边界值必须先算清楚再动手
动手写头文件之前,有几个边界必须明确,否则编译期不报错、运行期出玄学。
序号最大 255。超过之后nr会溢出到 type 字段里去,等于悄悄改了魔数,内核侧_IOC_TYPE校验直接失败,返回ENOTTY。这种错误非常隐蔽,因为代码看着完全正常。
大小最大 16383。这个在早期内核里是"超了就截断",现在内核侧的_IOC_TYPECHECK会在编译期拦住超限的类型,报一个__invalid_size_argument_for_IOC的符号未定义错误。看到这个报错别慌,就是你的结构体太大了,该拆分。
类型字段只有 8 位,取值 0 到 255。习惯上取一个可打印字符,比如'k'、'A'、'x',好处是十六进制 dump 出来能直接看出是哪个驱动。但要注意'a'到'z'这一段在社区里被大量驱动占用,内核文档里专门有一张推荐分配表,动手前应该去翻一眼,别和常见的子系统撞。
提示:选魔数时优先挑大写字母、数字或标点,小写字母区段的历史占用最密集。如果你们的驱动是内部使用的,也建议在代码注释里写明选这个字符的理由,方便后人排查冲突。
2. 四个宏逐个拆:方向位最容易搞反
这一节是重点,也是我review代码时发现错误最集中的地方。很多人能背出"_IOR是读,_IOW是写",但一问"站在谁的角度读",就开始含糊。方向位搞反的后果是:内核里明明用copy_to_user往用户态送数据,头文件里却定义成了_IOW,功能照样跑,因为内核根本不校验方向位,直到某天有人写了个静态检查工具或者跨架构移植,才发现语义全反了。
2.1 _IO:没有数据区,arg 就是值本身
_IO(type, nr)展开后 size 为 0,方向为_IOC_NONE。它的语义是"这条命令不携带数据",典型用途是复位、启动、停止、查询一个布尔状态。
因为没有数据区,第三参数arg的约定就变成了"直接传值",而不是传指针。比如复位命令:
#define DEMO_RESET _IO('k', 0) /* 用户态 */ ioctl(fd, DEMO_RESET, 0); /* 内核态 */ case DEMO_RESET: demo_hw_reset(dev); return 0;有些驱动会用_IO携带一个整数参数,比如设置某个档位,写法是arg = 3。这在技术上可行,因为unsigned long能装下指针也能装下整数,但我不推荐。原因有两个:一是语义不清晰,后来维护的人看到_IO会默认"无参数",容易忽略这个隐藏约定;二是跨 32/64 位时,如果把arg当整数用,宽窄不一致会带来细微差异。真正要传标量,用_IOW配一个定长结构体,或者用_IOW配一个__u32,都更规范。
真正适合_IO的场景其实不少:设备使能、清中断、软复位、触发一次自检、读取一个错误码标志位。这些操作的共同点是幂等或者无参数,用_IO语义干净。
2.2 _IOR 与 _IOW:站在用户态看方向
这是最容易错的地方,我把判断口诀放在前面:方向位描述的是"用户态视角下的数据流向"。
_IOR(type, nr, size)展开后方向位是_IOC_READ(值 2),含义是"用户态要读数据出来",也就是内核把数据拷给用户,内核侧用copy_to_user。名字里的 R 是 read,读的主体是应用程序。
_IOW(type, nr, size)展开后方向位是_IOC_WRITE(值 1),含义是"用户态要写数据进去",也就是内核从用户态取数据,内核侧用copy_from_user。名字里的 W 是 write,写的主体同样是应用程序。
我在内核源码里见过定义和实现相反的案例,追溯下来通常是这么错的:开发者想"这条命令是驱动去设置硬件寄存器,所以是驱动写,用_IOW",这个推理路径是对的,但结论错在主语。驱动去设置硬件,数据流向是用户态 -> 内核,站在用户视角就是"我写给你",所以确实是_IOW,方向是_IOC_WRITE。反过来"驱动读取硬件状态返回给用户",用户视角是"我从你那里读",用_IOR。你会发现只要主語固定在应用程序,就不会乱。
一个更硬的办法是记内核实现:_IOR一定伴随copy_to_user,_IOW一定伴随copy_from_user。写代码时对一遍,几乎不会错。
2.3 _IOWR:一次调用两个方向
_IOWR的方向位是_IOC_READ | _IOC_WRITE,值为 3。它表示同一次调用里数据既进去又出来,最典型的场景是"查询并修改":用户传一个结构体描述要查什么、带上若干字段,内核填好结果再原路返回。
struct demo_reg_q { __u32 addr; __u32 value; }; #define DEMO_REG_RW _IOWR('k', 3, struct demo_reg_q)内核侧的标准写法是先copy_from_user拿输入,处理,再copy_to_user回填。这里有个细节值得说:回填时建议直接用同一个用户指针整块写回,而不是只写回变化的那几个字段,减少一次指针运算和被中断打断的窗口。
用_IOWR要注意两个误区。第一,它不是"既可以读也可以写",而是一次调用里两个方向都有,所以数据结构必须能同时表达请求和响应。如果你发现某个_IOWR命令其实只往里传数据,那说明定义错了,应该降级成_IOW。第二,_IOWR的 size 是双向共用的,所以输入输出字段必须放进同一个结构体,这会让结构体随功能演进不断膨胀,超过 16383 就得分拆。
注意:
_IOWR和两个独立命令(一个_IOW加一个_IOR)之间不是等价关系。合并成一个_IOWR能减少一次系统调用往返,但也带来"部分成功"的语义问题——内核已经改了硬件但回填用户态失败,此时返回EFAULT,用户态并不知道硬件到底是什么状态。对时序敏感的设备,这个坑要提前想好怎么兜。
2.4 sizeof 里到底该填什么
这是第二个高频错误点。_IOR/_IOW/_IOWR的第三个参数,填的是类型名,不是变量名,也不是指针类型。
正确写法:
#define DEMO_SET_CFG _IOW('k', 1, struct demo_cfg) #define DEMO_GET_STAT _IOR('k', 2, struct demo_stat)错误写法有三种,都很常见:
/* 错:填了指针类型,得到的是指针宽度 8,不是结构体大小 */ #define DEMO_SET_CFG _IOW('k', 1, struct demo_cfg *) /* 错:填了变量名,宏里 sizeof(x) 能编译但语义混乱,且该变量必须可见 */ #define DEMO_SET_CFG _IOW('k', 1, g_cfg) /* 错:填了具体字节数,后续改结构体忘了同步,命令字悄悄变了 */ #define DEMO_SET_CFG _IOW('k', 1, 32)指针那一条最阴。sizeof(struct demo_cfg *)在 64 位下是 8,于是 size 字段写 8,而内核实际用sizeof(struct demo_cfg)做copy_from_user,长度对不上,轻则拷贝截断,重则内核侧觉得参数不合法返回EINVAL。更麻烦的是同一个头文件在 32 位下编译出来 size 也是 4 还是 8 取决于指针宽度,命令字会随目标架构漂移,形成一条永远只在一个平台上出现的 bug。
内核对这个问题做了编译期防护。在_IOC_TYPECHECK展开里,会检查sizeof(t) == sizeof(t[1])且sizeof(t)小于 16384,不满足就引用一个不存在的符号__invalid_size_argument_for_IOC。传指针类型时,sizeof(t)是指针宽度,sizeof(t[1])是结构体宽度,两者不等,立刻报错。所以内核驱动代码里写错,编译就过不去。但用户态头文件走的是非内核分支,只有朴素的sizeof(t),没有这层保护,所以应用侧的头文件更要自己盯紧。
3. 手写一套命令字:从 uapi 头文件到内核分发
理论说完了,来一套完整的、可以直接复用的流程。我以"某嵌入式项目里的一块数据采集外设"为例,功能包括设置采集参数、读状态、读写一个通用寄存器、软复位。这套结构在我手上几个项目里都用过,改动很少。
3.1 uapi 头文件怎么设计
头文件只有一个,放在include/uapi/linux/下,内核和用户态共用同一份,这是避免"两边定义不一致"最有效的手段。装到系统后用户态直接#include <linux/demo_ioctl.h>。
#ifndef _UAPI_LINUX_DEMO_IOCTL_H #define _UAPI_LINUX_DEMO_IOCTL_H #include <linux/types.h> #include <linux/ioctl.h> /* 选 'k' 作为魔数,注释里说明用途,避免后人重复占用 */ #define DEMO_IOC_MAGIC 'k' struct demo_cfg { __u32 sample_rate; __u32 channels; __u32 gain; __u32 flags; }; struct demo_stat { __u32 hw_version; __u32 temperature; __u32 error_code; __u32 run_state; }; struct demo_reg_q { __u32 addr; __u32 value; }; #define DEMO_RESET _IO(DEMO_IOC_MAGIC, 0) #define DEMO_SET_CFG _IOW(DEMO_IOC_MAGIC, 1, struct demo_cfg) #define DEMO_GET_STAT _IOR(DEMO_IOC_MAGIC, 2, struct demo_stat) #define DEMO_REG_RW _IOWR(DEMO_IOC_MAGIC, 3, struct demo_reg_q) #define DEMO_IOC_MAXNR 3 #endif几个设计点值得展开说。第一,所有结构体字段都用定长类型__u32、__u64,绝不用int、long、unsigned long。这是跨 32/64 位兼容的根本,后面第 4 节会专门讲为什么。第二,结构体里不放任何指针。内核和用户态是两个地址空间,用户态指针进了内核就是垃圾,必须用索引或固定长度数组替代。第三,DEMO_IOC_MAXNR显式定义,内核入口用它做上界检查,比在每个 case 里判断更省事。
还有个小技巧:如果结构体里真的有变长数据,比如一段配置字符串,常见的做法是定义一个头部结构体加一个__u8 data[]柔性数组,然后 size 填头部大小,实际长度通过头部某个字段传达。但这样内核侧就要自己算出总长度去copy_from_user,容易出越界,我个人的偏好是宁可拆成两次 ioctl,第一次传长度,第二次传数据。
3.2 内核侧的分发骨架
file_operations里现在只有unlocked_ioctl(老内核还有ioctl,这个字段早就废弃了,新代码不要用)。原型是:
static long demo_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)注意返回值是long,返回 0 表示成功,返回负的 errno 表示失败。用户态拿到的永远是 0 或 -1,真正的错误码在errno里。这套映射由系统调用层自动完成,驱动只要返回正确的负值即可。
分发骨架我一般这么写:
static long demo_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct demo_dev *dev = filp->private_data; void __user *uarg = (void __user *)arg; if (_IOC_TYPE(cmd) != DEMO_IOC_MAGIC) return -ENOTTY; if (_IOC_NR(cmd) > DEMO_IOC_MAXNR) return -ENOTTY; switch (cmd) { case DEMO_RESET: if (mutex_lock_interruptible(&dev->lock)) return -ERESTARTSYS; demo_hw_reset(dev); mutex_unlock(&dev->lock); return 0; case DEMO_SET_CFG: { struct demo_cfg cfg; if (_IOC_SIZE(cmd) != sizeof(cfg)) return -EINVAL; if (copy_from_user(&cfg, uarg, sizeof(cfg))) return -EFAULT; if (cfg.sample_rate == 0 || cfg.channels > DEMO_MAX_CH) return -EINVAL; return demo_apply_cfg(dev, &cfg); } case DEMO_GET_STAT: { struct demo_stat st = { 0 }; if (_IOC_SIZE(cmd) != sizeof(st)) return -EINVAL; demo_fill_stat(dev, &st); if (copy_to_user(uarg, &st, sizeof(st))) return -EFAULT; return 0; } case DEMO_REG_RW: { struct demo_reg_q q; int ret; if (_IOC_SIZE(cmd) != sizeof(q)) return -EINVAL; if (copy_from_user(&q, uarg, sizeof(q))) return -EFAULT; ret = demo_reg_access(dev, &q); if (ret) return ret; if (copy_to_user(uarg, &q, sizeof(q))) return -EFAULT; return 0; } default: return -ENOTTY; } }这段代码里有几个习惯,我建议保留。_IOC_TYPE和_IOC_NR的双重校验放在最前面,这是在 switch 之前就挡掉绝大多数非法命令,比让它们落进default更早暴露问题。每个带数据区的 case 里都对比_IOC_SIZE(cmd)和本地结构体大小,这能抓出"用户态用了旧头文件"这种情况——用户态编译时结构体是小版本,命令字里记的是旧尺寸,内核一看尺寸不匹配直接EINVAL,比盲目拷贝安全得多。共享资源一律加锁,且用mutex_lock_interruptible而不是mutex_lock,允许被信号打断。
提示:
ENOTTY是"这条命令我不认识",EINVAL是"命令我认识但参数不对",EFAULT是"我访问用户内存失败"。这三者在排查时含义完全不同,别图省事全返回EINVAL,会给应用侧定位问题增加成本。
3.3 用户态调用与错误处理
用户态这边,ioctl第二参数传命令字,第三参数类型是可变参数,传指针的原样传指针就行。
#include <linux/demo_ioctl.h> #include <sys/ioctl.h> int demo_set_cfg(int fd, const struct demo_cfg *cfg) { if (ioctl(fd, DEMO_SET_CFG, (void *)cfg) < 0) { switch (errno) { case ENOTTY: fprintf(stderr, "device does not support this cmd\n"); break; case EINVAL: fprintf(stderr, "invalid parameter\n"); break; case EFAULT: fprintf(stderr, "bad user pointer\n"); break; case EBUSY: fprintf(stderr, "device busy\n"); break; default: perror("ioctl"); break; } return -1; } return 0; }有一个特别容易忽略的点:ioctl成功时返回值不一定是 0。如果驱动在某条命令里return 某个正数,用户态会拿到这个正数,而errno保持原值不变。所以我判成功一律用< 0判负,绝不用== 0。反过来,作为驱动作者,除非有强烈理由,不要返回正数,保持"0 成功 / 负值失败"的惯例,应用侧写起来最省心。
再看_IO那条命令的调用,第三参数直接给 0:
if (ioctl(fd, DEMO_RESET, 0) < 0) { perror("reset"); }3.4 手算一遍命令字,再用脚本反解
会算是不慌的前提。拿DEMO_SET_CFG举例,struct demo_cfg里有四个__u32,大小 16 字节。魔数'k'的 ASCII 是0x6b,序号 1,方向写。
dir = 1 -> 1 << 30 = 0x40000000 type = 0x6b -> 0x6b << 8 = 0x00006b00 nr = 1 -> 1 = 0x00000001 size = 16 (0x10) -> 0x10 << 16 = 0x00100000 cmd = 0x40000000 | 0x00100000 | 0x00006b00 | 0x00000001 = 0x40106b01再算DEMO_GET_STAT,struct demo_stat也是 16 字节,方向读(值 2):
dir = 2 -> 2 << 30 = 0x80000000 cmd = 0x80000000 | 0x00100000 | 0x00006b00 | 0x00000002 = 0x80106b02DEMO_REG_RW是 8 字节、方向 3、序号 3:
dir = 3 -> 0xC0000000 size = 8 -> 0x00080000 cmd = 0xC0000000 | 0x00080000 | 0x00006b00 | 0x00000003 = 0xC0086b03手算的意义在于:当你在日志或 strace 里看到0x40106b01,能立刻反应出"这是给魔数 k 的驱动、序号 1、带 16 字节的写命令"。反过来,反解脚本在排查时更实用:
#!/usr/bin/env python3 import sys DIR_NAME = {0: "_IO", 1: "_IOW", 2: "_IOR", 3: "_IOWR"} def decode(cmd): cmd &= 0xFFFFFFFF d = (cmd >> 30) & 0x3 size = (cmd >> 16) & 0x3FFF typ = (cmd >> 8) & 0xFF nr = cmd & 0xFF ch = chr(typ) if 32 <= typ < 127 else "." print(f"cmd = 0x{cmd:08X}") print(f" dir = {DIR_NAME[d]} ({d})") print(f" type = 0x{typ:02X} '{ch}'") print(f" nr = {nr}") print(f" size = {size}") if __name__ == "__main__": for a in sys.argv[1:]: decode(int(a, 0))用起来就是python3 iocdec.py 0x40106b01 0x80106b02 0xC0086b03,输出一目了然。我一般会把这个脚本丢到项目的tools/目录里,配合命令字清单一起提交,接手的人省事。
4. 排查实录:那些让人抓狂的返回值
下面这几类问题,是我这些年处理 ioctl 相关故障时反复遇到的。它们共同的特点是:代码看着没错,编译也没错,就是行为不对,而且往往在特定平台上才复现。
4.1 ENOTTY、EINVAL、EFAULT 的边界
先说ENOTTY。这个名字来自 tty,但现在是通用的"不支持的 ioctl 命令"。用户态看到它,先怀疑三件事:命令字算错了(尤其是 size 填错导致整个数值变了)、魔数不匹配、驱动版本比头文件旧。
命令字算错是最常见的。比如用户态拿的是新头文件,struct demo_cfg加了两个字段变成 24 字节,内核还是老的 16 字节版本,那_IOC_SIZE(cmd)校验立刻返回EINVAL而不是ENOTTY。这两者的区别就在这里:尺寸不匹配走EINVAL,因为_IOC_TYPE是对得上的。
EFAULT几乎总是指针问题。常见的三种:用户态传了 NULL、传了一个未映射的地址、传了一个只读地址但内核要往里写。还有一种隐蔽情况,_IOWR里用户传的是const结构体,编译器不拦,但内核回填时写只读页会触发EFAULT。我遇到过一回,追了半天发现是调用方把一个static const配置结构体直接传进去了。
还有一种不是错误码能直接看出来的:驱动返回 0,但数据没变。这通常是copy_to_user之前就return 0了,或者字段赋值漏了。用 strace 看不到内容,只能靠在内核里加pr_debug打点。
4.2 32 位程序跑在 64 位内核上的命令字漂移
这是最阴的一类。核心原因只有一个:命令字里嵌了 size,而 size 在 32 位和 64 位下可能不同。
只要结构体里出现了long、unsigned long、size_t、指针,它在两种位宽下的 sizeof 就不一样,命令字数值跟着变。用户态的 32 位程序按 32 位算出来是0x40086b01,内核按 64 位算出来是0x40106b01,两者 switch 对不上,直接ENOTTY。
解决办法有三层,从根上治的是第一层:结构体里全部用定长类型,把long换成__u32或__u64,把指针换成__u64或索引。这样两种位宽下 sizeof 完全一致,命令字天然相同,连compat_ioctl都省了。这是我现在所有新代码的默认做法。
第二层是显式提供.compat_ioctl,在 64 位内核里处理 32 位程序的调用。如果第一层做得干净,直接compat_ptr_ioctl透传即可:
static const struct file_operations demo_fops = { .owner = THIS_MODULE, .unlocked_ioctl = demo_ioctl, .compat_ioctl = compat_ptr_ioctl, };第三层是结构体里不可避免存在异构字段时,写一个真正的转换函数,把 32 位布局的结构体转成内核内部表示。这一层工作量大且容易出错,能靠第一层规避就别走这条路。
注意:命令字本身也不是跨架构 ABI 稳定的。部分架构有自己的
ioctl.h,位布局和通用版本不同。所以用户态程序不要硬编码命令字数值,一定要#include同一份头文件让宏来算。这是"一次编写,到处运行"的前提。
4.3 魔数冲突与命令字复用
8 位魔数只有 256 个取值,社区驱动又特别爱用小写字母,冲突概率不低。冲突的表现很微妙:两个驱动分别注册了不同的字符设备,用户态拿错 fd,命令一路发下去,对方的驱动因为_IOC_TYPE恰好相同,会把它当成自己的命令处理,可能返回成功但什么也没做,也可能误操作硬件。
预防手段有三条。一是选魔数前查一下内核文档里的分配表,避开高频区段。二是在头文件里加注释说明用途和选取理由。三是在内核入口处同时校验_IOC_TYPE和_IOC_NR上界,把"类型撞车但序号越界"这种情况也挡掉。
命令字复用指的是另一个问题:某条命令废弃了,开发者图省事把序号让给新命令用。这会让新旧用户态程序同时跑时互相干扰,老程序以为在调老功能,实际执行了新逻辑。正确做法是废弃的序号永久保留,加一行注释/* nr 3: reserved, do not reuse */,新命令用新序号。序号只有 255 个,但一个驱动的生命周期里往往用不完,保留是对的。
4.4 结构体演进与向后兼容
设备驱动一旦发布,用户态程序就会长期存在,这是现实。所以头文件里的结构体只能加不能改,而且要加得讲究。
加字段只有两种安全方式。第一种是在结构体末尾追加,且追加的字段在旧调用方那里是未初始化的垃圾,内核侧必须容忍。这就要求新字段的语义是"可选",比如一个 flags 位。第二种是预留填充字段,一开始就把结构体定义成__u32 reserved[4],将来从中取用。
绝对不要做的事:调整字段顺序、改变字段类型宽度、从中间插入字段。这三种都会让旧程序的命令字 size 与新内核不一致,直接被_IOC_SIZE校验挡掉,表现为升级内核后老程序全挂。
如果必须做不兼容变更,标准做法是换一个新序号,新旧并存一段时间,内核里两条命令走同一套逻辑,新命令做完整校验,老命令做兼容转换。等所有用户态都升级完,老命令再标记废弃。
5. 工程化落地:把命令字管起来
单个驱动写的时候怎么都好说,一旦项目里十几个模块都用 ioctl,没有统一管理就会乱。这一节讲我实际操作中怎么把这件事管住,都是些不复杂但很省事的做法。
5.1 一份命令字清单是必须的
每个用到 ioctl 的模块,我要求维护一份表格,放在头文件注释里或者模块文档里,内容包括命令名、宏、序号、方向、size、结构体名、用途说明。评审的时候对着表看,一眼就能发现序号重复、方向写反、size 和结构体不匹配。
| 命令名 | 宏 | 序号 | 方向 | 数据大小 | 结构体 | 用途 |
|---|---|---|---|---|---|---|
| DEMO_RESET | _IO | 0 | 无 | 0 | 无 | 软复位 |
| DEMO_SET_CFG | _IOW | 1 | 用户写 | 16 | demo_cfg | 设置采集参数 |
| DEMO_GET_STAT | _IOR | 2 | 用户读 | 16 | demo_stat | 读取运行状态 |
| DEMO_REG_RW | _IOWR | 3 | 双向 | 8 | demo_reg_q | 寄存器读写 |
表格旁边再放一段自检清单,我每次提交前会过一遍:结构体里有没有long或指针;size 有没有超过 16383;魔数有没有和其它模块撞;方向位和内核里的copy_*_user是否一致;序号有没有复用;_IOC_MAXNR有没有同步更新。这六条挡掉了绝大多数低级错误。
5.2 调试时该看什么
用户态出问题,第一件事是 strace。strace -e trace=ioctl ./app能把每次调用的 fd、命令字数值、第三参数地址都打出来。拿到数值先用 3.4 的脚本反解,看方向、size、魔数、序号对不对,如果 size 和你预期的结构体大小对不上,问题基本就定位了。
内核侧,我习惯在入口加一行条件打印:
pr_debug("demo ioctl cmd=0x%08x nr=%u size=%u\n", cmd, _IOC_NR(cmd), _IOC_SIZE(cmd));配合动态调试打开,只在排查时输出,不污染正式日志。如果怀疑是copy_from_user失败,在返回EFAULT的地方加一次性打印;如果怀疑命令没进来,先确认_IOC_TYPE校验是否通过,很多时候是因为魔数写错了一位。
还有一个专门的场景:用LD_PRELOAD拦截ioctl打印真实请求参数。这在排查第三方库行为时特别好用,不用改它的代码。
5.3 代码评审时我会盯的几个点
最后分享几条经验性的关注点,都是从实际事故里总结的,条款化了一点,但确实管用。
看到_IOW就去核对内核实现是不是copy_from_user,看到_IOR就核对是不是copy_to_user。方向位不影响功能,但影响可维护性和工具检查,属于"现在不改,将来必改"的那类。
看到结构体里有long、unsigned long、size_t、指针,直接打回。这不是风格问题,是 32/64 兼容的硬伤,等到真的要支持 32 位用户态才发现,改动面会很大。
看到 switch 里没有_IOC_SIZE校验的 case,问一句为什么。有些 case 确实不需要,比如_IO没有数据区,但带数据区的 case 缺这个校验,就意味着内核完全信任用户态传的指针长度,一旦用户态传的长度小于内核预期,就是一次信息泄露。
看到copy_from_user之后没有对关键字段做范围校验,问一句。用户态传进来的数不可信,channels是 0 会除零,count是0xffffffff会溢出,这些都是经典问题。
看到用mutex_lock而不是mutex_lock_interruptible,问一句能不能被打断。用户态进程可能在你等锁的时候被 SIGKILL,不可中断的等待会让它挂在那里直到拿到锁,体验很差。
这套习惯我用了几年,基本上 ioctl 相关的线上问题能压下去一大半。剩下的都是真问题——硬件行为、时序竞争、协议理解偏差,那些靠的是另一套功夫了。