0%

csapp-shlab

实现一个简单的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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void sigint_handler(int sig) 
{
int tmp=errno;
sigset_t set,prev_set;
sigfillset(&set);//将全部信号加入set
sigprocmask(SIG_BLOCK,&set,&prev_set);//阻塞全部信号
//printf("494:阻塞全部信号\n");
pid_t pid=fgpid(jobs);
if(pid>0)
{
//printf("got SIGINT");
kill(-pid,SIGINT);//-pid kill函数发送信号给|pid|进程组的每个进程,起到了将所有的子进程父进程孙进程一网打尽的效果
}
//printf("501:恢复全部信号\n");
sigprocmask(SIG_SETMASK,&prev_set,NULL);//恢复
errno=tmp;
//printf("成功退出SIGCHLD处理函数\n");
return;
}

要注意的点就是kill函数第一个参数设置为负数,可以对整个进程组发送信号,防止产生孤儿进程。

sigtstp_handler(✔)

SIGSTOP属于不可忽略信号,作用是暂停前台作业。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void sigtstp_handler(int sig) 
{
int tmp=errno;

sigset_t set,prev_all;
sigfillset(&set);//将全部信号加入set
sigemptyset(&prev_all);
sigprocmask(SIG_BLOCK,&set,&prev_all);//阻塞全部信号
pid_t pid=fgpid(jobs);//有前台则返回pid,无则返回0
if(pid>0)
{
kill(-pid,sig);
}
sigprocmask(SIG_SETMASK,&prev_all,NULL);//恢复
errno=tmp;
return;
}

sigchld_handler(✔)

子进程终止或停止操作系统会向父进程发出SIGCHLD信号。

先看书中的几个例子。首先是这个例子,调用waitpid并将第一个参数设置为-1,等待集合由父进程的所有子进程组成。

在只有单一进程的情况下,这种处理方法是没有问题的,可是如果子进程有很多,就会出现丢失信号的情况。因为等待列表里最多只能有一个信号,当接收到SIGCHLD信号的时候,调用处理程序,SIGCHLD信号被阻塞,如果此时传来一个SIGCHLD信号他就会进入等待列表,等第一个信号处理完之后,解除阻塞,触发处理程序,一旦这时候在传来SIGCHLD信号,信号不会进入列表等待,而是直接丢失。要解决这个问题,我们就得明白信号是不会排队等待的。

下面是解决信号不排队等待问题的方案,设置了一个循环,waitpid函数如果成功等待进程停止就会返回pid>0,进入循环后阻塞全部的信号,删除作业,解除阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void sigchld_handler(int sig)
{
int tmp=errno;
int status;
pid_t pid;
struct job_t *job;
sigset_t mask_all,prev;
sigfillset(&mask_all);
sigemptyset(&prev);

while((pid=waitpid(-1,&status,WNOHANG | WUNTRACED))>0) //第三个参数代表立即返回,如果等待集合的子进程都没有被停职或终止则返回0,如果有则返回pid
{ //有子进程停止或终止,判断是停止则更改状态,是终止则删除作业
sigprocmask(SIG_BLOCK, &mask_all, &prev);//阻塞全部信号
if(WIFEXITED(status))
{ //如果进程是通过exit或return正常终止则返回真,进入语句
deletejob(jobs,pid);//删除指定pid的作业,pid由waitpid返回得到
}
else if(WIFSIGNALED(status))
{ //如果子进程是因为一个信号终止的则返回真
printf ("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid, WTERMSIG(status));
deletejob(jobs, pid);
}
else if(WIFSTOPPED(status))
{ //如果子进程现在是停止的,改变工作状态-->ST
printf ("Job [%d] (%d) stopped by signal %d\n", pid2jid(pid), pid, WSTOPSIG(status));
job = getjobpid(jobs,pid);
job->state = ST;
}
sigprocmask(SIG_SETMASK,&prev,NULL);
}
// if(errno!=ECHILD)
// unix_error("waitpid error!");
errno=tmp;
return;
}

注意waitpid的参数设置,这里不再是默认的,而是使用了status和options,status记录进程状态信息,options更改等待子进程的行为。默认情况options是0,等待集合中的任意一个子进程结束,如果集合中有后台进程,那么waitpid函数也会傻傻的等待,直到后台进程执行完毕才会重新弹出>,所以这里设置了参数WNOHANG | WUNTRACED,此时waitpid的行为是立即返回,即不会等待进程停止或结束。

输入处理函数

eval函数(✔)

先看一下书上的作为参考

eval函数在main函数中的调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* Execute the shell's read/eval loop */
while (1) {

/* Read command line */
if (emit_prompt) {
printf("%s", prompt);
fflush(stdout);
}
if ((fgets(cmdline, MAXLINE, stdin) == NULL) && ferror(stdin))
app_error("fgets error");
if (feof(stdin)) { /* End of file (ctrl-d) */
fflush(stdout);
exit(0);
}

/* Evaluate the command line */
eval(cmdline);
fflush(stdout);
fflush(stdout);

功能:首先检测第一个命令行参数是否为shll的内置命令()/

参数是commandline也就是我们的输入

函数功能:

接收我们的输入作为参数,如果是内置命令则直接执行,如果不是内置命令,创建一个新的进程调用execve运行程序。

在标准的Unix shell运行shell时,shell在前台进程组中运行,所以我们用fork创建的子进程也就是我们的前台作业也属于shell的进程组。但是ctrl+c会向前台进程组的每个进程发送SIGINT信号,也就是内核会向shell和shell创建的每个进程,我们使用setpid(0,0),将创建的子进程放到一个引得进程组中,新的进程组ID和PID相同。这样当按下ctrl+c时就只会对shell发送信号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void eval(char*cmdline)
{
char*argv[MAXARGS];
char buf[MAXLINE];
int bg;
sigset_t mask,prev,mask_all;
pid_t pid;

sigemptyset(&mask);
sigemptyset(&prev);
sigaddset(&mask,SIGCHLD);
sigfillset(&mask_all);

strcpy(buf,cmdline);//将输入复制到buf
bg=parseline(buf,argv);//构造参数列表,后台作业返回1,前台返回0
if(argv[0]==NULL)
{
return;
}
if(!builtin_cmd(argv))
{
//非内置命令
sigprocmask(SIG_BLOCK,&mask,&prev);//阻塞SIGCHLD信号
//printf("194:阻塞SIGCHLD信号\n");//为什么要阻塞sigchld信号?防止子进程结束发出的sigchld信号影响主程序,也就是保证add在delete之前
if((pid=fork())==0)//子进程运行命令,需要阻塞信号避免竞争
{
setpgid(0,0);//将子进程放入新的进程组中gpid=pid,可以避免停止时把tsh程序中断
sigprocmask(SIG_SETMASK,&prev,NULL);//恢复子进程信号
if(execve(argv[0],argv,environ)<0)
{
printf("%s:command not found\n",argv[0]);
exit(0);//退出子进程
}
}

//父进程,根据前后台添加作业至作业列表
//printf("父进程控制");
if(!bg)
{
//printf("执行了一个前台任务");
sigprocmask(SIG_BLOCK,&mask_all,NULL);
//printf("213:阻塞全部信号\n");
//printf("添加前台作业子进程的pid:%d,gpid:%d\n",pid,getpgid(pid));
addjob(jobs,pid,FG,cmdline);
waitfg(pid);
sigprocmask(SIG_SETMASK,&prev,NULL);

}
else//后台作业
{
sigprocmask(SIG_BLOCK,&mask_all,NULL);
//printf("225:阻塞全部信号\n");
//printf("添加后台作业:%d\n",pid);
addjob(jobs,pid,BG,cmdline);
printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
sigprocmask(SIG_SETMASK,&prev,NULL);
}
}
return;
}

这里比较关键的是setpgid(0,0),当使用了execve函数开始一个新的进程时,按下ctrl+c不会将我们的tsh程序关闭,因为该函数将新的进程放入了一个新的进程组中,通过键盘发送的信号被tsh进程捕获然后通过kill函数像子进程组发送SIGINT信号就好啦。

builtun_cmd(✔)

如果是内置命令(quit、fg、bg、jobs)则直接执行,如果不是则返回0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int builtin_cmd(char **argv)
{
if(!strcmp(argv[0],"quit"))
{
printf("exit");
exit(0);//退出shell
}
else if(!strcmp(argv[0],"fg")||(!strcmp(argv[0],"bg")))//fg %jid or fg pid 将一个作业切换至前台运行
{
do_bgfg(argv);
return 1;
}
else if(!strcmp(argv[0],"jobs"))
{
listjobs(jobs);
return 1;
}
else if(!strcmp(argv[0],"&"))//后台作业
{
return 1;
}
else
{
printf("command not found");
return 1;
}
return 0; /* not a builtin command 不是内置命令*/
}

waitfg(✔)

等待前台作业结束,这个参考了书上的代码,也想了很久

书中建议使用sigsuspend函数进行阻塞,既能解决单独pause引来的竞争问题,又能解决sleep速度太慢的问题。

注意这个向量使用的是prev,该函数的实际作用是:1)将阻塞列表设为prev。2)在捕捉到一个信号之前,该进程被挂起(pause函数)。3)如果捕捉到一个信号而且从处理程序返回则sigsuspend返回,并且将该进程的阻塞列表恢复,如果是终止信号则该进程不从sigsuspend返回直接终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
void waitfg(pid_t pid)//linux shell在接收下一个命令之前,必须显示地等待前台作业终止
{
sigset_t mask,prev;
sigemptyset(&mask);
sigemptyset(&prev);
//等待前台作业传送来的SIGCHLD信号
while(fgpid(jobs)!=0)//fgpid函数返回前台进程pid,如果没与前台进程则返回0
{
//进来循环了,那就说明前台进程还在
sigsuspend(&mask);//清除阻塞列表,挂起,恢复阻塞列表
}
//printf("暂无前台作业\n");
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
void do_bgfg(char **argv) //argv是一个指向字符串指针的指针
{
int jid,pid,ifpid,ifjid;
struct job_t *myjobs;
sigset_t mask;
sigemptyset(&mask);

if(argv[1]==NULL)
{
printf("%s command requires PID or %%jobid argument\n",argv[0]);
return;
}

ifpid=sscanf(argv[1],"%d",&pid);//如果参数是pid则返回真
ifjid=sscanf(argv[1],"%%%d",&jid);//如果是jid则返回真

if(!ifpid&&!ifjid)
{
printf("%s: argument must be a PID or %%jobid\n",argv[0]);
return;
}
//检查pid jid是否合规
if(ifjid==0)//接收pid为参数
{
jid=pid2jid(pid);
if(jid==0)//pid不合法
{
printf("(%s):No such process\n",argv[1]);
return;
}
}
else //接收jid作为参数
{
if(getjobjid(jobs,jid)==NULL)
{
printf("%s: No such job\n",argv[1]);
return;
}
}

if(jid==0)//接收pid为参数
{
jid=pid2jid(pid);
}
myjobs=getjobjid(jobs,jid);
if(!strcmp(argv[0],"fg"))
{ //fg的对象可能是bg也可能是st
if (myjobs->state == ST)
kill(-(myjobs->pid), SIGCONT);
myjobs->state = FG;
sigprocmask(SIG_SETMASK, &mask, NULL);
waitfg(myjobs->pid);
}
else
{ //bg命令
myjobs->state=BG;
printf("[%d] (%d) %s", myjobs->jid, myjobs->pid, myjobs->cmdline);
kill(-(myjobs->jid),SIGCONT);
}
return;
}

要注意的是使用kill的时候参数pid要设置为负数,设置为负数会将pid所在进程组的所有进程关闭。

检查

这里讲一下检测的方法,在lab这个目录下运行

1
2
3
make testxx     //出现的结果是我们编写的shell跑出的结果

make rtestxx //出现的是预期结果即正确的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
    12
    handler_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加载之后信号处理函数恢复默认。

全部的检测结果太长了,这里放一个比较复杂的代表了。