CS:APP Shell Lab实验
本文是CS:APP学习笔记的一部分:
相关的代码都放在了GitHub下了:RayZhang13/CSAPP-Labs
关于学习资源和视频等问题可以参考第一次写的Data Lab开头中提及的相关问题。
日常肝不动本科毕设,一直对自己的本专业知识水平感到堪忧,自动化大概是真的不适合我… 在看论文找解决方案最累的时候还是看CMU的网课最能振奋人心,晚上睡不着的时候就靠它了(不是
逃避可耻但是有用,起码看CMU的网课能很好的调节我的身心,而不产生很强烈的摸鱼愧疚感,也感谢老师的不杀之恩没有三天两头“关心”我的毕设进度…
回到正题,这次的Shell Lab,为啥叫Shell Lab呢,原来他真的是要你写个Shell出来,当然是一个简易的版本,没要求我们把所有的功能都实现出来… 听着David O’Halloran教授絮絮叨叨讲了好久,简单的把通过信号实现进程间通信的一些注意点了解了一下,感觉就是有点难受,配上机翻但没完全机翻的字幕,注意力被分散的有些厉害,勉勉强强过完了网课… 建议还是在看网课之前好好看一遍书
准备
对应课程
这次的Shell Lab作业。如果是自学,在B站课程中,应该大致需要完成P14~P15的学习,也就是书中第8章的内容,大致了解异常、中断等概念,了解进程上下文切换的实现,需要学会使用fork()
, wait()
, waitpid()
, kill()
, execve()
等函数,同时通过安装信号处理函数和设置信号掩码来实现信号的处理。
和上次的Cache Lab中间还隔开了一个P13是关于链接的,也就是书本第7章的相关内容,可惜这里并没有在Lab中有很好的体现,学还是要学的…
课程文件
相关的作业还是在CMU的官网上,相同位置:
在Shell Lab一栏中,我们可以查看相关文件,例如:
下载后并解压的文件如图所示:
使用环境
继续使用Arch Linux作为我的日常试验机…
题目
作业要求
目标
根据PDF讲义中的说法,我们需要修改文件夹下的tsh.c
文件,并通过编译,完成tsh
这个Shell程序。
而我们需要我们自己完成的函数有:
eval
:解析并执行命令行输入的主进程。builtin_cmd
:检测命令是否为内置的命令,在这次作业中需要实现的命令有:quit
,fg
,bg
和jobs
。do_bgfg
:实现bg
和fg
这两个内置指令,也就是前台和后台运行的两种情形。waitfg
:等待一个前台任务结束。sigchld_handler
:处理SIGCHLD
信号的信号处理函数。sigint_handler
:处理SIGINT(Ctrl-C)
信号的信号处理函数。sigtstp_handler
:处理SIGTSTP(Ctrl-Z)
信号的信号处理函数。
我们稍后对这几个函数慢慢的看过去…
Shell可以使用多个内建方法,例如他们的功能为:
jobs
:列出正在运行和停止的后台作业。bg <job>
:将一个停止的作业转入后台开始运行。fg <job>
:将一个停止或者正在运行的后台作业转入前台进行运行。kill <job>
:终止一个作业。(这个貌似没要求)quit
:退出终端程序
Shell简介
具体的实现规格:
- 每一行会输出一个
tsh>
,然后等待用户输入。 - 用户的输入包括
name
加上零个或多个参数,这些参数之间用一个或多个空格分隔。如果name
是内置命令,那么直接执行然后等待下一个命令,否则Shell需要认为name
是一个可执行文件的路径,需要新建一个子进程,并在子进程中完成具体的工作。 - 不需要支持管道或者I/O重定向。
- 输入
Ctrl-C
或Ctrl-Z
会给当前的前台进程(包括其子进程)发送SIGINT(SIGTSTP)
信号,如果没有前台任务,那么信号不应该产生任何效果。 - 如果输入的命令以
&
结尾,那么就要以后台任务的方式执行,否则按照前台执行。 - 每个作业都有其进程 ID(PID) 和 job ID(JID),都是由 tsh 指定的正整数,JID 以
%
开头(如%5
表示 JID 为 5,而5
则表示 PID 为 5)。 - 我们需要支持的内置命令有
quit
指令退出 Shell。jobs
指令列出所有的后台作业。bg job
给后台job
发送SIGCONT
信号来继续执行该任务,具体的job
数值可以是 PID 或 JID。fg job
给前台job
发送SIGCONT
信号来继续执行该任务,具体的job
数值可以是 PID 或 JID。
- tsh 应该回收所有的僵尸进程,如果任何作业因为接收了没有捕获的信号而终止,tsh 应该识别出这个时间并且打印出 JID 和相关信号的信息。
编写安全信号处理程序的原则
关键的要点在P534提及:
-
处理程序尽可能的简单。
避免麻烦的最好方法是保持处理程序尽可能的小和简单。例如,处理程序可能只是简单地设置全局标志并立即返回;所有与接收信号相关的处理程序都由主程序执行,它周期性地检查(并重置)这个标志。
-
在处理程序中只调用异步信号安全的函数。
所谓异步安全的函数能够被信号处理程序安全的调用,原因有二:要么它是可重入的(例如只访问局部变量),要么它不能被信号处理程序中断。很多常见的函数都不是安全的,例如
printf
,sprintf
,malloc
和exit
。P. S.
例如我们有如下的例子:
1
2
3
4
5
6
7
8
9
10
11
12int main(){
Signal(SIGINT, sigint_handler); //install the SIGINT handler
//....
while(1){
printf("Hello\n");
}
//...
}
void sigint_handler(){
printf("hi there\n");
}printf
每次需要获取输出缓冲区的锁才能对终端进行输出,如果我们恰好在printf
获得锁后将其通过一个信号打断,在信号处理程序中,如果我们继续调用printf
方法,就会发生死锁,信号处理程序等待原程序释放锁,然而那是不可能的,因此就会一直等下去…这也就暗示了,要么我们控制
printf
无法被信号打断,要么我们就将其设置为可重入的来解决死锁的问题。P. S.
另外这次的Lab中,在信号处理函数中大量使用了
printf
等方法,不知道是否是无心之举,还是纯粹是为了简化程序…事实上在信号处理程序中产生输出的唯一方法是使用
write
函数。而特别的,调用printf
或者sprintf
是不安全的,为了绕开这个限制,CS:APP特意开发了一些安全的函数,称为SIO
(安全的I/O包),可以用来在信号处理程序中打印简单的消息。1
2
3
4
5
ssize_t sio_putl(long v);
ssize_t sio_puts(char s[]);
void sio_error(char s[]);相关的示例程序在Code Examples:
-
保存和恢复
errno
。许多Linux异步安全的函数都会在出错返回时设置
errno
。在处理程序中调用这样的函数可能会干扰主程序中其他依赖于errno
的部分。解决办法是在进入处理程序时把errno
保存在一个局部变量中,在处理函数返回前恢复它。注意,只有在处理程序需要返回时才有此必要。如果处理程序调用_exit
终止该进程,那么就不需要这样做了。 -
阻塞所有的信号,保护对共享全局数据结构的访问。
如果处理程序和主程序或其他处理程序共享一个全局数据结构,那么在访问(读或者写)该数据结构时,你的处理程序和主程序应该暂时阻塞所有的信号。这条规则的原因是从主程序访问一个数据结构
d
通常需要一系列指令,如果指令序列被访问d
的处理程序中断,那么处理程序可能会发现d
的状态不一致,得到不可预知的结果。在访问d
时暂时阻塞信号保证了程序不会中断该指令。 -
用
volatile
声明全局变量。volatile
限定符强迫编译器每次在代码中引用g
时,从内存中读取g
的值。一般来说,和其他所有共享数据结构一样,应该暂时阻塞信号,保护每次对全局变量的访问。 -
用
sig_atomic_t
声明标志。在常见的处理程序设计中,处理程序会写全局标志来记录收到了信号。主程序周期性的读取这个标志,相应信号,再清除该标志。对于通过这种方式来共享的标志,C提供一种整型数据类型
sig_atomic_t
,对它的读和写保证会是原子的,因为可以用一条指令来实现它们:1
volatile sig_atomic_t flag;
我们可以通过这样的方法安全的读写,而不需要暂时的阻塞信号。
完成程序
错误包装函数
通过错误处理包装函数,我们可以进一步的简化代码。对于一个基本的函数,我们定义一个具有相同参数的包装函数,但是第一个字母大写。
这些代码其实也被CS:APP附在了csapp.c
的代码中,除了自己写也可以直接去上面提及的Code Examples里面找。
这些代码用于调用基本函数,检查错误,如果有任何问题则终止:
1 | /****************************** |
waitfg
实现
首先看一下注释中的要求:
1 | /* |
当我们在执行一个前台作业时,shell会选择卡在那里直到作业终止或者停止,暂时不接受接下来的输入,就像这样:
waitfg
函数实现的就是这样的功能,我们选择在前台作业结束前在此位置等待。
我们可以选择自旋的做法,使用fgjob
不断的比较在前台的作业PID和传入PID。如何确定当前在前台运行的作业PID是什么呢?tsh.c
中内置了fgpid
方法:
1 | /* fgpid - Return PID of current foreground job, 0 if no such job */ |
而如果当前没有前台任务,则会返回0
。
因此可以有:
1 | void waitfg(pid_t pid){ |
但是这样… 未免太消耗处理器资源了,我们不断的进行循环,每次还会进行条件的判断。
参考书中P546的做法,我们不仿使用全局变量fgchld_flag
来表示前台作业的状态,表示是否有前台作业尚未完成。根据刚才的信号处理函数的书写指南,当我们对这样的变量进行操作时,应当使用volatile
来确保其可见,使用sig_atomic_t
类型确保其操作原子:
1 | volatile sig_atomic_t fgchld_flag = 1; //一开始赋值1,表示没有前台作业 |
这样的话,我们在SIGCHLD
信号的处理函数中只要顺带对fgchld_flag
进行修改为1
表示无前台作业,让waitfg
发现fgchld_flag
发生了改变,也就达成了告知其前台作业已经结束的目的了。
前台作业我们可以不同于选择自旋的做法,我们使用sigsuspend
进行暂停来节省处理器资源。
关于为何使用sigsuspend
而非pause
,在书中和视频中已经讲的很清楚了,为了防止出现Data race现象,如果在while
循环刚检测完fgchld_flag
时决定进入循环时突然收到打断的SIGCHLD
直接修改了fgchld_flag
值,就没能达成通知的目的,程序会一直卡死在pause
无法醒来。相比之下suspend
函数等价于如下代码的原子(不可中断)版本:
1 | sigprocmask(SIG_SETMASK, &mask, &prev); |
我们确保只有在puase()
的时候才会接受SIGCHLD
信号。
所以我们要这样写:
1 | sigset_t mask_empty; |
所以最后的程序如下:
1 | /* |
sigint_handler
实现
先从简单的来~ 例如如何实现SIGINT
信号的处理,我们看一下tsh.c
中的注释:
1 | /* |
当内核向shell发送一个SIGINT
信号时,我们将其捕获,并将其发送给前台工作。
tsh.c
中内置的fgpid
方法刚才已经讲过:
1 | /* fgpid - Return PID of current foreground job, 0 if no such job */ |
而如果当前没有前台任务,则会返回0
。按照Shell简介,如果没有前台任务,那么SIGINT
信号不应该产生任何效果。
此外,由于当我们调用fgpid
时,需要访问全局数据结构jobs
。
1 | struct job_t { /* The job struct */ |
因此,按照之前的安全信号处理原则,最好在此期间对所有信号进行阻塞,所以我们有:
1 | /* |
P. S.
kill
函数并不完全代表“杀死某个进程”,不要被他的名字迷惑了,它的功能描述为用于向任何进程组或进程发送信号。我们除了发送SIGKILL(9)
以外,还有很多选择。
sigtstp_handler
实现
我们先看注释要求:
1 | /* |
输入Ctrl-Z
会给当前的前台进程(包括其子进程)发送 SIGTSTP
信号,如果没有前台任务,那么信号不应该产生任何效果。
emmmm,看起来和上面这个没差,只是发送的信号不一样,照抄下来:
1 | /* |
sigchld_handler
实现
接下来这个可能就会有点难度了,在这里我们需要处理捕获SIGCHLD
信号时的行为。
P. S.
注意这里有一个误区需要纠正,
SIGCHLD
不是只有子进程终止时这一种情形。SIGCHLD
信号产生的条件有:
- 子进程终止时会向父进程发送
SIGCHLD
信号,告知父进程回收自己,但该信号的默认处理动作为忽略,因此父进程仍然不会去回收子进程,需要捕捉处理实现子进程的回收;- 子进程接收到
SIGSTOP
信号停止时;- 子进程处在停止态,接受到
SIGCONT
后唤醒时。同样,
waitpid
功能也完全不是等待进程终止,而更强调是进程的状态发生改变,例如正常退出exit
,被信号终止,被信号暂停等等,因此我们可以使用WIFEXITED
,WIFSTOPPED
,WIFCONTINUED
等手段去确认。而且waitpid
也不是一定要阻塞,我们通过在options
中加入WNOHANG
就可以避免阻塞,找不到就直接返回-1
。更多详细细节可以看:waitpid(3) - Linux man page
我们先看一下注释下的要求:
1 | /* |
题目明确有如下几点需求:
- 不等待其他正在运行的进程终止,即不阻塞。考虑在
waitpid
的options
参数中加入WNOHANG
。 - 我们处理的子进程包含两种情形,一种是终止(称为僵尸进程),一种是进程收到
SIGSTOP
或者SIGTSTP
信号而暂停。由于是子进程,因此waitpid
中的pid
参数设置为-1
表示等待子进程,此外还需在options
参数中加入WUNTRACED
,表示当子进程暂停时也返回其PID。
此外调用waitpid
时,可能会对errno
进行修改,例如没有子进程时,就会修改为ECHILD
。因此我们需要对errno
进行保存和恢复,参照刚才的安全信号程序处理原则。
当子进程终止或者暂停时,我们需要对作业列表进行修改,选择删除作业或者将作业状态改为暂停的操作,我们会对jobs
作业列表进行访问,和刚才一样记得屏蔽所有信号事后恢复。
例如从作业表中删除当前进程,我们可以使用deletejob
:
1 | /* deletejob - Delete a job whose PID=pid from the job list */ |
假如我们要修改作业表中的状态status
或者打印相关的日志,我们首先还需根据子进程的PID定位到其在作业表中对应的job
实例,我们可以使用getjobpid
:
1 | /* getjobpid - Find a job (by PID) on the job list */ |
另外别忘了,刚才写的waitfg
还卡在那里呢,我们打断了waitfg
之后需要把全局变量fgchld_flag
改成1
,表示前台作业完成达到通知的目的,以此取消阻塞。
因此我们获得以下代码:
1 | /* |
builtin_command
实现
在这里我们需要实现题目中所给出的四个内建命令,分别是jobs
, quit
, bg
, fg
。
注释要求如下:
1 | /* |
我们直接将参数传入即可,然后根据内容按情况讨论,如果是内建命令就返回1
并执行,否则返回0
。
相关代码可参考书P525,但是不是很全…
至于相关的指令,quit
发生时,我们直接使用exit
退出Shell;
jobs
列出时,主进程会调用listjobs
函数,将作业表jobs
中的所有内容打印出来(访问了全局变量jobs
,建议信号阻塞一下):
1 | /* listjobs - Print the job list */ |
fg
或者bg
开头时,表示这个作业需要我们(转到)前台/后台运行,转到do_bgfg
,这个函数我们还没完成,等下写。
此外&
被单独列为了内建指令,P525中也有提及,我们的操作是直接忽略并返回。
所以我们有:
1 | /* |
do_bgfg
实现
在这里我们实现fg
和bg
这两个内建命令的实现,它们的功能分别是将对应的PID/JID转入前台/后台运行。
注释要求如下:
1 | /* |
由于fg
或者bg
的指令传入的参数可能是PID也可能是JID,我们需要根据参数的特征进行分别,也就是开头是否带%
带上的就是JID,否则就是PID。当然我们还要考虑异常情况,例如参数PID/JID均不匹配,没有找到PID/JID对应的作业实例等情况。
1 | void do_bgfg(char **argv) { |
接下来,我们将指定的进程(组)通过SIGCONT
运行起来。调用kill
函数即可。
如果是前台进程,我们需要将其state
设置为FG
,如果是后台进程就是就是BG
。如果是前台进程,我们还需要将Shell主进程挂起,等待到前台进程运行结束方可解除。
程序写作如下:
1 | /* |
eval
实现
eval
函数为shell的主处理函数,在书本P525处有类似的参考。
我们以此为基础进行处理:
1 | /* |
在中间处理过程中,小心注意屏蔽SIGCHLD
信号,如果SIGCHLD
在waitfg(pid)
前发生,极有可能提前更改了标志,而waitfg
就会卡在那里…
如果在addjob
前没有屏蔽SIGCHLD
信号,就有可能提前进入到SIGCHLD
处理程序中执行deletejob
… 在addjob
前使用deletejob
这显然也是不正常的…
测试
首先我们在文件夹下通过make
完成编译。
接下来共有16个测试,试图分析一下正确性:
trace01
这个一开始就是完成的,程序中有如下片段:
1 | if (feof(stdin)) { /* End of file (ctrl-d) */ |
我们也可以使用Ctrl-D
触发。
trace02
这里就是测试内置的quit
命令是否正常。
trace03
这里测试了调用一个前台作业是否成功,命令为/bin/echo tsh> quit
。
trace04
这里测试了后台程序是否能够正常使用,我们看到最后myspin
在运行了1s
后正常退出了。
trace05
这里测试jobs
内置指令是否正常,我们看到连续后台运行了两个程序,在jobs
中作业表都很好的体现了出来。
trace06
这里测试了前台程序对SIGINT
的反应,我们看到./myspin 4
被SIGINT
终止了,还打印出了相关日志。
trace07
这里测试尝试只对前台程序发送SIGINT
,我们看到后台程序./myspin 4 &
没有收到影响,而前台程序./myspin 5
被终止了。
trace08
这里测试尝试只对前台程序发送SIGTSTP
信号,我们看到后台程序./myspin 4 &
没有收到影响,而前台程序./myspin 5
被暂停了。
trace09
这里测试bg
内置指令,我们看到被暂停的./myspin 5
通过bg %2
运行了起来。
trace10
这里测试fg
内置指令,工作正常。
trace11
这里我们测试向前台进程组的每一个进程都发送SIGINT
,我们看到./mysplit 4
终止后,没有子进程残留。
trace12
这里测试了向前台进程组的每一个进程都发送SIGTSTP
,我们看到./mysplit 4
相关子进程都已经处于暂停的状态。
trace13
这里测试了重启进程组中的每一个进程,我们看到./mysplit 4
首先被暂停,在进程表中有显示,随后被fg
调起到前台运行,最后正常退出。
trace14
这里主要测试了异常的处理,我们输入各种不正确的输入,程序均予以了处理。
trace15
拼合的测试,目测没有问题…
trace16
这里测试了shell能否处理来自其他进程而非终端的SIGINT
和SIGTSTP
信号。
我们看到程序/mystop 2
运行了2s
后,自己暂停了自己;程序./myint 2
运行了2s
后,自己终止了自己。
自此,所有的程序测试都已完成。
总结
最后的代码如下:
1 | /* |
感觉不管是看书还是听课,这一章都过的迷迷糊糊的,但是通过完成Shell Lab,很快的帮助我扫除了很多的困惑和误解,果然上手做一做才是真的。