字符设备驱动模块:从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 是怎么一步步走到你的驱动函数里的?

  1. 应用调用 open("/dev/mydev", O_RDWR)
  2. VFS 根据设备号找到对应的 cdev
  3. cdev 的 ops 指向你的 my_open
  4. 你的 my_open 执行,返回 0
  5. 应用拿到文件描述符,后续的 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. 字符设备驱动核心流程

下面这张图展示了从模块加载到应用交互的完整流程。我画这张图时,特意把容易出错的地方标红了。

字符设备驱动核心流程 模块加载 (module_init) alloc_chrdev_region cdev_init + cdev_add 应用层 open/read/write/close → VFS → cdev → file_operations 模块卸载 (module_exit) cdev_del + unregister_chrdev_region 注意:cdev_add 之后,设备立即可被应用访问;卸载前必须先 cdev_del

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大小。