例行前言:
本篇不是学习课程时的笔记,是重看这本书时的简记。对于学习本课程的同学,未涉及的内容不代表考试不涉及。核心内容是信号部分。本章内容介绍了较多的信号处理函数,需要在实验中巩固本章所学内容及相关问题的处理(并发,信号阻塞等)。
计算机系统-异常控制流
本章主要介绍计算机对于异常控制流(ECF)的处理方式。
1.异常
异常是ECF的一种形式,既有硬件的异常,也有操作系统实现的软件异常。常见的异常事件有缺页、溢出、除零、IO等。处理器检测到异常时,会通过异常表跳转到异常处理程序进行处理。异常处理程序完成处理后,有三种可能的情况:
- 回到发生异常的指令重新执行
- 回到发生异常的下一条指令
- 终止程序
异常处理
异常处理通常需要硬件和软件配合完成。在系统中,每个异常都会有一个异常号,这些号码一部分由处理器分配,一部分由操作系统分配。系统启动时,会初始化一个异常表,异常表的起始地址放在异常表基址寄存器中,根据异常号找到相应的异常处理程序的起始地址。
异常处理和过程调用相似,但有以下几点不同:
-
跳转到处理程序之前,处理器将返回地址压栈,这个返回地址可能是当前指令或下一条指令。(OPENMIPS中,返回地址会存储到协处理器CP0的EPC寄存器当中,ctrl模块处理异常时向PC写入异常处理例程的地址,执行eret指令时,ctrl模块向PC写入来自CP0的EPC)
-
处理器会将条件码和含有其他内容的寄存器压栈,以便恢复程序的执行
-
如果控制从用户程序转移到内核,所有寄存器等信息被压到内核栈中
-
异常处理程序运行在内核模式下
两种类型的寄存器保存/恢复:
当控制从用户态到内核态时,所有的寄存器都被保存到内核栈当中,从内核态恢复时,也从内核栈中恢复寄存器;当发生进程切换时,处于内核态的程序的内核寄存器会被显式的保存到进程的进程结构当中,并从另一个进程的进程结构恢复内核寄存器。最终,新的进程会通过新进程的内核栈,恢复到用户态。(OSTEP:P43)
异常类别
不同的系统会将异常分为不同的类别。
CSAPP根据异步/同步、返回行为将异常分为四类:
- 中断:中断是异步发生的,是外部IO设备的信号的结果。IO设备会向处理器发出中断信号,处理器检测到后会处理中断,然后返回到下一条指令。
- 陷阱(系统调用):陷阱是用户向内核请求服务时主动发起的异常,提供了系统调用的接口。系统调用运行在内核模式当中,允许执行特殊指令和访问内核栈。
- 故障:故障由错误引起,可能可以被修正。缺页异常就是一个故障,并且可以被解决。对于可以解决的故障,当处理结束后,会回到故障发生的指令重新执行。如果无法解决错误,会导致程序终止。
- 终止:不可恢复的致命错误导致,通常是硬件错误。
IA32的系统调用
Linux提供上百种系统调用。系统调用通过一条int指令触发,参数为异常号,在异常表中对应一个异常处理例程的地址。IA32的系统调用为128号异常,即通过int 0x80进行系统调用。系统调用的参数(调用号等),通过寄存器来传递,按照惯例,%eax寄存器保存系统调用号。
2.进程
进程是一个执行中的程序的实例。
虚拟化部分内容已在OS中详细介绍。本节有一张已在前章节出现过的图:
3.系统调用错误处理
Unix系统函数遇到错误时,会返回-1,并设置errno来表示出错原因。检查错误是必要的,既是为了保证程序的正确性,也是为了在编写程序的过程中找到错误的来源。以下是调用fork时进行的错误检查:
if (pid=fork()<0){
fprintf(stderror,"fork error:%s\n",stderror(errno));
exit(0);
}
debug时的错误检查输出:
当你编写的代码触发了你无法意识到的异常,你可能需要输出中间结果来进行debug,而当编写的程序规模扩大,处理这些输出会变得麻烦,一个方式是使用宏定义:#ifdef DEBUG std::cout<<...中间结果<<std::endl; #endif g++ main.c -o main -D DEBUG
可以宏定义不同的名称,对应不同级别(LOG,DEBUG等),控制程序的输出。
4.进程控制
本节介绍一些Unix中操作进程的系统调用。
获取进程ID
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
创建和终止进程
可以认为进程总是处于以下三种状态之一:(实际上可能有更多状态)
- 运行:正在执行或等待被调度
- 停止:进程的执行被挂起(休眠),不会被调度。收到SIGSTOP,SIGTSTP,SIDTTIN等信号时,进程就会进入这种状态,直到收到一个SIGCONT信号,再次回到运行状态
- 终止:进程永远停止。有三种原因会导致终止:
- 收到一个信号,信号的处理行为是终止进程
- 从主程序返回
- 调用exit()函数
pid_t fork(void); //创建子进程
void exit(int status); //终止进程
fork创建的子进程和父进程拥有相同的代码和数据,区别二者的方式是从fork返回时的值不同。子进程拥有逻辑上相同但独立的地址空间,和父进程有相同的数据,但独立的在fork之后的程序中修改数据。(如果不需要写数据,仅仅是读数据,父进程与子进程实际上使用的是同一份物理地址空间中的数据,即采用COW(COPY-ON-WRITE)机制)。
回收子进程
子进程由于某些原因终止时,并未直接清除,需要父进程将其回收,才能彻底释放资源。如果父进程没有回收子进程就已经终止了,内核会安排init进程回收子进程,init进程是系统初始化是创建的一个常驻进程。
一个进程可以用waitpid来等待子进程终止。
pid_t waipid(pid_t pid, int *status, int options);
- pid:等待的子进程包括哪些
- pid>0:等待一个单独子进程,进程id为pid
- pid=-1:所有子进程
- 其他(进程组)
- status:status非空,则等待后返回子进程的状态信息到status,根据传入status的不同,返回值表示的意思也不同,以下是一些status的可选项:
- WIFEXITED:如果子进程通过exit或return返回,则status返回真
- WEXITSTATUS:返回一个正常终止的子进程的退出状态
- WIFSINALED:如果子进程是因为一个未被捕获的信号终止的,返回真
- WTERMSIG:返回导致子进程终止的信号的数量
- WIFSTOPPED:如果引起返回的子进程是被停止的,返回真
- WSTOPSIG:返回引起子进程停止的信号的数量
- options:可以设置为WNOHANG和WUNTRACED的组合,修改默认行为。默认的行为是挂起调用waitpid的进程,直到有子进程终止
- WNOHANG:等待集合中的任何子进程都没终止,就立即返回0。
- WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程已变为终止或停止,返回停止或终止的子进程PID。当需要检查停止的子进程时,这个选项有用
- WNOHANG|WUNTRACED:立即返回。返回值为0表示等待集合没有任何子进程终止或停止,否则返回停止或终止的进程的PID
进程休眠
sleep函数可以让系统挂起一个进程一段时间。而pause函数可以挂起一个进程,直到该进程收到一个信号。
unsigned int sleep(unsigned int secs); //时间到返回0,因为信号而提前返回则返回剩余的秒数
int pause(void);
加载并运行程序
fork产生的子进程的代码和父进程是相同的,要将新进程替换为新的程序内容,需要使用execve函数。
int execve(const char *filename, const char *argv[], const char *envp[]);
5.信号
Unix系统提供了一种软件形式的异常,称为信号,允许进程中断其他进程。每种系统事件都对应一个信号,内核的异常处理程序处理完事件后,通过信号来告知用户进程发生了什么异常。信号也可以用于进程与进程之间的通信,接下来会仔细介绍信号的使用和机制。
Linux系统支持30种不同类型的信号,在命令行输入man 7 signal就能得到。
进程组
Unix系统中,信号的发送是基于进程组概念的。每个进程号都属于一个进程组,有进程组号。
pid_t getpgrp(void); //获取进程组号
int setpgid(pid_t pid, pid_t pgid); //改变进程组
setpgid将pid的进程组改为pgid。如果pid为0,就使用当前进程的pid。如果pgid是0,就用pid指定的进程pid作为进程组id。
信号发送
内核是通过更新目的进程上下文中的某个状态,发送信号给目的进程的。发送信号可能有两个原因:
- 内核检测到系统事件
- 进程显式的调用函数,使内核发送信号给另一个进程(可以是自己
发送信号的机制有许多种:
- /bin/kill程序发送信号
/bin/kill -9 -15213 #给进程组15213发送SIGKILL信号
- 从键盘发送信号:ctrl c会导致一个SIGINT信号被发送给shell,shell捕获该信号并发送SIGINT给前台进程组,终止前台作业
- kill函数发送信号
int main(){
pid_t pid;
if((pid=fork()) == 0){ //省略了错误检查
Pause();
exit(0);
}
Kill(pid,SIGKILL);
exit(0);
}
- alarm函数发送信号(给自己)
void handler(int sig){
//自定义接收到信号的处理行为
}
int main(){
Signal(SIGALARM,handler); //设置信号处理函数
Alarm(1); //1s后发送SIGALRM信号给自己
while(1);
exit(0);
}
信号接收
目的进程可以对内核发送的信号作出反应。进程可以忽略信号,终止或是执行信号处理程序来捕获信号。一个只发出而没有被进程接收的信号为待处理信号。一种类型至多只有一个待处理信号。如果一个进程有一个类型k的待处理信号,此后的k信号会被丢弃。进程还可以阻塞信号,一个信号被阻塞时,待处理信号不会被进程处理,直到取消阻塞。
待处理信号只能被接收一次的原因是内核通过位向量来维护待处理和被阻塞的信号,一位表示一个类型的信号是否待处理或被阻塞,无法累积多个信号。
当内核从一个异常处理程序返回时,会检查进程未被阻塞的待处理信号,如果存在,则选择一个要求进程进行处理。每个信号类型有默认的处理行为,是下面中的一种:
- 进程终止
- 进程终止并被转储存储器(dump core)
- 进程停止直到被SIGCONT信号重启
- 进程忽略该信号
可以使用signal函数来修改处理行为:
sighandler_t signal(int signum, sighandler_t handler)
handler取以下三种:
- SIG_IGN:忽略signum信号
- SIG_DFL:恢复signum信号的默认行为
- 用户定义的函数
信号处理问题
当程序要捕获多个信号时,会产生一些问题。
- 待处理信号被阻塞:当前处理的信号类型为k,则此时进程又收到的类型为k的信号会被阻塞,处于阻塞并待处理的状态
- 待处理信号不会排队等待:一个类型的信号只能有一个待处理,此后到达的信号会被丢弃,因此不能用信号对事件进行计数
- 系统调用可以被中断:read,wait和accept这样的系统调用会阻塞进程较长的一段时间,如果在这个时间捕获到信号,系统调用在处理信号后不再返回继续,而是返回错误
书P512给出了一个体现上述问题的例子。子进程终止后会向父进程发送SIGCHLD信号,设定父进程接收到SIGCHLD后回收子进程,有三个相同的子进程。在回收第一个子进程时,第二个子进程终止的SIGCHLD信号处于待处理被阻塞状态,而第三个子进程终止的SIGCHLD信号被丢弃了,因此没有回收最后一个子进程。
显式阻塞信号
程序可以使用sigprocmask函数阻塞和取消阻塞选择的信号。已阻塞信号是按照集合来维护的,因此还有一些其他函数对集合进行维护。
/*改变已阻塞信号的集合*/
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
/*清空阻塞集合*/
int sigemptyset(sigset_t *set);
/*将所有信号添加到set中*/
int sigfillset(sigset_t *set);
/*添加/删除信号到set*/
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
/*信号是否在set中*/
int sigismember(const sigset_t *set, int signum);
sigprocmask改变集合的方式依赖how的取值:
- SIG_BLOCK:添加set中的信号到阻塞集合
- SIG_UNBLOCK:从阻塞集合删除set中的信号
- SIG_SETMASK:设置阻塞集合为set
避免并发错误
见书P518。通过阻塞信号等方式避免信号带来的并发错误,此部分内容在实验中很重要。
6.非本地跳转
C语言提供了另外一种用户级异常控制,称为非本地跳转。将控制从一个函数转移到另一个正在执行的函数。接口为setjmp,longjmp等函数。允许从一个深层嵌套的函数调用中返回。