文章目录
一、环境搭建
官方lab汇总主页:https://pdos.csail.mit.edu/6.828/2021/schedule.html
lab1主页:https://pdos.csail.mit.edu/6.828/2021/labs/util.html
在网站 https://pdos.csail.mit.edu/6.828/2021/tools.html中有各种环境的不同选择,我这里选择的是WSL Ubuntu 20.04。
命令行运行如下指令,安装所需要的工具
$ sudo apt-get update && sudo apt-get upgrade
$ sudo apt-get install git build-essential gdb-multiarch qemu-system-misc gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu
然后拉取项目:
$ git clone git://g.csail.mit.edu/xv6-labs-2021
$ cd xv6-labs-2021
$ git checkout util
不过这里我后来选择和我的个人仓库的main分支进行了合并。
构建和运行 xv6
make qemu
可以输入 ls 来测试:
$ ls
. 1 1 1024
.. 1 1 1024
README 2 2 2226
xargstest.sh 2 3 93
cat 2 4 23888
echo 2 5 22720
forktest 2 6 13080
grep 2 7 27248
init 2 8 23824
kill 2 9 22696
ln 2 10 22648
ls 2 11 26120
mkdir 2 12 22792
rm 2 13 22784
sh 2 14 41656
stressfs 2 15 23792
usertests 2 16 156008
grind 2 17 37968
wc 2 18 25032
zombie 2 19 22184
console 3 20 0
$
二、Sleep
2.1 实验要求
要求在 user/ 目录下创建 sleep.c文件,实现sleep系统调用。我这里选择 vscode 连接 WSL(安装WSL插件在WSL终端中输入 code . 即可自动打开vscode 并连接)
官网的一些hint:
- 通过查看
user/echo.c
,user/grep.c
, anduser/rm.c
来了解如何获取命令行参数 - 如果没有输入参数,应该打印错误信息
- 命令行参数格式为字符串,可以利用 atoi API
- 借助系统调用 sleep
- 参考 kernel/sysproc.c 中 xv6 内核代码对于 sleep 的实现,
user/user.h
中对于 sleep 的C风格声明,以及user/usys.S 中 从系统态进入内核态来调用 sleep 的汇编代码 - 确保main 函数中 有exit() 来退出程序
- 把 sleep 加到 makefile 中的UPROGS,这样 make qemu 能够编译程序,并且在 xv6 的shell 中运行
2.2 实现代码
非常简单,我们检查下参数数目,然后调用sleep() 即可
#include "kernel/types.h"
#include "user/user.h"
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(2, "please input 1 argument for sleep\n");
exit(-1);
}
int sleepTime = atoi(argv[1]);
printf("(nothing happens for a little while)\n");
sleep(sleepTime);
exit(0);
}
在 Makefile 相应位置添加参数
运行结果
三、pingpong
3.1 实验要求
基于UNIX 系统调用实现 pingpong ,使用一对pipe 来 “ping-pong” 一个字节。parent 给 child 发送一个字节,child 打印"<pid>: received ping",<pid> 是 进程号,然后通过通道 把字节写给parent,然后退出。parent 读取字节,然后打印 "<pid>: received pong"然后退出。
程序编写在 user/pingpong.c
下
官网的一些hints:
- 使用 pipe 来创建 pipe
- 使用 fork 来创建 child
- 使用 read 来读管道,write 来写管道
- getpid 来找到调用程序的 pid
- 记得把 程序加到 Makefile 的UPROGS
- xv6 可用的库函数是有限的,你可以在 user/user.h中看到。
user/ulib.c
,user/printf.c
,user/umalloc.c
中看到源码。
3.2 pipe 介绍
pipe
是 Linux 中用于进程间通信(IPC) 的系统调用,主要面向有血缘关系的进程(如父子进程)。其核心功能是创建一个匿名管道,该管道本质上是内核维护的环形缓冲区(默认 4KB 大小),通过两个文件描述符(读端和写端)实现单向数据传输。
调用 pipe
后会生成两个文件描述符:
fd[0]
:读端(从管道读取数据)fd[1]
:写端(向管道写入数据)
内核缓冲区:数据通过写端进入内核缓冲区,读端按 FIFO 顺序取出数据。数据一旦被读取,缓冲区将不再保留该数据
半双工通信:管道是单向的,同一时间只能用于读或写。若需双向通信,需创建两个管道
关系图:
3.3 fork 介绍
fork
是 Linux/Unix 系统中的核心系统调用,用于创建新进程(子进程)。其核心特性包括:
- 进程复制:子进程是父进程的副本,继承父进程的代码段、数据段、堆栈、打开的文件描述符、信号处理设置等资源
- 写时复制(Copy-on-Write):
为提高性能,现代系统采用写时复制技术。父进程和子进程初始共享内存空间,只有当任一进程尝试修改内存时,才会复制对应的内存页 - 双返回值:
fork
调用一次返回两次:- 父进程返回子进程的 PID(进程 ID)。
- 子进程返回 0。
工作原理
- 执行流程
- 调用阶段:父进程执行
fork()
后,内核复制父进程的 PCB(进程控制块)和地址空间,生成子进程。 - 返回阶段:父子进程从
fork()
之后的代码继续执行,但通过返回值区分执行逻辑(如父进程执行任务分发,子进程执行具体任务)
- 调用阶段:父进程执行
- 资源继承
- 共享资源:文件描述符、信号处理函数、当前工作目录等。
- 独立资源:进程 ID、父进程 ID、未决信号、计时器等
3.4 wait 介绍
wait
是 Linux/Unix 系统中用于 父进程等待子进程终止 的系统调用,主要功能包括:
- 阻塞父进程:父进程执行
wait
后进入阻塞状态,直到有子进程终止。 - 回收僵尸进程:子进程终止后,父进程通过
wait
回收其资源(如进程描述符),防止僵尸进程残留 - 获取子进程状态:收集子进程退出时的状态码(正常退出值或终止信号)。
原型:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
- 参数
status
:指向整型变量的指针,用于存储子进程终止状态。若设为NULL
,表示不关心状态信息 - 返回值:
- 成功:返回终止子进程的 PID。
- 失败:返回
-1
(如无子进程或信号中断)
3.5 实现代码
#include "kernel/types.h"
#include "user/user.h"
int main(int argc, char *argv[]) {
int p0[2], p1[2]; // 管道
char buf[] = {'P'}; // pingpong 的字节
int len = sizeof(buf); // 传输长度
if (pipe(p0) == -1) {
printf("error, failed for pipe0!\n");
exit(-1);
}
if (pipe(p1) == -1) {
printf("error, failed for pipe1!\n");
exit(-1);
}
// 子进程返回0
if (fork() == 0) {
close(p0[1]); // 可以关掉父进程的写端了
close(p1[0]); // 关掉子进程的读端
// 从 pipe0 读取
if(read(p0[0], buf, len) != len){
printf("parent to child: read error!");
exit(-1);
}
// 打印
printf("%d: received ping\n", getpid());
//子进程向pipe2的写端,写入字符数组
if(write(p1[1], buf, len) != len){
printf("child to parent: write error!");
exit(-1);
}
exit(0);
}
// 关掉子进程读端,父进程写端
close(p0[0]);
close(p1[1]);
//父进程向pipe0的写端写入数据
if(write(p0[1], buf, len) != len){
printf("parent to child: write error!");
exit(-1);
}
//父进程从pipe1的读端读入数据
if(read(p1[0], buf, len) != len){
printf("child to parent: read error!");
exit(-1);
}
//打印数据
printf("%d: received pong\n", getpid());
//等待子进程退出
wait(0);
exit(0);
}
运行结果如下:
四、primes
4.1 实验要求
利用管道写一个并发版本的素数筛。代码放在 user/primes.c
.
目标:利用 fork 和 pipe 来建立管道通信。第一个进程把 2~35内的素数写入管道,每一个素数,你需要创建一个进程来读取并传递给右边的进程。因为xv6 的文件描述符和进程数目有限,第一个进程要在 35停止。
官网hints:
- 谨慎关闭不需要的管道,否则你的xv6 在第一个进程到达35之前就寄了(资源不够)
- 一旦第一个进程到了35,它应该等待整个管道通信结束包括它的所有后代节点。所以主程序应该在所有素数打印完毕以及其他进程结束后退出。
- 如果管道写端关闭,read会返回0
- 最简单的方式是直接把int写入管道而不是转成ASCII。
- 你应该在管道通信需要进程的时候来创建进程
- 在 Makefile 的
UPROGS
中添加 参数
4.2 代码实现
#include "kernel/types.h"
#include "user/user.h"
const int N = 35;
// O(sqrt(x)) 判素数
int isPrime(int x) {
for (int i = 2; i * i <= x; ++ i) {
if (x % i == 0) return 0;
}
return 1;
}
// 左邻居管道
void sieve(int *pre) {
close(pre[1]); // 关闭邻居的写
int prime = -1;
// 左边关了?
if (read(pre[0], &prime, sizeof prime) == 0) {
return;
}
printf("prime %d\n", prime);
int next[2];
pipe(next);
if (fork() == 0){
sieve(next);
exit(0);
}
for (int x; read(pre[0], &x, sizeof x); ) {
write(next[1], &x, sizeof x);
}
close(pre[0]); // 关闭从左边的读
close(next[1]); // 关闭往右边的写
wait(0);
}
int main(int argc, char *argv[]) {
int p0[2];
pipe(p0);
if (fork()) {
for (int i = 2; i <= N; ++ i) {
if (isPrime(i)) {
write(p0[1], &i, sizeof i);
}
}
close(p0[1]);
wait(0);
} else {
sieve(p0);
close(p0[0]);
}
exit(0);
}
运行结果:
五、find.c
写一个简单版的 UNIX 的 find 程序,找到目录树下所有指定名字的文件。代码放在user/find.c
.
官网hints:
- 查看 user/ls.c 来了解如何读目录
- 使用回溯来下降到子目录
- 补药回溯到 . 以及 …
- 对文件系统的更改会持久化保留,若需获得全新的文件系统,请先执行
make clean
,再执行make qemu
。" - C 语言对于字符串的比较可以使用strcmp
- Add the program to
UPROGS
in Makefile.
通过阅读 ls.c 的源码发现非常简洁,就一个 ls 函数 和 一个 格式化字符串的函数。
ls 函数为我们提供了文件操作的示例。
5.1 文件操作相关
两个结构体:
-
dirent
-
文件名获取:通过
d_name
字段直接读取目录项的名称,支持文件遍历 -
文件类型判断:
d_type
字段标识文件类型(如DT_REG
表示常规文件,DT_DIR
表示目录) -
索引节点关联:
d_ino
与文件系统的 inode 关联,可用于唯一标识文件- 也常见为 inum
- de.inum == 0:这个目录项没有对应的 inode。
- de.inum == 1:是根目录
/
的 inode(和具体文件系统实现相关,本实验而言是这样的)
-
ls.c 中的目录遍历:
-
// struct dirent de; while(read(fd, &de, sizeof(de)) == sizeof(de)){ if(de.inum == 0) continue; memmove(p, de.name, DIRSIZ); p[DIRSIZ] = 0; if(stat(buf, &st) < 0){ printf("ls: cannot stat %s\n", buf); continue; } printf("%s %d %d %d\n", fmtname(buf), st.type, st.ino, st.size); }
-
-
-
stat
-
保存了一些文件的元数据:
-
文件类型:常规文件、目录、符号链接等(通过
st_mode
字段判断) -
权限信息:用户/组权限、特殊权限位(如 setuid)
-
时间戳:最后访问时间(
st_atime
)、最后修改时间(st_mtime
)等 -
其他属性:文件大小(
st_size
)、设备号(st_dev
)、i节点号(st_ino
)等 -
可以通过 fstat、lstat 和 stat 系统调用来读取
-
函数 参数类型 符号链接处理方式 适用场景 stat()
文件路径 自动解引用,返回目标文件信息 通过路径获取常规文件属性 lstat()
文件路径 不自动解引用,返回链接本身信息 处理符号链接时需保留元数据 fstat()
文件描述符 根据描述符指向对象返回信息 已打开文件的动态属性监控15
-
5.2 代码实现
- 参考了 ls.c 中的fmtname函数,以及ls 的框架
- 这里对 fmtname 函数修改,对于传入的path,我们截取最后一个 / 后面的部分并返回,即文件名
- find 函数的思路就是读当前路径,如果读到文件了,那就是找到了
- 如果读到目录了,我们加入这个目录名到path尾部,然后再加 ‘/’,以新的path 递归下去即可。
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
#include "kernel/fs.h"
#include "kernel/fcntl.h"
char *fmtname(char *path)
{
static char buf[DIRSIZ + 1];
char *p;
// Find first character after last slash.
for (p = path + strlen(path); p >= path && *p != '/'; p--)
;
p++;
// 截取文件名,别忘了末尾 '\0'
memmove(buf, p, strlen(p) + 1);
return buf;
}
void TryPrint(char *fileName, char *findName) {
if (strcmp(fmtname(fileName), findName) == 0) {
printf("%s\n", fileName);
}
}
// 在path下find指定文件
void find(char *path, char *findName) {
char buf[512], *p;
int fd;
struct dirent de;
struct stat st;
if ((fd = open(path, O_RDONLY)) < 0){
fprintf(2, "find: cannot open %s\n", path);
return;
}
if (fstat(fd, &st) < 0) {
fprintf(2, "find: cannot stat %s\n", path);
close(fd);
return;
}
switch (st.type) {
case T_FILE:
TryPrint(path, findName);
break;
case T_DIR:
if (strlen(path) + 1 + DIRSIZ + 1 > sizeof buf) {
printf("find: path too long\n");
break;
}
strcpy(buf, path);
p = buf + strlen(buf);
*p++ = '/'; // 递归进入这个目录内部
while (read(fd, &de, sizeof(de)) == sizeof(de)) {
if (de.inum == 0 || de.inum == 1 || strcmp(de.name, ".") == 0 || strcmp(de.name, "..") == 0)
continue;
memmove(p, de.name, strlen(de.name));
p[strlen(de.name)] = 0;
find(buf, findName);
}
break;
}
close(fd);
}
int main(int argc, char *argv[]){
if(argc < 3){
printf("find: find <path> <fileName>\n");
exit(0);
}
find(argv[1], argv[2]);
exit(0);
}
运行结果:
六、xargs
写一个简化版的UNIX xargs 程序,从标准输入流读取行,然后对每一行运行指令,作为xargs 后面指令的参数。
示例:
$ echo hello too | xargs echo bye
bye hello too
$
UNIX 会对多行参数输入做优化,但是本实验不要求做这种优化,相反,我们需要实现 -n 选项:
$ echo "1\n2" | xargs -n 1 echo line
line 1
line 2
$
官网的一些提示:
- 使用 fork 和 exec 来对于每一行调用指令。在父进程使用wati 来等待子进程完成指令
- 读一行输入,是读到 ‘\n’ 为止
6.1 关于 exec 后发生的事情
本实验的exec 函数就是包装后的execve 函数,避免我们去显示处理环境变量。
第一个参数传入执行的指令,第二个参数则是完整的指令,包括指令本身和参数。
值得注意的是:
- 文件描述符 fd = 0 的时候,代表标准输入
- 子进程执行 exec 系统调用后:
- 内核会清空旧的进程映像,加载新的进程映像,初始化堆和栈。(进程ID,打开的文件描述符等不会改变)
- 内核将程序计数器(PC)设定为新程序的入口点。。从此,进程开始执行新程序,从入口函数(例如 C 程序中的
main
)开始运行。 - exec 调用成功后,子进程的控制流将永远转向新程序代码。换句话说,调用 exec 的那段代码不再存在,如果 exec 调用失败,才会返回错误。但成功时 exec 并不返回到原调用点,而是直接用新程序覆盖了之前的所有程序代码。
6.2 代码实现
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
#include "kernel/fs.h"
#include "kernel/param.h"
char buf[512];
char *args[MAXARG];
int main(int argc, char *argv[])
{
char c;
char *p;
int n;
int status;
if (argc < 2) {
printf("xargs: missing operand\n");
exit(0);
}
// 拿到 xargs 后面的命令及参数
for (int i = 1; i < argc; ++i) {
args[i - 1] = argv[i];
}
while (1) {
p = buf;
while ((n = read(0, &c, 1)) && c != '\n') {
*p = c;
++ p;
}
if (n == 0) {
break;
}
if (n < 0) {
fprintf(2, "read error\n");
exit(1);
}
*p = '\0'; // 结束
if (p != buf) {
args[argc - 1] = buf;
if (fork() == 0) {
exec(argv[1], args);
exit(1);
}
wait(&status);
}
}
exit(0);
}
运行结果:
++ p;
}
if (n == 0) {
break;
}
if (n < 0) {
fprintf(2, "read error\n");
exit(1);
}
*p = '\0'; // 结束
if (p != buf) {
args[argc - 1] = buf;
if (fork() == 0) {
exec(argv[1], args);
exit(1);
}
wait(&status);
}
}
exit(0);
}
**运行结果:**
[外链图片转存中...(img-NI6lpvbj-1744648158177)]
[外链图片转存中...(img-gU4PAoBT-1744648158177)]