前情提要
- 这个实验和之前的不太一样,最好要去把书看一遍,很多具体的实验的方法,思想,甚至是代码在书上已经给出了,自己很难想出来
实验总览
- 总共有16个trace,运行make test0x 与 make rtest0x,比较结果是否相同
- 实验内容
eval
: Main routine that parses and interprets the command line. 70 linesbuiltin cmd
: Recognizes and interprets the built-in commands: quit, fg, bg, and jobs. 25 linesdo bgfg
: Implements the bg and fg built-in commands. 50 lineswaitfg
: Waits for a foreground job to complete. 20 linessigchld handler
: Catches SIGCHILD signals. 80 linessigint handler
: Catches SIGINT (ctrl-c) signals. 15 linessigtstp handler
: Catches SIGTSTP (ctrl-z) signals. 15 lines
什么是shell?
shell通常用来指命令行,我们可以通过这个命令行去启动各种程序
那么,这个命令行是如何启动这些程序的呢?
- shell将自己作为父进程
- shell创建一个子进程去启动各种程序
那么,shell其实也就是一个进程,也不过是一个比较特殊的程序,那它这个程序是什么样的呢? - 不断地循环,并打印一个提示符,我们这个lab的提示符就是这样
tsh>
- 然后通过fgets获得我们的输入,我们的输入通常就是程序的路径(名字)以及这个程序的参数
- 通过我们的输入去启动程序。这里就有两种程序了,一种是前台程序,一种是后台程序
- 首先要明确一点,前台程序和后台程序没有本质上的区别,运行起来都是一个进程而已
- 前台进程比较特殊的一点就是,它会阻塞shell,一直到自己执行完了,然后让shell继续执行
eval
eval函数的功能其实类似于一个启动器
- 获取我们输入的各种信息
- 按照我们的要求去创建子进程,操作父进程
第一步,获取各种输入的信息,这个就比较简单了,因为已经提供了写好的parseline
函数,我们只需要调用即可。
// 首先根据cmdline,解析这一行命令。由于我们在解析的函数中修改了传入的cmdline,所以用了一个备份的buffer。
// 同时解析函数的返回值其实就代表了当前这个被命令行启动的进程是前天还是后台运行的
// 但是其实好像不备份也行,留坑!
char buffer[MAXLINE];
strcpy(buffer, cmdline);
char *argv[MAXARGS];
int is_bg = parseline(buffer, argv);
// 题目已经定义为bg和fg,所以按照题目意思来定义状态
int state = is_bg ? BG : FG;
// 如果解析出来是个空的命令行,则直接return
if (argv[0] == NULL) {
return;
}
第二步,就比较复杂了。
首先想一想,父进程需要做什么?
父进程
- 创建子进程
- 为子进程创建一个对应的job
- 根据子进程的类型决定自己是否需要阻塞,等到子进程执行完毕
这里有两个注意点 - 创建子进程之前父进程需要阻塞掉
SIGCHLD
信号- 这个信号是子进程死亡后向父进程发出的信号
- 为什么要阻塞这个呢?
- 如果刚创建好子进程,子进程就获得了cpu的使用权
- 然后子进程马上就执行完成,发出这个死亡的信号给父进程
- 父进程拿到cpu并且受到信号后就需要调用对应的处理函数,这个处理函数需要在job中删除对应项
- 但是因为刚创建好子进程之后父进程就没有用过cpu,父进程压根没来得及在job中为子进程创建对应项,所以这个删除操作肯定是失败的
- 等父进程拿到cpu之后,才给子进程注册了一个job,可是子进程早就结束了,也就是说父进程永远等不到这个子进程的死亡信号
- 因此这个job永远不会被删除
- 因此在创建子进程之后需要阻塞这个信号,再父进程完成为子进程注册job之后,就可以解除这个阻塞
- 操作job之前需要阻塞掉所有的信号
- 这个是因为jobs是一个全局变量,为了防止并发的问题,必须要阻塞掉所以的信号,包括切换进程的信号
- 这个有点像操作系统的各种锁的作用,保护临界资源的
那么子进程需要干嘛呢?
- 首先,子进程继承了父进程的所有资源,而父进程在创建它之前已经阻塞了SIGCHLD信号量,子进程最好先将这个接触,避免可能的问题
- 子进程需要修改自己的组号,刚被创建时是和父进程一个组的。之所以要和父进程不同组,是为了防止子进程收到某些信号影响到同属一个组的父进程,比如对子进程发出kill命令的时候,往往是对子进程所在的组发出kill命令,如果不修改子进程组号,会把父进程也就是shell给kill了。
- 根据命令行的输入,使用
execve
函数启动对应的程序 - 终止
子进程这里需要注意一点,那就是用户输入的程序可能是不存在的,因此,对于execve函数的结果需要特殊处理一下
整个eval函数的实现如下,其中涉及了一些信号相关的操作比较陌生
void eval(char *cmdline) {
// 首先根据cmdline,解析这一行命令。由于我们在解析的函数中修改了传入的cmdline,所以用了一个备份的buffer。
// 同时解析函数的返回值其实就代表了当前这个被命令行启动的进程是前天还是后台运行的
// 但是其实好像不备份也行,留坑!
char buffer[MAXLINE];
strcpy(buffer, cmdline);
char *argv[MAXARGS];
int is_bg = parseline(buffer, argv);
// 题目已经定义为bg和fg,所以按照题目意思来定义状态
int state = is_bg ? BG : FG;
// 如果解析出来是个空的命令行,则直接return
if (argv[0] == NULL) {
return;
}
// 下面是核心操作部分
// 创建信号集并初始化,分别是不理会所有信号,不理会一个信号(SIGCHLD),和备份之前的信号
sigset_t all_mask, one_mask, pre_mask;
sigfillset(&all_mask);
sigemptyset(&one_mask);
sigaddset(&one_mask, SIGCHLD);
// 如果即将创建的进程不是系统程序,那下面这个函数会返回0
// 如果是系统程序,那这个函数内部就直接执行了, 不需要我们管了
// 因此,我们只需要处理非系统程序的情况
pid_t pid;
if (!builtin_cmd(argv)) {
// 在调用fork之前就要阻塞SIGCHLD信号
sigprocmask(SIG_BLOCK, &one_mask, &pre_mask);
if ((pid = fork()) == 0) {
// 子进程
// 子进程首先解除从父进程那继承来的阻塞信号
sigprocmask(SIG_SETMASK, &pre_mask, NULL);
// 修改自己的组id为自己的id
setpgid(0, 0);
// 创建真正的进程,其中environ是在libc文件中定义的
Execve(argv[0], argv, environ);
// 真正的进程执行完之后,子进程也就完成使命了,使用exit终止进程,更加强劲!
exit(0);
}
// 父进程继续操作
// 首先获取全局锁,因为要修改全局变量了
sigprocmask(SIG_BLOCK, &all_mask, NULL);
addjob(jobs, pid, state, cmdline);
sigprocmask(SIG_SETMASK, &one_mask, NULL);
// 如果是前台进程,则父进程要阻塞到这个前台进程结束
// 如果是后台进程,则父进程打印这个后台进程的信息
if (state == FG) {
waitfg(pid);
} else {
printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline);
}
// 父进程ok了,可以去接受SIGCHLD信号
sigprocmask(SIG_SETMASK, &pre_mask, NULL);
}
}
builtin_cmd
这个函数就比较简单了,用来判断用户输入的是否是内置的程序
tsh中要求的内置程序只有quit
,bg
,fg
,jobs
这个实现没什么好说的,可能有点前置的知识那就是,argv这个变量,可以看做一个一维数组,其中
每个值都是一个字符串
- 第一个字符串是程序的名字
- 后面的每个字符串都代表了一个参数
int builtin_cmd(char **argv) {
// 总共要处理 quit bg fg jobs,并&
if (!strcmp(argv[0], "quit")) {
// quit指令,直接终止
exit(0);
}
if (!strcmp(argv[0], "bg") || !strcmp(argv[0], "fg")) {
// 执行bg或者fg
do_bgfg(argv);
return 1;
}
if (!strcmp(argv[0], "jobs")) {
// 列出jobs
listjobs(jobs);
return 1;
}
return 0;
}
do_bgfg
void do_bgfg(char **argv)
这个函数是要我们将某个job唤醒,或者由后台操作变成前台操作
- 首先,我们需要知道到底是要变成前台还是后台,这个参数存在
argv[0]
中 - 然后我们需要拿出具体的pid或者jid,其中jid通过%起始以做区别。
- 需要处理这个参数为空情况,即没有给出任何的pid或者jid。
- 需要处理输入的pid或者jid非法的情况
- 通过具体的pid或者jid拿到对应的job,如果找不到这个job,说明不存在
- 给这个job发出一个
SIGCONT
的信号,不管它之前咋样,现在都醒过来 - 修改这个job的状态位前台或者后台
- 根据前台还是后台,决定当前进程是否需要等待
void do_bgfg(char **argv) {
// 后面要用到
struct job_t *job = NULL;
// 这个函数需要处理多种输入,包括一些非法输入
// 首先是确认到底是bg操作还是fg操作
int state = (strcmp(argv[0], "bg") == 0) ? BG : FG;
// 然后判断是否给出了具体的pid或者jid,如果没有给出正确的参数,那要给出提示
// 首先看看是否有参数
if (argv[1] == NULL) {
printf("%s command requires PID or %%jobid argument\n", argv[0]);
return;
}
// 有参数,那就先看看是不是jid
if (argv[1][0] == '%') {
// 使用sscanf尝试获取jid
int jid;
if (sscanf(&argv[1][1], "%d", &jid) > 0) {
job = getjobjid(jobs, jid);
// 如果getjobjid返回null,则说明没有这个job
if (job == NULL) {
printf("%%%d: No such job\n", jid);
return;
}
}
// 到了这里,那肯定有参数,并且不是jid,但是也有可能是瞎输入的先排除一下
} else if (!isdigit(argv[1][0])) {
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
} else {
// 肯定输入的是pid了
pid_t pid;
if (sscanf(argv[1], "%d", &pid) > 0) {
job = getjobpid(jobs, pid);
if (job == NULL) {
printf("(%d): No such process\n", pid);
return;
}
}
}
// 如果能够走到这里,那么说明已经正确取到对应的job了
// 唤醒这个job,修改状态
// 这里没有使用进程组,留坑!
kill(-job->pid, SIGCONT);
job->state = state;
// 根据bg或者fg进行特定的操作
if (state == BG) {
printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
} else {
waitfg(job->pid);
}
}
waitfg
void waitfg(pid_t pid)
这个函数需要做到,当pid还是前台程序的时候,调用waitfg的程序一直休眠
首先,lab已经给我们提供了一个函数fgpid
去检查是否还有程序是前台程序
- 理论上,我们只需要不断地循环,如果发现还有程序是前台程序,那自己就sleep一下就可以了
- 但是这样的话,可能会很浪费时间,因为我们不知道到底要sleep多久
所以,我们最好还是使用信号的机制,书上介绍了一个很牛逼的函数sigsuspend
具体的优点书上已经详细介绍了,这里不赘述
这里就有个问题,正常来说,调用这个函数之前,进程应该是阻塞了SIGCHLD
信号才对的
而我的实现里,有两处调用了这个函数,其中do_bgfg是没有阻塞上述的那个信号,但是也没有出现死锁,应该是测试数据太水了导致的
void waitfg(pid_t pid) {
// 注意,进入这个函数的时候,父进程已经阻塞了子进程可能传来的SIGCHLD信号
// 而我们使用的sigsuspend函数会让父进程进入一个完全没有阻塞信号的状态
// 因此,只要有一个子进程挂了,父进程就会跳出阻塞去检查一下是否还有前台进程
// 感觉有点问题,这里实现的好像太严格了,不只考虑了pid,还考虑所有前台进程,留坑
sigset_t none_mask;
sigemptyset(&none_mask);
while (fgpid(jobs) != pid) {
sigsuspend(&none_mask);
}
}
sigchld_handler
这个函数和SIGCHLD
信号绑定了,只要有子进程发出这个信号,父进程接收到之后就会使用这个函数去响应
- 首先,父进程会使用waitpid函数去看看是否有子进程挂了或者暂停
- 然后父进程会根据不同情况去处理
- 子进程正常结束:删除对应的job
- 子进程被信号终止:删除对应的job,并打印信息
- 子进程被暂停:找到对应的job,修改job的状态为暂停,并打印信息
注意点
3. WNOHANG | WUNTRACED
1. 第一个参数保证了,就算没有需要处理的子进程,当前进程也不会阻塞住不动
2. 第二个参数保证了,会去响应被暂停的子进程
4. 备份errno
5. 在修改jobs之前需要阻塞所有信号,作用和之前说的一样,防止并发带来的问题
由于我们不知道到底有多少个死亡信号或者暂停信号到达,所以必须要用while,而不是if
void sigchld_handler(int sig) {
int olderr = errno;
pid_t pid;
int state;
sigset_t all_mask, pre_mask;
sigfillset(&all_mask);
// 不断等待子进程,直到收到一个子进程停止的消息
while ((pid = waitpid(-1, &state, WNOHANG | WUNTRACED)) > 0) {
// 因为接下来需要操作jobs这个全局变量,先屏蔽所有的信号
sigprocmask(SIG_SETMASK, &all_mask, &pre_mask);
// 如果是正常终止,则只需要删除对应的job
if (WIFEXITED(state)) {
deletejob(jobs, pid);
// 如果是被信号杀死,则还需要输出被哪个信号杀死
} else if (WIFSIGNALED(state)) {
deletejob(jobs, pid);
printf("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid,
WTERMSIG(state));
// 如果是被信号暂停了,还需要修改对应job的状态
} else if (WIFSTOPPED(state)) {
struct job_t *job = getjobpid(jobs, pid);
job->state = ST;
printf("Job [%d] (%d) stoped by signal %d\n", pid2jid(pid), pid,
WSTOPSIG(state));
}
sigprocmask(SIG_SETMASK, &pre_mask, NULL);
}
errno = olderr;
}
sigint_handler
这个函数对应于ctrl c操作,强制杀死前台的进程
实现起来很简单:找到当前的前台进程,然后通过kill函数杀死它
注意保存和恢复error
void sigint_handler(int sig) {
// 前台进程只可能有一个,只要还存在前台进程,那就把这个前台进程给杀掉
// 感觉这里不加信号量也没问题
int olderr = errno;
pid_t pid;
if ((pid = fgpid(jobs)) != 0) {
kill(pid, sig);
}
errno = olderr;
}
sigtstp_handler
这个函数对应于ctrl z函数,暂停前台进程
int olderr = errno;
pid_t pid;
if ((pid = fgpid(jobs)) != 0) {
kill(pid, sig);
}
errno = olderr;
留坑
- 至今没有搞清楚tsh运行起来之后,到底是咋实现换行的
- 总感觉这个做的迷迷糊糊的,没有很理解。接下来把书看一遍,希望能完全弄懂。