linux 信号处理机制简介
2012-05-09 00:00:00

鉴于后面把进程的形象给彻底毁掉了,我提前声明一下,进程是有尊严的有节操的,当然大部分人可能也看不到毁形象那一段。为什么介绍linux要从信号开始呢,当然是为了保证能讲明白,因为翻了翻书我发现这一部分是最简单的了,所以呢,就讲这个吧,顺便把之前源码阅读的东西总结一下。

信号是什么东西呢?

两个直观的感受,你在终端运行一个程序然后摁一下Ctrl+c就是向正在运行的程序发送了一个终止信号,程序就被终止了;在终端kill一个pid相当于发送9号杀死这个进程;在终端运行kill -l 就可以查看系统的所有信号。

有了上面这些直观感受,那么信号本质是什么呢?信号本质上是一种向一个进程通知发生异步事件的机制,是在软件层次上对中断的一种模拟。这种通知机制可以用于通知硬件消息like上面的感受1,也可以用来进行进程间通信like上面的感受2,还可以用来通知一些程序错误如除0、非法内存访问。异步是说进程没有对信号进行实时监控,不必等待信号到来,事实上进程也根本不知道信号什么时候会来。一个进程本来在欢乐的跑着突然就被你一个ctrl+c给杀死了,飞来横祸呀。至于说是一种软中断,是因为在原理上,一个进程受到一个信号与处理器收到一个中断请求可以说是一样的,本来在欢乐的跑着就从你一个脚上给你来一个高电平。


信号分类

通过kill -l可以看到linux现在支持64个信号,注意一下信号不是从0编号的而是从1编号。其中前32为标准(Standard)信号,后32为实时(Real-time)信号。好吧,什么是标准信号,什么又是实时信号。

在遥远的古代是只有标准信号的,那时候它也不叫标准信号,就叫信号,它是一种十分简单的机制。先说一下信号的运行,当信号发送到程序时并不是立即执行而是等待某个时机再执行,在这个时机还没到来的时候你一个类型的信号无论发多少个都只记录一个,就好比有32个信箱每个信箱只能收一封信,多的就扔吧;另一方面信号的响应也是不保证顺序的,你发送信号的顺序和信号响应的顺序可能根本就没什么关系,因为古代人类都比较简单嘛。后来人类不断发展又想要可以响应一个类型的多个信号又想保证响应顺序,实时信号就诞生了,其实就是加了个sigqueue这么个队列数据结构,需求就被满足了。但是之前的简单信号已经成为了实际上的标准,而实时信号的应用也还不如前者广,两者就共存了。

Tips:实时信号的信号范围由SIGRTMIN和SIGRTMAX两个宏来决定,编程使用实时信号的话可以使用SIGRTMIN+n指定一个信号而不是直接一个数字,因为万一标准信号数量又增加呢,直接写数字编码可能就会出现bug,这两个宏也为将来信号的灵活扩展提供了基础。同时也是灌输一个编程不要使用魔数的原则。

信号响应

UNIX对前32个信号都有默认的响应方式,分为以下5类:
  • Term:终止进程
  • Ign:忽略该信号
  • Core:终止进程并保存内存信息
  • Stop:停止进程
  • Cont:有停止就有恢复进程

当然只有5个响应方法怎么够呢,not fashion 于是sigaction()这个系统调用就上了,通过它可以给一个信号绑定一个函数来当作信号处理函数,你就可以在这个函数里面胡作非为了。可是你胡作非为了内核开发人员又感觉不爽了于是就设了两个信号你是改不了的,以显示他们不可动摇的地位,这两个信号就是9号SIGKILL和19号SIGSTOP,所以你也就不能定义Ctrl+c和Ctrl+z发送出来的信号的处理方式了。

Tips:当然你足够邪恶的话可以定义这两个组合键指向别的操作。

信号处理机制

废话这么多终于开始讲机制了。

信号发送和接收

最简单的理解,一个程序给另一个程序发了个短信,通过中国移不动或者中国联不通的网络,另一个程序的手机就收到了,一个信号就算发送成功了。具体来说就是一个程序调用一个发送信号的系统调用例如。然后内核就扮演运营商的角色把信号扔给另一个进程。我们知道进程在内存里还是有很多家当的,主要维护了一个进程描述符,里面有着pid呀,进程状态呀,优先级呀一堆不可告人的秘密,等以后有空了我给大家八卦一下。

pending 和 signal 是两个挂起信号队列,为什么要有两个呀?因为一个是私有的队列一个是共享的队列。为什么有私有和共享之分呀?因为一个是针对轻量级进程的一个是针对线程组的?这两个又是什么东西呀?本小农发现这里开始不好说了.为什么有私有和共享之分呀?因为一个是针对轻量级进程的一个是针对线程组的?这两个又是什么东西呀?本小农发现这里开始不好说了……简单说,Linux是没有进程和线程的,有的只有轻量级进程,如果一组轻量级进程之间可以共享资源,那么就组成一个相当于线程组的东西,也就相当于一个线程,换句话说Linux是用轻量级进程这个东西模拟多线程,感兴趣同学可以看一下LWP,总之这里知道有两个信号挂起队列就好了,如果前面LWP的东西没看懂,你这里可以认为一个是记录的给线程的信号,一个记录的给进程的信号。sighand就简单多了就是记录64个信号对应的处理函数的入口地址,当然还有其他好多辅助的数据结构,但主要就是这个功能。如果能大致看懂下面这张图说明你还没晕。

signal struct

回到手机短信,内核把短信发给进程是干了什么事呢?就是找个队列把信号插进去。当然进程也是有尊严的,不会让你随便插的如果是标准信号的话你只能插一次,如果这个信号还在的话就不让你插了,不像对实时信号那么随便想插多少插多少。有的进程比较专一,如果有一个信号插进来他会设置一个屏蔽位不让别的信号插,可以对比一下中断,处理器有时也会设一个中断屏蔽位有木有。

信号的查看与处理

短信发过来了,不一定就被看到了,这也是异步说的意思,那么什么时候进程才会发现我有一个新的短消息呢?原来进程是在从内核态这个黑暗的角落到用户态切换之前偷偷的看一眼短信,看看都有谁插了进来,然后把他们处理掉,再回到用户态光明正大的去接客。那么什么时候进程会去内核态这个小黑屋呢,主要有三种情况:

  • 执行系统调用
  • 处理中断异常
  • 进程调度上CPU

出小黑屋前,会看一眼手机,如果有短消息就啪啪啪的处理短信,如果没有的话就伤感的回用户态。如果是比较规矩的短信只是执行五种规定的标准动作那么在小黑屋解决就好了,但是有的短信比较坏调用了sigaction告诉进程要出来到这个地方来玩,然后进程就把手机扔小黑屋里拔腿就跑到用户态去玩了,玩完想起来手机还在家,就又回趟家看看还有没有其他短信,没有再去用户态光明正大的见人。具体过程如下图。

好了不调戏进程了,进程也是有尊严的,下面讨论一下进程的节操问题,节操这个问题确实比较难说,唉……

不管你知不知道,进程从用户态进入内核态是要再内核态保存一份用户态堆栈的副本的,其中最重要的就是保存当前的pc这样进程从内核态返回的时候把pc还原就可以按照原来的指令流行进了;不管你知不知道,当进程从内核态回到用户态的时候这个堆栈的副本是被清空的。于是当进程在收到一个出去玩的短信出去之后,他原来的用户态返回地址就被默默的清空了,然后他玩完回到小黑屋就发现找不到回用户态的路了,一辈子就被关在这个阴冷黑暗的小黑屋,永世不得见光,这个故事告诉我们节操是很重要的。然而这个恐怖的故事没有限制住任何一个进程寻欢作乐,进程们出去玩之前先往外发了个消息把用户态的返回地址,堆栈信息什么的都发出去了,等玩完回家再等小哥把信息发回来,就又可以光明正大的回用户态了,所以节操这个东西……

好了,上面都是为了加强理解的段子,下面是正儿八经的原理介绍,需要有对堆栈和函数调用机制有一些了解,你会发现还是节操比较好说。

我们知道,当进程陷入内核态的时候,会在堆栈中保存中断现场。因为用户态和内核态是两个运行级别,所以要使用两个不同的栈。当用户进程通过系统调用刚进入内核的时候,CPU会自动在该进程的内核栈上压入下图所示的内容:(图来自《Linux内核完全注释》)

在处理完系统调用以后,就要调用do_signal()函数进行设置frame等工作。这时内核堆栈的状态应该跟下图左半部分类似(系统调用将一些信息压入栈了):

在找到了信号处理函数之后,do_signal函数首先把内核堆栈中存放返回执行点的eip保存为old_eip,然后将eip替换为信号处理函数的地址,然后将内核中保存的“原ESP”(即用户态栈地址)减去一定的值,目的是扩大用户态的栈,然后将内核栈上的内容保存到用户栈上,这个过程就是设置frame.值得注意的是下面两点:

  1. 之所以把EIP的值设置成信号处理函数的地址,是因为一旦进程返回用户态,就要去执行信号处理程序,所以EIP要指向信号处理程序而不是原来应该执行的地址。
  2. 之所以要把frame从内核栈拷贝到用户栈,是因为进程从内核态返回用户态会清理这次调用所用到的内核栈(类似函数调用),内核栈又太小,不能单纯的在栈上保存另一个frame(想象一下嵌套信号处理),而我们需要EAX(系统调用返回值)、EIP这些信息以便执行完信号处理函数后能继续执行程序,所以把它们拷贝到用户态栈以保存起来。

以上这些搞清楚之后,下面的事情就顺利多了。这时进程返回用户空间,就会根据内核栈中的EIP值执行信号处理函数。那么,信号处理程序执行完后,怎么返回程序继续执行呢?

信号处理程序执行完毕之后,进程会主动调用sigreturn()系统调用再次回到内核,查看有没有其他信号需要处理,如果没有,这时内核就会做一些善后工作,将之前保存的frame恢复到内核栈,恢复eip的值为old_eip,然后返回用户空间,程序就能够继续执行。至此,内核遍完成了一次(或几次)信号处理工作。


参考资料