OS-Shell Challenge

Shell Challenge 任务文档
实现不带 .b
后缀指令
你需要实现不带
.b
后缀的指令,但仍需兼容带有.b
后缀的指令,如ls
与ls.b
都应能够正确列出当前目录下的文件。
在 /home/git/22371236/user/lib/spawn.c
的 spawn
函数中,对程序路径进行特判,若其不含有 .b
后缀,则进行添加,实现兼容。同时,为避免修改到后面的参数,将 prog
复制给 temp
用于打开处理文件。
1 | //! challenge-shell start |
实现指令条件执行
你需要实现 Linux shell 中的
&&
与||
。 对于command1 && command2
,command2
被执行当且仅当command1
返回 0;对于command1 || command2
,command2
被执行当且仅当command1
返回非 0 值。注: 评测中保证不出现括号。并且需要注意的是,在 bash 中
&&
与||
的优先级相同,按照从左到右的顺序求值。
例如cmd1 || cmd2 && cmd3
,若cmd1
返回 0,则cmd1
执行后cmd2
不会被执行,cmd3
会被执行;若cmd1
返回非 0 且cmd2
返回非 0,则cmd3
将不会被执行。
提示:你可能需要修改 MOS 中对用户进程exit
的实现,使其能够返回值。
获取返回值
我们知道,spawn
出的程序装入内存后,在 user/lib/libos.c
中开始执行,所以我们可以在这里获取程序执行的返回值。在这里,我是给进程控制块中增加了 u_int env_ret;
属性来记录进程的返回值,同时增加相应的系统调用来设置进程返回值。
1 | void libmain(int argc, char** argv) { |
识别标识符
在 user/sh.c
的 gettoken
函数中,c
代表上一次识别的 token
,nc
代表这次识别到的 token
,假如这两次都识别 &
或 |
标识符,即识别到对应符号。同时,我们再次调用 _gettoker
,使得下一次执行时从标识符后面开始扫描。最后,我们返回相应的宏,使得 parsecmd
可以识别到。
1 | //! challenge-shell start |
条件执行
当 parsecmd
遇到对应的标识符时,fork
出一个子进程,父进程执行标识符左边的指令,子进程执行右边的指令。左边的指令执行完毕后,父进程通过 ipc 把执行结果的返回值发送给子进程,然后等待子进程执行完毕;子进程解析完右边的指令后,通过 ipc 接收父进程发送的返回值,据此根据标识符的结果决定是否执行,同时设置子进程的返回值。
1 | // parsecmd |
这里给 parsecmd
新增了 waitType
和 nextCmd
参数,分别记录标识符的类型和执行后面指令的进程 id。对于标识符右边的指令,需要记录标识符的类型,同时我们暂时认为它后面没有要执行的指令;对于标识符左边的指令,需要记录执行后面指令的进程 id。对于子进程,这里重新打开了控制台,并重置标准输入输出,是为了防止标识符前面的指令对输入输出进行重定向影响到子进程的标准输入输出。
1 | // runcmd |
在 runcmd
中,我们解析完指令后,首先根据标识符类型,通过 ipc 获取并记录返回值。接着,根据标识符的类型,决定是否执行右边的指令。若指令不执行,则根据左边指令执行的结果和标识符类型,设置自己的返回值(应对连续几个标识符的情况);若指令执行,则根据 spawn
的结果设置返回值。
1 | //! challenge-shell start |
在指令的返回值确定后,我们根据 nextCmd
参数确定其右边是否存在指令。若存在,则向其发送自己的返回值,然后等待进程结束。
进程关系梳理
在指令条件执行这一节,进程比较多,调用关系复杂,在此以 cmd1 || cmd2 && cmd3
为例进行梳理,假定 cmd1
、cmd3
为返回 0,cmd2
返回非 0 值:
进程创建过程:
- 进程 1:读取我们输入的指令(输出 $ 的那个)
- 进程 2: 由进程 1
fork
出来,用于 解析并执行 指令cmd1 || cmd2 && cmd3
- 进程 3:由进程 2
spawn
出来,用于 执行 进程 2 经过parsecmd
解析后的指令cmd1
- 进程 4:由进程 2
fork
出来,用于 解析并执行 指令|| cmd2 && cmd3
- (进程 5):由于
cmd1
为真,实际上不存在(由进程 4spawn
出来,用于 执行 进程 2 经过parsecmd
解析后的指令|| cmd2
) - 进程 6:由进程 4
fork
出来,用于 解析并执行 指令&& cmd3
- 进程 7:由进程 6
spawn
出来,用于 执行 进程 2 经过parsecmd
解析后的指令cmd3
- 进程 7:由进程 6
- (进程 5):由于
- 进程 3:由进程 2
- 进程 2: 由进程 1
进程退出过程:
- 进程 1:等待 进程 2 退出,然后读取下一条输入
- 进程 2:等待 进程 3 退出,并据此设置
cmd1
的返回值为 0,然后 发送 给进程 4,等待 进程 4 结束后退出- 进程 3:按照对应的
.c
文件主函数正常执行,并在libos.c
对应位置退出 - 进程 4:等待
cmd1
的返回值,然后据此决定cmd2
不用执行,从而设置cmd1 || cmd2
的返回值为 0,接着 发送 给进程 6,等待 进程 6 结束后退出- 进程 5:由于
cmd1
为真,没有被创建 - 进程 6:等待
cmd1 || cmd2
的返回值,然后据此决定cmd3
需要执行,等待 进程 7 结束后退出- 进程 7:按照对应的
.c
文件主函数正常执行,并在libos.c
对应位置退出
- 进程 7:按照对应的
- 进程 5:由于
- 进程 3:按照对应的
- 进程 2:等待 进程 3 退出,并据此设置
可以看到,上述的进程具有类似 递归 的调用关系,同时存在着父进程等待子进程退出,子进程等待父进程发送返回值的情况,稍有不慎就会产生 死锁,这实际上是个糟糕的设计!应该反过来,由父进程执行标识符后面的指令,子进程执行标识符前面的指令,这样每个进程执行完了就可以退出,而不需等待。
实现更多指令
你需要实现
touch
,mkdir
,rm
指令,只需要考虑如下情形:
touch
:
touch <file>
:创建空文件file
,若文件存在则放弃创建,正常退出无输出。 若创建文件的父目录不存在则输出touch: cannot touch '<file>': No such file or directory
。 例如touch nonexistent/dir/a.txt
时应输出touch: cannot touch 'nonexistent/dir/a.txt': No such file or directory
。
mkdir
:
mkdir <dir>
:若目录已存在则输出mkdir: cannot create directory '<dir>': File exists
,若创建目录的父目录不存在则输出mkdir: cannot create directory '<dir>': No such file or directory
,否则正常创建目录。mkdir -p <dir>
:当使用-p
选项时忽略错误,若目录已存在则直接退出,若创建目录的父目录不存在则递归创建目录。
rm
:
rm <file>
:若文件存在则删除<file>
,否则输出rm: cannot remove '<file>': No such file or directory
。rm <dir>
:命令行输出:rm: cannot remove '<dir>': Is a directory
。rm -r <dir>|<file>
:若文件或文件夹存在则删除,否则输出rm: cannot remove '<dir>|<file>': No such file or directory
。rm -rf <dir>|<file>
:如果对应文件或文件夹存在则删除,否则直接退出。
这三个指令的实现比较简单,主要集中在 touch.c
、mkdir
、rm
三个文件中。
- touch
- 分割目录与文件名(如果有上级目录)
- 若目录不存在(
open
目录返回值为负),输出错误信息 - 若目录存在,创建文件(
open
完整路径)
- mkdir:
- 若目录存在(
open
目录返回值非负),且没有-p
选项,则输出错误信息 - 若目录不存在,分割出父级目录
- 若没有
-p
选项- 若父级目录(
open
父级目录返回值为负)不存在,输出错误信息 - 若父级目录存在,创建目录(
open
完整路径)
- 若父级目录(
- 若有
-p
选项- 从顶级目录开始,依次尝试打开并创建目录文件(
open
对应级别的完整路径)
- 从顶级目录开始,依次尝试打开并创建目录文件(
- 若没有
- 若目录存在(
- rm:
- 若没有选项,尝试打开文件(
open
路径):- 若返回负值,则输出文件不存在的错误信息
- 若返回非负值,则判断文件类型
- 若为普通文件,则删除(
remove
路径) - 若为目录文件,则输出错误信息
- 若为普通文件,则删除(
- 若有选项,尝试打开目录(
open
路径):- 若返回负值,且没有
f
选项,则输出报错信息 - 若返回非负值,则直接删除目录
- 若返回负值,且没有
- 若没有选项,尝试打开文件(
在这里,我们创建文件和目录统一使用了 open
,给打开模式中加入 O_CREAT
即可;对于目录,再额外加上 O_MKDIR
。由于 fs/serv.c
的 serve_open
中没有加入创建目录的功能,所以我们需要在其中加入相关内容。
1 | //! challenge-shell start |
实现反引号
你需要使用反引号实现指令替换。只需要考虑 echo
进行的输出,你需要将反引号内指令执行的所有标准输出替换为 echo
的参数。例如:
echo
ls | cat | cat | cat
识别反引号
在 user/sh.c
的 _gettoken
函数中,我们需要读取出反引号之间的内容,同时修改指针,使得前面的字符串从左边的反引号后面的第一个字符开始,后面的字符串从右边的反引号后面的第一个字符开始,使得 parsecmd
可以识别到反引号,并获取中间的字符串。
1 | //! challenge-shell start |
指令替换
这里我们使用 fork
出的子进程去执行反引号之间的指令,然后将结果传递给父进程,父进程将结果解析为自身的参数,然后接着解析剩余的参数并执行。在结果传递的过程中,我使用了文件为媒介进行传递。子进程写文件,父进程读文件,实际上也可以使用管道。
1 | // sh.c 的全局变量 |
在子进程中,我们创建 “.TEMP” 文件,将子进程的标准输出重定向到文件中,然后使用 runcmd
执行指令;在父进程中,我们等待子进程执行完毕,然后打开文件并读取其内容,接着过滤掉空白符,将字符串写入到参数中。
注意到这里使用了 temp
这个二维数组全局变量来存储反引号内指令执行的结果,这是因为在 parsecmd
和 runcmd
中, argv
存储的都是字符串的指针,这首先意味着我们需要使用全局变量(最早的 argv
实际上是由 main
函数中 fork
出的子进程带入)。因为这些都是指针,所以我们要保证解析完毕后其指向的空间不会再被写入,因此我们采用了二维数组,以此来避免一行指令中具有多个反引号重复写 temp
的情况。
此外,我们这里的子进程使用 runcmd
而非 parsecmd
来执行指令,这是因为 gettoken
函数中的 np1
、np2
都是静态变量,存储的仍然是原来的命令的指针,因此我们需要调用 runcmd
来重置其 gettoken
函数并进行执行指令的操作。
实现注释功能
你需要使用 #
实现注释功能,例如 ls | cat # this is a comment meow
,ls | cat
会被正确执行,而后面的注释则会被抛弃。
1 | //! challenge-shell start |
这里只需要在 _gettoker
中识别 #
,并在 parsecmd
中增加新的分支,遇到 #
时停止解析即可。
实现历史指令
你需要实现 shell 中保存历史指令的功能,可以通过 ↑ Up
和 Down ↓
选择所保存的指令并执行。你需要将历史指令保存到根目录的 .mosh_history
文件中(一条指令一行),为了评测的方便,我们设定 $HISTFILESIZE=20
(bash 中默认为 500),即在 .mosh_history
中至多保存最近的 20 条指令。你还需要支持通过 history
命令输出 .mosh_history
文件中的内容。
注:在 bash 中,
history
为 shell built-in command,我们规定需要将history
实现为 built-in command。
你需要将当前执行的指令先存入 .mosh_history
中,例如:
1 | echo `ls | cat` |
当历史指令为空时,依次执行上述四条指令后,后两条指令会分别输出
1 | echo `ls | cat` |
与
1 | echo `ls | cat` |
使用↑ Up
能够切换到上一条指令(如果上一条指令存在),使用 Down ↓
能够切换到下一条指令(如果下一条指令存在)。能够选择的指令范围为:用户当前输入的指令与 .mosh_history
文件中保存的所有指令。例如在执行了上述四条指令后,用户输入了 echo
,此时 ↑ Up
应该将指令切换至 history | cat
,再次进行三次 ↑ Up
后切换至 echo ` ls | cat`
,此时再次 ↑ Up
应保留在该指令(因为已经不存在上一条指令);再进行四次 Down ↓
后将切换回 echo
,此时再次 Down ↓
应保留在该指令(因为不存在下一条指令)。
历史文件读写
由于一行指令的长度并不统一,所以为了便于找到一行指令在 .mosh_history
中的准确位置,我规定一行指令在文件中占据固定的大小,便于我们快速根据行号读取文件中一行的内容。
1 | int readhis(char* buf, int lineNum) { |
历史的记录与查询
一旦 shell
打开,我们便以读写的权限打开创建 .mosh_history
文件,在每次输入一行指令时进行读写。在这里,我根据需要,定义了三个行号指针,便于进行记录与查询操作。
因为要求记录最近的20条指令,所以历史文件也是循环写入,我们需要记录 逻辑上 的行号在 文件 中对应的行号。在这里,我的 hisStart
、hisEnd
、hisNow
分别对应起始行(最上面的行)、结束行(最下面的行)、当前观察行(终端显示的行)。注意到,在上面的 readhis
、writehis
函数中,我们均对行号进行了取模操作,所以这里的三个变量不用保持在 [1, 20],无限递增即可。这样做是为了便于后续处理,不用再区别考虑起始行在结束行前面或后面的情况。没错,管道中也是这么实现的,这里算是学习到了一点源代码的精髓
1 | int hisStart = 1; |
历史的记录与查询主体在原有的 readline
函数中进行。
当我们识别当上箭头(连续的三个字符 "\e[A"
)后:
- 若当前行等于结束行,我们需要把目前输入的内容保存,便于输入下箭头时可以回来重新编辑
- 输出下箭头,使光标保持不动;回到行首并输出空格,“清空”屏幕上显示的原有内容
- 当前行自减,前往上一行。若小于起始行,则重置为起始行
- 从历史中读取当前行,并进行输出,同时保持原有的终端符号
当我们识别当上箭头(连续的三个字符 "\e[A"
)后:
- 若当前行等于结束行,我们需要把结束行保存(确保下箭头按到底时,可以照常从文件中输出)
- 回到行首并输出空格,“清空”屏幕上显示的原有内容(这里没有输出上箭头,是因为输入下箭头时,光标并不会移动)
- 当前行自增,前往下一行。若大于结束,则重置为结束行
- 从历史中读取当前行,并进行输出,同时保持原有的终端符号
1 | if (buf[i] == '\e') { |
注意到,上面读写历史指令时,都把它放在了会读取输入的 buf
中,同时重置了 i
。这样的话,buf
中始终保存了当前屏幕上显示的那条指令,便于我们在移动到某条历史指令的时候,可以直接对其进行修改然后执行。正因如此,当我们接收到回车键时,只需把 buf
内容写入文件中,修改起始行、结束行和当前行指针,然后退出即可。
1 | if (buf[i] == '\r' || buf[i] == '\n') { |
历史输出
history
作为一条内置指令,并不存在 history.c
文件实现其功能,也不会采用 spawn
来装载程序。为了保证行为的一致性,在这里,我 fork
出一个子进程来专门执行指令。
1 | int child; |
实现一行多指令
你需要实现使用 ;
将多条指令隔开从而从左至右依顺序执行每条指令的功能。例如:
1 | ls;ls | cat; echo nihao,mosh; echo `ls; echo meow` |
由于 SYMBOLS
中已经含有 ;
,gettoken
函数已经能够解析到,所以我们只需在 parsecmd
函数中增加相应分支进行处理即可。
1 | case ';': |
吸取了条件执行复杂度飙升的教训之后,这次我们让 fork
出来的子进程执行 ;
左边的指令,父进程执行 ;
右边的指令。由于 ;
前面可能通过重定向更改了标准输入输出,所以我们需要重置父进程的标准输入输出。注意到,我们之前在处理反引号时,有个 *append = 1;
,它实际上就是告诉我们这里的标准输入输出应该重置为 .TEMP
;不然,则重置为终端。
实现追加重定向
你需要实现 shell 中 >>
追加重定向的功能,例如:
ls >> file1; ls >> file1
最后文件 file1
中将会有两次 ls
指令的输出。
与 &&
和 ||
的识别类似,识别标识符时,我们只需在 gettoken
函数中判断前后两次解析到的元素是否均为 >
;若符合条件,返回相应的宏即可。
1 | case APPEND: |
与普通输出重定向类似,我们都需要把标准输出重新设置为对应的文件。所不同的是,我们需要修改标准输出文件描述符的偏移量,移动到文件的末尾,从而实现追加写的功能。
实现引号支持
你需要实现引号支持,比如 echo "ls >"
,shell 在解析时需要将双引号内的内容看作是单个字符串。
所谓引号支持,就是把引号的字符串看做一个完整的参数,而不进行额外的解析。与 `
的处理类似,我们只需在 _gettoker
中识别到其中的字符串,返回普通的参数标识即可。
1 | //! challenge-shell start |
实现前后台任务管理
- 你需要支持 mosh 运行后台进程,当命令的末尾添加上
&
符号时,该命令应该在后台执行。 - 实现
jobs
指令列出当前 shell 中所有后台任务的状态。你需要为任务创建 ID(每次启动 mosh 时,任务从 1 开始编号,每个新增任务编号应加 1),并且通过jobs
指令输出包括:任务 ID(job_id
)、任务的运行状态(status
:可能的取值为Running
,Done
)、任务的进程 ID(env_id
)与运行任务时输入的指令(cmd
)。请以printf("[%d] %-10s 0x%08x %s", job_id, status, env_id, cmd)
的格式进行输出。 - 实现
fg
将后台任务带回前台继续运行,用户通过fg <job_id>
的方式将对应任务带回前台。 - 实现
kill
指令,用户通过kill <job_id>
来实现结束后台任务。
在 fg
或 kill
指令中,若 job_id
对应的后台任务不存在则输出 printf("fg: job (%d) do not exist\n", job_id)
,若 job_id
对应的 ID 为 envid
的进程状态不为 Running
则输出 printf("fg: (0x%08x) not running\n", envid)
。
例如:
1 | sleep 10& |
依次执行上述指令,则第一个 jobs
应输出(其中进程 ID 的值可能与你本地运行的输出结果不同):
1 | [1] Running 0x00003805 sleep 10 |
第二个 jobs
应输出:
1 | [1] Done 0x00003805 sleep 10 |
我对这个任务采取了一种简化的实现方法——即认为 &
只会出现在一行指令的结尾,不会出现一行中多条命令分别 &
的情况;同时 fg
、kill
、jobs
也作为单独的指令来出现,不会出现和其他指令嵌套的情况。
对于以 &
结尾的指令,我在 readline
结束后即进行扫描,检验这行指令是否需要后台执行。若需要,则子 shell
不需等待其执行完成就可输入下一条指令;不然,则需在终端阻塞等待执行指令的子 shell
,显示地展示指令的执行过程。
1 | if ((r = fork()) < 0) { |
对于 fd
、kill
、jobs
这三条指令,我也是在 readline
结束后进行检验执行,不进入 parsecmd
中进行处理。从这个角度来说,我实现的 shell
是具有明显缺陷的,这应当是日后改进的方向。
1 | if (strncmp(buf, "fg", 2) == 0) { |
相关具体处理函数如下:
1 | struct Job { |