3. 字符设备驱动框架:file_operations结构体、主次设备号、cdev注册与注销
字符设备驱动,说白了就是Linux世界里最基础、最常用的一种驱动模型。你想想看,我们平时用的键盘、鼠标、串口,甚至一些传感器,本质上都是字符设备。它们的特点是什么?数据是一个字节一个字节地流式传输,不像块设备那样可以随机读写。
这一章,我们就来拆解字符设备驱动的核心骨架。我个人习惯把驱动框架比作「前台接待」和「后台档案」的关系——file_operations 就是前台接待员,负责处理用户程序的各种请求;而 cdev 和 设备号 就是后台的档案系统,让内核能找到对应的接待员。
3.1 设备号:驱动的身份证
每个字符设备在内核里都有一个唯一的编号,这就是设备号。它由两部分组成:
| 组成部分 | 位数 | 作用 |
|---|---|---|
| 主设备号 | 12 bit | 标识设备对应的驱动程序 |
| 次设备号 | 20 bit | 区分同一个驱动管理的不同设备实例 |
主设备号就像公司的总机号码,打进来先找到总机;次设备号就是分机号,总机根据分机号把电话转给具体的人。我在项目中遇到过好几次设备号冲突的问题——两个驱动模块不小心申请了同一个主设备号,结果insmod第二个模块时直接报错,系统日志里一堆「register_chrdev_region: major number already in use」。
3.1.1 设备号管理API
内核提供了两套接口来管理设备号:
// 静态申请——你知道要用的主设备号
int register_chrdev_region(dev_t from, unsigned count, const char *name);
// 动态分配——让内核帮你挑一个空闲的主设备号
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
// 注销设备号
void unregister_chrdev_region(dev_t from, unsigned count);
我个人强烈推荐用动态分配。为什么?因为你不知道你的驱动将来会在什么内核版本、什么硬件平台上跑。静态分配的主设备号可能已经被别的驱动占用了。动态分配省心省力,内核会帮你找一个没人用的号。
设备号在内核中用 dev_t 类型表示,它是一个32位的无符号整数。你可以用宏来拆解和组装:
MAJOR(dev_t dev); // 获取主设备号
MINOR(dev_t dev); // 获取次设备号
MKDEV(int major, int minor); // 组合主次设备号
3.2 file_operations:驱动与用户态的桥梁
用户程序通过 open()、read()、write() 等系统调用操作设备文件时,内核怎么知道该调用驱动里的哪个函数?答案就在 file_operations 结构体里。
这个结构体定义在 <linux/fs.h> 中,里面全是函数指针。说白了,它就是一张「映射表」——把系统调用映射到驱动开发者实现的函数上。
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
// ... 还有很多其他操作
};
你不需要实现所有函数指针。大多数驱动只实现 open、release、read、write 这四个就够了。其他的设为NULL,内核会用默认行为处理。
read 和 write 函数的第二个参数是 char __user *,这个 __user 标记很重要!它告诉内核这个指针指向的是用户空间地址,不能直接解引用。必须用 copy_to_user() 和 copy_from_user() 来安全地传输数据。
3.2.1 一个最小化的file_operations实例
static int my_open(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Device closed\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t len, loff_t *off) {
char kernel_buf[] = "Hello from kernel!\n";
if (*off >= sizeof(kernel_buf))
return 0; // 文件末尾
if (copy_to_user(buf, kernel_buf, sizeof(kernel_buf)))
return -EFAULT;
*off += sizeof(kernel_buf);
return sizeof(kernel_buf);
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t len, loff_t *off) {
// 实际项目中这里会处理用户数据
printk(KERN_INFO "Received %zu bytes from user\n", len);
return len;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
嗯,这里要注意 .owner = THIS_MODULE 这一行。它告诉内核这个结构体属于哪个模块。当有人正在使用这个设备时,内核会阻止你卸载模块,防止出现悬空指针。我见过有人忘了写这一行,结果模块卸载后设备还在被访问,直接内核panic。
3.3 cdev:内核中的设备对象
file_operations 定义了操作,cdev 则把这些操作和设备号绑定在一起。你可以把 cdev 理解成内核里代表字符设备的「对象」。
3.3.1 cdev的生命周期
- 分配:
cdev_alloc()或静态定义 - 初始化:
cdev_init()绑定 file_operations - 添加:
cdev_add()告诉内核这个设备上线了 - 删除:
cdev_del()设备下线
struct cdev my_cdev;
// 方式一:静态初始化
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 方式二:动态分配(适合多个设备)
struct cdev *p_cdev = cdev_alloc();
p_cdev->ops = &my_fops;
p_cdev->owner = THIS_MODULE;
3.3.2 完整的注册流程
把前面所有知识点串起来,一个完整的字符设备驱动注册流程是这样的:
static int __init my_driver_init(void) {
dev_t dev_num;
int ret;
// 1. 分配设备号(动态)
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
printk(KERN_ERR "Failed to allocate device number\n");
return ret;
}
printk(KERN_INFO "Allocated major=%d, minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
// 2. 初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 添加cdev到内核
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
printk(KERN_ERR "Failed to add cdev\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
printk(KERN_INFO "My device driver loaded successfully\n");
return 0;
}
static void __exit my_driver_exit(void) {
// 1. 删除cdev
cdev_del(&my_cdev);
// 2. 释放设备号
unregister_chrdev_region(my_cdev.dev, 1);
printk(KERN_INFO "My device driver unloaded\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
这个流程我几乎在每个字符设备驱动里都会写一遍。你可能会问:为什么 cdev_add 失败时要先 unregister_chrdev_region?因为设备号已经申请成功了,如果不释放,这个设备号就永远被占用了,直到你重启系统。
3.4 设备节点:用户态看到的入口
驱动注册完成后,用户程序怎么访问它?需要通过设备节点——就是 /dev/ 目录下的那个文件。有两种方式创建设备节点:
- 手动创建:
mknod /dev/mydevice c 240 0(c表示字符设备,240是主设备号,0是次设备号) - 自动创建:使用udev或mdev,驱动注册时自动在
/dev/下生成节点
手动创建只适合开发调试。产品中一定要用udev自动管理,否则换个内核版本主设备号变了,你的设备节点就废了。
3.5 本章知识结构图
下面这张图把字符设备驱动的核心框架串起来了,你可以对照着理解各个组件之间的关系:
3.6 常见问题与避坑
| 问题 | 现象 | 原因 | 解决方法 |
|---|---|---|---|
| 设备节点打不开 | open()返回 -ENOENT | 设备号不匹配或cdev未注册 | 检查 /proc/devices 确认主设备号 |
| 读写返回 -EFAULT | read/write失败 | copy_to_user/from_user 地址错误 | 检查用户空间指针是否有效 |
| 模块卸载时死机 | rmmod后系统卡死 | 设备正在被使用,但owner未设置 | 确保 .owner = THIS_MODULE |
| 设备号冲突 | insmod失败,dmesg报错 | 主设备号已被占用 | 改用 alloc_chrdev_region |
cdev_add 之后立刻返回成功,但 file_operations 里的函数还没完全初始化好。结果用户程序一打开设备,直接访问了空指针。记住:cdev_add 之后,你的驱动就要准备好被随时调用。所有初始化工作必须在 cdev_add 之前完成。