本文以虚拟设备
vsim为例,结合cdev、file_operations、open流程,把 ioctl 从用户空间到内核空间的完整链路讲清楚。适合驱动入门和面试复习。
一、为什么需要 ioctl
read/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 -> 找到 cdev,filp->f_op = cdev->ops(也就是 vsim_fops) -> 调用 vsim_fops.openopen之后,filp->f_op已经指向vsim_fops。
用户调用:
ioctl(fd, cmd, arg);内核路径是:
sys_ioctl -> VFS:fd -> 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 里怎么挂 ioctl
static 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位 | | +------+---------+--------------------+-------------+-------------+| 段 | 位数 | 宏 | 含义 |
|---|---|---|---|
| dir | 2 | _IOC_DIR | 方向 |
| size | 14 | _IOC_SIZE | 参数大小 |
| type | 8 | _IOC_TYPE | magic |
| nr | 8 | _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 再 to |
4.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 的作用:同一个驱动内部,区分不同操作。
三层分工:
| 层 | 靠什么区分 |
|---|---|
| 哪个驱动 | fd(open 时已绑定 f_op) |
| 驱动内哪个操作 | cmd 的 nr |
| 数据怎么传 | cmd 的 dir + arg 指针 + copy_*_user |
六、用户传的®,内核收到的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, ®);传的是®,本质是用户空间结构体的地址。
内核收到的arg,就是这个地址的数值,类型是unsigned long。
| 用户空间 | 内核空间 | |
|---|---|---|
| 写法 | ® | arg |
| 本质 | 用户地址空间里的一个地址 | 一个整数,值等于那个地址 |
| 类型 | struct vsim_reg_arg * | unsigned long |
| 能否直接访问内容 | 能,reg.value | 不能,arg只是个数字 |
arg不是结构体,arg是"结构体在用户空间的地址"。
6.3 一张图说清
用户空间 内核空间 +-------------------------+ | struct vsim_reg_arg reg | | reg = 0 | | value = 0x12345678 | +-------------------------+ ^ | ® = 0x7fff1234 | | ioctl(fd, cmd, ®) 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(®_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_ioctl
static 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(®_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, ®_arg, sizeof(reg_arg))) { ret = -EFAULT; break; } break; case VSIM_IOC_WRITE_REG: if (copy_from_user(®_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, ®) < 0) { perror("WRITE_REG"); close(fd); return 1; } reg.value = 0; if (ioctl(fd, VSIM_IOC_READ_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)| 参数 | 含义 |
|---|---|
filp | open 时创建的文件对象,含private_data |
cmd | 命令码,打包了 magic、nr、size、dir |
arg | 用户传的第三个参数,本质是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 / nr
if (_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 用可中断版本,用户按 Ctrl+C 能退出。被打断返回-ERESTARTSYS,VFS 会转成用户可见的-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_ioctl,open 时已绑定 |
| 为什么不用 read/write? | read/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 区分命令 |
用户传的®和内核arg什么关系? | arg是用户结构体地址的数值,类型unsigned long |
| 用户指针怎么处理? | 必须copy_from_user/copy_to_user |
| arg 什么时候是整数? | _IO时可直接(unsigned int)arg |
| 返回值规则? | 成功 0,失败负 errno |
为什么用mutex_lock_interruptible? | 可被信号打断,用户可 Ctrl+C 退出 |
| 和 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/下的字符设备驱动