MIT6.S081-Lab1

之前写过xv6的安装,和一些课程先导,有挺多学习资料的,在第一篇文章,本来还想弄一个系列,但是我又懒,关键要做lab,那就先写lab吧,顺带一提xv6的英文文档阅读起来还是有一些困难的,甚至咱的作业也是没看懂,但是lab重要。

先贴上lab的地址

这边说一个问题,就是这个课每年的课程地址都在改变如果你使用的和我一样是2020年的地址,那么你就会遇到一个问题,2020年的lab仓库里的代码出现bug的解决方案到如今2023年已经无法使用了,所以办法就是切换新的lab。下面的lab地址我已经换成了2022年的了。

稍微提一下这个bug,在2020年的安装指导里提到运行make qemu之后会卡在编译的最后环节。

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

然后解决方案是换一个更低版本的包,but现在这个包已经被删除了,无独有偶找到一篇知乎的文章,提到了lab地址就想到了,更新后的lab代码。

Lab: Xv6 and Unix utilities

稍微翻译一下lab。。。

这个实验会帮助你熟悉xv6和它的系统调用。

启动xv6(简单)

上一次已经启动过了,我以为上课要先给他启动起来,结果第一节课的实验的第一题居然是启动。

很简单就是说如果没有Athena(大概是MIT的内部服务器,可以直接用)要在自己电脑上装的话就看lab tools page来安装。

然后是一堆关于Athena的使用直接忽略。

下一段就是说要先clone实验(在github上)

1
2
3
git clone git://g.csail.mit.edu/xv6-labs-2022
cd xv6-labs-2022
git checkout util #切换到util分支

这边我看了一下应该是有两个分支,一个分支是origin,另一个是util,如果我们不做任何修改的话目前origin和util现在是一样的,我们需要切换到util进行代码编写实验修改xv6。

使用git status查看当前分支。

切换分支的命令如上,其次还提到了下载的lab代码xv6-labs-2020 repository和book’s xv6-riscv里的略有不同。主要是增加了一些文件,运行git log查看历史记录。稍微看了一下,这个历史修改记录有亿点点多。

然后说了一下,这些代码后续提交什么的需要使用Git,推了基本Git的帮助手册,使用Git的书,这里就不看了,还是英文的,大可不必再烧我的脑细胞。提到了上述切换分支的util分支包含了契合于实验版本的xv6,Git可以跟踪修改如果完成了练习,可以使用如下命令提交修改。

1
git commit -am 'my solution for util lab exercise 1'

然后说,如果要查看修改可以使用git diff命令,可以使用它来显示你最后一次修改的差异。使用git diff origin/util可以显示你的代码与原始版本的xv6代码的差异,这里提到了origin分支是原始xv6代码。

然后如果你按照上述安装文档,安装完qemu和编译所需的运行环境时,就可以直接编译运行xv6了。

使用make qemu命令就开始编译运行了。

这里粘贴一下运行正常显示的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
riscv64-unknown-elf-gcc    -c -o kernel/entry.o kernel/entry.S
riscv64-unknown-elf-gcc -Wall -Werror -O -fno-omit-frame-pointer -ggdb -DSOL_UTIL -MD -mcmodel=medany -ffreestanding -fno-common -nostdlib -mno-relax -I. -fno-stack-protector -fno-pie -no-pie -c -o kernel/start.o kernel/start.c
...
riscv64-unknown-elf-ld -z max-page-size=4096 -N -e main -Ttext 0 -o user/_zombie user/zombie.o user/ulib.o user/usys.o user/printf.o user/umalloc.o
riscv64-unknown-elf-objdump -S user/_zombie > user/zombie.asm
riscv64-unknown-elf-objdump -t user/_zombie | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$/d' > user/zombie.sym
mkfs/mkfs fs.img README user/xargstest.sh user/_cat user/_echo user/_forktest user/_grep user/_init user/_kill user/_ln user/_ls user/_mkdir user/_rm user/_sh user/_stressfs user/_usertests user/_grind user/_wc user/_zombie
nmeta 46 (boot, super, log blocks 30 inode blocks 13, bitmap blocks 1) blocks 954 total 1000
balloc: first 591 blocks have been allocated
balloc: write bitmap block at sector 45
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

xv6 kernel is booting

hart 2 starting
hart 1 starting
init: starting sh
$

大概就这样,最下面会有一个$符,当成是shell,然后输入ls命令得出类似于如下的结果就证明没有问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ ls
. 1 1 1024
.. 1 1 1024
README 2 2 2059
xargstest.sh 2 3 93
cat 2 4 24256
echo 2 5 23080
forktest 2 6 13272
grep 2 7 27560
init 2 8 23816
kill 2 9 23024
ln 2 10 22880
ls 2 11 26448
mkdir 2 12 23176
rm 2 13 23160
sh 2 14 41976
stressfs 2 15 24016
usertests 2 16 148456
grind 2 17 38144
wc 2 18 25344
zombie 2 19 22408
console 3 20 0

下面是说了一下有很多文件可以执行包括mkfs,大多数程序都可以运行,包括刚刚的ls。

然后提了一下xv6没有ps这个命令,你可以使用Ctrl-p来查看进程信息,如果你现在运行,应该可以看到init和sh。

退出qemu使用Ctrl-a x

最后说了一下可以运行make grade来查看测试结果,提交是运行make handin,咱大概是不需要提交的。

发现了个bug卡了很久,但是解决了,问题是要看最新的lab指导。

顺带提一嘴,老师上课的时候使用了make clean命令,清除了编译缓存,为了展示一下编译过程。

sleep(简单)

编写一个UNIX程序为xv6实现sleep功能,你的sleep功能应该实现暂停用户指定的tick(滴答)数,一个tick是xv6内核定义的时间概念,也就是计时器两次停顿的中间时间,你的代码应该写在/user/sleep.c。

一些提示:

  • 开始编程前请先阅读xv6 book的第一章。(蒸汽牛奶:关于xv6的介绍书,已经更新到了第三个版本。建议下载最新版,或者实验对用的版本)。
  • 阅读其他位于/user目录的代码(例如,user/echo.c,user/grep.c和user/rm.c),注意观察命令行参数对程序的传递。
  • 如果用户没有给sleep传递参数,程序应该打印错误信息。
  • 命令行参数是以字符串的形式传递,你可以使用atoi (详见 user/ulib.c).将其转换成一个整数(integer)。
  • 使用 系统调用 sleep。
  • 阅读xv6的内核代码kernel/sysproc.c实现系统调用sleep的代码(寻找sys_sleep),user/user.h中对于可供用户程序调用的sleep的C的定义,还有user/usys.S中sleep从用户代码跳转到内核的汇编代码实现。
  • main函数需要在结束时调用exit(0)。
  • 将你的sleep程序添加到UPROGS在Makefile中,这样make qemu就会编译你的源代码并且可以在shell中运行。
  • 阅读Kernighan and Ritchie’s book The C programming language (second edition) (K&R)来学习C。

运行程序:

1
2
3
4
5
6
$ make qemu
...
init: starting sh
$ sleep 10
(nothing happens for a little while)
$

如果运行结果如上的话,那么程序应该就是正确的,运行make grade可以检查程序是否正真通过了测试。

注意make grade会运行所有测试,如果只要运行一项测试就运行下面的任意一个指令(效果一样)。

1
2
$ ./grade-lab-util sleep
$ make GRADEFLAGS=sleep grade

结果:

这题很简单哈,但是我不会。。。于是我就偷看了答案,很久没看C语言了,我真的会谢,很简单的题目看了很久。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

int
main(int argc, char *argv[])
{
if(argc < 2){
fprintf(2, "Usage: sleep [time]\n");
exit(1);
}

sleep(atoi(argv[1]));
exit(0);
}

解析:

关于头文件,貌似类似于echo.c和rm.c都是这个头文件,我看其他的答案有少了一条的,具体是否调用也不清楚。其次错误输出用fprintf重定向到2也就是错误输出。这边提一下因为xv6的书里提到了,每个程旭启动的时候默认保持最少三个文件描述符,即0 stdin,1stdout,2stderr。

还有几个看不懂的地方,首先是argc这个经查阅代表的是传递过来的参数个数,最少是两个sleep num,包括sleep和睡眠时间。再者使用atoi转换argv[1]。

检验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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\

再Makefile里面找到UPROGS在最下面添加编写的sleep,然后make qemu时就可以编译并且在命令行运行。

1
2
3
4
5
6
#使用命令检验
./grade-lab-util sleep
make: 'kernel/kernel' is up to date.
== Test sleep, no arguments == sleep, no arguments: OK (0.9s)
== Test sleep, returns == sleep, returns: OK (0.9s)
== Test sleep, makes syscall == sleep, makes syscall: OK (1.0s)

运行命令的原理

很纳闷为什么参数是这样传递的,于是又翻了翻xv6 book,shell调用exec来代表用户执行程序。一个主循环使用getcmd循环读入用户输入,然后调用fork创建一个父进程的副本,父进程调用wait等待子进程结束,子进程执行命令。

参数传递:

关于调用主函数的参数,我的理解为传给runcmd的是两个参数,在runcmd调用exec,如果exec执行成功,echo将替换掉runcmd,我理解为exec传递给sleep的参数为,第一个参数是参数个数,即argc=2,如果小于2就证明没有传递给sleep参数,第二个就是sleep和time,使用argv[1]是因为忽略第一个参数sleep。再提一嘴就是弹出的报错的意思是sleep的使用方法,usage又规范用法的意思。

fork和exec:

再提一嘴为什么要先fork再exec,类似于书上win32里面有CreateProcess这个API,实际上百度一番发现这个方法并不是很适合多线程即现在的环境,也不是很推崇使用,至于现在还存在于Linux中是因为保留的Unix的部分,至于以前为什么用是因为以前的内存很小甚至是单线程,使用exec会干掉原来的shell,那么当exec运行完成之后再启动shell就啥也没了,没了交互。再者还提到一个观点就是对子进程环境的修改,而不影响父进程,比如修改子进程的文件描述符标准输出到文件,但是又不想影响到父进程,于是就出现了fork,fork不适用于现在的环境,特别是进程内存及其他很大的时候,fork一个一模一样的进程很消耗资源。

pingpong(简单)

编写一个程序使用Unix系统调用在两个进程间使用管道“ping-pong”一个字节,每个方向各有一个。父进程向子进程发送一个byte,子进程打印“<pid>:received ping”,pid时进程id,然后再写一个字节到通向父进程的管道中,再退出。父进程收到子进程发送的byte后,打印“<pid>: received pong”,然后退出。文件位于user/pingpong.c。

一些提示:

  • 使用pipe来创建一个管道。
  • 使用fork来创建一个子进程。
  • 使用read来从管道读取,使用write来写入管道。
  • 使用getpid来获取调用进程的pid。
  • 将程序添加到Makefile的UPROGS中。
  • xv6上用户程序可调用的库函数的表位于user/user.h中,源代码在user/ulib.c, user/printf.c, 和user/umalloc.c中。

运行程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#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 pid = fork();
if(pid > 0){
// parent
char buf[10];
write(p1[0], "ping", sizeof("ping"));
close(p1[0]);
read(p2[1], buf, sizeof(buf));
close(p2[1]);
printf("%d: received pong\n", getpid());
exit(0);
} else {
// child
char buf[10];
read(p1[1], buf, sizeof(buf));
close(p1[1]);
write(p2[0], "pong", sizeof("pong"));
close(p2[0]);
printf("%d: received ping\n", getpid());
exit(0);
}
}

结果:

1
2
3
$ pingpong
4: received ping
3: received pong

解析:

一个注意点子进程和父进程都需要exit(0)。(网上看到的)

调用函数创建管道需要在主函数内创建。

冒号后面还有个空格。。。

然后wait(0),主进程要等待子进程结束再printf,不然输出会绞在一起。

user.h

由最后一个提示可以得到user.h包含了用户程序可调用的库函数,打开可以看到里面包含了系统调用和ulib.c。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
truct stat;

// system calls
int fork(void);
int exit(int) __attribute__((noreturn));
int wait(int*);
int pipe(int*);
int write(int, const void*, int);
int read(int, void*, int);
int close(int);
int kill(int);
int exec(const char*, char**);
int open(const char*, int);
int mknod(const char*, short, short);
int unlink(const char*);
int fstat(int fd, struct stat*);
int link(const char*, const char*);
int mkdir(const char*);
int chdir(const char*);
int dup(int);
int getpid(void);
char* sbrk(int);
int sleep(int);
int uptime(void);

// ulib.c
int stat(const char*, struct stat*);
char* strcpy(char*, const char*);
void *memmove(void*, const void*, int);
char* strchr(const char*, char c);
int strcmp(const char*, const char*);
void fprintf(int, const char*, ...);
void printf(const char*, ...);
char* gets(char*, int max);
uint strlen(const char*);
void* memset(void*, int, uint);
void* malloc(uint);
void free(void*);
int atoi(const char*);
int memcmp(const void *, const void *, uint);
void *memcpy(void *, const void *, uint);
fork

关于fork的一个点是fork在父进程和子进程都会有返回,在父进程中返回子进程的pid,在子进程中返回0。

检验:

1
2
3
./grade-lab-util pingpong
make: 'kernel/kernel' is up to date.
== Test pingpong == pingpong: OK (1.3s)

primes(适中)/(困难)

(咱就是说有时候自己看不懂Google翻译还是挺准的)

使用管道编写prime sieve的并发版本。这个想法源自于Unix的管道发明者Doug Mcllroy。本页中的图片和周围文字说明了该如何操作。文件应位于user/primes.c。

你的目标是使用pipe和fork来创建设置管道。第一个进程将数字2到35输入管道。对于每个质数,将创建一个进程,进程通过管道从其左邻居读取并通过管道写入其右邻居。由于xv6的文件描述符和进程数量限制,所以数字是最大35。

一些提示:

  • 注意关闭进程不需要的文件描述符,因为xv6将在没有到达数字35之前耗尽资源。
  • 一旦第一个进程到达35,它需要等待所有管道线路关闭,包括所有子进程,子进程的子进程等。所以primes的主函数只有在在所有的输出都已经被打印之后,所有的进程都退出后才能结束。
  • 如果管道的写端口已经关闭,read会返回0(读完之后)。
  • 最简单的方式是直接写32-bit(4-byte)的int传给管道,而不是使用标准ASCII I/O。
  • 你只有在管道线路需要的时候才能创建进程。
  • 将文件添加到Makefile的UPROGS。

运行程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

void prime(int pr); //函数声明。

int
main()
{
int p[2]; //p[0]是读端口,p[1]是写端口。
pipe(p); //创建管道。

int pid = fork(); //创建进程
if(pid != 0){ //在主进程里
// first process
close(p[0]); //关闭读端口
//向管道写入2-35
for(int i = 2, i <= 35; i++){
write(p[1], i, 4);
}
close(p[1]); //关闭写端口
wait(0); //主进程等待子进程
} else { //子进程内
close(p[1]); //关闭写端口
prime(p[0]); //将读端口传入prime
close(p[0]); //关闭读端口
}
exit(0);
}

void
prime(int pr)
{
int n;
read(pr, &n, 4); //读取第一个数字
printd("prime %d\n", n); //打印第一个数据
int created = 0; //标记是否创建进程
int p[2]; //用作管道
int num; //接受数据
while(read(pr, &num, 4)){ //只要还能读到数据while一直存在
if(created == 0){ //如果没有创建过进程
pipe(p);
created = 1;
int pid = fork();
if(pid == 0){ //子进程中
close(p[1]); //关闭写端口
prime(p[0]); //传入读端口
return; //无返回值
} else {
close(p[0]); //本进程关闭读端口
}
}
if(num % n != 0){
write(p[1], &num, 4); //写入不能整除n的数字进入下一轮
}
}
close(pr);
close(p[1]);
wait(0);
}

结果:

1
2
3
4
5
6
7
8
9
10
11
12
$ primes
prime 2
prime 3
prime 5
prime 7
prime 11
prime 13
prime 17
prime 19
prime 23
prime 29
prime 31

解析:

提示说到最简单的方式是写入4-byte的数据,于是read和write的数据大小。

不知道是我基础太薄弱还是智商有限哈,看了半天都没看懂,直接复制粘贴别人的代码开始做注解了,还有两个别人写的代码超级长,吃不消,定义了好多东西。。。

首先第一个进程将2-35全部写入管道,然后先打印第一个,每次传入的第一个数字一定是质数,然后将数据对n取余,将不能够整除n的数字进入下一轮,根据文档中的图片。

第一轮去除4,6,8,10…

第二轮去除9…

一个问题是需要关闭文件描述符和关闭内存释放资源。

检验:

1

find(适中)

编写一个简单的UNIX查找程序:在文件树中查找指定名字的所有文件。你的C文件应该被放在user/find.c。

一些提示:

阅读user/ls.c来学习如何读取目录。

利用递归来允许find访问子目录。

不要递归进入“.”和“..”。

你需要使用C字符串,阅读K&R的The C book,例如5.5。


MIT6.S081-Lab1
https://steammilk.com/2023/01/17/2023-all/6-s081-lab1/
作者
蒸奶泡
发布于
2023年1月17日
更新于
2025年1月8日
许可协议