2. 字符设备驱动框架:核心数据结构与注册流程
字符设备是 Linux 驱动中最基础的设备类型,其驱动框架围绕 file_operations、dev_t(主次设备号)和 cdev 结构体展开。理解这三者的关系,是编写任何字符设备驱动的第一步。
2.1 核心数据结构总览
字符设备驱动的用户态接口通过 VFS(虚拟文件系统)映射到内核驱动。下图展示了应用层 open/read/write/close 系统调用如何通过 file_operations 回调进入驱动层:
2.2 file_operations 结构体
file_operations 是驱动与 VFS 之间的契约。驱动开发者通过填充该结构体的函数指针,将系统调用映射到具体的硬件操作。
| 成员 | 函数原型 | 说明 |
|---|---|---|
.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 安全拷贝 |
.unlocked_ioctl |
long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) |
设备控制命令接口,用于配置 GPIO 方向、I2C 速率等 |
.llseek |
loff_t (*llseek)(struct file *, loff_t, int) |
文件定位操作,字符设备通常设为 no_llseek 或 default_llseek |
实战要点:在 GPIO 驱动中,
.read 通常返回引脚电平值(0 或 1);.write 设置引脚输出电平;.unlocked_ioctl 用于配置方向(输入/输出)和上下拉。在 I2C 驱动中,.read/.write 封装 I2C 总线传输协议。
2.3 主次设备号
设备号是内核标识设备的唯一数字,类型为 dev_t(32 位无符号整数)。高 12 位为主设备号,低 20 位为次设备号。
- 主设备号:标识设备对应的驱动。同一类设备共享主设备号(如所有 GPIO 设备可共用同一主设备号)。
- 次设备号:标识同一驱动下的不同设备实例。例如,GPIO 芯片的 8 个引脚可分别使用次设备号 0~7。
设备号分配有两种方式:
| 方式 | 函数 | 适用场景 |
|---|---|---|
| 静态分配 | register_chrdev_region(dev_t from, unsigned count, const char *name) |
已知主设备号(如调试或平台固定分配),需在 /proc/devices 中确认未占用 |
| 动态分配 | alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name) |
推荐方式,内核自动分配未使用的主设备号,避免冲突 |
推荐做法:产品级驱动始终使用
alloc_chrdev_region 动态分配。主设备号由内核管理,驱动无需关心具体数值。次设备号从 0 开始连续分配,每个设备实例对应一个次设备号。
2.4 cdev 结构体:注册与注销
cdev 是内核中表示字符设备的对象。驱动必须将 cdev 注册到内核,VFS 才能通过设备号找到对应的 file_operations。
注册流程分为以下步骤:
- 分配设备号:调用
alloc_chrdev_region或register_chrdev_region。 - 初始化 cdev:调用
cdev_init(&cdev, &fops),关联file_operations。 - 添加 cdev 到内核:调用
cdev_add(&cdev, devno, count),使设备号与 cdev 绑定。 - 注销时逆序操作:先
cdev_del,再unregister_chrdev_region。
2.5 设备节点创建
设备节点是用户空间访问驱动的入口文件,位于 /dev 目录下。节点创建有两种方式:
| 方式 | 命令/机制 | 特点 |
|---|---|---|
| 手动创建 | mknod /dev/mydev c 240 0 |
调试阶段使用,主设备号需手动指定,不灵活 |
| 自动创建 | udev + class_create + device_create | 产品级方案,模块加载时内核自动通知 udev 创建设备节点 |
自动创建设备节点的典型代码片段:
/* 在模块加载函数中 */
static struct class *my_class;
static struct device *my_device;
/* 1. 创建设备类 */
my_class = class_create(THIS_MODULE, "my_device_class");
/* 2. 创建设备节点,内核自动生成 /dev/mydev0 */
my_device = device_create(my_class, NULL, devno, NULL, "mydev%d", 0);
/* 在模块卸载函数中 */
device_destroy(my_class, devno); /* 销毁设备 */
class_destroy(my_class); /* 销毁类 */
常见错误:忘记调用
device_destroy 和 class_destroy 会导致 /dev 下残留僵尸节点,下次加载模块时 udev 可能无法正确创建新节点。务必在注销函数中逆序释放。
2.6 完整驱动框架代码模板
以下是一个最小字符设备驱动的完整框架,整合了上述所有要素:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "my_gpio"
#define CLASS_NAME "gpio_class"
static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;
static struct device *my_device;
/* file_operations 回调实现 */
static int my_open(struct inode *inode, struct file *file) {
pr_info("Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *file) {
pr_info("Device closed\n");
return 0;
}
static ssize_t my_read(struct file *file, char __user *buf,
size_t len, loff_t *offset) {
char data = '1'; /* 模拟 GPIO 高电平 */
if (copy_to_user(buf, &data, 1))
return -EFAULT;
return 1;
}
static ssize_t my_write(struct file *file, const char __user *buf,
size_t len, loff_t *offset) {
char data;
if (copy_from_user(&data, buf, 1))
return -EFAULT;
pr_info("Received data: %c\n", data);
return len;
}
static struct file_operations fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
/* 模块初始化 */
static int __init my_driver_init(void) {
/* 1. 动态分配设备号 */
alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
/* 2. 初始化 cdev */
cdev_init(&my_cdev, &fops);
my_cdev.owner = THIS_MODULE;
/* 3. 添加 cdev 到内核 */
cdev_add(&my_cdev, dev_num, 1);
/* 4. 自动创建设备节点 */
my_class = class_create(THIS_MODULE, CLASS_NAME);
my_device = device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME);
pr_info("Driver loaded, major=%d minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
return 0;
}
/* 模块退出 */
static void __exit my_driver_exit(void) {
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
pr_info("Driver unloaded\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Driver Developer");
MODULE_DESCRIPTION("Minimal GPIO Character Device Driver");
编译与测试:将上述代码保存为
my_gpio_drv.c,编写 Makefile 编译为内核模块。加载后执行 ls -l /dev/my_gpio 确认节点存在,使用 echo 1 > /dev/my_gpio 和 cat /dev/my_gpio 测试读写。
2.7 本章小结
- file_operations 是驱动与 VFS 的接口契约,必须实现
.open/.release/.read/.write等回调。 - 设备号 由主设备号(驱动标识)和次设备号(实例标识)组成,推荐使用
alloc_chrdev_region动态分配。 - cdev 注册 三步曲:
cdev_init→cdev_add→ 设备节点创建;注销时逆序操作。 - 设备节点 通过
class_create+device_create自动创建,产品级驱动必须使用此方式。