实验介绍
完成一个简单的shell程序,总体的框架和辅助代码都已经提供好了,我们需要完成的函数主要以下几个:
- eval: 主要功能是解析cmdline,并且运行. [70 lines]
- builtin cmd: 辨识和解析出bulidin命令: quit, fg, bg, and jobs. [25lines]
- do bgfg: 实现bg和fg命令. [50 lines]
- waitfg: 实现等待前台程序运行结束. [20 lines]
- sigchld handler: 响应SIGCHLD. 80 lines]
- sigint handler: 响应 SIGINT (ctrl-c) 信号. [15 lines]
- sigtstp handler: 响应 SIGTSTP (ctrl-z) 信号. [15 lines]
难点主要在于对信号的处理,需要我们捕获信号,改变其对应的处理方式。
其他需要注意的地方:
- 系统函数的返回值检查,一定要多注意有可能出错的地方;
- 竞争条件,fork子进程之后,如果子进程很快就结束了,而此时主进程还没addjob就会有问题,总之就是不能假设进程之间以安全的顺序执行,这里利用互斥量的思路,主进程会阻塞子进程的信号,直到addjob之后;
- SIGCHLD信号处理函数,考虑多个子进程结束,以及非正常结束时waitpid的返回值,后面结合课本里详细说。
有关Shell
我们要实现的shell有两种执行模式
- 如果用户输入的命令是内置命令,那么 shell 会直接在当前进程执行(例如 jobs)
- 如果用户输入的是一个可执行程序的路径,那么 shell 会 fork 出一个新进程,并且在这个子进程中执行该程序(例如 /bin/ls -l -d)
第二种情况中,如果命令以& 结束,那么这个job在后台执行
需要支持的功能:
- job control:允许用户更改进程的前台/后台状态以及京城的状态(running, stopped, or terminated)
- ctrl-c 会触发 SIGINT 信号并发送给每个前台进程,默认的动作是终止该进程
- ctrl-z 会触发 SIGTSTP 信号并发送给每个前台进程,默认的动作是挂起该进程,直到再收到 SIGCONT 信号才继续
- jobs 命令会列出正在执行和被挂起的后台任务
- bg job 命令可以让一个被挂起的后台任务继续执行 ,fg job 命令同理
参考课本代码
先来看看课本上我们可以参考的代码有哪些
P525 eval()函数原型
void eval(char *cmdline)
{
char *argv[MAXARGS]; /* Argument list execve() */
char buf[MAXLINE]; /* Holds modified command line */
int bg; /* Should the job run in bg or fg? */
pid_t pid; /* Process id */
strcpy(buf, cmdline);
bg = parseline(buf, argv); //解析命令行函数都提供好了
if (argv[0] == NULL)
return; /* Ignore empty lines */
if (!builtin_command(argv)) {
if ((pid = Fork()) == 0) { /* 子进程来执行job */
if (execve(argv[0], argv, environ) < 0) {
printf("%s: Command not found.\n", argv[0]);
exit(0);
}
}
/* 如果是前台作业,主进程需要等待子进程运行完毕 */
if (!bg) {
int status;
if (waitpid(pid, &status, 0) < 0)
unix_error("waitfg: waitpid error");
}
else
printf("%d %s", pid, cmdline);
}
return;
}
基本功能都完成了,唯一的不足就是由于joblist的存在,需要考虑竞争条件,也就是主进程一定要先addjob,然后才能deletejob
然后看一下信号处理函数,实验代码里已经要求了,需要对以下三个信号进行处理
Signal(SIGINT, sigint_handler); /* ctrl-c /
Signal(SIGTSTP, sigtstp_handler); / ctrl-z /
Signal(SIGCHLD, sigchld_handler); / Terminated or stopped child */
那么就来看看书上P532的示例:
#include "csapp.h"
void sigint_handler(int sig) /* SIGINT handler */ //line:ecf:sigint:beginhandler
{
printf("Caught SIGINT!\n"); //line:ecf:sigint:printhandler
exit(0); //line:ecf:sigint:exithandler
} //line:ecf:sigint:endhandler
int main()
{
/* Install the SIGINT handler */
if (signal(SIGINT, sigint_handler) == SIG_ERR) //line:ecf:sigint:begininstall
unix_error("signal error"); //line:ecf:sigint:endinstall
pause(); /* Wait for the receipt of a signal */ //line:ecf:sigint:pause
return 0;
}
嗯,SIGINT就是我们想自己处理的信号,然后通过sigint_handler来进行自定义的处理。(当然这示例也忒简单了)
还有一个重要的信号不排队问题,涉及到父进程回收子进程:
- 子进程结束的时候向父进程发生信号,但是内核的规矩是这样的:
在任何时刻,一种类型至多只会有一个待处理信号(内核中负责维护待处理信号的pending位向量对应的特定信号类型只有一位),也就是说信号是不会排队的,如果处理信号A的过程中又来了信号B,信号B是会被阻塞的,此时又来信号C,那么信号C就被丢弃了,处理办法也很简单,每次处理信号的时候,用while循环尽可能多接收几个信号。
书上的对应代码P539
void handler2(int sig)
{
int olderrno = errno;
while (waitpid(-1, NULL, 0) > 0) {
Sio_puts("Handler reaped child\n");
}
if (errno != ECHILD)
Sio_error("waitpid error");
Sleep(1);
errno = olderrno;
}
接下来再看信号阻塞,也就是要避免父进程和子进程多job列表操作的竞争
linux提供阻塞信号的隐式机制和显式机制
- 隐式:默认阻塞当前处理程序正在处理的信号类型
- 显式:使用sigprocmask函数
具体怎么使用可以参考课本P543的promask2.c
#include "csapp.h"
void handler(int sig)
{
int olderrno = errno;
sigset_t mask_all, prev_all;
pid_t pid;
Sigfillset(&mask_all);
while ((pid = waitpid(-1, NULL, 0)) > 0) { /* Reap a zombie child */
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); //阻塞所有信号
deletejob(pid); /* 对joblist安全删除 */
Sigprocmask(SIG_SETMASK, &prev_all, NULL); //恢复所有信号
}
if (errno != ECHILD)
Sio_error("waitpid error");
errno = olderrno;
}
int main(int argc, char **argv)
{
int pid;
sigset_t mask_all, mask_one, prev_one;
Sigfillset(&mask_all);
Sigemptyset(&mask_one);
Sigaddset(&mask_one, SIGCHLD);
Signal(SIGCHLD, handler);
initjobs(); /* Initialize the job list */
while (1) {
Sigprocmask(SIG_BLOCK, &mask_one, &prev_one); /* 阻塞 SIGCHLD */
if ((pid = Fork()) == 0) { /* Child process */
Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* 子进程解除阻塞 SIGCHLD */
Execve("/bin/date", argv, NULL);
}
Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* 阻塞所有信号*/
addjob(pid); /* 对job列表的操作安全了 */
Sigprocmask(SIG_SETMASK, &prev_one, NULL); /* 父进程解除阻塞 SIGCHLD */
}
exit(0);
}
看起来挺麻烦,实际我们可以简化的,实际写的时候只阻塞SIGCHLD即可。
课本里面还提到一点,主程序显式等待某个信号处理程序进行,尤其是shell创建前台作业的时候,基本思路是用循环,当接收到信号时,在信号处理程序中把while条件更改来跳出循环,这样做存在资源浪费以及无法精准唤醒的问题,比较好的解决方法是sigsuspend,相当于一个原子操作,可以暂时取消阻塞SIGCHLD,然后pause接收信号,紧接着恢复阻塞。
否则的话,本来的pause()是挂起进程直到收到信号,那么下面的代码有可能永远休眠
while(!pid)
pause(); //如果在while判断之后和pause之前收到信号,那就错过啦,所以需要原子操作
实验代码
经过上面对课本代码的复习,实际上好多功能都已经给出了实现方法,实验代码自然也就不难给出啦。
1. eval()
主要功能是对用户输入的参数进行解析并运行计算。如果用户输入内建的命令行(quit,bg,fg,jobs)那么立即执行。 否则,fork一个新的子进程并且将该任务在子进程的上下文中运行。如果该任务是前台任务那么需要等到它运行结束才返回。
- 注意每个子进程必须用户自己独一无二的进程组id,要不然就没有前台后台区分啦
- 在fork()新进程前后要阻塞SIGCHLD信号,防止出现竞争(race)这种经典的同步错误
void eval(char *cmdline)
{
char* argv[MAXARGS];
pid_t pid;
sigset_t mask;
int fgorbg = parseline(cmdline,argv);
if(argv[0] == NULL)
return;
sigemptyset(&mask);
sigaddset(&mask,SIGCHLD);
sigprocmask(SIG_BLOCK,&mask,NULL);
if(!builtin_cmd(argv)){
if((pid = fork()) == 0){
sigprocmask(SIG_UNBLOCK,&mask,NULL); //子进程也是要解除阻塞的
if(setpgid(0,0)<0)
unix_error("eval: setgpid failed.\n");
if(execve(argv[0],argv,environ)<0){
printf("%s: Command not found.\n",argv[0]);
exit(0);
}
}
if(!fgorbg)
addjob(jobs,pid,FG,cmdline);
else
addjob(jobs,pid,BG,cmdline);
sigprocmask(SIG_UNBLOCK,&mask,NULL); //这里一定要addjob之后再解除阻塞
if(!fgorbg) // FG job
waitfg(pid);
else
printf("[%d] (%d) %s\n",pid2jid(pid),pid,cmdline);
}
return;
}
2. builtin_cmd()
判断命令是否是内置指令,是的话立即执行,不是则返回,对单独的‘&’无视
int builtin_cmd(char **argv)
{
if(strcmp(argv[0],"quit")==0)
{printf("exit\n");exit(0);}
if(strcmp(argv[0],"jobs")==0)
{
listjobs(jobs);
return 1;
}
if(strcmp(argv[0],"bg")==0 || strcmp(argv[0],"fg")==0)
{
do_bgfg(argv);
return 1;
}
return 0; /* not a builtin command */
}
3. do_bgfg()
执行bg和fg指令功能
void do_bgfg(char **argv)
{
char *id = argv[1];
struct job_t *job;
int jobid;
if(id == NULL){
printf("%s command requires PID of jobid argument.\n",argv[0]);
return;
}
if(id[0] == '%')
jobid = atoi(id+1);
if((job = getjobjid(jobs,jobid))==NULL){
printf("Job does not exist.\n");
return;
}
if(strcmp(argv[0],"bg")==0){
job->state = BG;
kill(-(job->pid),SIGCONT);
}
if(strcmp(argv[0],"fg")==0){
job->state = FG;
kill(-(job->pid),SIGCONT);
waitfg(job->pid);
}
return;
}
kill函数的用法,向任何进程组或进程发送信号
int kill(pid_t pid, int sig);
参数pid的可能选择:
- pid大于零时,pid是信号欲送往的进程的标识。
- pid等于零时,信号将送往所有与调用kill()的那个进程属同一个使用组的进程。
- pid等于-1时,信号将送往所有调用进程有权给其发送信号的进程,除了进程1(init)。
- pid小于-1时,信号将送往以-pid为组标识的进程。
4. waitfg()
等待前台进程完成,这里偷懒了,没用sigsuspend,还是用了比较消耗资源的方法哈哈哈。。。
void waitfg(pid_t pid)
{
while(pid == fgpid(jobs));
return;
}
5. 几个信号处理函数
SIGINT处理比较简单,就是截获CTRL+C然后发给前台程序嘛
void sigint_handler(int sig)
{
pid_t pid = fgpid(jobs);
int jid = pid2jid(pid);
if(pid!=0){
printf("Job [%d] terminated by SIGINT.\n",jid);
deletejob(jobs,pid);
kill(-pid,sig);
}
return;
}
SIGTSTP就是把CTRL+Z发给前台
void sigtstp_handler(int sig)
{
pid_t pid = fgpid(jobs);
int jid = pid2jid(pid);
if(pid!=0){
printf("Job [%d] stopped by SIGINT.\n",jid);
(*getjobpid(jobs,pid)).state = ST;;
kill(-pid,sig);
}
return;
}
SIGCHLD是最麻烦的了,参考网上大牛的方法,需要考虑子进程返回的原因
运用waitpid()函数并且用WNOHANG|WUNTRACED参数,该参数的作用是判断当前进程中是否存在已经停止或者终止的进程,如果存在则返回pid,不存在则立即返回
通过另外一个&status参数,我们可以判断返回的进程是由于什么原因停止或暂停的。
- WIFEXITED(status):
如果进程是正常返回即为true,什么是正常返回呢?就是通过调用exit()或者return返回的 - WIFSIGNALED(status):
如果进程因为捕获一个信号而终止的,则返回true - WTERMSIG(status):
当WIFSIGNALED(status)为真时,设置该值,返回导致当前状态的信号编号 - WIFSTOPPED(status):
如果返回的进程当前是被停止,则为true - WSTOPSIG(status):
返回引起进程停止的信号
void sigchld_handler(int sig)
{
pid_t pid;
int status,child_sig;
while((pid = waitpid(-1, &status, WUNTRACED | WNOHANG)) > 0 ){
printf("Handling chlid proess %d\n", (int)pid);
/*handle SIGTSTP*/
if( WIFSTOPPED(status) )
sigtstp_handler( WSTOPSIG(status) );
/*handle child process interrupt by uncatched signal*/
else if( WIFSIGNALED(status) ) {
child_sig = WTERMSIG(status);
if(child_sig == SIGINT)
sigint_handler(child_sig);
}
else
deletejob(jobs, pid);
}
return;
}
总结
本次Shell Lab的收获有以下几点
- 对Shell有了更加深刻的理解,借助实验代码实现了不带重定向的简单shell
- 掌握信号的正确接收处理,阻塞和解除阻塞机制,写出避免竞争的代码
- 父进程和子进程的fork,回收,信号传递等
- linux下编程规范,以及进程相关函数的使用,学到了学到了