行业资讯
📅 2026/8/24 14:04:11
XV6 Lab1 Utilities 实验全记录: 从零基础到五关通关
XV6 Lab1 Utilities 实验全记录从零基础到五关通关作者注本文记录了我作为零基础学生在无任何系统编程的基础上完成 MIT 6.S081 Lab1 的完整历程。包含所有踩坑、修复与认知升级。实验概览Lab1 Utilities 是 MIT 6.S081 操作系统课程的第一个实验要求实现五个 类Unix 工具sleep、pingpong、primes、find、xargs。官方目标是让学生熟悉 xv6 的系统调用接口但我使用的代码副本因环境不完整额外经历了系统调用补全的过程反而让我对内核有了更深的理解。实验一sleep2.1 用户程序实现sleep要求接收一个 tick 参数调用系统调用让进程休眠指定时长。c#include kernel/types.h #include user/user.h int main(int argc, char *argv[]) { if (argc ! 2) { fprintf(2, Usage: sleep ticks\n); exit(1); } int ticks atoi(argv[1]); sleep(ticks); exit(0); }关键认知fprintf(2, ...)使用文件描述符 2标准错误这是评分脚本make grade识别错误输出的关键。2.2 出现报错exec sleep failed现象在 xv6 shell 中输入sleep 2提示exec sleep failed。原因分析Makefile的UPROGS列表中未添加$U/_sleep编译系统没有将程序打包进fs.img。解决在Makefile的UPROGS末尾添加$U/_sleep注意续行符\的规则。2.3 链接错误undefined reference to sleep现象编译时链接器报错找不到sleep函数实现。原因分析xv6 通过user/usys.pl脚本生成系统调用的用户态存根sleep未被注册。解决方案在user/usys.pl中添加entry(sleep);在kernel/syscall.h中添加#define SYS_sleep 23认知升级用户态调用sleep()实际上是一个ecall指令需要一个跳板存根来触发陷入内核。这就是usys.pl的作用。2.4 内核报错unknown sys call 23现象编译通过QEMU 启动但执行sleep 2时内核打印unknown sys call 23。原因分析内核虽然收到了系统调用号 23但在syscalls[]分发数组中没有注册对应的处理函数。解决方案在kernel/sysproc.c中实现sys_sleepuint64 sys_sleep(void) { int n; argint(0, n); if(n 0) return -1; acquire(tickslock); uint64 ticks0 ticks; while(ticks - ticks0 n) { if(killed(myproc())) { release(tickslock); return -1; } sleep(ticks, tickslock); } release(tickslock); return 0; }2.在kernel/syscall.c中注册[SYS_sleep] sys_sleep,认知升级用户程序sleep.c只是点菜单真正的厨师是内核中的sys_sleep。用户态无法操作硬件时钟必须通过系统调用陷入内核。2.5 核心收获系统调用全链路用户程序 → 存根 (ecall) → 内核分发 (syscall.c) → 具体实现 (sys_sleep)。缺一不可。pingpong —— 进程间通信初体验3.1 实验要求父进程通过管道发送一个字节给子进程子进程收到后打印pid: received ping再通过另一个管道回传一个字节父进程收到后打印pid: received pong。3.2 完整代码c#include kernel/types.h #include user/user.h int main(void) { int parent_to_child[2]; int child_to_parent[2]; pipe(parent_to_child); //创建两个管道用于两进程通信 pipe(child_to_parent); int pid fork(); if(pid 0){ //子进程 close(parent_to_child[1]); close(child_to_parent[0]); char buf[1]; read(parent_to_child[0],buf,1); printf(%d:received ping\n,getpid()); write(child_to_parent[1],buf,1); close(parent_to_child[0]); close(child_to_parent[1]); }else{ close(parent_to_child[0]); //父进程 close(child_to_parent[1]); char buf[1] {a}; //父进程给子进程发送一个子节 write(parent_to_child[1],buf,1); read(child_to_parent[0],buf,1); printf(%d:received pong\n,getpid()); wait(0); //等待子进程结束 close(parent_to_child[1]); close(child_to_parent[0]); exit(0); } return 0; }3.3 关键认知1. 管道是单向的数据从写端流向读端因此需要两个管道实现双向通信。2.fork()后必须立即close()每个进程必须关闭自己不使用的管道端否则read()会永远阻塞——管道写端未关闭意味着可能还有人要写。3.wait(0)防止僵尸进程父进程必须等待子进程退出否则子进程会变成僵尸进程。4.fork()if-else 两个进程同时运行fork()返回 0 的是子进程返回正 PID 的是父进程。两个分支在不同进程中执行。5. 多进程 vs 多线程fork()创建的是独立进程拥有独立地址空间父子进程通过管道通信而非共享内存这是多进程编程而非多线程。实验三primes —— 递归分叉的并发筛法4.1 算法原理采用埃拉托斯特尼筛法的并发版本主进程将 2~35 写入第一个管道第一个进程读出第一个数 2素数筛掉其倍数将剩余数传给下一个进程第二个进程读出 3素数继续筛...形成进程链4.2 完整代码c#include kernel/types.h #include user/user.h void primes(int p0){ int prime; if(read(p0,prime,sizeof(prime)) 0){ //若在管道中没数可读则终止该进程。 close(p0); exit(0); } printf(prime %d\n,prime); int p[2]; //创建新管道用与连接下一进程。 pipe(p); if(fork()0){ //子进程像它的父进程接受待筛数字打印prime筛掉当前prime的倍数和创建其子进程。 close(p[1]); primes(p[0]); close(p[0]); exit(0); } else { //父进程接受待筛数字打印prime筛掉当前prime的倍数。 close(p[0]); int num; while(read(p0,num,sizeof(num) ) 0){ if(num %prime ! 0){ write(p[1],num,sizeof(num)); } } close(p0); close(p[1]); wait(0); //等待该父进程的子进程结束 exit(0); } } int main() { int p[2]; pipe(p); if(fork()0){ close(p[1]); primes(p[0]); close(p[0]); exit(0); } else { //初始父进程负责输入235给其子进程 close(p[0]); for(int i 2;i 35;i ){ write(p[1],i,sizeof(i)); } close(p[1]); wait(0); exit(0); } }4.3 核心认知1. 递归分叉Recursive Forking递每个素数发现后fork()子进程形成进程链归最末进程读不到数据退出父进程wait()回收层层回溯2.read()的推进机制cint num; while (read(p0, num, sizeof(num)) 0) { ... }read()自带迭代推进——每次调用从管道取 4 字节内核维护读指针。不需要i。3. 单变量复用num是一个临时工每次read覆盖旧值。程序永远只处理当前数字不存所有数据体现了流式处理的内存优势。实验四find —— 目录树递归遍历5.1 实验要求实现find命令在指定目录树中递归查找所有匹配文件名的文件打印完整路径。5.2 完整代码先在user目录下的ulib.c末尾添加strcat函数实现有就不用了char* strcat(char *dest, const char *src) { char *rdest dest; while (*dest) dest; while ((*dest *src)) ; return rdest; }然后在user/user.h中添加char* strcat(char*, const char*);也是有就不用c#include kernel/types.h #include kernel/stat.h #include kernel/fs.h //包含对struct dirent的定义 #include user/user.h void find(char *path, char *target){ char path_record[512]; //用来存放路径 struct dirent de; struct stat st; //stat()用于判断该文件是普通文件还是目录,或是无法检验 int fd open(path, 0); if(fd 0){ //无权打开或该路径失效 fprintf(2,find: cannot open %s\n,path); return; } if(fstat(fd, st) 0){ //无法判断用户给的directory是普通文件还是目录 fprintf(2,find: cannot stat %s\n,path); close(fd); return; } if(st.type T_FILE){ //若用户给的directory是普通文件则结束find函数 close(fd); return; } //逐行遍历目录 while(read(fd, de,sizeof(de)) sizeof(de)){ if(de.inum 0) //跳过空成员 continue; if(strcmp(de.name, .) 0 || strcmp(de.name, ..) 0) //跳过指向目录自身和其父目录的两行 continue; strcpy(path_record, path); //更新目录成员的路径 strcat(path_record, /); strcat(path_record, de.name); if(strcmp(de.name, target) 0){ printf(%s\n,path_record); } if(stat(path_record, st) 0){ continue; } if(st.type T_DIR){ find(path_record, target); //若查到是子目录则用其路径递归调用find() } } close(fd); } int main(int argc, char *argv[]){ if(argc ! 3){ fprintf(2,Usage: find directory filename\n); exit(1); } find(argv[1], argv[2]); exit(0); }5.3 核心认知1. 目录本质是账本Unix 中目录不是容器而是文件存储着struct dirent数组文件名 inode 号。read(fd, de, sizeof(de))就是在翻账本。2. 文件描述符和路径的区别路径字符串快递地址用于定位文件描述符整数取件码用于实际读写操作3. 必须跳过.和..否则会在当前目录和父目录间无限递归耗尽内核栈。4. 路径拼接strcpy(path_record, path) strcat(path_record, /) strcat(path_record, de.name)5.stat的必要性匹配到文件名后需要stat判断它是普通文件还是目录。即使是目录也要递归进去查是否有同名子文件。6. 递归不会漏扫父目录的read指针在递归期间冻结返回后继续读下一行。深度优先搜索天然保证全覆盖。实验五xargs —— 参数构建与进程执行6.1 实验要求从标准输入逐行读取数据每读一行执行一次指定命令将行内容作为附加参数。6.2 完整代码c#include kernel/types.h #include kernel/stat.h #include user/user.h void execute_exec(char *filename, char *argve[]){ //创建子进程并在子进程调用exec函数 int pid; pid fork(); if(pid 0){ exec(argve[0], argve); //exec加载对应程序并执行如执行echo) fprintf(2,xargs:exec %s failed\n, argve[0]); exit(1); }else { wait(0); } } int main(int argc, char *argv[]){ if(argc 2){ //参数校验至少要有命令名如echo) fprintf(2,Usage: xargs command [arguments...]\n); exit(1); } char buf[512]; //存放从stdin读入的一行 int count 0; //存放读入的字符数 char ch; //逐字符读取标准输入文件标识符0 while(read(0, ch, 1) 0){ if(ch \n){ //若遇到换行符 if(count 0){ continue; } buf[count] \0; //在末尾加上字符串结束符 char *args[32]; //构造exec所需的参数列表 int i 0; for(i 0;i argc - 1; i){ //先在args填入xargs的固定参数argv[1]到argv[argc-1]) args[i] argv[i 1]; } args[i] buf; //再填入从stdin读入的字符串 args[i 1] 0; //最后加0空指针表示参数列表结束 execute_exec(args[0], args); count 0; } else { //若读到字符串 if(count 511) { buf[count] ch; } } } if(count 0){ //若最后一行结束没有\n了,则直接在末尾加上字符串结束符 buf[count] \0; char *args2[32]; int j 0; for(j 0;j argc - 1; j){ args2[j] argv[j 1]; } args2[j] buf; args2[j 1] 0; execute_exec(args2[0], args2); count 0; } exit(0); }6.3 核心认知1.exec()的本质是夺舍exec()用新程序替换当前进程的代码段如果成功永不返回。因此必须fork()子进程去exec()父进程继续读下一行。2. Stdin标准输入文件描述符 0默认指向键盘输入管道符|将其重定向到左边程序的输出文件重定向将其指向文件xargs不关心文件描述符 0指向哪里只做read(0)读入流式数据3.argc/argv与stdinargv启动时一次性传入的静态参数stdin运行时动态流入的数据流4. 参数列表必须0结尾exec()遍历args数组直到遇到0否则越界。5.if(count 0)的作用处理文件末尾没有换行符的最后一行。因为\n是发令枪没有最后一声枪响需要手动补发。环境问题总结7.1 VS Code 误报conflicting types原因VS Code 的 C/C 插件拿标准库原型校验 xv6 的kernel/defs.h长度参数uintvsunsigned long。解决在VS Code资源管理器创建.vscode/c_cpp_properties.json添加-fno-builtin让检查器禁用内置函数校验。json{ configurations: [ { name: Linux, includePath: [${workspaceFolder}/**], compilerPath: /usr/bin/riscv64-linux-gnu-gcc, cStandard: gnu99, intelliSenseMode: linux-gcc-x64, compilerArgs: [ -fno-builtin, -ffreestanding ] } ], version: 4 }7.2sprintf不可用xv6 是独立环境没有完整标准 C 库。sprintf可能缺失声明或实现。解决使用strcpystrcat手动拼接字符串。总结与感悟8.1 知识体系建立通过 Lab1 五关我建立了以下认知框架系统调用用户态请求内核服务的完整链路进程管理fork()克隆进程exec()变身程序wait()回收资源进程间通信管道是单向字节流双向需两个管道文件系统目录是特殊文件通过read读取目录项标准 I/O文件描述符 0、1、2 的抽象与重定向九、附Makefile 配置要点makefileUPROGS \ ...原有项... \ $U/_sleep \ $U/_pingpong \ $U/_primes \ $U/_find \ $U/_xargs续行符规则非最后一项末尾必须有\最后一项不能有新增程序先写user/xxx.c再改Makefile最后make clean make qemu