4. 字符设备驱动:设备号、file_operations结构体、cdev注册、open/read/write实现
字符设备驱动,说白了就是Linux驱动里最基础、最常用的一种。你想想看,键盘、鼠标、串口、LED灯,这些设备本质上都是字符设备——数据按字节流一个一个传,没有缓冲区,也不讲究块结构。我刚开始学驱动时,第一个练手的项目就是写一个虚拟字符设备,那会儿踩了不少坑,今天我把这些经验都摊开来跟你聊聊。
4.1 设备号:驱动的身份证
每个字符设备在内核里都有一个唯一的编号,这就是设备号。设备号分两部分:主设备号和次设备号。主设备号标识驱动类型,次设备号标识具体设备实例。
设备号结构:dev_t 类型,高12位是主设备号,低20位是次设备号。
我个人习惯用 MKDEV(major, minor) 宏来组合设备号,用 MAJOR(dev) 和 MINOR(dev) 来拆分。别自己去算位移,容易出错。
#include <linux/types.h> // dev_t
#include <linux/kdev_t.h> // MKDEV, MAJOR, MINOR
dev_t dev_num;
int major = 240; // 主设备号
int minor = 0; // 次设备号
dev_num = MKDEV(major, minor); // 组合
// 反过来拆
major = MAJOR(dev_num);
minor = MINOR(dev_num);
4.2 设备号分配:静态还是动态?
分配设备号有两种方式:静态分配和动态分配。我建议你优先用动态分配,除非你有特殊理由。
| 分配方式 | 函数 | 优点 | 缺点 |
|---|---|---|---|
| 静态分配 | register_chrdev_region() |
设备号固定,方便用户空间mknod | 可能冲突,需要向内核申请保留 |
| 动态分配 | alloc_chrdev_region() |
自动分配,避免冲突 | 设备号不固定,需要从/proc/devices查看 |
我在项目中遇到过一件事:有个同事用了静态分配,主设备号选了241,结果跟另一个模块冲突了,两个驱动都加载失败。后来改成动态分配,问题就解决了。所以,除非你明确知道这个主设备号是空闲的,否则老老实实用动态分配。
// 动态分配
dev_t dev_num;
int 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));
// 静态分配
dev_t dev_num = MKDEV(240, 0);
int ret = register_chrdev_region(dev_num, 1, "my_device");
if (ret < 0) {
printk(KERN_ERR "Failed to register device number\n");
return ret;
}
注意:用完设备号一定要释放!在模块卸载时调用 unregister_chrdev_region(),否则下次加载时会分配失败。
4.3 file_operations结构体:驱动的函数表
file_operations 结构体是字符设备驱动的核心。它就像一个函数指针表,告诉内核:当用户空间调用 open、read、write 时,该执行驱动里的哪个函数。
嗯,这里要注意:这个结构体里的成员非常多,但你不需要全部实现。只实现你设备需要的操作就行。比如一个只读设备,你只需要 open 和 read,write 可以留空。
struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
我个人习惯用 .owner = THIS_MODULE 来防止模块被意外卸载。你想想看,如果用户空间正开着设备文件,你突然把模块卸载了,内核直接就崩溃了。这个字段就是用来避免这种情况的。
4.4 cdev注册:让设备活起来
有了设备号和 file_operations,接下来就是把它们绑定到一起,注册到内核里。这一步用 cdev 结构体来完成。
注册流程分三步:
- 分配cdev:用
cdev_alloc()或静态定义 - 初始化cdev:用
cdev_init()绑定 file_operations - 添加cdev:用
cdev_add()注册到内核
#include <linux/cdev.h>
struct cdev my_cdev;
dev_t dev_num;
// 1. 初始化cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 2. 添加cdev到内核
int ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
printk(KERN_ERR "Failed to add cdev\n");
return ret;
}
小技巧:cdev_add() 之后,设备就立即可用了。所以一定要确保所有初始化都完成了再调用它。我曾经在初始化顺序上吃过亏——先加了cdev,然后才分配内存,结果用户空间一读就崩溃。
4.5 open/read/write实现:真正的干活
好了,前面的都是准备工作。现在我们来写真正的驱动逻辑。
4.5.1 open函数
open 函数在用户空间调用 open() 时触发。它的主要工作是:初始化设备、分配资源、检查权限。
int my_open(struct inode *inode, struct file *filp)
{
// 从inode获取设备结构体
struct my_device *dev = container_of(inode->i_cdev,
struct my_device, cdev);
filp->private_data = dev; // 保存到file结构体
printk(KERN_INFO "Device opened\n");
return 0;
}
这里有个关键点:container_of 宏。它通过结构体成员的地址,反推出整个结构体的地址。我在项目中见过有人不用这个宏,而是自己算偏移量,结果算错了,整个驱动都跑飞了。所以,老老实实用 container_of。
4.5.2 read函数
read 函数负责把设备数据拷贝到用户空间。注意,这里要用 copy_to_user(),不能直接用 memcpy。因为内核空间和用户空间的地址不能直接互访。
ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
struct my_device *dev = filp->private_data;
size_t available = dev->data_size - *f_pos;
size_t to_read = min(count, available);
if (to_read == 0)
return 0; // EOF
if (copy_to_user(buf, dev->data + *f_pos, to_read)) {
return -EFAULT; // 拷贝失败
}
*f_pos += to_read;
return to_read;
}
避坑指南:copy_to_user() 和 copy_from_user() 可能会睡眠,所以不能在自旋锁保护的临界区里调用。我曾经犯过这个错,结果内核直接报 scheduling while atomic 错误。
4.5.3 write函数
write 函数跟 read 对称,负责从用户空间接收数据。
ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
struct my_device *dev = filp->private_data;
size_t available = DEVICE_BUFFER_SIZE - *f_pos;
size_t to_write = min(count, available);
if (to_write == 0)
return -ENOSPC; // 空间不足
if (copy_from_user(dev->data + *f_pos, buf, to_write)) {
return -EFAULT;
}
*f_pos += to_write;
dev->data_size = max(dev->data_size, *f_pos);
return to_write;
}
4.6 完整流程:一张图看懂
说了这么多,我们来画一张图,把整个流程串起来。从模块加载到设备操作,每一步都清清楚楚。
4.7 完整示例:一个虚拟字符设备
最后,我把上面所有知识点整合成一个完整的驱动示例。这个驱动创建了一个虚拟字符设备,支持读写操作,缓冲区大小4KB。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "vchar"
#define BUFFER_SIZE 4096
struct vchar_device {
struct cdev cdev;
char *buffer;
size_t size;
};
static struct vchar_device *vchar_dev;
static dev_t dev_num;
static int vchar_open(struct inode *inode, struct file *filp)
{
struct vchar_device *dev = container_of(inode->i_cdev,
struct vchar_device, cdev);
filp->private_data = dev;
return 0;
}
static ssize_t vchar_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
struct vchar_device *dev = filp->private_data;
size_t available = dev->size - *f_pos;
size_t to_read = min(count, available);
if (to_read == 0)
return 0;
if (copy_to_user(buf, dev->buffer + *f_pos, to_read))
return -EFAULT;
*f_pos += to_read;
return to_read;
}
static ssize_t vchar_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
struct vchar_device *dev = filp->private_data;
size_t available = BUFFER_SIZE - *f_pos;
size_t to_write = min(count, available);
if (to_write == 0)
return -ENOSPC;
if (copy_from_user(dev->buffer + *f_pos, buf, to_write))
return -EFAULT;
*f_pos += to_write;
dev->size = max(dev->size, *f_pos);
return to_write;
}
static int vchar_release(struct inode *inode, struct file *filp)
{
return 0;
}
static struct file_operations vchar_fops = {
.owner = THIS_MODULE,
.open = vchar_open,
.read = vchar_read,
.write = vchar_write,
.release = vchar_release,
};
static int __init vchar_init(void)
{
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
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. 分配设备结构体
vchar_dev = kzalloc(sizeof(*vchar_dev), GFP_KERNEL);
if (!vchar_dev) {
unregister_chrdev_region(dev_num, 1);
return -ENOMEM;
}
// 3. 分配缓冲区
vchar_dev->buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL);
if (!vchar_dev->buffer) {
kfree(vchar_dev);
unregister_chrdev_region(dev_num, 1);
return -ENOMEM;
}
vchar_dev->size = 0;
// 4. 初始化并注册cdev
cdev_init(&vchar_dev->cdev, &vchar_fops);
vchar_dev->cdev.owner = THIS_MODULE;
ret = cdev_add(&vchar_dev->cdev, dev_num, 1);
if (ret < 0) {
kfree(vchar_dev->buffer);
kfree(vchar_dev);
unregister_chrdev_region(dev_num, 1);
return ret;
}
printk(KERN_INFO "vchar device initialized\n");
return 0;
}
static void __exit vchar_exit(void)
{
cdev_del(&vchar_dev->cdev);
kfree(vchar_dev->buffer);
kfree(vchar_dev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "vchar device removed\n");
}
module_init(vchar_init);
module_exit(vchar_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple virtual character device driver");
测试方法:加载模块后,用 cat /proc/devices 查看分配的主设备号,然后用 mknod /dev/vchar c major 0 创建设备节点,最后用 echo "hello" > /dev/vchar 和 cat /dev/vchar 测试读写。
好了,字符设备驱动的核心内容就这些。设备号、file_operations、cdev注册、open/read/write实现,这四个点你掌握了,大部分字符设备驱动都能写。剩下的就是根据具体设备的需求,往这些框架里填逻辑而已。