字符设备驱动:设备号注册与注销、file_operations结构体、cdev结构体、设备节点的自动创建
字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种。你想想看,键盘、鼠标、串口,这些设备本质上都是字符设备——数据按字节流一个个传过来。今天我就把这块的核心知识点给你捋清楚。
设备号:驱动的身份证
每个字符设备在内核里都有一个唯一的编号,叫设备号。它分两部分:主设备号和次设备号。主设备号标识驱动类型,次设备号标识具体设备实例。
我个人习惯用MKDEV(major, minor)这个宏来组合设备号。注册时,内核提供两种方式:
- 静态分配:你指定主设备号,用
register_chrdev_region()注册。适合你知道设备号的情况。 - 动态分配:让内核帮你分配,用
alloc_chrdev_region()。我强烈推荐这种方式,避免设备号冲突。
核心API:
// 动态分配设备号
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor,
unsigned int count, char *name);
// 静态分配设备号
int register_chrdev_region(dev_t from, unsigned int count,
const char *name);
// 注销设备号
void unregister_chrdev_region(dev_t from, unsigned int count);
注意:注销设备号一定要在驱动卸载时做。我曾经见过有人忘了这步,结果模块卸载后设备号还被占用,下次加载直接失败。嗯,这种bug排查起来挺头疼的。
file_operations:驱动与应用的桥梁
这个结构体定义了应用层能对设备做哪些操作。说白了,它就是一张函数指针表。应用调用open()、read()、write()时,内核会通过这张表找到你写的驱动函数。
我一般这样初始化:
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.llseek = my_llseek,
};
这里有个坑——.owner一定要赋值为THIS_MODULE。为什么?它用来防止模块在使用中被卸载。你想想看,如果应用正读着数据,驱动模块突然被卸载了,内核直接崩溃。我早期做项目时就踩过这个坑,从那以后每次写驱动都会检查这一行。
cdev结构体:内核中的设备对象
cdev是内核里表示字符设备的核心结构体。你需要做三件事:
- 分配:用
cdev_alloc()或静态定义 - 初始化:用
cdev_init()绑定file_operations - 添加:用
cdev_add()将设备注册到内核
struct cdev my_cdev;
dev_t dev_num;
// 第一步:分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
// 第二步:初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 第三步:添加到内核
cdev_add(&my_cdev, dev_num, 1);
经验之谈:cdev_add()之后,设备就对外可见了。所以一定要确保所有初始化都完成后再调用它。我习惯把它放在驱动入口函数的最后一步。
设备节点的自动创建:udev与mdev
早期Linux驱动需要手动mknod创建设备节点,太麻烦了。现在有了udev(桌面系统)和mdev(嵌入式系统),可以自动创建。
实现自动创建需要两步:
- 创建设备类:用
class_create() - 创建设备:用
device_create()
static struct class *my_class;
// 在驱动入口函数中
my_class = class_create(THIS_MODULE, "my_class");
device_create(my_class, NULL, dev_num, NULL, "my_device");
// 在驱动出口函数中
device_destroy(my_class, dev_num);
class_destroy(my_class);
这样,驱动加载后,/dev/my_device就会自动出现。udev/mdev会监听内核发出的uevent事件,然后根据规则创建设备节点。
完整示例代码:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
static int major = 0;
static struct cdev my_cdev;
static struct class *my_class;
static int my_open(struct inode *inode, struct file *file) {
printk(KERN_INFO "Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *file) {
printk(KERN_INFO "Device closed\n");
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
};
static int __init my_init(void) {
dev_t dev_num;
// 分配设备号
alloc_chrdev_region(&dev_num, 0, 1, "my_device");
major = MAJOR(dev_num);
// 初始化cdev
cdev_init(&my_cdev, &my_fops);
cdev_add(&my_cdev, dev_num, 1);
// 自动创建设备节点
my_class = class_create(THIS_MODULE, "my_class");
device_create(my_class, NULL, dev_num, NULL, "my_device");
printk(KERN_INFO "Driver loaded, major=%d\n", major);
return 0;
}
static void __exit my_exit(void) {
dev_t dev_num = MKDEV(major, 0);
// 销毁设备节点
device_destroy(my_class, dev_num);
class_destroy(my_class);
// 注销cdev和设备号
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "Driver unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
核心流程总结
我把整个流程画了张图,方便你理解:
这张图把加载和卸载的流程对应关系画得很清楚。你注意看,卸载的顺序和加载完全相反——这是内核编程的一个通用原则:谁先申请,谁后释放。
避坑指南:我曾经在项目里遇到一个问题——驱动卸载时先调了cdev_del(),然后才调device_destroy()。结果设备节点还在,但内核已经找不到对应的cdev了。应用层访问这个节点直接导致Oops。从那以后,我每次写驱动都会严格按「反向顺序」来清理资源。
关于udev和mdev的区别
简单说两句:
- udev:运行在用户空间,基于netlink通信。功能强大,支持复杂规则。桌面Linux都用它。
- mdev:BusyBox自带的精简版,基于uevent文件系统。嵌入式系统常用,资源占用小。
不管用哪个,驱动层的接口是一样的——都是class_create + device_create。内核发出uevent,用户空间的守护进程收到后去创建设备节点。驱动开发者只需要做好内核侧的工作就行。
嗯,字符设备驱动的核心内容就这些。设备号、file_operations、cdev、自动创建节点,这四个点串起来就是一套完整的驱动框架。写驱动时按这个模板来,基本不会出大问题。