4、字符设备驱动基础:实现一个基本的字符设备驱动,理解file_operations结构体与设备号注册
字符设备驱动是Linux驱动开发中最基础、最核心的模块之一。它通过file_operations结构体将用户空间的系统调用(如open、read、write)与内核空间的驱动函数绑定,并通过设备号注册让系统识别和管理设备。本章将手把手带你实现一个完整的字符设备驱动,并深入剖析其背后的机制。
学习目标:掌握字符设备驱动的骨架结构;理解主设备号与次设备号的作用;熟练使用
alloc_chrdev_region与cdev_init等核心API;能够独立编写一个可加载/卸载的字符设备驱动模块。
4.1 字符设备驱动架构总览
字符设备驱动的核心是“文件操作”抽象。用户空间通过文件描述符操作设备,内核通过file_operations结构体将操作转发到驱动函数。下图展示了从用户态open()到驱动xxx_open()的完整调用链路。
4.2 设备号:主设备号与次设备号
每个字符设备在内核中由设备号唯一标识。设备号是一个dev_t类型的32位无符号整数,其中高12位为主设备号,低20位为次设备号。
| 概念 | 位数 | 范围 | 作用 |
|---|---|---|---|
| 主设备号 (Major) | 12 bit | 0 ~ 4095 | 标识设备对应的驱动程序 |
| 次设备号 (Minor) | 20 bit | 0 ~ 1048575 | 标识由同一驱动管理的具体设备实例 |
设备号注册方式对比:
- 静态注册:
register_chrdev_region(dev_t first, unsigned int count, const char *name)—— 需要提前知道可用的主设备号,容易冲突。 - 动态分配:
alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, const char *name)—— 由内核自动分配未使用的主设备号,推荐使用。
4.3 file_operations 结构体详解
file_operations是字符设备驱动的“函数跳转表”,定义在<linux/fs.h>中。驱动开发者只需实现需要的回调函数,未实现的成员置为NULL,内核会使用默认行为。
| 成员 | 函数原型 | 说明 |
|---|---|---|
.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 *) |
从设备读取数据,需使用 copy_to_user 传递数据到用户空间 |
.write |
ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) |
向设备写入数据,需使用 copy_from_user 从用户空间获取数据 |
.llseek |
loff_t (*llseek)(struct file *, loff_t, int) |
修改文件读写位置,默认实现支持 seek 操作 |
4.4 实战:实现一个完整的字符设备驱动
下面实现一个名为“mychardev”的字符设备驱动,支持open、release、read、write四个基本操作。驱动内部维护一个4KB的缓冲区。
4.4.1 驱动代码框架
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "mychardev"
#define BUFFER_SIZE 4096
static dev_t dev_num; // 设备号
static struct cdev my_cdev; // 字符设备对象
static char *device_buffer; // 内核缓冲区
// open 函数
static int my_open(struct inode *inode, struct file *filp) {
printk(KERN_INFO "mychardev: device opened\n");
return 0;
}
// release 函数
static int my_release(struct inode *inode, struct file *filp) {
printk(KERN_INFO "mychardev: device closed\n");
return 0;
}
// read 函数
static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) {
size_t available = BUFFER_SIZE - *f_pos;
size_t to_read = count < available ? count : available;
if (to_read == 0) return 0;
if (copy_to_user(buf, device_buffer + *f_pos, to_read)) {
return -EFAULT;
}
*f_pos += to_read;
return to_read;
}
// write 函数
static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) {
size_t available = BUFFER_SIZE - *f_pos;
size_t to_write = count < available ? count : available;
if (to_write == 0) return -ENOSPC;
if (copy_from_user(device_buffer + *f_pos, buf, to_write)) {
return -EFAULT;
}
*f_pos += to_write;
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,
};
// 模块初始化
static int __init my_init(void) {
int ret;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) {
printk(KERN_ERR "mychardev: failed to allocate device number\n");
return ret;
}
printk(KERN_INFO "mychardev: allocated major=%d, minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
// 2. 分配内核缓冲区
device_buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL);
if (!device_buffer) {
unregister_chrdev_region(dev_num, 1);
return -ENOMEM;
}
memset(device_buffer, 0, BUFFER_SIZE);
// 3. 初始化并添加字符设备
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
printk(KERN_ERR "mychardev: failed to add cdev\n");
kfree(device_buffer);
unregister_chrdev_region(dev_num, 1);
return ret;
}
printk(KERN_INFO "mychardev: driver loaded successfully\n");
return 0;
}
// 模块退出
static void __exit my_exit(void) {
cdev_del(&my_cdev);
kfree(device_buffer);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "mychardev: driver unloaded\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Linux Kernel Expert");
MODULE_DESCRIPTION("A simple character device driver example");
4.4.2 驱动加载与设备文件创建
加载驱动后,需要手动创建设备文件(或使用udev自动创建)。设备文件是用户空间访问驱动的入口。
# 1. 编译驱动模块
make -C /lib/modules/$(uname -r)/build M=$(pwd) modules
# 2. 加载模块
sudo insmod mychardev.ko
# 3. 查看设备号分配情况
cat /proc/devices | grep mychardev
# 输出示例: 240 mychardev
# 4. 手动创建设备文件 (主设备号240,次设备号0)
sudo mknod /dev/mychardev c 240 0
# 5. 设置权限
sudo chmod 666 /dev/mychardev
# 6. 测试读写
echo "Hello, Kernel!" > /dev/mychardev
cat /dev/mychardev
# 输出: Hello, Kernel!
# 7. 卸载模块
sudo rmmod mychardev
sudo rm /dev/mychardev
⚠️ 常见错误与排查:
- insmod失败:检查内核版本是否匹配,使用
dmesg | tail查看内核日志。 - open返回-1:确认设备文件的主次设备号与
/proc/devices一致。 - read/write返回-14 (EFAULT):检查
copy_to_user/copy_from_user的指针是否正确,用户空间地址必须使用__user标记。 - 模块卸载失败:确保没有进程持有设备文件描述符,使用
lsof /dev/mychardev检查。
4.5 驱动初始化与退出流程
字符设备驱动的生命周期分为加载和卸载两个阶段,每个阶段都有固定的步骤顺序。下图展示了完整的初始化与退出流程。
4.6 关键API速查表
| API | 头文件 | 功能 |
|---|---|---|
alloc_chrdev_region() |
<linux/fs.h> |
动态分配指定数量的设备号 |
register_chrdev_region() |
<linux/fs.h> |
静态注册已知的设备号 |
unregister_chrdev_region() |
<linux/fs.h> |
释放已注册的设备号 |
cdev_init() |
<linux/cdev.h> |
初始化cdev结构体并与file_operations绑定 |
cdev_add() |
<linux/cdev.h> |
将cdev添加到内核设备链表,使设备可用 |
cdev_del() |
<linux/cdev.h> |
从内核设备链表中移除cdev |
copy_to_user() |
<linux/uaccess.h> |
将内核空间数据拷贝到用户空间 |
copy_from_user() |
<linux/uaccess.h> |
将用户空间数据拷贝到内核空间 |
4.7 本章小结
- 字符设备驱动的核心是设备号 + file_operations + cdev 三要素。
- 设备号分为主设备号(驱动标识)和次设备号(设备实例标识),推荐使用
alloc_chrdev_region动态分配。 file_operations结构体中的open、release、read、write是最常用的回调函数。- 用户空间与内核空间的数据交换必须使用
copy_to_user/copy_from_user,不能直接解引用用户指针。 - 驱动加载和卸载必须成对操作,注意资源回滚和释放顺序。
课后练习:
- 修改驱动,支持同时打开多次(记录打开计数)。
- 增加
.llseek实现,支持lseek系统调用定位到缓冲区任意位置。 - 实现一个
ioctl命令,用于清空缓冲区或获取缓冲区大小。