☰
Linux字符设备驱动开发:从file_operations到可读写的设备文件
2026/10/14 11:53:12 网站建设 项目流程

简介:针对XDU(西安电子科技大学)嵌入式驱动程序设计课程实验一“简单字符设备驱动”的完整资料包,适合高校嵌入式方向学生和Linux驱动初学者,用于解决字符设备驱动编写与用户态测试衔接的实际问题。压缩包内共5个文件,包含实验报告docx、驱动程序源码demo_driver.c、测试程序test.c和2张运行截图,整体仅465KB,获取轻量、按需查阅方便。已有477人学习下载。实验围绕全局结构指针与1024字节缓冲区展开,要求open中判断并分配内存、初始化buffer和count,release中当引用计数为0时释放、否则自减,read/write完成数据读写;配套测试程序能直观验证应用层到驱动层的调用流程。附带的实验报告写明实验目的、环境与原理,截图展示运行效果,可帮助读者高效完成实验环节,并加深对字符设备驱动机制的理解,也可作为后续驱动实验的参考模板。

1. 实验一在做一件什么事:把字符设备驱动变成可读写的文件

嵌入式驱动程序设计实验一,通常就是用最简单的方式把一个字符设备驱动跑起来。它和 hello world 模块最大的区别是:驱动没有 main,insmod 之后你看不到任何主动输出,它只是把一堆函数指针注册进内核,之后全靠用户态 open、read、write 这样的文件操作来触发。实验目标通常是一句话:让一个用户态程序像读写普通文件一样,读写一块由驱动维护的内核缓冲区,设备节点挂在 /dev 下。

这个实验解决的不只是“能不能加载”,而是 Linux 驱动里最关键的认知问题:应用层一个 open 或 read,如何一步步走到你写的驱动函数里。适合刚接触内核模块的新手,也适合已经很会写应用、但对设备模型没有概念的开发者。做完它,你已经踩过了设备号、设备节点、用户态拷贝这几个绕不开的坎,后面接触总线驱动、传感器驱动时会顺畅很多。

2. 搭字符设备地基:file_operations、设备号与设备节点必须一起理解

字符设备驱动不是写一个 main,而是把“文件系统希望调用的那组回调”准备好,再把设备号注册进去。很多人在这一章把概念背混了:file_operations 是行为,设备号是身份,设备节点是入口。三者配合的完整链路是:/dev/simple 这个文件通过 inode 里的设备号找到 cdev,cdev 再指向 file_operations,文件系统最终把 open() 转发到驱动里的 open 回调。下面逐个拆开。

2.1 用户态一次 open(),在内核里走过了什么

当你在用户态执行 fd = open("/dev/simple", O_RDWR) 时,内核先按路径找到设备节点,从 inode 里取出主设备号和次设备号,再根据设备号找到已经注册的 cdev,最后从 cdev 中取出 file_operations,调用其中的 open 成员。这个过程没有轮询、没有等待,本质就是一次函数指针跳转。驱动开发者交给内核的东西,核心就是这张函数指针表。

file_operations 里最常用的成员只有几个,实验一先把它们记住就够了:

成员用户态对应操作典型作用
.owner-设为 THIS_MODULE,和模块引用计数相关
.openopen()初始化状态、递增打开计数
.readread()把内核数据拷贝到用户缓冲区
.writewrite()把用户数据拷贝进内核
.releaseclose()清理状态、递减计数

这张表的价值在于:驱动不需要关心系统调用怎么路由,只要实现表里的回调,文件系统就会在合适的时机调用。常见的翻车点是漏了 .owner,或者回调函数名写错,前者影响模块卸载,后者会在打开设备时让内核直接找不到函数。建议先把这张表写在驱动文件顶部,再逐个实现成员。

2.2 主设备号与次设备号:先搞清楚 dev_t 再选分配方式

设备号是一个 32 位的 dev_t 类型,高 12 位是主设备号,低 20 位是次设备号。主设备号决定这个设备归哪个驱动管,次设备号让同一个驱动能区分多个设备实例。用户态基本感觉不到主次设备号的存在,但 /proc/devices、mknod、设备节点 inode 全都围着它转。

分配方式有静态和动态两种,实验一强烈建议用动态分配:

方式接口适用场景风险
静态注册register_chrdev_region主设备号预先约定、长期固定的设备主设备号可能已被占用,加载即失败
动态分配alloc_chrdev_region教学实验、普通外设驱动设备号每次可能不同,需要从 /proc/devices 查询

动态分配代码只有几行:

dev_t devno; int err = alloc_chrdev_region(&devno, 0, 1, "simple_dev"); if (err < 0) { printk(KERN_ERR "allocate devno failed: %d\n", err); return err; } printk(KERN_INFO "got major=%d minor=%d\n", MAJOR(devno), MINOR(devno));

alloc_chrdev_region 的四个参数分别是:传出参数 devno,保存分配到的设备号;起始次设备号 0;连续分配个数 1;设备名称 simple_dev,这个名字会出现在 /proc/devices 里。MAJOR() 和 MINOR() 宏从 dev_t 里拆出主次设备号。err 接收的是函数返回值,devno 通过指针传出,别把两个搞混。

2.3 设备节点不是自动出现的:手动 mknod 与自动创建两条路

设备号分配好、cdev_add 注册完,驱动其实已经可以被内核识别了,cat /proc/devices 能看到 simple_dev,但 /dev 下没有文件,用户程序根本打不开。设备节点的作用,就是把设备号翻译成一个用户态能访问的文件路径。

最稳的办法是手动建节点,先从 /proc/devices 查到实际主设备号,再执行:

sudo mknod /dev/simple c 240 0

这里的 c 表示字符设备,240 是主设备号,0 是次设备号,实际使用时 240 要用你查询到的值替换。实验阶段这条路最可靠,不受系统里 udev/mdev 影响,任何板子上都能用。

更工程化的做法是让内核在设备注册时自动创建节点,内核侧两行代码:

struct class *cls = class_create("simple_class"); device_create(cls, NULL, devno, NULL, "simple");

class_create 创建一个类,device_create 在这个类下创建设备,最后一个参数指定设备节点名,于是 /dev/simple 自动出现。需要留意,class_create 在不同内核里签名有差异,有的要求把 THIS_MODULE 作为第二参数传入,编译报错时按头文件提示补上即可。device_create 的第三个参数必须用已经注册过的 devno,不能用主设备号整数直接顶替。

3. 把驱动模块写出来:从最小骨架到完整注册,先让 insmod 跑通

概念铺开之后开始写可编译、可加载的代码。我的建议是分两步走:先写一个只有 init 和 exit 的最小模块,编译加载一次,确认环境没问题,再往里面填设备号注册和 device_create。这样一旦 insmod 失败,你能快速判断是环境问题还是代码问题,而不是一大堆代码堆在一起找不出错。

3.1 最小模块骨架:license、入口与出口

任何内核模块都长这样:

#include <linux/module.h> #include <linux/init.h> #include <linux/kernel.h> static int __init simple_init(void) { printk(KERN_INFO "simple module loaded.\n"); return 0; } static void __exit simple_exit(void) { printk(KERN_INFO "simple module removed.\n"); } module_init(simple_init); module_exit(simple_exit); MODULE_LICENSE("GPL");

module_init 和 module_exit 把两个函数声明为加载入口和卸载出口。__init 和 __exit 是优化标记,告诉内核这些函数在完成初始化或卸载后,占用的内存可以被释放。return 0 表示初始化成功,负数会让内核认为加载失败。MODULE_LICENSE("GPL") 不只是写给人看的,缺失时内核会提示模块污染内核,甚至某些内核符号无法导出。这一版不需要设备号相关代码,先把编译环境验证通过。

3.2 注册字符设备:cdev_init 与 cdev_add 的顺序不能反

接着在骨架上加入设备号分配、cdev 注册和自动创建设备节点。先放一个只带 .owner 的 file_operations,第 4 章会把读写回调填进去:

static const struct file_operations simple_fops = { .owner = THIS_MODULE, };

有了 fops,再写完整的初始化函数。关键顺序是:先分配设备号,再 cdev_init,最后 cdev_add,任何一步失败都要把前面申请的资源释放干净,否则 rmmod 之后设备号还被占着,下次加载直接失败。

#include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #define BUFFER_SIZE 64 static char kernel_buf[BUFFER_SIZE]; static dev_t devno; static struct cdev simple_cdev; static struct class *simple_class; static int __init simple_init(void) { int err; err = alloc_chrdev_region(&devno, 0, 1, "simple_dev"); if (err < 0) return err; cdev_init(&simple_cdev, &simple_fops); simple_cdev.owner = THIS_MODULE; err = cdev_add(&simple_cdev, devno, 1); if (err < 0) { unregister_chrdev_region(devno, 1); return err; } simple_class = class_create("simple_class"); if (IS_ERR(simple_class)) { cdev_del(&simple_cdev); unregister_chrdev_region(devno, 1); return PTR_ERR(simple_class); } device_create(simple_class, NULL, devno, NULL, "simple"); printk(KERN_INFO "simple driver ready.\n"); return 0; } static void __exit simple_exit(void) { device_destroy(simple_class, devno); class_destroy(simple_class); cdev_del(&simple_cdev); unregister_chrdev_region(devno, 1); printk(KERN_INFO "simple driver removed.\n"); }

cdev_init 只负责把 cdev 结构和 fops 绑定,真正“上线”的是 cdev_add,它把设备号登记到内核的 cdev_map。cdev_add 的第三个参数是设备数量,和分配设备号时用的 1 要一致。device_create 返回一个 struct device 指针,实验一可以不检查,严谨一点也应该用 IS_ERR 判断。卸载顺序和加载相反:先销毁用户态可见的设备节点,再删除 cdev,最后释放设备号。

3.3 Makefile 与编译加载:先让 insmod 跑通

Makefile 用行业通用模板:

obj-m := simple.o KERNELDIR := /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M=$(CURDIR) modules clean: $(MAKE) -C $(KERNELDIR) M=$(CURDIR) clean

obj-m 表示以模块方式编译,simple.o 由同目录的 simple.c 生成。KERNELDIR 指向当前运行内核的构建目录,里面要有完整的头文件和配置,缺失时先装对应的内核头文件包。all 规则里 -C 是切换目录,M=$(CURDIR) 告诉内核源码树到当前目录编译外部模块。用 CURDIR 而不是 PWD,是因为 PWD 在从其他目录执行 make 时可能指向错误路径。

编译和加载命令:

make sudo insmod simple.ko dmesg | tail -n 20 cat /proc/devices | grep simple_dev sudo rmmod simple

insmod 成功时没有任何输出,所以要立刻用 dmesg 看内核日志,确认 simple driver ready。cat /proc/devices 能看到 simple_dev 和它拿到的动态主设备号,这一步在手动 mknod 前一定要做。rmmod 卸载模块,再 dmesg 确认清理。如果 insmod 就失败,或者 dmesg 里是 Invalid module format,先回 3.1 的最小模块查环境,别急着往驱动代码上加设备号。

4. 实现读写回调:用户态 open、read、write 如何进入驱动

设备节点建好后,应用对 /dev/simple 的 open、read、write、close 就会进入 fops 表。这一章把回调写全,并解释为什么 read 和 write 里绝不能直接操作用户态指针。核心就一句话:内核态没有能力替用户进程做缺页处理,访问用户地址必须靠 copy_* 系列函数,它们内部处理了地址校验。

4.1 open 与 release:记录打开次数,避免模块被强行卸载

open 回调是最容易被低估的,很多实验只写一句 printk 就完事。一个有价值的 open 应该承担状态初始化和引用计数。在全局区加一个计数变量:

static int open_count = 0; static int simple_open(struct inode *inode, struct file *filp) { open_count++; try_module_get(THIS_MODULE); printk(KERN_INFO "device opened, open_count=%d\n", open_count); return 0; } static int simple_release(struct inode *inode, struct file *filp) { if (open_count > 0) open_count--; module_put(THIS_MODULE); printk(KERN_INFO "device released, open_count=%d\n", open_count); return 0; }

open_count 用来知道当前有多少个进程打开了设备。try_module_get 把模块引用计数加一,这样 rmmod 时如果设备还在被使用,内核会拒绝卸载,避免驱动代码正在执行、模块却被卸载导致内核崩溃。release 在最后一个 close 时调用,把计数减回来。这个 open_count 在多核并发下不是一个安全计数,实验一不做并发保护可以,但你要清楚真实驱动里它应该换成 atomic_t 或加锁保护。

4.2 read 与 write:把内核缓冲区当作一块可读写的内存

读写实现是实验一的核心。驱动内部维护一块 64 字节的静态缓冲区 kernel_buf,read 把缓冲区内容拷给用户,write 把用户内容拷进缓冲区。

static ssize_t simple_read(struct file *filp, char __user *user_buf, size_t count, loff_t *ppos) { size_t n; if (*ppos >= BUFFER_SIZE) return 0; n = min(count, (size_t)(BUFFER_SIZE - *ppos)); if (copy_to_user(user_buf, kernel_buf + *ppos, n)) return -EFAULT; *ppos += n; return n; } static ssize_t simple_write(struct file *filp, const char __user *user_buf, size_t count, loff_t *ppos) { size_t n = min(count, (size_t)BUFFER_SIZE); if (copy_from_user(kernel_buf, user_buf, n)) return -EFAULT; *ppos += n; return n; }

read 先判断文件是否读到头,返回 0 在文件系统语义里就是 EOF。然后取 count 和剩余空间的较小值,避免越界。copy_to_user 返回还没拷贝成功的字节数,0 表示全部成功,非 0 代表用户缓冲区非法,这时返回 -EFAULT,用户态 perror 会看到 Bad address。write 用 min 限制写入长度,防止用户一次写 1KB 把 64 字节的内核缓冲区写穿。这里的 ppos 在 write 后也推进了,所以写完立刻 read 会从尾部开始,下一章专门说这个坑。

这两个函数写好后,把 fops 更新成完整版本:

static const struct file_operations simple_fops = { .owner = THIS_MODULE, .open = simple_open, .release = simple_release, .read = simple_read, .write = simple_write, };

更新后再 make,如果只加了函数没改 fops,open/read/write 都不会被调用,insmod 也不报错,但行为和你预期完全不同。这个坑很隐蔽,排查时先检查 fops。

4.3 应用层验证:先用命令行,再用一个 30 行的 C 程序

最简单的验证不写程序,直接命令行:

printf 'hello kernel' > /dev/simple cat /dev/simple

前提是按 2.3 的方法建好设备节点。printf 重定向触发 write,cat 触发 read。如果 cat 没有输出,基本就是 ppos 推进导致读到了文件末尾。用 C 程序可以把整个流程看得更清楚:

#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main(void) { char buf[64] = { 0 }; int fd = open("/dev/simple", O_RDWR); if (fd < 0) { perror("open"); return 1; } write(fd, "hello kernel", 12); lseek(fd, 0, SEEK_SET); read(fd, buf, sizeof(buf) - 1); printf("read back: %s\n", buf); close(fd); return 0; }

open 得到文件描述符,write 写入 12 字节,lseek 把文件偏移拨回 0,read 再读出来。把 lseek 去掉再跑一次,read 会直接返回 0,因为偏移已经在缓冲区末尾。这里的关键认知是:文件偏移由内核 VFS 管理,但 read/write 的具体语义由驱动的两个回调决定,设备文件不是普通文件,它的偏移行为完全可以自定义。

5. 实验一避坑指南:从内核日志里定位五类常见翻车

这个实验的报错,一大半是因为不看 dmesg。用户态程序段错误,内核日志里可能有完整线索;insmod 失败,dmesg 里有最直接的原因。下面按现象、原因、处理三条来写,全是这个实验里最容易踩的地方。

5.1 insmod 失败:Invalid module format 与 Operation not permitted 不是一回事

现象:sudo insmod simple.ko 返回 could not insert module,dmesg 里能看到 Invalid module format、Exec format error,或者直接 Operation not permitted。原因分两层:Invalid module format 几乎都是 Makefile 里的 KERNELDIR 指向的内核头文件和当前运行内核不一致,或者编译器工具链架构不对;Operation not permitted 则是权限或内核安全策略不允许加载,比如没加 sudo,或者目标系统禁用了模块加载。处理:先执行 uname -r 看清内核版本,ls /lib/modules/$(uname -r)/build 确认构建目录存在且完整;再退回 3.1 的最小模块验证环境,最小模块能加载,问题就在后加的代码里。这一步慢下来排查,比反复 make clean 有效得多。

5.2 设备节点不出现:去 /proc/devices 查主设备号补一个

现象:insmod 成功,dmesg 也打印了 ready,但 ls /dev/simple 提示不存在。原因:代码里只做了 cdev_add,没有做 device_create,或者系统里没有运行 udev/mdev 这类设备节点管理机制。cdev_add 只让内核知道这个设备号有人接管,它不会自动生成文件节点。处理:先 cat /proc/devices | grep simple_dev 拿到主设备号,再 sudo mknod /dev/simple c <主设备号> 0,这是最快补救。mknod 里的 c 千万别漏,漏了会建出普通文件,open 时行为完全不对。如果想让节点自动出现,代码里补上 class_create 和 device_create 即可。

5.3 echo 写进去了 cat 读不到:文件偏移走到了末尾

现象:printf 'hello' > /dev/simple,紧接着 cat /dev/simple,终端上什么都没有,但驱动日志显示 write 确实被调用过。原因:write 回调推进了 ppos,read 从当前位置开始读,位置已经在缓冲区末尾,read 立刻走到返回 0 的 EOF 分支。数据没有丢,是位置不对。处理:测试时先 lseek(fd, 0, SEEK_SET) 再 read;命令行里重新 open 一次本质上也会重置偏移。这个坑的价值在于理解 read/write 里的 loff_t *ppos 不是摆设,FIFO、块设备、串口驱动对偏移的处理都是在这个参数上做文章。

5.4 应用层一跑就段错误:别在内核里解引用用户指针

现象:C 程序调用 read/write 时直接 Segmentation fault,内核日志出现 unable to handle page fault,或者驱动里打印出奇怪的地址。原因:read/write 回调里直接写了 memcpy(kernel_buf, user_buf, count) 或 strcpy,把一个指向用户空间的指针当成内核指针用了。用户态页面可能不在内存里,内核不能像用户态那样触发缺页处理,一访问就是 Oops。处理:把所有用户内存访问换成 copy_from_user 和 copy_to_user,并检查返回值。遇到 char __user * 类型参数时,默认就不能直接解引用,必须经过 copy 系列中转。

5.5 rmmod 提示 Device or resource busy:有人还开着设备

现象:sudo rmmod simple 报 module is in use 或 Device or resource busy,卸载不掉。原因:最常见的是某个应用进程没有 close(fd),或者测试程序被打断后 fd 没释放;另一个可能是 open_count 和 try_module_get 不对称,模块引用计数被顶住。处理:用 lsof /dev/simple 或 fuser -v /dev/simple 找到占用进程,结束掉再 rmmod。如果查不到进程,就检查 open 里的 try_module_get 和 release 里的 module_put 是否一一对应。调内核模块时不要一 busy 就重启,先看引用计数被谁持有。

6. 让实验一驱动更接近真实产品:验证习惯与两个可做扩展

实验一跑通只是起点,我一般建议把“最小能跑”的东西再往前推两步,这两步都不超纲,但能让你的驱动从“能过实验”变成“能看出有工程意识”。

每次实验先清空内核日志。printk 的信息混在 dmesg 几千行里,找自己那一条全靠运气。我现在的习惯是加载前先执行 sudo dmesg -c 把环形缓冲区清空,之后加载、卸载、应用测试,再看 dmesg 时全是本次实验的输出,定位问题快得多。日志太多时用 grep simple 或 tail -n 30 收窄范围。这个习惯形成之后,写总线驱动、调试中断处理器都能少走很多弯路。

第一个值得改的地方:把 BUFFER_SIZE 从 64 改成 4096,然后从用户态 write 一段明显超过 4096 的数据,观察 write 返回值被截断成 4096,再用 lseek 加 read 读回,内容刚好是前 4096 字节。这个实验会让你直观看到驱动里的限制如何真实作用于应用层。第二个扩展:alloc_chrdev_region 时把数量从 1 改成 2,注册两个设备节点 /dev/simple0 和 /dev/simple1,open 回调里用 iminor(inode) 判断次设备号,两个节点各自维护一份缓冲区。这一步直接覆盖了“主设备号相同、次设备号区分实例”的用法,后面的 GPIO、串口这类多实例驱动都是同一套思路。

驱动调试有一条教训我一直留着:永远从最小可加载模块开始,每加一个功能就 insmod、rmmod 一次,确认没把环境搞坏再往下写。不要一上来就写满 open、read、write、release,再回过头找是哪一行让内核崩溃。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询