Introduction

本文旨在记录作者完成课程任务 HITSZ os-lab1 的过程,可供参考。

本实验需要完成的内容包括

  1. 编写用户程序 sleep.c ,调用 xv6 提供的 sleep() 系统调用。
  2. 编写用户程序 pingpong.c,调用 xv6 提供的 pipe(), read(), write(),实现父进程和子进程的通信
  3. 编写用户程序 find.c,调用 xv6 提供的 open(), fstat() 等文件系统相关系统调用,实现根据文件名的递归查找功能
  4. 使用 GDB 跟踪 xv6 的启动流程,并打印 initcode 和 init 程序名称

实验仓库链接如下

xv6-HITSZ

指导书链接如下(需校内网)

xv6-HITSZ-guidance

仓库内有多个 lab 分支,本文所提的 lab1 对应的分支切换方法如下

git clone git@gitee.com:ftutorials/xv6-oslabs-hitsz.git
git checkout util

常用的指令如下:

  • make clean 清理编译文件
  • make qemu 编译并进入 qemu 的 xv6 终端
  • make diff 执行 git diff util-base-26 HEAD > commit.patch 在对应结点基础上生成 commit.patch 文件,用于提交
  • make grade 对编写的程序进行评分测试
  • make qemu-gdb CPUS=1 启动调试,在另一个终端窗口上可输入 make gdb 来进行对应调试
  • make gdb 对代码进行调试
  • git add . 将当前目录下所有文件暂存
  • git commit -m "lab 1 finished" 提交更改到本地分支
  • git push 将本地分支同步到远程仓库
  • git pull 将远程仓库同步到本地

一些问题

下面是本人在 macOS 27 上完成 lab 过程中遇到的环境相关的问题,可供参考

GDB 相关问题

如果你的 GDB 版本较高,可能在编译时会遇到一些报错/警告,修改 Makefile 中的 CFLAGS 即可

CFLAGS = -Wall -Winfinite-recursion -O -fno-omit-frame-pointer -ggdb -DTEST

如果你在 macOS 上使用 riscv64-elf-gdb 发生了报错,考虑将原本的

make gdb

替换成

make GDB=riscv64-elf-gdb gdb 

或者修改 Makefile

GDB = $(TOOLPREFIX)gdb

为

GDB = riscv64-elf-gdb

python 环境相关问题

如果你使用 make grade 发生 python 环境问题相关的报错,考虑修改 grade-lab-util 中的路径

#!/usr/bin/env python

任务1: sleep

实验要求在 user/sleep.c 中实现一个简化版 sleep 程序。它读取命令行参数中的时间长度,让当前进程休眠一段时间后返回。

运行效果示例如下(以下命令在 XV6 命令行 中运行):

$ sleep
Sleep 10
$

完成步骤如下,首先在 Makefile 中将 sleep 程序添加到编译目录中

...

UPROGS=\
    $U/_cat\
    $U/_echo\
    $U/_forktest\
    $U/_grep\
    $U/_init\
    $U/_kill\
    $U/_ln\
    $U/_ls\
    $U/_mkdir\
    $U/_rm\
    $U/_sh\
    $U/_stressfs\
    $U/_usertests\
    $U/_grind\
    $U/_wc\
    $U/_zombie\

    $U/_sleep\      # 表示新增的 sleep 用户程序

...

然后新建文件 user/sleep.c,写入下面的内容

#include "kernel/types.h"
#include "user/user.h"

int
main(int argc, char *argv[])
{
  if(argc != 2){
    printf("Sleep needs one argument!\n");
    exit(-1);
  }

  int ticks = atoi(argv[1]);
  sleep(ticks);
  printf("Sleep %d\n", ticks);
  exit(0);
}

启动 qemu xv6

make qemu

运行用户程序,现象应该如下

init: starting sh
$ sleep 10
Sleep 10
$ sleep
Sleep needs one argument!
$

进行测试

sodium@nas-MacBook-Air-13 xv6-oslabs-hitsz % ./grade-lab-util sleep
make: `kernel/kernel' is up to date.
== Test sleep, no arguments == sleep, no arguments: OK (1.7s) 
== Test sleep, returns == sleep, returns: OK (0.8s) 
== Test sleep, makes syscall == sleep, makes syscall: OK (1.0s) 

任务2: pingpong

你需要在 user/pingpong.c 中实现两个进程之间基于管道的双向通信。

任务要求如下:

父进程向子进程发送一条消息。 子进程收到后打印 received ping 相关信息。 子进程再通过另一条管道把消息发回父进程。 父进程收到后打印 received pong 相关信息。 运行效果示例如下(以下命令在 XV6 命令行 中运行)

init: starting sh
$ pingpong
4: received ping from pid 3
3: received pong from pid 4
$ pingpong
6: received ping from pid 5
5: received pong from pid 6
$ 

编写思路:建立两个管道得到用于通信的文件描述符,通过 fork 得到子进程,使用 read(), write() 进行读写

本文编写的 pingpong.c 代码如下,可供参考

#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

int main(int argc, char *argv[]) {
  int p1[2];
  int p2[2];
  pipe(p1);
  pipe(p2);
  int ppid = getpid();  // 获取当前进程 pid
  int cpid = fork();    // parent 这边会返回 child 的 pid
  if (cpid == 0) {      // child 这边会返回 0
    close(p1[1]);
    close(p2[0]);       // 关闭用不到的文件描述符
    cpid = getpid();
    char read_buffer[256];
    read(p1[0], read_buffer, 256);  // [0] 是读端
    close(p1[0]);
    printf("%d: received %s from pid %d\n", cpid, read_buffer, ppid);
    char write_string[] = "pong";
    write(p2[1], write_string, 5);  // [1] 是写端
    close(p2[1]);
  } else {  // parent
    close(p1[0]);
    close(p2[1]);
    char write_string[] = "ping";
    write(p1[1], write_string, 5);
    close(p1[1]);
    char read_buffer[256];
    read(p2[0], read_buffer, 256);
    close(p2[0]);
    printf("%d: received %s from pid %d\n", ppid, read_buffer, cpid);
  }
  exit(0);
}

同样记得修改 Makefile

UPROGS=\
    $U/_cat\
    $U/_echo\
    $U/_forktest\
    $U/_grep\
    $U/_init\
    $U/_kill\
    $U/_ln\
    $U/_ls\
    $U/_mkdir\
    $U/_rm\
    $U/_sh\
    $U/_stressfs\
    $U/_usertests\
    $U/_grind\
    $U/_wc\
    $U/_zombie\

    $U/_sleep\      # 表示新增的 sleep 用户程序
    $U/_find\       # find
    $U/_pingpong\   # pingpong

进行测试

sodium@nas-MacBook-Air-13 xv6-oslabs-hitsz % ./grade-lab-util pingpong
make: `kernel/kernel' is up to date.
== Test pingpong lenient testing == pingpong lenient testing: OK (1.3s) 
== Test pingpong strict testing with changing pids == pingpong strict testing with changing pids: OK (0.8s) 

任务3: find

实验要求编写用户程序 find.c ,命令格式是

$ find <path> <name>

使用示例如下

查找文件

$ echo > b
$ find . b
./b

查找目录

$ mkdir c
$ find . c
./c

递归查找

$ mkdir a
$ echo > a/target
$ mkdir a/b
$ echo > a/b/target
$ echo > c/target
$ find . target
./c/target
./a/target
./a/b/target

指导书提供了下面的思路:

  • 新建 user/find.c。
  • 先参考 user/ls.c,理解目录是怎样被打开和读取的。
  • 按 find <path> <name> 的形式读取参数。
  • 使用 open() 和 fstat() 判断当前路径是普通文件还是目录。
  • 如果是目录,就依次读取目录项;如果名字匹配,就输出路径。
  • 如果读到的还是目录,就递归继续查找。
  • 递归时一定要跳过 . 和 ..。
  • 别忘了把程序加入 UPROGS

该程序涉及到的数据结构和宏定义如下

// Directory is a file containing a sequence of dirent structures.
#define DIRSIZ 14

struct dirent {
  ushort inum;
  char name[DIRSIZ];
};

// stat.h
#define T_DIR     1   // Directory
#define T_FILE    2   // File
#define T_DEVICE  3   // Device

struct stat {
  int dev;     // File system's disk device
  uint ino;    // Inode number
  short type;  // Type of file
  short nlink; // Number of links to file
  uint64 size; // Size of file in bytes
};

// fcntl.h
#define O_RDONLY  0x000
#define O_WRONLY  0x001
#define O_RDWR    0x002
#define O_CREATE  0x200
#define O_TRUNC   0x400

涉及到的新接口如下

int fstat(int fd, struct stat*);            // syscall
int stat(const char *n, struct stat *st);   // ulib.c

stat() 帮你完成了 open 和 close 的操作,这样你只需要提供文件/目录路径,就可以返回 struct stat 类型变量,从而得知相关信息。

那么给定一个路径,我们就得使用 stat() ,获取到的 struct stat 变量的 type 来判断它是文件( T_FILE )或者目录( T_DIR ),如果是文件,那么可以直接比较字符串;如果是目录,那么除了比较字符串,我们还需要递归调用函数进行查找。

对于字符串处理,xv6 提供了一些接口,以下为一部分

  • uint strlen(const char *s):返回字符串长度,判断依据是 \0
  • char *strcpy(char *s, const char *t):字符串拷贝,使用时需注意越界问题
  • int strcmp(const char *p, const char *q):
  • void *memmove(void*, const void*, int);

一个关键问题:怎么知道目录下有哪些文件?

目录本身也是文件,它的组成其实是许多的 struct dirent 数据,因此,我们只需要使用 read() 既可读取到文件名字,那么知道名字又可以判断是目录或者文件了。

// buf = "{path}/"
strcpy(buf, path);
p = buf + strlen(buf);
*p++ = '/';

while (read(pathfd, &de, sizeof(de)) == sizeof(de)) {   // 读取目录,得到 dirent
  if (de.inum == 0) continue;
  if (!strcmp(de.name, "..") || !strcmp(de.name, ".")) {// 根据 dirent 名字,忽略掉 . 和 ..
    continue;
  }
  if (!strcmp(de.name, name)) {         // 对比文件/文件夹名称
    printf("%s/%s\n", path, de.name);
  }
  memmove(p, de.name, DIRSIZ);          // 这里 p 是 char* ,指向 buf 的末尾
  p[DIRSIZ] = 0;                        // buf = "{path}/{de.name}\0"
  if (stat(buf, &st) < 0) {             // 根据合成的路径获取 stat
    printf("ls: cannot stat %s\n", buf);
    continue;
  }
  if (st.type == T_DIR) {               // 发现指向的是目录,递归调用
    if (find(buf, name) == -1) {
      goto error;
    }
  }
}

完整的代码如下,可供参考

#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
#include "kernel/fcntl.h"
#include "kernel/fs.h"

int find(char *path, char *name) {
  struct dirent de;
  struct stat st;
  int pathfd;
  char buf[512], *p;

  if (strlen(path) + 1 + DIRSIZ + 1 > sizeof buf) {
    fprintf(2, "%s path too long\n", path);
    return -1;
  }

  if ((pathfd = open(path, O_RDONLY)) < 0) {
    fprintf(2, "cannot open %s\n", path);
    return -1;
  }
  if (fstat(pathfd, &st) < 0) {
    fprintf(2, "cannot stat %s\n", path);
    goto error;
  }
  if (st.type != T_DIR) {
    fprintf(2, "%s is NOT a Directory!\n ", path);
    goto error;
  }
  // buf = "{path}/"
  strcpy(buf, path);
  p = buf + strlen(buf);
  *p++ = '/';

  while (read(pathfd, &de, sizeof(de)) == sizeof(de)) {
    // printf("DEBUG: name=[%s], target=[%s], inum=%d\n", de.name, name, de.inum);
    if (de.inum == 0) continue;
    if (!strcmp(de.name, "..") || !strcmp(de.name, ".")) {
      continue;
    }
    if (!strcmp(de.name, name)) {
      printf("%s/%s\n", path, de.name);
    }
    memmove(p, de.name, DIRSIZ);
    p[DIRSIZ] = 0;  // buf = "{path}/{de.name}\0"
    if (stat(buf, &st) < 0) {
      printf("ls: cannot stat %s\n", buf);
      continue;
    }
    if (st.type == T_DIR) {  // recursively search
      if (find(buf, name) == -1) {
        goto error;
      }
    }
  }
  return 0;
error:
  close(pathfd);
  return -1;
}

int main(int argc, char *argv[]) {
  if (argc != 3) {
    printf("Using Example: $find <path> <name>\n");
    exit(-1);
  }
  char *path = argv[1];
  char *name = argv[2];
  if (find(path, name) == -1) {
    exit(-1);
  }
  exit(0);
}

测试结果如下

sodium@nas-MacBook-Air-13 xv6-oslabs-hitsz % ./grade-lab-util find
make: `kernel/kernel' is up to date.
== Test find, in current directory and create a file == find, in current directory and create a file: OK (1.2s) 
== Test find, in current directory and create a dir == find, in current directory and create a dir: OK (0.8s) 
== Test find, find file recursive == find, find file recursive: OK (1.2s) 
== Test find, find dir recursive with no duplicates == find, find dir recursive with no duplicates: OK (1.2s) 

完成三个任务后,可以执行 make grade 进行评分,结果如下

== Test sleep, no arguments == 
$ make qemu-gdb
sleep, no arguments: OK (4.5s) 
== Test sleep, returns == 
$ make qemu-gdb
sleep, returns: OK (1.2s) 
== Test sleep, makes syscall == 
$ make qemu-gdb
sleep, makes syscall: OK (1.0s) 
== Test pingpong lenient testing == 
$ make qemu-gdb
pingpong lenient testing: OK (1.0s) 
== Test pingpong strict testing with changing pids == 
$ make qemu-gdb
pingpong strict testing with changing pids: OK (1.1s) 
== Test find, in current directory and create a file == 
$ make qemu-gdb
find, in current directory and create a file: OK (1.0s) 
== Test find, in current directory and create a dir == 
$ make qemu-gdb
find, in current directory and create a dir: OK (1.0s) 
== Test find, find file recursive == 
$ make qemu-gdb
find, find file recursive: OK (1.3s) 
== Test find, find dir recursive with no duplicates == 
$ make qemu-gdb
find, find dir recursive with no duplicates: OK (1.0s) 
Score: 60/60

任务4: xv6 启动流程分析

任务要求学会使用命令行 GDB 跟踪 XV6 的启动流程,并在 GDB 命令行 中于两处关键位置打印当前进程名:

p cpus[$tp]->proc->name

其中,$tp 是当前 CPU 编号对应的寄存器值。你最终需要分别打印出:

  • 初始进程 initcode
  • exec("/init") 之后的 init

最终效果示例如下:

$1 = "initcode\000\000\000\000\000\000\000"
$2 = "init", '\000' <repeats 11 times>

实验要求把命令保存为 commands.gdb ,进入 gdb 之后输入 source commands.gdb 打印现象。

虽然指导书说:“这个脚本不一定要写得很长;如果思路清楚,最短仅需 6 行,同学们可以尝试自己精简一下。”,但实测五行命令就足以打印要求的 init 和 initcode

break forkret
c
p cpus[$tp]->proc->name
c
p cpus[$tp]->proc->name

gdb 使用

这里只介绍最基本的命令行 GDB 使用方法,先前提到了两个命令。

  • make qemu-gdb CPUS=1 启动调试,在另一个终端窗口上可输入 make gdb 来进行对应调试
  • make gdb 对代码进行调试

这里进行示范,首先在一个终端窗口上输入 make qemu-gdb ,可以看到如下的现象

sodium@nas-MacBook-Air-13 xv6-oslabs-hitsz % make qemu-gdb                          
*** Now run 'gdb' in another window.
qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 3 -nographic -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 -S -gdb tcp::25501

接下来在另一个终端窗口(同样的项目文件夹下),输入 make gdb

sodium@nas-MacBook-Air-13 os-lab-submit % make GDB=riscv64-elf-gdb gdb                                                                                                              
make: *** No rule to make target `gdb'.  Stop.
sodium@nas-MacBook-Air-13 os-lab-submit % cd ..                       
sodium@nas-MacBook-Air-13 xv6 % cd xv6-oslabs-hitsz 
sodium@nas-MacBook-Air-13 xv6-oslabs-hitsz % make GDB=riscv64-elf-gdb gdb
riscv64-elf-gdb
GNU gdb (GDB) 17.2
Copyright (C) 2025 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "--host=aarch64-apple-darwin25.4.0 --target=riscv64-elf".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word".
The target architecture is set to "riscv:rv64".
⚠️ warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
0x0000000000001000 in ?? ()
(gdb) 

随后便可以在这个窗口输入命令进行调试,常用的命令如下

命令完整写法作用最常见场景
help 命令help 命令查看命令帮助忘记命令语法时
b 位置break 位置设置断点想在某个函数或某一行停下时
ccontinue继续运行从当前断点继续跑时
nnext源码单步,不进入函数想按行排查,但先不进入函数时
sstep源码单步,会进入函数想跟进函数内部时
ni / sinexti / stepi汇编单步调试系统调用、陷入与返回时
finishfinish运行到当前函数返回已经进函数,但想直接回到调用处时
p 表达式print 表达式打印变量或表达式如 p p->pid
x/格式 地址examine /格式 地址查看指定地址的内存内容如 x/10i $pc、x/16x 0x80000000
i rinfo registers查看寄存器值想看 pc、sp、a0 等寄存器时
btbacktrace查看调用栈想知道当前是怎么走到这里时
qquit退出 GDB结束本次调试时

下面是一些使用示例

(gdb) source commands.gdb
Breakpoint 1 at 0x80001bb8: file kernel/proc.c, line 482.

Thread 1 hit Breakpoint 1, forkret () at kernel/proc.c:482
482 void forkret(void) {
$1 = "initcode\000\000\000\000\000\000\000"

Thread 1 hit Breakpoint 1, forkret () at kernel/proc.c:482
482 void forkret(void) {
$2 = "init", '\000' <repeats 11 times>
(gdb) b swtch
Breakpoint 2 at 0x80002758
(gdb) c
Continuing.

Thread 1 hit Breakpoint 2, 0x0000000080002758 in swtch ()
=> 0x0000000080002758 <swtch+0>:    00153023            sd  ra,0(a0)
(gdb) i r pc
pc             0x80002758   0x80002758 <swtch>
(gdb) i r a0
a0             0x80011f30   2147557168
(gdb) n
Single stepping until exit from function swtch,
which has no line number information.
scheduler () at kernel/proc.c:437
437         c->proc = 0;
(gdb) p cpus[$tp]->proc->name
$3 = "init", '\000' <repeats 11 times>
(gdb) p $tp
$4 = (void *) 0x0

xv6 启动流程

xv6 的启动流程大致如下

  1. 进入 entry.S 中的 _entry 函数
    • 获取 CPU ID(mhartid),设置启动栈(sp)
  2. 进入 start.c 中的 start() 函数
    • 设置 previous mode 为 s-mode,,设置 mepc ,禁用页表( satp 设置为 0),设置中断/异常委托,初始化计时器、UART等
    • 完成之后通过 mret 进入 mepc 设置的地址(main)
  3. 进入 main.c 中的 main() 函数,调用很多初始化函数
    • 其中一个关键函数是 userinit() ,它会分配一个新的进程,也就是 initcode 进程,并设置为 RUNNABLE
    • 初始化完成之后,进入 scheduler() 函数
  4. proc.c 中的 scheduler() 函数会遍历进程列表,找到 RUNNABLE 进程执行,然后会使用 swtch 切换到进程的上下文中
    • 首先执行的用户程序是 initcode,它的内容是调用 exec(init, argv) ,将程序加载到内存中,执行完毕之后这个进程就会变成 init
    • init 用户程序的内容则是打开控制台,并且 fork()+exec("sh", argv),也就是打开 shell,然后 init 会调用 wait 进入睡眠。
    • 如果 sh 退出了,那么 init 会被唤醒,重新 fork()+exec() 启动 shell
    • 如果不是 sh 退出,那么说明是孤儿进程,init 不会管这个进程,继续 wait 进入睡眠
  5. sh 的内容大致是读取用户输入,并且执行对应的程序