3、字符设备驱动基础:Linux字符设备驱动框架、file_operations结构体、设备号注册与注销
好,咱们今天聊聊字符设备驱动。这是Linux驱动开发里最基础、也是最核心的一块。说白了,你只要搞嵌入式Linux,就绕不开它。
我记得刚入行那会儿,带我的老工程师跟我说:“字符设备驱动就是Linux驱动的‘Hello World’。” 我当时还不信,觉得太简单了。后来做了几个项目才发现,嗯,这话一点不假。很多复杂的驱动,比如触摸屏、GPIO、串口,底层都是字符设备驱动的变种。
3.1 Linux字符设备驱动框架
先说说框架。Linux把设备分成了三大类:字符设备、块设备和网络设备。字符设备的特点是啥?数据是按字节流一个一个读写的,没有缓冲区。像键盘、鼠标、串口,都是典型的字符设备。
驱动框架其实不复杂。你想想看,用户程序调用 open()、read()、write() 这些系统调用,内核怎么知道该调用哪个驱动的函数?
答案就是:通过文件系统。Linux里“一切皆文件”。设备在 /dev 目录下也有一个文件节点。用户操作这个文件,内核通过 VFS(虚拟文件系统)找到对应的驱动函数。
整个流程是这样的:
- 用户程序调用
open("/dev/mydevice", O_RDWR) - VFS 根据设备文件的主设备号,找到对应的驱动
- 驱动里注册的
file_operations结构体中的open函数被调用 - 驱动执行硬件操作,返回文件描述符给用户
这个流程,我建议你背下来。面试的时候经常问,项目里也天天用。
核心要点: 字符设备驱动的本质,就是实现 file_operations 结构体里的函数指针,然后告诉内核:“嘿,这个设备号归我管了!”
3.2 file_operations 结构体
这个结构体,是字符设备驱动的灵魂。它定义在 <linux/fs.h> 里。说白了,它就是一张函数跳转表。
我挑几个最常用的成员给你讲讲:
| 成员 | 函数原型 | 说明 |
|---|---|---|
owner |
struct module *owner |
指向拥有该结构体的模块。一般设为 THIS_MODULE |
open |
int (*open)(struct inode *, struct file *) |
打开设备时调用。我习惯在这里做硬件初始化 |
release |
int (*release)(struct inode *, struct file *) |
关闭设备时调用。记得释放资源 |
read |
ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) |
从设备读取数据 |
write |
ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) |
向设备写入数据 |
unlocked_ioctl |
long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) |
设备控制命令。这个很常用,我后面会细讲 |
看一个最简单的实现例子:
// 一个极简的字符设备驱动框架
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "my_char_dev"
#define BUF_SIZE 1024
static char device_buffer[BUF_SIZE];
static int major_num;
// open 函数
static int my_open(struct inode *inode, struct file *file) {
printk(KERN_INFO "Device opened\n");
return 0;
}
// release 函数
static int my_release(struct inode *inode, struct file *file) {
printk(KERN_INFO "Device closed\n");
return 0;
}
// read 函数
static ssize_t my_read(struct file *file, char __user *user_buf,
size_t count, loff_t *offset) {
size_t to_read = min(count, (size_t)BUF_SIZE);
if (copy_to_user(user_buf, device_buffer, to_read)) {
return -EFAULT;
}
return to_read;
}
// write 函数
static ssize_t my_write(struct file *file, const char __user *user_buf,
size_t count, loff_t *offset) {
size_t to_write = min(count, (size_t)BUF_SIZE);
if (copy_from_user(device_buffer, user_buf, to_write)) {
return -EFAULT;
}
return to_write;
}
// 定义 file_operations 结构体
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
个人经验: 我建议你在 open 函数里加一个计数器。每次 open 加1,release 减1。这样能防止用户程序异常退出时资源没释放。我曾经在一个工控项目里吃过这个亏,设备打开后没关,结果第二次就打开失败了。
3.3 设备号注册与注销
设备号是内核识别设备的“身份证”。它由两部分组成:主设备号和次设备号。主设备号对应驱动,次设备号对应具体设备。
比如,同一个串口驱动,可以管理多个串口。主设备号相同,次设备号不同。
注册设备号有两种方式:
3.3.1 静态分配
你自己指定一个主设备号。比如:
major_num = register_chrdev(0, DEVICE_NAME, &my_fops);
第一个参数传 0,表示让内核自动分配。如果你传一个具体的数字,比如 200,那就是静态分配。
注意: 静态分配容易冲突。我以前在调试一个项目时,两个驱动都用了主设备号 200,结果加载第二个驱动时直接报错。从那以后,我都是让内核自动分配,省心。
3.3.2 动态分配
推荐用这种方式。内核会帮你找一个没被占用的主设备号:
major_num = register_chrdev(0, DEVICE_NAME, &my_fops);
if (major_num < 0) {
printk(KERN_ERR "Failed to register device\n");
return major_num;
}
printk(KERN_INFO "Registered with major number %d\n", major_num);
注销设备号也很简单:
unregister_chrdev(major_num, DEVICE_NAME);
这个函数一定要在模块卸载时调用。不然下次加载驱动时,设备号可能已经被别人占用了。
3.4 完整的驱动模板
把上面的代码拼起来,就是一个能用的字符设备驱动了。我再加一个模块初始化和卸载函数:
static int __init my_driver_init(void) {
major_num = register_chrdev(0, DEVICE_NAME, &my_fops);
if (major_num < 0) {
printk(KERN_ERR "Failed to register device\n");
return major_num;
}
printk(KERN_INFO "My char driver loaded. Major number: %d\n", major_num);
return 0;
}
static void __exit my_driver_exit(void) {
unregister_chrdev(major_num, DEVICE_NAME);
printk(KERN_INFO "My char driver unloaded\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");
避坑指南: 我曾经犯过一个低级错误——在 __exit 函数里忘了注销设备号。结果模块卸载后,/proc/devices 里还显示着这个设备。下次加载时,内核说设备号已存在,加载失败。排查了半天才发现是卸载没清理干净。
3.5 总结
字符设备驱动的基础就这些。说白了就是三步:
- 实现
file_operations结构体里的函数 - 注册设备号
- 在模块卸载时注销设备号
你想想看,是不是很简单?但别小看它。很多复杂的驱动,比如 I2C、SPI 设备驱动,底层都是基于这个框架的。只是多了些总线、设备树的处理。
下一章,我会讲怎么创建设备节点,以及用户程序怎么和驱动交互。到时候咱们再深入聊聊 copy_to_user 和 copy_from_user 的那些坑。
我的建议: 学驱动开发,一定要动手。光看代码没用。你把这个模板编译一下,加载到开发板上,用 echo 和 cat 测试一下读写功能。只有亲手跑通了,才算真正理解。