字符设备驱动基础:设备号申请与注册、file_operations结构体实现、cdev接口详解、设备节点自动创建
字符设备驱动,说白了就是 Linux 驱动世界里最基础、最经典的一种驱动模型。你想想看,键盘、鼠标、串口、LED 灯——这些按字节流读写的外设,几乎都用字符设备驱动来管理。我刚开始做驱动时,第一个练手的项目就是字符设备驱动,那时候踩的坑,现在回想起来还挺有意思的。
这一章,我们就来把字符设备驱动的几个核心环节彻底讲透:设备号怎么申请、file_operations 怎么填、cdev 接口怎么用、设备节点怎么自动创建。这些东西你搞明白了,后面写任何字符驱动都会顺手很多。
设备号:驱动的身份证
每个字符设备在 Linux 内核里都有一个唯一的标识——设备号。它由两部分组成:主设备号和次设备号。主设备号用来区分驱动类型,次设备号用来区分同类型下的不同设备。
| 概念 | 说明 | 示例 |
|---|---|---|
| 主设备号 | 标识设备对应的驱动程序 | 89(I2C 设备) |
| 次设备号 | 标识同驱动下的不同设备实例 | 0、1、2… |
| 设备号类型 | dev_t,32 位无符号整数 | 高 12 位主设备号,低 20 位次设备号 |
设备号的申请有两种方式:静态申请和动态分配。
静态申请:指定主设备号
这种方式适合你知道某个主设备号没有被占用的情况。比如一些经典的驱动,像 LED、按键,主设备号是固定的。我早期做项目时,喜欢用静态申请,因为调试起来方便,设备号固定,不用每次去查。
#include <linux/fs.h>
#define MY_MAJOR 200
#define MY_MINOR 0
#define DEVICE_NAME "my_device"
int major;
dev_t dev_num;
// 静态申请设备号
dev_num = MKDEV(MY_MAJOR, MY_MINOR);
ret = register_chrdev_region(dev_num, 1, DEVICE_NAME);
if (ret < 0) {
printk(KERN_ERR "Failed to register device number\n");
return ret;
}
动态分配:让内核帮你选
动态分配更灵活,内核会从空闲的设备号里挑一个给你。我现在的习惯是,除非有特殊要求,否则都用动态分配。省心,也避免冲突。
// 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) {
printk(KERN_ERR "Failed to alloc device number\n");
return ret;
}
major = MAJOR(dev_num);
printk(KERN_INFO "Allocated major number: %d\n", major);
动态分配后,你需要通过 MAJOR() 和 MINOR() 宏来提取主次设备号。嗯,这里要注意,设备号申请成功后,记得在模块卸载时释放:
unregister_chrdev_region(dev_num, 1);
file_operations:驱动与用户态的桥梁
用户程序通过 open、read、write、ioctl 等系统调用操作设备文件,这些调用最终会落到驱动里的 file_operations 结构体上。说白了,file_operations 就是驱动暴露给用户态的函数指针表。
我见过不少新手,一上来就把所有函数指针都实现了,其实没必要。你只需要实现你的设备真正需要的操作就行。比如一个简单的 LED 驱动,可能只需要 open、release 和 ioctl。
| 函数指针 | 说明 | 常用场景 |
|---|---|---|
| .open | 打开设备 | 初始化硬件、增加引用计数 |
| .release | 关闭设备 | 释放资源、减少引用计数 |
| .read | 从设备读取数据 | 读取传感器数据、接收缓冲区 |
| .write | 向设备写入数据 | 发送命令、写入配置 |
| .unlocked_ioctl | 设备控制命令 | 设置参数、启动停止、获取状态 |
| .llseek | 修改文件读写位置 | 支持随机访问的设备 |
来看一个典型的实现:
static int my_open(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Device opened\n");
return 0;
}
static int my_release(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Device closed\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off) {
// 从内核空间拷贝数据到用户空间
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 len, loff_t *off) {
// 从用户空间拷贝数据到内核空间
if (copy_from_user(kernel_buf, buf, len)) {
return -EFAULT;
}
return len;
}
static long my_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
switch (cmd) {
case MY_IOCTL_SET:
// 处理设置命令
break;
case MY_IOCTL_GET:
// 处理获取命令
break;
default:
return -EINVAL;
}
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.unlocked_ioctl = my_ioctl,
};
cdev 接口:内核里的设备对象
设备号有了,file_operations 也填好了,接下来怎么把它们告诉内核?答案就是 cdev 结构体。cdev 是内核用来管理字符设备的核心数据结构,每个字符设备在内核里都对应一个 cdev 实例。
cdev 的使用分三步走:
- 分配 cdev 对象:可以用静态定义,也可以用 cdev_alloc 动态分配。
- 初始化 cdev:调用 cdev_init,把 file_operations 挂上去。
- 注册 cdev:调用 cdev_add,把 cdev 和设备号关联起来。
#include <linux/cdev.h>
struct cdev my_cdev;
dev_t dev_num;
// 1. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 2. 注册 cdev
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
printk(KERN_ERR "Failed to add cdev\n");
unregister_chrdev_region(dev_num, 1);
return ret;
}
这里有个细节:cdev_add 之后,设备就立即可用了。所以,我建议在 cdev_add 之前,先把硬件初始化、资源申请这些事情都做完。否则,用户态程序一打开设备,驱动还没准备好,就容易出问题。
卸载时,顺序要反过来:先 cdev_del,再 unregister_chrdev_region。
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
设备节点自动创建:udev 与 class 机制
驱动注册好了,设备号也有了,但用户态怎么访问?传统做法是手动 mknod 创建设备节点。但这样做太麻烦了,而且设备号一变,mknod 的参数就得改。
现代 Linux 系统里,设备节点由 udev(用户空间设备管理器)自动创建。驱动只需要在注册时,创建一个 class,然后在 class 下创建 device,udev 就会自动在 /dev 下生成对应的设备节点。
来看代码:
#include <linux/device.h>
static struct class *my_class;
static struct device *my_device;
// 在模块初始化函数中
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
ret = PTR_ERR(my_class);
goto err_class;
}
my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
if (IS_ERR(my_device)) {
ret = PTR_ERR(my_device);
goto err_device;
}
这样,当驱动加载后,udev 会在 /dev 下创建名为 my_device 的设备节点,权限默认是 660,属主是 root。你可以通过 udev 规则来调整权限和属组。
卸载时,记得销毁 device 和 class:
device_destroy(my_class, dev_num);
class_destroy(my_class);
完整流程:从申请到注册
把上面所有环节串起来,一个完整的字符设备驱动初始化流程是这样的:
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) 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) goto err_cdev;
// 4. 创建 class 和 device
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
ret = PTR_ERR(my_class);
goto err_class;
}
my_device = device_create(my_class, NULL, dev_num, NULL, "my_device");
if (IS_ERR(my_device)) {
ret = PTR_ERR(my_device);
goto err_device;
}
printk(KERN_INFO "My driver loaded, major=%d\n", MAJOR(dev_num));
return 0;
err_device:
class_destroy(my_class);
err_class:
cdev_del(&my_cdev);
err_cdev:
unregister_chrdev_region(dev_num, 1);
return ret;
}
static void __exit my_driver_exit(void) {
dev_t dev_num = MKDEV(MAJOR(my_cdev.dev), 0);
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "My driver unloaded\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
这个流程,我建议你背下来。后面写任何字符设备驱动,基本就是这个模板。你想想看,设备号、cdev、file_operations、class/device,这四个东西搞清楚了,字符设备驱动你就掌握了八成。
这张图把整个流程串起来了。你写驱动的时候,就照着这个顺序来,准没错。记住,注册顺序和卸载顺序完全相反,这是内核编程里的一条铁律。