Linux proc文件系统实现指南:从PDE到seq_file与write回调
2026/9/17 15:13:01 网站建设 项目流程

简介:操作系统实验报告,围绕 Linux 0.11 上 proc 文件系统的实现展开,适合正在学习操作系统课程、需要完成类似实验或理解虚拟文件系统原理的读者。报告覆盖实验目的、实验内容、详细步骤与核心代码,重点说明 psinfo 结点的实现流程,包括增加文件类型、修改 mknod、初始化函数、sys_read 分支以及 proc 处理函数,并给出 get_psinfo、get_hdinfo、get_inodeinfo 等代码实现。同时针对“多次 read 之间进程状态变化”的思考题,提出了基于文件指针与缓冲区的处理方案,对理解文件系统与进程管理有直接帮助。资源为 1 个 docx 文档,共 944KB,内容紧凑,可直接作为实验报告撰写参考或复习资料。已有 208 人学习下载,适合需要借鉴完整实验思路与代码细节的同学。

1. proc文件系统:内核暴露给用户态的“内容签发窗口”

ls /proc时看到的数字目录、meminfo、cpuinfo 这些文件,在磁盘上占用是零。每次用 cat 读它们,内核都会临时执行一段函数,把结果拷给你。也就是说,proc 里的“文件”其实是一组回调函数的外壳。

计算机操作系统课程里,实验8安排 proc 文件系统的实现,目的就是让你自己注册一个节点,让用户态读到你从内核里取到的数据。这个实验不是 Linux 独占,BSD 也有类似机制,但 Linux 的 /proc 最典型,也是接触 VFS、内核模块、缓冲区管理三者交汇的最短路径。就算你是从零开始手搓操作系统的选手,底层也绕不开“文件系统不存数据、只存函数指针”这套抽象。

2. proc文件系统的内核台面:PDE、inode与proc_ops回调组

2.1 不落盘的inode:proc文件系统究竟“存”了什么

一个 ext4 文件要能读,必须有块号、有 i_size、有数据块映射。而 procfs 的 inode 在创建时就知道自己没有任何数据块。struct inode 仍然被分配,但它的 i_op、i_fop 被设置成一组 procfs 专用的函数。真正持有文件元数据的是 struct proc_dir_entry(PDE),它挂在父目录的链表上,记录 name、mode 和一组操作指针。

用户open("/proc/myfile")走到 procfs 的 inode 查找时,内核按名字找到 PDE,再用它填充一个新 inode。因此“文件什么时候存在”取决于“PDE 是否在目录链表上”,和磁盘没有任何关系。这一点是理解 proc 文件系统实现的关键:创建文件就是往链表里添加一个 PDE;删除文件就是从链表上摘掉它,同时等所有打开它的 fd 关闭。

2.2 proc_create 与 proc_ops:接口的今与昔

内核 3.10 之后推荐用 proc_create 创建单个文件节点。它的完整签名在不同内核版本上有差异,2.6 时代是可能从《Linux 设备驱动》第三版上看到的 create_proc_entry,后来并入 proc_create。到了 5.6,第四个参数从 struct file_operations 换成了 struct proc_ops。两者成员名不同:

file_operationsproc_ops说明
openproc_openopen 时执行,常用于绑定 seq_file
readproc_read把数据拷贝到用户缓冲
writeproc_write接收来自用户态的写入
releaseproc_release关闭时释放 seq_file 资源
llseekproc_lseek调整读位置,通常委托 seq_lseek

写实验模块前先确认当前内核版本,5.6 以上用 proc_ops,更早的用 file_operations 并把 .proc_* 换成 .owner、.read、.write 等命名。你可以直接在内核头文件里确认接口是否已经切换:

grep -E 'proc_create|proc_ops' /lib/modules/$(uname -r)/build/include/linux/proc_fs.h

这段命令看的是当前内核源码树里 proc_create 的原型和 struct proc_ops 是否存在。返回结果里如果有#define proc_create(name, mode, parent, ops)或者 struct proc_ops 的定义,说明当前内核走的是新接口。Ubuntu 22.04 默认 5.15 内核,属于 proc_ops 时代。

2.3 从 open 到 read 的完整调用链

当用户进程执行 open 时,VFS 调用 proc_reg_open。它首先把 PDE 里保存的 proc_ops 取出来挂到 file->f_op 上,这一步很重要:procfs 正是靠这个替换让不同节点拥有各自回调,而不是统一的通用读法。之后如果存在 proc_open 回调,就转调进去。

read 路径同理:proc_reg_read 拿到 proc_ops->proc_read。如果你用 seq_file,proc_read 就是 seq_read,它内部会调用你注册的 start/show/next/stop,把多次 show 的结果拼起来,一次性或分多次拷贝到用户缓冲区。整条链路的尽头就是“用户态在 read,内核态在跑你的函数”,这正是 proc 文件系统实现实验的核心体验。

3. 实现proc文件系统第一个节点:proc_mkdir与proc_create

3.1 模块骨架与初始化

实验最稳妥的开始是先创建目录再创建文件,避免直接在 /proc 根目录铺开。先看最小可编译的模块:

#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> #include <linux/proc_fs.h> #include <linux/uaccess.h> #define EXP_PROC_DIR "exp8" #define EXP_PROC_NODE "version" static struct proc_dir_entry *exp_parent; static ssize_t version_read(struct file *file, char __user *buf, size_t len, loff_t *off) { char version[] = "exp8 v0.1\n"; return simple_read_from_buffer(buf, len, off, version, strlen(version)); } static const struct proc_ops exp_version_ops = { .proc_read = version_read, }; static int __init exp8_init(void) { exp_parent = proc_mkdir(EXP_PROC_DIR, NULL); if (!exp_parent) return -ENOMEM; if (!proc_create(EXP_PROC_NODE, 0444, exp_parent, &exp_version_ops)) { proc_remove(exp_parent); return -ENOMEM; } pr_info("exp8: /proc/exp8/version is ready\n"); return 0; } static void __exit exp8_exit(void) { proc_remove(exp_parent); pr_info("exp8: proc node removed\n"); } module_init(exp8_init); module_exit(exp8_exit); MODULE_LICENSE("GPL");

这个模块的逻辑是:proc_mkdir("exp8", NULL) 在 /proc 下建目录,第二个参数传 NULL 表示父节点是 /proc;proc_create("version", 0444, exp_parent, &exp_version_ops) 在这目录下创建文件。如果任何一步失败,需要把已建好的节点全部清理掉,避免留下半成品。

version_read 里只用 simple_read_from_buffer。它接收 buf(用户态目标)、len(用户缓冲区大小)、off(当前读写位置),第四个参数是内核态数据源。它会从 off 位置开始拷贝,自动更新 off,返回值是实际拷贝字节数,也就是 read 系统调用的返回值。第一次读返回字符串长度,第二次读时 off 已经越过末尾,返回 0,cat 看到 EOF。

3.2 Makefile与加载步骤

编写 Makefile:

obj-m := exp8.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

编译后,sudo insmod exp8.ko装载模块,然后ls -l /proc/exp8/version应当能看到权限 0444 的文件,cat /proc/exp8/version显示 exp8 v0.1。卸载用sudo rmmod exp8,再查看 /proc/exp8 目录应已消失。insmod 和 rmmod 是一对,如果 init 过程中返回非零,insmod 会直接报错并把错误码打印出来。

3.3 创建失败时的排查方向

proc_create 返回 NULL 最常见的原因是名字冲突。如果你在另一个模块里已经建过同名 PDE,这个调用会失败。排查方法:先ls /proc/exp8看是否存在,再用grep -r exp8 /proc看是不是其他模块占用,或者换一个前缀不常见的目录名。dmesg 里模块 init 返回非零会打印init_module from exp8.ko failed,结合 pr_info 能定位失败的是哪一步。

4. 给proc文件系统加动态内容:seq_file四回调

4.1 为什么用 show 而不是 read:从多行输出看 seq_file 的价值

上一节的 version 节点只能输出静态字符串,实验要求往往不止这样,还要输出多个条目,比如从内核数组或链表中取数据。如果继续用 read 回调,就得自己维护 offset 偏移、处理用户缓冲区不够大时的分次返回、以及多次 read 之间的状态保持,很容易写出 bug。

seq_file 是内核给 procfs 开发者准备的通用序列化接口。它不是文件系统,而是一个把“遍历一个数据结构”转换成“输出到用户缓冲区”的中间层。你只需实现四个回调:start、next、stop、show。seq_read 负责在用户缓冲区和这些回调之间做分页、拷贝、推进。

4.2 四个回调的握手协议

回调何时进入返回值生命周期义务
startseq_read 需要新一段数据时返回“当前位置的对象”指针;返回 NULL 终止适合拿锁、取引用
show拿到位置对象之后向 seq_file 写一行;返回 0 继续,非 0 停止只负责 seq_printf / seq_puts
nextshow 成功结束之后返回下一个对象;NULL 表示遍历结束推进 pos,释放临时资源
stop遍历结束或出错时void释放 start 里获取的锁 / 引用

seq_read 会反复调用 start/show/next,直到填满一个 page,然后把这一页拷给用户。如果用户用 cat 读,cat 会一直 read 到返回 0,seq_read 在下一次 read 时用上次保存的 pos 续传,直到 next 返回 NULL。所以“一次遍历”可能被拆进好几次 read,但展示给调用者的语义是连续的文件流。

4.3 完整实现:从内核数组输出到 proc

假设实验模块内部维护一张记录表:

#include <linux/seq_file.h> struct exp_item { int id; const char *name; long value; }; static struct exp_item exp_items[] = { {1, "cpu", 950}, {2, "mem", 2048}, {3, "io", 128}, }; #define EXP_ITEMS_NUM ARRAY_SIZE(exp_items) static void *exp_seq_start(struct seq_file *s, loff_t *pos) { if (*pos >= EXP_ITEMS_NUM) return NULL; return &exp_items[*pos]; } static void *exp_seq_next(struct seq_file *s, void *v, loff_t *pos) { (*pos)++; if (*pos >= EXP_ITEMS_NUM) return NULL; return &exp_items[*pos]; } static void exp_seq_stop(struct seq_file *s, void *v) { } static int exp_seq_show(struct seq_file *s, void *v) { struct exp_item *item = v; seq_printf(s, "%d %s %ld\n", item->id, item->name, item->value); return 0; } static const struct seq_operations exp_seq_ops = { .start = exp_seq_start, .next = exp_seq_next, .stop = exp_seq_stop, .show = exp_seq_show, };

start 的入参 pos 是“第几个条目”的指针,把 pos 作为数组下标直接定位元素,返回元素的地址。show 负责把这个地址当作 struct exp_item *,用 seq_printf 写一行。next 把 pos 加一,判断是否越界。由于数组是静态数据,不需要在 stop 中做清理,stop 留空即可。

接下来把这四个回调接到 proc 节点上:

static int exp_seq_open(struct inode *inode, struct file *file) { return seq_open(file, &exp_seq_ops); } static const struct proc_ops exp_seq_proc_ops = { .proc_open = exp_seq_open, .proc_read = seq_read, .proc_lseek = seq_lseek, .proc_release = seq_release, };

exp_seq_open 里的 seq_open 创建 struct seq_file 对象,挂到 file->private_data。proc_ops 里必须同时给出 .proc_read = seq_read 和 .proc_release = seq_release。很多人写丢 release,结果 open 时 seq_file 分配的内存在 close 时没有释放,模块卸载就会泄漏甚至 oops。也正是因为 seq_file 需要 open 时初始化,所以不能像第 3 章那样只给 .proc_read。把 exp_seq_proc_ops 交给 proc_create 并命名节点为 table,cat /proc/exp8/table就能看到三行数据。

4.4 从数组到进程列表:换掉 start/next 即可

如果你想把实验升级成“输出当前全部进程”,只需把 start/next 中的数组换成任务链表。数组版本 pos 是数组下标,链表版本 pos 是遍历计数,二者都遵循“pos 做游标”这一约定。以 for_each_process 为例:

static void *proc_seq_start(struct seq_file *s, loff_t *pos) { struct task_struct *p; loff_t n = *pos; rcu_read_lock(); for_each_process(p) { if (n-- == 0) return p; } return NULL; } static void *proc_seq_next(struct seq_file *s, void *v, loff_t *pos) { struct task_struct *p = v; struct task_struct *next = next_task(p); (*pos)++; return (next == &init_task) ? NULL : next; }

start 里把 *pos 拷贝到局部变量 n,用 n 做递减,避免修改 *pos 导致 next 的游标错乱。next 里 *pos++,再判断 next_task 是否回到 init_task 锚点,回到就返回 NULL 结束遍历。show 里输出 p->pid、p->comm、p->state,stop 里别忘了 rcu_read_unlock。这个版本的完整代码留给你自己补齐,它和数组版的差别只在数据获取层,seq_file 框架不需要改。

5. 让proc文件系统接受写入:write回调解析用户态数据

5.1 write回调的接口约束

只读节点能看内核数据,实验通常还要求能改。proc 文件系统实现 write 回调的套路和 read 对称:proc_ops 里加一项 .proc_write。注意 write 函数收到的是用户态缓冲区和字节数,不能直接访问 buf,必须用 copy_from_user 把数据搬进内核空间。这里没有“校验和”等安全机制,漏掉 copy_from_user 直接解引用 buf 会让内核访问任意用户地址,产生 oops。

write 返回值必须是“实际消费的字节数”。用户态 write 以这个返回值为准,如果返回 0,echo 会认为写入失败并报错;如果返回负数,系统调用返回该负数对应的错误码。最直接的理解:echo "123" > /proc/...实际上 write 了 4 个字节,其中包含末尾换行,正常实现应该返回 4。

5.2 完整读写模块

下面这个节点让用户态可以写入一个整数,并在 read 时原样输出:

static int exp_value = 100; static ssize_t exp_value_read(struct file *file, char __user *buf, size_t len, loff_t *off) { char kbuf[32]; int n = snprintf(kbuf, sizeof(kbuf), "%d\n", exp_value); return simple_read_from_buffer(buf, len, off, kbuf, n); } static ssize_t exp_value_write(struct file *file, const char __user *buf, size_t len, loff_t *off) { char kbuf[32]; long val; int ret; if (len == 0 || len >= sizeof(kbuf)) return -EINVAL; if (copy_from_user(kbuf, buf, len)) return -EFAULT; kbuf[len] = '\0'; if (kbuf[len - 1] == '\n') kbuf[len - 1] = '\0'; ret = kstrtoint(kbuf, 10, &val); if (ret) return ret; exp_value = val; *off += len; pr_info("exp8: value set to %d\n", exp_value); return len; } static const struct proc_ops exp_value_ops = { .proc_read = exp_value_read, .proc_write = exp_value_write, };

exp_value_read 用 snprintf 把当前值格式化成字符串,再交给 simple_read_from_buffer 搬运。exp_value_write 第一步做缓冲区边界检查:len 必须大于 0 且小于等于 kbuf 大小减一,因为后面要补 '\0',这一步防止栈上越界写。copy_from_user 返回值是“没能拷贝的字节数”,为 0 才表示完全成功,因此非零直接 return -EFAULT。

kstrtoint 以十进制方式解析以 nul 结尾的字符串,返回 0 表示成功。它不像 strtol 那么宽容,输入 "abc" 会干净地返回 -EINVAL,而不是静默解析出 0。如果用户端用 printf 写入带 \r\n 的字符串,你还需要在去掉 \n 之后判断末尾是不是 \r,一并置为 '\0',否则 kstrtoint 会拿到 "123\r" 而报错。最后 *off += len 让文件偏移保持一致,返回 len 告诉 VFS 整个缓冲区都被消费了。创建这个节点的代码和第 3 章一样,把 proc_create 的 ops 换成 exp_value_ops,mode 用 0644 或 0666 都可以,0666 允许普通用户直接改。

装好模块后验证写路径:

echo 250 > /proc/exp8/value cat /proc/exp8/value

看到输出 250 说明写入生效。echo 999999999999超出 int 范围时会返回write error: Invalid argument,这是 kstrtoint 在起作用。

5.3 同时提供 read 和 write 时 proc_ops 的边界

实验里容易踩的坑是:一个节点要读也要写,却在 proc_ops 里漏了 .proc_read。此时 echo 能写成功,但 cat 拿到的可能是错误号。另一个坑是 write 回调返回长度小于 len,比如输入是 "250\n" 时只返回 3,echo 会反复把剩余的 "\n" 再发送一次,直到全部写完,日志里可能出现两次 pr_info。规范做法就是“接受多少返回多少”。

6. 验证proc文件系统实现的链路:dmesg、strace与新内核接口核对

6.1 用 strace 验证用户态行为

加载模块后,如果你想确认 cat 到底怎么触发回调,可以在另一个终端跑:

sudo strace -f -e trace=openat,read,write,close cat /proc/exp8/value

输出里能看到 cat 依次调用了 openat(AT_FDCWD, "/proc/exp8/value", O_RDONLY)、read 到一批字节、关闭文件。如果 read 返回 0 之后还跟了一次 read,说明读回调没有正确返回“应该返回的字节数”,或者 off 处理有误。用 strace 对比自定义节点和 /proc/meminfo 的系统调用序列,多数返回差异能暴露实现问题。

6.2 用 dmesg 追踪回调调用次数

在 read/write 实现里临时插入 pr_info,然后:

echo test > /proc/exp8/value dmesg | tail -20

如果 pr_info 只出现一条且 write 返回无误,说明 write 是原子完成的。如果插入到 show 里,cat 之后会看到多次 show 日志,这是 seq_read 按页填充所致,不是 bug。模块卸载前再查一次 dmesg,确保没有BUG: scheduling while atomic或其他告警。

6.3 版本差异与本实验直接相关的 3 个检查点

第一,内核 5.6 以下用 file_operations 时,注册结构体成员名是 .read/.write,不是 .proc_read/.proc_write,编译会直接报 unknown field。第二,seq_file 方案必须有 .proc_open,只读静态方案可以有也可以没有,但多数教材使用 seq_file 时要配齐 seq_release。第三,/proc 节点权限位 mode 不要加 S_IFREG,proc_create 内部自己设置 inode 类型,写 0444 或 0644 即可。如果参考书还是《Linux 内核设计与实现》对应的旧内核例子,注意 create_proc_entry 已经彻底删掉了。

把这些检查点写进实验报告,同时把加载前后ls -l /proc/exp8的权限变化、strace 的 read 长度变化贴进去。procfs 里最典型的三个现象是“能 insmod 不能读”“能读不能写”“写完重启失效”,前两个在 6.3 的检查点里都能定位,第三个是常态:procfs 节点每次开机都要由模块重新创建,因此把创建函数放进模块 init 属于标准做法。

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

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

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

立即咨询