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 机制通知用户空间,用户空间的守护进程收到消息后去创建设备节点。
echo /sbin/mdev > /proc/sys/kernel/hotplug 和 mdev -s。我曾经在一个项目里排查了半天,最后发现是启动脚本里漏了这两行。
4.1.2 内核中的自动创建机制
内核提供了一套标准的 API 来实现自动创建设备节点。核心就两个函数:class_create 和 device_create。它们配合起来,能让内核在加载驱动时自动向用户空间发送 uevent。
流程其实很简单:
- 先创建一个 class(类),比如叫 "my_driver_class"
- 再在这个 class 下创建一个 device,指定设备名和主次设备号
- 内核自动发送 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");
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 本章知识体系
为了让你更直观地理解这些概念之间的关系,我画了一张图:
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 没执行成功。