MIT-6.S081 Lab-1:Utilities

终于开始这一套臭名昭著好评如潮的lab了。因为B站上的大部分视频都是2020年的,而且从清单上看,2020年的lab比后面2021年和2022年的要多出一个网络驱动lab。所以这里配置环境和课程网站都是使用2020年的版本。

本次lab的指导

0. 配环境\text{0. 配环境}

0.1. 编译运行xv6\text{0.1. 编译运行xv6}

我的系统是Arch Linux\text{Arch Linux},课程网站上对于Arch\text{Arch}配置环境只需一行命令就可以完成。当然,官方教程说得有多简单,出意外的可能性就有多大。

安装xv6\text{xv6},不要安装原始的xv6\text{xv6},要安装MIT为课程实验准备的改版,在指导里也有。代码仓库克隆下来,主分支如果是个空文件夹(实际上有些隐藏文件),这就对了。按照指导切换至util\text{util}分支,就出现了xv6\text{xv6}的源码和一些必要的脚本(比如评分脚本)。

然后用make qemu命令把操作系统跑起来,就出意外了。下面是我遇到的问题和解决方法。

编译sh.c时报错无穷递归\text{编译sh.c时报错无穷递归}

这个是正常的,因为xv6\text{xv6}本来就没指望shell会返回。从网上搜了一圈,在函数定义前面加上下面这一行

1
__attribute__((noreturn))

就好了

编译完成,但是启动xv6卡住\text{编译完成,但是启动xv6卡住}

编译以后启动xv6\text{xv6}时卡住了,卡在了

1
2
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

之后就没动静了。

翻了下课程网站,然后发现这个现象课程组解释了,但是是在ubuntu\text{ubuntu}的安装指南下面解释的。

原文是

At this moment in time, it seems that the package qemu-system-misc has received an update that breaks its compatibility with our kernel. If you run make qemu and the script appears to hang after …

you’ll need to uninstall that package and install an older version

ubuntu下还给出了解决方案,但是Arch\text{Arch}下就没话说了。

查了一下安装的qemu版本,发现确实有点高,所以要降级。关于降级可以安装一个实用程序叫做downgrade\text{downgrade}

1
sudo pacman -S downgrade

然后使用downgrade\text{downgrade}来安装qemu-arch-extra\text{qemu-arch-extra}的旧版本

1
sudo downgrade qemu-arch-extra

选择了5.2.0-4的版本,装好以后再次make qemu这个问题就解决了。但是引入了另一个问题

缺共享库libbrlapi.so\text{缺共享库libbrlapi.so}

make报错缺了共享库文件,这个就比较简单,直接装一个就行,如果缺的是其它的应该也是同理(但是我也遇到过这么解决不了的情况)。

1
sudo pacman -S libbrlapi.so

就好了。

0.2. 配置gdb调试\text{0.2. 配置gdb调试}

Guidance里简要说了如何调试,但是写得如此简略还是应该被打,跟没说一样。

参考了这里

照着坐下来没遇到什么大问题。具体原理应该是本地make qemu-gdb开启一个tcp端口,然后用gdb连这个端口远程调试。

顺便一提,gdb的tui看起来真是人性化。

到此基本上就结束配环境了。

1. 必做lab\text{1. 必做lab}

1.1. sleep\text{1.1. sleep}

要求是利用系统调用sleep写一个sleep程序。

设定上讲,这是个最简单的lab,但是还是要花点时间熟悉操作,主要难倒不是难在写程序,写程序就十来行的事实在不知道的话照猫画虎抄mkdir.c就能知道怎么写,重点是初步理解整个xv6编译过程发生了什么。

user/user.h\text{user/user.h}中已经封装好了系统调用,所以直接用就可以。

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

int main(int argc, char **argv)
{
if(argc != 2)
{
fprintf(2, "Usage: sleep times...\n");
exit(1);
}
int sleep_time = atoi(argv[1]);
if(sleep(sleep_time) < 0)
printf("sleep failed.\n");
exit(0);
}

接下来把程序加入到Makefile里面,就要读一下Makefile看看怎么改其实是现查现学以及凭感觉猜测

其实最后还是看了这个才改好

下面来读一读Makefile,首先是指定一大坨目标文件,为了在后面用来制作内核。

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
-include conf/lab.mk

K=kernel
U=user

OBJS = \
$K/entry.o \
$K/start.o \
$K/console.o \
$K/printf.o \
$K/uart.o \
$K/kalloc.o \
$K/spinlock.o \
$K/string.o \
$K/main.o \
$K/vm.o \
$K/proc.o \
$K/swtch.o \
$K/trampoline.o \
$K/trap.o \
$K/syscall.o \
$K/sysproc.o \
$K/bio.o \
$K/fs.o \
$K/log.o \
$K/sleeplock.o \
$K/file.o \
$K/pipe.o \
$K/exec.o \
$K/sysfile.o \
$K/kernelvec.o \
$K/plic.o \
$K/virtio_disk.o \

ifeq ($(LAB),pgtbl)
OBJS += $K/vmcopyin.o
endif

我暂时也没搞明白这些目标文件为什么就凭空出现了

基本上就是分成用户部分和内核部分两块儿来编译构建。在conf/lab.mk里指定了一个LAB=util,用来在Makefile里识别这一次我们做的是哪个lab。

接下来检测工具链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Try to infer the correct TOOLPREFIX if not set
ifndef TOOLPREFIX
TOOLPREFIX := $(shell if riscv64-unknown-elf-objdump -i 2>&1 | grep 'elf64-big' >/dev/null 2>&1; \
then echo 'riscv64-unknown-elf-'; \
elif riscv64-linux-gnu-objdump -i 2>&1 | grep 'elf64-big' >/dev/null 2>&1; \
then echo 'riscv64-linux-gnu-'; \
elif riscv64-unknown-linux-gnu-objdump -i 2>&1 | grep 'elf64-big' >/dev/null 2>&1; \
then echo 'riscv64-unknown-linux-gnu-'; \
else echo "***" 1>&2; \
echo "*** Error: Couldn't find a riscv64 version of GCC/binutils." 1>&2; \
echo "*** To turn off this error, run 'gmake TOOLPREFIX= ...'." 1>&2; \
echo "***" 1>&2; exit 1; fi)
endif

QEMU = qemu-system-riscv64

CC = $(TOOLPREFIX)gcc
AS = $(TOOLPREFIX)gas
LD = $(TOOLPREFIX)ld
OBJCOPY = $(TOOLPREFIX)objcopy
OBJDUMP = $(TOOLPREFIX)objdump

粗略来看整个构建过程的话,跳过中间的一些FLAGS的设置,下一步指定了编译各个部分的规则。

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
LDFLAGS = -z max-page-size=4096

$K/kernel: $(OBJS) $K/kernel.ld $U/initcode
$(LD) $(LDFLAGS) -T $K/kernel.ld -o $K/kernel $(OBJS)
$(OBJDUMP) -S $K/kernel > $K/kernel.asm
$(OBJDUMP) -t $K/kernel | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $K/kernel.sym

$U/initcode: $U/initcode.S
$(CC) $(CFLAGS) -march=rv64g -nostdinc -I. -Ikernel -c $U/initcode.S -o $U/initcode.o
$(LD) $(LDFLAGS) -N -e start -Ttext 0 -o $U/initcode.out $U/initcode.o
$(OBJCOPY) -S -O binary $U/initcode.out $U/initcode
$(OBJDUMP) -S $U/initcode.o > $U/initcode.asm

tags: $(OBJS) _init
etags *.S *.c

ULIB = $U/ulib.o $U/usys.o $U/printf.o $U/umalloc.o

_%: %.o $(ULIB)
$(LD) $(LDFLAGS) -N -e main -Ttext 0 -o $@ $^
$(OBJDUMP) -S $@ > $*.asm
$(OBJDUMP) -t $@ | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $*.sym

$U/usys.S : $U/usys.pl
perl $U/usys.pl > $U/usys.S

$U/usys.o : $U/usys.S
$(CC) $(CFLAGS) -c -o $U/usys.o $U/usys.S

$U/_forktest: $U/forktest.o $(ULIB)
# forktest has less library code linked in - needs to be small
# in order to be able to max out the proc table.
$(LD) $(LDFLAGS) -N -e main -Ttext 0 -o $U/_forktest $U/forktest.o $U/ulib.o $U/usys.o
$(OBJDUMP) -S $U/_forktest > $U/forktest.asm

mkfs/mkfs: mkfs/mkfs.c $K/fs.h $K/param.h
gcc -Werror -Wall -I. -o mkfs/mkfs mkfs/mkfs.c


对于这次lab,要关注的就是这个部分

1
2
3
4
_%: %.o $(ULIB)
$(LD) $(LDFLAGS) -N -e main -Ttext 0 -o $@ $^
$(OBJDUMP) -S $@ > $*.asm
$(OBJDUMP) -t $@ | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $*.sym

百分号相当于是通配符,这其实就是生成可执行的程序文件,除此以外还反汇编了一下。

接下来就是指定用户部分的程序,U开头的变量表示的都是用户部分。

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\ #这一行原本是没有的,为了使得sleep.c能够被编译进xv6里面,我们要把这一行加上

按照上面的规则,这里加上$U/_sleep\就比较好理解了。

底下针对不同的lab还做了不同的处理,我们要关注的就是util的lab

1
2
3
4
5
UEXTRA=
ifeq ($(LAB),util)
UEXTRA += user/xargstest.sh
UEXTRA += user/sleep.c #这一行原本也是没有的,为了编译sleep.c我们要加上这行
endif

最后将所有用户文件制作成一个映像文件(虽然其实我也不懂这是干什么)

1
2
fs.img: mkfs/mkfs README $(UEXTRA) $(UPROGS)
mkfs/mkfs fs.img README $(UEXTRA) $(UPROGS)

运行make qemu时,就是下面的QEMUOPTS\text{QEMUOPTS}变量以及qemu目标的制作规则规定的事情。

1
2
3
4
5
6
QEMUOPTS = -machine virt -bios none -kernel $K/kernel -m 128M -smp $(CPUS) -nographic
QEMUOPTS += -drive file=fs.img,if=none,format=raw,id=x0
QEMUOPTS += -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0

qemu: $K/kernel fs.img
$(QEMU) $(QEMUOPTS) #按照这个规则,make qemu实际上就是运行了这么一条命令

也就是指定好运行qemu的选项,然后运行就行,这里的选项就把做好的内核和用户部分文件的映像都包括进去了。

关于系统调用的事情,此处先不多说。后面自然会有lab来搞清楚的

1.2. pingpong\text{1.2. pingpong}

这个任务主要是初步认识使用管道实现进程间的通信。写一个程序要它做这样的事情:

  1. fork一个子进程

  2. 父进程通过pipe向子进程发送一个字节

  3. 子进程收到字节,然后输出...: received ping...是子进程的进程id

  4. 子进程向父进程发送一个字节

  5. 父进程收到字节,然后输出...: received pong,同样的,...是子进程的进程id

在指导里提示了,可以使用getpid()获取当前进程的进程id。

稍微熟悉一下fork,pipe,readwrite系统调用就行,并不难。用fork的返回值区分父子进程,最后父进程记得wait一下子进程免得出现僵尸进程。

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
#include "kernel/types.h"
#include "user/user.h"

int main()
{
int p_pid = getpid();
int p[2];
if(pipe(p) < 0)
{
fprintf(2, "pingpong: failed to create pipe\n");
exit(1);
}
int pid = fork();
if(pid < 0)
{
fprintf(2, "pingpong: failed to fork\n");
exit(1);
}
if(pid != 0)//in the parent proc
{
char buf = 'a';
write(p[1], &buf, 1);
int state;
if(wait(&state) == pid)
{
if(read(p[0], &buf, 1) > 0)
printf("%d: received pong\n", p_pid);
exit(0);
}
else
{
fprintf(2, "pingpong: error when return from child proc\n");
exit(1);
}
}
else
{
int ch_pid = getpid();
char buf;
if(read(p[0], &buf, 1) > 0)
printf("%d: received ping\n", ch_pid);
write(p[1], &buf, 1);
exit(0);
}
}

1.3. primes\text{1.3. primes}

写一个并发的埃拉托斯特尼筛。

这个任务的难度标为moderate/hard......\text{moderate/hard......}实际上感觉没那么难,毕竟指导里已经给了个链接告诉了你应该是怎么做的。用父进程往管道里输入数,然后在子进程里不断读取,一旦不能整除就继续输入管道传给下一个进程。

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
61
62
63
64
65
66
#include "kernel/types.h"
#include "user/user.h"

int main()
{
int fd[2];
if(pipe(fd) < 0)
{
printf("prime: failed to create pipe\n");
exit(1);
}
int pid = fork();
if(pid < 0)
{
printf("prime: failed to fork\n");
exit(1);
}
if(pid)
{
close(fd[0]);
for(int i = 2; i <= 35; i++)
write(fd[1], &i, sizeof(int));
close(fd[1]);
int status;
while(wait(&status) > 0);
exit(0);
}
else
{
int p = -1, num;
int LP, RP[2];
LP = fd[0];
close(fd[1]);
RP[0] = RP[1] = -1;
while(read(LP, &num, sizeof(int)) == sizeof(int))
{
if(p < 0)
{
p = num;
printf("prime %d\n", p);
continue;
}
if(num % p)
{
if(RP[1] < 0)
{
pipe(RP);
int ch_pid = fork();
if(ch_pid)write(RP[1], &num, sizeof(int));
else
{
LP = RP[0];
close(RP[1]);
RP[0] = RP[1] = -1;
p = -1;
}
}
else write(RP[1], &num, sizeof(int));
}
}
if(RP[1] > 0)close(RP[1]);
int status;
while(wait(&status) > 0);
exit(0);
}
}

注意的是,管道中不用的读写端需要及时关闭,因为最后结束进程的关键是read返回0,这只有在对写入端的引用计数为0时才有可能。如果不关闭不用的写入端,引用计数永不为0,那么当管道为空时会阻塞程序而不是返回0。

写这个程序的时候犯了很多很弱智的错误比如说开头不知道怎么的写了个fork以及把程序名字写成了prime而不是primes病发编程

1.4. find\text{1.4. find}

编写find程序,在给定目录下根据文件名查找文件。

感觉难度确实不如前面的primes,许多文件相关操作以及字符串操作可以直接从ls.c\text{ls.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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#include "kernel/types.h"
#include "kernel/stat.h"
#include "kernel/fs.h"
#include "user/user.h"
char* fmtname(char *path)
{
static char buf[DIRSIZ+1];
char *p;
for(p=path+strlen(path); p >= path && *p != '/'; p--);
p++;
if(strlen(p) >= DIRSIZ)
return p;
int n = strlen(p);
memmove(buf, p, n);
buf[n] = 0;
return buf;
}
void find(char *path, char* target)
{
char buf[512], *p;
int fd;
struct dirent de;
struct stat st;
if((fd = open(path, 0)) < 0) return;
if(fstat(fd, &st) < 0)
{
close(fd);
return ;
}
if(strcmp(fmtname(path), target) == 0)
printf("%s\n", path);
if(st.type == T_DIR)
{
if(strlen(path) + 1 + DIRSIZ + 1 > sizeof(buf)){
printf("find: path too long\n");
return ;
}
while(read(fd, &de, sizeof(de)) == sizeof(de)){
if(de.inum == 0) continue;
if(strcmp(de.name, ".") == 0 || strcmp(de.name, "..") == 0) continue;
if(stat(buf, &st) < 0) continue;
strcpy(buf, path);
p = buf+strlen(buf);
*p++ = '/';
memmove(p, de.name, sizeof(de.name));
p[strlen(de.name)] = 0;
find(buf, target);
}
}
close(fd);
}
int main(int argc, char **argv)
{
if(argc < 3)
{
printf("Usage: find [direction] [file name]\n");
exit(0);
}
else
{
find(argv[1], argv[2]);
exit(0);
}
}

1.5. xargs\text{1.5. xargs}

这次要求写一个简单的xargs。启动xargs程序本身时会带有一堆参数,第一个参数是xargs需要启动的程序名称,后续的参数则是启动这个程序的参数,此外,对于每行输入的字符串,xargs都要将其附加在参数后面并启动子进程。这样的特性使得xargs和管道配合时有很好的效果。

比如说下面这个栗子:

1
echo "1\n2" | xargs echo hello

第一个echo程序输出了两行,输出为

1
2
1
2

那么xargs读取第一行的1,并且附加在自己已有的参数后面,也就是

echo hello 1

以此作为参数调用echoecho就会输出hello 1。同理,对于第二行输入,再次调用echo的结果是hello 2。因此最终输出为

1
2
hello 1
hello 2

理解了以后,代码就不难写了除了一如既往地在字符串细节和数组边界上翻车。唯一需要知道的新东西就是exec系统调用。

exec调用根据传入的参数启动新进程,并覆盖原来进程的上下文。因为会覆盖原来进程的上下文,所以我们需要fork一个子进程,在子进程里面使用exec,以免把父进程覆盖了。这样我们就能够在xargs进程里面启动其他的程序作为子进程。

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
61
62
63
64
#include "kernel/types.h"
#include "user/user.h"
#include "kernel/param.h"
#define BUFSIZE 512

int main(int argc, char **argv)
{
static char buf[BUFSIZE * MAXARG];
static char* Argv[MAXARG];
static int Argc = 0;
if(argc < 2)
{
printf("Usage: xargs [programme] [arguments]\n");
exit(0);
}
for(int i = 1; i < argc; i++)
{
Argv[Argc] = (char*)malloc(sizeof(char) * BUFSIZE);
strcpy(Argv[Argc++], argv[i]);
}
Argc--;
while(1)
{
gets(buf, BUFSIZE * MAXARG);
if(buf[0] == '\0' || buf[0] == '\n' || buf[0] == '\r') break;
int n = strlen(buf);
if(buf[n - 1] == '\n' || buf[n - 1] == '\r')buf[--n] = '\0';
int m = 0;
for(int i = 0; i < n; i++)
{
if(buf[i] == ' ')
{
Argv[Argc][m] = '\0';
m = 0;
}
else
{
if(!m)
{
Argc++;
if(!Argv[Argc])Argv[Argc] = (char*) malloc(sizeof(char) * BUFSIZE);
}
Argv[Argc][m++] = buf[i];
}
}
Argv[Argc][m++] = '\0';
Argc++;
int pid = fork();
if(pid == 0)
{
Argv[Argc] = 0;
exec(argv[1], Argv);
exit(0);
}
else
{
Argc = argc - 2;
m = 0;
int status;
wait(&status);
}
}
exit(0);
}

到此,就完成了所有必做lab\text{lab},正式走出了新手村。

2. 选做lab\text{2. 选做lab}

选做lab\text{lab}就是要你给shell\text{shell}增加更多基本功能…懒得管了。

总而言之,这次lab\text{lab}就是思维难度不是很大,但是细节有亿点多,不仔细一点容易去世…