这次的CSAPP的实验是要自己实现一个shell(外壳),即自己实现一个命令行,要自己实现一个简单的我们在linux上常用的shell,想想就让人兴奋呢。
这次的实验环境,已经给我们搭好了程序的基本框架,只需要完成shell里面的几个关键函数就可以,相关的单元测试试验都已经给出,该试验涉及的内容包括:解析命令行参数,理解并使用(fork,execve,waitpid)常见的多进程函数,了解linux进程组,以及前台进程和后台进程的相关概念,理解linux的信号机制(包括发送信号,接受信号,阻塞信号等)。
先上相关的实验指导书的链接书地址
http://csapp.cs.cmu.edu/public/labs.html
本次shell实验要求我们主要填充的函数如下所示:
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]
dobgfg: 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]
同时试验给了15个测试文件和标准输出,需要我们编写的程序在测试要和标准输出基本相同。
测试的命令为:
make test01
make test02
...
make test16
标准输出文件为:tshref.out
实验中需要注意的几个关键点:
1. 读懂并理解CSAPP第8章异常控制流的每一个词。(这一点很关键,课本上给出了例如fork函数,execval函数,signal信号机制的讲解,并且给出了很多函数的初版函数,可以在此的基础上进行修改和理解。)
2. 利用你的测试文件来跟踪你的进展,你成功测试到那个测试文件说明的程序进展如下所示。
3. waitpid(回收僵尸进程,使用在sigchld_handler中) ,kill(发送信号给指定进程,函数原型为kill(pid,信号类型)如果pid为负的说明给该进程组的所有进程发送信息,该函数使用在sigint_handler和sigtstp_handler中),execve(让进程执行指定的程序,这个函数结合fork使用,让新fork出来的进程执行指定的外部命令),setpgid(设置进程进新的进程组,这个函数在后面会进行说明),sigprocmask将信号添加进阻塞进程中进行处理,阻塞之后要记得解阻塞哦。
4. 在kill函数中发送SIGINT和SIGTSTP信号的时候,记得使用-pid,给整个进程组发送信号。
5. 下面是waitfg和sigchld_handler的推荐写法:
-waitfg:利用忙等待来调用sleep(0),当然忙等待要针对前台进程而言。
-sigchld_handler:最好只使用一次waitpid,不然容易出现竞争条件。
6. 关于在eval里面要使用sigprocmask函数在addjob之前阻塞住SIGCHLD,不然在子进程执行完的时候如果还没有执行addjob,就会给父进程shell发送SIGCHLD,这时候调用sigchld_handler在addjob之前deletejob一个根本不存在的job,容易出现竞争条件。同时,阻塞之后要及时释放锁,释放锁的地方有三个地方,
一是:在子进程执行execve,即子进程执行其他程序的时候需要把锁还原。
二是:在父进程addjob之后要及时归还锁。
三是:不能把sigprocmask阻塞进程在build_cmd判断外面,不然会出现锁住了没有归还锁的情况。
7. 程序中不要使用/bin/vi,/bin/emacs,这些会改变shell本身设置的程序,尽量使用/bin/echo , /bin/ps,这些简单的程序。
8. 当按下ctrl-c 或者ctrl-z的时候,内核会发送SIGINT或者SIGTSTP信号给所有前台进程组,因为shell是前台进程,那么从shell中fork出的子进程都属于shell这个前台进程组的,那么我们每次SIGINT或者SIGTSTP信号都会给我们的所有进程,这明显是不合理的。
所以当我们在每次fork出子进程的时候,都要利用setpgid(0,0)把新加的进程添加到新的进程组中。
接下来是解题攻略的说明:
以下是eval函数体内容:
以下的Fork函数(),Sigaddset等都是封装了相关异常检测机制的函数进去
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*/
sigset_t mask;
strcpy(buf,cmdline);
bg=parseline(cmdline,argv);
//跳过空白指令
if(argv[0]==NULL)
{
return;
}
//Prevented from the completing condition, add the thing
if(!builtin_cmd(argv))
{
//Prevented from the completing condition, add the thing
//必须放在builtin_cmd里面,因为如果放在外面,在执行内部命令的时候,就不会释放这些锁了
Sigemptyset(&mask);
Sigaddset(&mask,SIGCHLD);
Sigprocmask(SIG_BLOCK,&mask,NULL);
if((pid=Fork())==0) //创建子进程,Running the child process.
{
Sigprocmask(SIG_UNBLOCK,&mask,NULL); //UNBLOCK the order
if(setpgid(0,0)<0)
{
unix_error("eval: setpgid failed.\n");
}
Execve(argv[0],argv,environ); //孩子进程自己执行他的应用程序
}
//Parent process:add new jobs and printf message
else
{
if(bg)
addjob(jobs,pid,BG,cmdline);
else
addjob(jobs,pid,FG,cmdline);
Sigprocmask(SIG_UNBLOCK,&mask,NULL); //UNBLOCK the order
//父进程等待前置进程结束
if(!bg)
{
waitfg(pid);
}
else
{
printf("[%d] (%d) %s",pid2jid(pid),pid,cmdline);
}
}
}
}
eval需要注意的点为:1.判断是否为内部函数,如果是内部函数,直接执行built_cmd函数就行。如果不是内部函数,需要Fork出一个新的进程去执行相应的函数。
2. 函数框架提供了parseline命令行解析工具,即可以通过该解析函数来得到命令行参数。如果不是内部函数,首先要先将SIGCHLD信号阻塞住,以防出现竞争条件。
3. 子进程解决信号阻塞,并执行相关函数。
4. 父进程要判断子进程是前台进程还是后台进程,如果是前台进程,则调用waitpid来等待前台进程,如果是后台进程,则打印出相关进程信息。同时,把新添加的进程利用addjob添加到工作组中。
以下是判断是否是内部命令的函数
int builtin_cmd(char **argv)
{
if(!strcmp(argv[0],"quit")) //退出命令
{
exit(0);
}
else if(!strcmp(argv[0],"&")) /* Ignore singleton & */
{
return 1;
}
else if(!strcmp(argv[0],"jobs")) //job order
{
listjobs(jobs);
return 1;
}
else if(!strcmp(argv[0],"bg") || !strcmp(argv[0],"fg"))
{
do_bgfg(argv);
return 1;
}
return 0;
}
这个就不多解释了
下面是waitpid函数内容,该函数是为前台进程而准备
void waitfg(pid_t pid)
{
while(pid==fgpid(jobs))
{
sleep(0);
}
}
下面是较为关键的三个信号处理函数的。
首先是处理孩子进程,即僵尸进程的函数
/*
* sigchld_handler - The kernel sends a SIGCHLD to the shell whenever
* a child job terminates (becomes a zombie), or stops because it
* received a SIGSTOP or SIGTSTP signal. The handler reaps all
* available zombie children, but doesn't wait for any other
* currently running children to terminate.
*/
void sigchld_handler(int sig)
{
int status;
pid_t pid;
//Waiting for/ handling all of the child processes according to their status
while((pid=waitpid(-1,&status,WNOHANG|WUNTRACED))>0) /* Reap a zombie child */
{
if(WIFSTOPPED(status))
{
sigtstp_handler(-pid);
}
//WIFSIGNALED表示因为未被捕获的信号而中断,适用于子进程自己给自己发送KILL而中断
else if(WIFSIGNALED(status))
{
//表示进程自己给自己发信号而造成的程序中止
sigint_handler(-pid);
}
else if(WIFEXITED(status))
{
deletejob(jobs,pid); /* Delete the child from the job list */
}
}
if(errno!=ECHILD)
unix_error("waitpid error");
return;
}
用while循环来避免信号阻塞的问题,为了回收所有的僵尸进程
说明一下waitpid的几个参数的含义
status表示中止进程或者停止进程的原因,WNOHANG | WUNTRACED表示立即返回,如果等待集合中没有进程被中止或停止返回0,否则返回进程的pid,
下面的几种宏的意思为:WIFSTOPPED(status):表示如果进程是因为停止的信号而停止,那么返回true. WIFSIGNALED(status):表示进程如果是因为未捕获的信号而中止,返回true。WIFEXITED(status):表示进程通过调用exit()或者return正常结束,则返回true。那么,明显的对于停止或者中止的程序,我们分别调用其的异常处理函数,即handler。传入的参数为-pid,这一点很关键,利用传入的参数为-pid可以有效的区别是因为ctrl-z或者ctrl-c或者通过kill函数发送的信号的区别。
下面是sigint_handler
/*
* sigint_handler - The kernel sends a SIGINT to the shell whenver the
* user types ctrl-c at the keyboard. Catch it and send it along
* to the foreground job.
*/
void sigint_handler(int sig)
{
pid_t pid=fgpid(jobs);
int jid=pid2jid(pid);
//只处理前台进程
if(pid!=0)
{
//说明是进程通过kill函数发送的信号, 通过sigchld_handler发的信号
if(pid==-sig)
{
printf("Job [%d] (%d) terminated by signal %d\n",pid2jid(-sig),-sig,2);
deletejob(jobs,-sig);
}
// when sig<0, send SIGINT singal to all foreground process
else if(sig==SIGINT)
{
kill(-pid,SIGINT);
printf("Job [%d] (%d) terminated by signal %d\n",jid,pid,sig);
deletejob(jobs,pid);
}
//只触发一次sigint_handler,实现对进程的发送信息,打印结果,删除任务
}
return;
}
收到中断信号后,先做的事是判断传入的参数
1. 如果参数为SIGINT,那么就是通过ctrl-z或者ctrl-c发送的信号,所做的操作就为如下三点:
1.1. 发送SIGINT进程中止信号到前台进程组
1.2. 打印中止信号信息
1.3. 删除任务。
2. 如果参数为负数,说明是通过sigchld_handler调用的函数,那么有两种情况
1.1. sig==-前台进程的进程号,说明并没有执行过deletejob或者改变进程状态,说明是进程自己发送的信号,这个时候只需要做两件事。
1.1.1. 打印中止信号信息。
1.1.2. 删除任务。
1.2. sig!=-前台进程的进程号,说明该进程已经不为前台进程,说明ctrl-z引起的kill函数调用sigchld,然后再跳用sigtstp_handler处理函数。此时,不需要进行任何操作。
下面是sigtstp_handler
/*
* sigtstp_handler - The kernel sends a SIGTSTP to the shell whenever
* the user types ctrl-z at the keyboard. Catch it and suspend the
* foreground job by sending it a SIGTSTP.
*/
void sigtstp_handler(int sig)
{
pid_t pid=fgpid(jobs);
int jid=pid2jid(pid);
//send fg job/ related process group signal
if(pid!=0)
{
//通过ctrl-z发送信号
if(sig==20)
{
printf("Job [%d] (%d) Stopped by signal %d\n",jid,pid,sig);
getjobpid(jobs,pid)->state=ST;
kill(-pid,SIGTSTP);
}
//通过自己发送kill函数发送信号
else if(pid==-sig)
{
printf("Job [%d] (%d) Stopped by signal %d\n",jid,pid,20);
getjobpid(jobs,pid)->state=ST;
}
//其他情况说明是已经停止的进程通过sigchild发送信息
}
return;
}
对于判断中止信号来源的判断内容与sigint_handler类似,该函数的主要内容如下所示,打印停止信息,将该进程的状态改为ST停止状态,然后发送SIGSTSP信号给前台进程。
最后是比较难的,dofgbg函数
void do_bgfg(char **argv)
{
struct job_t* StpJob;
char* id=argv[1];
int jid;
pid_t pid;
//如果命令不存在
if(id==NULL)
{
printf("%s command requireds pid or %%jobid argument\n",argv[0]);
return;
}
//如果命令表示的是job
if(id[0]=='%')
{
jid=trunce(id);
if(!(StpJob=getjobjid(jobs,jid)))
{
printf("%s:No such job\n",id);
return;
}
}
// For a PID
else if(isdigit(id[0]))
{
pid=trunce(id);
if(!(StpJob=getjobpid(jobs,pid)))
{
printf("(%d):No such process\n",pid);
return;
}
}
else
{
printf("%s: argument must be a PID or %%jobid\n",argv[0]);
return;
}
//发送continue信息
if(kill(-(StpJob->pid),SIGCONT)<0)
{
if(errno!=ESRCH)
{
unix_error("kill error");
}
}
//FG和BG执行两种操作
if(!strcmp(argv[0],"bg"))
{
StpJob->state=BG;
printf("[%d] (%d) %s",StpJob->jid,StpJob->pid,StpJob->cmdline);
}
else if(!strcmp(argv[0],"fg"))
{
StpJob->state=FG;
waitfg(StpJob->pid);
}
else
{
printf("bg/fg error:%s\n",argv[0]);
}
return;
}
这个函数先做的是命令错误判断,fg和bg后面是否有参数,参数是否符合%+数字或者数字,所表示的进程是否为正在运行的进程,做完这些判断之后,根据如果是%号,说明取的是工作组号,如果直接是数字说明取的是进程号,根据工作组号和进程号获取对应的job结构体,接下来如果是bg,说明要恢复成后台进程,即改变job的state;如果是fg,说明要恢复成前台进程,即改变job的state,然后调用waitfg;等前台进程运行结束。
下面是我自己编写的用于截取工作号和进程号的函数:
/*从字符串的右边截取n个字符*/
char * right(char *dst,char *src, int n)
{
char *p = src;
char *q = dst;
int len = strlen(src);
if(n>len) n = len;
p += (len-n); /*从右边第n个字符开始,到0结束,很巧啊*/
while((*(q++) = *(p++)));
return dst;
}
//用于获取pid,jid的数字
int trunce(char *src)
{
int len=strlen(src);
char * dst=malloc(sizeof(char)*len*2);
if(src[0]=='%')
right(dst,src,len-1);
else
memcpy(dst,src,len);
int result=atoi(dst);
free(dst);
return result;
}
完整的代码和实验指导书会在我的github上放出:
https://github.com/HBKO/CMUlab/tree/master