linux信号机制

一,信号处理概述

       进程间的通信一般有两种方式,共享内存和消息传递。比较随性的说,信号也可以理解为进程间异步通信的一种方式。不过一般操作系统原语喜欢将信号叫做软中断,即由外部系统通知进程发生了某件异步事件,被通知的进程可以在适当的时机对异步事件进行响应处理。

       信号机制拥有非常悠久的历史,在早期UNIX版本中就已经存在,经典操作系统教科书《UNIX操作系统设计与实现》第七章7.2节对信号处理有经典的描述。同时其他经典书籍比如《深入理解计算机系统》,《操作系统设计与实现》,《现代操作系统》都对信号有精彩的描述,但基本上只是个轮廓。而深入分析操作系统源代码的书籍《深入理解Linux内核》,《Linux源代码情景分析》从代码实现角度对信号实现机制进行了透彻的分析。这里存在一个问题,即只看原理性书籍上的讲述,只知道个信号的大概。只看操作系统源代码书籍又太偏细节,很难有一个整体的认识。而翻阅Linux内核实现,信号机制基本上贯穿了Linux进程管理的诸多部分(进程调度,进程等待,进程终止)方方面面。本人在阅读《Linux内核源代码完全剖析》进程管理部分的时候,看到信号处理遍布各地,摸不着头脑,后来又结合《UNIX环境高级编程》,以及上面种种原理书籍经过将近一周的琢磨,终于对信号机制有了一个大概的认识,下面总结如下。

       Linux的信号机制可以理解为一种异步通知机制,可以是当前进程给当前进程发送信号,也可以是A进程给B进程发送信号。在内核数据结构task_struct中会维护和进程收到的信号相关的信息,主要有如下三个字段:

       long signal;

       long blocked;

       struct sigaction sigaction[32];

       signal是信号位图,每一位代表进程收到的一个信号,所以在32位机器上可以表示进程最多收到32个信号。位为0表示进程没有收到对应的信号,位为1表示进程收到了对应的信号,信号值=位偏移值+1

       blocked是信号阻塞位图,每一位代表对应signal信号位图上的屏蔽情况,位为0代表信号不被屏蔽,位为1代表信号被屏蔽。  比如signal = XXXXXXX100001100,blocked = XXXXXXX000001010说明如下。 

     信号值和对应位偏移值关系如下:

信号值和位偏移值对应关系图
信号值信号的数字表示位偏移值说明
SIGINT21来自键盘的中断被屏蔽
SIGQUIT32进程收到来自键盘的退出(该信号未被屏蔽)
SIGILL43进程收到非法指令(该信号被屏蔽)
SIGKILL98进程收到强迫进程终止(该信号未被屏蔽)

           上图表示进程收到了SIGQUIT,SIGILL,SIGKILL信号。进程的SIGINT,SIGILL信号被屏蔽。只有在信号位图中置为1的,同时在信号阻塞位图中没有至为1的信号才能被进程处理。

           自己的UNIX操作系统支持多少种信号,可以在命令行中通过kill -l指令查看。一般来说MAC支持31种信号,Linux支持64种信号。不同平台支持的信号都差不多,因为毕竟都是按照POSIX标准来的。而我们在应用开发中用到的就这么几种,总结如下:

                            

UNIX支持的常用信号一览
信号英文名信号数字表述信号中文说明
SIGHUP1挂断控制终端或进程
SIGINT2终止进程
SIGQUIT3终止进程并阐述dump文件
SIGKILL9强制终止进程
SIGALARM15系统调用alarm超时后产生,终止进程
SIGTERM16终止进程
SIGCHLD18子进程死,默认忽略该信号
SIGCONT19恢复进程执行,默认忽略该信号
SIGSTOP20终止进程

           从其中可以看出大部分信号的默认处理方式是终止进程执行,只有SIGCHLD和SIGCONT是忽略信号。
           对于信号的处理一般分为三个步骤:信号发送时机,信号处理方式设置,信号处理时机,信号处理,结果清理五个部分,下面分别一一阐述。

二,信号发送时机

       信号发送时机主要有两种,一种是内核自动给进程发送信号。另一种是进程主动给进程发送信号,此时可以是当前进程给当前进程发信号,也可以是进程A给进程B发信号。下面分别解释:

       2.1,内核自动给进程发送信号

                这里单独列出内核给进程发送信号其实有点牵强,因为内核本身就属于进程地址空间的一部分,只不过这部分地址空间是所有进程共享的。这里只讲一个信号,SIGALARM。

               此信号和alarm系统调用有关,alarm()系统调用是给调用进程设置一个告警时间值,到达那个告警时间值内核自动给进程发一个SIGALARM信号,其实现过程是这样的:进程调用alarm()系统调用会传一个时间参数(以秒为单位),该系统调用会在调用进程的task_struct.alarm字段上加上指定的秒然后退出系统调用。内核调度程序schedule()每次执行的时候会遍历一遍进程数组列表里面的所有进程,只要发现有进程当前时间的值已经大于task_struct.alarm的值,就给该进程发一个SIGALARM信号,并重置该进程的alarm=0。我们姑且认为这种信号发送机制为内核自动给进程发送的。                                

 

       2.2,进程给进程发送信号      

                进程给进程发送信号分为进程给自己发信号,进程给其他进程发信号两种。大部分应用场景都是进程给其他进程发信号。

不管如何进程给进程发信号都要通过系统调用kill(pid , sig)来实现,注意这里kill不仅仅代表杀死进程的意思,虽然大多数信号都是杀死进程。这里有一个限制,发送进程的euid必须和接受进程的euid相同,或者发送进程具有超级用户权限。该函数的参数说明如下(pid标志接收信号的进程,sig标志要发送的信号):

               pid > 0 , pid代表进程号,即给某单个进程发信号,该单个进程由pid来唯一标志。

               pid = 0 , 信号被发送给当前进程的进程组中的所有进程,这里的一个隐含条件是发送信号的进程必须是进程组的组长。

               pid = -1 , 信号被发送给除0号进程进程外的所有进程。

               pid < -1 , 信号被发送给进程组中的所有进程(进程组号=-pid)。

              如果是进程自己给进程自己发信号,则一般是在进程执行程序中调用kill(pid,sig)。

              如果是进程自己给其他进程发信号,可选择的方式就很多了,可以在进程代码执行过程中发送,也可以用命令行发送。

用命令行发送信号的格式一般是 kill -sig pid。其实用命令行发送信号本质也是进程给进程发信号。

 

三,信号处理方式设置

       一般UNIX系统提供的信号都有默认的处理方式,大部分信号处理方式都是终止进程(有些信号在终止的时候会产生内存转储文件),也有的信号处理方式是忽略信号(SIGCHLD,SIGCONT)。

      不过内核也提供系统调用让进程自己给某个指定的信号设置自己特有的信号处理方式。SIGKILL,SIGCONT这两个信号不允许被设置信号处理方式,操作系统提供一个强制终止进程的原语。      

 

四,信号处理时机

       信号在被进程从内核态转到用户态的时候执行。常见的执行态切换有 系统调用返回, 时钟中断返回。这两种本质上都是中断处理返回。Linux当中系统调用的返回值放在eax寄存器中,接着内核代码判断进程状态,如果进程状态不是0,则去执行调度程序。如果进程时间片到期,则也去执行调度程序。

       接着开始检查当前进程的task_struct.signal & ~task_struct.blocked , 如果有收到未被屏蔽的信号,则按照信号从低位到高位的方式依次调用do_signal信号处理函数

       从这里可以看出,信号的处理过程是异步的,并不是说给进程发了信号,进程就立刻马上执行完当前指令就去执行信号处理程序。而是选择在从内核态返回到用户态的时候检查处理。一个进程在执行过程中可能没有系统调用(第一次创建fork(),最后一次退出exit()除外),或者系统调用的频率非常低,所以我们发的信号有可能很长时间得不到处理。但是时钟中断发生的频率非常频繁,并且是匀速发生的,所以在这里从时钟中断的角度来说的话,可以认为信号的处理是准实时进行的。

 

五,信号处理

       对信号的处理方式一般有三种,分别是忽略该信号,对信号执行默认操作,捕获该信号。我们可以人为的设置指定进程指定信号的信号处理方式,这种方式就是捕获该信号。注意忽略该信号和执行默认操作不叫捕获该信号,虽然我们的信号处理函数也可以指定忽略该信号或者执行默认操作。

       这里先解释忽略该信号和执行默认操作:一般操作系统内核都设置有每种信号的默认处理方式。比如SIGCONT和SIGCHLD的默认信号处理方式就是忽略该信号,继续处理下一个信号。其余信号默认都是调用系统调用do_exit()函数退出执行,有的会在退出前会产生内存转储文件,这里不再赘诉。

 

六,信号处理收尾

       一般情况下信号处理完了接着进程返回到用户态之后就可以继续执行系统调用/中断,此时我们说的对信号的处理都是在内核态处理。而如果我们自定义了信号处理函数,自定义的信号处理函数是在用户空间执行的,即系统调用/中断返回到用户自定义的信号处理函数中去执行,再返回到系统调用/中断的下一条指令执行。这是怎么做到的呢,还是挺有意思的:

        我们来考察一个系统调用的处理过程,假如我们执行一个fork()系统调用,fork()系统调用宏展开之后代码是这样的:

        static inline int fork(void){

                 long __res;

                 _asm__volatile ("int $0x80" : "=a"(__res) : "0"(__NR_fork));

                 if(__res >= 0 )

                      return (int)__res;

                 errno = -__res;

                 return -1;

       }

       展开成汇编语言表示形式是这样的:

                mov __NR_fork , %eax                  -------将系统调用功能号赋值给eax寄存器,fork的系统调用功能号是2

                int 0x80                                           ------发起系统调用

                mov %eax , res                               ------将系统调用的返回结果赋值给res变量,系统调用返回结果放在eax寄存器中

                XXXXXX 

       当CPU执行到int 0x80的时候,它会从中断描述符表(中断描述符表在操作系统初始化的时候已经建立好)中寻找0X80索引处的中断描述符,然后跳转到中断描述符指定的偏移地址去执行。CPU执行int 0x80陷入内核默认会把ss , esp , eflages , cs , eip五个寄存器的值压入内核栈。中断处理程序中也会做压栈操作,执行完系统调用后内核栈是下面的模样:

       

                   

            其中ss,esp指向用户栈,eflages为标志寄存器,cs,eip执行int 0x80下一条指令地址,即mov %eax , res的地址。系统调用的过程是根据系统调用功能号从系统调用表中寻找函数入口地址,然后跳转到里面去执行,返回结果放到eax寄存器中,再将eax压栈。执行到这一步就算系统调用的实际过程处理完了,然后接着内核会判断进程的状态有没有变成非0(在Linux中进程状态为0表示就绪态,用户运行态,内核运行态)状态,如果变成非0状态了就去调用调度函数重新选择一个进程执行。如果进程的时间片用完了也去调用调度函数重新选择一个进程执行。

            接下来开始判断进程有没有收到信号,判断方式是这样的:

            movl _current , %eax                                          ------------将当前进程task_struct的地址赋值给eax寄存器

            movl signal(%eax) , %ebx                                   -----------将当前进程收到的信号位图赋值给ebx寄存器

            movl blocked(%eax) , %ecx                                -----------将当前进程收到的阻塞信号位图赋值给ecx寄存器

            notl %ecx                                                             -----------将当前进程的阻塞信号位图按位取反

            andl %ebx , %ecx                                        ---------将当前进程的信号位图和阻塞信号位图按位相与并将结果赋给ecx寄存器

            bsfl %ecx , %ecx           -------从第0位开始扫描信号位图,看是否有1的位,如果有1的位则将1的位的偏移值放到ecx寄存器 

            je 3f                            ---------如果没有信号则向前跳转到标号3处执行

            btrl %ecx , %ebx       ------复位该信号

            movl %ebx , signal(%eax)    ---------重新保存信号位图

            incl %ecx                     -----------计算出真实的信号值

            pushl %ecx                  -----------信号值压栈作为调用信号处理函数的入参

            call _do_signal            ------------调用信号处理函数

            popl %ecx                   ------------弹出入栈的信号值

            testl %eax , %eax     

            jne 2b                           -----------测试返回值,如果返回值不为0则继续前面的信号处理过程

            popl %eax

            popl %ebx

            popl %ecx

            popl %edx

            addl $4 , %esp

            pop %fs

            pop %es

            pop %ds

            iret 

            上面这段汇编代码非常关键,下面来逐行解释一下。

            首先补充一个知识点,在Linux内核空间中有一个全局变量_current , 该_current变量存放的是当前进程任务数据结构task_struct的起始虚拟地址,系统刚刚启动的时候该全局变量指向0号进程的虚拟地址,0号进程的代码段和数据段都指向内核代码段和数据段,内核代码永驻内存,不会被交换出去。我们一般说的内存管理(和磁盘交换区交换,写时复制,需求加载技术)管理的都是用户地址空间。为了方便内核地址空间的管理,操作系统在启动的时候将内核地址空间的页目录表和页表设置成和实际物理地址一一对应,即内核代码的虚拟地址就是物理地址。当进程切换的时候,_current变量也会跟着切换,指向下一个切换到的进程的task_struct的虚拟地址。所有任务的task_struct,内核栈都位于内核地址空间中。0号进程的任务就是启动操作系统,接着执行无限for循环,在for循环中调用schedule()函数,来做进程调度。

             所以第一句是将当前进程任务数据结构的起始物理基地址赋值给eax寄存器。

            

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值