终于开始这一套臭名昭著 好评如潮的lab了。因为B站上的大部分视频都是2020年的,而且从清单上看,2020年的lab比后面2021年和2022年的要多出一个网络驱动lab。所以这里配置环境和课程网站都是使用2020年的版本。
本次lab的指导
0. 配环境 \text{0. 配环境} 0. 配环境
0.1. 编译运行xv6 \text{0.1. 编译运行xv6} 0.1. 编译运行 xv6
我的系统是Arch Linux \text{Arch Linux} Arch Linux ,课程网站上对于Arch \text{Arch} Arch 配置环境只需一行命令就可以完成。当然,官方教程说得有多简单,出意外的可能性就有多大。
安装xv6 \text{xv6} xv6 ,不要安装原始的xv6 \text{xv6} xv6 ,要安装MIT为课程实验准备的改版,在指导里也有。代码仓库克隆下来,主分支如果是个空文件夹(实际上有些隐藏文件),这就对了。按照指导切换至util \text{util} util 分支,就出现了xv6 \text{xv6} xv6 的源码和一些必要的脚本(比如评分脚本)。
然后用make qemu
命令把操作系统跑起来,就出意外了。下面是我遇到的问题和解决方法。
编译sh.c时报错无穷递归 \text{编译sh.c时报错无穷递归} 编译 sh.c 时报错无穷递归
这个是正常的,因为xv6 \text{xv6} xv6 本来就没指望shell会返回。从网上搜了一圈,在函数定义前面加上下面这一行
1 __attribute__((noreturn ))
就好了
编译完成,但是启动xv6卡住 \text{编译完成,但是启动xv6卡住} 编译完成,但是启动 xv6 卡住
编译以后启动xv6 \text{xv6} 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} 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} Arch 下就没话说了。
查了一下安装的qemu版本,发现确实有点高,所以要降级。关于降级可以安装一个实用程序叫做downgrade \text{downgrade} downgrade
1 sudo pacman -S downgrade
然后使用downgrade \text{downgrade} downgrade 来安装qemu-arch-extra \text{qemu-arch-extra} qemu-arch-extra 的旧版本
1 sudo downgrade qemu-arch-extra
选择了5.2.0-4的版本,装好以后再次make qemu
这个问题就解决了。但是引入了另一个问题
缺共享库libbrlapi.so \text{缺共享库libbrlapi.so} 缺共享库 libbrlapi.so
make报错缺了共享库文件,这个就比较简单,直接装一个就行,如果缺的是其它的应该也是同理(但是我也遇到过这么解决不了的情况)。
1 sudo pacman -S libbrlapi.so
就好了。
0.2. 配置gdb调试 \text{0.2. 配置gdb调试} 0.2. 配置 gdb 调试
Guidance里简要说了如何调试,但是写得如此简略还是应该被打,跟没说一样。
参考了这里
照着坐下来没遇到什么大问题。具体原理应该是本地make qemu-gdb
开启一个tcp端口,然后用gdb连这个端口远程调试。
顺便一提,gdb的tui看起来真是人性化。
到此基本上就结束配环境了。
1. 必做lab \text{1. 必做lab} 1. 必做 lab
1.1. sleep \text{1.1. sleep} 1.1. sleep
要求是利用系统调用sleep
写一个sleep程序。
设定上讲,这是个最简单的lab,但是还是要花点时间熟悉操作,主要难倒不是难在写程序,写程序就十来行的事实在不知道的话照猫画虎抄mkdir.c就能知道怎么写 ,重点是初步理解整个xv6编译过程发生了什么。
user/user.h \text{user/user.h} 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.mkK=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 ifndef TOOLPREFIXTOOLPREFIX := $(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) $(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\
按照上面的规则,这里加上$U/_sleep\
就比较好理解了。
底下针对不同的lab还做了不同的处理,我们要关注的就是util的lab
1 2 3 4 5 UEXTRA= ifeq ($(LAB) ,util) UEXTRA += user/xargstest.sh UEXTRA += user/sleep.c endif
最后将所有用户文件制作成一个映像文件(虽然其实我也不懂这是干什么)
1 2 fs.img: mkfs/mkfs README $(UEXTRA) $(UPROGS) mkfs/mkfs fs.img README $(UEXTRA) $(UPROGS)
运行make qemu
时,就是下面的QEMUOPTS \text{QEMUOPTS} 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)
也就是指定好运行qemu的选项,然后运行就行,这里的选项就把做好的内核和用户部分文件的映像都包括进去了。
关于系统调用的事情,此处先不多说。后面自然会有lab来搞清楚的
1.2. pingpong \text{1.2. pingpong} 1.2. pingpong
这个任务主要是初步认识使用管道实现进程间的通信。写一个程序要它做这样的事情:
fork一个子进程
父进程通过pipe向子进程发送一个字节
子进程收到字节,然后输出...: received ping
,...
是子进程的进程id
子进程向父进程发送一个字节
父进程收到字节,然后输出...: received pong
,同样的,...
是子进程的进程id
在指导里提示了,可以使用getpid()
获取当前进程的进程id。
稍微熟悉一下fork
,pipe
,read
和write
系统调用就行,并不难。用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 ) { 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} 1.3. primes
写一个并发的埃拉托斯特尼筛。
这个任务的难度标为moderate/hard...... \text{moderate/hard......} 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} 1.4. find
编写find
程序,在给定目录下根据文件名查找文件。
感觉难度确实不如前面的primes
,许多文件相关操作以及字符串操作可以直接从ls.c \text{ls.c} 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} 1.5. xargs
这次要求写一个简单的xargs
。启动xargs
程序本身时会带有一堆参数,第一个参数是xargs
需要启动的程序名称,后续的参数则是启动这个程序的参数,此外,对于每行输入的字符串,xargs
都要将其附加在参数后面并启动子进程。这样的特性使得xargs
和管道配合时有很好的效果。
比如说下面这个栗子:
1 echo "1\n2" | xargs echo hello
第一个echo程序输出了两行,输出为
那么xargs
读取第一行的1
,并且附加在自己已有的参数后面,也就是
echo hello 1
以此作为参数调用echo
,echo
就会输出hello 1
。同理,对于第二行输入,再次调用echo
的结果是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} lab ,正式走出了新手村。
2. 选做lab \text{2. 选做lab} 2. 选做 lab
选做lab \text{lab} lab 就是要你给shell \text{shell} shell 增加更多基本功能…懒得管了。
总而言之,这次lab \text{lab} lab 就是思维难度不是很大,但是细节有亿点多,不仔细一点容易去世…