字符设备驱动框架:file_operations结构体、模块加载与卸载、设备号注册与注销
大家好,我是你们的老朋友。今天咱们来聊聊字符设备驱动最核心的骨架——驱动框架。说实话,我刚入行那会儿,对着这个框架也是一头雾水。但后来我发现,搞懂了它,Linux驱动开发就算入门了。
字符设备,说白了就是像串口、按键、LED这类设备。它们的数据传输方式是一个字节一个字节来的,不像块设备那样可以整块整块地读写。你想想看,我们平时用的鼠标、键盘,不都是字符设备吗?
1. file_operations结构体:驱动与应用的桥梁
这个结构体,我愿称之为驱动的“灵魂”。它定义了应用层能对设备做哪些操作。比如打开、关闭、读、写、控制等等。
先看代码,这是它的核心定义:
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 *);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*splice_read) (struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
ssize_t (*splice_write) (struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
};
嗯,看着挺长,但常用的其实就几个:open、release、read、write、unlocked_ioctl。其他的,说实话,很多驱动都用不上。
我在项目中遇到过这样一个坑:有个同事把owner字段忘了赋值,结果模块卸载时系统直接崩溃。为什么?因为内核不知道这个驱动属于哪个模块,卸载时没法正确清理资源。
重要提醒:owner字段一定要设置为THIS_MODULE,这是惯例,也是安全底线。
来看一个实际例子,我习惯这样初始化:
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.unlocked_ioctl = my_ioctl,
};
注意,这里用的是C99的指定初始化语法。我个人觉得这样写更清晰,哪个函数对应哪个操作一目了然。
2. 模块加载与卸载:驱动的生命周期
每个驱动模块都有两个关键函数:module_init和module_exit。它们定义了驱动什么时候“活过来”,什么时候“死去”。
加载函数一般做这些事情:
- 注册设备号
- 初始化硬件
- 创建设备节点
- 分配内存等资源
卸载函数则相反:
- 注销设备号
- 释放硬件资源
- 删除设备节点
- 释放内存
我曾经犯过一个低级错误:卸载时忘了释放中断。结果下次加载时,中断号已经被占用了,设备根本没法用。嗯,从那以后我每次写卸载函数都会反复检查,确保所有资源都释放干净。
看一个标准的模板:
static int __init my_init(void)
{
int ret;
printk(KERN_INFO "my_driver: initializing\n");
// 注册设备号
ret = register_chrdev(0, "my_device", &my_fops);
if (ret < 0) {
printk(KERN_ERR "my_driver: failed to register device\n");
return ret;
}
printk(KERN_INFO "my_driver: initialized, major=%d\n", ret);
return 0;
}
static void __exit my_exit(void)
{
printk(KERN_INFO "my_driver: exiting\n");
unregister_chrdev(major, "my_device");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple character device driver");
小技巧:__init和__exit宏告诉内核,这些函数只在初始化/退出时使用,之后可以回收内存。省一点是一点嘛。
3. 设备号注册与注销:给设备一个身份证
设备号就像设备的身份证。主设备号标识驱动类型,次设备号标识具体设备。比如,你的电脑上可能有两个串口,它们共享同一个主设备号,但次设备号不同。
注册设备号有两种方式:
| 方式 | 函数 | 特点 |
|---|---|---|
| 静态注册 | register_chrdev_region() |
需要提前知道主设备号,容易冲突 |
| 动态分配 | alloc_chrdev_region() |
内核自动分配,推荐使用 |
我个人强烈建议用动态分配。为什么?你想想看,如果你写死一个主设备号,万一别的驱动已经用了呢?系统就起不来了。动态分配让内核帮你挑一个空闲的,省心又安全。
来看动态分配的代码:
dev_t dev_num;
int major;
// 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
printk(KERN_ERR "Failed to allocate device number\n");
return ret;
}
major = MAJOR(dev_num);
printk(KERN_INFO "Allocated major number: %d\n", major);
注销时,记得用unregister_chrdev_region():
unregister_chrdev_region(dev_num, 1);
注意:设备号是系统全局资源,用完一定要还回去。我曾经见过一个驱动,卸载时忘了注销设备号,结果系统里留下了一个“幽灵设备”,其他驱动想用这个号都用不了。
4. 完整的驱动框架示例
好了,我们把上面这些串起来,写一个完整的字符设备驱动框架。这个框架虽然简单,但五脏俱全:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
static int major;
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 ssize_t my_read(struct file *file, char __user *buf,
size_t count, loff_t *pos)
{
printk(KERN_INFO "Read called\n");
return 0;
}
static ssize_t my_write(struct file *file, const char __user *buf,
size_t count, loff_t *pos)
{
printk(KERN_INFO "Write called\n");
return count;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
static int __init my_init(void)
{
dev_t dev;
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev, 0, 1, "my_device");
if (ret < 0) {
printk(KERN_ERR "Failed to allocate device number\n");
return ret;
}
major = MAJOR(dev);
// 2. 初始化cdev并添加到内核
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev, 1);
if (ret < 0) {
printk(KERN_ERR "Failed to add cdev\n");
unregister_chrdev_region(dev, 1);
return ret;
}
// 3. 创建设备类(用于自动创建设备节点)
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
printk(KERN_ERR "Failed to create class\n");
cdev_del(&my_cdev);
unregister_chrdev_region(dev, 1);
return PTR_ERR(my_class);
}
// 4. 创建设备节点
device_create(my_class, NULL, dev, NULL, "my_device");
printk(KERN_INFO "my_driver loaded, major=%d\n", major);
return 0;
}
static void __exit my_exit(void)
{
dev_t dev = MKDEV(major, 0);
// 逆序释放资源
device_destroy(my_class, dev);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev, 1);
printk(KERN_INFO "my_driver unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Complete character device driver example");
这个框架里,我用了cdev接口,这是现代Linux驱动推荐的方式。老式的register_chrdev虽然简单,但功能有限,不支持次设备号管理。
核心要点:
- 加载顺序:分配设备号 → 添加cdev → 创建类 → 创建设备节点
- 卸载顺序:销毁设备节点 → 销毁类 → 删除cdev → 释放设备号
- 顺序不能乱,否则系统会出问题
好了,这就是字符设备驱动的完整框架。你想想看,其实没那么复杂对吧?就是几个结构体、几个函数、一套固定的流程。搞懂了这些,后面再学什么中断、DMA、内存映射,都是在这个框架上添砖加瓦。
下一章,我会带大家深入read和write的实现细节,看看数据到底是怎么在用户空间和内核空间之间传递的。到时候见!