CSAPP阅读笔记-信号-来自第八章8.5-8.8的笔记-P526-P550

信号的概念

前几节讲过,系统检测到异常发生时,会进入异常处理程序,并切换到内核模式,处理完后视情况不同选择终止程序或返回。

信号是一种软件形式的异常,允许进程和内核中断其他进程,可以用来通知用户进程发生了某些异常。

以下是我的理解:

  为什么会需要信号这种机制?

举个例子:你的程序设计有问题,跑到一半触发了必须终止的异常,程序直接退出,但此时用户在用户看来就是发生了“闪退”,会很奇怪。平时用软件应该见过这种情况:程序发生了某种错误,然后跳出个提示框,“xxxxx”,点击确定后程序退出了,我认为这就是一种“通知”机制。再比如,有些程序,比如matlab,在跑程序时可以通过按下ctrl+c键来终止,这显然是通过信号机制强行中断了这个进程。

上图是Linux中的不同类型信号,看一下就行。

  内核发送信号给进程,进程捕获到此信号后可以通过一个“信号处理程序”来作相应处理。

如何捕获信号呢?

  内核为每个进程维护了两个位向量,一个用来标记待处理信号,一个标记被阻塞信号,相应的位置1表示收到某个信号,比如收到信号9,SIGKILL,则第9位置1。

那么待处理信号和被阻塞信号是怎么回事?

  对一个进程而言,同一时刻,同种类型的信号最多只能有一个处于待处理状态,多余的会被直接抛弃,因此同种类型的信号最多同时有两个,一个正处于其对应的信号处理程序,另一个处于待处理状态。另外,进程可以有选择地阻塞某种信号的接收,此时收到的对应类型的信号的待处理位不会标记,只会标记对应的被阻塞位。

  

信号处理程序

那么当发送过来的信号处于待处理状态时,怎么将它检测到呢?

  答案是通过内核实现,当内核把进程从内核模式切换到用户模式时,会检查进程的待处理信号集合,若为空,则控制返回到进程的下一条指令,否则内核选择某个信号(通常是序号最小的那个)并强制进程接收此信号。

怎么通过信号处理程序来对接收的信号处理呢?

  首先,如上面的表所示,接收的信号是有默认行为的,若代码中不做处理,执行默认行为,可以通过signal函数修改除了SIGSTOP和SIGKILL之外的所有信号的默认行为。

  signal函数包含于头文件signal.h中,其函数原型如下:

  

这时候就体现自己的C语言功底不扎实了,以前只看到过类似这样的typedef用法:typedef int size;  size array[5];   这个比较好理解

那这里typedef怎么理解?我参考了:https://blog.csdn.net/jiuyueguang/article/details/9350903

总结一下:只要记住,typedef在语句中所起的作用只不过是把语句原先定义变量的功能变成了定义类型的功能而已,由此可见,typedef int size相当于定义了一个类型int,其名字是size,相当于做了初始化,而typedef void (*sighandler_t)(int),先看void (*sighandler_t)(int),这是一个名为sighandler_t的函数指针,无返回值,参数为int,typedef void (*sighandler_t)(int)则是定义了一种名为sighandler_t的函数指针类型,此函数无返回值,参数为int,定义此类型后没有对其初始化。

因此这里两句连起来的意思就是:signal函数,参数1为int,参数2为指向一个参数为int,无返回值的函数指针,返回值也是同样类型的函数指针。

我特意去我的电脑里看了下,发现我的是这样的: 

显然这是上述定义的精简写法。

另一个有意思的是图中这句话:若出错则为SIG_ERR,这说明SIG_ERR也是一个函数指针,那么其声明是什么样子呢?

如下图所示: 

看到了没?这是个强制类型转换,参考如double a = 1.1; int c = (int)a;这样的强制类型转换,那么这里就是把-1强制转换成了void(*)(int)类型的函数指针,不得不说,底层的开发确实很吃语言功底,如上面的这种写法,我是没见到几个开发里这么写的。。。

  言归正传,signal函数可以改变与某个信号类型绑定的默认信号处理行为,第1个参数就是对应的信号(参考图8-26),第2个参数就是要传入的信号处理程序的函数指针,注意,第2个参数也可以为SIG_IGN或SIG_DFL,前者代表忽略对应类型的信号,后者代表恢复对应类型信号的默认行为。

 

看两个例子:

第一个:

这个程序可以捕获SIGINT信号,举例而言,当键盘按下ctrl+c时,内核就会向前台进程组中的每个进程发送SIGINT信号。这里用signal函数把接收到SIGINT信号的默认行为绑定到了sigint_handler函数上。

另一个需要注意的是pause函数,此函数原型为:int pause(void);   它会让调用函数休眠,直到该进程收到一个信号。

 

第二个:

答案如下:

这里有好几个注意点:

1.这里的执行这个编写的snooze.c文件时,用了./snooze 5命令,上一篇文章中我们大致解释过shell里面是怎么运行的,显然snooze是shell的外部指令,shell里面用了execve来加载并执行这个程序,而它的第一个参数就是可执行文件名,也就是snooze,第二个参数是argv参数数组首地址,第三个参数是环境变量参数数组首地址,经shell内部处理后以参数形式传入main中,所以此时5这个参数存放在argv[1]中,argv[0]存放的是程序名"snooze",argc代表参数数量,现在为2,因此main中第一个判断是为了保证参数格式输入正确。

2.atoi函数即ascii to int,可以实现字符串转int,所以atoi(argv[1])是为了把5这个参数转为int并传递给snooze函数来实现调用。

3.snooze函数中有用到sleep函数,此函数可以将进程挂起一段时间,原型为:unsigned int sleep(unsigned int secs);参数为指定挂起的时间,若挂起时间到了指定时间,返回0,若sleep函数被信号中断,信号处理完毕后返回到sleep时,sleep会输出剩下要休眠的秒数。

4. 我是在Xcode上运行它的,结果有个很尴尬的事情发生了,新建的源文件默认main不带参数,导致argc和argv不存在,这是因为xcode没设置好,参考下面的链接设置一下即可:http://www.th7.cn/Program/IOS/201603/791849.shtml

然而,修改后又出现了新问题,运行时按下ctrl+C时,并没有切换到信号处理程序,那是因为Xcode屏蔽了signal的回调,参考下面两个链接:

https://blog.csdn.net/bravegogo/article/details/81168444

https://www.jianshu.com/p/fbdcaab304f1

以上两个链接讲述了如何解除屏蔽,但我懒得试了,因为有一个简单的办法解决这个问题,只需用gcc编译生成一下这个snooze.c,然后直接用命令行启动就行了,经测试,这种情况下程序运行符合预期。

 

可以用显式的方式改变阻塞信号的集合,需要使用sigprocmask函数,其原型如下:

int sigprocmask(int how,const sigset_t *set,sigset_t *oldset);

配合使用的有sigemptyset,sigfillset等等,具体说明看书。

 

并发运行

信号处理程序与主程序并发运行,共享相同的全局变量,这带来了一些很棘手的问题,比如主程序和处理程序同时访问相同的全局变量,会导致不可预知的后果。

保守的解决方案有以下几个:

1.在处理程序中只使用异步安全的函数,如write,它们可以保证被异步调用时不会引发错误,具体的函数列表看书。

2.进入处理程序时先保存errno,返回时恢复,防止其他进程修改errno。

3.对共享全局数据结构,访问时应先阻塞所有信号。

4.用volatile声明全局变量,如volatile int g;它相当于告诉编译器,不要缓存这个变量,每次访问它时需从内存中读取,当然,由于一般用在全局变量上,所以会和第三条配合使用,即访问时先阻塞所有信号。

几个有趣案例:

这个程序中父进程创建了3个子进程,并试图在每个子进程退出时(此时内核会发SIGCHILD信号给父进程),对其进行回收。

结果如下:

可以看到只回收了两个子进程,还有一个成了僵尸进程,原因是子进程退出太快,而信号处理程序里有Sleep,此时连续3个信号发过来,一个进入处理程序,一个进入待处理集合,另一个自然就会被抛弃。

 改进方法: 

此时父进程接到信号时会循环等待并回收子进程,直到无子进程,即使信号阻塞了也没关系,因此不会出现僵尸进程。

补充一点,这里的Sio_puts是书中自己包装的输出函数,因为printf是不安全的,Sio_puts本质是一个封装了write的输出函数。

 

再来一个例子:

几个注意点:

1.处理程序里在输出前先阻塞了所有信号,输出完毕后解除阻塞。

2.介绍一下kill函数,原型为int kill(pid_t pid,int sig);它可以向指定的进程发送指定信号,具体规则不再介绍,书上有。

3.输出结果为213,2不用说了,父进程创建子进程,子进程进入无限循环,父进程调用Kill,向子进程发送SIGUSR1,子进程的处理程序输出自减后的counter,即1,随后子进程退出,父进程回收成功,随后父进程打印counter的自增量,为3。注意,因为信号是发给子进程的,这说明,父进程绑定了handler1后,子进程被创建时,存留有父进程的状态。

4.子进程是在无限循环中被信号打断的,这验证了之前所说的信号处理程序可以中断进程的说法。

5.子进程修改了counter后父进程的不变,说明子进程拥有的counter只是一份拷贝。

 

再看一个例子:

这个程序维护了一个作业列表,当创建子进程时,把子进程的id添加到作业列表中,当子进程结束时,信号处理程序中,父进程回收它,并将其id从作业列表中移除。

它的问题是:父进程在阻塞所有信号并添加条目之前,已经将子进程退出的信号绑定了对应的移除条目的信号处理程序,因此若父进程创建子进程后,子进程在父进程阻塞所有信号并添加条目之前结束,会导致信号处理程序去删除一个不存在的条目,随后父进程又将此条目加入列表,这样此条目永远不会得到移除。

 

一种解决方法是在创建子进程前阻塞对应触发handler的信号,直到父进程添加条目完毕后再解除阻塞:

不过这里要注意一点:因为子进程被创建时存留有父进程的状态,因此若创建前信号被阻塞了,创建出来的子进程也会阻塞对应信号,此时子进程操作前需要先解除阻塞(第32行),而这会不会影响父进程呢?不会,因为此时父进程仍为阻塞状态!!!因此当子进程退出时,父进程不会响应信号。而父进程在添加完条目后解除的是所有信号的阻塞,因此SIGCHLD对应的信号阻塞也会被一并解除。

 

后面的sigsuspend和非本地跳转也不难,看书即可。

 

posted on 2019-05-10 17:26  暴躁法师  阅读(949)  评论(0编辑  收藏  举报