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.

代码:

代码C · 193 行
#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);
}

不过留下的疑问:

  1. system call,在user.h 文件里面,在 vscode 里面点击「查看定义」,溯回不到汇编源码,那么,汇编源码是如何和 C 语言头文件结合的?
  2. pause 的返回值,光看汇编也看不出来是什么

答案:脚本生成 + 链接器符号解析。日后再深究吧。

感觉是我没读 csapp 的缘故?有点一头雾水,说实话。没有一个高屋建瓴的架构视角的感觉有点不爽,毕竟在 LLM 时代,这个比较重要。

记得把文件加入 Makefile,不然重新编译内核也没用

sixfive

这里重点是注意参数可以接多个。

memdump

这里注意 s 的实现:

case 's':
   printf("%s\n", *(char**)d);
   d += 8;
   break;

先取出指针的指针,再解引用所指向的内容。

find

一个意外的 C 语言语法是:const 的指针可以执行运算,只是不能修改指向的内容。

换句话理解:const 修饰的是指向的内容,不是指针本身。

find with -exec

先上代码:

代码C · 99 行
#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);
}

主要遇到的问题:

  1. cmd 的逻辑这一块,太久没手写了,如何写得优雅卡了一会儿,不过其实感觉这个让语言模型代劳完全没问题,毕竟和 OS 没什么关系...试着把握好度吧。

  2. 忘记处理参数了,开始直接写的是 exec(cmd[0], cmd),没把 path 加进去。

  3. 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 完成: