字符设备驱动开发实践:设备号、file_operations与/dev节点创建

发布时间:2026/10/11 14:50:11
字符设备驱动开发实践:设备号、file_operations与/dev节点创建 简介这是面向XDU嵌入式驱动程序设计实验一的配套资源包目标读者是正在学习Linux字符设备驱动的本科生或嵌入式入门开发者。资源围绕“简单字符设备驱动”这一实验题目提供了完整的实验报告、驱动程序源码和测试程序并附有运行截图便于读者对照复现。实验要求实现全局结构指针、动态内存分配、引用计数增减以及buffer的读写操作代码逻辑清晰可直观展示应用程序通过系统调用进入驱动层的过程帮助理解字符设备驱动的核心框架。包内共5个文件包括2个C源码文件、1份docx实验报告以及2张实验运行截图压缩包整体仅465KB内容精炼、无冗余文件。目前已有477人学习下载适合正在完成同类课程设计、准备实验答辩或希望快速上手字符设备驱动编写的人群是嵌入式驱动入门阶段的实用参考资料。1. 字符设备驱动实验入门第一个能跑通的 /dev 节点嵌入式驱动开发的第一个实验通常是写一个简单的字符设备驱动。这个实验看起来容易——无非分配一个设备号、实现几个回调函数、注册进内核——但真正动手的人都知道从「模块编译通过」到「应用层能稳定读写」中间隔着设备节点、权限、内核态内存拷贝这几个谁都绕不过去的门槛。这个实验包把整条链路拆开了内核模块源码、Makefile 构建脚本、用户态测试程序以及文档里对每一步为何这么写的解释。适合刚接触 Linux 驱动开发、想搞懂设备驱动到底怎么运行的人也适合复习驱动框架的从业者。它要解决的是最实际的问题让驱动跑起来并且看得到每个环节的数据流向。2. 字符设备驱动的工作原理设备号、file_operations 与系统调用路径用户在应用层写 open(/dev/chardev0, O_RDWR) 的那一刻内核实际上做了三件事通过设备文件的设备号找到字符设备通过该设备的 file_operations 回调表找到驱动函数再从用户态陷入内核态执行具体代码。理解这条链路是驱动开发的第一步。很多人在实验里反复 insmod 失败、open 报错最后回溯到根因几乎都是这条链路的某一环断了。2.1 设备号分配主设备号与次设备号的区别Linux 用 dev_t 类型表示设备号它是一个 32 位无符号整数高 12 位是主设备号major低 20 位是次设备号minor。主设备号用于定位驱动程序像门牌号一样告诉内核谁来处理这个设备次设备号用于区分同一驱动下的多个设备实例。比如同样是串口驱动ttyS0 和 ttyS1 靠次设备号区分主设备号都是同一个。查看 /proc/devices 可以确认设备号状态。这个文件列出当前系统所有已注册的字符设备和块设备名及主设备号。驱动调试阶段这个文件的地位和 dmesg 几乎一样重要模块加载后先来这里确认设备号有没有注册上再考虑后面的事。分配设备号有两种常见方式。手动注册使用 register_chrdev_region(dev_t first, unsigned count, const char *name)first 是自己在代码里预定的设备号动态分配使用 alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)由内核自动挑选一个当前空闲的主设备号。实验包里的代码采用的是动态分配#include linux/fs.h #include linux/kernel.h #include linux/module.h #define DEVICE_NAME chardev0 static int major_num; static dev_t dev_num; static int __init chardev_init(void) { int ret; // 动态分配一个字符设备号起始次设备号为 0分配个数为 1 ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { pr_err(Failed to alloc chrdev region, ret %d\n, ret); return ret; } major_num MAJOR(dev_num); pr_info(chardev: allocated major%d minor%d\n, major_num, MINOR(dev_num)); return 0; } static void __exit chardev_exit(void) { unregister_chrdev_region(dev_num, 1); pr_info(chardev: unregistered device region\n); } module_init(chardev_init); module_exit(chardev_exit); MODULE_LICENSE(GPL);代码的逻辑很简单模块加载函数里申请设备号并提取主设备号卸载函数里释放设备号。注意 alloc_chrdev_region 的第一个参数是出参函数执行成功之后 dev_num 才真正有效返回值小于 0 说明申请失败这时必须中断初始化流程不能继续往下注册设备。MAJOR 和 MINOR 是标准宏从 dev_t 里拆出主、次设备号这里只需要主设备号用于后续的 mknod。MODULE_LICENSE(GPL) 不是可有可无的。内核模块如果不声明 GPL 兼容许可证部分内核符号无法引用加载时会出现 unknown symbol 类型的错误。实验阶段养成写许可证声明的习惯后面接触更复杂的内核接口时会少踩很多坑。动态分配的好处是不需要去翻系统里哪些主设备号被占用了系统会自动选一个空闲的省掉了环境相关的麻烦。2.2 file_operations 回调表驱动行为的最小集合字符设备驱动的行为全部由 file_operations 结构体定义。这个结构体包含几十个函数指针实际使用时不需要全部填充只实现驱动需要的那几个其余保持 NULL 即可。最简单的字符设备驱动至少要实现四个回调open、read、write、release。这四个函数分别对应应用层的 open()、read()、write()、close() 系统调用。它们的原型长这样static int chardev_open(struct inode *inode, struct file *filp); static ssize_t chardev_read(struct file *filp, char __user *buf, size_t count, loff_t *offset); static ssize_t chardev_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset); static int chardev_release(struct inode *inode, struct file *filp); static struct file_operations chardev_fops { .owner THIS_MODULE, .open chardev_open, .read chardev_read, .write chardev_write, .release chardev_release, };owner 字段填 THIS_MODULE作用是把回调表与模块实例关联起来防止模块正在被使用时被卸载产生悬空的函数指针。open 返回 0 表示成功返回负值表示打开失败这个返回值会直接传递给应用层的 open()所以错误码必须语义准确。read 和 write 的 buf 参数带有 __user 标记表示它指向用户态地址空间。驱动内不能直接解引用这个指针必须通过 copy_to_user 和 copy_from_user 完成数据拷贝。这是内核态与用户态隔离的核心规则也是最容易引发内核崩溃的地方。后续第 3 章写代码时会重点看到这两个函数的使用方式。2.3 一次 read 系统调用的完整路径应用层调用 read(fd, buf, len) 后glibc 封装把它送入内核。内核通过 fd 找到对应的 struct file再通过 file-f_op-read 定位到 chardev_read 函数。chardev_read 执行完数据拷贝后返回读取的字节数这个返回值沿系统调用路径返回最终成为应用层 read() 函数的返回值。这条路径把设备文件、设备号、cdev、file_operations 四个对象串在一起。设备节点通过设备号关联到已注册的 cdevcdev 内部保存了 fops 指针。所以注册字符设备的标准流程是分配设备号 → cdev_init → cdev_add → 创建设备节点顺序不能乱。cdev_add 之前设备号必须已经申请成功否则内核无法把设备号映射到 cdev设备节点必须在 cdev_add 之后创建否则打开设备文件时内核找不到 cdevopen 直接返回失败。很多实验中的诡异问题比如模块加载成功但 open 报 No such device基本都能回溯到设备号没对上或 cdev 注册不完整这两个原点。把这一步想清楚后面再写平台设备、PCI 设备驱动时会发现框架逻辑完全一致只是资源获取和注册的具体接口不同。3. 动手实现一个简单字符设备驱动从代码到可读写的 /dev 节点第 2 章讲完原理这一章开始写完整驱动并让它真正跑起来。实验包里驱动部分包含几个文件chardev.c 是驱动主体Makefile 负责内核模块构建test_app.c 是用户态测试程序README 记录实验步骤和遇到问题的排查方法。先写驱动主文件再编译加载最后创建设备节点。3.1 驱动主文件注册、初始化与核心回调实验驱动的核心逻辑是维护一块内核缓冲区。应用层向设备写入的数据存进缓冲区读的时候再从缓冲区取出来。这样设计的用意是避开真实硬件专注验证驱动框架本身是否跑通。缓冲区由 kzalloc 分配在模块卸载时释放。完整的 chardev.c 结构如下模块加载函数完成设备号申请、cdev 初始化和添加、缓冲区分配模块卸载函数反向执行清理open 和 release 先打印日志后续可以加设备实例管理逻辑read 把内核缓冲区数据拷贝到用户态write 把用户态数据接收进来。#include linux/cdev.h #include linux/fs.h #include linux/kernel.h #include linux/module.h #include linux/slab.h #include linux/uaccess.h #define DEVICE_NAME chardev0 #define BUFFER_SIZE 128 static int major_num; static dev_t dev_num; static struct cdev chardev_cdev; static char *device_buf; static int buf_len; static int chardev_open(struct inode *inode, struct file *filp) { pr_info(chardev: open called\n); return 0; } static ssize_t chardev_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t bytes_to_read; // 如果缓冲区已读完返回 0 表示 EOF if (*offset buf_len) return 0; // 本次实际读取字节数不能超过缓冲区剩余数据 bytes_to_read min(count, (size_t)(buf_len - *offset)); // 将内核缓冲区数据拷贝到用户态 buf if (copy_to_user(buf, device_buf *offset, bytes_to_read)) { pr_err(chardev: copy_to_user failed\n); return -EFAULT; } *offset bytes_to_read; return bytes_to_read; } static ssize_t chardev_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { size_t bytes_to_write; // 写入长度超出缓冲区容量时截断 bytes_to_write min(count, (size_t)BUFFER_SIZE); // 从用户态拷贝数据到内核缓冲区 if (copy_from_user(device_buf, buf, bytes_to_write)) { pr_err(chardev: copy_from_user failed\n); return -EFAULT; } buf_len bytes_to_write; *offset 0; // 重置读偏移让后续 read 从头开始 pr_info(chardev: write %zu bytes\n, bytes_to_write); return bytes_to_write; } static int chardev_release(struct inode *inode, struct file *filp) { pr_info(chardev: release called\n); return 0; } static struct file_operations chardev_fops { .owner THIS_MODULE, .open chardev_open, .read chardev_read, .write chardev_write, .release chardev_release, }; static int __init chardev_init(void) { int ret; // 第 1 步动态分配设备号 ret alloc_chrdev_region(dev_num, 0, 1, DEVICE_NAME); if (ret 0) { pr_err(chardev: alloc_chrdev_region failed\n); return ret; } major_num MAJOR(dev_num); pr_info(chardev: major number %d\n, major_num); // 第 2 步初始化 cdev 并关联 file_operations cdev_init(chardev_cdev, chardev_fops); chardev_cdev.owner THIS_MODULE; // 第 3 步将 cdev 添加到内核与设备号绑定 ret cdev_add(chardev_cdev, dev_num, 1); if (ret 0) { pr_err(chardev: cdev_add failed\n); unregister_chrdev_region(dev_num, 1); return ret; } // 第 4 步分配内核缓冲区 device_buf kzalloc(BUFFER_SIZE, GFP_KERNEL); if (!device_buf) { cdev_del(chardev_cdev); unregister_chrdev_region(dev_num, 1); return -ENOMEM; } buf_len 0; pr_info(chardev: init done\n); return 0; } static void __exit chardev_exit(void) { kfree(device_buf); cdev_del(chardev_cdev); unregister_chrdev_region(dev_num, 1); pr_info(chardev: exit done\n); } module_init(chardev_init); module_exit(chardev_exit); MODULE_LICENSE(GPL);这段代码有几个需要仔细理解的细节。read 回调里用 *offset 记录读位置当 *offset 达到 buf_len 时返回 0正好对应用户态 read 的 EOF 语义。write 回调在写入后把 *offset 重置为 0这样每次写入新数据后应用层从头读取就能拿到最新内容逻辑简单且符合实验预期。copy_to_user 和 copy_from_user 的返回值表示未成功拷贝的字节数返回 0 才是全部成功所以判断条件用 if (copy_to_user(...)) 而不是 if (copy_to_user(...) 0)。这是很多初学者第一次写内核代码时最容易犯的错误一旦写反驱动行为会完全颠倒。初始化流程必须每个步骤都有错误回滚cdev_add 失败时要释放设备号kzalloc 失败时既要删除 cdev 也要释放设备号。不这么做模块虽然加载失败但设备号已经占用了下次再 insmod 时可能报 resource busy。3.2 Makefile 与编译内核模块构建的最小工程内核模块不能用 gcc 直接编译必须借助内核的 kbuild 系统。实验包里的 Makefile 是标准写法obj-m : chardev.o KERNEL_DIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) cleanobj-m 告诉 kbuild 将 chardev.o 编译成可加载的内核模块。KERNEL_DIR 指向当前内核的构建目录标准发行版中 /lib/modules/$(uname -r)/build 是软链接指向内核头文件和构建配置所在的位置。执行 make 后会生成 chardev.ko这就是可以 insmod 的模块文件。编译最常见的报错是内核头文件版本与运行内核不一致。比如你在旧内核头文件上编译出的 .ko 拿到新内核加载会报 invalid module format 或 version magic 不匹配。排查方式很简单先 uname -r 确认运行内核版本再检查 /lib/modules/对应版本/build 目录是否存在。如果目录缺失说明没装内核头文件包需要先补上。3.3 加载模块与创建设备节点insmod、mknod、chmod 的顺序模块编译好之后按以下顺序操作sudo insmod chardev.ko dmesg | tail -20 cat /proc/devices | grep chardev sudo mknod /dev/chardev0 c 240 0 sudo chmod 666 /dev/chardev0先加载模块再从 dmesg 或 /proc/devices 获取实际主设备号然后用 mknod 创建设备节点。命令里的 c 表示字符设备240 要替换为实际主设备号0 是次设备号。chmod 666 是给设备节点开放普通用户读写权限调试期间省去频繁 sudo 的麻烦但产品环境不能这样放开权限。两个工具的配合方式很直接工具用途关键信息dmesg查看内核打印日志设备号、加载结果、错误原因/proc/devices查看已注册设备号主设备号与设备名对应关系ls -l /dev/chardev0查看设备节点类型、主次设备号是否匹配如果 mknod 后 ls -l 显示的主设备号与 dmesg 打印的不一致应用层 open 必然失败。这是实验中最常见的低级错误排查时要先对齐设备号再查其他。卸载的顺序也有讲究sudo rm -f /dev/chardev0 sudo rmmod chardev先删节点再卸载模块。反过来的话节点文件会残留为一个「空壳」看起来还在但任何 open 操作都会失败而且它还占着 /dev 下的文件名干扰后续排查。这个习惯养成之后中间省掉很多莫名其妙的故障时间。4. 用户态测试应用验证驱动读写行为的完整流程驱动本身不产生数据它的正确性要靠用户态程序来检验。实验包里配套的测试应用覆盖 open、write、read、close 四个操作并打印每次系统调用的返回值。跑通这个测试驱动的基本数据通路才算验证完成。4.1 测试程序打开设备、写入数据、读回校验测试程序的核心逻辑是打开设备节点写入一段字符串再读回来对比。如果读写一致说明驱动的缓冲区管理、数据拷贝、偏移管理都是正确的。#include fcntl.h #include stdio.h #include stdlib.h #include string.h #include unistd.h #define DEVICE_PATH /dev/chardev0 int main(void) { int fd; char write_buf[] Hello from userspace; char read_buf[128] {0}; ssize_t ret; // 以读写方式打开设备文件 fd open(DEVICE_PATH, O_RDWR); if (fd 0) { perror(open failed); exit(EXIT_FAILURE); } // 向驱动写入数据包含结尾的 \0 ret write(fd, write_buf, strlen(write_buf) 1); printf(write ret %zd\n, ret); if (ret 0) { perror(write failed); close(fd); exit(EXIT_FAILURE); } // 从驱动读回数据 ret read(fd, read_buf, sizeof(read_buf)); printf(read ret %zd, data %s\n, ret, read_buf); if (ret 0) { perror(read failed); close(fd); exit(EXIT_FAILURE); } // 检查读写数据是否一致 if (strcmp(write_buf, read_buf) 0) printf(PASS: data matches\n); else printf(FAIL: data mismatch\n); close(fd); return 0; }测试程序的关键判断点有几个。open 返回 -1 时用 perror 打印具体错误原因可能是设备节点不存在、权限不足或驱动没加载write 的返回值如果和传入长度不一致说明驱动没有完整接收数据需要查 write 回调里的长度判断逻辑strcmp 对比写读数据快速判断缓冲区数据是否被篡改。注意 write 时传入的是 strlen(write_buf) 1把结尾的 \0 也写进驱动。这样读回来之后可以直接用 %s 安全打印。如果只写 strlen(write_buf) 个字节read_buf 末尾没有结束符printf 会越界读到未知内存轻则输出乱码重则段错误。4.2 边界测试缓冲区溢出与连续读的 EOF 行为除了完整读写一条数据还需要验证驱动对边界情况的处理。第一类是超出缓冲区容量的写入。实验驱动里 BUFFER_SIZE 是 128如果应用层写入 256 字节驱动应该只接收前 128 字节且返回值是 128。char large_buf[256]; memset(large_buf, A, sizeof(large_buf)); ret write(fd, large_buf, sizeof(large_buf)); printf(large write ret %zd (expected 128)\n, ret);如果第二次 read 仍然返回数据而不是 0说明 offset 管理或 buf_len 更新逻辑有问题缓冲区会被重复读取这在真实设备场景下会引发严重的数据错乱。如果返回值是 256 而不是 128说明驱动没有做长度截断缓冲区写越界了。这个测试虽然简单但能直接暴露驱动里最严重的防御性缺陷。第二类是连续读的 EOF 行为。第一次 read 读走全部数据之后第二次 read 应该返回 0ret read(fd, read_buf, sizeof(read_buf)); printf(second read ret %zd (expected 0)\n, ret);正常驱动的 read 回调会判断 *offset buf_len 并返回 0。如果第二次 read 仍然返回数据说明 offset 管理有问题缓冲区会被重复读取。这种 bug 在真实设备场景下会造成数据错乱和上层逻辑误判。4.3 编译测试程序与实验包目录结构整个实验包的源码结构大致如下. ├── chardev.c # 驱动主体 ├── Makefile # 内核模块构建脚本 ├── test_app.c # 用户态测试程序 └── README.md # 实验步骤与注意事项test_app.c 是普通用户态程序用 gcc 直接编译运行gcc -o test_app test_app.c ./test_app在正常加载驱动并创建设备节点后终端输出顺序是write ret 21、read ret 21随后打印 PASS: data matches。这里的 21 是 Hello from userspace 加上 \0 的字节数。如果你看到的返回值不同先检查驱动里的长度计算是否正确再检查测试程序传入的长度参数。5. 字符设备驱动避坑从加载失败到数据错乱的五个案例这一章把实验过程里最高频的五个问题集中列出来。每一条都按「现象 → 原因 → 解决」的格式记录排查时可以直接对照。这些坑单独看都很简单但组合在一起往往能消耗掉一整个下午。5.1 insmod 报 invalid module format现象insmod 加载 .ko 时提示 invalid module format或者内核日志里出现 version magic 不匹配。原因编译模块使用的内核版本和运行环境不一致。最常见的是系统更新内核后没有重启或者 build 目录指向了旧版本内核头文件导致模块的 vermagic 字符串对不上。解决先 uname -r 查看当前内核版本再确认 /lib/modules/$(uname -r)/build 是否指向这个版本的头文件。如果 build 目录不存在或指向错误版本需要安装对应的内核头文件包并重新编译模块然后再加载。5.2 mknod 之后 open 失败设备号对不上现象设备节点创建成功但应用层 open 返回 No such device 或 Operation not permitted。原因mknod 命令里的主设备号填错了或者驱动里的 cdev_add 没有执行成功。设备节点必须携带正确的设备号才能让内核找到对应的 cdev。解决执行 dmesg | grep chardev 查看驱动注册时打印的主设备号再用 ls -l /dev/chardev0 核对节点上的主设备号。两个数字必须完全一致。如果驱动根本没有打印注册日志说明 cdev_add 之前就出错了需要往更早的错误输出查。5.3 直接解引用用户指针导致内核崩溃现象运行测试程序时系统直接死机或重启重启后 dmesg 显示 general protection fault。原因驱动里把 read/write 回调的 __user 参数当作普通内核指针直接读写。用户态地址空间在内核态没有直接访问权限解引用必然触发异常。这个坑一旦踩中系统会直接挂掉不会给你从容调试的机会。解决所有用户态数据交互必须走 copy_from_user 和 copy_to_user。这是内核与用户态数据隔离的根本规则。实验阶段可以在写完代码后主动扫一遍把可疑的 buf 直接访问全部替换成拷贝函数避免上线后翻车。5.4 copy_to_user 返回非零用户态缓冲区无效现象驱动日志打印 copy_to_user failed应用层 read 返回 -1errno 是 EFAULT。原因应用层传给 read 的 buf 指向了无效内存或者 count 参数大于 buf 的实际容量。驱动返回 -EFAULT 是正确行为问题出在调用方传参上。解决检查测试程序的 buf 是否真的申请了内存、长度参数是否合理。一个典型错误是把 sizeof(指针) 当成了 sizeof(数组)导致内核拷贝超量数据到一块过小的用户缓冲区。传参之前先把长度打印出来确认一遍。5.5 重复加载模块时报 resource busy现象第一次 insmod 成功rmmod 之后再次 insmod 报 Device or resource busy。原因之前加载的模块没有彻底卸载或者设备号没被释放。残留的设备号占用会导致新模块注册失败。有时候是 rmmod 命令本身报模块被占用还没来得及执行完就被中断了留下半吊子状态。解决先 lsmod | grep chardev 检查模块是否还在内核里如果还在就先 rmmod。再看 /dev 下是否残留旧节点用 rm -f 删除。确保 dmesg 里出现 unregistered 日志后再重新加载。如果模块确实被占用需要找到占用它的进程并先释放不能直接强制卸载。6. 从实验一到实战自动创建设备节点与多设备实例扩展实验一里的驱动只管理一个设备设备节点靠手动 mknod 创建。这个模式在实验阶段够用但到了产品级代码里手动创建节点和单实例结构都不够。有两个进阶改动值得做用 class_create 和 device_create 自动创建设备节点以及把单设备扩展成多设备实例。自动创建节点在内核 2.6 之后已经很成熟。在 cdev_add 之后追加两段代码static struct class *chardev_class; chardev_class class_create(THIS_MODULE, chardev_class); if (IS_ERR(chardev_class)) { ret PTR_ERR(chardev_class); goto fail_class; } device_create(chardev_class, NULL, dev_num, NULL, DEVICE_NAME);class_create 的第一个参数传 THIS_MODULE第二个参数是类名会在 /sys/class 下生成对应目录。device_create 会在 /dev 下自动创建设备节点省掉手动 mknod 的步骤也避免了设备号写错的人为失误。卸载时对应执行 device_destroy 和 class_destroy顺序不能颠倒。多设备实例的核心在于用 filp-private_data 区分当前操作的是哪个设备。open 回调里根据 inode 找到对应的设备对象把它挂到 filp-private_data 上read/write 先取 private_data 再操作struct chardev_dev { struct cdev cdev; char *buf; int len; }; static int chardev_open(struct inode *inode, struct file *filp) { struct chardev_dev *dev; // container_of 从 inode 中取出包含 cdev 的设备对象 dev container_of(inode-i_cdev, struct chardev_dev, cdev); filp-private_data dev; return 0; }container_of 是内核里最常用的结构体操作宏通过成员指针反推出整个结构体的地址。这里 inode-i_cdev 指向内嵌的 cdev 结构container_of 就能拿到外层的 chardev_dev 结构。理解了这层关系再去看内核里真实设备的驱动源码会发现基本模式完全一致差别只是设备对象里装配的资源不同。这套验证方法可以延伸得很远加载后可以先 cat /sys/class/chardev_class/chardev0/dev 确认内核分配的设备号再对照 /dev 下的节点是否匹配。每改一次驱动代码我都习惯走一遍「编译 → insmod → 对比设备号 → 应用层读写 → 卸载清理」的最小验证闭环。从那以后凡是这类字符设备驱动我每次都会强制走一遍这个流程少走了很多弯路。实验一虽然简单但把这条闭环走顺了后面的驱动开发都是在同一个框架里填不同的硬件逻辑希望帮到你。本文还有配套的精品资源点击获取