Introduction
本项目要实现一个简易版Shell,支持以下Features: -
命令提示符tsh> -
若用户输入的命令第一个单词是内置命令,在当前进程tsh(Tiny
Shell)执行命令: 1
2
3
4
5jobs   列出运行/挂起的后台job
bg <job>   通过发送SIGCONT信号重启进程,将后台挂起的job设为在后台运行,<job>可以是PID或%JID
fg <job>   通过发送SIGCONT信号重启进程,将后台运行/挂起的job设为在前台运行,<job>可以是PID或%JID
kill <job>   结束<job>
quit   退出tsh|和I/O重定向<
>(TODO)
Background Knowledge
- Shell
 shell是一个交互式的命令行解释器,可以执行用户输入的指令,显示计算结果。
 用户输入既可以是内置命令,也可以是可执行文件路径。
 用户既可以在前台运行,也可以在后台运行。后台job可以在命令最后加一个&,否则视为前台。比如/bin/ls -l -d &表示在后台执行ls程序。前台job只能有1个,后台job则可以有多个。
- Signals
 信号机制的作用就是允许进程/内核打断其他进程运行,是进行进程间通信的一种方式。
 Linux的常用信号有: 
Implementation
整体的思路是:父进程读取命令行输入,通过系统调用fork()创建子进程执行这些输入。对于前台命令,父进程必须等待子进程完成才能继续读取下一条命令;对于后台命令,父进程和子进程则是并发执行。
- useful functions
 int fork(void):父进程创建一个子进程,在子进程中返回0,父进程中返回子进程的PID。
 int kill(pid_t pid, int sig):进程向其它进程(包括自己)发送信号,成功返回0,错误返回-1。可以通过改变参数pid调整发送目标:pid>0,给该进程发信号;pid==0,给包括自己在内的当前进程组发信号;pid<0,给pgid=|pid|的进程组的每个进程发信号。
 int execve(char *filename, char *argv[], char *envp[]):在当前进程的上下文环境中装载并运行新的程序,成功不返回,失败返回-1。filename可以是可执行目标文件或脚本文件,会覆盖原进程的data/code/stack,会保留原进程的PID/open files/signal context。
 pid_t waitpid(pid_t pid, int *status, int options):指定进程终止父进程会进行回收,否则等待。内核会将子进程的退出状态传给父进程,之后清除子进程。options=WNOHANG | WUNTRACED时,如果wait set中没有终止/暂停的子进程,立即返回0;否则返回任意一个子进程的PID。
 int sigprocmask(int how, const sigset_t *set, sigset_t *oldset):how=SIG_BLOCK将set中的信号加入阻塞向量;how=SIG_UNBLOCK将set中的信号从阻塞向量中移除;how=SIG_SETMASK将阻塞向量设置为set。如果oldset!=NULL之前的阻塞向量就存储在oldset中。
 handler_t *signal(int signum, handler_t *handler):改变信号的默认行为。
- step by step
 -- 整体结构:除了测试文件和Makefile外,全部实现都在tsh.c中,main函数有一个死循环,不停调用eval()实现命令的解析、执行。
 --eval(char* cmdline)
 接收到用户输入后,第一件事就是解析。解析是通过int parseline(const char* cmdline, char** argv)完成,将cmdline解析到argv中,如果用户要求后台运行就返回1,否则返回0。
 解析后,我们需要通过int builtin_cmd(char** argv)判断是否为内置命令。如果是内置命令,就在builtin_cmd里立刻执行;否则需要创建子进程执行,这里需要区分前后台进程:如果是前台,需要等待terminate才能返回并接受新的输入;如果是后台,则可以立即接收新输入。
- key point 1
 父进程在fork子进程之前,要用sigprocmask阻塞SIGCHLD信号。否则由于父子进程执行顺序不确定,可能导致:
 子进程首先执行完毕,内核向父进程发送SIGCHLD信号;
 从内核态切换到用户态时,检测到SIGCHLD信号并且执行sigchld_handler,删除该job;
 父进程执行addjob操作,显然删除和添加顺序反了。
 如果我们正确阻塞了SIGCHLD信号,还是按照上面的顺序:
 子进程首先执行完毕,内核向父进程发送SIGCHLD信号;
 从内核态切换到用户态时,由于父进程阻塞了SIGCHLD信号,所以不会执行sigchld_handler;
 父进程添加该job,解除SIGCHLD信号的阻塞,下一次context switch时删除job。
- key point 2
 用户从键盘输入ctrl-c时,内核给shell进程发SIGINT信号(默认的signal handler是终止shell进程),一般有以下几种方式处理:
- 忽略该信号
- 使用默认的signal handler
- 实现一个单独的signal-handling函数
在main里面安装handler,在sigint_handler中处理:终止所有的前台进程及其子进程;
用户从键盘输入ctrl-z时,内核给shell发SIGTSTP信号(默认暂停当前进程直到收到SIGCONT),在main里面安装handler,在sigtstp_handler中处理:暂停所有的前台进程及其子进程。
- key point 3
默认情况下,fork出来的子进程和他爹属于同一个进程组。当我们在机器上运行Tiny
Shell时,程序运行在前台进程组中,这时如果Tiny
Shell创建一些子进程,这些子进程也会同属于这个前台进程组,用户输入ctrl-c会终止所有前台进程,包括Tiny
Shell,这显然不是我们想要的。
解决方案是:fork之后,子进程调用setpgid(0,0)将其放到一个新的进程组里,这个组的group
id和PID相同。这样就可以确保前台进程组里只有Tiny
Shell一个进程,用户输入ctrl-c时,就可以在sigint_handler中调用kill()终止特定的前台job。
- key point 4
当子进程终止或者暂停,内核会给父进程发送SIGCHLD信号,我们在sigchld_handler中根据子进程的状态做相应的处理:
WIFEXITED(status):子进程通过exit或return正常终止;
WIFSIGNALED(status):子进程通过信号终止;
WIFSTOPPED(status):子进程暂停;
WTERMSIG(status):当WIFSIGNALED()为真,返回造成子进程终止的信号ID;
WSTOPSIG(status):当WIFSTOPPED()为真,返回造成子进程暂停的信号ID。
- key point 5
对于前台进程,需要一直等待其执行完毕,然后回收,可以在waitfg()中调用sigsuspend完成;但对于后台进程,由于不用等待其完成,所以为了避免其成为zombie,需要在其执行完毕或者暂停时通知父进程,这个机制就是signal,具体的就是我们的sigchld_handler做的事情。
如果子进程因为收到了未能捕获的信号(如SIGKILL)而终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,那么该子进程就成为僵尸进程。
处理僵尸进程有2种方法: -
杀死其父进程,子进程变为孤儿进程,进而被系统回收 -
wait函数
Test
一方面通过提供的脚本测试,共有16个脚本测试文件,测试通过make test01~make test16进行;
另一方面通过实际执行去测试各项功能。
Code & Reference
TODO
- IO重定向
 https://www.cnblogs.com/weidagang2046/p/io-redirection.html
- 管道
 https://panqiincs.me/2017/04/19/write-a-shell-redirect-and-pipeline/
- xv6-riscv/sh.c
- history:支持快速访问最近的若干条命令
 
        