CSAPP笔记05异常控制流(下)
CSAPP笔记05异常控制流(下)
0x00 信号
操作系统和处理器配合处理底层的异常,而linux信号,则提供了一种更高层级的异常,它允许进程和内核中断其他进程。
一个信号就是一条小消息,它通知某个进程发生了某种类型的事件。以下为linux的30种信号:
每种信号类型都对应于某种系统事件,本来应该是内核处理,用户进程不可见。但是信号提供了一种机制,通知用户进程发生了这些异常。
例如,一个进程试图除以0,那么内核就给他发送一个SIGFPE,如果该进程没有设置该信号的处理程序,那么就默认终止并储存该进程(见上表)。又例如某个进程进行非法内存引用,内核就给它发送SIGSEGV信号。又如,如果某进程运行在前台,键入Crtl + C,那么内核会给它发送一个SIGINT信号,会终止该进程。
信号中断步骤
①发送信号:信号都是内核发送的,内核通过更新目的进程上下文中的某个状态,发送一个信号给目标进程。内核发送信号有两种原因:1)内核检测到一个系统事件(除0 键盘输入 定时中断);2)一个进程调用了kill函数,显式要求内核发送一个信号给目的进程(目的进程可以是自己)。
②接受信号:当目的进程被内核强迫以某种方式对信号的发送做出反应时(没有屏蔽信号时),它就接受了信号。进程接受后可以忽略这个信号,终止自己或者通过执行一个信号处理程序(signal handler)的用户层函数来处理这个信号。
一个发出而没有被接受的信号叫做待处理信号(pending signal)。在任何时刻,一种类型至多只有待处理信号。也就是说,如果进程有一个类型为k的待处理信号,那么新的类型为k的信号,将不会排队等待,而是直接被丢弃。
另外,进程可以有选择性的阻塞某种信号的接收,当一种信号被阻塞时,它仍然可以被发送,但是待处理信号不会被接收,知道进程取消对这种信号的阻塞。
一个待处理信号最多只能被接收一次。内核为每个进程在pending位向量中维护一个待处理信号集合,在blocked位向量中维护者被阻塞信号的集合。只要传送一个类型位k的信号,那么内核会设置pending中的第k位,如果第k位已经被设置了,那么直接丢弃信号;只要接收了一个类型为k的信号,内核就会清除pending中的第k位。
发送信号
要了解发送信号,先了解进程组(process group)。
每个进程都只属于一个进程组,进程组也是一个正整数ID,getpgrp()函数放回当前进程组ID:
#include <unistd.h>
pid_t getpgrp(void);
一个子进程默认和父进程属于同一个进程组。进程也可以通过setpgid()函数来设置自己或者其他进程的进程组。
#include <unistd.h>
int setpgid(pid_t pid,pid_t gpid);
如果pid是0,那么就代表设置当前进程,如果gpid是0,就用当前进程的pid当作gpid。
setpgid(0,0)设置当前进程的进程组ID为自己的PID.
用kill函数/程序发送信号
linux中/bin/kill程序可以向另外的进程发送任意信号:
kill -9 15213//向pid为15213发送信号9(对应前表的sigkill信号)
同样可以向进程组里的每个进程发送信号:
kill -9 -15213//向gpid为15213的每个进程发
同样的,使用系统调用kill函数也是同样的用法:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid,int sig);
从键盘发送信号
linux shell使用作业(job),命令来管理通过shell命令行创建的进程,并且在任意时刻,至多只有一个前台作业和若干后台作业。例如键入:
ls|sort
会创建由两个进程组成的作业(一个作业对应一个进程组)。shell为每个作业创建一个独立的进程组,进程组的GPID取自作业中的父进程。
上图中有三个作业,对应三次shell命令,每个作业都是一个进程组,子进程和父进程属于一个作业。
在键盘上输入Ctrl + C会导致内核发送一个SIGINT信号发送到前台作业对应的进程组中的每个进程,导致终止进程组,类似的Ctrl + Z会发送一个SIGTSTP信号到前台进程组的每个进程,停止/挂起前台作业。
#include <unistd.h>
#include <sys/types.h>
int main(){
int i;
pid_t p;
p=fork();
if(p==0){
printf("child!\n");
while(1){
p++;
}
}
printf("parent!\n");
while(1){
p++;
}
return 0;
}
例如上面的程序被shell运行时创建一个子进程,进程组是前台,键入Ctrl + Z,可以发现两个进程都被停止了挂起了suspended
用alarm函数发送信号
进程可以通过调用alarm函数向它自己发送SIGALRM信号:
#include <unistd.h>
unsigned int alarm(unsigned int secs);
值得注意的是,每个进程只能存在一个alarm闹钟,如果之前设置的闹钟还没生效,那么新调用的alarm函数会覆盖前面的。
接收信号
当内核把进程p从内核模式切回到用户模式(从系统调用返回或者完成了一次上下文切换),会检查进程p未被阻塞的待处理信号的集合(pending&~blocked),如果这个集合空,则直接回到正常的逻辑控制流;否则非空时,去选择一个待处理的信号类型的处理程序,处理完后会回到正常逻辑控制流。(注意,信号处理程序可以被其他信号嵌套中断,而且信号程序处理信号,不代表信号对主程序中sleep,pause的影响消失,仍然会将解除其挂起)
每个信号有默认的处理行为:进程终止、进程终止并转储内存、进程挂起、忽略(前面那张图里有默认行为)
可以通过signal函数修改接受某种信号的处理行为:
#include <signal.h>
void (*handler)();//定义一个函数指针handler
sighandler_t signal(int signum,sighandler_t handler);
当设置了某种类型信号的handler函数,并用signal设置后,当接受到非阻塞的信号后,会自动调用该类型的handler函数(内核强制跳转的,不由编写者不愿意,除非当时signal设置忽略)。当handler函数return后,回到中断的地方继续执行。
值得注意的是,handler函数处理信号的时候,可以被其类型的信号继续中断。
阻塞和解除阻塞信号
隐式阻塞:内核默认阻塞任何当前处理程序正在处理的类型的信号。例如程序正在处理s类型的信号,新的s类型的信号,会变成待处理(如果前面没有待处理的s信号的话,如果有就直接丢弃了)。
显式阻塞:可以使用sigprocmask函数和辅助函数,明确的阻塞和接触阻塞选定的信号。被阻塞的信号类型,收到信号时,会直接将待处理维向量的对应位置1,不会被处理:
编写信号处理程序
编写信号处理程序面临两个问题:
①处理程序与主程序并发的运行(即主程序的开始和结束时间和处理程序的开始结束时间重叠),共享全局变量,因此很可能与主程序或者其他处理程序互相干扰。
②如何接收和何时接受信号的规则通常有违直觉。
第一个问题,又称为安全的信号处理问题,涉及到并发编程,作者给出了如下建议:
①处理程序尽可能简单,比如处理程序只负责简单地修改全局标志,然后让主程序来执行相关处理,主程序周期地检查并重置这个标志。
②处理程序中只调用异步信号安全的函数。异步信号安全的函数能够被信号处理程序安全的调用,因为它们要么是可重入的(即只是用局部变量),那么不能被信号处理程序再次中断。
③恢复和保存errno,处理函数内部如果会修改errno(系统调用出错),那么在进入handler函数时立刻备份errno(整数常量),然后返回前恢复
④如果存在handler函数和主程序共用的全局变量或者结构,那么每次在handler或者主程序访问或者修改这些变量或者结构时,要先阻塞所有信号,结束后再解除阻塞。因为如果在访问或者修改这些变量时被中断了,访问或者写入不完整,再进入handler函数,那么handler对其的访问和写入就会出现难以预料的结果。
⑤用volatile来声明全局变量,如果一个全局变量在主程序中从未被更新过,只在handler函数中更新,编译器会认为该变量从未更新,将其缓存,不从内存中访问,导致更新失效。使用volatile 声明的变量,会强制编译器让对这个变量的访问是从内存访问。
⑥使用sig_atomic(atomic 原子的)作为全局标志变量,常见的处理程序设计中,处理程序会更新全局标志,主程序周期性检查该标志,相应信号,再清除该标志,除了可以用前面的④措施外,可以直接用sig_atomic 类型
volatile sig_atomic flag;
第二个问题,又称正确的信号处理
待处理的信号是不排队的,因为pending位向量中每种类型的信号对应只有一位,所以每种类型最多只能有一个未处理的信号。如果当前进程正在执行信号k的处理程序,现在来了两个信号k,那么第二就被丢弃了!,比如下面的代码:
#define __USE_POSIX
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <wait.h>
#include <sys/wait.h>
#include <string.h>
#include <signal.h>
volatile sig_atomic_t flag;
void handle(){
flag++;
return ;
}
int main(){
int i;
pid_t p;
flag=0;
signal( SIGCHLD,handle);//子进程停止或者终止信号
for(i=0;i<3;i++){
if(p=fork()==0){
exit(0);
}
}
while(1){
printf("parent receive %d signal totally\n",flag);
sleep(1);
}
return 0;
}
对于上面的程序,它在父进程中创建了三个子进程(但是子进程也会创建子进程的子进程,具体可以画图展现进程树),并在父进程用signal设置了SIGCHLD信号的handler,子进程空间中也有这个代码,但是并没有被执行,因此子进程并没有设置信号处理程序。
当父进程的handler函数被调用时,会给flag变量+1,该变量被初始化未0,且是volatile声明的,禁止缓存,并且是sig_atomic_t类型,该类型读写都是原子的(即不会被中断)。
上面的代码运行后接收flag的值最终不固定,三个子进程结束时内核发送给父进程的三个SIGCHLD信号,能够处理的信号不固定:
因此,为了接收全部的信号,应该在某种类型信号的处理程序中,经可能一次处理多的信号!,上面的代码可以改为下面,在一次handler调用里处理全部同类型信号:
#define __USE_POSIX
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <wait.h>
#include <sys/wait.h>
#include <string.h>
#include <signal.h>
volatile sig_atomic_t flag;
void handle(){
while(waitpid(-1,NULL,0)>0){
flag++;
}
return ;
}
int main(){
int i;
pid_t p;
flag=0;
signal( SIGCHLD,handle);//子进程停止或者终止信号
for(i=0;i<3;i++){
if(p=fork()==0){
exit(0);
}
}
while(1){
printf("parent receive %d signal totally\n",flag);
sleep(1);
}
return 0;
}
同步流以避免并发错误
所谓同步流,是指对于多个并发流,将其中的一部分阻塞,等待合适的时机,再让其恢复,或者直接控制某些流开始的时间,这样的好处时能够确定指令不同流的指令执行顺序。让并发流能够得到正确的结果。
考虑这样一个场景,shell对每个其打开进程,shell fork 进程,然后execve,父进程(shell的进程)执行add job函数,子进程终止,发送信号SIGCHLD给父进程,调用delete job函数.
但是,考虑父进程执行fork后,调度器调度子进程,然后子进程直接终止了,于是发送信号给父进程,当切换到父进程时,检测到pending&~blocked集合非空,直接执行父进程的handler函数delete job,问题是,这个job还没被父进程加入呢!
这是一个被称为竞争(race)的经典的同步错误示例.这个例子中 main函数调用add job和 handler函数中调用delete job之间存在竞争(都是父进程中的).如果add job赢得竞争,那么结果是正确的,如果没有,就是错误的,这样的错误无法调试,无法预测
对于前面的例子,应该在调用add job之前,阻塞这些信号,保证调用delete job在add job之后,因此我们在fork之前就要阻塞这些信号,因为fork执行完就子进程就产生了,就有可能发送信号了!
但是需要注意,子进程会默认继承父进程的阻塞集合,因此我们要在子进程恢复信号,代码如下:
#define __USE_POSIX
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <wait.h>
#include <sys/wait.h>
#include <string.h>
#include <signal.h>
volatile sig_atomic_t flag;
void handle(){
//delete job
return ;
}
int main(){
pid_t p;
sigset_t mask_all,mask_one,prev_one;
sigfillset(&mask_all);
sigempty(&mask_one);
sigaddset(&mask_one,SIGCHLD);
signal(SIGCHLD,handler);
initjoblist();
while(1){
sigprocmask(SIG_BLOCK,&mask_one,&prev_one);//block sigchld
if(p=fork()==0){
sigprocmask(SIG_SETMASK,&pre_one,NULL);
execve("executablefile",argv,NULL);
}
sigprocmask(SIG_BLOCK,&mask_all,NULL);
addjob();
sigprocmask(SIG_SETMASK,&prev_one,NULL);
}
exit(0);
return 0;
}
(上面的进程还在addjob前阻塞了所有中断,执行完后又解除了,保证addjob正确执行)
0x01 非本地跳转
c语言提供了一种用户级异常控制流形式,称为非本地跳转(nonlocal jump),它将控制直接从一个函数转移到另一个一个函数,而不遵循调用栈.
使用的方法很简单,先使用setjmp和sigsetjmp函数,保存要跳转目的地的调用环境,以供longjmp和siglongjmp函数跳转.sigsetjmp和siglongjmp是setjmp和longjmp能够在信号处理程序中使用的版本(也就是异步信号安全的版本)
本质上,这和goto没什么区别