Linux --进程信号
本文要点:
1.掌握Linux信号的基本概念
2.掌握信号产生的一般方式
3.理解信号递达和阻塞概念以及原理
4.掌握信号的一般捕捉方式
5.了解中断过程,理解中断的意义
6.用户态和内核态
7.重新了解可重入函数的概念
1.掌握Linux信号的基本概念
1.1信号实际上是操作系统给进程发送的一些指令信息,进程在接受指令信息时会在合适的时候处理这些指令,进程在接受信号的时候是异步的,即与进程此时在做什么无关,OS可以在任何时候给进程发送信号。识别信号是内置的,进程识别信号,是内核程序员写的内置特性。信号产生以后应该怎么处理?在信号产生之前进程就需要知道应该怎么处理信号,且处理信号可能并不是即时的,可能进程此时正在做优先级更高的事情。所以我们可以将进程接受信号并处理分为三个过程:(1)信号产生 (2)信号保存 (3)信号处理 。信号处理的方式一般有三种:默认处理,忽略,自定义处理方式。
1.2.1这里我们可以写一个代码来看到进程在接受信号并处理的现象:
这里我们可以看到代码本应该是死循环打印的,但是当我们按下ctr+c时进程接受到了一个信号然后执行了它的默认行为即退出该进程,说明无论进程在干什么都可以接受信号然后在合适的时候进行信号的处理。
1.2.2进程在接受到信号时也可以执行我们自定义的处理行为,这里介绍一个系统函数:
sighandler_t signal(int signum, sighandler_t handler); 参数说明:signum:信号编号,其实是一些宏代表的数字。 handler:函数指针,表⽰更改信号的处理动作,当收到对应的信号,就回调执⾏handler⽅法。 sighandler_t是本质上是void (*sighandler_t)(int),一个函数指针类型。我们上面使用的ctr+c实际上是使用键盘给前台进程发送SIGINT即2号信号,这里我们可以使用这个函数来验证一下。
此时我们再按下ctr+c确实能看到接受到的就是2号信号且使用了我们对应的自定义处理方法。要注意的是,signal函数仅仅是设置了特定信号的捕捉⾏为处理⽅式,并不是直接调⽤处理动作。如果后续特定信号没有产⽣,设置的捕捉函数永远也不会被调⽤!!
1.3信号的概念:信号是进程之间事件异步通知的一种方式,属于软中断。查看信号可以使用kill -l
编号34以上的是实时信号,本文只讨论编号34以下的信号,不讨论实时信号。这些信号各⾃在什么条件下产⽣,默认的处理动作是什么,在signal(7)中都有详细说明:
2.掌握信号产生的一般方式
2.1通过终端按键产生信号:我们可以使用之前提到的ctr+c(SIGINT)以及ctr+\(SIGQUIT)可以发送终⽌信号并⽣成core dump⽂件,⽤于事后调试。Ctrl+Z(SIGTSTP)可以发送停⽌信号,将当前前台进程挂起到后台等。这里就不作具体演示了,与捕捉SIGINT的流程是一致的。
2.1.2理解OS如何得知键盘有数据的:这里我们一图详解:
2.1.3理解信号起源:我们从上图可知CPU是通过硬件中断的方式告知了CPU有数据输入然后通过操作系统将信号发送给进程,所以信号其实是软件层面的模拟硬件中断的过程,两者有相似性但是层级不同,后续我们会再解释。
2.2调用系统命令向进程发送信号:我们可以在终端中使用kill 命令来发送信号给进程
2.3使用函数产生信号
2.3.1 kill:kill命令实际上是调用kill函数实现的,kill函数可以给一个指定地进程发送指定的信号。成功返回0,失败则返回-1。
我们可以自己来调用kill函数来模拟实现kill命令
2.3.2rase函数:函数可以给当前进程发送指定的信号(⾃⼰给⾃⼰发信号)。成功返回0,失败返回非0,底层也是调用了kill函数。
调用实例:
2.3.3abort:使当前进程接收到信号⽽异常终⽌。其实就是底层调了kill(pid,SIGABRT),这里就不作具体演示了。需要注意的是,该函数只要调用就会成功,所以没有返回值。
2.4.1由软条件产生信号:SIGPIPE 是⼀种由软件条件产⽣的信号,在“管道”中已经介绍过了。本节主要介绍 alarm 函数和 SIGALRM 信号。调⽤ alarm 函数可以设定⼀个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发SIGALRM 信号,该信号的默认处理动作是终⽌当前进程。这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个⽐⽅,某⼈要⼩睡⼀觉,设定闹钟为30分钟之后响,20分钟后被⼈吵醒了,还想多睡⼀会⼉,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表⽰取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
这里调用alarm来测试一秒钟CPU的计算次数,一秒以后打印cnt的次数再退出进程
在CPU内部是通过时钟源来定期进行进程切换以及调度等一些操作的,以下是简单模拟时钟源的流程,CPU内部收到的是硬件中断而不是信号。
2.4.2如何理解软件条件:在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产⽣机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产⽣的SIGPIPE信号)等。当这些软件条件满⾜时,操作系统会向相关进程发送相应的信号,以通知进程进⾏相应的处理。简⽽⾔之,软件条件是因操作系统内部或外部软件操作⽽触发的信号产⽣。
2.5.1硬件异常产生信号:硬件异常被硬件以某种⽅式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执⾏了除以0的指令, CPU的运算单元会产⽣异常, 内核将这个异常解释为SIGFPE信号发送给进程。再⽐如当前进程访问了⾮法内存地址, MMU会产⽣异常,内核将这个异常解释为SIGSEGV信号发送给进程。
模拟除0:
模拟野指针:
由此可以确认,我们在C/C++当中除零,内存越界等异常,在系统层⾯上,是被当成信号处理的。通过上⾯的实验,我们可能发现: 发现⼀直有8号信号产⽣被我们捕获,这是为什么呢?上⾯我们只提到CPU运算异常后,如何处理后续的流程,实际上 OS 会检查应⽤程序的异常情况,其实在CPU中有⼀些控制和状态寄存器,主要⽤于控制处理器的操作,通常由操作系统代码使⽤。状态寄存器可以简单理解为⼀个位图,对应着⼀些状态标记位、溢出标记位。OS 会检测是否存在异常状态,有异常存在就会调⽤对应的异常处理⽅法。除零异常后,我们并没有清理内存,关闭进程打开的⽂件,切换进程等操作,所以CPU中还保留上下⽂数据以及寄存器内容,除零异常会⼀直存在,就有了我们看到的⼀直发出异常信号的现象。访问⾮法内存其实也是如此,⼤家可以⾃⾏实验。
2.5.2Core Dump:SIGINT的默认处理动作是终⽌进程,SIGQUIT的默认处理动作是终⽌进程并且Core Dump。⾸先解释什么是Core Dump。当⼀个进程要异常终⽌时,可以选择把进程的⽤⼾空间内存数据全部保存到磁盘上,⽂件名通常是core,这叫做Core Dump。进程异常终⽌通常是因为有Bug,⽐如⾮法内存访问导致段错误,事后可以⽤调试器检查core⽂件以查清错误原因,这叫做 Post-mortem Debug (事后调试)。⼀个进程允许产⽣多⼤的 core ⽂件取决于进程的 Resource Limit (这个信息保存 在PCB中)。默认是不允许产⽣ core ⽂件的, 因为 core ⽂件中可能包含⽤⼾密码等敏感信息,不安全。
2.6总结:(1)上面所说的信号都是由OS产生然后发送给进程的,因为OS是进程的管理者。(2)信号的处理并不是即时的,而是选择在合适的时候,讲解内核态与用户态是会说明。信号如果不是立即处理的那么应该被保存起来,进程在收到信号之前就应该知道怎么处理这个信号了。
3.理解信号递达和阻塞概念以及原理
3.1信号其他相关的常见概念:(1)信号递达:实际执行信号处理动作称为信号递达 (2)信号从产生到递达之间的状态称为信号未决。(3)进程可以选择阻塞某个信号,此时被阻塞的信号处于未决状态,直到进程解除该信号的阻塞状态才会执行递达动作。(4)注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动作。
3.2信号在内核中的表示:信号在内核中有三个主要的表,分别是block表,pending表,handler表。
每个信号都有两个标志位分别表⽰阻塞(block)和未决(pending),还有⼀个函数指针表⽰处理动作。信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。
SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻 塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产⽣过多次,将如何处理?POSIX.1允许系统递送该信号⼀次或多次。Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,⽽实时信号在递达之前产⽣多次可以依次放在⼀个队列⾥。
3.3每个信号只有⼀个bit的未决标志, ⾮0即1, 不记录该信号产⽣了多少次,阻塞标志也是这样表⽰的。因此, 未决和阻塞标志可以⽤相同的数据类型sigset_t来存储, , 这个类型可以表⽰每个信号的“有效”或“⽆效”状态, 在阻塞信号集中“有效”和“⽆效”的含义是该信号是否被阻塞, ⽽在未决信号集中“有 效”和“⽆效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Msak),这⾥的“屏蔽”应该理解为阻塞⽽不是忽略。
3.4信号集的操作函数,sigset_t类型对于每种信号都用一个bit表示有效或无效状态,其内部是如何让构成的对于使用者是没有意义的,所以使用者只能调用以下的函数来操作sigset_t变量,而不应对它的内部数据进行任何解释,比如用printf打印这类变量是没有意义的。
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何的有效信号。
函数sigfillset初始化set所指向的信号集,使其中所有信号对应的bit置位,表示该信号集的有效信号包括系统支持的所有信号。
注意,在使⽤sigset_ t类型的变量之前,⼀定要调⽤sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调⽤sigaddset和sigdelset在该信号集中添加或删除某种有效信号
这四个函数都是成功返回0,出错返回-1。sigismember是⼀个布尔函数,⽤于判断⼀个信号集的有信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
3.4.1sigpromask:此函数可以读取或更改进程的信号屏蔽字,调用成功返回0,失败返回-1。
其中的参数sigset是要操作的一个信号集,如果oset是非空指针会输出当前进程的信号屏蔽字,how参数表示如何操作当前的信号集。如果oset和set都是⾮空指针,则先将原来的信号 屏蔽字备份到oset⾥,然后 根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
如果调⽤sigprocmask解除了对当前若⼲个未决信号的阻塞,则在sigprocmask返回前,⾄少将其中⼀个信号递达。
3.4.2sigpending:取出当前进程的未决信号集,通过参数set传出,调用成功返回0,失败返回-1。
这里我们可以使用上面提到的函数来写一下代码:
下图我们可以清楚的看到在ctr+c未按下之前2号信号并不在pending表中,按下以后由于以已经被屏蔽了所以出现在pending表中由0变1,当cnt=0时此时2号信号屏蔽被解除信号可以被递达去执行我们自定义的处理动作,此时pending表中的2号信号由1变0,因为已经递达了。
4.掌握信号的一般捕捉方式
4.1信号捕捉的流程:
如果信号的处理动作是用户自定义的函数,在信号递达时就调用这个函数称为信号捕捉。由于信号处理函数的代码时在用户空间中的,处理过程较为复杂,以下举例说明:
(1)用户注册了SIGINT信号的处理函数handler。
(2)当发生中断或者异常此时OS会从用户态陷入内核态。
(3)在中断异常处理完以后在返回用户态继续执行时检查到SIGINT递达。
(4)内核此时会进入用户态去执行SIGINT自定义的处理函数handler,handler 和 main 函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个独⽴的控制流程。
(5)当处理完handler函数后自动执行系统调用sigreturn再次进入内核态。
(6)如果此时没有新的信号递达则返回用户态继续执行Main函数的上下文。
所以在处理信号自定义函数时需要经过4次用户态与内核态的转换,检查Pending表时在内核态是执行的,因为进程的Pending表对于OS来说是全局函数,无论进程执行到哪一步都可以访问。
4.2sigaction:用来读取和修改指定信号相关联的处理动作。调用成功返回0,失败返回-1。
int sig
:要检查或修改的信号编号。常见的信号包括 SIGINT
、SIGTERM
、SIGKILL
等。若act指针⾮空,则根据act修改该信号的处理动作。若oact指针⾮空, 则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:
将sa_handler赋值为常数SIG_IGN传给sigaction表⽰忽略信号,赋值为常数SIG_DFL表⽰执⾏系统默认动作,赋值为⼀个函数指针表⽰⽤⾃定义函数捕捉信号,或者说向内核注册了⼀个信号处理函数,该函数返回值为void,可以带⼀个int参数,通过参数可以得知当前信号的编号,这样就可以⽤同⼀个函数处理多种信号。显然,这也是⼀个回调函数,不是被main函数调⽤,⽽是被系统所调⽤。
当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时⾃动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么 它会被阻塞到当前处理结束为⽌。 如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。 sa_flags字段包含⼀些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本文不详细解释这两个字段
代码示例:
在第一次调用2号的自定义处理函数时,我们使用3号信号与2号信号此时都会被屏蔽,当第二次handler函数调用结束解除对3号信号的屏蔽,接着递达执行默认退出进程的操作。
与signal的不同,有哪些新功能?signal只能捕获信号,对信号进行处理。但是不能获取信号的其它信息,sigaction可以使用sigaction结构体的sa_handler函数对信号进行处理(此处等同于signal函数),也可以使用sa_sigaction函数查看信号的各种详细信息并且sigaction函数还可以通过sa_mask、sa_flags对信号处理时进行很多其他操作。
5.了解中断过程,理解中断的意义
5.1硬件中断:在OS中并没有去轮询每一个外部设备去检查是否发生中断,而是把这个任务交给了CPU去做,当外部设备发生中断时首先中断控制器会保存中断号并通知CPU,然后CPU再告诉OS去通过中断号去查中断向量表然后执行中断方法。中断向量表是操作系统的一部分,启动就加载到内存中了。
5.2时钟中断:进程是在OS的指挥下被调度和执行的,那么OS又被谁推动执行?在CPU内部有一个时钟源持续出发时钟中断,然后OS就能通过对应中断号查中断向量表去执行中断服务:进程调度,所以OS也就能够被定时推动去执行了。本质上OS就是一个死循环,是一个被中断触发对应功能的一个函数 void main(){for(;;) pause();}。
CPU的主频,即CPU内核工作的时钟频率(CPUClockSpeed)。通常所说的某某CPU是多少兆赫的,而这个多少兆赫就是“CPU的主频”。很多人认为CPU的主频就是其运行速度,其实不然。CPU的主频表示在CPU内数字脉冲信号震荡的速度,与CPU实际的运算能力并没有直接关系。CPU的主频不代表CPU的速度,但提高主频对于提高CPU运算速度却是至关重要的,主频越高进程调度越快,也就能提高运行速度了。
5.3软中断:为了让操作系统⽀持进⾏系统调⽤,CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。OS的中断向量表中也有处理软中断的中断服务
一些细节问题:⽤⼾层怎么把系统调⽤号给操作系统? - 寄存器(⽐如EAX)。 操作系统怎么把返回值给⽤⼾?- 寄存器或者⽤⼾传⼊的缓冲区地址。系统调⽤的过程,其实就是先int 0x80、syscall陷⼊内核,本质就是触发软中断,CPU就会⾃动执⾏系统调⽤的处理⽅法,⽽这个⽅法会根据系统调⽤号,⾃动查表,执⾏对应的⽅法。系统调⽤号的本质:数组下标!
缺⻚中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后⾛中断处理例程,完成所有处理。有的是进⾏申请内存,填充⻚表,进⾏映射的。有的是⽤来处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。
6.用户态和内核态
6.1用户态:即线程执行用户自己的程序,不能直接使用系统资源,也不能改变 CPU 的工作状态,并且只能访问这个用户程序自己的存储空间!内核态:就是系统运行线程。系统中既有操作系统的程序,也有普通用户程序。为了安全性和稳定性,操作系统的程序不能随便访问,这就是内核态。即需要执行操作系统的程序就必须转换到内核态才能执行内核态可以使用计算机所有的硬件资源
6.2在CPU中有一个CS寄存器,CS寄存器低两位,叫做CPL,用于储存当前特权级,在linux中,有两种状态,就是0和3,0表示内核态,3表示用户态。
6.3用户空间与内核空间:在内存资源上的使用,操作系统对用户态与内核态也做了限制,在虚拟地址空间中0,3GB的空间称为用户态,特权级为3时只能访问此区域的空间,此空间是当前进程独有的。3到4GB称为内核空间,此区域的空间是所有进程所共有的,虽然此空间在每一个不同进程的地址空间中,但是他们映射了同一个内核空间,也就意味着每个进程进入内核态的时候访问的是同一块空间。
所以我们调用任何的函数(库,系统调用)都是在我们自己进程的地址空间进行的,OS无论怎么切换进程都能找到同一个操作系统,换句话说操作系统的系统调用方法的执行都是在进程的地址空间中执行的。
另外不管是哪一个进程的地址空间都是通过软中断进入内核进行操作的。
6.4用户态与内核态的切换:当发送系统调用,中断,异常的时候特权等级会由3变成0进入内核态去执行对应的任务,执行完了再返回用户态。
系统调用:用户态进程主动切换到内核态的方式,用户态进程通过系统调用向操作系统申请资源完成工作,例如 fork()就是一个创建新进程的系统调用,系统调用的机制核心使用了操作系统为用户特别开放的一个中断来实现,如Linux 的 int 80h 中断,也可以称为软中断
异常:当 C P U 在执行用户态的进程时,发生了一些没有预知的异常,这时当前运行进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺页异常
中断:当 C P U 在执行用户态的进程时,外围设备完成用户请求的操作后,会向 C P U 发出相应的中断信号,这时 C P U 会暂停执行下一条即将要执行的指令,转到与中断信号对应的处理程序去执行,也就是切换到了内核态。如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后边的操作等。
7.重新了解可重入函数的概念
这里用一个链表的插入来说明可重入函数:
main函数调⽤insert函数向⼀个链表head中插⼊节点node1,插⼊操作分为两步,刚做完第⼀步的 时候,因为硬件中断使进程切换到内核,再次回⽤⼾态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调⽤insert函数向同⼀个链表head中插⼊节点node2,插⼊操作的两步都做完之后从sighandler返回内核态,再次回到⽤⼾态就从main函数调⽤的insert函数中继续往下执⾏,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后向链表中插⼊两个节点,⽽最后只有⼀个节点真正插⼊链表中了
像上例这样,insert函数被不同的控制流程调⽤,有可能在第⼀次调⽤还没返回时就再次进⼊该函数,这称为重⼊,insert函数访问⼀个全局链表,有可能因为重⼊⽽造成错乱,像这样的函数称为不可重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊(Reentrant) 函数。想⼀下,为什么两个不同的控制流程调⽤同⼀个函数,访问它的同⼀个局部变量或参数就不会造成错乱?核心原因:每次函数调用都会创建自己独立的一份“活动记录”(activation record,也叫栈帧),函数的 形参 与 自动局部变量 存放在该栈帧中。不同调用路径(甚至同一个函数的递归或并发调用)具有各自的栈帧,地址空间(栈上的那段区域)不重叠,因此互不干扰。
如果⼀个函数符合以下条件之⼀则是不可重⼊的:调⽤了malloc或free,因为malloc也是⽤全局链表来管理堆的。 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重⼊的⽅式使⽤全局数据结构。