从零搞懂 Linux 字符设备驱动中的 ioctl:原理、实现与面试要点 本文以虚拟设备vsim为例结合cdev、file_operations、open流程把 ioctl 从用户空间到内核空间的完整链路讲清楚。适合驱动入门和面试复习。一、为什么需要 ioctlread/write只能搬运数据流无法表达控制语义。比如一个设备要复位设置工作模式读写某个寄存器获取设备状态启动/停止采集这些都不是读写数据流而是控制命令。Linux 提供了ioctl作为字符设备的控制通道。一句话定位ioctl是字符设备驱动里除read/write之外的控制通道。二、ioctl 在整个链路里的位置回顾字符设备的 open 流程用户 open(/dev/vsim) - VFS 路径解析dentry - inode - inode 是字符设备i_rdev major:minor - inode-i_fop def_chr_fops - chrdev_open 用 i_rdev 查 cdev_map - 找到 cdevfilp-f_op cdev-ops也就是 vsim_fops - 调用 vsim_fops.openopen之后filp-f_op已经指向vsim_fops。用户调用ioctl(fd, cmd, arg);内核路径是sys_ioctl - VFSfd - struct file *filp - filp-f_op-unlocked_ioctl(filp, cmd, arg) - vsim_ioctl关键点ioctl 不需要再查 cdev_map因为 open 时已经通过chrdev_open把filp-f_op换成了vsim_fops。后面read/write/ioctl都直接走filp-f_op。三、file_operations 里怎么挂 ioctlstatic const struct file_operations vsim_fops { .owner THIS_MODULE, .open vsim_open, .release vsim_release, .read vsim_read, .write vsim_write, .unlocked_ioctl vsim_ioctl, .llseek no_llseek, };然后cdev_init(dev-cdev, vsim_fops); cdev_add(dev-cdev, devno, 1);这样设备号 → cdev → vsim_fops 就绑好了。用户 open 后ioctl 就会进vsim_ioctl。注意unlocked_ioctl是现在的接口老接口是ioctl。unlocked意思是不再持有大内核锁 BKL并发保护要驱动自己做。四、cmd 到底是什么4.1 cmd 是一个 32 位整数cmd不是结构体就是一个unsigned int按位分成四段31 30 29 16 15 8 7 0 ------------------------------------------------------------- | dir | size | type | nr | | | 2位 | 14位 | 8位 | 8位 | | -------------------------------------------------------------段位数宏含义dir2_IOC_DIR方向size14_IOC_SIZE参数大小type8_IOC_TYPEmagicnr8_IOC_NR序号4.2 命令宏#define _IO(type, nr) _IOC(_IOC_NONE, type, nr, 0) #define _IOR(type, nr, size) _IOC(_IOC_READ, type, nr, sizeof(size)) #define _IOW(type, nr, size) _IOC(_IOC_WRITE, type, nr, sizeof(size)) #define _IOWR(type, nr, size) _IOC(_IOC_READ | _IOC_WRITE, type, nr, sizeof(size))宏方向含义_IO无数据纯命令_IOR内核 → 用户用户要读数据_IOW用户 → 内核用户要写数据_IOWR双向先 from 再 to4.3 dir 有四种不是两种_IOC_DIR取出来是 2 位所以有 4 种取值值宏含义0_IOC_NONE无数据1_IOC_WRITE用户 → 内核2_IOC_READ内核 → 用户3_IOC_READ | _IOC_WRITE双向关键理解_IOC_READ/_IOC_WRITE是站在用户空间角度定义的。_IOR用户要读 → 内核copy_to_user把数据给用户_IOW用户要写 → 内核copy_from_user从用户拿数据dir 是位掩码判断方向时要用而不是因为_IOWR两个位都置位。五、magic 和 nr 检查在干什么重要纠正检查 magic 和 nr不是 VFS 干的是驱动自己干的。VFS 只负责把 cmd 原样传下来。5.1 magic 检查#define VSIM_IOC_MAGIC v if (_IOC_TYPE(cmd) ! VSIM_IOC_MAGIC) return -ENOTTY;v的 ASCII 是 0x76。只要 cmd 的 type 段不等于 0x76就说明这不是给我的命令。为什么需要 magic防止用户写错 fd把别的驱动的命令传进来误执行。注意magic 是防御性检查不是路由。路由靠 fd。区分哪个驱动靠的是 open 时绑定的filp-f_op不是 cmd。5.2 nr 检查if (_IOC_NR(cmd) VSIM_IOC_MAXNR) return -ENOTTY;_IOC_NR(cmd)取出 8 位的序号范围 0~255。nr 的作用同一个驱动内部区分不同操作。三层分工层靠什么区分哪个驱动fdopen 时已绑定 f_op驱动内哪个操作cmd 的 nr数据怎么传cmd 的 dir arg 指针 copy_*_user六、用户传的reg内核收到的arg这是最容易困惑的地方。6.1 两边函数原型不同用户空间int ioctl(int fd, unsigned long request, ...);第三个参数是变参可以传整数、传指针。内核空间long (*unlocked_ioctl)(struct file *filp, unsigned int cmd, unsigned long arg);第三个参数固定是unsigned long。6.2 关键差异用户调用struct vsim_reg_arg reg; reg.reg 0; reg.value 0x12345678; ioctl(fd, VSIM_IOC_WRITE_REG, reg);传的是reg本质是用户空间结构体的地址。内核收到的arg就是这个地址的数值类型是unsigned long。用户空间内核空间写法regarg本质用户地址空间里的一个地址一个整数值等于那个地址类型struct vsim_reg_arg *unsigned long能否直接访问内容能reg.value不能arg只是个数字arg不是结构体arg是结构体在用户空间的地址。6.3 一张图说清用户空间 内核空间 ------------------------- | struct vsim_reg_arg reg | | reg 0 | | value 0x12345678 | ------------------------- ^ | reg 0x7fff1234 | | ioctl(fd, cmd, reg) v ------------------ | unsigned long arg| | 0x7fff1234 | ------------------ | | copy_from_user v ------------------------- | struct vsim_reg_arg reg_arg | | reg 0 | | value 0x12345678 | -------------------------6.4 为什么不能直接解引用 arg错误写法struct vsim_reg_arg *p (struct vsim_reg_arg *)arg; dev-regs[p-reg] p-value; // 错误原因arg是用户地址可能非法直接解引用会 oops。用户内存可能被换出内核态缺页处理很危险。有些架构内核根本访问不了用户地址。安全隔离要求用copy_*_user。规范做法struct vsim_reg_arg reg_arg; if (copy_from_user(reg_arg, (void __user *)arg, sizeof(reg_arg))) return -EFAULT;七、copy_from_user / copy_to_user函数方向用在copy_from_user(dst_kernel, src_user, n)用户 → 内核_IOW、_IOWR的输入部分copy_to_user(dst_user, src_kernel, n)内核 → 用户_IOR、_IOWR的输出部分返回值成功返回 0失败返回未拷贝的字节数非 0。所以判断写成if (copy_from_user(...))。__user是什么给编译器看的注解表示这是用户空间指针不是类型。八、完整实现8.1 共享头文件 vsim_ioctl.h#ifndef VSIM_IOCTL_H #define VSIM_IOCTL_H #include linux/ioctl.h #define VSIM_IOC_MAGIC v struct vsim_reg_arg { unsigned int reg; unsigned int value; }; #define VSIM_IOC_RESET _IO(VSIM_IOC_MAGIC, 0) #define VSIM_IOC_SET_MODE _IOW(VSIM_IOC_MAGIC, 1, unsigned int) #define VSIM_IOC_READ_REG _IOWR(VSIM_IOC_MAGIC, 2, struct vsim_reg_arg) #define VSIM_IOC_WRITE_REG _IOW(VSIM_IOC_MAGIC, 3, struct vsim_reg_arg) #define VSIM_IOC_MAXNR 3 #endif8.2 驱动侧 vsim_ioctlstatic long vsim_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { struct vsim_dev *dev filp-private_data; struct vsim_reg_arg reg_arg; unsigned int mode; int ret 0; /* 1. 检查 magic 和 nr */ if (_IOC_TYPE(cmd) ! VSIM_IOC_MAGIC) return -ENOTTY; if (_IOC_NR(cmd) VSIM_IOC_MAXNR) return -ENOTTY; /* 2. 检查用户指针 */ if (_IOC_DIR(cmd) _IOC_READ) { if (!access_ok((void __user *)arg, _IOC_SIZE(cmd))) return -EFAULT; } if (_IOC_DIR(cmd) _IOC_WRITE) { if (!access_ok((void __user *)arg, _IOC_SIZE(cmd))) return -EFAULT; } /* 3. 加锁 */ if (mutex_lock_interruptible(dev-lock)) return -ERESTARTSYS; /* 4. 分发命令 */ switch (cmd) { case VSIM_IOC_RESET: dev-mode 0; dev-busy 0; break; case VSIM_IOC_SET_MODE: if (copy_from_user(mode, (void __user *)arg, sizeof(mode))) { ret -EFAULT; break; } if (mode VSIM_MODE_MAX) { ret -EINVAL; break; } dev-mode mode; break; case VSIM_IOC_READ_REG: if (copy_from_user(reg_arg, (void __user *)arg, sizeof(reg_arg))) { ret -EFAULT; break; } if (reg_arg.reg VSIM_REG_NR) { ret -EINVAL; break; } reg_arg.value dev-regs[reg_arg.reg]; if (copy_to_user((void __user *)arg, reg_arg, sizeof(reg_arg))) { ret -EFAULT; break; } break; case VSIM_IOC_WRITE_REG: if (copy_from_user(reg_arg, (void __user *)arg, sizeof(reg_arg))) { ret -EFAULT; break; } if (reg_arg.reg VSIM_REG_NR) { ret -EINVAL; break; } dev-regs[reg_arg.reg] reg_arg.value; break; default: ret -ENOTTY; break; } mutex_unlock(dev-lock); return ret; }8.3 用户侧验证#include stdio.h #include fcntl.h #include unistd.h #include sys/ioctl.h #include vsim_ioctl.h int main(void) { int fd open(/dev/vsim, O_RDWR); if (fd 0) { perror(open); return 1; } unsigned int mode 1; if (ioctl(fd, VSIM_IOC_SET_MODE, mode) 0) { perror(SET_MODE); close(fd); return 1; } struct vsim_reg_arg reg; reg.reg 0; reg.value 0x12345678; if (ioctl(fd, VSIM_IOC_WRITE_REG, reg) 0) { perror(WRITE_REG); close(fd); return 1; } reg.value 0; if (ioctl(fd, VSIM_IOC_READ_REG, reg) 0) { perror(READ_REG); close(fd); return 1; } printf(reg[%u] 0x%x\n, reg.reg, reg.value); close(fd); return 0; }编译gcc -o test_vsim test_vsim.c sudo ./test_vsim九、骨架逐行拆解9.1 函数签名static long vsim_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)参数含义filpopen 时创建的文件对象含private_datacmd命令码打包了 magic、nr、size、dirarg用户传的第三个参数本质是unsigned long返回值必须是负 errno成功返回 0。9.2 取私有数据struct vsim_dev *dev filp-private_data;private_data是struct file里的void *由驱动在 open 时自己存static int vsim_open(struct inode *inode, struct file *filp) { struct vsim_dev *dev container_of(inode-i_cdev, struct vsim_dev, cdev); filp-private_data dev; return 0; }9.3 检查 magic / nrif (_IOC_TYPE(cmd) ! VSIM_IOC_MAGIC) return -ENOTTY; if (_IOC_NR(cmd) VSIM_IOC_MAXNR) return -ENOTTY;-ENOTTY inappropriate ioctl for device表示命令不支持。参数值不合法才用-EINVAL。9.4 检查用户指针if (_IOC_DIR(cmd) _IOC_READ) { if (!access_ok((void __user *)arg, _IOC_SIZE(cmd))) return -EFAULT; } if (_IOC_DIR(cmd) _IOC_WRITE) { if (!access_ok((void __user *)arg, _IOC_SIZE(cmd))) return -EFAULT; }access_ok只做范围检查真正的检查由copy_*_user做。用而不是因为_IOWR两个位都置位。9.5 加锁if (mutex_lock_interruptible(dev-lock)) return -ERESTARTSYS;mutex_lock不可被信号打断mutex_lock_interruptible可被信号打断返回非 0进程上下文的 ioctl 用可中断版本用户按 CtrlC 能退出。被打断返回-ERESTARTSYSVFS 会转成用户可见的-EINTR。9.6 switch 分发每个 case取数据 → 检查参数 → 操作 → 回数据。出错设ret用break而不是return因为后面要mutex_unlock。9.7 统一解锁返回mutex_unlock(dev-lock); return ret;保证所有路径都走到这里锁一定释放。十、常见坑不要直接解引用用户指针*(int *)arg 10; // 错误 copy_to_user((void __user *)arg, val, sizeof(val)); // 正确方向搞反_IOR是内核写回用户_IOW是内核从用户读入。判断方向用不用_IOWR时两个位都置位。返回值用负 errno返回EINVAL是错的要返回-EINVAL。并发保护unlocked_ioctl无 BKL必须自己加锁。32/64 位兼容需要compat_ioctl可用compat_ptr_ioctl。命令码冲突每个驱动的 magic 尽量唯一nr 不要重复。十一、面试高频考点问题答案ioctl 走哪条路径进驱动sys_ioctl - filp-f_op-unlocked_ioctlopen 时已绑定为什么不用 read/writeread/write 只搬数据流控制语义不清晰cmd 怎么组成dir(2) size(14) type(8) nr(8)用_IO/_IOR/_IOW/_IOWR生成dir 有几种4 种_IOC_NONE、_IOC_WRITE、_IOC_READ、_IOC_READ|_IOC_WRITE谁检查 magic 和 nr驱动自己VFS 只透传magic 和 nr 的区别magic 区分驱动nr 区分命令用户传的reg和内核arg什么关系arg是用户结构体地址的数值类型unsigned long用户指针怎么处理必须copy_from_user/copy_to_userarg 什么时候是整数_IO时可直接(unsigned int)arg返回值规则成功 0失败负 errno为什么用mutex_lock_interruptible可被信号打断用户可 CtrlC 退出和 cdev 的关系cdev-ops就是file_operations里面挂unlocked_ioctl和 open 的关系open 时chrdev_open把filp-f_op换成vsim_fops十二、总结ioctl 的完整链路用户 ioctl(fd, cmd, arg) - fd 决定进哪个驱动open 时已绑定 filp-f_op - vsim_ioctl(filp, cmd, arg) - 检查 magic是不是给我的 - 检查 nr命令号范围 - 检查 dir 和指针决定 copy 方向 - mutex_lock_interruptible - switch(cmd) 分发 - copy_from_user / copy_to_user - 操作 dev 状态 - mutex_unlock - return 0 或 -errno三层分工哪个驱动 → fd哪个操作 → cmd 的 nr数据怎么传 → cmd 的 dir arg 指针 copy_*_user一句话总结ioctl 是字符设备的控制通道。用户ioctl(fd, cmd, arg)→ VFS 通过 fd 找到filp-f_op→ 调用unlocked_ioctl→ 驱动检查 magic/nr/指针 →switch分发 →copy_*_user传数据 → 返回 0 或负 errno。cmd是 32 位整数按位分段arg是用户指针的数值不能直接解引用。参考宋宝华《Linux设备驱动开发详解》第 6.3.5 节内核源码include/uapi/asm-generic/ioctl.h内核源码drivers/char/下的字符设备驱动