3. 字符设备驱动框架:设备号注册与注销、file_operations结构体、cdev初始化、设备节点创建
字符设备是Linux驱动模型中最基础、最常用的设备类型,其核心框架围绕设备号、文件操作接口、内核字符设备对象以及用户空间访问入口四个维度展开。本章将系统梳理字符设备驱动的完整骨架,并给出每一步的代码实现与内核接口说明。
3.1 设备号:主设备号与次设备号
设备号是内核标识字符设备或块设备的唯一数字对,由主设备号(major)和次设备号(minor)组成,类型为 dev_t(32位无符号整数,高12位为主设备号,低20位为次设备号)。
| 概念 | 位宽 | 取值范围 | 作用 |
|---|---|---|---|
| 主设备号 | 12 bit | 0 ~ 4095 | 标识设备对应的驱动程序 |
| 次设备号 | 20 bit | 0 ~ 1048575 | 标识同一驱动管理的不同设备实例 |
3.1.1 设备号注册与注销
内核提供两种设备号分配方式:静态指定(已知主设备号)和动态分配(由内核自动分配)。
推荐策略:生产驱动优先使用动态分配(
alloc_chrdev_region),避免主设备号冲突;教学或固定硬件平台可使用静态注册(register_chrdev_region)。
// 静态注册:已知主设备号
#define MAJOR_NUM 200
#define MINOR_NUM 0
#define DEVICE_CNT 2
dev_t devno = MKDEV(MAJOR_NUM, MINOR_NUM);
int ret = register_chrdev_region(devno, DEVICE_CNT, "my_char_dev");
if (ret < 0) {
printk("Failed to register chrdev region\n");
return ret;
}
// 动态分配:由内核分配主设备号
dev_t devno_dyn;
ret = alloc_chrdev_region(&devno_dyn, 0, DEVICE_CNT, "my_char_dev");
if (ret < 0) {
printk("Failed to alloc chrdev region\n");
return ret;
}
printk("Allocated major=%d minor=%d\n", MAJOR(devno_dyn), MINOR(devno_dyn));
// 模块卸载时注销
unregister_chrdev_region(devno, DEVICE_CNT); // 静态注销
unregister_chrdev_region(devno_dyn, DEVICE_CNT); // 动态注销
3.2 file_operations 结构体:驱动与VFS的契约
file_operations 是字符设备驱动的核心接口表,定义了用户空间通过系统调用(open/read/write/ioctl等)访问设备时,内核VFS层回调的驱动函数指针。
struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.unlocked_ioctl = my_ioctl,
.llseek = my_llseek,
};
| 回调成员 | 触发系统调用 | 典型实现职责 |
|---|---|---|
open |
open() |
设备初始化、权限检查、递增使用计数 |
release |
close() |
资源释放、递减使用计数 |
read |
read() |
从设备拷贝数据到用户空间 |
write |
write() |
从用户空间拷贝数据到设备 |
unlocked_ioctl |
ioctl() |
设备控制命令处理(无大内核锁) |
llseek |
lseek() |
修改文件读写位置 |
注意:在Linux 5.x+内核中,
ioctl 字段已被移除,统一使用 unlocked_ioctl。若驱动不需要某个回调,直接赋为 NULL,内核会使用默认行为。
3.3 cdev 初始化:将驱动注册到内核
struct cdev 是内核用于管理字符设备的核心对象。驱动必须完成cdev的初始化、设置file_operations,并调用 cdev_add 将其与设备号绑定。
struct cdev my_cdev;
// 方法一:运行时初始化
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 方法二:静态+动态分配(推荐)
struct cdev *p_cdev = cdev_alloc();
p_cdev->ops = &my_fops;
p_cdev->owner = THIS_MODULE;
// 将cdev添加到内核(绑定设备号)
int err = cdev_add(p_cdev, devno, DEVICE_CNT);
if (err) {
printk("cdev_add failed\n");
// 错误处理
}
// 模块卸载时移除
cdev_del(p_cdev);
关键顺序:必须先注册设备号(
register_chrdev_region / alloc_chrdev_region),再调用 cdev_add。反之会导致内核警告或设备节点无法正确关联。
3.4 设备节点创建:用户空间的访问入口
设备节点(device node)是用户空间通过文件系统访问驱动的接口,通常位于 /dev/ 目录下。创建方式分为手动mknod和自动udev/mdev两种。
3.4.1 手动创建(调试阶段)
# mknod /dev/my_char_dev c 200 0
# 格式:mknod 路径 类型(c/b) 主设备号 次设备号
3.4.2 自动创建(生产环境)
驱动通过 class_create 和 device_create 在初始化时自动创建设备节点,依赖用户空间的udev(桌面Linux)或mdev(嵌入式busybox)守护进程。
static struct class *my_class;
static struct device *my_device;
// 在模块初始化函数中
my_class = class_create(THIS_MODULE, "my_char_class");
if (IS_ERR(my_class)) {
// 错误处理
}
my_device = device_create(my_class, NULL, devno, NULL, "my_char_dev");
if (IS_ERR(my_device)) {
class_destroy(my_class);
// 错误处理
}
// 在模块退出函数中
device_destroy(my_class, devno);
class_destroy(my_class);
3.5 字符设备驱动完整框架图
下图展示了字符设备驱动从内核注册到用户空间访问的完整数据流与调用链:
3.6 完整驱动骨架代码
以下代码整合了设备号注册、cdev初始化、file_operations实现和设备节点自动创建的全流程:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#define DEVICE_CNT 1
static dev_t dev_num;
static struct cdev *my_cdev;
static struct class *my_class;
static struct device *my_device;
static int my_open(struct inode *inode, struct file *filp)
{
printk("Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp)
{
printk("Device closed\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos)
{
// 从内核缓冲区拷贝数据到用户空间
return 0;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos)
{
// 从用户空间拷贝数据到内核缓冲区
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_driver_init(void)
{
int ret;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, DEVICE_CNT, "my_driver");
if (ret < 0) {
pr_err("alloc_chrdev_region failed\n");
return ret;
}
pr_info("Allocated major=%d minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
// 2. 分配并初始化cdev
my_cdev = cdev_alloc();
if (!my_cdev) {
unregister_chrdev_region(dev_num, DEVICE_CNT);
return -ENOMEM;
}
my_cdev->ops = &my_fops;
my_cdev->owner = THIS_MODULE;
ret = cdev_add(my_cdev, dev_num, DEVICE_CNT);
if (ret) {
pr_err("cdev_add failed\n");
cdev_del(my_cdev);
unregister_chrdev_region(dev_num, DEVICE_CNT);
return ret;
}
// 3. 自动创建设备节点
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
cdev_del(my_cdev);
unregister_chrdev_region(dev_num, DEVICE_CNT);
return PTR_ERR(my_class);
}
my_device = device_create(my_class, NULL, dev_num,
NULL, "my_dev");
if (IS_ERR(my_device)) {
class_destroy(my_class);
cdev_del(my_cdev);
unregister_chrdev_region(dev_num, DEVICE_CNT);
return PTR_ERR(my_device);
}
pr_info("my_driver initialized successfully\n");
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, DEVICE_CNT);
pr_info("my_driver exited\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("HAL Course");
MODULE_DESCRIPTION("Character device driver skeleton");
3.7 常见错误与调试要点
- 设备号冲突:静态注册时使用已被占用的主设备号,导致
register_chrdev_region返回 -EBUSY。使用cat /proc/devices查看已注册设备号。 - cdev_add 顺序错误:在设备号注册之前调用
cdev_add,内核会发出警告并拒绝绑定。 - 设备节点权限:自动创建的节点默认权限为 0600,root用户才能访问。可通过
device_create的devt参数或udev规则调整。 - 模块引用计数:
file_operations的.owner必须设为THIS_MODULE,否则设备打开时不会增加模块引用计数,可能导致模块被强制卸载。
调试技巧:在驱动初始化函数中加入
pr_info 打印设备号、cdev地址和节点路径;使用 ls -l /dev/my_dev 确认节点主次设备号与驱动注册的一致;使用 echo "test" > /dev/my_dev 和 cat /dev/my_dev 验证读写回调是否被调用。