4. 字符设备驱动进阶:自动创建设备节点、class_create与device_create、内存映射与mmap

上一章我们写了一个能用的字符设备驱动,但有个问题很烦人——每次加载驱动后,都得手动敲 mknod 创建设备节点。你想想看,要是产品里有几十个驱动,每个都这么搞,运维的人怕是要骂娘了。这一章我们就来解决这个痛点,顺便把内存映射也讲透。

4.1 自动创建设备节点:从手动到自动

手动创建设备节点,说白了就是告诉内核:「嘿,这个设备的主次设备号是多少,给我在 /dev 下建个文件」。但内核其实早就提供了自动化的机制,只是很多初学者不知道而已。

4.1.1 udev 与 mdev 的区别

先搞清楚两个概念:udev 和 mdev。它们都是设备节点管理工具,但应用场景不同。

特性 udev mdev
运行环境 桌面/服务器 Linux 嵌入式 Linux(Busybox)
依赖 libudev、systemd 或 eudev Busybox 内置
配置方式 /etc/udev/rules.d/ 下的规则文件 /etc/mdev.conf
资源占用 较大 极小
热插拔支持 完善 基本支持

我个人习惯在开发板上用 mdev,因为 Busybox 几乎就是嵌入式标配。但在 PC 上调试驱动时,udev 更靠谱。其实底层原理都一样——内核通过 uevent 机制通知用户空间,用户空间的守护进程收到消息后去创建设备节点。

小技巧: 在嵌入式开发中,如果发现 /dev 下没有自动生成节点,先检查 /etc/init.d/rcS 里有没有执行 echo /sbin/mdev > /proc/sys/kernel/hotplug 和 mdev -s。我曾经在一个项目里排查了半天,最后发现是启动脚本里漏了这两行。

4.1.2 内核中的自动创建机制

内核提供了一套标准的 API 来实现自动创建设备节点。核心就两个函数:class_create 和 device_create。它们配合起来,能让内核在加载驱动时自动向用户空间发送 uevent。

流程其实很简单:

  1. 先创建一个 class(类),比如叫 "my_driver_class"
  2. 再在这个 class 下创建一个 device,指定设备名和主次设备号
  3. 内核自动发送 uevent,用户空间的 udev/mdev 收到后创建 /dev/xxx 节点

嗯,这里要注意:class_create 是 #define 宏,实际调用的是 __class_create。而 device_create 在新版内核中已经变成了 device_create(注意没有下划线了)。

4.2 class_create 与 device_create 实战

光说不练假把式,我们直接上代码。下面是一个完整的字符设备驱动模板,包含了自动创建设备节点的逻辑。

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/device.h>
#include <linux/cdev.h>
#include <linux/slab.h>

#define DEVICE_NAME "my_char_dev"
#define CLASS_NAME  "my_char_class"

static int major;
static struct class *my_class;
static struct device *my_device;
static struct cdev my_cdev;

static int my_open(struct inode *inode, struct file *file) {
    pr_info("Device opened\n");
    return 0;
}

static int my_release(struct inode *inode, struct file *file) {
    pr_info("Device closed\n");
    return 0;
}

static struct file_operations fops = {
    .owner = THIS_MODULE,
    .open = my_open,
    .release = my_release,
};

static int __init my_init(void) {
    dev_t dev_num;
    int ret;

    // 1. 分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("Failed to allocate device number\n");
        return ret;
    }
    major = MAJOR(dev_num);

    // 2. 初始化 cdev 并添加到内核
    cdev_init(&my_cdev, &fops);
    my_cdev.owner = THIS_MODULE;
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        pr_err("Failed to add cdev\n");
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    // 3. 创建 class
    my_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(my_class)) {
        pr_err("Failed to create class\n");
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_class);
    }

    // 4. 创建设备节点
    my_device = device_create(my_class, NULL, dev_num, NULL, DEVICE_NAME);
    if (IS_ERR(my_device)) {
        pr_err("Failed to create device\n");
        class_destroy(my_class);
        cdev_del(&my_cdev);
        unregister_chrdev_region(dev_num, 1);
        return PTR_ERR(my_device);
    }

    pr_info("Driver loaded, major=%d\n", major);
    return 0;
}

static void __exit my_exit(void) {
    device_destroy(my_class, MKDEV(major, 0));
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(MKDEV(major, 0), 1);
    pr_info("Driver unloaded\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Auto device node creation example");
关键点: 注意清理顺序。卸载驱动时,要先销毁 device,再销毁 class,最后删除 cdev 和释放设备号。顺序反了会导致内核崩溃,我当年刚学的时候就被这个坑过。

4.3 内存映射与 mmap

接下来聊一个更高级的话题——mmap。说白了,就是把内核空间的物理内存或 I/O 内存,直接映射到用户空间。这样用户程序就能像访问普通内存一样访问硬件寄存器,省去了 read/write 的系统调用开销。

4.3.1 为什么需要 mmap?

你想想看,如果每次读写一个寄存器都要调用 read/write,那性能得多差?尤其是视频采集、高速数据采集这类场景,每秒几万次甚至几十万次的系统调用,CPU 全花在上下文切换上了。

mmap 的解决方案是:在用户空间和内核空间之间建立一条「直通车」。用户程序通过 mmap() 系统调用拿到一块内存的虚拟地址,之后读写这块内存就直接操作硬件了,不再经过内核。

4.3.2 mmap 在内核中的实现

驱动中要实现 mmap,需要在 file_operations 结构体中提供 .mmap 回调函数。核心工作是调用 remap_pfn_range 或 io_remap_pfn_range 来建立页表映射。

static int my_mmap(struct file *file, struct vm_area_struct *vma) {
    unsigned long phys_addr = 0x10000000;  // 假设硬件寄存器物理地址
    unsigned long size = vma->vm_end - vma->vm_start;
    unsigned long pfn = phys_addr >> PAGE_SHIFT;

    // 设置 VMA 标志:禁止交换、禁止拷贝
    vma->vm_flags |= VM_IO | VM_DONTEXPAND | VM_DONTDUMP;

    // 建立物理地址到用户空间的映射
    if (remap_pfn_range(vma, vma->vm_start, pfn, size, vma->vm_page_prot)) {
        pr_err("remap_pfn_range failed\n");
        return -EAGAIN;
    }

    pr_info("mmap: phys=0x%lx, size=%lu, vma=0x%lx\n",
            phys_addr, size, vma->vm_start);
    return 0;
}

static struct file_operations fops = {
    .owner = THIS_MODULE,
    .mmap = my_mmap,
    // ... 其他回调
};
警告: remap_pfn_range 只能映射连续的物理内存。如果你要映射的是通过 kmalloc 分配的虚拟内存,需要用 virt_to_phys 先转成物理地址。但更推荐的做法是用 dma_alloc_coherent 分配一致性内存,它返回的就是物理连续且适合 DMA 的内存。

4.3.3 用户空间如何使用 mmap

用户程序这边就简单多了,标准的 POSIX 接口:

#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

int main() {
    int fd = open("/dev/my_char_dev", O_RDWR);
    if (fd < 0) {
        perror("open");
        return -1;
    }

    // 映射 4KB 内存
    void *map_base = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                          MAP_SHARED, fd, 0);
    if (map_base == MAP_FAILED) {
        perror("mmap");
        close(fd);
        return -1;
    }

    // 直接读写硬件寄存器
    volatile unsigned int *reg = (volatile unsigned int *)map_base;
    printf("Register value: 0x%x\n", *reg);
    *reg = 0x12345678;

    munmap(map_base, 4096);
    close(fd);
    return 0;
}

这里有个细节要注意:MAP_SHARED 标志表示对映射区的修改会写回文件(也就是硬件),而 MAP_PRIVATE 则不会。驱动开发中几乎都用 MAP_SHARED。

4.4 本章知识体系

为了让你更直观地理解这些概念之间的关系,我画了一张图:

字符设备驱动进阶:自动节点与mmap 用户空间 (User Space) 用户程序 udev / mdev 系统调用层 (System Call) open / read / write mmap uevent 内核空间 (Kernel Space) file_operations .mmap / .open / .release class_create device_create remap_pfn_range 物理内存映射 说明:用户程序通过系统调用与驱动交互;class/device_create 触发 uevent 通知 udev/mdev 创建设备节点;mmap 建立用户空间到物理内存的直接映射

4.5 避坑指南

最后分享几个我实际项目中踩过的坑:

  • 设备节点权限问题: 默认创建的设备节点权限是 0600,普通用户无法访问。可以在 device_create 之后用 dev_set_drvdata 设置权限,或者在 udev 规则中修改。我曾经在客户现场调试,发现驱动加载成功了但应用程序打不开设备,最后发现是权限问题。
  • mmap 的大小必须是页对齐: 内核以页为单位管理内存,所以 mmap 的大小必须是 PAGE_SIZE(通常是 4096)的整数倍。如果你只映射 100 字节,内核也会给你一页。
  • 不要映射 I/O 内存为可缓存: 硬件寄存器通常要求不可缓存(uncacheable),否则 CPU 的缓存会导致读写顺序错乱。在 vma->vm_page_prot 中要用 pgprot_noncached 或 pgprot_writecombine 来设置。
  • class 名称不要重复: 内核中 class 名称是全局唯一的。如果你加载了两个同名的 class,第二个会失败。我建议在 class 名称中加入驱动名或厂商名前缀。
调试小技巧: 当设备节点没有自动生成时,可以手动查看 /sys/class/你的class名/ 目录下有没有对应的设备文件。如果 sysfs 中有,说明驱动侧没问题,问题出在 udev/mdev 配置上。反之,如果 sysfs 中都没有,那就是驱动里的 class_create 或 device_create 没执行成功。