实现一个简单的shell,光是看官方的pdf就看了半天。
其实很简单,我们只要完善七个函数就好了,其中三个信号相关的。每次更新我们都要使用make命令编译一下。
这样就完成了更新。
一共有七个任务:
- eval: Main routine that parses and interprets the command line. [70 lines]
- builtin cmd: Recognizes and interprets the built-in commands: quit, fg, bg, and jobs. [25 lines]
- do bgfg: Implements the bg and fg built-in commands. [50 lines]
- waitfg: Waits for a foreground job to complete. [20 lines]
- sigchld handler: Catches SIGCHILD signals. 80 lines]
- sigint handler: Catches SIGINT (ctrl-c) signals. [15 lines]
- sigtstp handler: Catches SIGTSTP (ctrl-z) signals. [15 lines]
中文解释:
- 解析和解释命令行的main程序。[70行]
- 内置命令:识别和解释内置命令:quit、fg、bg和jobs。[25行]
- 执行bgfg:实现bg和fg内置命令。[50行]
- 等待前台作业完成:等待前台作业完成。[20行]
- SIGCHLD处理程序:捕获SIGCHILD信号。[80行]
- SIGINT处理程序:捕获SIGINT(ctrl-c)信号。[15行]
- SIGTSTP处理程序:捕获SIGTSTP(ctrl-z)信号。[15行]
中括号里的行数是预期函数代码。
信号处理函数
正如书中所说信号处理是linux系统编程中最棘手的一个问题。
如何安全的进行信号处理?
- 处理程序尽可能简单
- 在处理程序中只调用异步信号安全的函数。
- 保存和恢复errno。许多Linux异步信号处理程序都会在出错返回时设置errno。在处理程序运行时可能会干扰主程序中其他依赖于errno的部分。解决方法是在进入处理程序时将errno保存到一个局部变量,在处理函数返回前恢复它。如果处理程序用_exit终止该程序,那么就不需要这样做。
- 阻塞所有的信号,保护对共享全局数据结构的访问。如果处理程序和主程序或其他处理程序共享一个全局数据结构,那么在访问该数据结构时,处理程序和主程序应该暂时阻塞所有的信号。这条规则的原因是从主程序访问一个数据结构d通常需要一系列指令,如果指令序列被访问d的处理程序中断,那么处理程序可能会发现d的状态不一致,得到不可预知的结果。在访问d时暂时阻塞信号保证了处理程序不会中断该指令序列。
sigint_handler(✔)
先来个行数最小的,捕获SIGINT,也就是令cltr-c得到处理。有两点需要注意:1)这个程序不是被_exit终止的,所以我们最好设置一下errno。2)处理程序访问了全局数据jobs,安全起见我们暂时阻塞所有信号。
1 | void sigint_handler(int sig) |
要注意的点就是kill函数第一个参数设置为负数,可以对整个进程组发送信号,防止产生孤儿进程。
sigtstp_handler(✔)
SIGSTOP属于不可忽略信号,作用是暂停前台作业。
1 | void sigtstp_handler(int sig) |
sigchld_handler(✔)
子进程终止或停止操作系统会向父进程发出SIGCHLD信号。
先看书中的几个例子。首先是这个例子,调用waitpid并将第一个参数设置为-1,等待集合由父进程的所有子进程组成。
在只有单一进程的情况下,这种处理方法是没有问题的,可是如果子进程有很多,就会出现丢失信号的情况。因为等待列表里最多只能有一个信号,当接收到SIGCHLD信号的时候,调用处理程序,SIGCHLD信号被阻塞,如果此时传来一个SIGCHLD信号他就会进入等待列表,等第一个信号处理完之后,解除阻塞,触发处理程序,一旦这时候在传来SIGCHLD信号,信号不会进入列表等待,而是直接丢失。要解决这个问题,我们就得明白信号是不会排队等待的。
下面是解决信号不排队等待问题的方案,设置了一个循环,waitpid函数如果成功等待进程停止就会返回pid>0,进入循环后阻塞全部的信号,删除作业,解除阻塞。
1 | void sigchld_handler(int sig) |
注意waitpid的参数设置,这里不再是默认的,而是使用了status和options,status记录进程状态信息,options更改等待子进程的行为。默认情况options是0,等待集合中的任意一个子进程结束,如果集合中有后台进程,那么waitpid函数也会傻傻的等待,直到后台进程执行完毕才会重新弹出>,所以这里设置了参数WNOHANG | WUNTRACED,此时waitpid的行为是立即返回,即不会等待进程停止或结束。
输入处理函数
eval函数(✔)
先看一下书上的作为参考
eval函数在main函数中的调用
1 | /* Execute the shell's read/eval loop */ |
功能:首先检测第一个命令行参数是否为shll的内置命令()/
参数是commandline也就是我们的输入
函数功能:
接收我们的输入作为参数,如果是内置命令则直接执行,如果不是内置命令,创建一个新的进程调用execve运行程序。
在标准的Unix shell运行shell时,shell在前台进程组中运行,所以我们用fork创建的子进程也就是我们的前台作业也属于shell的进程组。但是ctrl+c会向前台进程组的每个进程发送SIGINT信号,也就是内核会向shell和shell创建的每个进程,我们使用setpid(0,0),将创建的子进程放到一个引得进程组中,新的进程组ID和PID相同。这样当按下ctrl+c时就只会对shell发送信号
1 | void eval(char*cmdline) |
这里比较关键的是setpgid(0,0)
,当使用了execve函数开始一个新的进程时,按下ctrl+c不会将我们的tsh程序关闭,因为该函数将新的进程放入了一个新的进程组中,通过键盘发送的信号被tsh进程捕获然后通过kill函数像子进程组发送SIGINT信号就好啦。
builtun_cmd(✔)
如果是内置命令(quit、fg、bg、jobs)则直接执行,如果不是则返回0。
1 | int builtin_cmd(char **argv) |
waitfg(✔)
等待前台作业结束,这个参考了书上的代码,也想了很久
书中建议使用sigsuspend函数进行阻塞,既能解决单独pause引来的竞争问题,又能解决sleep速度太慢的问题。
注意这个向量使用的是prev,该函数的实际作用是:1)将阻塞列表设为prev。2)在捕捉到一个信号之前,该进程被挂起(pause函数)。3)如果捕捉到一个信号而且从处理程序返回则sigsuspend返回,并且将该进程的阻塞列表恢复,如果是终止信号则该进程不从sigsuspend返回直接终止。
1 | void waitfg(pid_t pid)//linux shell在接收下一个命令之前,必须显示地等待前台作业终止 |
do_bgfg(✔)
执行内置的bg函数和fg函数,在builtin_cmd函数中被调用,其参数时命令行参数列表。
在完成这个函数之前我们要认识一下fg命令和bg命令。
fg是foreground(前台)的缩写,用于将一个在后台的进程切换到前台,并恢复执行
fg
将最近放入后台的进程移动到前台执行fg %jid
根据jid进行操作fg pid
根据pid进行操作
bg是background(后台)的缩写,用于将一个暂停的程序放入后台执行。操作和fg相同。
下面在真正的shell演示一下:
先来一个sleep 1000
当前进程处于休眠状态,1000秒后返回控制,我们可以将其理解为一个运行中的程序。
CTRL+Z将其暂停
jobs查看作业列表
使用fg命令将其切换到前台并执行
然后CTRL+z将其暂停,使用bg命令让其在后台运行,jobs查看状态显示running
无论是fg还是bg都涉及到恢复一个stopped状态的进程,那么如何恢复一个进程?
由此可知,我们在这个函数内要调用kill函数。
1 | void do_bgfg(char **argv) //argv是一个指向字符串指针的指针 |
要注意的是使用kill的时候参数pid要设置为负数,设置为负数会将pid所在进程组的所有进程关闭。
检查
这里讲一下检测的方法,在lab这个目录下运行
1 | make testxx //出现的结果是我们编写的shell跑出的结果 |
检查真的很有必要,在前面的函数是现阶段代码框架大致都有了,但是就像书中提到的涉及到linux信号处理的编程时十分棘手的,稍有不慎就会引发错误,而且有的错误藏得很深,可能运行几十次代码他都是正确的,但是有可能就是下一次,问题就出现了。一定要通过实验中提供的验证手段,逐个检查,遇到问题就去调试,或者用printf插入一些桩,我的方法是在容易出问题的地方(信号的阻塞与解除阻塞)打印出状态。
问题一
按下CTRL+z之后,应该暂停前台进程并返回我们的shell,打印出“tsh>”,但是
可以看到进入了处理程序,并且暂停了前台程序。原来的函数是没有
1 | getjobpid(jobs,pid)->state=ST;//将前台作业标记为ST |
这条语句的,经过测试程序会卡死到恢复信号那个步骤,试想,当我们恢复信号,上面用kill指令对pid进程组的所有进程发送SIGTSTP信号,当我们的前台进程被停止的时候,内核会向父进程发送SIGCHLD信号,触发SIGCHLD。而这个程序被卡死在了SIGCHLD的处理程序中,说的更透彻一点,其实被卡在了
waitpid这个函数里面,waitpid会挂起父进程(shell)等待子进程结束,而我们已经通过kill对子进程发送了SIGTSTP信号,子进程已经停止了,waitpid永远不会结束。这显然是SIGCHLD信号处理函数的漏洞,刚开始我错误的将SIGCHLD处理函数只用来处理终止的进程,没有考虑到停止的进程也会发送信号。所以我们要在SIGTSTP处理函数那里设置job的状态,是状态变为ST然后在SIGCHLD处理函数中增加条件判断
知识盲点
信号处理函数可以被其他信号处理程序中断
woc,看代码没看仔细,里面的Signal函数不是signal,而是对sigaction函数的封装。
Unix信号处理在不同的系统有不同的信号处理语义。
一些老的Unix系统在信号k被处理程序捕获之后就把对信号k的反应恢复到默认值(怪不得我看到有文章这样说,但实践了一下发现不是这样的),在这些系统上,每次运行之后,处理程序必须调用signal函数显示地重新设置自己,即在信号处理函数中使用signal重新设置。
像read、write这样的下系统调用潜在的会阻塞进程一段较长的时间,在一些比较早版本的unix系统中,当处理程序捕获到一个信号时,被中断的慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR,在这些系统上,程序员必须手动重启被中断的系统调用。
在这个lab中使用的使一个包装函数Signal,他调用了sigaction
1
2
3
4
5
6
7
8
9
10
11
12handler_t *Signal(int signum, handler_t *handler)
{
struct sigaction action, old_action;
action.sa_handler = handler;
sigemptyset(&action.sa_mask); /* block sigs of type being handled 阻塞正在处理的信号类型 */
action.sa_flags = SA_RESTART; /* restart syscalls if possible 如果可能,重新启动系统调用,即系统调用被中断后不需要手动恢复*/
if (sigaction(signum, &action, &old_action) < 0)
unix_error("Signal error");
return (old_action.sa_handler);
}信号处理语义如下:
- 只有这个处理程序当前正在处理的那种类型的信号被阻塞。
- 和所有信号实现一样,信号不会排队等待。
- 只要可能,被中断的系统调用会重新启动。
- 一旦设置信号处理程序,他就会一直保持。
吐了原来那些信号处理函数是默认的。。。怎么说?因为当我们执行/bin/sleep命令时,其实是通过execve函数加载的新程序,execve加载之后信号处理函数恢复默认。
全部的检测结果太长了,这里放一个比较复杂的代表了。