2026/10/10 17:22:54

Linux内核模块实战:实现可读写的/proc文件系统节点

Linux内核模块实战:实现可读写的/proc文件系统节点 简介这份docx格式的操作系统实验报告围绕Linux 0.11内核中proc文件系统的实现展开适合高校操作系统课程学生、课程设计者或正在做虚拟文件系统实验的读者参考。报告完整覆盖实验目的、实验内容、报告思考题与逐步实现过程包括新增文件类型、修改mknod()、初始化流程、sys_read()处理分支以及psinfo/hdinfo/inodeinfo等proc结点的读取逻辑与测试结果其中还嵌入了关键代码片段便于对照内核源码逐行理解。针对“多次read()期间进程状态变化”的思考题给出了基于文件指针与缓冲区缓存的解决方案能帮助读者理解内核数据刷新机制。资源为单个docx文件大小约944KB内容以代码、流程说明和实验总结为主便于对照修改与复盘整理。目前已有208人学习下载适合需要完成同类实验、梳理procfs实现要点或撰写实验报告的同学使用。1. 操作系统实验 8 proc 文件系统的实现这节实验到底要你交什么操作系统实验做到 proc 文件系统这一轮很多人第一反应是困惑/proc 不是开机就在的吗还有什么可实现的等到自己动手才会明白实验要的并不是把内核里现成的 procfs 重写一遍而是让你用内核模块在 /proc 下挂出自己的节点把内核里的计数器、状态、参数通过文件读写暴露出来。《操作系统实验 8 proc 文件系统的实现》这个标题基本就是这一定位。它适合正在上操作系统课、第一次接触内核模块的本科生也适合刚转驱动开发的从业者补上虚拟文件系统这一课。读完你应该能自己写出一个可读可写的 /proc 节点并且知道权限、并发、卸载这一类问题到底去哪查。2. procfs 的底层逻辑虚拟文件系统为什么不落盘初学 proc 最容易想不明白的一点是/proc/meminfo 这个文件到底存在哪里用ls -l看它大小是 0但cat能读出一大堆内容。它不是磁盘上的普通文件而是内核虚拟文件系统 procfs 的一个投影。文件路径、inode、打开方式都是真的唯独数据不是从磁盘读出来的而是内核在 read 的时候现场生成的。理解这一点之后整个实验的代码结构就清楚了你要写的不是“文件内容”而是“当用户读这个路径时内核调用的回调函数”。这也是 procfs 和其他真实文件系统最本质的区别。2.1 文件等于内核数据的投影而不是磁盘上的块procfs 里每个文件都可以看成内核某个数据结构的定向暴露。比如 /proc/uptime 读出来的两个数直接来自内核维护的启动时间/proc/self 指向当前进程的目录同一个路径在不同进程看来内容不同因为它每次解析都会换成当前进程的 pid。所以 proc 节点没有自己的 page cache、没有磁盘块号、没有真正的 pwrite 语义。你写实验时也不需要关心文件在磁盘上怎么分布只需要关心三件事节点叫什么、权限是多少、打开和读写时回调谁。我一般会给学生一个判断标准真文件系统用 block 存数据procfs 用函数存数据。你看到 /proc 下每个文件名本质上就是一个函数入口的符号链接。2.2 读路径上谁在干活从 VFS 到 proc_ops用户敲cat /proc/demo时调用链大致是这样的VFS 根据路径找到 /proc 挂载点再找到 demo 这个 dentry 和 inodeinode 里记录了这类文件的自定义操作方法早期叫 file_operations5.6 之后叫 proc_opsopen 阶段触发.proc_openread 阶段触发.proc_read或者 seq_file 的一套接口读出多少字节由回调自己决定返回 0 表示 EOF返回负数表示错误。procfs 的 inode 不关联真实磁盘地址但关联一个proc_dir_entry结构。proc_create这个函数就是把 dentry、inode、proc_ops 三样东西绑在一起然后挂到 /proc 目录树下。模块卸载时再用remove_proc_entry把这棵小树拆掉。很多实验第一步卡住不是因为代码写错而是没分清“注册路径”和“读写实现”是两件事。注册是告诉 VFS 这个节点存在读写实现决定打开它之后发生什么。2.3 为什么常见做法是先写内核模块而不是改内核源码实验题里如果让你“实现 proc 文件系统”不意味着你要把整个 /proc 重新编译进内核。常见做法是写一个独立内核模块动态注册一个或多个 proc 节点。这样做的原因很实际改内核源码需要重新编译整个内核一次十几分钟调试一次代价太大而模块可以用 insmod/rmmod 反复加载卸载出错也只需要看 dmesg。我做类似实验时默认会用一个最小模块起步先注册一个只读节点确认cat能读到内容再加写回调最后再扩展成目录和多文件。这个顺序能让你把“内核模块框架”“proc 节点生命周期”“读写回调边界”三个知识点分开排查不会混在一起翻车。3. 实现一个可读可写的 /proc/demo最小内核模块的完整流程这一章直接给代码。下面这个模块包含注册、读、写三部分编译进任意带开发环境的 Linux 内核后执行insmod就能在 /proc/demo 看到一个节点。写完clear会把计数器清零写inc会让计数器加一。3.1 用 proc_create 注册节点权限位和命名都不能随意先看完整代码再逐段解释#include linux/module.h #include linux/kernel.h #include linux/proc_fs.h #include linux/uaccess.h #include linux/version.h static int demo_counter 0; static ssize_t demo_read(struct file *file, char __user *buf, size_t len, loff_t *ppos) { char tmp[64]; int size snprintf(tmp, sizeof(tmp), counter%d\n, demo_counter); int remain size - *ppos; if (remain 0) return 0; if (len remain) remain len; if (copy_to_user(buf, tmp *ppos, remain)) return -EFAULT; *ppos remain; return remain; } static ssize_t demo_write(struct file *file, const char __user *buf, size_t len, loff_t *ppos) { char cmd[32]; if (len sizeof(cmd)) return -EINVAL; if (copy_from_user(cmd, buf, len)) return -EFAULT; cmd[len] \0; if (strncmp(cmd, clear, 5) 0) demo_counter 0; else if (strncmp(cmd, inc, 3) 0) demo_counter; else return -EINVAL; return len; } #if LINUX_VERSION_CODE KERNEL_VERSION(5, 6, 0) static struct proc_ops demo_ops { .proc_read demo_read, .proc_write demo_write, }; #else static struct file_operations demo_ops { .owner THIS_MODULE, .read demo_read, .write demo_write, }; #endif static int __init demo_init(void) { proc_create(demo, 0644, NULL, demo_ops); return 0; } static void __exit demo_exit(void) { remove_proc_entry(demo, NULL); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);这里最需要注意的是proc_create的第二个参数 0644。这个权限位决定节点在 /proc 下对用户是否可读可写。实验里如果只要求cat可以给 0444如果要做写操作就必须包含写位。第三个参数填NULL表示挂在 /proc 根目录下如果想挂进子目录这里要传子目录的proc_dir_entry *。代码里兼容了两个内核版本。Linux 5.6 之后把file_operations换成了proc_ops字段名从.read变成.proc_read。实验环境如果是老内核走#else分支新内核走前面分支。这个兼容写法我第一次做实验时也没加结果在 5.15 内核上编译报错后来才养成用LINUX_VERSION_CODE判断的习惯。3.2 读回调的正确姿势返回长度必须等于实际输出demo_read最容易被忽略的是*ppos。用户态cat会循环调用 read直到 read 返回 0。所以 read 回调必须做两件事从*ppos指定的偏移量开始拷贝数据然后在所有数据都输出完后返回 0。代码里先算出tmp里的字符串长度 size然后用remain size - *ppos判断还有多少没读完。如果remain 0返回 0表示 EOF。如果用户传入的缓冲区比剩余数据小只拷贝 len 字节并让*ppos相应前进。这样能保证cat多读几次也不会读越界。有个常见错误是直接返回size而不考虑*ppos结果cat会把内容反复输出看起来永远停不下来。这是因为每次调用返回值都大于 0用户态会认为还有数据继续读。我调类似模块时只要看到终端刷屏第一反应就是去看 read 回调有没有正确处理 EOF。3.3 写回调里最关键的防线copy_from_user操作系统的用户态缓冲区与内核态不能直接互相访问。demo_write收到的是用户态指针buf如果直接strcmp(buf, clear)大概率会触发内核异常。正确写法是先用copy_from_user把数据搬到内核数组cmd再判断内容。copy_from_user的返回值表示拷贝失败了多少字节返回 0 才是完全成功。写成if (copy_from_user(...)) return -EFAULT;是内核模块里的标准防御姿势。另外len也必须先做大小检查否则用户一次性写 1KB 数据栈数组cmd[32]会溢出。写回调的返回值应该回传成功消费的字节数这里返回len表示整段数据都接受了。不接受的情况返回负的错误码比如-EINVAL。用户态 shell 在执行echo inc /proc/demo时如果写入失败会看到write error: Invalid argument。这个回显本身就是调试线索。4. 把单个文件扩展成目录proc_mkdir 和多文件节点的组织方式大部分实验不会只让你挂一个文件而是要求模拟一个“子系统”比如/proc/demo/status、/proc/demo/counter。这就要用到proc_mkdir创建目录再往目录节点里注册文件。4.1 创建目录和文件注册顺序与删除顺序要反过来目录形式的模块初始化函数一般是这样的static struct proc_dir_entry *demo_dir; static int __init demo_dir_init(void) { demo_dir proc_mkdir(demo, NULL); if (!demo_dir) return -ENOMEM; proc_create(counter, 0644, demo_dir, counter_ops); proc_create(status, 0444, demo_dir, status_ops); return 0; } static void __exit demo_dir_exit(void) { remove_proc_entry(counter, demo_dir); remove_proc_entry(status, demo_dir); remove_proc_entry(demo, NULL); }proc_mkdir的第一个参数是目录名第二个参数是父目录。第一次调用时父目录填NULL创建的是/proc/demo。之后再调proc_create第三个参数传demo_dir文件就落在/proc/demo/counter。删除顺序必须和创建顺序相反先删子文件再删目录。如果直接remove_proc_entry(demo, NULL)内核在调试路径上会提示目录非空或出现节点残留。这里我还习惯检查proc_mkdir的返回值创建失败时直接return -ENOMEM避免后续对空指针调用proc_create。4.2 权限位、属主和 mode实验评分最容易看漏的参数proc 节点虽然不落盘但权限模型和普通文件一致。下面是实验里最常碰到的几种 mode 配置mode常见用途注意点0444只读状态节点如 /proc/demo/status用户写会返回 EACCES不是回调里判断出来的0644可读可写控制节点如 counterroot 和普通用户都可读只有 root 可写0600只允许 root 访问的调试节点普通用户cat会提示 Permission denied0200只写不读适合命令接收节点很少用容易造成实验自测时无法确认状态procfs 里节点属主默认是 root:root所以 0644 中的“写权限”实际上只有 root 能用。如果你用普通用户跑echo inc /proc/demo报 Permission denied先查 mode 而不是查写回调。另一个坑是proc_create的第二个参数如果传 0节点确实创建成功但普通用户对它的所有操作都会被 VFS 拒绝。实验报告里截图会非常难看我一般直接建议至少给 0444。4.3 用 seq_file 重写读回调更稳的批量输出方案手写 read 回调在数据量小的时候能跑但输出内容一多偏移量、缓冲区长度、拷贝位置都很容易算错。内核提供了 seq_file 接口专门用来解决“一次 read 要输出多行数据”的问题。static int counter_show(struct seq_file *m, void *v) { seq_printf(m, counter: %d\n, demo_counter); seq_printf(m, jiffies: %lu\n, jiffies); return 0; } static int counter_open(struct inode *inode, struct file *file) { return single_open(file, counter_show, NULL); } static struct proc_ops counter_ops { .proc_open counter_open, .proc_read seq_read, .proc_lseek seq_lseek, .proc_release single_release, };用 seq_file 后不再需要自己维护*ppos。single_open会分配一个 seq_file 缓冲区counter_show每次往缓冲区里追加内容内核在合适时机把数据拷贝给用户态。.proc_read直接填seq_read.proc_lseek填seq_lseek.proc_release填single_release这三件套是配套的。如果想让 show 函数访问自定义数据可以用proc_create_data替代proc_create。最后一个参数会保存在 inode 里open 时用PDE_DATA(inode)取出来传给single_open。这比全局变量干净也更接近真实内核模块的写法。5. proc 文件系统实现的避坑清单5 个最容易翻车的现场这一章是我调类似模块时反复遇到的真实问题按“现象、原因、解决”的顺序写每条都可以直接对照。5.1 insmod 成功了但 /proc/demo 不存在现象insmod demo.ko没有任何报错模块也出现在lsmod里但cat /proc/demo报 No such file or directory。原因最常见的是proc_create返回了 NULL但初始化函数没有检查。名字冲突、权限不足、父目录指针无效都会导致创建失败。另一个可能性是demo_init里注册路径写成了带/的名字proc_create 不会自动创建多级目录。解决在proc_create后面检查返回值失败时打印日志并返回错误码。初始化代码里加一句pr_err(failed to create /proc/demo\n)然后dmesg | tail会直接告诉你问题。命名上避免和内核已有节点冲突实验环境里可以先ls /proc看一眼。5.2 cat 疯狂重复输出永远不停现象执行cat /proc/demo内容打印一遍之后又重复打印只能按 CtrlC 终止。原因read 回调没有正确处理*ppos。用户态 read 循环的结束条件是内核返回 0如果每次返回长度都大于 0cat就认为还有下一段数据。另一个相似场景是返回值大于实际拷贝字节数用户态读到残留内存。解决按size - *ppos计算剩余量剩余为 0 时返回 0。拷贝完成后再更新*ppos。这段逻辑我建议直接用第 3 章的模板改不要在返回值上做“差不多”的优化。5.3 用户态读到乱码或多余字符现象cat /proc/demo输出正常文字但后面跟着一串 或不可见字符。原因栈数组没有初始化或者 snprintf 之后直接返回了包含\0的长度。用户态读到的是内核栈上的残留数据看起来像乱码。解决把输出数组初始化为 0或者只返回snprintf得到的有效长度。特别注意不要把字符串结尾的\0算进返回值cat不需要它。代码里我习惯写成int size snprintf(...)返回值就是实际载荷长度。5.4 一执行 echo 写操作内核直接报 Oops现象echo inc /proc/demo后shell 卡住dmesg 里出现 kernel bug地址里带ffff...指针。原因写回调把用户态buf直接当内核地址解引用比如strcmp(buf, inc)。用户态地址在内核态不可直接访问一旦触发缺页就是内核异常。解决一律用copy_from_user先拷进内核数组再判断内容。拷入前先检查len是否超过目标数组大小。这条是内核模块开发的基本红线写 proc 实验时最容易在“懒得多写一步”的地方翻车。5.5 rmmod 提示 Module is in use节点删不掉现象执行rmmod demo报错模块一直停留在 lsmod 列表里。原因模块退出函数没有调用remove_proc_entry或者 /proc/demo 还被某个进程打开比如正在跑的cat、tail、watch。也可能是目录模块只删了目录、没删子文件导致节点树残留。解决先确认没有进程占用节点fuser -v /proc/demo能看到谁开着它关掉后再 rmmod。代码里删除顺序一定从最底层子节点开始。如果只是忘了删补上remove_proc_entry再加载卸载一次即可。6. 实验验收前的自我检查用三条命令把隐患清干净6.1 自测顺序加载、读取、写入、卸载代码能编译只是第一步实验能不能过看的是运行时行为。我常用的自测命令序列是make sudo insmod demo.ko ls -l /proc/demo cat /proc/demo printf inc\n /proc/demo cat /proc/demo sudo rmmod demo dmesg | tail -20ls -l看权限位是否和预期一致第一次cat验证读路径写一次再读一次验证计数器真的变了最后rmmod验证卸载路径干净dmesg 看整个过程中有没有隐藏的异常。如果环境里有 strace还可以加一条strace -e openat,read,write,lseek cat /proc/demo 21 | head -30这会列出cat对节点发起的系统调用顺序能直接看出 read 是否结束、lseek 偏移是否合理。对写回调的排查也有效write 返回 -1 时 strace 会显示错误码比看 shell 提示更准确。6.2 从“能过实验”到“能用在项目里”把节点做成调试仪表盘实验验收只需要一个计数器但真实项目里 proc 节点往往承担“内核运行状态观测站”的角色。我的习惯是在节点里同时输出计数值、最近一次更新时间、以及调用次数static int stat_show(struct seq_file *m, void *v) { seq_printf(m, counter%u\n, atomic_read(demo_counter)); seq_printf(m, hits%lu\n, demo_hits); seq_printf(m, last_jiffies%lu\n, last_jiffies); return 0; }配合watch -n 1 cat /proc/demo/stat就能像读仪表盘一样观察内核模块的运行状态。加上proc_create_data传入自定义结构体可以做多个同类型节点共用一套回调这就是往真实驱动方向靠的写法。我现在的习惯是任何 proc 模块都先让读路径连续读两次数据一致再让写路径走一遍非法输入最后才测 rmmod。这三个动作在实验报告里各对应一段清晰的 dmesg 输出比贴一堆代码更能证明你读懂了文件生命周期。希望帮到你。本文还有配套的精品资源点击获取