ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MIT 6.S081 Lab1 深度解析:用xv6实现Unix工具与系统调用

MIT 6.S081 Lab1 深度解析:用xv6实现Unix工具与系统调用 如果你问一个正在修操作系统课的人“MIT 6.S081 的 lab1 到底是个什么难度”大概会得到两种答案有人觉得它轻松得像课后作业有人却在make grade面前被红叉逼到怀疑人生。我第一次做完 lab1 的时候其实属于后者——那会我连xv6的用户态和内核态边界都没完全拎清愣是靠着不断打印、不断试错把五个程序磨到全绿。现在回头看这个 lab 最大的价值不是让你会写几个小程序而是用最小的成本把你从“操作系统理论”拉到“操作系统代码”的真实场景里。lab1 的官方名字叫Lab: Xv6 and Unix utilities中文社区一般翻译成“实现常见的用户程序”。它的任务是在 xv6 教学操作系统上自己动手写出sleep、pingpong、primes、find、xargs这五个用户态程序。这篇文章把我完整做 lab1 的经验整理出来包括每个程序的思路、关键代码、我在测试用例上踩过的坑以及最后排查问题的通用方法。如果你正准备入坑 6.S081或者刚做完 lab0 想找人带一带这篇应该能帮你少走不少弯路。1. 认识 lab1操作系统课的“用户态第一课”1.1 lab1 到底考什么先给没接触过的同学解释一下背景。MIT 6.S081 是麻省理工的本科操作系统课配套的 xv6 是一个教学用的小型 Unix 操作系统代码量只有一万多行但该有的进程、管道、文件系统、中断机制全都具备。lab1 是这门课的起点让你在 xv6 上实现五个 Unix 工具程序表面上是在“写应用”实际上是在训练你理解系统调用接口、进程模型、管道通信和文件描述符生命周期。明白这一点非常重要。很多人把 lab1 当成普通编程题打开 Linux 的 man 手册照着写结果在 xv6 里处处碰壁——因为 xv6 的用户态库非常精简没有全套 glibc你没法用现成的opendir、strtok、getline等函数很多功能必须基于系统调用来手搓。五个任务之间的递进关系也很清晰程序核心考点难度sleep参数解析、基础系统调用低pingpong管道创建、fork 继承、fd 关闭中低primes多进程递归、管道数据流、阻塞读高find目录遍历、路径拼接、递归中高xargs标准输入解析、fork/exec 参数构造中这五个程序全都要跑在 xv6 启动后的 shell 里而不是你本机的 Linux 上。你写完代码后需要把程序加入到 xv6 的 Makefile 的UPROGS列表里重新编译启动make qemu然后在 xv6 的 shell 里手动执行测试。1.2 环境准备与调试手段环境搭建属于 lab0 的内容但我觉得还是有必要提几个关键点因为很多人在做 lab1 时卡在工具链上。第一你得有一台能跑 Linux 的机器或者 Windows 上的 WSL、macOS 都行。克隆 MIT 官方的 xv6 源码后在根目录执行make qemu如果能看到一个$提示符说明环境通了。qemu是一个模拟器它负责把 xv6 跑起来。第二退出 qemu 的方式固定是CtrlA然后按X不是CtrlC这点我第一次折腾了好久。第三修改 Makefile 后不需要手动清理。在UPROGS变量里加上你的程序名它会在编译时自动把你写的 C 文件编译成 xv6 内建命令。例如UPROGS\ $U/_cat\ $U/_sleep\ $U/_pingpong\ $U/_primes\ $U/_find\ $U/_xargs\注意前面有个下划线这是 xv6 的约定表示编译后的可执行文件。调试手段方面xv6 支持 gdb但配置门槛偏高。从我实际经验来看lab1 阶段最有效的调试方式就是用户态 printf 打印大法。xV6 的用户库虽然精简但printf、fprintf这些基础函数还是有的。你在程序的每个关键节点打印一行就能看到数据流到哪一步断了。很多卡死问题并不是程序逻辑复杂而是某个文件描述符没关、某个read永远在阻塞。打印一下进程的每一步动作问题基本能定位出来。2. sleep 与 pingpong热身项目的两处关键细节2.1 sleep最简单的调用最容易被忽视的参数检查第一个任务sleep要求实现一个程序接受一个以 tick 为单位的参数然后睡眠对应时间。理论上这是最简单的题因为 xv6 内核已经实现了sleep系统调用你只需要在用户态调用它。#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); }这段代码看起来平淡无奇但里面藏着 lab1 的第一个坑参数检查。测试用例里专门有一项是sleep, no arguments也就是说在没有参数的情况下程序必须报错并返回非零退出码而不是直接崩溃或忽略参数。这就是为什么开头要判断argc ! 2并且用fprintf把错误信息输出到文件描述符 2标准错误。xv6 没有提供完整的标准 C 库atoi是它自带的一个简易函数可以从字符串解析出整数但对于非法输入不会做太多检查。在 lab1 阶段你不用管atoi的边界情况但你的程序必须能编译通过。还有一点值得注意xv6 的sleep系统调用参数是 tick 数不是秒数。tick 是操作系统时钟中断的计数单位默认情况下一个 tick 大约是 10ms 量级。所以你要测试的时候直接sleep 100而不是sleep 1否则睡眠时间太短肉眼几乎察觉不到。2.2 pingpong管道时序与文件描述符的关闭第二个任务pingpong是你第一次接触 xv6 的进程通信。题目要求父进程和子进程通过管道各发一次数据父进程向子进程发送一个字节的“ping”子进程读完后向父进程发回一个字节的“pong”。整个过程的输出必须是received ping received pong其中received ping由子进程打印received pong由父进程打印。这意味着两件事一是要创建两个管道二是父子进程之间不能把它搞混。我的第一版代码是#include kernel/types.h #include user/user.h int main(int argc, char *argv[]) { int p2c[2], c2p[2]; pipe(p2c); pipe(c2p); int pid fork(); if (pid 0) { // child char buf[1]; close(p2c[1]); // 关闭不需要的写端 close(c2p[0]); // 关闭不需要的读端 read(p2c[0], buf, 1); printf(%d: received ping\n, getpid()); write(c2p[1], x, 1); close(p2c[0]); close(c2p[1]); exit(0); } else { // parent close(p2c[0]); // 关闭不需要的读端 close(c2p[1]); // 关闭不需要的写端 write(p2c[1], x, 1); wait(0); // 等待子进程先执行完 char buf[1]; read(c2p[0], buf, 1); printf(%d: received pong\n, getpid()); close(p2c[1]); close(c2p[0]); exit(0); } }这个程序里最重要的一行其实是四个close。很多第一次做管道的人写完fork之后就直接write/read也不管哪些 fd 该关结果程序要么阻塞要么读到意想不到的数据。原因在于fork会把父进程当前所有的文件描述符原样复制一份给子进程。也就是说两个管道一共四个 fd经过 fork 之后父子进程手里各有四个 fd总共八个引用。管道读端在缓冲区为空且写端的所有引用都关闭后才会返回 EOF。如果父进程不关闭自己读端的引用即使子进程把写端全部关了父进程的read也永远不会返回 0只会一直阻塞。反过来如果子进程没有关闭自己的写端引用父进程等子进程读数据时也可能摸不着头脑。所以我在代码中对称地关掉了每一侧不需要的 fd。这样做的本质是让每个进程只持有自己需要用到的管道端引用确保 EOF 能够正确传递。这不仅仅是编程习惯问题而是后续primes题能不能做对的关键——那题对 fd 关不关极其敏感差一个close就会整个程序卡死。还有一个细节是父进程在写完后先调用了wait(0)等子进程退出后再去读子进程发来的数据。这样能保证输出顺序不会乱。如果你不调用wait父进程的printf(received pong)可能会先于子进程的printf(received ping)执行输出顺序就会错。测试用例对字符串的前后顺序是有要求的。3. primes用管道实现素数筛理解进程同步的最好案例3.1 素数筛的原理每个进程只负责一道过滤第三个任务primes是 lab1 里最经典、也最劝退的一道题。题目要求你写一个并发的素数筛程序把 2 到 35 之间的所有素数打印出来。但限制条件是必须用进程和管道模拟经典的埃拉托色尼筛法每个筛子一个进程。如果你没接触过这个概念可以先看下这个思路。传统的素数筛是在内存里维护一个布尔数组把合数全部标记掉。但这里要求的是进程版的筛法本质上是把一个序列不断“过滤”下去第一个进程从管道中读入 2 到 35 的所有整数。每次从管道中取出第一个数这个数一定是素数因为之前的进程已经把所有更小的素数的倍数都筛掉了把它打印出来。然后把剩余的数中不能被这个素数整除的数全部写入一个新的管道。创建一个子进程让子进程从新管道中重复第 2~4 步。这个过程展开来看就是一个递归的管道链每个进程只负责“筛掉某个素数的倍数”然后把剩下的数据交给下一级进程。这和你平时用 Linux 命令seq 2 35 | ... | ...做过滤是同一个思想只不过这里每一级过滤都对应一个真实的操作系统进程。我个人的理解是这道题不只是在考你会不会写递归更是在考你是否理解read 在管道上的阻塞行为。管道的读操作在没有数据时会阻塞直到写端写入数据而当所有写端的引用都关闭、且缓冲区里的数据被读空后read才会返回 0。这个 “EOF 所有写端关闭” 的语义是整个程序的终止条件。如果没有理解这一点你很容易在某个环节上让子进程永远等不到数据。3.2 代码实现与文件描述符管理的细节我最终的代码结构是写一个递归函数#include kernel/types.h #include user/user.h void primes(int p[2]) { int prime; int n; if (read(p[0], prime, 4) 0) { close(p[0]); exit(0); } printf(prime %d\n, prime); int p2[2]; pipe(p2); int pid fork(); if (pid 0) { // parent: 当前筛子进程负责过滤数据 close(p2[0]); while (read(p[0], n, 4) 0) { if (n % prime ! 0) { write(p2[1], n, 4); } } close(p[0]); close(p2[1]); wait(0); exit(0); } else { // child: 下一个筛子进程 close(p2[1]); close(p[0]); primes(p2); } } int main(int argc, char *argv[]) { int p[2]; pipe(p); int pid fork(); if (pid 0) { close(p[0]); for (int i 2; i 35; i) { write(p[1], i, 4); } close(p[1]); wait(0); exit(0); } else { close(p[1]); primes(p); } }这段代码的核心在于primes函数里每次创建新管道p2后父进程当前筛子负责把过滤后的数据写给子进程然后由子进程递归调用primes(p2)。每个进程打印自己拿到的第一个数就相当于认定它是素数。为什么第一个数一定是素数因为所有更小的素数的倍数在到达当前进程之前已经被前面的筛子过滤掉了。比如第三个筛子拿到的第一个数如果是一个合数它必然拥有一个小于它自身的素数因子那么这个因子在前几层就应该把它筛掉它不可能存活到这里。这是一个归纳式的推理。我需要特别强调这个程序里有大量的close每一个都不能少。很多人刚写的时候容易漏掉close(p[0])或者close(p2[1])导致整个进程链“卡住”。原因就是我在 pingpong 那节提到的 EOF 语义如果父进程没有关闭它不用的读端那么当管道里数据读完后read不会返回 0而是继续阻塞如果子进程没有关闭它不需要的写端父进程的read也永远等不到 EOF程序就无限挂起。另外wait(0)是必要的。如果不等待子进程退出父进程直接exit虽然程序能结束但子进程可能还没打印完就被内核回收输出会不完整同时不wait会产生僵尸进程在 xv6 里虽然不致命但会让make grade跑得很慢甚至超时。我在做这道题时还被一个细节坑过写入管道的数据单位是int4 字节但管道本身不维护消息边界也就是说它只是一条字节流。你在写端write(i, 4)读端read(n, 4)一次读 4 个字节这在当前场景下没问题但如果哪次read返回了 1 或者 2说明你读错了边界应该把它当作错误来处理。xv6 的教学代码里没有这种情况但养成检查read返回值的好习惯很重要。4. find 与 xargs目录遍历和参数拼接的实战4.1 find递归遍历目录第四个任务find要求实现一个简化版find在指定目录下按文件名模式搜索文件。比如find . b表示在当前目录下找所有名字是b的文件。测试用例里会有find . a之类的搜索以及一个隐藏的测试目录。如果你在 Linux 上写过 C可能会下意识想用opendir、readdir这些库函数。但 xv6 的用户态库没有这些。你只能用最底层的系统调用open、read、stat、fstat。xv6 的目录文件本质上也是一个文件里面的数据是一串struct dirent。这个结构体定义在kernel/fs.h里struct dirent { ushort inum; char name[DIRSIZ]; };其中inum是 inode 编号name是文件名字符串。注意DIRSIZ是 14也就是说 xv6 文件名最长 14 个字符。遍历目录的基本流程是open打开目录然后用read循环读取struct dirent对每一项拼出完整路径再通过stat判断它是普通文件还是目录。如果是目录就递归进入如果是文件就比对文件名是否等于目标。核心代码大概是char* fmtname(char *path) { static char buf[512]; char *p; for (p path; *p; p) ; // 查找最后一个 / for (; p path *p ! /; p--) ; p; strcpy(buf, p); return buf; } void find(char *base, char *target) { char path[512]; struct stat st; int fd; struct dirent de; if ((fd open(base, 0)) 0) { fprintf(2, find: cannot open %s\n, base); return; } if (fstat(fd, st) 0) { fprintf(2, find: cannot stat %s\n, base); close(fd); return; } if (st.type ! T_DIR) { 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; if (strlen(base) 1 DIRSIZ sizeof(path)) { fprintf(2, find: path too long\n); return; } strcpy(path, base); strcat(path, /); strcat(path, de.name); if (stat(path, st) 0) { fprintf(2, find: cannot stat %s\n, path); continue; } if (st.type T_DIR) { find(path, target); } else if (st.type T_FILE) { if (strcmp(de.name, target) 0) { printf(%s\n, path); } } } close(fd); }我在这里想重点说两个坑。第一个就是continue跳过.和..。如果不跳过find会沿着.无限递归自己最终把栈撑爆或者死循环。你可能觉得这是常识但在 xv6 这种没有动态栈增长的环境下递归过深的表现不是报错而是直接触发一个usertrap崩溃。第二个坑是路径拼接的缓冲区空间。xv6 的目录项name最长 14 字节加一个斜杠再加上原来的路径总长度可能轻松超过 64 字节的固定数组。如果你用一个char path[64]的局部数组很容易越界。我最后用了 512 字节的缓冲区并且在拼接前检查长度宁可报错也不能越界。还有一点测试用例里有一个find a会跑到一个隐藏目录去找具体我不剧透但你一定要确保find能正确处理多层目录而不是只搜一层。4.2 xargs把标准输入变成命令行参数第五个任务xargs要求实现一个简化版从标准输入读取多行每一行按空格分割成若干参数然后执行一个给定的命令把这些参数传给该命令。典型用法是find . b | xargs echo意思是把find输出的每一行即每个匹配到的文件路径作为参数传给echo执行。xv6 的 xargs 不支持完整的命令行解析也没有-I、-n这些高级选项只要做到“每读一行执行一次命令”即可。实现思路比较直接从标准输入一次读一个字符或者读一整行。遇到换行符时把这一行拆成多个单词。构造参数数组argv第一个元素是给定的命令名后续元素是这一行的所有单词最后以NULL结尾。fork一个子进程在子进程里exec执行命令。父进程wait等待子进程退出然后继续读下一行。核心代码#include kernel/types.h #include user/user.h int main(int argc, char *argv[]) { char buf[512]; char *args[MAXARG]; int n; if (argc 2) { fprintf(2, usage: xargs command [args...]\n); exit(1); } for (int i 1; i argc; i) { args[i - 1] argv[i]; } args[argc - 1] 0; int offset argc - 1; int pos 0; while (read(0, buf[pos], 1) 1) { if (buf[pos] \n) { buf[pos] 0; // split buf into words char *p buf; while (*p) { while (*p ) p; if (*p 0) break; args[offset] p; while (*p *p ! ) p; if (*p ) *p 0; } args[offset] 0; int pid fork(); if (pid 0) { exec(args[0], args); fprintf(2, xargs: exec %s failed\n, args[0]); exit(1); } else { wait(0); } // 重置准备读下一行 offset argc - 1; pos 0; } else { pos; if (pos sizeof(buf) - 1) { fprintf(2, xargs: line too long\n); exit(1); } } } exit(0); }这个程序有几个容易错的地方我逐个说。第一个是args数组的复用问题。args[0]到args[argc - 2]是你在命令行指定的初始参数这些指针你可以留着但新增的参数需要指向当前行buf内存中的某个位置。读下一行时buf的内容会被覆盖因此是否需要重新拼接其实不用因为新一行的参数本来就是从buf里重新切出来的你只要在每一行开始时把offset重置为初始参数的个数再重新切分新的行就能保证args数组始终有效。第二个是换行符和空格的细节处理。xargs遇到空行应该直接跳过不要执行一次命令。换句话说如果一行里只有换行符拆分后没有产生任何参数就不应该fork。我代码里是通过while (*p)循环跳过空格所以args[offset]最终仍是指向NULL但如果不小心把一个空字符串当作参数传进去exec就会失败。测试用例里有多次执行的场景专门检查你能不能正确处理多个输入行和尾部空格。第三个是exec失败的处理。exec如果成功就不会返回如果失败会返回-1此时应该在子进程里报错并exit否则子进程会继续执行原来的代码导致同一段父进程逻辑被跑了两遍输出错乱。这也是一个很经典的坑。5. 验收与踩坑make grade 之外的细节5.1 我在这些用例上栽过的跟头做完五个程序后用make grade来验收。这个脚本会自动跑完官方测试用例打印每个用例的得分。我第一次跑的时候几乎每个用例都出了问题这里挑几个最典型的复盘一下。pingpong 的测试如果一直卡住不动十有八九是某些 fd 没在 fork 之后关闭。诊断方法是在程序的各个阶段加printf看打印到最后哪一步停了。最常见的情形是父进程写完 “ping” 后子进程读了数据但子进程写完 “pong” 后父进程的read却永远等不到数据。原因是子进程没有关闭它自己不用的管道的写端导致该管道写端的引用计数一直不为 0父进程的read收不到 EOF——但注意这里其实不是 EOF 问题因为父进程本来就会收到“pong”数据所以更可能是管道搞混、写了错误的管道导致父进程一直没等到。primes 的测试如果报了超时timeout先检查递归函数里是否存在多余的 fd 泄漏。常见情况是每层递归都复制了上一层的管道 fd导致某级管道写端引用永远不归零read永远等不到 EOF进程链就不会终止。另外父进程一定要调用wait否则可能出现了一个“还没打印完就退出”的子进程链。我在测试时发现wait放错位置会导致输出顺序不对prime列表乱序所以每一层的父进程都必须等它的直接子进程结束。find 的测试如果一搜索就崩溃多半是递归到了.和..。有一种相对隐蔽的情形是路径缓冲区不够导致strcat把相邻内存写穿等到递归回来时栈已经坏了。我建议把所有路径缓冲区统一为char path[512]并且在拼接前用strlen检查而不是裸用strcat。xv6 本身没有保护“用户态缓冲溢出”的机制写穿了基本就是随机性崩溃排查起来很头疼。xargs 的测试如果输出多了一行或者少了一行多半是换行处理不对。测试用例里有一项会输入多行其中某些行是空行xargs应该直接跳过而不是执行一次带空参数的命令。另外如果输入行末尾有空格拆分参数时会产生一个空字符串这会污染exec的参数列表。我在代码里用连续跳过空格的方式处理了这种情况确保连续的多个空格不会产生空参数。5.2 检查输出格式的实用方法make grade对输出字符串非常敏感多一个空格、多一个换行都可能直接判 FAIL。一个很实用的技巧是跑完make qemu后在 xv6 的 shell 里手动执行程序把输出和官方要求逐字符比对。不要靠肉眼直接截图或者复制对比。另外xv6 的printf输出不会自动刷新到串口但在 qemu 里通常表现正常不需要担心这个。我个人的排错顺序是先看输出对不对再看程序会不会卡住最后看有没有超时。对应关系是现象根因可能性排查方向输出错误/乱序wait 位置不对、管道用混打印进程 pid确认执行顺序程序卡死fd 未关闭、read 阻塞逐段打印执行进度输出为空fork 后父子逻辑混乱检查 if/else 分支超时僵尸进程、递归未终止检查 wait 和递归出口最后说一个我自己的体会。lab1 的代码量不大五个程序加起来也就三百行左右但这些程序的难度不在于“写出来”而在于“真正理解每一步发生了什么”。我在写primes时第一次对“管道是字节流”“文件描述符是进程的资源”“fork 会复制所有 fd”这些抽象概念有了实质感受。做 lab1 的过程就像把操作系统的进程模型从书本上搬到了眼前每一个pipe、fork、read、close调用都能在 xv6 源码里找到对应的实现逻辑。做完之后再看 Linux 的那些管道命令会有一种“它们在后台也不过是这些系统调用的组合”的踏实感。如果你卡在某个用例上我的建议很简单别急着上网抄答案先加几个 printf把每个进程“读到什么、写了什么、关了哪些 fd、等到谁退出”打印出来问题会自己浮出水面。尤其是primes和xargs一旦你把数据流理清楚后面再去读 xv6 的pipe.c和exec.c源码都会有豁然开朗的感觉。
返回列表