字符设备驱动框架:从设备号到自动创建设备节点
字符设备驱动,说白了就是 Linux 下最基础、最常用的驱动模型。你写的每一个 GPIO 控制、每一个 I2C 传感器、每一个 UART 通信,底层几乎都是字符设备。今天我就带你把这个框架彻底捋一遍。
我个人习惯把字符设备驱动拆成四个步骤:设备号注册与注销、file_operations 结构体实现、cdev 的初始化与添加、设备节点的自动创建。这四个步骤串起来,就是一个完整的字符设备驱动骨架。
核心要点:字符设备驱动的本质是让用户空间的 open/read/write/close 等系统调用,能够通过 VFS 层最终调用到你写的驱动函数。设备号是桥梁,cdev 是载体,file_operations 是具体实现。
1. 设备号:驱动的身份证
每个字符设备在系统中都有一个唯一的编号,这就是设备号。它由两部分组成:主设备号和次设备号。主设备号标识驱动类型,次设备号标识同一驱动下的不同设备。
我记得刚入行时,有个老工程师跟我说:“设备号就像你家门牌号,主设备号是小区号,次设备号是单元号。” 这个比喻我一直记到现在。
设备号的数据结构
Linux 内核用 dev_t 类型来表示设备号,它是一个 32 位的无符号整数。高 12 位是主设备号,低 20 位是次设备号。
typedef __u32 __kernel_dev_t;
typedef __kernel_dev_t dev_t;
// 主设备号:12 位,范围 0~4095
// 次设备号:20 位,范围 0~1048575
// 常用宏操作
MAJOR(dev_t dev) // 从 dev_t 中提取主设备号
MINOR(dev_t dev) // 从 dev_t 中提取次设备号
MKDEV(int major, int minor) // 将主次设备号组合成 dev_t
设备号注册的两种方式
| 方式 | 函数 | 适用场景 | 注意事项 |
|---|---|---|---|
| 静态分配 | register_chrdev_region() |
已知主设备号(如调试阶段) | 可能与其他驱动冲突 |
| 动态分配 | alloc_chrdev_region() |
生产环境、模块化开发 | 需要从 /proc/devices 查看分配结果 |
我个人强烈建议用动态分配。为什么?因为静态分配的主设备号需要去内核维护的 Documentation/admin-guide/devices.txt 里查,万一冲突了,驱动加载就会失败。我曾经在一个项目里吃过这个亏——两个模块用了同一个主设备号,结果系统直接 panic 了。
// 静态分配示例
#define MY_MAJOR 240
#define MY_MINOR 0
#define MY_DEVICE_COUNT 2
dev_t dev = MKDEV(MY_MAJOR, MY_MINOR);
int ret = register_chrdev_region(dev, MY_DEVICE_COUNT, "my_device");
if (ret < 0) {
printk(KERN_ERR "Failed to register device number\n");
return ret;
}
// 动态分配示例(推荐)
dev_t dev;
int ret = alloc_chrdev_region(&dev, 0, 2, "my_device");
if (ret < 0) {
printk(KERN_ERR "Failed to allocate device number\n");
return ret;
}
printk(KERN_INFO "Allocated major=%d, minor=%d\n", MAJOR(dev), MINOR(dev));
小技巧:动态分配后,可以通过 cat /proc/devices 查看系统已分配的设备号。我习惯在驱动加载成功后,主动打印主设备号,方便后续 mknod 或 udev 规则配置。
设备号注销
驱动卸载时,必须释放设备号。这个操作很简单,但很多人会忘。
void cleanup_module(void)
{
unregister_chrdev_region(dev, MY_DEVICE_COUNT);
// 注意:必须先注销 cdev,再注销设备号
}
警告:设备号注销的顺序很重要。必须先调用 cdev_del() 删除 cdev,再调用 unregister_chrdev_region() 释放设备号。顺序反了,系统可能还在引用已释放的设备号,导致内核崩溃。
2. file_operations:驱动的灵魂
设备号只是让系统知道“有这个设备”,但设备能做什么,完全取决于 file_operations 结构体。它定义了用户空间通过系统调用操作设备时,内核该调用哪些驱动函数。
说白了,这就是一张函数跳转表。用户调用 open(),内核查这张表,找到你的 my_open() 函数去执行。
核心成员
| 成员 | 函数原型 | 说明 |
|---|---|---|
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 *) |
从设备读取数据 |
write |
ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *) |
向设备写入数据 |
unlocked_ioctl |
long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) |
设备控制命令(新内核用这个) |
llseek |
loff_t (*llseek)(struct file *, loff_t, int) |
文件定位操作 |
嗯,这里要注意:read 和 write 函数的缓冲区参数带有 __user 标记。这意味着你不能直接访问这个指针,必须用 copy_to_user() 和 copy_from_user() 来安全地传输数据。我曾经见过新手直接 memcpy 操作用户空间指针,结果内核直接 oops。
// 一个简单的 file_operations 实现
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 count, loff_t *f_pos)
{
char kernel_buf[] = "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[128];
if (count > sizeof(kernel_buf))
return -EINVAL;
if (copy_from_user(kernel_buf, buf, count))
return -EFAULT;
printk(KERN_INFO "Received: %s\n", kernel_buf);
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,
};
经验之谈:我习惯在 open 函数里用 filp->private_data 保存设备结构体指针。这样在 read/write 里可以直接取出来用,避免全局变量满天飞。你想想看,如果驱动管理多个设备,全局变量根本没法区分是哪个设备在操作。
3. cdev:内核中的设备对象
设备号有了,操作函数有了,怎么把它们绑定在一起?答案就是 cdev 结构体。它是内核中表示字符设备的对象,负责将设备号和 file_operations 关联起来。
cdev 的初始化与添加
操作步骤很简单,就三步:分配、初始化、添加。
static struct cdev my_cdev;
// 1. 初始化 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
// 2. 将 cdev 添加到内核
int ret = cdev_add(&my_cdev, dev, 1); // 第三个参数是次设备号数量
if (ret < 0) {
printk(KERN_ERR "Failed to add cdev\n");
unregister_chrdev_region(dev, 1);
return ret;
}
这里有个细节:cdev_add() 一旦成功,设备就立即可用了。所以在调用 cdev_add() 之前,必须确保所有初始化工作都已完成。我曾经在 cdev_add() 之后才去申请中断,结果用户空间一打开设备就触发了中断,而中断处理函数还没准备好——系统直接挂了。
cdev 的删除
void cleanup_module(void)
{
cdev_del(&my_cdev); // 先删除 cdev
unregister_chrdev_region(dev, 1); // 再释放设备号
}
重要:删除顺序绝对不能错。先 cdev_del() 确保不会有新的操作进来,再释放设备号。如果先释放设备号,此时 cdev 还在内核里,其他进程可能还在使用这个设备,就会访问到已经释放的内存。
4. 设备节点的自动创建:告别 mknod
早期写驱动,每次加载模块后都要手动 mknod /dev/mydevice c 240 0。不仅麻烦,而且主设备号如果是动态分配的,你还得先 cat /proc/devices 查看。这体验太差了。
现代 Linux 内核提供了两种自动创建设备节点的机制:udev(用户空间)和 mdev(嵌入式常用)。内核只需要在驱动中创建类(class)和设备(device),udev/mdev 会自动在 /dev 下生成节点。
使用内核自动创建设备节点
static struct class *my_class;
static struct device *my_device;
static int __init my_init(void)
{
dev_t dev;
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev, 0, 1, "my_device");
if (ret < 0)
return ret;
// 2. 初始化并添加 cdev
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev, 1);
if (ret < 0) {
unregister_chrdev_region(dev, 1);
return ret;
}
// 3. 创建类
my_class = class_create(THIS_MODULE, "my_class");
if (IS_ERR(my_class)) {
cdev_del(&my_cdev);
unregister_chrdev_region(dev, 1);
return PTR_ERR(my_class);
}
// 4. 创建设备节点(udev/mdev 会自动在 /dev 下生成)
my_device = device_create(my_class, NULL, dev, NULL, "my_device");
if (IS_ERR(my_device)) {
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev, 1);
return PTR_ERR(my_device);
}
printk(KERN_INFO "My device initialized, major=%d\n", MAJOR(dev));
return 0;
}
static void __exit my_exit(void)
{
device_destroy(my_class, dev);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev, 1);
printk(KERN_INFO "My device removed\n");
}
你看,驱动加载后,/dev/my_device 就自动出现了。不需要手动 mknod,也不需要关心主设备号是多少。这就是现代驱动开发的正确姿势。
嵌入式环境注意:如果你的系统没有 udev(比如 busybox 环境),可以用 mdev。只需要在驱动加载后执行 echo /sbin/mdev > /proc/sys/kernel/hotplug,然后 mdev -s 扫描即可。我做的几个 ARM 项目都是这么干的。
完整框架图:字符设备驱动核心流程
下面这张图总结了整个字符设备驱动的框架流程,从模块加载到设备节点创建,再到用户空间操作。我建议你把它存下来,写驱动时对照着看。
避坑指南:我踩过的那些坑
- 设备号冲突:我曾经在一个项目里,两个驱动模块用了同一个静态主设备号。加载第二个模块时,系统直接报错
register_chrdev_region: major 240 already in use。从那以后,我全部改用动态分配。 - cdev_add 顺序:有一次我在
cdev_add()之后才初始化自旋锁,结果用户空间一打开设备就触发并发访问,锁还没初始化,直接死锁。记住:cdev_add()之前,所有资源必须就绪。 - copy_to_user 失败:新手最容易犯的错——直接解引用用户空间指针。内核空间不能直接访问用户空间内存,必须用
copy_to_user/copy_from_user。而且这两个函数会返回剩余未拷贝的字节数,返回 0 才表示成功。 - 设备节点权限:自动创建的设备节点默认权限是 0600,只有 root 能访问。如果想让普通用户也能操作,可以在
device_create()之后用dev_set_uevent_suppress()或者修改 udev 规则。我一般会在 udev 规则里加一行:SUBSYSTEM=="my_device", MODE="0666"。
总结一下:字符设备驱动的框架其实不复杂。设备号是身份标识,file_operations 是功能实现,cdev 是绑定桥梁,自动创建设备节点是用户体验。把这四块搞明白,你就能写出一个完整的字符设备驱动。下一章我们会在这个框架基础上,加入实际硬件操作,让驱动真正动起来。