字符设备驱动基础:设备号、file_operations 与 cdev 注册
字符设备驱动,说白了就是 Linux 驱动里最基础、最常用的一类。你想想看,键盘、鼠标、串口、LED 灯——这些硬件在 Linux 下基本都是字符设备。今天我就带你把这套机制彻底搞明白。
设备号:主设备号与次设备号
Linux 内核怎么区分不同的设备?靠的就是设备号。一个设备号由两部分组成:主设备号和次设备号。
| 概念 | 作用 | 典型值 |
|---|---|---|
| 主设备号 | 标识设备对应的驱动程序 | 1 (内存), 4 (tty), 89 (i2c) |
| 次设备号 | 标识同一个驱动下的不同设备实例 | 0, 1, 2 ... |
内核用 dev_t 类型来保存设备号,这是一个 32 位的无符号整数。高 12 位是主设备号,低 20 位是次设备号。我个人习惯用 MAJOR(dev) 和 MINOR(dev) 这两个宏来提取,比手动移位靠谱多了。
关键点:主设备号是驱动和内核之间的"契约"。同一个主设备号下的所有次设备号,都由同一个驱动处理。
设备号分配:静态 vs 动态
分配设备号有两种方式,我刚开始做驱动时踩过静态分配的坑,后来就只用动态分配了。
- 静态分配:调用
register_chrdev_region(),需要你自己指定主设备号。问题是——你怎么知道这个号没被占用?查/proc/devices?那只是当前状态,不代表以后不被占。 - 动态分配:调用
alloc_chrdev_region(),内核自动给你分配一个可用的主设备号。省心,而且不会冲突。
// 动态分配设备号
dev_t dev_num;
int ret = alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
if (ret < 0) {
printk(KERN_ERR "设备号分配失败\n");
return ret;
}
printk("主设备号: %d, 次设备号: %d\n",
MAJOR(dev_num), MINOR(dev_num));
我的建议:产品代码里永远用动态分配。静态分配只适合教学实验,或者你明确知道某个主设备号被预留给你了。
file_operations 结构体:驱动与用户态的桥梁
用户程序调用 open()、read()、write() 时,内核怎么知道该执行你驱动里的哪个函数?答案就是 file_operations 结构体。
这个结构体里全是函数指针。你实现了哪些操作,就填充哪些字段。没实现的填 NULL,内核会帮你处理默认行为。
static int my_open(struct inode *inode, struct file *filp) {
printk("设备已打开\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t len, loff_t *off) {
printk("设备读取请求, 长度: %zu\n", len);
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
这里有个细节我当年栽过跟头:.owner 一定要赋值为 THIS_MODULE。否则模块被卸载时,如果还有进程持有文件描述符,内核直接崩溃给你看。
| 常用操作 | 说明 | 注意点 |
|---|---|---|
| open | 设备打开时调用 | 可以做初始化、计数 |
| release | 设备关闭时调用 | 记得释放资源 |
| read | 从设备读取数据 | 数据拷贝用 copy_to_user |
| write | 向设备写入数据 | 数据拷贝用 copy_from_user |
| unlocked_ioctl | 设备控制命令 | 替代旧的 ioctl |
cdev 结构体:内核中的设备对象
file_operations 只是定义了操作函数,内核还需要一个对象来把设备号和这些操作绑定起来。这个对象就是 cdev。
注册 cdev 的流程很简单,就三步:
- 分配 cdev:静态定义或者用
cdev_alloc()动态分配 - 初始化 cdev:调用
cdev_init(),绑定 file_operations - 添加 cdev:调用
cdev_add(),告诉内核这个设备号归你管
static struct cdev my_cdev;
static int __init my_init(void) {
dev_t dev;
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev, 0, 1, "my_driver");
if (ret) return ret;
// 2. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 3. 注册到内核
ret = cdev_add(&my_cdev, dev, 1);
if (ret) {
unregister_chrdev_region(dev, 1);
return ret;
}
printk("驱动加载成功, 主设备号: %d\n", MAJOR(dev));
return 0;
}
我曾经踩过的坑:cdev_add() 之后,内核随时可能调用你的操作函数。所以一定要在 cdev_add() 之前完成所有初始化工作,否则并发访问会出问题。
注销 cdev 与释放设备号
有注册就有注销。模块卸载时,顺序和注册相反:
static void __exit my_exit(void) {
dev_t dev = MKDEV(major, 0);
// 1. 从内核移除 cdev
cdev_del(&my_cdev);
// 2. 释放设备号
unregister_chrdev_region(dev, 1);
printk("驱动卸载完成\n");
}
这里有个顺序问题:必须先 cdev_del() 再 unregister_chrdev_region()。如果反过来,设备号已经释放了,但 cdev 还在,内核访问时就找不到对应的驱动了。
手动创建设备节点
驱动加载成功后,用户空间怎么访问它?需要通过设备节点。设备节点就是 /dev 目录下的一个文件,用 mknod 命令创建:
# 查看驱动打印的主设备号
dmesg | grep my_driver
# 创建设备节点(假设主设备号为 240)
sudo mknod /dev/my_device c 240 0
sudo chmod 666 /dev/my_device
参数说明:c 表示字符设备,240 是主设备号,0 是次设备号。
小技巧:实际产品中不会手动 mknod,而是用 udev 自动创建。但调试阶段手动创建更直接,能帮你确认驱动本身没问题。
完整知识结构图
下面这张图把整个字符设备驱动的注册流程串起来了,我建议你多看几遍:
完整示例代码
把上面所有知识点整合到一起,就是一个完整的字符设备驱动骨架:
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "my_driver"
static dev_t dev_num;
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 len, loff_t *off) {
char data[] = "Hello from kernel!";
size_t datalen = strlen(data);
if (*off >= datalen) return 0;
if (len > datalen - *off) len = datalen - *off;
if (copy_to_user(buf, data + *off, len)) return -EFAULT;
*off += len;
return len;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
};
static int __init my_init(void) {
int ret;
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret) {
printk(KERN_ERR "设备号分配失败\n");
return ret;
}
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret) {
unregister_chrdev_region(dev_num, 1);
printk(KERN_ERR "cdev 注册失败\n");
return ret;
}
printk("%s: 加载成功, 主设备号=%d\n",
DEVICE_NAME, MAJOR(dev_num));
return 0;
}
static void __exit my_exit(void) {
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");
测试步骤:
- 编译:
make - 加载:
sudo insmod my_driver.ko - 查看设备号:
dmesg | tail - 创建设备节点:
sudo mknod /dev/my_driver c 主设备号 0 - 测试读取:
cat /dev/my_driver - 卸载:
sudo rmmod my_driver
嗯,到这里字符设备驱动的基础框架就搭好了。你把这个骨架跑通,后面加硬件操作、加 ioctl、加中断,都是在这个基础上做加法。记住核心思路:设备号是身份证,file_operations 是操作手册,cdev 是内核里的登记表——三样东西凑齐,你的设备就能被用户空间认出来了。