Linux 字符设备驱动从入门到精通:从 register_chrdev 到 cdev 的演进实践
Linux 字符设备驱动从入门到精通从 register_chrdev 到 cdev 的演进实践目录前言核心概念梳理2.1 设备号主设备号与次设备号2.2 关键数据结构struct file与struct file_operations2.3struct cdev字符设备的化身2.4 设备类与设备节点自动创建旧接口register_chrdev的工作方式与缺陷现代 cdev 接口详解4.1 分步注册流程4.2 各函数参数精讲4.3 错误处理与资源回滚实战编写一个支持读写的小驱动5.1 驱动程序完整代码cdev 版本5.2 关键代码逐行剖析5.3 用户空间测试程序编译、加载与测试全流程6.1 Makefile 编写6.2 编译与解决警告6.3 模块的加载与卸载6.4 功能测试与现象解释常见问题与调试技巧7.1insmod: File exists错误排查7.2 设备号查看方法7.3 时间戳异常分析7.4 如何确认当前运行的驱动版本总结与扩展自测题附答案完整代码下载前言在 Linux 系统中设备驱动是连接硬件与用户程序的桥梁。字符设备驱动是最常见的一类驱动它把硬件抽象为一个文件/dev/xxx应用程序通过标准的open、read、write、close等系统调用即可操作硬件。Linux 内核在发展过程中字符设备驱动的注册接口经历了从register_chrdev到基于cdev的现代化接口的演进。本文将以一个最简单的“hello 驱动”为例带你从零开始掌握字符设备驱动的编写并深入理解两种接口的差异与适用场景。本文内容基于 Linux 4.9.88 内核测试平台为 NXP i.MX6ULL 开发板但原理适用于所有 Linux 版本。核心概念梳理2.1 设备号主设备号与次设备号在 Linux 中每个字符设备都由一个设备号唯一标识。设备号是一个 32 位无符号整数dev_t类型其中高 12 位代表主设备号低 20 位代表次设备号。主设备号标识设备对应的驱动程序。同一个驱动程序管理的所有设备通常共享同一个主设备号。次设备号由驱动程序内部使用用于区分同一驱动管理的不同具体设备。例如串口驱动中/dev/ttyS0和/dev/ttyS1主设备号相同次设备号不同。可以通过以下宏操作设备号cdev_t dev MKDEV(major, minor); // 组合成设备号 int major MAJOR(dev); // 提取主设备号 int minor MINOR(dev); // 提取次设备号2.2 关键数据结构struct file与struct file_operations当应用程序调用open(/dev/hello, ...)时内核会为该次打开操作创建一个struct file对象它记录了本次打开的状态信息cstruct file { fmode_t f_mode; // 读写权限 loff_t f_pos; // 当前文件偏移量 unsigned int f_flags; // 打开标志如 O_RDWR const struct file_operations *f_op; // 指向操作函数集 void *private_data; // 驱动私有数据指针 // ... 其他成员 };而struct file_operations则是驱动开发者需要填充的核心结构体它将系统调用与驱动函数进行绑定cstruct file_operations { struct module *owner; loff_t (*llseek) (struct file *, loff_t, int); ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); int (*open) (struct inode *, struct file *); int (*release) (struct inode *, struct file *); // ... 还有很多其他操作 };在我们的驱动中只实现了最基础的open、read、write、release。2.3struct cdev字符设备的化身struct cdev是内核用来描述一个字符设备的结构体cstruct cdev { struct kobject kobj; // 内核对象基础结构 struct module *owner; // 所属模块 const struct file_operations *ops; // 操作函数集 struct list_head list; // 链接到内核的 cdev 链表 dev_t dev; // 起始设备号 unsigned int count; // 管理的次设备号数量 };当我们向内核注册一个cdev后内核就会在内部散列表中记录一条映射“从某设备号开始的连续 N 个设备号均由该 cdev 处理”。此后任何打开这些设备号的请求都会调用该cdev关联的file_operations中的函数。2.4 设备类与设备节点自动创建早期的 Linux 系统中设备节点需要手动使用mknod命令创建十分繁琐。现代内核通过设备模型提供了自动创建设备节点的机制涉及两个关键函数class_create(owner, name)在/sys/class/下创建一个设备类。device_create(class, parent, devt, drvdata, fmt, ...)在/dev/下自动创建一个设备节点并触发udev或mdev设置权限。这种方式使得模块加载后设备节点自动出现模块卸载后节点自动消失极大方便了开发和部署。旧接口register_chrdev的工作方式与缺陷函数原型cint register_chrdev(unsigned int major, const char *name, const struct file_operations *fops);若major为0则由内核动态分配一个主设备号并返回该主设备号。该函数一次性完成三件事分配设备号、注册 cdev、绑定 fops。问题无论你实际需要多少个次设备号该函数会固定占用 256 个次设备号0~255。对于只需要一个设备节点的简单驱动这是巨大的资源浪费。卸载接口cvoid unregister_chrdev(unsigned int major, const char *name);释放之前占用的主设备号和次设备号范围。示例代码旧方式cstatic int major; static int __init hello_init(void) { major register_chrdev(0, 100ask_hello, hello_drv); // ... 创建 class 和 device return 0; } static void __exit hello_exit(void) { // ... 销毁 device 和 class unregister_chrdev(major, 100ask_hello); }虽然这种方式简单直观但由于其资源浪费且不符合现代内核的设计哲学Linux 2.6 之后推荐使用更精细的cdev接口。现代 cdev 接口详解4.1 分步注册流程现代接口将设备号申请、cdev 初始化、cdev 注册分成独立的步骤申请设备号alloc_chrdev_region()或register_chrdev_region()初始化 cdevcdev_init()向内核添加 cdevcdev_add()同时错误处理中必须按照“后申请的先释放”的原则进行回滚。4.2 各函数参数精讲4.2.1alloc_chrdev_regioncint alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);dev输出参数内核将分配到的起始设备号存入其中。baseminor申请的起始次设备号通常填0。count申请多少个连续的次设备号。按需申请例如只需要一个设备就填1。name设备名称会显示在/proc/devices中。返回值成功返回0失败返回负的错误码。4.2.2cdev_initcvoid cdev_init(struct cdev *cdev, const struct file_operations *fops);将fops绑定到cdev-ops并初始化cdev的其他字段。4.2.3cdev_addcint cdev_add(struct cdev *cdev, dev_t dev, unsigned count);cdev已初始化的 cdev 对象。dev该 cdev 管理的起始设备号。count该 cdev 管理的连续次设备号数量必须 ≤alloc_chrdev_region中申请的数量。函数执行后内核将记录映射关系。4.2.4 注销顺序卸载时必须严格按相反顺序释放资源ccdev_del(hello_cdev); // 1. 移除 cdev unregister_chrdev_region(dev, count); // 2. 释放设备号注意cdev_del必须在unregister_chrdev_region之前调用以避免设备号被新驱动抢占后仍可访问到旧的 cdev。4.3 错误处理与资源回滚由于资源是分步申请的如果中间某一步失败必须将之前已申请的资源释放掉否则会造成资源泄漏。初始化函数中的典型回滚逻辑cret alloc_chrdev_region(dev, 0, 2, hello); if (ret) return ret; cdev_init(hello_cdev, fops); ret cdev_add(hello_cdev, dev, 2); if (ret) { unregister_chrdev_region(dev, 2); // 回滚第一步 return ret; } hello_class class_create(THIS_MODULE, hello_class); if (IS_ERR(hello_class)) { cdev_del(hello_cdev); // 回滚第二步 unregister_chrdev_region(dev, 2); // 回滚第一步 return PTR_ERR(hello_class); }教学代码中有时会省略回滚以保持简洁但在生产级驱动中完整的错误处理是必须的。实战编写一个支持读写的小驱动5.1 驱动程序完整代码cdev 版本文件名hello_drv.cc#include linux/cdev.h #include linux/device.h #include linux/fs.h #include linux/init.h #include linux/module.h #include linux/uaccess.h static struct class *hello_class; static struct cdev hello_cdev; static dev_t dev; static unsigned char hello_buf[100]; static int hello_open(struct inode *node, struct file *filp) { printk(KERN_INFO %s %s %d\n, __FILE__, __func__, __LINE__); return 0; } static ssize_t hello_read(struct file *filp, char __user *buf, size_t size, loff_t *offset) { unsigned long len size 100 ? 100 : size; printk(KERN_INFO %s %s %d\n, __FILE__, __func__, __LINE__); if (copy_to_user(buf, hello_buf, len)) return -EFAULT; return len; } static ssize_t hello_write(struct file *filp, const char __user *buf, size_t size, loff_t *offset) { unsigned long len size 100 ? 100 : size; printk(KERN_INFO %s %s %d\n, __FILE__, __func__, __LINE__); if (copy_from_user(hello_buf, buf, len)) return -EFAULT; return len; } static int hello_release(struct inode *node, struct file *filp) { printk(KERN_INFO %s %s %d\n, __FILE__, __func__, __LINE__); return 0; } static const struct file_operations hello_drv { .owner THIS_MODULE, .read hello_read, .write hello_write, .open hello_open, .release hello_release, }; static int __init hello_init(void) { int ret; /* 1. 动态申请设备号次设备号从0开始申请2个 */ ret alloc_chrdev_region(dev, 0, 2, hello); if (ret 0) { printk(KERN_ERR alloc_chrdev_region failed\n); return ret; } /* 2. 初始化 cdev 并绑定 file_operations */ cdev_init(hello_cdev, hello_drv); /* 3. 向内核注册 cdev */ ret cdev_add(hello_cdev, dev, 2); if (ret) { printk(KERN_ERR cdev_add failed\n); unregister_chrdev_region(dev, 2); return ret; } /* 4. 创建设备类 */ hello_class class_create(THIS_MODULE, hello_class); if (IS_ERR(hello_class)) { printk(KERN_ERR class_create failed\n); cdev_del(hello_cdev); unregister_chrdev_region(dev, 2); return PTR_ERR(hello_class); } /* 5. 自动创建设备节点 /dev/hello */ device_create(hello_class, NULL, dev, NULL, hello); printk(KERN_INFO hello driver initialized.\n); return 0; } static void __exit hello_exit(void) { device_destroy(hello_class, dev); class_destroy(hello_class); cdev_del(hello_cdev); unregister_chrdev_region(dev, 2); printk(KERN_INFO hello driver removed.\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple cdev-based hello driver);5.2 关键代码逐行剖析1头文件c#include linux/cdev.h // cdev 相关函数 #include linux/device.h // class_create, device_create #include linux/fs.h // alloc_chrdev_region, file_operations #include linux/uaccess.h // copy_to/from_user2全局变量cstatic dev_t dev; // 设备号 static struct cdev hello_cdev; // cdev 对象 static struct class *hello_class;// 设备类指针 static unsigned char hello_buf[100]; // 数据缓冲区3copy_to_user与copy_from_user用户空间与内核空间不能直接通过指针访问对方的内存必须使用专用函数copy_to_user(to, from, n)从内核拷贝到用户空间。copy_from_user(to, from, n)从用户空间拷贝到内核。两个函数均返回未成功拷贝的字节数成功返回0。示例中我们增加了错误判断若拷贝失败则返回-EFAULT。4申请数量为何是2而不是1本文申请了 2 个次设备号minor 0 和 minor 1但只创建了/dev/hello使用 minor 0。这样做是为了演示cdev_add可以一次性管理多个次设备号同时为后续扩展预留空间。实际产品中应根据需要精确申请。5.3 用户空间测试程序文件名hello_test.cc#include stdio.h #include string.h #include fcntl.h #include unistd.h /* * 用法: * 写: ./hello_test /dev/hello string * 读: ./hello_test /dev/hello */ int main(int argc, char **argv) { int fd; char buf[100]; if (argc 2) { printf(Usage:\n); printf( %s dev [string]\n, argv[0]); return -1; } fd open(argv[1], O_RDWR); if (fd 0) { printf(can not open file %s\n, argv[1]); return -1; } if (argc 3) { int len write(fd, argv[2], strlen(argv[2]) 1); printf(write ret %d\n, len); } else { int len read(fd, buf, sizeof(buf) - 1); buf[len] \0; printf(read str: %s\n, buf); } close(fd); return 0; }编译、加载与测试全流程6.1 Makefile 编写makefile# 指定内核源码树路径根据你的环境修改 KERN_DIR /home/book/100ask_imx6ull-sdk/Linux-4.9.88 # 交叉编译工具链前缀若为本地编译则无需设置 CROSS_COMPILE arm-buildroot-linux-gnueabihf- all: make -C $(KERN_DIR) M$(PWD) modules $(CROSS_COMPILE)gcc -o hello_test hello_test.c clean: make -C $(KERN_DIR) M$(PWD) modules clean rm -rf modules.order Module.symvers rm -f hello_test # 目标模块文件名 obj-m hello_drv.o6.2 编译与解决警告执行bear make后可能会看到如下警告textwarning: ignoring return value of ‘copy_from_user’, declared with attribute warn_unused_result这是因为未检查copy_*_user的返回值。一定要处理返回值否则可能因用户传入非法地址导致内核崩溃。我们在代码中已加入错误判断编译警告消失。编译成功后当前目录下会生成hello_drv.ko内核模块和hello_test测试程序。6.3 模块的加载与卸载将文件推送到开发板bashadb push hello_drv.ko hello_test /root/在开发板上执行bash# 加载模块 insmod /root/hello_drv.ko # 查看内核日志 dmesg | tail -5 # 检查设备节点 ls -l /dev/hello # 查看设备号分配情况 cat /proc/devices | grep hello # 卸载模块 rmmod hello_drv6.4 功能测试与现象解释写入数据bash./hello_test /dev/hello 100ask预期输出write ret 7包括字符串结尾的\0内核日志中可见hello_open、hello_write、hello_release的打印信息。读出数据bash./hello_test /dev/hello预期输出read str: 100ask内核日志中可见hello_open、hello_read、hello_release。验证多设备管理由于申请了两个次设备号我们可以手动创建次设备号为 1 的节点并测试bashmknod /dev/abc c 244 1 # 主设备号请根据实际值修改 ./hello_test /dev/abc 1234 ./hello_test /dev/abc你会看到读写/dev/abc同样成功且数据与/dev/hello共享同一缓冲区因为驱动内部未区分次设备号。这也展示了如何利用次设备号区分不同设备实例。常见问题与调试技巧7.1insmod: File exists错误排查现象加载模块时提示insmod: ERROR: could not insert module hello_drv.ko: File exists可能原因与解决方案模块已加载bashlsmod | grep hello rmmod hello_drv设备节点残留模块已卸载但/dev/hello未删除bashrm -f /dev/hello设备号在/proc/devices中仍有记录极少见通常由于卸载函数不完善导致重启系统是最简单的清理方法。7.2 设备号查看方法查看主设备号cat /proc/devices | grep hello查看完整设备号ls -l /dev/hello输出中244, 0即为主设备号 244次设备号 0。通过 sysfs 查看cat /sys/class/hello_class/hello/dev输出格式244:0。7.3 时间戳异常分析在测试中ls -l /dev/hello显示的时间戳可能是Jan 2 20:27而当前系统时间为Jan 2 20:32 1970。这是因为开发板没有 RTC 电池每次上电系统时间重置为 1970-01-01 00:00:00Unix 纪元。设备节点在模块加载时被创建因此时间戳为当时的系统时间。如果系统时间从未被同步这个时间戳就会显示为 1970 年 1 月 1 日之后的某个时刻。解决方法使用ntpdate同步网络时间或手动date -s 2026-04-16 15:30:00设置。7.4 如何确认当前运行的驱动版本如果你频繁修改驱动代码可能会担心加载的是旧版本。可以通过以下方法确认在驱动初始化函数中加入版本打印cprintk(KERN_INFO hello_drv version: 2026-04-16-v2\n);然后查看dmesg。查看模块文件的修改时间bashls -l /root/hello_drv.ko检查/sys/module/hello_drv/目录如果定义了模块参数或版本信息。总结与扩展通过本文你已掌握了字符设备驱动的核心概念设备号、cdev、file_operations、设备模型。从register_chrdev到cdev接口的演进及其背后的设计思想。完整编写、编译、测试一个基于 cdev 的字符设备驱动。调试常见问题的方法。扩展思考如何实现多个设备各自独立的缓冲区可以在open函数中根据iminor(inode)分配不同的缓冲区并将其地址保存在filp-private_data中后续的read/write即可操作对应缓冲区。如何支持更复杂的 ioctl 命令在file_operations中添加.unlocked_ioctl成员实现命令解析。如何在驱动中使用并发控制信号量、自旋锁当多个进程同时访问驱动时需要保护共享资源如hello_buf防止数据混乱。字符设备驱动是 Linux 驱动开发的基石扎实掌握这部分知识将为你后续学习平台总线驱动、I2C/SPI 等子系统驱动打下坚实基础。自测题附答案1. 请解释struct cdev中的ops成员的作用。答案ops是一个指向struct file_operations的指针它将设备号与具体的操作函数如open、read、write关联起来。当用户程序打开设备时内核根据设备号找到对应的cdev然后通过cdev-ops调用驱动实现的函数。2.alloc_chrdev_region(dev, 0, 2, hello)中参数0和2分别代表什么答案0表示起始次设备号baseminor2表示申请 2 个连续的次设备号即 minor 0 和 minor 1。3. 下面的卸载代码有何问题cunregister_chrdev_region(dev, 2); cdev_del(hello_cdev);答案应该先调用cdev_del再释放设备号。如果先释放设备号其他驱动可能立刻占用同一设备号而此时cdev尚未从内核移除会导致设备号冲突和内核状态不一致。4. 用户程序调用read(fd, buf, 100)时内核如何一步步调用到驱动的hello_read函数答案系统调用read进入 VFS虚拟文件系统。VFS 从fd对应的struct file中获取f_op即hello_drv。调用f_op-read即驱动的hello_read函数。驱动执行copy_to_user将数据返回用户空间。5. 为什么现代驱动推荐使用 cdev 接口而非register_chrdev答案资源利用register_chrdev固定占用 256 个次设备号而 cdev 接口按需申请节省资源。灵活性cdev 接口将设备号申请和 fops 绑定分离一个驱动可注册多个 cdev 管理不同设备。可维护性符合 Linux 设备模型的分层思想代码更清晰。6. 如何让本驱动支持两个独立的设备节点/dev/hello0和/dev/hello1且拥有各自的缓冲区答案将全局hello_buf改为数组char hello_buf[2][100]。在open函数中通过iminor(inode)获得次设备号保存到filp-private_data。在read/write中从filp-private_data取出次设备号操作对应的缓冲区。在hello_init中分别用device_create创建两个设备节点hello0minor 0和hello1minor 1。7. 加载模块时出现insmod: ERROR: could not insert module hello_drv.ko: File exists可能的原因有哪些如何解决答案可能原因模块已经在内存中lsmod检查rmmod卸载。设备节点/dev/hello已存在手动删除。设备号在/proc/devices中残留重启或完善卸载函数。解决方法依次执行rmmod、rm -f /dev/hello、检查/proc/devices必要时重启。