ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux字符设备驱动实验:从内核模块到读写实现

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函数要做的是把用户态的数据搬到kbuf:

static 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_init+cdev_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/mydev

c表示字符设备,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返回-1,errno是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)/build
insmod报invalid format内核版本不匹配modinfo对比vermagic和uname -r
设备节点无法访问权限或设备号错误ls -l和cat /proc/devices
read返回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过滤,只看你自己的模块日志,排查效率会高很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进