CSAPP-Lab7-shellLab
CSAPP-Lab7-shellLab
Hoshea Zhang实验概览
需要我们实现的函数:
eval
:解析命令行 [约 70 行]builtin_cmd
:检测是否为内置命令quit
、fg
、bg
、jobs
[约 25 行]do_bgfg
:实现内置命令bg
和fg
[约 50 行]waitfg
:等待前台作业执行完成 [约 20 行]sigchld_handler
:处理SIGCHLD
信号,即子进程停止或者终止 [约 80 行]sigint_handler
:处理SIGINT
信号,即来自键盘的中断ctrl-c
[约 15 行]sigtstp_handler
:处理SIGTSTP
信号,即来自终端的停止信号 [约 15 行]
作者提供的帮助函数及功能:
1 | /* Here are helper routines that we've provided for you */ |
回顾
回收子进程
一个终止了但是还未被回收的进程称为僵死进程。对于一个长时间运行的程序(比如 Shell)来说,内核不会安排init
进程去回收僵死进程,而它虽不运行却仍然消耗系统资源,因此实验要求我们回收所有的僵死进程。
waitpid
是一个非常重要的函数,一个进程可以调用waitpid
函数来等待它的子进程终止或停止,从而回收子进程,在本实验大量用到,我们必须学习它的用法:
这个函数用来挂起调用进程的执行,直到pid
对应的等待集合的一个子进程的改变才返回,包括三种状态的改变:
- 子进程终止
- 子进程收到信号停止
- 子进程收到信号重新执行
如果一个子进程在调用之前就已经终止了,那么函数就会立即返回,否则,就会阻塞,直到一个子进程改变状态。
等待集合以及监测那些状态都是用函数的参数确定的,函数定义如下:
1 | pid_t waitpid(pid_t pid, int *wstatus, int options); |
各参数含义及使用
pid:判定等待集合成员
- pid > 0 : 等待集合为 pid 对应的单独子进程
- pid = -1: 等待集合为所有的子进程
- pid < -1: 等待集合为一个进程组,ID 为 pid 的绝对值
- pid = 0 : 等待集合为一个进程组,ID 为调用进程的 pid
options:修改默认行为
- WNOHANG:集合中任何子进程都未终止,立即返回 0
- WUNTRACED:阻塞,直到一个进程终止或停止,返回 PID
- WCONTINUED:阻塞,直到一个停止的进程收到 SIGCONT 信号重新开始执行
- 也可以用或运算把 options 的选项组合起来。例如 WNOHANG | WUNTRACED 表示:立即返回,如果等待集合中的子进程都没有被停职或终止,则返回值为 0;如果有一个停止或终止,则返回值为该子进程的 PID
statusp:检查已回收子进程的退出状态
- waitpid 会在 status 中放上关于导致返回的子进程的状态信息,很容易理解,这里就不再翻译了
并发编程原则
这里仅列出在本实验中用到的原则,后面的解析也会大量提及
- 注意保存和恢复 errno。很多函数会在出错返回式设置 errno,在处理程序中调用这样的函数可能会干扰主程序中其他依赖于 errno 的部分,解决办法是在进入处理函数时用局部变量保存它,运行完成后再将其恢复
- 访问全局数据时,阻塞所有信号。这里很容易理解,不再解释了
- 不可以用信号来对其它进程中发生的事情计数。未处理的信号是不排队的,即每种类型的信号最多只能有一个待处理信号。举例:如果父进程将要接受三个相同的信号,当处理程序还在处理一个信号时,第二个信号就会加入待处理信号集合,如果此时第三个信号到达,那么它就会被简单地丢弃,从而出现问题
- 注意考虑同步错误:竞争。我们在下面单独开一节说明
竞争
例子如下所示:
这是一个 Unix Shell 的框架,父进程在一个全局列表中记录子进程,并设置了一个 SIGCHLD 处理程序来回收子进程,乍一看没问题,但是考虑如下可能的事件序列:
- 第 29 行,创建子进程运行
- 假设子进程在父进程运行到 32 行,即运行
addjob
函数之前就结束了,并发送一个 SIGCHLD 信号 - 父进程接收到信号,运行信号处理程序,调用
deletejob
函数,而这个job
本来就没有添加入列表 - 返回父进程,调用
addjob
函数,而这个子进程已经终止并回收了,job
早就不存在了
也就是说,在这里,deletejob
函数的调用发生在了addjos
之前,导致错误。我们称addjob
和deletejob
存在竞争。
解决方法是在父进程folk前就阻塞SIGCHLD信号:
错误处理函数
当 Unix 系统及函数遇到错误时,它们通常会返回 -1,并设置全局整型变量errno
来表示什么出错了。为了能让程序检查错误的同时代码不那么臃肿,按照书本推荐的方式,编写了一套后面将要用到的错误处理包装函数
1 | /* Error wrapper function */ |
具体实现
trace1
不用写就过了
trace2
trace02.txt文件中只有quit,WAIT两条命令。
程序会首先执行 eval(),在 eval中进行判断(使用buildin_cmp()函数),如果发现命令不是内置命令,则会调用 fork()函数来新建一个子进程,在子进程中调用 execve()函数通过 argv[0]来寻找路径,并在子进程中运行路径中的可执行文件,如果找不到可执行文件,则说明命令为无效命令,输出命令无效,并用 exit(0)结束该子进程即可。
思路:首先从命令中提取参数,然后判断是否为内置命令,如果为内置命令,则直接在当前进程执行即可;如果不是内置命令,则需要新建一个子进程,并利用 execve 来通过参数给出的路径寻找出可执行文件并在子进程中执行,如果找不到该可执行文件,则输出命令未找到,并结束子进程。
可以看到无法正常终止,因为tsh的quit内置命令还未编写,所以不能正常退出
quit
先完成eval函数中和quit相关的部分
然后是builtin_cmd()
test3是运行一个前台job并quit,这两个可以一起验证
trace4
实现eval函数的后台作业功能
- 在原有的eval函数基础之上添加将作业添加至后台作业管理的函数使用(addjobs())。
- 加以信号的阻塞和取消阻塞。
为什么要阻塞,复习一下上面所说的竞争:
为了保证处理程序回收终止的子进程(delete job)在父进程(addjob)之后进行,否则父子进程之间会出现经典的同步错误—-竞争
因为当父进程创建一个子进程时,它就会将这个子进程添加到作业列表(addjobs)。当父进程在SIGCHLD处理程序中回收一个僵尸子进程时,就要从作业列表中删除子进程。理想状态下,这个过程很正确,但是往往真实的运行情况下,会出现问题,如下图:
实现
使用一个标记符号:bg,标记这个作业是否应该在后台进行
1 | bg = parseline(buf, argv); //解析参数 |
wait fg
这个函数从要求实现阻塞父进程,直到当前的前台进程不再是前台进程了。这里显然要显示地等待信号,我们先回顾一下相关知识
1
2
3
4
5
6
7
8
9void waitfg(pid_t pid)
{
sigset_t mask;
Sigemptyset(&mask);
while (fgpid(jobs) != 0){
sigsuspend(&mask); //暂停时取消阻塞,见sigsuspend用法
}
return;
}val
1
2
3
4
5
6
7
8
9
10
11
12if(state==FG){
Sigprocmask(SIG_BLOCK, &mask_all, NULL); //添加工作前阻塞所有信号
addjob(jobs, pid, state, cmdline); //添加至作业列表
Sigprocmask(SIG_SETMASK, &mask_one, NULL);
waitfg(pid); //等待前台进程执行完毕
}
else{
Sigprocmask(SIG_BLOCK, &mask_all, NULL); //添加工作前阻塞所有信号
addjob(jobs, pid, state, cmdline); //添加至作业列表
Sigprocmask(SIG_SETMASK, &mask_one, NULL);
printf("[%d] (%d) %s",pid2jid(pid), pid, cmdline); //打印后台进程信息
}解除阻塞:
1
Sigprocmask(SIG_SETMASK, &prev_one, NULL);
trace 5
直接使用listjob方法
trace06、trace07
要实现的功能是:trace06->将SIGINT信号转发到前台作业;
trace07->仅仅将SIGINT信号转发到前台作业;
因此这里放在一起实现。
即ctrl+C键盘中断
eval
1 | if((pid = Fork()) == 0) { //创建子进程 |
把子进程放到一个新进程组中,进程组ID与子进程PID相同,确保前台进程组只有一个进程,就是shell进程
更改信号处理函数,实现转发信号给前台作业(包括进程组)
同时修改sigchld_handler()函数
为了区分进程终止的原因
是正常终止(exit或return)
还是因为收到其他信号如:SIGINT而终止。
后面的先不做了