前言

视频已经刷完好几天了,lab才打完这个,不得不说shell lab也是设计的非常贴心,注意事项几乎都能在文档找到。

参考资料:

课程视频链接:2015 CMU 15-213 CSAPP 深入理解计算机系统 课程视频

实验文档:shlab.dvi (cmu.edu)

一、实验须知

实验文件已经在main函数中为我们完成了命令行参数的读取、给信号绑定handler等主体部分,并且提供了一系列有用好用的函数,我们需要做的只有在tsh.c中完成以下函数:

  1. eval: 解析每条命令行的类型并执行
  2. builtin_cmd: 识别和处理四条内置命令: quit, fg, bg 和 jobs.
  3. do_bgfg: 实现内置命令fg 和 bg
  4. waitfg: 等待前台作业完成
  5. sigchld_handler: 接收SIGCHLD信号
  6. sigint_handler: 接收SIGINT(Ctrl + C)信号
  7. sigtstp_handler: 接收SIGINT(Ctrl + Z)信号

实验文件还提供了参考的tshref,sdriver.pl以及许多tracexx.txt文件供我们对比检验自己的tsh是否符合要求,使用方法如下:

1
2
3
4
5
6
7
8
9
root@Andrew:/usr/coding/shlab-handout# ./sdriver.pl -a "-p" -t trace01.txt -s ./tsh
#
# trace01.txt - Properly terminate on EOF.
#
root@Andrew:/usr/coding/shlab-handout# ./sdriver.pl -a "-p" -t trace01.txt -s ./tshref
#
# trace01.txt - Properly terminate on EOF.
#
root@Andrew:/usr/coding/shlab-handout#

-a “-p”表示输入不会有多余的prompt,观感更佳
-t 指定tracexx.txt,即测试点,共有16个
-s 指定shell,一次指定参考文件tshref,一次指定自己的tsh,检查两次的输出是否一致即可

二、编写代码

按照把trace01到trace16一个一个攻关的思路,就能一点点把shell造全了。

由于过程实在有些曲折,而且也有部分失败的代码被抛弃了,就懒得展示每一步的过程了。直接上每个函数的代码然后解说。

1. eval

eval的参数cmdline是从命令行输入的一串字符,结尾是换行符\n,所以在输出cmdline是自带换行

实验提供了函数parseline,用于解析cmdline中的每个参数,返回一个int值bg代表后台作业(结尾是&符号的时候)

随后利用builtin_cmd判断是否为内置指令,是的话在其中执行,返回1;不是的话返回0,之后再fork + execve调用可执行文件。注意到需要对不存在的可执行文件做异常处理,打印Command not found,并且指定子进程的进程组id为自身setgpid(0, 0),从而保证前台进程组只有我们的shell。

After the fork, but before the execve, the child process should call setpgid(0, 0), which puts the child in a new process group whose group ID is identical to the child’s PID. This ensures that there will be only one process, your shell, in the foreground process group

由于我们维护一个全局的job_t类型数组jobs,并在每次创建作业后调用addjob保存作业的信息到jobs当中。

但是由于我们将要在sigchld_handler中对jobs数组做某些操作,比如使用deletejob。为了保证addjob和deletejob的顺序,我们必须阻塞SIGCHLD 从fork进程之前完成了addjob之后,才允许进程接收SIGCHLD

完整代码如下:

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
void eval(char *cmdline) 
{
char *argv[MAXARGS];
int bg = parseline(cmdline, argv);
if(argv[0] == NULL)
return;

pid_t pid;
if(!builtin_cmd(argv)){
// 阻塞SIGCHLD的接收,保证addjob和deletejob的顺序
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);

sigprocmask(SIG_BLOCK, &mask, NULL);

if((pid = fork()) < 0){
// Fork失败
unix_error("Fork error");
}else if(pid == 0){
// 子进程,解除SIGCHLD的阻塞,然后execve运行程序
sigprocmask(SIG_UNBLOCK, &mask, NULL);
setpgid(0, 0);
if(execve(argv[0], argv, environ) < 0){
printf("%s:Command not found.\n",argv[0]);
exit(-1);
}
}else{
// 主进程
// 添加job后解除阻塞,保证add和delete的顺序
// 前台运行时,调用waitfg做自旋(spin)

addjob(jobs, pid, bg ? BG : FG, cmdline);
sigprocmask(SIG_UNBLOCK, &mask, NULL);

if(bg){
printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
}else{
waitfg(pid);
}
}
}
return;
}

2. builtin_cmd

内置指令包括四个:

  1. quit:遇到quit时,直接终止shell所在进程就行了,简单!
  2. jobs:也简单!实验已经给我们写好了listjobs,直接调就好了
  3. bg和fg:识别出来后就丢进do_bgfg里边,另外再处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int builtin_cmd(char **argv) 
{
if(!strcmp(argv[0], "quit")){
exit(0);
}
if(!strcmp(argv[0], "bg") || !strcmp(argv[0], "fg")){
do_bgfg(argv);
return 1;
}
if(!strcmp(argv[0], "jobs")){
listjobs(jobs);
return 1;
}
return 0; /* not a builtin command */
}

3. sigint_handler 和 sigtstp_handler

通过函数fgpid拿到前台作业的pid,然后将信号发送给进程组中每个进程

int kill(pid_t pid, int sig)
如果 pid 大于零,那么 kill 函数发送信号号码 sig 给进程 pid。如果 pid 小于零,那么 kill 发送信号sig给进程组 abs(pid)中的每个进程。

1
2
3
4
5
6
7
void sigint_handler(int sig) 
{
pid_t pid = fgpid(jobs);
// printf("Job [%d] (%d) terminated by signal 2\n", pid2jid(pid), pid);
kill(-pid, SIGINT);
return;
}
1
2
3
4
5
6
7
void sigtstp_handler(int sig) 
{
pid_t pid = fgpid(jobs);
// printf("Job [%d] (%d) stopped by signal 20\n", pid2jid(pid), pid);
kill(-pid, SIGTSTP);
return;
}

这里不打印前台作业被终止或停止的信息,是因为进程被终止或停止时会发送SIGCHLD信号,我们可以在sigchld_handler中处理。

而非要在sigchld_handler处理的原因是:sigchld_handler不仅能够监控前台作业,还能够监控后台作业的终止和停止(为了通过trace16),并且能够利用status获得终止或停止的原因

4. do_bgfg

bg 和 fg的处理思路高度相似,先解析参数有没有%,然后决定是使用getjobjid还是getjobpid确认作业是否存在,然后做一系列error handling(trace13要求)

最后,向作业的pid所标识进程组发送信号SIGCONT,如果是bg就打印后台作业的信息,如果是fg就调用waitfg自旋

完整代码如下:

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
void do_bgfg(char **argv) 
{
// parse argument
int jid = 0;
pid_t pid = 0;
int bg = !strcmp(argv[0], "bg");
struct job_t *job;

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

if(argv[1][0] == '%'){
jid = atoi(argv[1] + 1);
}else{
pid = atoi(argv[1]);
}

if(jid){
job = getjobjid(jobs, jid);
if(job == NULL){
printf("%%%d: No such job\n", jid);
return ;
}
pid = job->pid;
}else if(pid){
job = getjobpid(jobs, pid);
if(job == NULL){
printf("(%d): No such process\n", pid);
return ;
}
}else{
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return ;
}

kill(-pid, SIGCONT);

if(bg){
job->state = BG;
printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
}else{
job->state = FG;
waitfg(pid);
}
return;
}

5. waitfg

前面说了几次调用waitfg自旋(在eval和do_bgfg中),为啥要自旋而不是waitpid呢?

因为如果我们在waitfg中调用waitpid回收前台作业的话,当该作业终止或停止,它会发送信号SIGCHLD,这就造成了sigchld_handler和waitfg的竞争关系,那就不如让sigchld_handler回收就好了。

而且文档中也明确建议了waitfg中仅使用一个循环+sleep:

One of the tricky parts of the assignment is deciding on the allocation of work between the waitfg and sigchld_handler functions. We recommend the following approach:

– In waitfg, use a busy loop around the sleep function.

– In sigchld_handler, use exactly one call to waitpid.

While other solutions are possible, such as calling waitpid in both waitfg and sigchld_handler, these can be very confusing. It is simpler to do all reaping in the handler.

代码如下:

1
2
3
4
5
6
7
void waitfg(pid_t pid)
{
while(fgpid(jobs) == pid){
sleep(1);
}
return;
}

6. sigchld_handler

最重量级的来了,前面提到:当子进程终止或停止时,都会给父进程发送一个SIGHCLD信号,告诉父进程该调用waitpid去回收它了

当然,waitpid也有很多花活:

pid_t waitpid(pidt pid,int *status, int options);

如果 pid>0,那么等待集合就是一个单独的子进程,它的进程ID等于 pid。
如果 pid=-1,那么等待集合就是由父进程所有的子进程组成的。

所以我们这里应该使用pid = -1等待任一子进程

可以通过用常量 WNOHANG 和WUNTRACED的不同组合来设置 options,修改默认行为:
WNOHANG: 如果没有等待集合中的任何子进程终止,那么就立即返回(返回值为0)。
WUNTRACED: 挂起调用进程的执行,直到等待集合中的一个进程变成终止的或者被暂停。返回的 PID为导致返回的终止或暂停子进程的PID。
WNOHANG | WUNTRACED: 立即返回,如果没有等待集合中的任何子进程停止或终止,那么返回值为0,或者返回值等于那个被停止或者终止子进程的PD。

我们需要sigchld_handler在后台静静地处理终止和停止的子进程,而不会影响主进程的执行,所以options应该选择WNOHANG | WUNTRACED

然后就是根据几个解释status的宏,处理每种发送SIGCHLD的情况

WIFEXITED(status): 如果子进程正常终止就返回真,也就是通过调用exit 或者一个返回(return)
WIFSIGNALED(status): 如果是因为一个未被捕获的信号造成了子进程的终止,那么就返回真
WIFSTOPPED(status): 如果引起返回的子进程当前是暂停的,那么就返回真。

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void sigchld_handler(int sig) 
{
pid_t pid;
int status;
while((pid = waitpid(-1, &status, WNOHANG | WUNTRACED) ) > 0){
if(WIFEXITED(status)){
// 如果正常终止
deletejob(jobs, pid);
}else if(WIFSIGNALED(status)){
// 收到信号终止(为了能监控其他进程)
printf("Job [%d] (%d) terminated by signal 2\n", pid2jid(pid), pid);
deletejob(jobs, pid);
}else if(WIFSTOPPED(status)){
// 如果进程停止
printf("Job [%d] (%d) stopped by signal 20\n", pid2jid(pid), pid);
struct job_t *job = getjobpid(jobs, pid);
job->state = ST;
}
}
return;
}