Linux字符设备驱动实验:从内核模块到读写实现
1. 从“字符设备”这四个字说起这个实验到底在折腾什么如果你正在上操作系统实验课翻到“字符设备”这一章大概率会有两种反应要么觉得“不就是个读写嘛”要么完全不知道从哪下手。我当年属于后者。字符设备这个专题表面上看是让你写一个能读能写的设备驱动实际上它是在逼你理解三件事内核模块怎么加载、设备号怎么分配、用户态和内核态的数据怎么来回搬。这三件事搞明白了后面块设备、网络设备你都能触类旁通。这篇内容适合谁看适合已经上过操作系统理论课、知道进程和内存管理大概怎么回事但一碰到“写个模块编译进内核”就发怵的同学。也适合那些实验报告写完了但心里没底、想真正搞懂每一步在干什么的人。我会从整体设计思路讲到具体代码实现再到踩坑排查尽量把每个“为什么”都说清楚。你不需要事先精通内核编程但需要有一台能跑Linux的机器以及一颗愿意对着终端反复试错的心。字符设备的核心特征是什么它像文件一样被访问但背后不是磁盘上的数据块而是一个驱动程序在响应你的read和write。你echo hello /dev/xxx数据就从用户空间跑到了内核空间再被你写的驱动接住。这个“接住”的过程就是实验要你实现的东西。听起来简单但中间涉及主设备号与次设备号的分配、file_operations结构体的填充、copy_to_user和copy_from_user的正确使用每一步都有坑。我见过太多同学在这个实验上卡住不是因为逻辑难而是因为环境配置、头文件路径、内核版本匹配这些“杂事”没处理好。所以这篇内容会花不少篇幅讲实操细节包括Makefile怎么写、模块怎么编译、设备节点怎么创建、权限怎么设。你跟着走一遍基本能跑通跑通之后再回头看代码理解会深很多。2. 整体设计思路为什么字符设备实验要这么安排2.1 从“用户态调用”到“内核态响应”的完整链路字符设备实验的经典设计是写一个内核模块注册一个字符设备实现open、read、write、release这几个基本操作。用户态程序通过设备文件/dev/xxx发起系统调用内核根据设备号找到你的驱动调用你实现的函数。这个链路里设备号是桥梁file_operations是入口copy_to_user和copy_from_user是数据通道。为什么实验要这么设计因为这是操作系统里“抽象”的典型体现。用户态不需要知道设备具体怎么工作只需要像操作文件一样操作它。而驱动开发者需要把具体的硬件操作或数据缓冲封装在file_operations里。字符设备实验让你亲手搭这个抽象层比单纯看课本要深刻得多。我建议你在动手之前先画一张图用户程序调用read(fd, buf, len)内核收到系统调用根据fd找到对应的struct file再找到struct cdev再找到你注册的file_operations然后调用你的read函数。你的read函数从内核缓冲区把数据copy_to_user到用户提供的buf。这张图能帮你定位大部分问题——比如read返回-EFAULT多半是copy_to_user失败了比如open返回-ENODEV多半是设备号没注册对。2.2 为什么选择“模块化”而不是“编译进内核”实验通常要求以内核模块的形式加载驱动而不是直接编译进内核。原因有几个第一模块可以动态加载和卸载调试方便改完代码make一下再insmod就行不用重启第二模块化能让你清楚地看到“注册”和“注销”的过程理解资源管理的必要性第三编译进内核需要重新编译整个内核耗时且容易出错对实验环境不友好。但模块化也有代价内核版本必须匹配。你的模块编译时用的头文件必须和当前运行的内核版本一致否则insmod会报“invalid module format”。这是新手最容易踩的坑之一。我后面会讲怎么确认版本、怎么找对头文件路径。2.3 设备号分配静态还是动态设备号分主设备号和次设备号。主设备号标识驱动次设备号标识具体设备。实验里通常有两种做法静态指定比如主设备号240和动态分配让内核自动分配。静态指定简单直观但可能和已有设备冲突动态分配更安全但需要从/proc/devices里读取分配到的号来创建设备节点。我建议实验里先用静态指定因为好调试。你可以在代码里写#define MY_MAJOR 240然后register_chrdev(MY_MAJOR, mydev, fops)。如果insmod后dmesg显示注册失败说明240被占用了换一个就行。动态分配的话alloc_chrdev_region之后要cat /proc/devices找到你的设备名对应的主设备号再mknod。两种方式都值得试一遍理解区别。3. 核心细节解析代码里每一行到底在干什么3.1 头文件与模块声明别小看这几行一个典型的内核模块开头是这样的#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h MODULE_LICENSE(GPL); MODULE_AUTHOR(YourName); MODULE_DESCRIPTION(A simple char device);MODULE_LICENSE(GPL)不是可有可无的。如果不写内核会认为你的模块是专有代码加载时可能报“module license unspecified taints kernel”虽然不一定失败但会污染内核。实验里老老实实写上GPL。linux/uaccess.h是必须的因为你要用copy_to_user和copy_from_user。有些老教程写asm/uaccess.h在新内核里可能找不到用linux/uaccess.h更稳妥。3.2 file_operations结构体驱动的“接口清单”file_operations是你驱动对外的接口。实验里至少要实现这几个static struct file_operations fops { .owner THIS_MODULE, .open my_open, .release my_release, .read my_read, .write my_write, };.owner THIS_MODULE是为了防止模块在使用中被卸载。open和release通常只打印日志但release里要注意释放你分配的资源。read和write是核心下面单独说。3.3 read和write的实现数据怎么搬假设你用一个内核缓冲区char kbuf[1024]来存数据。write函数要做的是把用户态的数据搬到kbufstatic ssize_t my_write(struct file *filp, const char __user *buf, size_t len, loff_t *off) { if (len sizeof(kbuf)) len sizeof(kbuf); if (copy_from_user(kbuf, buf, len)) return -EFAULT; return len; }read反过来static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off) { if (len sizeof(kbuf)) len sizeof(kbuf); if (copy_to_user(buf, kbuf, len)) return -EFAULT; return len; }这里有几个细节copy_from_user和copy_to_user的返回值是未拷贝的字节数0表示成功。所以判断失败要用if (copy_from_user(...))非0即失败。另外len不能超过缓冲区大小否则会越界。实验里可以简单处理但实际驱动里要考虑偏移量*off和部分读写。注意不要直接用memcpy在用户态和内核态之间搬数据。用户态指针在内核里不能直接解引用必须用copy_*_user系列函数它们会处理地址空间切换和权限检查。3.4 模块加载与卸载注册和注销要成对module_init里做注册module_exit里做注销static int __init my_init(void) { int ret register_chrdev(MY_MAJOR, mydev, fops); if (ret 0) { printk(KERN_ERR register failed\n); return ret; } printk(KERN_INFO mydev loaded\n); return 0; } static void __exit my_exit(void) { unregister_chrdev(MY_MAJOR, mydev); printk(KERN_INFO mydev unloaded\n); } module_init(my_init); module_exit(my_exit);register_chrdev是旧接口新接口用cdev_initcdev_add但实验里用register_chrdev更简单。注意unregister_chrdev的参数要和注册时一致否则会残留。4. 实操过程从零到跑通的完整步骤4.1 环境确认内核版本和头文件第一步不是写代码是确认环境。打开终端uname -r记下版本号比如5.15.0-91-generic。然后确认头文件在不在ls /lib/modules/$(uname -r)/build如果这个目录不存在或指向错误需要安装对应版本的linux-headers。在Debian系上sudo apt install linux-headers-$(uname -r)在RedHat系上sudo yum install kernel-devel-$(uname -r)这一步不做后面make会报“找不到头文件”。4.2 编写Makefile别照抄网上的一个能用的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) clean注意make -C前面是Tab不是空格。KDIR指向当前内核的构建目录。如果你的实验环境有指定内核源码路径把KDIR改成那个路径。4.3 编译、加载、创建设备节点编译make如果成功会生成mydev.ko。加载sudo insmod mydev.ko查看日志dmesg | tail应该能看到“mydev loaded”。然后创建设备节点sudo mknod /dev/mydev c 240 0 sudo chmod 666 /dev/mydevc表示字符设备240是主设备号0是次设备号。chmod 666是为了让普通用户也能读写实验里方便测试。4.4 测试读写用echo和cat验证写入echo hello /dev/mydev读取cat /dev/mydev如果看到“hello”说明基本功能通了。如果cat卡住可能是read没有正确返回如果报“Permission denied”检查设备节点权限如果报“No such device”检查insmod是否成功、设备号是否对。4.5 卸载模块清理现场sudo rm /dev/mydev sudo rmmod mydev dmesg | tail看到“mydev unloaded”就干净了。如果rmmod报“Module is in use”说明还有进程打开着设备用lsof /dev/mydev查一下。5. 常见问题与排查技巧实录5.1 编译报错头文件找不到现象make时报“linux/module.h: No such file or directory”。原因KDIR路径不对或者没装linux-headers。解决确认/lib/modules/$(uname -r)/build存在且指向正确的头文件目录。如果不存在安装对应版本的headers。5.2 insmod失败invalid module format现象insmod报“invalid module format”。原因模块编译时用的内核版本和当前运行的不一致。常见于你之前编译过别的版本或者KDIR指向了错误的内核源码。解决make clean后重新make确保uname -r和KDIR里的版本一致。用modinfo mydev.ko查看模块的vermagic和uname -r对比。5.3 设备节点创建后无法读写现象echo报“Permission denied”或“No such device”。原因权限不对或者设备号不对。解决ls -l /dev/mydev看权限cat /proc/devices看主设备号是否和mknod时一致。如果不一致删掉节点重新创建。5.4 read返回-EFAULT现象用户态read返回-1errno是EFAULT。原因copy_to_user失败通常是用户提供的缓冲区地址无效或者你传了内核地址给copy_to_user。解决检查read函数里buf参数是否直接来自用户态不要在内核里分配缓冲区再传给copy_to_user。copy_to_user的第一个参数必须是用户态地址。5.5 模块无法卸载Module is in use现象rmmod报“Module is in use”。原因有进程还打开着设备文件。解决lsof /dev/mydev找到进程关掉它。如果找不到可能是open里没正确实现导致引用计数没减。检查release函数是否返回0。5.6 常见问题速查表问题可能原因排查方法编译报头文件缺失未安装headers或KDIR错误检查/lib/modules/$(uname -r)/buildinsmod报invalid format内核版本不匹配modinfo对比vermagic和uname -r设备节点无法访问权限或设备号错误ls -l和cat /proc/devicesread返回EFAULTcopy_to_user失败检查用户态地址有效性rmmod报in use有进程占用lsof查找并关闭dmesg无输出printk级别或缓冲区问题用dmesg -w实时查看实操心得每次改完代码先make clean再make避免旧的目标文件干扰。加载前用dmesg -C清空日志加载后dmesg看输出干净利落。6. 进阶思考字符设备实验还能怎么扩展6.1 支持多个次设备号实验里通常只用一个次设备号。你可以尝试支持多个比如mknod /dev/mydev0 c 240 0和mknod /dev/mydev1 c 240 1在open里根据iminor区分给每个设备分配独立缓冲区。这能帮你理解次设备号的作用。6.2 实现ioctl接口ioctl是字符设备常用的控制接口。你可以加一个unlocked_ioctl实现清空缓冲区、获取缓冲区长度等命令。这比单纯读写更接近真实驱动。6.3 用sysfs暴露信息在/sys/class/mydev/下创建属性文件用户态可以cat查看缓冲区内容或统计信息。这能让你理解sysfs和字符设备的关系。6.4 并发控制加个互斥锁如果多个进程同时读写你的kbuf会乱。加一个struct mutex在read和write里mutex_lock/mutex_unlock。这是理解内核并发控制的入门实践。我个人在实际操作中的体会是字符设备实验的价值不在于代码多复杂而在于它把“用户态-内核态”的边界具象化了。你写的每一行copy_to_user都是在跨越那道边界。跑通之后再去看/dev下的其他设备会有完全不同的感觉。最后分享一个小技巧如果dmesg输出太多用dmesg | grep mydev过滤只看你自己的模块日志排查效率会高很多。