ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

为Linux 0.11内核手写proc文件系统:从零实现psinfo节点

为Linux 0.11内核手写proc文件系统:从零实现psinfo节点 简介操作系统课程实验报告面向计算机相关专业学生聚焦Linux 0.11内核中proc文件系统的实现。文档围绕实验目的、内容、思考题与实现步骤展开涵盖增加新文件类型、修改mknod()和sys_read()、编写proc处理函数及makefile配置等关键环节可帮助读者掌握虚拟文件系统原理理解进程状态信息如何通过procfs暴露给用户态。资源为单个docx文档大小944KB内容包含完整实验代码与思考题解答例如如何设计额外节点、处理多次read()时数据一致性等。已有208人学习适合正在完成操作系统课程设计或对内核文件系统感兴趣的学生参考。1. 从零给 Linux 0.11 挂一个 proc 文件系统当你在一个没有 /proc 的 Linux 系统上排查进程问题时连cat /proc/psinfo都报 No such file or directory第一步不是修应用而是先问内核到底有没有把进程表以文件的形式暴露出来。我在重做操作系统实验时把这个场景压缩到了一个最小的内核Linux 0.11。这个版本没有现代 VFS 抽象层但文件、目录、inode 概念已经成型正好用来实现 procfs 的 psinfo 节点。实验核心是在 sys_read 路径上增加一个分支让读取 /proc/psinfo 时动态拼出所有进程的 PID、PPID、状态与 CPU 时间随后我又补了 hdinfo 和 inodeinfo 两个节点把硬盘块位图和 inode 表也暴露成文件。适合正在学操作系统的学生也适合想理解 Linux 内核 read 路径的人。理解了这里的 sys_read 分支再看现代内核的 file_operations 会容易得多。2. 虚拟文件系统的位点procfs 接入内核的三条线索2.1 为什么 procfs 要设计成“文件”而不是新的系统调用Unix 设计哲学里有一个关键约定一切皆文件。进程列表、内存统计、设备状态如果都暴露成普通文件用户态只需要 open/read 就能拿数据不需要为每个功能发明一个新系统调用。现代 Linux 的 procfs 挂载在 /proc 下用的是 file_operations 注册到 VFSLinux 0.11 还没有这层抽象但它已经把文件类型、inode 和系统调用 mknod、read 分开了所以可以让 proc 节点走一条独立的类型分支。我把这个位置梳理成三条线索第一文件类型常量 S_IFPROC让 inode 从“普通文件/目录/设备”里脱离出来第二sys_mknod 放行这个类型允许在根目录建立 /proc/psinfo 这样的节点第三sys_read 在真正走文件系统读写前拦截这个类型的 inode把请求派发给 proc 处理函数。三处都改完后内核就多了一种“看起来像文件、实际上不落盘”的伪文件。普通文件与 proc 伪文件最关键的区别是数据来源和可写性。普通文件的 read 最终走到块设备驱动从磁盘扇区读数据proc 文件的数据在 read 调用发生时由内核临时生成。下面这张表可以表达我在动手前对两个概念的划分维度普通文件proc 伪文件数据来源磁盘块/缓存页内核数据结构是否占用磁盘块是否read 行为由 inode 的映射关系决定由处理函数现场拼接生命周期随文件创建/删除随内核运行始终存在写操作写回磁盘0.11 里通常只读有了这个认识实现 psinfo 时就不会把 proc_buf 当成磁盘缓冲区而是把它理解为“每次生成快照的工作区”。2.2 增加文件类型常量S_IFPROC 与 S_ISPROCLinux 0.11 的文件类型用 st_mode 的高 4 位表示S_IFMT 是掩码 0170000。普通文件是 0100000目录是 0040000字符设备是 0020000。要在不破坏既有类型的前提下增加 proc 类型最直接的办法是找一个未占用的编码。实验指导的写法是 S_IFPROC但注释里特别提醒判断宏应该写成 S_ISPROC(m)而不是把 S_IFPROC 当函数调用。这里给出我在include/sys/stat.h里的改动#define S_IFMT 0170000 #define S_IFREG 0100000 #define S_IFDIR 0040000 #define S_IFCHR 0020000 #define S_IFBLK 0060000 #define S_IFPROC 0010000 /* 自定义proc 伪文件类型 */ #define S_ISPROC(m) (((m) S_IFMT) S_IFPROC)这个宏先取出 st_mode 的高四位再和 S_IFPROC 比较。用 S_ISPROC(m) 而不是 (S_IFPROC(m))是因为我们的设计里 S_IFPROC 只是一个常量不是函数。初写代码时经常把 S_ISREG 和 S_IFREG 混用编译报错后才发现内核里这套命名规则其实非常统一S_IF* 给 mknod 传参S_IS* 用来判断 inode 类型。2.3 inode 里的设备号字段如何区分多个 proc 节点一个 procfs 里不可能只有 psinfo还要有 hdinfo、inodeinfo 甚至更多节点。Linux 0.11 的 sys_read(fd, buf, count) 只能拿到文件指针 filp再由 filp-f_inode 拿到 inode但拿不到路径名。所以不能用文件名来区分当前是哪个人口只能在创建节点时把节点编号塞进 inode 的某个字段。常见做法是复用设备号字段sys_mknod(path, mode, dev) 的 dev 低位在这个实验里就是节点编号。我的约定是dev0 对应 psinfodev1 对应 hdinfodev2 对应 inodeinfo。这三个编号由 sys_read 里的 proc_read 第一个参数接收再分别调用不同的生成函数。下面是节点与编号的对应关系节点dev返回内容/proc/psinfo0进程状态快照/proc/hdinfo1硬盘总块数、空闲块数、总 inode 数/proc/inodeinfo2超级块中的 inode 总数inode-i_zone[0] 在 0.11 里经常被用来存放设备号。现代内核里你会看到 inode-i_rdev 专门干这个事而 0.11 里还没拆出来所以读取时直接拿 i_zone[0] 就行。这个复用方式虽然简陋但很符合 0.11 的代码风格能塞进 inode 的字段绝不新增结构体。3. 动手实现mknod、sys_read 与 proc 处理函数3.1 改动清单总览这一章的改动分布在六个文件里。先记住下面的清单后面逐个讲文件改动include/sys/stat.h增加 S_IFPROC/S_ISPROCfs/namei.csys_mknod 放行 proc 类型init/main.c创建 /proc 及三个节点fs/read_write.csys_read 增加 proc 分支fs/proc.cproc_read 与 get_* 函数fs/Makefile加入 proc.o3.2 放行 S_IFPROC修改 sys_mknodsys_mknod 在fs/namei.c中负责创建节点。默认情况下它只允许常规文件、目录、字符设备和块设备遇到未知类型会返回 -EINVAL。要让 /proc/psinfo 能被创建必须把 S_ISPROC(mode) 加进判断。if (!S_ISREG(mode) !S_ISDIR(mode) !S_ISCHR(mode) !S_ISBLK(mode) !S_ISPROC(mode)) return -EINVAL;逻辑说明这一串条件用“非 A 且非 B”的方式过滤掉非法类型任何一个 S_IS* 为真都会让整体条件为假从而放行。加上 !S_ISPROC(mode) 之后mknod(/proc/psinfo, S_IFPROC|0444, 0) 才能走到 namei 层创建 inode。注意这里 mode 是用户传进来的完整 st_mode包含权限位和类型位所以必须通过 S_ISPROC() 做掩码匹配不能直接比较 mode S_IFPROC。3.3 初始化阶段创建 /proc 目录和节点在 Linux 0.11 里根目录通常是 Minix 格式的软盘或硬盘镜像。proc 目录本身不落盘所以不能在 shell 里用 mkdir 直接创建而要在内核初始化或系统启动脚本里显式调用 sys_mkdir 和 sys_mknod。我一般放在init/main.c的 init() 函数中挂接根文件系统之后调用sys_mkdir(/proc, 0755); sys_mknod(/proc/psinfo, S_IFPROC | 0444, 0); sys_mknod(/proc/hdinfo, S_IFPROC | 0444, 1); sys_mknod(/proc/inodeinfo, S_IFPROC | 0444, 2);参数说明sys_mkdir 的第二个参数是权限位0755 保证 root 和普通用户都能进入目录sys_mknod 的第三个参数就是前面约定的节点编号 0/1/2。权限位 0444 表示只读proc 文件不应该被随意写入这也避免了后续处理 write 的麻烦。编译进内核后启动时 /proc 目录会直接出现在根文件系统中ls -l /proc 能看到这几个节点但它们的 i_size 和普通文件不同因为数据不是预置的。3.4 sys_read 增加 proc 分支整个实验最关键的一处改动在fs/read_write.c的 sys_read。正常情况下sys_read 会根据 inode 类型分别走到管道、字符设备或普通文件逻辑。我在这些分支之前插入 proc 判断if (S_ISPROC(inode-i_mode)) { return proc_read(inode-i_zone[0], filp-f_pos, buf, count); }说明这里 inode 来自 filp-f_inodeinode-i_zone[0] 保存的是节点编号filp-f_pos 是用户态 read 的文件位置buf 和 count 是用户传下来的目标缓冲区与长度。将 f_pos 的地址传给 proc_read是为了让多次 read 能连续接着读如果不传地址内核无法记住这次读到了哪里下一次 read 又会从开头返回相同数据。proc_read 的返回值是实际读取的字节数它直接成为 sys_read 系统调用的返回值。用户态的 read(2) 返回 0 表示 EOF返回负数表示错误。这个约定在这个分支里必须严格遵守否则 shell 的 cat 会陷入死循环。3.5 完成 proc_read 与 get_psinfo下面是我整理后的 proc_read在原实验代码基础上修掉了 ans 未初始化的问题char proc_buf[4096]; static int proc_len; int get_psinfo(void) { int ans 0; struct task_struct **p; ans sprintf(proc_buf ans, PID\tPPID\tS\tPRI\tTTY\tTIME\n); for (p LAST_TASK; p FIRST_TASK; --p) { if (*p) { ans sprintf(proc_buf ans, %d\t, (*p)-pid); ans sprintf(proc_buf ans, %d\t, (*p)-father); ans sprintf(proc_buf ans, %d\t, (*p)-state); ans sprintf(proc_buf ans, %d\t, (*p)-priority); ans sprintf(proc_buf ans, %d\t, (*p)-tty); ans sprintf(proc_buf ans, %d\n, (*p)-cutime (*p)-cstime); } } return ans; } int proc_read(int dev, unsigned long *pos, char *buf, int count) { int i; if (*pos 0) { if (dev 0) proc_len get_psinfo(); else if (dev 1) proc_len get_hdinfo(); else if (dev 2) proc_len get_inodeinfo(); else return 0; } for (i 0; i count; i, (*pos)) { if (*pos proc_len) return i; put_fs_byte(proc_buf[*pos], buf[i]); } return i; }逻辑说明get_psinfo 从 LAST_TASK 往前扫描到 FIRST_TASK把每个非空 task 的六个字段格式化成行。sprintf 的返回值是本次写入的字节数累加进 ans 后同时作为 proc_buf 的下一个写入位置。循环里 *p 为真才输出因为进程表中某些槽位可能是空指针。proc_read 使用 if (pos 0) 判定是否重新生成快照。文件指针为 0 时代表用户刚开始读或通过 lseek 回到了开头这时调用对应的 get_函数刷新缓冲区文件指针不为 0 时直接用上一轮生成的 proc_buf 继续返回。put_fs_byte(proc_buf[*pos], buf[i]) 负责把内核内存的数据拷贝到用户空间这是一个不可省略的步骤直接 *buf proc_buf[*pos] 会因为地址空间隔离而触发段错误。参数说明dev 是节点编号pos 指向 filp-f_pos每次循环自增确保下一次 read 从上次结束位置继续count 是用户请求的字节数循环里 i count 保证不越界写用户缓冲区proc_len 是当前快照长度当 pos 越过它时返回正数 i下一次进 proc_read 会因为 *pos 0 且 *pos proc_len 在最外层返回 0从而告诉用户态读到 EOF。3.6 Makefile 的改动如果 proc_read 和 get_psinfo 写在fs/read_write.c里不需要额外加文件但如果单独放在fs/proc.c要通知构建系统把这些符号编进去。我习惯新建 fs/proc.c然后在 fs/Makefile 中加一行OBJS proc.o说明Linux 0.11 的 Makefile 通过 OBJS 列表收集内核需要的目标文件加入 proc.o 后链接器会在 fs 目录下找到 proc.o 并把它编入 system image。改完 Makefile 后最好 make clean make 一次避免旧版本目标文件残留影响判断。4. 扩展hdinfo/inodeinfo 与多次 read 的快照决策4.1 报告题为什么我选择实现硬盘利用率节点实验报告里要求设想一个新节点并说明理由。我没有选任务调度器而是选择把硬盘块位图暴露出来原因是 Linux 0.11 的超级块里已经有了 s_nzones 和 s_zmap_blocks统计剩余块只需要把每个位图块的每个字节逐位检查代码量很小还不会影响进程调度路径。这与 Windows 任务管理器性能页的“磁盘使用率”在语义上接近能直观看到根文件系统还剩多少空间。如果要做得和任务管理器性能页更像可以再加一个节点读取 mem_map[] 中空闲页的数量来显示内存利用率。0.11 的物理内存管理很简单遍历 mem_map 并统计空闲页数量的开销可以接受但这里我保留了 hdinfo因为寄存器少、位图结构稳定作为例子更干净。4.2 get_hdinfo 的位图统计实现get_hdinfo 是我在上面的 proc_read 中通过 dev 1 调用的函数完整实现如下int get_hdinfo(void) { struct super_block *sb; unsigned int ans 0, i, j, blockused 0; unsigned char tmp; sb get_super(current-root-i_dev); for (i 0; i sb-s_zmap_blocks; i) for (j 0; j 1024; j) for (tmp sb-s_zmap[i]-b_data[j]; tmp; tmp 1) blockused tmp 1; ans sprintf(proc_buf ans, total_blocks :%u\n, sb-s_nzones); ans sprintf(proc_buf ans, Free blocks :%d\n, sb-s_nzones - blockused); ans sprintf(proc_buf ans, Total inodes :%u\n, sb-s_ninodes); return ans; }逻辑说明get_super(current-root-i_dev) 拿到根设备对应的超级块current-root 是当前进程的根目录 inode它的 i_dev 就是启动后挂载的根设备号。s_zmap 是指向块位图块的指针数组每个块大小是 1024 字节。内层循环把每个字节 tmp 不断右移通过 tmp 1 统计该字节中二进制位为 1 的个数也就是已分配的块数。最后用空闲块数 总块数 - 已用块数计算并输出。get_inodeinfo 更简单只需要读取超级块的 s_ninodes 字段。这两个函数都用 sprintf 把格式化结果写入 proc_buf与 get_psinfo 共用同一个缓冲区所以必须保证同一时刻只有一个节点被读取。实验里用 if (*pos 0) 重新生成快照正好串行化了这个访问。4.3 多次 read 之间进程状态变化数据应该取变化前这里要回答报告中的第二个问题。cat 读取一个超过 4096 字节的 psinfo 时会多次调用 read(2)两次调用之间进程可能退出或 fork 出新进程。如果把每次 read 都做成“读取当前进程表”那前后两次拿到的进程集合可能不连续第一次返回 PID 10、11、12第二次再回去读时 12 已经退出用户会看到 pid 突然消失数据拼接处出现明显的错位。我的选择是返回变化前的数据。具体做法就是 proc_read 里的 if (*pos 0) 设计只有文件指针归零时才重新生成快照后续所有 read 都只从旧的 proc_buf 中继续取字节。这样用户从头到尾读到的都是同一个瞬间的快照逻辑上等价于内核把一份当时的进程表拷贝给了用户。策略两次 read 的数据来源衔接风险实现代价每次 read 重新生成两个不同时刻高PID 集合可能变化低代码直观从头读取时生成缓存同一快照低数据自洽需要保存 proc_len需要说明的是这个方案要求用户严格从 pos0 开始读。如果应用只 read 一部分后就 lseek 到中间位置那么读到的仍然是上次缓存的那一段即旧快照的中间部分并不会返回新数据。实验指导里“利用文件指针和缓冲区缓冲区读完再刷新”正是这个意思刷新时机只由 *pos 0 决定不是由缓冲区被读完决定。4.4 原实验代码中的一个坑ans 未初始化按实验指导里的原始代码proc_read 中 int ans; 在 *pos ! 0 时不会进入 if 块后面 if (*pos ans) 就会读到栈上随机值。连续 read 超过缓冲区长度后行为就不可预测。我在前面的实现里把长度挪成了全局 proc_len并且在生成函数返回时赋值。这样第二次 read 进入时proc_len 保存的是上一次快照的长度边界判断 *pos proc_len 才稳定。同样值得注意的还有 proc_buf 的容量。Linux 0.11 在真实硬件上并发进程数远小于理论上限但初始化时仍会创建多个内核线程134 个 TASK 槽位如果全部占满表头加每行约 30 字节会接近 4096 的极限。稳妥的做法是在 get_psinfo 的每个 sprintf 之前检查 ans 行长 4096超限就截断输出避免内存越界。5. 验证与排错让 /proc/psinfo 在你的内核里跑起来5.1 用 QEMU 跑内核并挂载根文件系统Linux 0.11 内核不想直接刷到物理机时可以用 QEMU 做验证。这也是我推荐的方式在本机写好代码交叉编译出 Image然后用 QEMU 启动虚拟机。下面这条命令是常见做法qemu-system-i386 -m 16 -kernel ./linux-0.11/Image \ -hda rootfs.img -append root/dev/hda1参数说明-m 16 给虚拟机分配 16MB 内存Linux 0.11 不需要更多-kernel 直接加载内核映像省去引导扇区-append root/dev/hda1 把根设备告诉内核。启动后进入 shell先执行 ls -l /proc看到 psinfo、hdinfo、inodeinfo 三个节点再执行 cat /proc/psinfo。如果第一行是 PID PPID S PRI TTY TIME下面跟着若干行数字说明节点已经生效。5.2 三个高频坑第一个坑是宏名写错。指导里写的是 S_IFPROC()实际代码应当用 S_ISPROC()。编译时若报 S_IFPROC undeclared 或 parse error before )优先检查这里。第二个坑是 proc_buf 越界。进程表较大时一旦 sprintf 写入超过 4096 字节会破坏相邻内核数据表现为随机死机或输出乱码。我在自己的实现里给每个写入点都加了剩余空间判断宁可截断也不越界。第三个坑是忘记在 sys_read 里插入分支导致打开 /proc/psinfo 返回 0 字节。排错时先确认 open 成功再用 hexdump -C /proc/psinfo | head 看是否有内容如果一直是空文件检查 inode-i_zone[0] 是否被正确初始化为 0/1/2。5.3 用 dd 验证分段读取的快照行为要确认“多次 read 返回变化前数据”是否真的生效可以人为限制单次 read 大小dd if/proc/psinfo bs64 count1 2/dev/null | od -c第一段输出后再用 dd if/proc/psinfo skip64 count1 看第二段。没有跳转时第二次 read 会从 filp-f_pos 继续。此时可以在 proc_read 里临时加一句 printk(read dev%d pos%d count%d\n, dev, *pos, count) 观察调用序列第二次调用的 pos 大于 0且不再进入 if (*pos 0)。这比在用户态猜数据来源可靠得多也是我排查这类问题时的首选手段。本文还有配套的精品资源点击获取
返回列表