Lab6 挑战性任务

任务目标

在 Lab6 的后半程,我们在 MOS 上实现了一个基本的外部指令 Shell,其能够通过不断创建 sh.c 的进程,并调用其他文件来处理用户指令。在 Lab6 的挑战任务中,我们要在这基础上对 Shell 进行迭代开发,使其能完成更丰富的服务要求。

实现一行多命令

; 分开同一行内的两条命令,表示依次执行前后两条命令。; 左右的命令都可以为空。

在 Linux 的控制台中也支持这样的指令,例如在控制台输入如下指令会有对应的现象:

image-20230607102927426

image-20230607102937379

可以看到先实现了 clear 操作,后进行了 echo 操作。

我们要求,用 ; 隔开的两条指令必须有执行的先后顺序,即先执行左侧指令,后执行右侧指令。利用 gettoken 中自支持的 ;parsecmd 中新添加判断分支即可实现对 ; 的识别。

由于执行两条指令,并保证指令执行的先后顺序,则需要先 fork 进程并执行左侧指令,使用 wait 等待其执行完后再继续解析右侧指令(重复 parsecmd 即可)

int parsecmd(char **argv, int *rightpipe) {
// ...
int c = gettoken(0, &t);
switch (c) {
// ...
case ';':
if ((*rightpipe = fork()) == 0) {
return argc; // parse end
} else {
debugf("parsed ';', created %x\n", *rightpipe);
wait(*rightpipe);

close(0);close(1);
dup(opencons(), 1);dup(1,0);

return parsecmd(argv, rightpipe);
}
}
// ...
  • upd 23.6.16:这里需要处理重定向的问题,以防左侧指令修改了输出 fd ,但右侧指令的 fd 不仅没有恢复为控制台,修改的 fd 还被左侧指令关了的情况。所以需要调 opencons 把控制台重新开开,并且 dup 给两个 fd。

测试用例

为了体现 shell 的两条指令是分开执行的,这里采用不会实时结束的 cat.b 作为左端指令.

当运行 cat.b 并输入 Ctrl + D 时,应该继续右侧指令的解析与执行。现试输入 cat.b ; ls.b 和不包含单侧指令的数据测试功能:

$ cat.b ; ls.b
parsed ';', created 3004 // 识别到 ';',并创建新 Shell 3004
llaabb66CChhaalllleennggee // cat.b 执行内容,使用 Ctrl + D 结束
[00003805] destroying 00003805 // cat.b 执行结束,终止其进程 3805
[00003805] free env 00003805
i am killed ...
[00003004] destroying 00003004 // 子 Shell 执行完毕,终止其进程 3004
[00003004] free env 00003004
i am killed ...
aaa.txt testarg.b cat.b pingpong.b testbss.b newmotd testpiperace.b testpipe.b motd init.b num.b lorem testfdsharing.b testshell.sh script ls.b echo.b sh.b halt.b testptelibrary.b // ls.b 执行内容
[00004004] destroying 00004004 // ls.b 执行结束,终止其进程 4004
[00004004] free env 00004004
i am killed ...
[00002803] destroying 00002803 // 原 Shell 执行完毕,终止其进程 2803
[00002803] free env 00002803
i am killed ...
$ cat.b;
parsed ';', created 5004
ssssssssss
[00005805] destroying 00005805
[00005805] free env 00005805
i am killed ...
[00005004] destroying 00005004
[00005004] free env 00005004
i am killed ...
[00004803] destroying 00004803 // 原有 Shell 无命令处理,直接终止进程 4803
[00004803] free env 00004803
i am killed ...

$ ;cat.b
parsed ';', created 6804
[00006804] destroying 00006804 // 子 Shell 无命令处理,直接终止进程 6804
[00006804] free env 00006804
i am killed ...
aaaaaa
[00007004] destroying 00007004
[00007004] free env 00007004
i am killed ...
[00006003] destroying 00006003
[00006003] free env 00006003
i am killed ...

测试中 Shell 均能正常回显,可认为功能实现。

实现后台任务

& 分开同一行内的两条命令,表示同时执行前后两条命令。& 左侧的命令应被置于后台执行,Shell 只等待 & 右侧的命令执行完毕,然后继续执行后续语句,此时用户可以输入新的命令,并且可能同时观察到后台任务的输出。

左侧命令不可为空

; 类似,& 也要求实现两条指令的运行,但是要求其同时运行,只等待右侧指令。处理时同样要修改 parsecmdswitch,在这里要额外注意不要用 rightpipe 承载 fork 的返回值了。因为在 runcmd 中需要等待 rightpipe 执行结束才能完成,而 Shell 应该只等待右侧命令的执行。

switch (c) {
// ...
case '&':
if ((r = fork()) == 0) {
return argc; // parse end
} else {
dup(opencons(), 1);dup(1,0);
debugf("parsed '&', created %x\n", r);
return parsecmd(argv, rightpipe);
}
}

case '<':
if (gettoken(0, &t) != 'w') {
debugf("syntax error: < not followed by word\n");
exit();
}
// Open 't' for reading, dup it onto fd 0, and then close the original fd.
/* Exercise 6.5: Your code here. (1/3) */
if ((r = open(t, O_RDONLY)) < 0) {
user_panic("redirction_1: open file in shell failed!");
}
fd = r;
dup(fd, 0);
close(fd);

不知道怎么测试合适,就先不测了(

实现引号支持

实现引号支持后,shell 可以处理如: echo.b "ls.b | cat.b" 这样的命令。即 shell 在解析时,会将双引号内的内容看作单个字符串,将 ls.b | cat.b 作为一个参数传递给 echo.b

实质上我们要把引号中的这些内容当作一个 w 类型处理,所以与其修改 parsecmd 的逻辑,不如直接从 _gettoken 入手,直接把引号处理成一个内容再返回

// user/sh.c -> _gettoken

if (*s == '\"') {
debugf("parsed '\"': begin\n");
s++;
*p1 = s;
debugf("parsed: ");
while (*s && *(s++) != '\"') {
debugf("%c", *(s - 1));
}
*(s - 1) = 0;
*p2 = s;
debugf("\nparsed '\"': end\n");
return 'w';
}

整体实现相对简单,可以参照前后的解析方式,在面向对象第一单元的解析器也是一个道理(划

需要注意的就是 p1p2 两个指针的定位, p1 应该指向 token 的起始字符,而 p2 应该指向 token 结束后的下一个字符;还有就是引号这个符号是要去掉的,也不要忘记用 0 将引号内的部分截断。

测试用例

测试主要看能否把引号内包含的 SYMBOL 正确解析为字符即可

$ echo.b "sh.b | cat.b"
parsed '"': begin
parsed: sh.b | cat.b
parsed '"': end
sh.b | cat.b
[00003004] destroying 00003004
[00003004] free env 00003004
i am killed ...
[00002803] destroying 00002803
[00002803] free env 00002803
i am killed ...

实现键入命令时任意位置的修改

现有的 shell 不支持在输入命令时移动光标。你需要实现:键入命令时,可以使用 Left 和 Right 移动光标位置,并可以在当前光标位置进行字符的增加与删除。要求每次在不同位置键入后,可以完整回显修改后的命令,并且键入回车后可以正常运行修改后的命令。

在 MOS 的 Shell 中,我们设计了 readline 函数处理指令的输入,它实际上利用了一个控制台,并从中逐个读取字符,从而解析读入的字符串。设计的核心,读入实际上一次只向目标缓冲区 buf 中读取一个字符:

for (int i = 0; i < n; i++) {
if ((r = read(0, buf + i, 1)) != 1) {
// ...

因为 readline 会直接向表示指令的 buf 中直接写入内容,当我们想要删除时可能会稍显麻烦,于是我们采用一个临时的 char 保存每次 read 获得的字符,并根据其值判断下一步该如何处理。由于在判断应该如何回显时需要注意光标的位置,所以我们用一个变量 i 代表光标所在下标,用变量 len 代表已经读入 buf 的总长度。在此基础上修改 readline 函数使其支持原生功能应该不是难事,不再赘述。

首先我们来处理键入光标时的左右移动

在终端中,ANSI 标准声明左右方向键分别为 \033[D\033[C,也就是说,在向控制台输入 “←” 时,实际上会解析成三个字符,即 \033[D 。那么我们就对暂时读入的字符(称为 temp)进行判断,如果是 \033,就进入方向键的判断,连续获取到左、右键代表的三个字符后,才能令光标变量做出对应的修改。

还有一点需要注意:光标在 0 字符时的 ← 与 光标在末端字符的 → 需要考虑其处理方式,首先不能修改光标变量的值了,否则会造成越界;其次可以让控制台显示的光标停在原地,这样就不会跑到左边的 $ 字符那里了(

case '\033':
read(0, &temp, 1);
if (temp == '[') {
read(0, &temp, 1);
if (temp == 'D') { // get input ←
if (i > 0) { // have space for cursor to move left
i -= 1;
} else {
printf("\033[C"); // print a reverse arrow to pull back the cursor
}
} else if (temp == 'C') { // get input →
if (i < len) { // have space for cursor to move right
i += 1;
} else {
printf("\033[D");
}
}
}
break;

随后处理移动后的插入与删除。实际上我们实现的核心在这里就变成了字符串的操作,还有就是如何在终端上覆盖旧字符串,显示正确的新字符串。

注意到, BackSpace 键的 ASCII 码为 127 (0x7f),而在原程序的循环中已经为我们判断了 buf[i] == 0x7f 的分支判断,我们需要先删掉它然后新开一个 case

类似的,Delete 键在读入时和 ~ 键的 ASCII 相同,所以也要做响应的处理,注意退格位置不同

case 0x7f:
if (i <= 0) { break; } // cursor at left bottom, ignore backspace

for (int j = (--i); j <= len - 1; j++) { // move chars already in buf
buf[j] = buf[j + 1];
}
buf[--len] = 0; // cut the last char
printf("\033[%dD%s \033[%dD", (i + 1), buf, (len - i + 1));
break;

case '~':
if (i == len) { break; }

for (int j = i; j <= len - 1; j++) {
buf[j] = buf[j + 1];
}
buf[--len] = 0;
if (i != 0) {
printf("\033[%dD%s \033[%dD", i, buf, (len - i + 1));
} else {
printf("%s \033[%dD", buf, (len - i + 1));
}
break;

最后的 printf 用处是覆盖原有的字符串,再把新的字符串打印上去,打印后再控制光标的位置。这个操作可以分成三部分:

  • \033[%dD:终端的光标向左移动 i + 1 个字符
  • %s<space>:把新字符串从光标所在处打印出来,追加一个空格(因为删除后字符串变短,需要覆盖掉多出来的一个字符)
  • \033[%dD:终端的光标向左移动 len - i + 1 个字符

至于为什么移动的字符是这些值,可以拿笔试一试,主要还是为了保证光标的位置不发生改变

测试用例

测试过程中发现如果网络不太稳定,就会输出 [D 之类的字符,不太理解原因,初步推断可能是因为传输字符速度慢,被解析成了分开的三个字符进行输出。

$ ha2Dhal3Dha[2Dh[1Djhalt
spawn jhalt: -10
[00002803] destroying 00002803
[00002803] free env 00002803
i am killed ...

实现程序名称中 .b 的省略

目前的用户程序被烧录到文件系统中后,其可执行文件以 .b 为后缀,为 shell 中命令的输入带来了不便。你需要修改现有的实现,以允许命令中的程序名称省略 .b 后缀,例如当用户指定的程序路径不存在时,尝试在路径后追加 .b 再打开。

比较简单的一个小功能。

在 Shell 中具体运行外部命令的原理是创建一个子进程,加载命令对应的 ELF 文件,传递相应参数,然后 Shell 等待子进程上加载的程序运行结束后再循环执行。在创建子进程时,我们用到的是在 runcmd 中使用的 spawn,其参数为 argv[0]。我们只需要在打开 argv[0] 失败后再尝试打开追加 .b 后的 argv[0] 即可。如果仍然无法打开,说明指令名错误。

int child;
if ((child = spawn(argv[0], argv)) < 0) {
char name[1024] = {0};
int len = strlen(argv[0]);
strcpy(name, (const char *) argv[0]);
name[len] = '.';
name[len + 1] = 'b';
name[len + 2] = '\0'; // add '.b' to old filename
child = spawn(name, argv);
}

测试用例

尝试省略文件中的 .b 后进行调用,效果如下(已省略进程销毁输出)

$ echo.b aaa
aaa
$ echo aaa
aaa
$ ls
aaa.txt testarg.b cat.b pingpong.b testbss.b newmotd testpiperace.b testpipe.b motd init.b num.b lorem testfdsharing.b testshell.sh script ls.b echo.b sh.b halt.b testptelibrary.b

实现更丰富的命令

参考实验环境中的 Linux 命令 treemkdirtouch 来实现这三个命令,请尽可能地实现其完整的功能。

tree 命令

tree 命令,用于输出指定路径的文件树,使用字符码进行树状表示的生成。

本次实现的 tree 命令包括一个参数的实现:

-d:只输出目录文件,省略非目录的输出

输出指定目录的文件树,可以分成以下几部分:

  • 打开指定目录的文件控制块
  • 遍历目录中的每个文件,并进行输出
  • 递归地对每个目录重复第二步,直至不存在子目录

在这里需要注意输出的形式,由于需要保持缩进,于是我们在递归的过程中需要保留文件的深度;又由于目录中最后一个文件需要输出 └── 而不是 ├── ,所以需要判断当前输出的文件是不是目录中的最后一个文件。同时需要注意输出缩进时的输出格式,如果不是本目录最后一个文件,中途的文件夹也应该输出 ,否则会出现以下的状况:

[2000] /testdir $ tree
./
├── a
├── b.c
├── a.c
├── testa
├── testb
└── a.c
└── test.c
└── testb

很显然 testa 文件夹中输出时没有输出最开头的 ,应该判断是不是最后一个文件,并且补上

一个更复杂的情况如下,不过这次的输出是正确的:

[2000] /testa $ tree
./
├── a
│ └── a
├── b
│ ├── a
│ ├── b
│ │ └── c.c
│ └── c.c
└── c
├── c.c
└── a
└── c.c
  • 注意 a b 两文件夹下的内容输出时,必须在第一个字符输出 ,而 c 文件夹下内容则相反
  • 内层文件夹也要递归地遵守这个规则

输出使用到的函数如下:

void printFile(char *name, int depth, int pos, int isDir) {
// 输出预留的缩进
for (int i = 0 ; i < depth; i++) {
if (poss[i] == 0) { printf("│ "); } else { printf(" "); }
}

// pos = 1 即最后一个文件
if (pos == 0) { printf("├── "); } else { printf("└── "); }

if (isDir == 1) { // 目录文件会输出为蓝色
printf("\033[0;34m%s\033[0m\n", name);
} else {
printf("%s\n", name);
}
}

递归函数如下:

void dfsFile(char *path, int depth) {
int fdnum, size, va, j, len;
struct Fd *fd;

if ((fdnum = open(path, O_RDONLY)) < 0) { user_panic("open %s: %d", path, fdnum); }
fd = (struct Fd *) num2fd(fdnum);

if (((struct Filefd *) fd)->f_file.f_type != FTYPE_DIR) {
fileCount++; // 对文件计数,并返回(不存在子目录)
return;
} else {
dircCount++; // 对目录计数,准备输出其内的所有文件
}

size = ((struct Filefd *)fd)->f_file.f_size;
va = (int) fd2data(fd);
// 遍历目录中的每个文件(文件控制块)
for (int i = 0; i < size; i += BY2FILE) {
struct File *file = (struct File *) (va + i);
if (file->f_name[0] == 0) { break; }

// 获得完整的路径名,为递归调用预留
char fullPath[MAXPATHLEN] = {0};
strcpy(fullPath, path);
fullPath[strlen(fullPath) + 1] = '\0';
fullPath[strlen(fullPath)] = '/';
len = strlen(fullPath);
for (j = 0; j < strlen(file->f_name); j++) {
fullPath[len + j] = file->f_name[j];
}
fullPath[len + j] = '\0';

// 判断是否达到目录末尾,控制输出中的 'pos' 变量
int pos = (i == size || (file + 1)->f_name[0] == 0) ? 1 : 0;
if (directory != 1 || file->f_type == FTYPE_DIR) {
poss[depth] = pos; // 把当前层是否为最后一个文件的状态保存,输出时使用
printFile(file->f_name, depth, pos, (file->f_type == FTYPE_DIR));
}
dfsFile(fullPath, depth + 1); // 递归
// printf("%s\n", fullPath);
}
}
  • 需要注意的是判断到达目录末尾的方式,一个是遍历到了目录的最后一个控制块,或者是下一个控制块名称为空(名称为空意味着文件不存在)

顶层的调用函数:

void tree(char *path) {
int r;
struct Stat st;

// 判断输入文件类型
if ((r = stat(path, &st)) < 0) { user_panic("stat %s: %d", path, r); }

if (!st.st_isdir) { user_panic("%s is not a directory!", path); }

// 开始递归
printf("%s\n", path);
dfsFile(path, 0);

// 最后需要对输出进行计数
if (directory == 1) {
printf("\n%d directories\n", dircCount);
} else {
printf("\n%d directories, %d files\n", dircCount, fileCount);
}
}

最后文件的 main 函数可以封装如下:

int main(int argc, char **argv) {

// 解析参数
ARGBEGIN {
case 'd':
directory = 1;
break;
default:
usage();
break;
} ARGEND
if (argc == 0) { // 默认为根目录
tree("/");
} else { // 选定指定目录
tree(argv[0]);
}
return 0;
}

完成 tree.c 后,需要回到 user 目录中修改 include.mk,在 USERAPP 中把 tree.b 加进去,这样刚写完的文件就会编译并烧录进磁盘了,这样就可以使用了

mkdir & touch 命令

其实两个命令实现的功能类似,唯一差异在创建文件后的文件类型 f_typemkdir 需要 FTYPE_DIRtouch 则是 FTYPE_REG

内核中其实已经在 fs/fs.c 中预留好了创建文件的函数,但是没有向用户态提供接口。我们可以通过新增 fsipc 类型从而让文件服务函数调用这个接口,从而实现文件的创建

Lab5 Probe 中已经详细介绍过创建 fsipc_* 的过程了,这里就只写代码了

// user/include/fsreq.h

#define FSREQ_CREATE 8

struct Fsreq_create {
char req_path[MAXPATHLEN];
u_int f_type;
};
// user/lib/fsipc.c

int fsipc_create(const char *path, u_int f_type, struct Fd* fd) {
u_int perm;
struct Fsreq_create *req;

req = (struct Fsreq_create *)fsipcbuf;

// The path is too long.
if (strlen(path) >= MAXPATHLEN) {
return -E_BAD_PATH;
}

strcpy((char *)req->req_path, path);
req->f_type = f_type;
return fsipc(FSREQ_CREATE, req, fd, &perm);
}
// fs/serv.c

switch (req) {
case FSREQ_CREATE:
serve_create(whom, (struct Fsreq_create *)REQVA);
break;
}

void serve_create(u_int envid, struct Fsreq_create *rq) {
struct File *f;
int r;
if ((r = file_create(rq->req_path, &f)) < 0) {
ipc_send(envid, r, 0, 0);
return;
}
// touch 和 mkdir 的区别仅限于此,所以只需要控制这个值的传递就能实现两个函数
f->f_type = rq->f_type;
ipc_send(envid, 0, 0, 0);
}
// fs/serv.h

int file_create(char *path, struct File **pfile);

// user/include/lib.h

int fsipc_create(const char *, u_int, struct Fd *);

以上便创建了创建文件的 fsipc 请求。接下来两个函数实际上是对这个请求的调用

// Unimplemented open modes
#define O_CREAT 0x0100 /* create if nonexistent */

我们利用上面这个定义,对 open 函数做一些改动,使得当传入函数的 omode 包含 O_CREAT 时,就会触发文件创建的过程

// user/lib/file.c

// Step 2: Prepare the 'fd' using 'fsipc_open' in fsipc.c.
/* Exercise 5.9: Your code here. (2/5) */
if ((mode & O_CREAT) == 0) { // 如果不包含 O_CREAT 走正常的路线,不进行变动
if ((r = fsipc_open(path, mode, fd)) != 0) { return r; }
} else { // mkdir & touch
mode &= ~O_CREAT;
// 如果打开文件失败就进行文件创建,反之则报错(已存在文件,不能再创建)
if ((r = fsipc_open(path, mode, fd)) != 0) {
return fsipc_create(path, mode, fd); // mode = f_type
} else {
return 1; // already exist.
}
}
  • 当进入 else 分支时,此时的 mode 就不再是代表文件打开的方式了,它代表的是创建文件的类型,我们在这里不修改 open 的参数数量,而通过 mode 这个参数进行额外信息的传递

最后顶层封装两个功能函数,加入相关的函数声明,并在对应的文件的 main 方法中调用即可

// user/lib/file.c (实现在哪里其实不影响)

int mkdir(const char *path) {
int r;
if ((r = open(path, O_CREAT | FTYPE_DIR)) > 0) {
user_panic("mkdir: path %s already exist!\n", path);
}
if (r < 0) {
user_panic("mkdir %s: %d\n", path, r);
}
return r;
}

修改 Shell 中重定向符的实现

在 Linux 的 Shell 中,我们可以通过重定向符 > 将输出重定向到文件中,我们的 MOS 也可以实现类似的操作,但是不能创建新文件并进行输入,我们在这里对 sh.c 文件进行修改,使其能实现此功能

实现要点就是在原有要打开文件的地方进行判断,如果打开失败了就追加一句创建文件的函数

注意要再调用一次 open 以传递新创建文件的文件管理符

case '>':
if (gettoken(0, &t) != 'w') {
debugf("syntax error: > not followed by word\n");
exit();
}
// Open 't' for writing, dup it onto fd 1, and then close the original fd.
/* Exercise 6.5: Your code here. (2/3) */
if ((r = open(t, O_WRONLY)) < 0) {
if ((r = touch(t)) < 0) {
user_panic("redirction_2: create file in shell failed!");
} else {
if ((r = open(t, O_WRONLY)) < 0) {
user_panic("redirction_2: open file in shell failed!");
}
}
}
fd = r;
dup(fd, 1);
close(fd);

// user_panic("> redirection not implemented");

break;

测试用例

三个函数的功能可以相互验证:先通过 touchmkdir 创建新的文件/目录,再调用 tree 对这些文件的存在和位置进行检查,如果 tree 能检查并正确输出,说明文件创建和文件树的功能都是正常的。下面的输出隐藏了进程销毁信息

  • 本处的功能测试已经完成了相对目录的实现,故输入使用了相对目录
[2000] /testdir $ tree
./
└── a

1 directories, 1 files
[2000] /testdir $ touch b.c
created file: b.c

[2000] /testdir $ touch a.c
created file: a.c

[2000] /testdir $ mkdir testa
created path: /testdir/testa

[2000] /testdir $ mkdir testa/testb
created path: /testdir/testa/testb

[2000] /testdir $ touch testa/testb/a.c
created file: testa/testb/a.c

[2000] /testdir $ touch testa/test.c
created file: testa/test.c

[2000] /testdir $ tree
./
├── a
├── b.c
├── a.c
└── testa
├── testb
│ └── a.c
└── test.c

3 directories, 5 files

实现历史命令功能

在 Linux 的 shell 中我们输入的命令都会被保存起来,并可以通过 Up 和 Down 键回溯,这为我们的 shell 操作带来了极大的方便。在此项任务中,需要实现保存所有输入至 shell 的命令,并可以通过 history.b 命令输出所有的历史命令,以及通过上下键回溯命令并运行。

实现这个功能的要点如下:

  • 首先需要在 sh.c 中调用 touch 函数生成 .history 文件
  • 将每条解析的命令都输入进 .history 文件中,这里需要额外实现文件的追加写入
  • 在输入中实现对 Up/Down 键的识别,并回显对应的指令
  • 实现 history.chistory 功能,读取 .history 文件,显示全部历史命令

.history 文件的生成

当我们在解析指令结束后,应该对指令进行保存,如果是第一次解析,则需要额外创建一个存放历史指令的 .history 文件。在这里我们使用一个变量 int hisCount = 0 表示已经处理过的历史指令数,当历史为空则进行文件的创建。

与光标的左右移动类似,我们需要一个表示当前指令所处行数的变量 int curLine = 0,用以指明当前输入显示的行数

文件的追加输入模式 - O_APPEND

在 MOS 系统中,我们只实现了从头打开文件,即 f_offset = 0 的打开方式,在这里为了便于我们对 .history 文件的输入,试实现文件的追加输入模式:O_APPEND

由于 O_APPEND 只是指定了文件打开时的偏移指针位置,原则上我们仍需要控制文件的打开方式。为了不遮盖原本的打开方式,可以把 O_APPEND 的控制位放大一点,以实现应有的功能。

// user/include/lib.h

#define O_APPEND 0X00002000 /* open file and redirect cursor to the last char */

设置好后,接下来需要修改 open 函数以识别 O_APPEND,并尝试在文件服务进程中的 serve_open 函数中实现对偏移指针的定位,这样我们就不需要再更改 open 函数的逻辑了。

// fs/serv.c -> serve_open

o->o_mode = rq->req_omode;
ff->f_fd.fd_omode = o->o_mode;
ff->f_fd.fd_dev_id = devfile.dev_id;

// add here for O_APPEND
if (o->o_mode & O_APPEND) {
struct Fd *fff = (struct Fd *) ff;
fff->fd_offset = f->f_size; // redirect the file pointer
}

回到 sh.c,完成写入历史命令和回显的功能。为了便于快速找到对应的指令数,我们事先实现一个数组 int hisBuf,其内存放第 $i$ 条指令所占的字节数,便于我们在 .history 中快速找到指定的行

// user/sh.c

static int hisCount, curLine;
static int hisBuf[1024];

然后在 sh.c 内实现一个快速读取第 target 行指令的函数,将其存入 code 指向的空间

int readPast(int target, char *code) {
int r, fd, spot = 0;
char buff[10240];
if ((fd = open("/.history", O_RDONLY)) < 0) { printf("G1");return fd; }
for (int i = 0; i < target; i++) {
spot += (hisBuf[i] + 1); // + '\n'
}
if ((r = readn(fd, buff, spot)) != spot) { printf("G2");return r; }
if ((r = readn(fd, code, hisBuf[target])) != hisBuf[target]) { printf("G3");return r; }
if ((r = close(fd)) < 0) { printf("G4");return r; }

code[hisBuf[target]] = '\0';
return 0;
}
  • 先读取 0 ~ target - 1条指令,并丢弃,然后读取第 target 条指令,保存在传入的参数 code 内。读取前面指令时需要根据预设的 hisBuf 控制读取的字符数,需要注意每一条语句后面都存放一个 \n 用以区分,所以需要多读一个字符。

历史指令的写入

相对简单的一部分。

当 Shell 检测到 换行符时,便会判断指令输入的结束,从而开始解析,我们就从这里开始写入历史命令。

readline 函数的 switch 分支内,针对 case '\r'case '\n' 需要做写入文件的操作:

case '\r':
case '\n':
buf[len] = '\0';
// printf("hisCount: %d\n", hisCount);
if (hisCount == 0) {
if ((r = touch("/.history")) != 0) { exit(); }
}
int hisFd;
if ((hisFd = open("/.history", O_APPEND | O_WRONLY)) < 0) { exit(); }
if ((r = write(hisFd, buf, len)) != len) { exit(); }
if ((r = write(hisFd, "\n", 1)) != 1) { exit(); }
if ((r = close(hisFd)) < 0) { exit(); }
hisBuf[hisCount++] = len;
curLine = hisCount;
// cannot 'curLine++', otherwise usable instrctions will be [0, curLine + 1]
memset(curIn, '\0', sizeof(curIn));
return;
  • 这里要在每一条指令写入后追加一个和常规指令有区分的字符,为了方便这里就选了 \n,原因可以在 history 功能实现时再看。再注意写入时使用刚刚写好的(追加 + 只写)即可。
  • 当按下回车时,不能简单地让 curLine++ ,因为可能当前 curLine 并不在最底端,此时错误的自增操作会让 curLine 的值出错

up/down 键的识别 & 指令回显

最后修改 readline 的逻辑,需要在键入方向键的分支处继续判断。同时为了能够恢复当前已经输入的字符,我们把已输入的字符也存入一个缓冲的字符数组 char curIn 内,在按下 down 键时视情况回显。

每次回显,都需要实时变动 buf 的内容,也包括显示的光标位置。因为我们可能会在此基础上修改 buf,或直接运行。

// user/sh.c -> readline

case '\033':
read(0, &temp, 1);
if (temp == '[') {
read(0, &temp, 1);
if (temp == 'D') { // have space for left

} else if (temp == 'C') {

} else if (temp == 'A') { // up
printf("\033[B"); // 恢复光标位置
if (curLine != 0) { // 处在第一行时应忽略 up 的输入
buf[len] = '\0';
if (curLine == hisCount) { // 显示的是正在输入的字符
strcpy(curIn, buf); // 暂时保存在 curIn 中,暂时视作一条指令
}

// 使用 <space> + 光标移动,清空当前行
if (i != 0) { printf("\033[%dD", i); }
for (int j = 0; j < len; j++) { printf(" "); }
if (len != 0) { printf("\033[%dD", len); }

// 读入指定的历史指令并输出,重定位光标
if ((r = readPast(--curLine, buf)) < 0) { printf("G");exit(); }
printf("%s", buf);
i = strlen(buf);
len = i; // redirect cursor
}
} else if (temp == 'B') { // 同理
buf[len] = '\0';
if (i != 0) { printf("\033[%dD", i); }
for (int j = 0; j < len; j++) { printf(" "); }
if (len != 0) { printf("\033[%dD", len); }
if (1 + curLine < hisCount) { // 注意这里的判断
if ((r = readPast(++curLine, buf)) < 0) { printf("G");exit(); }
} else {
strcpy(buf, curIn);
curLine = hisCount;
}
printf("%s", buf);
i = strlen(buf);
len = i;
// redirect cursor
}
}
break;
  • 这部分比较重要的就是边界情况的处理和写入后光标的定位。
    • 输入 up 键,需要把光标向下移动一行(固定,不要乱跑),使 curLine--,如果 curLine == 0 (第一条指令)就不再响应
    • 输入 down 键,光标不需移动,curLine++,如果 curLine + 1 = hisCount (即马上要从 .history 的最后一行换成最开始预存的缓冲输入时)需要特别处理;若 curLine == hisCount 则不再响应
    • 无论输入 up/down 键,只要重新回显了字符,就需要借助 printf("\033[A"); 等方法实现的光标移动和打印空格把当前行原本的内容清空后再输出

history.b 功能的实现

还是一样的,别忘了把 history.b 加入 include.mk,让程序进行编译并烧录磁盘

// user/lib/history.c

int main(int argc, char **argv) {

if (argc != 1) {
usage();
exit();
}
printf("history instruction:\n\n");
int fd, r, line = 1;
char temp[1], print;
if ((fd = open("/.history", O_RDONLY)) < 0) {
user_panic("history: %d", fd);
}
if ((r = read(fd, &temp, 1)) != 1) {
printf("no history instruction.\n");
exit();
}
print = temp[0];
printf(" %4d : ", line);
while ((r = read(fd, &temp, 1)) == 1) {
printf("%c", print);
if (print == '\n') {
printf(" %4d : ", ++line);
}
print = temp[0];
}
printf("\n\ntotal instruction: %d\nhistory finished.\n\n", line);
return 0;
}

实际上就是对文件的打开、读取、判断和输出。而判断是否为一条指令的标准就是 \n,这也就是在前面写入指令时要加入一个分隔符的理由。我们根据 \n 编排输出的方式,从而实现历史命令的输出。

测试用例

上下键指令回显

先在 Shell 中输入几条指令,随后输入半条还没有执行的指令,然后连续按上下键观察变化

[2000] /testdir $ input halfway
[2000] /testdir $ tree // press ↑
[2000] /testdir $ touch a // press ↑
[2000] /testdir $ tree // press ↓
[2000] /testdir $ input halfway // press ↓
  • 并且可以随时修改回显的任意一条指令,并随时按下回车输出

history 指令功能

在上一测试的基础上直接输入 history 观察输出。

[2000] /testdir $ history
history instruction:

1 : mkdir testdir
2 : cd testdir
3 : touch a
4 : tree
5 : input half|cut|way
6 : history

total instruction: 6
history finished.

[00007804] destroying 00007804

检测到 .history 文件的内容可以正常写入、读出,并且 history 指令功能也正常实现

选做部分 2:支持相对路径

MOS 中现有的文件系统操作并不支持相对路径,对于一切路径都从根目录开始查找,因此在 shell 命令中也需要用绝对路径指代文件,这为命令的描述带来了不便。

现在,我们需要在 MOS 中支持相对路径的输入与解析,并且当前工作路径的保存是进程级别的,也就是说不同进程的工作目录可能不同。

首先我们要求:只有以 / 开头的目录才会被识别为绝对路径,此外的所有非 / 开头路径(包括 ./ )都会被识别为相对路径并进行识别与处理。

需要完成的工作有以下几点:

  • 在内核态中为进程维护一个表示当前工作目录的字符数组 char r_path
  • 通过系统调用向用户态提供更改 r_path 的接口,实现用户调用函数 chdir()getcwd()
  • 更改 sys_exofork 逻辑,使其能令子进程继承父进程的工作目录
  • 修改 sh.c 实现内部命令 cd外部命令 pwd
  • 修改文件操作函数(Shell 已有命令能调用的只有 open 函数),识别并提供相对路径的功能支持
  • 更改已实现的 Shell 命令对文件的操作
  • 优化 Shell 输出界面

维护工作目录数组

为了便于管理与复制,同时能够体现不同进程目录不同的特点,这里直接将字符数组放置在进程控制块中,当然在内核态中开一个大二维数组也是可行的,比修改进程控制块更安全。

// include/env.h

struct Env {
// ...
char r_path[256];
};

在创建进程时,也需要在创建进程的函数中初始化进程的目录为 / 根目录

系统调用设置接口 & 实现用户调用函数

为了便于用户态获取/修改当前进程所处的工作目录,我们添加两个系统调用为用户态提供接口。

// include/syscall.h

enum {
// ...
SYS_get_rpath,
SYS_set_rpath,
MAX_SYSNO,
};


// kern/syscall_all.c

int sys_set_rpath(char *newPath) {
if (strlen(newPath) > 1024) { return -1; }
strcpy(curenv->r_path, newPath);
return 0;
}

int sys_get_rpath(char *dst) {
if (dst == 0) { return -1; }
strcpy(dst, curenv->r_path);
return 0;
}

void *syscall_table[MAX_SYSNO] = {
// ...
[SYS_get_rpath] = sys_get_rpath,
[SYS_set_rpath] = sys_set_rpath,
};


// user/lib/syscall_lib.c

int syscall_set_rpath(char *newPath) {
return msyscall(SYS_set_rpath, newPath);
}

int syscall_get_rpath(char *dst) {
return msyscall(SYS_get_rpath, dst);
}


// user/lib/file.c

int chdir(char *newPath) {
return syscall_set_rpath(newPath);
}

int getcwd(char *path) {
return syscall_get_rpath(path);
}

最后在相应的头文件中添加声明

// user/include/lib.h

int syscall_set_rpath(char *newPath);
int syscall_get_rpath(char *dst);
int chdir(char *newPath);
int getcwd(char *path);

实现用户态函数后,可以直接新建 pwd.c 文件,直接调用 getcwd 函数输出当前路径

工作目录传递

现在在单个进程中,我们已经完成了工作目录的修改,现在需要在所有会出现创建进程的位置添加对父进程工作目录的复制工作。算过来也就只有 env_allocforkspawn 三个函数会创建进程,而它们最终也都会调用 sys_exofork 作为进程创建的核心函数。所以直接修改 sys_exofork 来实现父子进程中的工作目录复制。

// kern/syscall_all.c

int sys_exofork(void) {
struct Env *e;

try(env_alloc(&e, curenv->env_id));
// ...
strcpy(e->r_path, curenv->r_path); // copy at here

return e->env_id;
}

实现内部命令 cd

这里实现的 cd 指令需要作为内部指令,也就是执行后并不切换进程,而是继续处理。处理方法是在读入结束后、解析开始前的这一段空隙对输入指令做一次预处理,如果满足 cd 指令格式,就进行工作目录切换,切换后重新读入;反之则开始解析,准备调用外部命令。

要注意的是,Linux 中只输入 cd 相当于跳转至家目录,MOS 就直接跳根目录得了

// sh.c -> main

if (parseCD(buf) == 0) {
if ((r = fork()) < 0) {
user_panic("fork: %d", r);
}
} else {
runcmd(buf);
continue;
}
if (r == 0) {
runcmd(buf);
exit();
} else {
wait(r);
}

上面一段在 main 函数中先预先判断是否存在 cd,如果存在,则不应创建进程,而直接解析,反之就应该创建子进程并运行指令。

parseCD 函数实现时需要注意:cd 并不一定出现在指令开头,也可能出现在 ins1; cd 的格式中,所以需要对 ;& 进行特判。

// sh.c -> runcmd
if (strcmp("cd", argv[0]) == 0) {
int r;
char cur[1024] = {0};

if (argc == 1) {
cur[0] = '/';
} else if (argv[1][0] != '/') {
char *p = argv[1];
if (argv[1][0] == '.') { p += 2; }

syscall_get_rpath(cur);
int len1 = strlen(cur);
int len2 = strlen(p);
if (len1 == 1) { // cur: '/'
strcpy(cur + 1, p);
} else { // cur: '/a'
cur[len1] = '/';
strcpy(cur + len1 + 1, p);
cur[len1 + 1 + len2] = '\0';
}

} else {
strcpy(cur, argv[1]);
}
printf("cur:%s\n", cur);

if ((r = stat(cur, &st)) < 0) { printf("4");exit(); }
if (!st.st_isdir) {
printf("%s is not a directory\n", cur);
printf("5");exit();
}
if ((r = chdir(cur)) < 0) { printf("6");exit(); }
return;
}

这段函数的功能是获取 cd 后的绝对路径,并进行工作目录的切换;关键是输入的相对路径与工作路径之间的拼接。cd 大概可以分成以下几类, if-else 中的逻辑也是这么写的:

  • 输入路径为绝对路径(/xxxx、没有输入路径(默认为根目录 /)):不需要进行拼接,直接进行目录判断和跳转即可
  • 输入路径为相对路径,形式上分两种(./yyyzzz):根据形式不同,需要进行处理
    • 如果有 ./ 出现,需要先去掉,统一形式为 zzz
    • 获取工作目录,再进行字符串拼接,获取绝对目录

切换工作目录前要查看要跳转的路径是不是一个目录,若不是目录应不允许切换

open 函数支持相对路径

我们已经实现的用户程序中,只有 open 会用到程序的路径名,并且 lstreemkdir,甚至 spawn 都需要 open 函数支持,所以与其更改每个用户函数的接口,不如直接修改 open 函数逻辑,让文件系统支持输入相对路径。最后再在用户程序中做一些微调就能够正常使用了。

类似地, open 函数的输入路径也可能分为绝对路径和相对路径两种,这取决于用户的字符串输入。所以处理方式可以和上面 cd 的方式保持一致,直接 CV 都能用

用户程序功能调整

在已经实现好的用户程序中,大多数指令的默认情况都会以根目录为输入目录,如直接键入 tree 就会生成根目录文件树,现在我们就要把默认情况改为 ./ ,即输出当前工作目录的文件树

需要更改的文件有 lstree

还有一个相对特殊的 spawn,它的默认打开路径就是只能从根目录开始,如果带上相对路径,那么在 cd 至其他路径后再输入外部命令,spawn 会先调用 open打开相对路径下的用户程序, Shell 就会因为 spawn 了错误的文件而无法运行。例如:

$ mkdir test
$ cd test
$ ls
// 此刻 Shell 中在尝试调用工作目录下的 ls.b ,也即 /test/ls.b,显然这个文件是不存在的

所以为了避免 open 将指令解析成相对路径文件,直接在最前面加一个 / 声明为绝对路径就可以了

// user/lib/spawn.c -> spawn

char path[1024] = {0};
if (prog[0] != '/') {
path[0] = '/';
strcpy(path + 1, prog);
} else {
strcpy(path, prog);
}
if ((fd = open(path, O_RDONLY)) < 0) {
return fd;
}

Shell 界面优化

既然已经支持了工作路径的使用,所以不如在 Shell 的工作状态下输出当前的工作目录,更符合 Linux 的界面风格

// user/sh.c -> main

if (interactive) {
if ((r = getcwd(curPath)) < 0) { printf("G");exit(); }
printf("\n[%04x] %s $ ", syscall_getenvid(), curPath);
}

测试用例

纯指令功能

[2000] / $ cd testdir
cur:/testdir

[2000] /testdir $ halt
halt at halt.c:4: halt mos!

一条多语句测试

省略销毁进程输出

[2000] /a $ mkdir b ; cd b
parsed ';', created 3803
created path: /a/b
cur:/a/b

[2000] /a/b $ halt
halt at halt.c:4: halt mos!

spawn & open 功能测试

[2000] / $ mkdir testdir
created path: /testdir

[2000] / $ cd testdir
cur:/testdir

[2000] /testdir $ touch a
created file: a

[2000] /testdir $ tree
./
└── a

1 directories, 1 files
  • 此处的 touch 在相对目录中使用 spawn 创建,可以正常打开根目录的用户程序
  • tree 指令内部在 open 中使用了相对路径 ./ ,也可以正常解析

lab6 挑战性任务需求的功能到这里就全部实现力,是时候休息一把了(×