简介:面向嵌入式驱动初学者的完整实验资料,对应西安电子科技大学“嵌入式驱动程序设计”实验一——简单字符设备驱动。压缩包共5个文件,包括实验报告文档、驱动模块源码、应用层测试程序、两张运行结果截图,整体仅465KB,内容精炼。实验报告明确实验目的:掌握简单字符设备驱动程序的编写方法,并编写应用程序测试,理解驱动与应用的调用关系,实验环境基于Linux 3.14.0与嵌入式开发板。源码实现全局结构体指针初始为NULL,open时动态分配含1024字节buffer和count计数的结构,清空缓冲区并自增计数;release按计数判断是否释放;read和write分别实现缓冲区读取与任意赋值,测试程序可直接运行验证。适合嵌入式Linux入门者学习字符设备驱动的基本框架、内存管理和引用计数思路。资源已有477人学习,是实践性很强的参考样例。
1. 一个字符设备驱动实验,凭什么让人卡一整晚
我第一次做某高校的嵌入式驱动程序设计实验一时,以为写字符设备驱动跟写普通 C 程序差不多:main 函数、printf、编译、运行。结果第一次 insmod 就翻车了,dmesg 里只有一行 "Invalid module format",整整一晚上都在跟内核版本和编译环境较劲。后来才明白,简单字符设备驱动这个实验,卡的从来不是代码量,而是你对"用户态程序和内核模块是两套世界"这一点的理解深度。
简单字符设备驱动,指的是通过 /dev 下的设备节点,以文件读写方式跟内核驱动交互的那类设备。这个实验通常是嵌入式驱动程序设计的第一课,目标是让你亲手实现一个可以被 open、read、write、release 的内核模块,并搞懂设备号、file_operations、模块加载与卸载这几块地基知识。适合刚接触 Linux 驱动、想快速建立"驱动到底怎么被系统调用"完整链路的人。做完这一个实验,后面再看平台驱动、中断、设备树,都会顺不少。
2. 写代码前先定三个参数:设备号、file_operations 和注册方式
2.1 为什么实验一必须从字符设备开始:三类设备的本质区别
Linux 把设备抽象成三类:字符设备、块设备、网络设备。网络设备走套接字接口,不经过 /dev 节点;块设备以固定大小的块为单位读写,还要处理页缓存和调度队列;而字符设备本质上就是一个字节流,你往里面写什么、驱动按什么顺序拿,完全由驱动自己定义。实验一选字符设备,不是因为它简单,而是因为它能让你用最少的代码把"设备模型 -> 设备号 -> 回调函数"这条链路走通。
我在做实验时最喜欢用字符串当测试数据,原因很直接:字符设备驱动最典型的场景就是"接受用户态数据、在驱动内部保存、再按需返回",字符串天然适合验证这个读写回路。你 echo 一句 hello 到设备节点,再 cat 出来,整个数据流一目了然。相比之下,如果一上来就做块设备驱动,光一个 bio 请求的处理就够写几百行,根本不适合当第一个实验。
2.2 主设备号与次设备号:驱动如何被内核"找到"
设备号是驱动在内核里的"身份证",由主设备号和次设备号两部分组成。主设备号用于关联驱动,告诉内核"这个设备归哪个驱动管";次设备号用于区分同一驱动下的多个实例,比如两个串口可以共用主设备号,但用次设备号 0 和 1 区分。在代码里,设备号类型是 dev_t,内核提供 MAJOR() 和 MINOR() 两个宏来拆分它。
dev_t dev = MKDEV(240, 0); printk("major=%d minor=%d\n", MAJOR(dev), MINOR(dev));这里 MKDEV(240, 0) 是手动构造设备号,主设备号 240,次设备号 0。但在实验里,我不建议你手工指定主设备号,因为你不知道它是否跟系统里已有驱动冲突。更稳的做法是用 register_chrdev(0, ...) 让内核自动分配,内核会返回一个空闲的主设备号,然后你再通过 mknod 创建对应的设备节点。这个"动态分配"的思路,跟后面做真实驱动时用 alloc_chrdev_region 完全一致,只是接口老一点、用法更直接。
2.3 file_operations:把"文件操作"翻译成"驱动回调"
字符设备驱动的核心是 struct file_operations,这个结构体里的成员是一堆函数指针,open、read、write、release 分别对应你在用户态打开文件、读文件、写文件、关闭文件的操作。用户态调用 open("/dev/mydev", O_RDONLY),内核 VFS 层根据设备号找到驱动注册的 file_operations,然后调用你写的 open 回调。
这里有一个非常关键的认知:用户态的 read/write 操作的是"用户缓冲区",内核驱动的 read/write 回调拿到的是"内核缓冲区的指针"。字符设备驱动必须用 copy_to_user 和 copy_from_user 来搬运数据,防止用户传入非法地址直接让内核崩溃。所以我一般会在实验一就反复强调一个原则:回调函数里不要直接操作 buf 指针,必须经过 uaccess 接口。
2.4 register_chrdev 与 cdev 接口:老方式和新方式的取舍
注册字符设备有两条常见路径。老接口是 register_chrdev(major, name, &fops),一个函数同时完成设备号注册和 file_operations 注册,简单直白,非常适合实验一去理解流程,但它的次设备号支持范围固定是 0~255,而且无法精细管理多个设备的注册细节。新接口是 cdev_init + cdev_add + alloc_chrdev_region,更贴近内核现在的实现,适合真实项目。
我自己的建议是:实验一老老实实用 register_chrdev 把主框架跑通,之后再做第二次实验时切到 cdev 接口,体会两者的差异。因为实验一的核心目标是验证"设备号 + file_operations + 模块生命周期"这条主线,老接口少一步 cdev_add,排查问题时更容易定位。等主线清晰了,再用新接口补上"次设备号管理"和"设备类"的概念,学习曲线不会太陡。
3. 从零写一个可编译可加载的最小字符设备驱动
3.1 模块骨架:init 与 exit 里到底要做什么
内核模块的入口不再是 main,而是 module_init 指定的初始化函数和 module_exit 指定的退出函数。初始化函数一般负责:申请资源、注册设备、初始化状态;退出函数负责反方向释放。实验一里最小化的做法,就是 init 里调用 register_chrdev,exit 里调用 unregister_chrdev。下面是最小的骨架:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init mydev_init(void) { printk(KERN_INFO "mydev: module loaded\n"); return 0; } static void __exit mydev_exit(void) { printk(KERN_INFO "mydev: module unloaded\n"); } module_init(mydev_init); module_exit(mydev_exit); MODULE_LICENSE("GPL");先别急着加设备操作,把这个空模块编译并加载成功,你就已经跨过了第一个坎:内核编译环境是通的。__init 和 __exit 两个标记告诉内核,这部分内存用完后可以释放或丢弃,对嵌入式环境尤其重要。MODULE_LICENSE("GPL") 不止是声明代码许可证,很多内核导出符号(比如 copy_to_user 相关的一些 helper)要求模块声明 GPL 协议才能使用,漏掉时某些版本上会出现警告甚至链接问题。
3.2 file_operations 实现:一个带缓冲区的最小驱动
接下来把核心逻辑加进去。我用一个全局数组作为设备缓冲区,实现 open、read、write、release 四个回调。read 将缓冲区内容拷贝到用户态,write 将用户态数据存入内核缓冲区,release 只打印日志。这个实现足以验证整个链路:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> #include <linux/uaccess.h> #define DEVICE_NAME "mydev" #define BUFFER_SIZE 128 static int mydev_major; static char mydev_buf[BUFFER_SIZE]; static int mydev_len; static int mydev_open(struct inode *inode, struct file *filp) { printk(KERN_INFO "mydev: open\n"); return 0; } static ssize_t mydev_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { size_t bytes; if (mydev_len == 0) return 0; bytes = min(count, (size_t)mydev_len); if (copy_to_user(buf, mydev_buf, bytes)) return -EFAULT; mydev_len = 0; return bytes; } static ssize_t mydev_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { if (count >= BUFFER_SIZE) return -ENOMEM; if (copy_from_user(mydev_buf, buf, count)) return -EFAULT; mydev_buf[count] = '\0'; mydev_len = count; return count; } static int mydev_release(struct inode *inode, struct file *filp) { printk(KERN_INFO "mydev: release\n"); return 0; } static struct file_operations mydev_fops = { .owner = THIS_MODULE, .open = mydev_open, .read = mydev_read, .write = mydev_write, .release = mydev_release, }; static int __init mydev_init(void) { mydev_major = register_chrdev(0, DEVICE_NAME, &mydev_fops); if (mydev_major < 0) { printk(KERN_ERR "mydev: register failed\n"); return mydev_major; } printk(KERN_INFO "mydev: major=%d\n", mydev_major); return 0; } static void __exit mydev_exit(void) { unregister_chrdev(mydev_major, DEVICE_NAME); printk(KERN_INFO "mydev: unregistered\n"); } module_init(mydev_init); module_exit(mydev_exit); MODULE_LICENSE("GPL");这套代码里几个参数值得细看。register_chrdev 的第一个参数填 0 表示由内核分配主设备号,返回值就是分配到的设备号,负数表示失败。read 回调的返回值表示"实际读取的字节数",返回 0 代表 EOF;write 回调的返回值必须是"实际接受的字节数",用户态 write 函数会拿它跟请求字节数比对,不一致会报 partial write。copy_to_user / copy_from_user 返回 0 表示成功,返回非零表示未拷贝成功的字节数,这里统一返回 -EFAULT,用户态会直接拿到 EFAULT 错误。
关于 read 回调里那个"读后清空",是我有意为之。它让实验的验证逻辑变得非常清晰:echo 一次、cat 一次、再 cat 就为空,你立刻能感知到 read 回调被执行了。如果你希望 buffer 保留内容,就引入一个内部读偏移,把 offset 纳入管理,但实验一不必走到那一步。
3.3 Makefile:让内核构建系统替你编译
内核模块不能在用户态直接用 gcc 编译,必须借助内核的构建系统。你只需要写一个很短的 Makefile,把源文件名告诉内核,并指明内核源码目录:
obj-m := mydev.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) cleanobj-m 表示编译成模块,而不是编进内核镜像;如果写成 obj-y 就会编入内核 vmlinux,这在实验里几乎不会用。KDIR 指向当前运行内核的构建目录,常见发行版装好内核头文件后就有这个路径。M=$(PWD) 告诉内核构建系统"去当前目录找模块源码",modules 目标负责生成 .ko 文件。执行 make 之后,你会在目录里看到 mydev.ko,这才是真正的可加载模块文件。
这里有个常见误解:不是把 C 文件编译成 .o 就行,内核模块的最终产物是 .ko,它带有内核模块元数据和重定位信息,必须由内核构建系统来生成。如果头文件路径报错,先看 KDIR 是否存在;如果 make 提示缺少 gcc 或 make 版本不对,装基础工具链就能解决。嵌入式交叉编译时,要把 KDIR 换成目标板的内核源码目录,并给 CC 变量指定交叉编译器,比如 aarch64-linux-gnu-gcc。但实验一如果是在 x86 虚拟机里跑,直接用本机内核最省事。
3.4 加载、创建节点、读写验证:一条龙命令
模块编译好之后,加载和验证的顺序很固定:insmod 加载,dmesg 看日志,mknod 生成设备节点,改变权限,然后用 echo/cat 做读写测试。下面是我每次实验都会完整跑一遍的命令:
sudo insmod mydev.ko dmesg | tail -5 cat /proc/devices | grep mydev sudo mknod /dev/mydev c 240 0 sudo chmod 666 /dev/mydev echo "hello driver" > /dev/mydev cat /dev/mydev sudo rmmod mydev dmesg | tail -3mknod 命令里的 c 表示字符设备,240 是刚才 dmesg 里看到的主设备号,0 是次设备号。如果你忘了先看主设备号,设备节点指向的设备号跟驱动注册的不匹配,打开时照样报 No such device。chmod 666 是为了让当前用户直接读写设备节点,避免每次测试都带 sudo;如果你在意安全,保留 660 并用 sudo 测试也可以。最后 rmmod 卸载模块,再 dmesg 确认 release 和 unregistered 两条日志都出现。
这里要特别说 /proc/devices 的用途:加载模块后,它会把已注册字符设备的主设备号和名字列出来。我一般用它确认 register_chrdev 是否真正成功,而不是只盯着 insmod 的返回值。insmod 成功不代表设备节点能用,设备节点创建成功也不代表驱动逻辑正确,这三件事要分开验证,否则出了问题很难定位到底卡在哪一层。
4. 实验一避坑实录:从 insmod 失败到读写异常的 5 个典型现场
4.1 现象:insmod 报错,dmesg 显示注册设备号失败
有同学执行 insmod 后命令没有任何报错,但打开设备节点总提示 No such device。再细查发现,模块加载成功了,register_chrdev 却返回了负数,日志里有一行 "mydev: register failed"。
原因很简单:register_chrdev(0, ...) 虽然让内核动态分配主设备号,但实验用的内核模块一旦注册失败,insmod 会加载一个"空壳"模块,设备号根本没拿到,后面的 mknod 自然指向不存在。多数情况是设备名跟系统内已有驱动重名了,比如把设备名写成 "tty" 或 "console" 这类被系统占用的名字。
解决方法是换一个不冲突的设备名,比如 mydev0、chardev_demo,或者干脆在 register_chrdev 调用后检查返回值,小于 0 就让模块初始化函数返回错误。检查返回值是内核开发的习惯:用户态程序忽略返回值顶多行为异常,在内核里忽略返回值很可能留下一个半初始化的僵尸设备。
4.2 现象:open 设备节点被拒绝,dmesg 却没有内核报错
明明按步骤 mknod 了,chmod 666 也做了,但打开 /dev/mydev 时返回 Operation not permitted。用 sudo cat 又正常,这通常不是驱动问题,而是设备节点权限跟当前用户的组不匹配。
我在某个实验环境里也遇到过类似问题:设备节点默认属主是 root:root,chmod 666 会把普通用户的读写权限补上,但如果终端用户不在 root 组,并且系统上有 SELinux 或 AppArmor 拦了一道,权限表现就不只是文件 mode 能解释的了。另一个常见变体是 mknod 创建出来的节点主设备号写错,但此时打开返回的往往不是权限错误,而是 No such device。现象不同,排查路径完全不同。
解决方法是先 ls -l /dev/mydev 确认设备号和权限,再用 strace 或直接 sudo 测试来缩小范围。如果是 SELinux 拦截,临时用 setenforce 0 验证一下,但改了安全策略要记得还原。我一般建议:权限问题先别陷入玄学,优先确认 mknod 时的 c 参数和主设备号是否和 /proc/devices 一致,再考虑安全模块。
4.3 现象:echo 字符串后 cat 读出来的内容不对
另一个高频问题:write 进去的数据,用 cat 出来要么多一截乱码,要么完全为空。先说乱码,典型的直接原因是我前面代码里那个 mydev_buf[count] = '\0' 被漏掉了,read 回调把未初始化的数组尾部一起拷贝给了用户态。cat 是按读到什么就输出什么,缓冲区里没初始化过的数据自然会变成乱码。
再看读不到的情况:某些写法在 read 里对 *offset 做了偏移计算,但驱动并没有真的按 offset 管理缓冲区内容。第一次 cat 读到内容,第二次 cat 却为空,这是因为 read 回调里没有真正的"文件位置"概念。字符设备驱动如果不打算支持 lseek,最好直接忽略 offset,把设备当作一个"读了就清空的暂存区"。这在实验一是合理的简化,但你要清楚,真实驱动里读操作需要考虑偏移量和并发访问,不能这么随意。
解决乱码问题很简单:init 时把缓冲区 memset 清零,write 时手动补 \0,read 时严格按 mydev_len 拷贝。解决第二次读不到的问题:要么明确"读后清空"的设计并写清楚注释,要么在 read 里维护一个内部写指针和读指针。实验一用读后清空最直观,也方便验证你写的代码真的执行了。
4.4 现象:rmmod 提示 Device or resource busy,模块卸载不掉
模块卸载时报 "Device or resource busy",最常见的原因是设备节点还在,而且有进程持有它的文件描述符。我见过有人用 tail -f /dev/mydev 挂在后台,或者编辑器打开了设备文件没关,然后 rmmod 怎么都不成功。
另一个没那么直观的原因:你在模块的 open 回调里做了资源分配,但没有在 release 里释放,虽然这不直接导致 busy,但它会让模块状态变得很难预测。Linux 模块卸载时会检查模块引用计数,rmmod 前可以先看 lsmod 里模块的 "Used by" 列,再用 fuser -v /dev/mydev 找出谁占用了设备。
解决的顺序一般是:先确保没有进程在使用设备节点,必要时 rm /dev/mydev 把节点删掉再卸载模块。实验一里最稳妥的卸载顺序:先删设备节点,再 rmmod,最后看 dmesg 确认 exit 函数真的执行到了。
4.5 现象:printk 日志完全看不到,怀疑驱动没跑起来
还有一个出现频率极高的坑:dmesg 里只有 insmod 之前的内核日志,你模块里的 printk 一句都看不到。于是有人怀疑模块没加载成功,反复 insmod/rmmod,其实大部分时候是日志级别把 KERN_INFO 级别的输出过滤掉了。
printk 有日志级别,从 KERN_EMERG 到 KERN_DEBUG,系统默认的 console loglevel 通常只把 KERN_ERR 及以上输出到终端,dmesg 里则一般都能看到。如果你用的是 KERN_INFO,终端上看不到是正常的。我遇到过在自己终端上盯着屏幕观察,发现什么都没有,误以为代码没执行,白白折腾了一个多小时。
正确做法是:先执行 dmesg | tail,再看 insmod 进程本身的报错。如果想强制输出,可以把 printk 里的 KERN_INFO 改成 KERN_ERR,或者用 echo 8 > /proc/sys/kernel/printk 临时提高内核日志级别,但记得实验结束要恢复默认值,否则终端会被内核日志刷屏。
5. 别让驱动成黑匣子:用 strace 和动态调试验证驱动行为
5.1 用 strace 追踪系统调用与回调的对应关系
验证驱动到底有没有执行,不能只靠猜。我用 strace 跟踪一个 echo 写操作,能在用户态看到完整调用链:
strace -f -e open,read,write,close,ioctl echo "abc" > /dev/mydev输出里会出现 open("/dev/mydev", O_WRONLY|O_CREAT|O_TRUNC) 和 write(3, "abc", 4) 这类记录。这一步能帮你确认用户态确实发起了系统调用、文件描述符是多少、数据长度是多少。再配合 dmesg 里的驱动日志,就能把"系统调用 -> VFS -> 驱动回调"这条链路完整对上。实验一最怕的是"不知道卡在哪一层",strace 恰好能回答这个问题。
5.2 给驱动加调试开关:记录读写次数看运行全貌
我习惯在实验一结束前给驱动加两个全局计数器和一个小开关,专门应对多个 open/read/write 交叉发生的场景。用 module_param 接收参数,可以在 insmod 时决定是否开启详细日志,把 "debug=1" 加到 insmod 命令行,驱动就在每次回调里打印参数,否则保持静默:
static int debug = 0; module_param(debug, int, 0644); static ssize_t mydev_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { if (debug) printk(KERN_INFO "mydev: read count=%zu len=%d\n", count, mydev_len); ... }这种做法让我在后面写真实驱动时受益很大:驱动是内核的一部分,出了问题用 gdb 直接跟内核并不方便,让驱动自己开口说话,反而最快。我的习惯是每完成一个回调,先加一行 debug 日志,验证通过后再决定是删掉还是用 debug 开关控制。之前在做文件系统交互相关的调试时,我就是靠这种"关键路径留脚印"的方式,把一次读写卡死的定位时间从大半天缩到了半小时。做实验也一样,别急着抄完代码就跑,先把"如何观察驱动内部"这根弦绷起来,后面遇到复杂驱动才不会两眼一抹黑。希望这段经验能帮到你。
本文还有配套的精品资源,点击获取