xv6 Lab1 Utilities - MIT 6.1810 Fall 2025 Operating System
本文最后更新于 2026年7月26日 凌晨
read xv6 book
阅读 xv6 book:
shell 是一个普通的程序,它读取用户的命令并执行它们。shell是一个用户程序,而不是内核的一部分,这一事实说明了系统调用接口的强大功能:shell没有什么特别的。这也意味着外壳易于更换;因此,现代Unix系统有多种shell可供选择,每种都有自己的用户界面和脚本功能。
xv6 提供的 system call:

cd 必须是 shell 的内置命令,因为:
假设 cd 是普通程序,Shell 会这样运行它:
Shell
│ fork
▼
子进程执行 cd子进程执行:
chdir("/home");改变的只是子进程自己的工作目录。随后子进程退出,原来的 Shell 目录完全没变。
因此 cd 必须由 Shell 自己执行:
Shell 进程直接调用 chdir()这样才能真正改变后续命令使用的工作目录。
这是 1.4 中值得重点理解的例子,因为它再次体现:fork 后,父子进程的进程状态彼此独立。
关于 ping pong:
Write a program that uses UNIX system calls to “ping-pong” a byte between two processes over a pair of pipes, one for each direction. Measure the program’s performance, in exchanges per second.
代码:
代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>
#define N_EXCHANGES 100000
int main(int argc, char** argv) {
int pipe_child_to_parent[2]; // pipe_child_to_parent[0]=读端, [1]=写端
int pipe_parent_to_child[2]; // pipe_parent_to_child[0]=读端, [1]=写端
char b = 'a';
if (pipe(pipe_child_to_parent) < 0 || pipe(pipe_parent_to_child) < 0) {
perror("Pipe error.");
// *** 问题:pipe 失败了但没有 exit(1),程序会带着无效 fd 继续执行。
// 后面所有 read/write 都会操作 fd=-1,虽然不会崩溃但全部静默失败。
exit(1);
}
int pid = fork();
time_t start, end;
if (pid < 0) {
perror("fork error");
exit(1);
// *** 问题:fork 失败后没有 exit(1),只有 perror。
// 程序会继续往下执行,进入 pid>0 或 pid==0 的未知分支。
} else if (pid == 0) {
// ============================================================
// 关键概念:fork 之后谁持有哪些 fd?
// ============================================================
//
// pipe() 创建的管道是内核对象。fd 只是"指向这个内核对象的编号"。
// fork() 会复制整个 fd 表,所以父子各自拥有独立的 fd 编号,但都
// 指向同一个内核管道。
//
// 创建了两条管道后:
//
// pipe_parent_to_child[0] = 3 (读端) 父→子,父写子读
// pipe_parent_to_child[1] = 4 (写端)
// pipe_child_to_parent[0] = 5 (读端) 子→父,子写父读
// pipe_child_to_parent[1] = 6 (写端)
//
// fork 之后,父子进程各有一份 fd 3,4,5,6,都指向同两个内核管道。
//
// 现在子进程的任务是:
// - 从 pipe_parent_to_child 的读端读取父发来的数据 → 用 fd 3
// - 向 pipe_child_to_parent 的写端把数据回给父 → 用 fd 6
// 所以子进程只用到 fd 3 和 fd 6。fd 4 和 fd 5 完全不用。
//
// ============================================================
// 什么时候需要 close?——两条铁律
// ============================================================
//
// 铁律 1:一个进程如果不打算在某条管道上"写",就必须把自己持有
// 的该管道**所有写端 fd** 全部关掉。
//
// 铁律 2:一个进程如果不打算在某条管道上"读",就必须把自己持有
// 的该管道**所有读端 fd** 全部关掉。
//
// ============================================================
// 为什么不关就会出事?——具体推演
// ============================================================
//
// 以 pipe_parent_to_child 这条管道为例:
//
// fork 之后:父进程有 fd{3,4},子进程也有 fd{3,4}
// (3=读端,4=写端)
//
// 父进程会写这条管道(用 fd 4),子进程会读这条管道(用 fd 3)。
// 但子进程的 fd 4(写端)他从来不写,如果没关 ——
//
// 那会发生什么?
//
// 假设程序没有固定循环次数,而是"读到 EOF 才停":
//
// 父进程:写了 10 次 → close(fd4) → 结束
// 子进程:while (read(fd3, ...) > 0) { ... }
//
// 问题是:子进程 read 的时候,内核看了一眼 pipe_parent_to_child
// 这个管道——"嗯...父进程关了写端,但子进程的 fd4(写端)还开着!
// 可能还有数据要来,我再等等..."——于是 read() 永远阻塞,子进程
// 永远不会从 while 循环里出来。
//
// 这就是"忘了 close 导致死锁"的经典场景。
//
// 你的代码靠固定循环次数 + 两个进程最后都 exit() 避免了这个问题,
// 但这是危险的——将来只要有一个读操作依赖 EOF 来判断结束,就会死锁。
//
// ============================================================
// 在哪里 close?——越早越好
// ============================================================
//
// 一旦你确定"这个进程不需要这个 fd 了",就立刻 close。
// 对于 ping-pong 程序,在进入循环之前就应该关掉:
//
// ==================== 子进程:进入循环前应 close ====================
// *** 缺失:close(pipe_parent_to_child[1]);
// 原因:子进程永远不会向 pipe_parent_to_child 这条管道写数据。
// 写端 fd[1] 只有父进程在用。子进程留着写端=告诉内核"我也可能
// 写哦",会导致铁律 1 被违反。
//
// *** 缺失:close(pipe_child_to_parent[0]);
// 原因:子进程永远不会从 pipe_child_to_parent 这条管道读数据。
// 读端 fd[0] 只有父进程在用。不关也会违反铁律 2。
// close 先关掉不用的 fd(铁律!)
close(pipe_parent_to_child[1]); // 子进程不写 parent→child,关写端
close(pipe_child_to_parent[0]); // 子进程不读 child→parent,关读端
for (int i = 0; i < N_EXCHANGES; ++i) {
// read 返回实际读到的字节数;期望读到 1 字节
int rt = read(pipe_parent_to_child[0], &b, 1);
if (rt != 1) {
// 读到 0 = 对方关了写端(EOF),-1 = 出错
perror("child read");
exit(1);
}
rt = write(pipe_child_to_parent[1], &b, 1);
if (rt != 1) {
perror("child write");
exit(1);
}
}
exit(0);
} else {
// ==================== 父进程 ====================
//
// 父进程的任务:
// - 向 pipe_parent_to_child 写入数据 → 用 fd[1](写端)
// - 从 pipe_child_to_parent 读取子进程回传 → 用 fd[0](读端)
//
// 不用的 fd(进入循环前必须关):
// pipe_parent_to_child[0](读端)→ 父进程永远不会从这里读
// pipe_child_to_parent[1](写端)→ 父进程永远不会往这里写
//
// 不关的话:子进程将来如果想通过 read 返回 0(EOF)来判断
// "父进程写完了",会因为父进程留着写端而永远等不到 EOF。
//
// 另外,你当前在第 143 行 close 的是 pipe_parent_to_child[1] 和
// pipe_child_to_parent[0]——这两个是父进程在循环中**正在使用**的
// 写端和读端。它们应该最后关(循环结束后)。
// 而本该先关的 pipe_parent_to_child[0] 和 pipe_child_to_parent[1]
// 却完全没有被 close。顺序完全搞反了。
//
// 正确的 close 布局:
// close(pipe_parent_to_child[0]); // ← 循环前:关掉不用的读端
// close(pipe_child_to_parent[1]); // ← 循环前:关掉不用的写端
// for (...) { write(...); read(...); } // ← 用自己需要的 fd
// close(pipe_parent_to_child[1]); // ← 循环后:用完再关
// close(pipe_child_to_parent[0]); // ← 循环后:用完再关
// *** 缺失:close(pipe_parent_to_child[0]);
// *** 缺失:close(pipe_child_to_parent[1]);
start = time(NULL);
// 父进程先关掉自己不用的 fd
close(pipe_child_to_parent[1]); // 父进程不写 child→parent,关写端
close(pipe_parent_to_child[0]); // 父进程不读 parent→child,关读端
for (int i = 0; i < N_EXCHANGES; ++i) {
// *** 关键:父进程先 write(发起 ping),再 read(等 pong)!
// 子进程那边是先 read 后 write,两边必须配对,
// 否则同时 read 就死锁。
int rt = write(pipe_parent_to_child[1], &b, 1);
if (rt != 1) {
perror("parent write");
exit(1);
}
rt = read(pipe_child_to_parent[0], &b, 1);
if (rt != 1) {
perror("parent read");
exit(1);
}
}
end = time(NULL);
double elapsed = difftime(end, start);
printf("每秒交换次数:%.2f\n", N_EXCHANGES / elapsed);
exit(0);
}
}
// 总结你的主要问题:
//
// 1. 【致命】父进程把读端和写端搞反了。
// 记住口诀:pipe(fd) 之后,fd[0] 永远用来读,fd[1] 永远用来写。
// 你父进程里 read(fd[1]) 和 write(fd[0]) 都反了。
//
// 2. 【重要】缺少 close 不用的 fd。
// 在进入循环之前,每个进程应该关掉自己不需要的那两个 fd。
//
// 3. 【次要】pipe() 和 fork() 的错误处理不完整,失败后没有 exit。
//
// 4. 【次要】read/write 没有检查返回值,失败时静默将继续。Boot xv6
根据 lab tools page 配置好在 WSL 下运行 xv6 的环境。
克隆好相应的代码。
输入 make qemu 以构建并运行 xv6。
查看进程:不是 ps,是 Ctrl + P
退出 qemu,输入: Ctrl-a x
sleep
#include "kernel/types.h"
#include "kernel/stat.h"
#include "kernel/fcntl.h"
#include "user/user.h"
int main(int argc, char* argv[]) {
if (argc != 2) {
fprintf(2, "Usage: sleep time...\n");
exit(1);
}
int ret = pause(atoi(argv[1]));
exit(ret);
}不过留下的疑问:
- system call,在
user.h文件里面,在 vscode 里面点击「查看定义」,溯回不到汇编源码,那么,汇编源码是如何和 C 语言头文件结合的? pause的返回值,光看汇编也看不出来是什么
答案:脚本生成 + 链接器符号解析。日后再深究吧。
感觉是我没读 csapp 的缘故?有点一头雾水,说实话。没有一个高屋建瓴的架构视角的感觉有点不爽,毕竟在 LLM 时代,这个比较重要。
记得把文件加入 Makefile,不然重新编译内核也没用。
sixfive
这里重点是注意参数可以接多个。
memdump
这里注意 s 的实现:
case 's':
printf("%s\n", *(char**)d);
d += 8;
break;先取出指针的指针,再解引用所指向的内容。
find
一个意外的 C 语言语法是:const 的指针可以执行运算,只是不能修改指向的内容。
换句话理解:const 修饰的是指向的内容,不是指针本身。
find with -exec
先上代码:
代码
#include "kernel/types.h"
#include "kernel/fcntl.h" // O_RDONLY
#include "kernel/fs.h" // struct dirent, DIRSIZ
#include "kernel/stat.h" // struct stat, T_DIR, T_FILE
#include "kernel/param.h"
#include "user/user.h" // open, read, close, fstat, printf...
#define NULL 0
void
check_path(const char *path, const char *name, char **cmd);
int
main(int argc, char *argv[])
{
if(argc < 3) {
fprintf(2, "Usage: find [path] [name] [-exec command ...]\n");
exit(1);
}
const char *path = argv[1];
const char *name = argv[2];
char **cmd = NULL;
if(argc > 3) {
if(strcmp(argv[3], "-exec") != 0) {
fprintf(2, "The third argument should be -exec.\n");
exit(1);
}
if(argc == 4) {
fprintf(2, "No command was provided after -exec.\n");
exit(1);
}
cmd = argv + 4;
}
check_path(path, name, cmd);
return 0;
}
void
check_path(const char *path, const char *name, char **cmd)
{
int fd = open(path, O_RDONLY);
struct stat st;
struct dirent de;
if(fd < 0) {
fprintf(2, "Can not open the path.\n");
return;
}
fstat(fd, &st);
if(st.type == T_DIR) {
while(read(fd, &de, sizeof(de)) == sizeof(de)) {
if(!strcmp(de.name, ".") || !strcmp(de.name, "..") || !de.inum) {
continue;
}
char buf[512];
strcpy(buf, path);
char *p = buf + strlen(buf); // p 指向 buf 末尾的 '\0'
*p++ = '/';
memmove(p, de.name, DIRSIZ);
p[DIRSIZ] = 0;
check_path(buf, name, cmd);
}
} else {
// 当前 path 即为完整路径,判断最后文件名是否符合即可
const char *p;
for(p = path + strlen(path); *p != '/' && p > path; p--)
;
p++;
if(!strcmp(p, name)) {
// printf("%s\n", path);
if(!cmd) {
printf("%s\n", path);
} else {
int rc = fork();
if(rc < 0) {
fprintf(2, "fork failed.\n ");
exit(1);
} else if(rc == 0) {
char *new_cmd[MAXARG];
int i;
for(i = 0; cmd[i]; i++) {
new_cmd[i] = cmd[i];
}
new_cmd[i] = (char*)path;
new_cmd[i+1] = 0;
exec(new_cmd[0], new_cmd);
fprintf(2, "exec failed.\n");
exit(1);
} else {
wait(NULL);
}
}
}
}
close(fd);
}主要遇到的问题:
-
cmd的逻辑这一块,太久没手写了,如何写得优雅卡了一会儿,不过其实感觉这个让语言模型代劳完全没问题,毕竟和 OS 没什么关系...试着把握好度吧。 -
忘记处理参数了,开始直接写的是
exec(cmd[0], cmd),没把path加进去。 -
de.inum == 0的判断很重要,这里其实照抄ls.c就行,不过解释一下原因:把目录理解成一本有"已删除"标记的笔记本。
文件系统删除文件时,并不会把目录里那条记录擦掉,也不会把后面的记录往前挪——太费劲了。它只做一件事:把
inum改成 0。删除前: 删除后: ┌────────┬──────────────┐ ┌────────┬──────────────┐ │ inum=5 │ name="cat" │ │ inum=0 │ name="cat" │ ← 名字还在! ├────────┼──────────────┤ ├────────┼──────────────┤ │ inum=7 │ name="ls" │ │ inum=7 │ name="ls" │ ├────────┼──────────────┤ ├────────┼──────────────┤ │ inum=0 │ name(垃圾) │ │ inum=0 │ name(垃圾) │ └────────┴──────────────┘ └────────┴──────────────┘那你遍历目录时会发生什么?
read(fd, &de, sizeof(de)) → de.inum=0, de.name="cat"如果不检查
de.inum == 0,你会拿着"cat"这个名字去open("cat")——但 cat 早就不存在了!更要命的是,空槽位里的
name可能残留着任意旧数据。它可能恰好是一个有效的名字,导致你去 open 一个不该 open 的东西;或者更糟——在你这个递归场景下——引发预料之外的循环。ls.c第 61 行做的就是这件事:if(de.inum == 0) // 空座位,跳过 continue;
Lab1 完成:
