4. ioctl 调试命令:自定义命令码、用户态与内核态交互、参数校验
ioctl 是驱动开发里最常用的“后门”。
为什么这么说?因为 read/write 只能传数据,而 ioctl 可以传“命令”。你想让设备复位、改配置、查状态,都得靠它。但 ioctl 也是 bug 重灾区——命令码冲突、参数校验漏了、用户态传了个野指针进来,驱动直接就崩了。
我个人习惯,调试 ioctl 时先看三样东西:命令码对不对、参数能不能访问、返回值有没有被截断。下面我们一个一个说。
4.1 自定义 ioctl 命令码:别乱写,有规矩
很多新手写 ioctl 命令码,直接写个 0x01、0x02 完事。这在产品里是埋雷。Linux 内核规定了命令码的编码格式,你想想看,如果两个驱动用了同一个 magic number,调用时就会串到别人的 handler 里去。
标准命令码分 4 个字段:
| 位域 | 宽度 | 含义 |
|---|---|---|
| type (magic number) | 8 bit | 设备类标识,选一个不冲突的字母/数字 |
| number (序数) | 8 bit | 同一设备下的命令编号,从 0 开始 |
| direction | 2 bit | 数据流向:_IOC_NONE、_IOC_READ、_IOC_WRITE、_IOC_READ|_IOC_WRITE |
| size | 14 bit | 参数结构体大小(内核会帮你做边界检查) |
内核提供了宏来生成命令码:
// 头文件:<linux/ioctl.h> 或 <asm-generic/ioctl.h>
#define MY_MAGIC 'k'
// 无参数
#define MY_CMD_RESET _IO(MY_MAGIC, 0)
// 读参数(内核→用户)
#define MY_CMD_GET_STAT _IOR(MY_MAGIC, 1, struct my_stat)
// 写参数(用户→内核)
#define MY_CMD_SET_CFG _IOW(MY_MAGIC, 2, struct my_cfg)
// 读写参数
#define MY_CMD_RW_BUF _IOWR(MY_MAGIC, 3, struct my_buf)
我的习惯:magic number 用 ASCII 字符,比如 'k'、'd'、'x',调试时用
cat /proc/devices 或 strace 看命令码一眼就能认出来。别用 0x00,那是 _IO(0, n),容易和默认值撞车。
4.2 用户态与内核态交互:copy_from_user 是底线
用户态传进来的指针,你永远不能直接解引用。我在项目中遇到过一例:驱动里直接 memcpy(dev->buf, arg, size),结果用户态传了个 mmap 出来的地址,内核直接 oops。从那以后,我要求团队所有 ioctl 必须用 copy_from_user / copy_to_user。
标准写法:
static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
struct my_dev *dev = filp->private_data;
struct my_cfg cfg;
int ret = 0;
// 1. 检查命令码合法性
if (_IOC_TYPE(cmd) != MY_MAGIC) {
pr_err("ioctl: bad magic 0x%x\n", cmd);
return -ENOTTY;
}
if (_IOC_NR(cmd) >= MY_CMD_MAX) {
pr_err("ioctl: bad number %d\n", _IOC_NR(cmd));
return -ENOTTY;
}
// 2. 检查参数大小(可选,内核 _IOC_SIZE 已做,但可以二次校验)
if (_IOC_SIZE(cmd) != sizeof(struct my_cfg)) {
pr_err("ioctl: size mismatch %zu vs %zu\n",
_IOC_SIZE(cmd), sizeof(struct my_cfg));
return -EINVAL;
}
// 3. 带方向检查的拷贝
switch (cmd) {
case MY_CMD_SET_CFG:
if (copy_from_user(&cfg, (void __user *)arg, sizeof(cfg)))
return -EFAULT;
// 处理配置...
break;
case MY_CMD_GET_STAT:
// 填充 stat
if (copy_to_user((void __user *)arg, &dev->stat, sizeof(dev->stat)))
return -EFAULT;
break;
default:
return -ENOTTY;
}
return ret;
}
曾经踩过的坑:copy_from_user 返回非 0 表示拷贝失败,但 不要 直接返回 -EFAULT 就完事。我建议加一条 pr_debug 打印出错的地址和长度,因为很多时候是用户态传了 NULL 或者只分配了部分内存。另外,
__user 注解不是摆设——Sparse 工具能帮你检查有没有漏掉拷贝函数。
4.3 ioctl 参数校验:宁可多查,不可少查
参数校验是 ioctl 调试里最容易被忽略的环节。你想想看,用户态传进来的结构体,里面的字段可能是负数、越界值、甚至未初始化的垃圾。驱动一旦信了这些值,轻则功能异常,重则内存越界。
我总结了一套“三层校验法”:
- 命令码层:检查 magic、number、size 是否匹配(上面代码已做)。
- 结构体层:拷贝完成后,检查每个字段的合法性。
- 资源层:检查设备状态是否允许当前操作(比如设备未打开、正在 DMA 等)。
举个例子:
struct my_cfg {
int channel; // 通道号,范围 0-7
int rate; // 采样率,只能是 44100/48000/96000
char mode; // 模式:'A' 或 'B'
};
static int validate_cfg(struct my_cfg *cfg)
{
if (cfg->channel < 0 || cfg->channel > 7) {
pr_err("cfg: invalid channel %d\n", cfg->channel);
return -EINVAL;
}
if (cfg->rate != 44100 && cfg->rate != 48000 && cfg->rate != 96000) {
pr_err("cfg: invalid rate %d\n", cfg->rate);
return -EINVAL;
}
if (cfg->mode != 'A' && cfg->mode != 'B') {
pr_err("cfg: invalid mode %c\n", cfg->mode);
return -EINVAL;
}
return 0;
}
调试技巧:在 validate 函数里加
pr_debug 打印所有字段值。我曾经调试一个音频驱动,用户态传进来的 rate 是 0,驱动直接除零 panic。加上校验后,问题变成了“用户态传错参数”,而不是“驱动崩溃”。
4.4 ioctl 调试命令码与参数的可视化流程
下面这张图是我自己画的一张 ioctl 完整调用链,从用户态 ioctl(fd, cmd, arg) 到内核返回,每一步的校验点都标出来了。调试时对着这张图,能快速定位问题出在哪一层。
4.5 调试 ioctl 的实用命令
调试时我常用这几个工具,按频率排序:
| 工具/方法 | 用法 | 能查出什么 |
|---|---|---|
strace -e ioctl |
strace -e ioctl ./app |
用户态发出的命令码、参数地址、返回值 |
pr_debug / dev_dbg |
驱动内打印 cmd、arg、ret | 内核收到的实际值,与 strace 对比 |
ftrace |
echo function_graph > current_tracer |
ioctl 调用路径、耗时、是否被调度打断 |
addr2line |
配合 oops 中的 PC 地址 | 定位到具体的 ioctl handler 行号 |
一个小技巧:在驱动里加一个
#define DEBUG 或 #define pr_fmt(fmt) KBUILD_MODNAME ": " fmt,然后所有 pr_debug 就变成 pr_info 了。调试完再关掉,不用改代码。
4.6 避坑指南:我踩过的三个 ioctl 坑
- 坑一:命令码 size 不匹配 —— 用户态用
_IOR定义,内核用_IOW定义,size 字段不同,内核直接返回 -ENOTTY。我曾经花了一下午才发现头文件里两个宏写反了。 - 坑二:copy_from_user 后没校验 —— 用户态传了个负数 length,驱动里直接
kmalloc(length),分配了超大内存,系统 OOM。从那以后我所有 size 字段都做上限检查。 - 坑三:并发 ioctl —— 多线程同时调用 ioctl,共享资源没加锁。我建议在 ioctl 入口加 mutex_lock,除非你明确知道某个命令是原子的。
最后提醒一句:ioctl 的返回值是 long,但用户态只认 int。如果你返回一个大于 INT_MAX 的正数,用户态会收到负数。所以返回值要么是 0,要么是负的 errno,别搞花活。