如果你问一个正在修操作系统课的人,“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 的方式固定是Ctrl+A然后按X,不是Ctrl+C,这点我第一次折腾了好久。
第三,修改 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跑得很慢,甚至超时。
我在做这道题时,还被一个细节坑过:写入管道的数据单位是int(4 字节),但管道本身不维护消息边界,也就是说它只是一条字节流。你在写端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源码,都会有豁然开朗的感觉。