字符设备驱动模块:从cdev到应用交互
字符设备驱动,是嵌入式Linux驱动开发里最基础、也最常用的驱动类型。说白了,就是那些按字节流读写数据的设备——串口、GPIO、LED、按键,都属于这一类。我刚开始做驱动时,第一个练手的项目就是字符设备驱动。嗯,今天我们就把它彻底讲透。
1. cdev结构体:驱动的核心载体
在Linux内核里,每个字符设备都对应一个 cdev 结构体。你可以把它理解成设备的「身份证」——内核通过它来找到你的驱动函数。
#include <linux/cdev.h>
struct cdev {
struct kobject kobj; // 内核对象,用于设备模型
struct module *owner; // 所属模块
const struct file_operations *ops; // 操作函数集
struct list_head list; // 链表节点
dev_t dev; // 设备号
unsigned int count; // 设备数量
};
这里有个关键点:ops 指针指向的就是我们接下来要实现的 file_operations。我见过不少新手直接在内核里硬编码操作函数,结果模块卸载时内核崩溃——就是因为没处理好 owner 字段。
核心要点:cdev 的生命周期管理必须谨慎。分配、初始化、添加、删除,每一步都不能省。
2. 设备号分配:主次号的艺术
设备号由主设备号(major)和次设备号(minor)组成。主设备号标识驱动类型,次设备号标识具体设备实例。
| 分配方式 | 函数 | 适用场景 |
|---|---|---|
| 静态分配 | register_chrdev_region() |
已知主设备号,避免冲突 |
| 动态分配 | alloc_chrdev_region() |
推荐方式,内核自动分配 |
我个人习惯用动态分配。为什么?因为静态分配的主设备号容易跟其他驱动冲突。我记得有一次调试一个USB转串口驱动,死活加载不上,查了半天才发现是主设备号被占用了。从那以后,我基本只用 alloc_chrdev_region()。
dev_t dev_num;
int ret;
// 动态分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_char_dev");
if (ret < 0) {
printk(KERN_ERR "Failed to allocate device number\n");
return ret;
}
printk(KERN_INFO "Major: %d, Minor: %d\n",
MAJOR(dev_num), MINOR(dev_num));
小技巧:用 cat /proc/devices 可以查看当前系统已分配的设备号,避免冲突。
3. file_operations实现:驱动与内核的契约
这是驱动开发的核心。你实现的每个函数,都会被内核在特定时机调用。说白了,这就是你和内核之间的「接口协议」。
static int my_open(struct inode *inode, struct file *filp) {
// 获取设备私有数据
struct my_device *dev = container_of(inode->i_cdev,
struct my_device, cdev);
filp->private_data = dev;
printk(KERN_INFO "Device opened\n");
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t len, loff_t *off) {
struct my_device *dev = filp->private_data;
// 注意:必须用 copy_to_user
if (copy_to_user(buf, dev->buffer, len)) {
return -EFAULT;
}
return len;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t len, loff_t *off) {
struct my_device *dev = filp->private_data;
// 注意:必须用 copy_from_user
if (copy_from_user(dev->buffer, buf, len)) {
return -EFAULT;
}
return len;
}
static int my_release(struct inode *inode, struct file *filp) {
printk(KERN_INFO "Device closed\n");
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
避坑指南:我曾经在 read/write 里直接使用 memcpy 操作用户空间指针,结果内核直接 oops。记住:用户空间和内核空间的数据拷贝,必须用 copy_to_user 和 copy_from_user。
4. 驱动与应用程序交互:数据流动的完整链路
你想想看,应用层的 open/read/write 是怎么一步步走到你的驱动函数里的?
- 应用调用
open("/dev/mydev", O_RDWR) - VFS 根据设备号找到对应的 cdev
- cdev 的 ops 指向你的
my_open - 你的
my_open执行,返回 0 - 应用拿到文件描述符,后续的 read/write 走同样的路径
这个链路里,最容易被忽视的是权限检查。我见过一个驱动,open 函数里没做任何权限校验,结果普通用户也能操作硬件——这在产品里是严重的安全漏洞。
// 应用层测试代码
int fd = open("/dev/mydev", O_RDWR);
if (fd < 0) {
perror("open");
return -1;
}
char buf[64] = "Hello from userspace";
write(fd, buf, strlen(buf));
memset(buf, 0, sizeof(buf));
read(fd, buf, sizeof(buf));
printf("Read: %s\n", buf);
close(fd);
5. 完整驱动框架:把一切串起来
下面是一个完整的字符设备驱动骨架。我建议你把它作为模板,以后写新驱动时直接往上套。
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#define DEVICE_NAME "my_char_dev"
#define BUFFER_SIZE 1024
struct my_device {
struct cdev cdev;
char buffer[BUFFER_SIZE];
};
static struct my_device *my_dev;
static dev_t dev_num;
static int my_open(struct inode *inode, struct file *filp) {
filp->private_data = container_of(inode->i_cdev,
struct my_device, cdev);
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf,
size_t len, loff_t *off) {
struct my_device *dev = filp->private_data;
if (copy_to_user(buf, dev->buffer, len))
return -EFAULT;
return len;
}
static ssize_t my_write(struct file *filp, const char __user *buf,
size_t len, loff_t *off) {
struct my_device *dev = filp->private_data;
if (copy_from_user(dev->buffer, buf, len))
return -EFAULT;
return len;
}
static int my_release(struct inode *inode, struct file *filp) {
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
static int __init my_init(void) {
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) return ret;
// 2. 分配设备结构体
my_dev = kzalloc(sizeof(*my_dev), GFP_KERNEL);
if (!my_dev) {
unregister_chrdev_region(dev_num, 1);
return -ENOMEM;
}
// 3. 初始化 cdev
cdev_init(&my_dev->cdev, &my_fops);
my_dev->cdev.owner = THIS_MODULE;
// 4. 添加 cdev 到内核
ret = cdev_add(&my_dev->cdev, dev_num, 1);
if (ret < 0) {
kfree(my_dev);
unregister_chrdev_region(dev_num, 1);
return ret;
}
printk(KERN_INFO "Device registered: major=%d minor=%d\n",
MAJOR(dev_num), MINOR(dev_num));
return 0;
}
static void __exit my_exit(void) {
cdev_del(&my_dev->cdev);
kfree(my_dev);
unregister_chrdev_region(dev_num, 1);
printk(KERN_INFO "Device unregistered\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple char device driver");
关键流程总结:alloc_chrdev_region → cdev_init → cdev_add → 应用交互 → cdev_del → unregister_chrdev_region。这个顺序不能乱,否则内核会报错。
6. 字符设备驱动核心流程
下面这张图展示了从模块加载到应用交互的完整流程。我画这张图时,特意把容易出错的地方标红了。
7. 常见问题与调试技巧
- 模块加载失败:检查
dmesg输出,看是设备号冲突还是内存分配失败 - 应用 open 返回 -1:确认设备节点存在,权限正确。用
mknod /dev/mydev c major minor创建节点 - read/write 返回 -EFAULT:检查
copy_to_user/copy_from_user的指针是否正确 - 模块卸载时内核崩溃:确认没有应用还在使用设备,用
lsof检查
调试利器:在驱动里加 printk 打印关键信息,配合 dmesg -w 实时查看。我调试驱动时,基本离不开这个组合。
字符设备驱动看似简单,但它是理解整个Linux驱动模型的基础。把cdev、设备号、file_operations这三者的关系理清楚,后面学块设备、网络设备就会轻松很多。嗯,今天就先到这里,代码你可以直接拿去用,但记得改设备名和buffer大小。