3. 字符设备驱动核心
字符设备是 Linux 驱动开发中最基础、也最能体现“内核与用户态交互”本质的一类设备。它不经过块层缓存,用户态通过设备文件节点直接调用驱动注册的 file_operations 回调,实现字节流的读写与控制。理解字符设备驱动的关键,在于把四个对象串成一条完整链路:设备号标识设备身份,cdev把驱动与设备号绑定并注册进内核,file_operations提供用户态可调用的操作入口,而用户态测试程序则通过 open/read/write/ioctl 验证整条链路是否真正打通。
一、核心原理:四个对象的职责与关系
主次设备号是字符设备的“身份证”。主设备号(major)对应一类驱动,次设备号(minor)对应同一驱动下的不同实例。内核通过 dev_t 类型保存设备号,并用 MKDEV(major, minor) 合成、MAJOR(dev) 与 MINOR(dev) 拆分。设备号可以静态指定,也可以由内核动态分配,后者更推荐,能避免与已有驱动冲突。
struct cdev 是内核对字符设备的抽象,它把设备号与 file_operations 关联起来。驱动加载时,先申请设备号,再初始化 cdev、设置其 owner 与 ops,最后调用 cdev_add() 注册到内核。卸载时顺序相反:cdev_del() 注销,再释放设备号。这个“申请—初始化—注册—注销—释放”的顺序是字符设备驱动的骨架。
file_operations 是用户态系统调用进入内核的入口表。用户态调用 open() 时,内核根据设备节点找到对应 cdev,再调用 fops->open;read()、write()、ioctl() 同理。每个回调的签名、返回值语义、用户空间指针处理方式都不同,尤其是 copy_to_user() 与 copy_from_user() 的使用,是驱动安全性的关键。
二、方法流程:从模块加载到设备可用
一个可用的字符设备驱动,通常按以下步骤实现。第一步,定义 file_operations 结构体并实现回调函数;第二步,在模块初始化函数中申请设备号,推荐使用 alloc_chrdev_region() 动态分配;第三步,初始化并注册 cdev;第四步,用 class_create() 与 device_create() 自动创建 /dev 节点,避免手动 mknod;第五步,在模块退出函数中按相反顺序清理资源。
static int __init mychar_init(void)
{
int ret;
dev_t dev;
ret = alloc_chrdev_region(&dev, 0, 1, "mychar");
if (ret < 0)
return ret;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev, 1);
if (ret < 0) {
unregister_chrdev_region(dev, 1);
return ret;
}
my_class = class_create(THIS_MODULE, "mychar");
device_create(my_class, NULL, dev, NULL, "mychar");
return 0;
}
回调实现中,open 通常用于初始化私有数据结构并保存到 file->private_data;read 负责把内核缓冲区数据拷贝到用户空间;write 负责把用户数据拷贝进内核;ioctl 则根据命令码执行不同控制逻辑。所有涉及用户空间指针的操作,都必须使用 copy_to_user() 或 copy_from_user(),不能直接解引用。
三、案例场景:一个可读写的字符设备
假设实现一个简单的字符设备,内部维护一个 256 字节缓冲区。用户态写入数据后,再读取应得到相同内容;通过 ioctl 可以清空缓冲区或查询当前长度。驱动侧关键代码如下:
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *ppos)
{
if (*ppos >= buf_len)
return 0;
if (count > buf_len - *ppos)
count = buf_len - *ppos;
if (copy_to_user(buf, kbuf + *ppos, count))
return -EFAULT;
*ppos += count;
return count;
}
static long my_ioctl(struct file *filp, unsigned int cmd,
unsigned long arg)
{
switch (cmd) {
case MYCHAR_CLEAR:
buf_len = 0;
break;
case MYCHAR_GET_LEN:
if (put_user(buf_len, (int __user *)arg))
return -EFAULT;
break;
default:
return -ENOTTY;
}
return 0;
}
用户态测试程序通过 open("/dev/mychar", O_RDWR) 打开设备,调用 write() 写入字符串,再 read() 读回并打印,最后用 ioctl() 验证控制命令。测试时可用 dmesg 观察驱动打印,用 ls -l /dev/mychar 确认主次设备号是否正确。
四、误区与复盘
| 常见误区 | 现象 | 正确做法 |
|---|---|---|
| 直接解引用用户指针 | 内核 oops 或数据错乱 | 使用 copy_to_user / copy_from_user |
| 忘记设置 cdev.owner | 模块引用计数异常,无法卸载 | my_cdev.owner = THIS_MODULE |
| 设备号申请后未释放 | 重复加载失败,资源泄漏 | 退出时 unregister_chrdev_region |
| ioctl 命令码随意定义 | 与内核冲突或参数方向错误 | 使用 _IO/_IOR/_IOW 宏规范定义 |
| read 未更新 *ppos | 反复读取同一段数据 | 按实际拷贝长度推进文件偏移 |
insmod 后 /dev 节点自动出现,再用 strace 跟踪用户态系统调用返回值。若 open 返回 -ENODEV,优先检查 cdev 是否注册成功以及设备号是否匹配。count、arg、指针都必须做合法性检查,返回值统一使用负 errno。掌握字符设备驱动的核心,不在于记住多少 API,而在于理解“设备号—cdev—file_operations—用户态调用”这条链路的每一环如何衔接。当你能独立完成一个带 ioctl 控制的字符设备,并用用户态程序稳定验证读写与控制命令时,就已经具备了继续学习更复杂驱动类型的基础能力。