字符设备驱动基础:设备号、file_operations、cdev与设备节点
字符设备驱动,说白了就是 Linux 里最简单、最基础的一类驱动。你想想看,键盘、鼠标、串口、LED 灯——这些设备本质上都是“按字节流读写”的,它们就是字符设备的典型代表。
我刚开始学驱动时,觉得这玩意儿挺玄乎。后来做多了才发现,字符设备驱动的骨架其实就四个东西:设备号、file_operations 结构体、cdev 结构体,还有设备节点。把这四个搞明白,字符设备驱动你就入门了。
设备号:驱动的身份证
Linux 里每个字符设备都有一个唯一的编号,这就是设备号。它分两部分:
- 主设备号(major number):标识设备对应的驱动程序。比如 1 代表内存设备,4 代表 tty 设备。
- 次设备号(minor number):标识同一个驱动管理的不同设备实例。比如两个串口,主设备号相同,次设备号不同。
设备号在内核里用 dev_t 类型表示,它是一个 32 位的数。高 12 位是主设备号,低 20 位是次设备号。嗯,这里要注意,主设备号范围是 0~4095,次设备号范围是 0~1048575。
关键点:设备号是用户空间访问内核驱动的“门牌号”。没有它,应用程序根本找不到你的驱动。
设备号的分配方式
分配设备号有两种路子:
| 方式 | 函数 | 适用场景 | 我的建议 |
|---|---|---|---|
| 静态分配 | register_chrdev_region() |
你知道设备号,或者设备号已固定 | 不推荐,容易冲突 |
| 动态分配 | alloc_chrdev_region() |
你不知道用哪个号,让内核帮你选 | 推荐,省心省力 |
我个人习惯用动态分配。为什么?因为静态分配你得去查 /proc/devices 看看哪个号还没被占,麻烦不说,换个内核版本可能就冲突了。动态分配让内核给你挑一个空闲的,多省事。
// 动态分配设备号示例
dev_t dev_num;
int major, minor;
// 申请一个主设备号,次设备号从0开始,共1个设备
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_char_dev");
if (ret < 0) {
printk("设备号申请失败\n");
return ret;
}
major = MAJOR(dev_num);
minor = MINOR(dev_num);
printk("分配的设备号: 主=%d, 次=%d\n", major, minor);
小技巧:用 cat /proc/devices 可以查看当前系统已分配的设备号。调试时经常用这个命令确认你的驱动是否注册成功。
file_operations:驱动与应用的接口契约
应用程序怎么操作你的设备?通过文件接口——open()、read()、write()、close() 这些。而驱动这边,就是用 file_operations 结构体来提供这些操作的实现。
说白了,file_operations 就是一张函数指针表。应用层调 read(),内核就去找这张表里的 .read 指针,然后调用你写的那个函数。
// file_operations 结构体定义(部分)
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
// ... 还有很多
};
实际项目中,你不需要实现所有函数。只实现你设备需要的就行。比如一个简单的 LED 驱动,可能只需要 open、release 和 write。
// 一个简单的 file_operations 实例
static int my_open(struct inode *inode, struct file *filp) {
printk("设备已打开\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp) {
printk("设备已关闭\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos) {
// 从内核空间拷贝数据到用户空间
// 注意用 copy_to_user()
return 0;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos) {
// 从用户空间拷贝数据到内核空间
// 注意用 copy_from_user()
return count;
}
// 定义 file_operations 结构体
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
};
注意:在 read 和 write 函数里,千万不要直接访问用户空间的指针!必须用 copy_to_user() 和 copy_from_user()。我曾经见过一个新手直接 memcpy,结果内核直接 oops 了。
cdev 结构体:内核中的设备对象
设备号有了,操作函数表也有了,怎么把它们告诉内核?这就轮到 cdev 结构体出场了。
cdev 是内核用来管理字符设备的核心数据结构。它把设备号和 file_operations 绑定在一起,然后注册到内核的设备管理系统中。
// cdev 结构体定义
struct cdev {
struct kobject kobj;
struct module *owner;
const struct file_operations *ops;
struct list_head list;
dev_t dev;
unsigned int count;
};
使用 cdev 的步骤很简单,就三步:
- 分配:用
cdev_alloc()或静态定义 - 初始化:用
cdev_init()绑定file_operations - 注册:用
cdev_add()告诉内核这个设备存在
// cdev 使用完整示例
static struct cdev my_cdev;
// 在模块初始化函数中
static int __init my_driver_init(void) {
dev_t dev_num;
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
printk("设备号分配失败\n");
return ret;
}
// 2. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 注册 cdev
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
printk("cdev 注册失败\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
printk("驱动加载成功\n");
return 0;
}
// 在模块卸载函数中
static void __exit my_driver_exit(void) {
dev_t dev_num = my_cdev.dev;
// 1. 删除 cdev
cdev_del(&my_cdev);
// 2. 释放设备号
unregister_chrdev_region(dev_num, 1);
printk("驱动卸载成功\n");
}
经验之谈:我习惯把 cdev 放在一个自定义的设备结构体里,而不是用全局变量。这样方便管理多个设备实例。比如:
struct my_device {
struct cdev cdev;
// 其他设备相关数据
int data;
spinlock_t lock;
};
设备节点:用户空间的访问入口
驱动注册好了,设备号也有了,但用户空间的应用程序怎么找到它?答案是设备节点。
设备节点就是一个特殊的文件,通常放在 /dev 目录下。它记录了设备类型(字符设备还是块设备)和设备号。应用程序打开这个文件,内核就知道要调用哪个驱动的 file_operations。
创建设备节点的两种方式
| 方式 | 命令/函数 | 特点 |
|---|---|---|
| 手动创建 | mknod /dev/mydev c 240 0 |
需要知道设备号,重启后消失 |
| 自动创建 | class_create() + device_create() |
驱动加载时自动生成,卸载时自动删除 |
手动创建太原始了,我从来不用。现在都用自动创建,配合 udev 或 mdev,驱动一加载,设备节点就自动出现在 /dev 下。
// 自动创建设备节点示例
static struct class *my_class;
static int __init my_driver_init(void) {
// ... 前面的设备号分配和 cdev 注册代码 ...
// 创建设备类
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
printk("类创建失败\n");
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
return PTR_ERR(my_class);
}
// 创建设备节点(会在 /dev 下生成 my_device0)
device_create(my_class, NULL, dev_num, NULL, "my_device%d", 0);
return 0;
}
static void __exit my_driver_exit(void) {
dev_t dev_num = my_cdev.dev;
// 先销毁设备节点
device_destroy(my_class, dev_num);
// 再销毁设备类
class_destroy(my_class);
// 最后删除 cdev 和释放设备号
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
}
注意顺序:卸载驱动时,一定要先销毁设备节点和类,再删除 cdev 和释放设备号。顺序反了,系统可能会崩溃。我踩过这个坑,那次调试了整整一个下午。
知识体系总览
下面这张图把字符设备驱动的核心脉络串起来了,你可以对照着理解:
完整驱动框架模板
最后,我把上面所有知识点串起来,给你一个可以直接用的驱动模板。这个模板我用了好几年,改改就能用:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "my_device"
#define CLASS_NAME "my_class"
static int major;
static struct class *my_class;
static struct cdev my_cdev;
static int my_open(struct inode *inode, struct file *filp) {
printk("%s: 设备打开\n", DEVICE_NAME);
return 0;
}
static int my_release(struct inode *inode, struct file *filp) {
printk("%s: 设备关闭\n", DEVICE_NAME);
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t count, loff_t *f_pos) {
char kernel_buf[64] = "Hello from kernel!";
size_t len = strlen(kernel_buf);
if (count < len)
return -EINVAL;
if (copy_to_user(buf, kernel_buf, len))
return -EFAULT;
return len;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t count, loff_t *f_pos) {
char kernel_buf[64];
if (count > sizeof(kernel_buf))
return -EINVAL;
if (copy_from_user(kernel_buf, buf, count))
return -EFAULT;
printk("%s: 收到数据: %s\n", DEVICE_NAME, kernel_buf);
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_init(void) {
dev_t dev_num;
int ret;
// 1. 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) {
printk("设备号分配失败\n");
return ret;
}
major = MAJOR(dev_num);
// 2. 初始化并注册 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
printk("cdev 注册失败\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
// 3. 自动创建设备节点
my_class = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(my_class)) {
printk("类创建失败\n");
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
return PTR_ERR(my_class);
}
device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME);
printk("%s: 加载成功, 主设备号=%d\n", DEVICE_NAME, major);
return 0;
}
static void __exit my_exit(void) {
dev_t dev_num = MKDEV(major, 0);
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk("%s: 卸载成功\n", DEVICE_NAME);
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");
测试方法:加载驱动后,用 echo "hello" > /dev/my_device 写数据,用 cat /dev/my_device 读数据。看看内核日志里打印了什么。
嗯,字符设备驱动的基础就这些。设备号是身份证,file_operations 是操作手册,cdev 是内核里的登记表,设备节点是用户空间的入口。把这四个东西理解透,写一个简单的字符设备驱动就不成问题了。