引言:
北京时间:2023/6/27/21:43,刚刚更新完这个星期的第一篇博客,现在刚好趁热打铁,看看写到11点左右,该篇博客能完成多少,并且今天和我预想的一样,通过早睡,成功在7点起床,但是由于一些列原因,导致我们起床洗漱,吃完饭之后,又睡过去了,并且一睡就睡到了10点,所以在学习时长方面,还没有昨天时间长,不过好在今天晚上进行了一定的弥补,尽量早日可以调整好作息,达到最好的更文效率吧!过几天线上课就要开始了,到时候肯定是更花时间,单单是上课我就有点受不了,更不要说,还要开始做题,头大,哎,感慨!废话不多说,为了早睡,正式进入该篇博客主题,还是有关Linux系统中信号相关的知识,信号这块的知识,涉及面比较广,所以需要我们花费比较大的力气去搞定它。
深入信号处理
明白信号的处理,一般不是立即处理,而是在"合适"的时候进行处理,并且这个合适的时候指的就是当某一个进程从内核态切换为用户态,那么此时就会有两个问题,首先是为什么进程从内核态切换为用户态时,信号就会被处理呢?其次是内核态和用户态具体表示什么意思呢?如下所述,此时我们对在"合适"的时候进行信号处理进行深度解刨!
如何理解用户态和内核态
首先明白,用户态和内核态是操作系统中两种不同的执行模式,最大的区别就在于资源访问权限,其中用户态执行模式也被称为受限模式,内核态也被称为特权模式,单从名称上我们就可以理解,用户态进程访问资源的能力有限,而内核态进程的访问资源能力则不受限,能够对所有的系统资源以及硬件资源进行访问,本质理解也就是涉及到操作系统内部的代码(系统调用接口、硬件资源)只有内核态进程能够访问,而用户态进程不行,最简单理解如下:
用户态:表示的是某个进程在执行用户,当然也就是我们自己,编写的代码时,进程所处的状态。
内核态:同理,表示的是进程在执行操作系统(OS)代码时,进程所处的状态。
深入理解用户态和内核态
搞定了上述知识,我们对什么是用户态,什么是内核态有了一定的理解,但这些理解也只是基于概念上的理解,所以想要深入理解用户天和内核态相关知识,此时就需要通过图示或者是对比,结合以前学习过的相关知识来理解它,具体如下重谈地址空间内容中详解,但是在重谈地址空间之前,我们需要明白一个点,就是为什么用户态进程会去执行内核级代码(OS代码),具体和两个原因有关,其一与时间片有关,其二更好理解,与系统调用接口有关,具体什么是时间片,下述简单介绍,这里不多做讲解,而系统调用接口就非常好理解,因为无论是我们在使用各种系统调用接口,还是各种动态库、静态库,编写代码时,都离不开操作系统的内核代码,所以明白用户态进程和内核态进程是不可分割,相互配合使用的。
时间片理解:操作系统的多任务处理环境下,为了提高代码执行效率,实现公平的调度和资源共享,操作系统将CPU执行时间划分成一段一段的时间片,分配给每个进程,以轮换的方式让每一个进程都有机会执行,每当一个进程的时间片用完了,操作系统就会进行上下文切换,将CPU控制权转移到下一个等待执行的进程。
重谈地址空间
通过对有关虚拟地址空间的知识理解,我们就可以很好的深入理解用户态和内核态,当然重谈地址空间最主要的目的就是搞懂为什么要区分用户态进程和内核态进程,简单理解很容易明白,就是不想让内核代码被随意访问,所以此时问题就可以转变成,为什么内核代码会被随意访问呢?想要解决该问题,并且搞懂上述问题,此时我们就需要重谈地址空间,如下图所示:
结合之前学过的知识,我们知道,操作系统为了提高代码执行效率和资源管理方式,对于进程的执行方式,实现的是多任务同时进行,并且为了保证每一个进程之间互不干扰,每一个进程都有属于自己的虚拟地址空间,这样不仅可以很好的保证系统的安全性和稳定性,还可以高效的进行内存管理和资源利用。此时如上图所示,我们明白,在虚拟地址空间中,内核空间占1GB,用户空间占3GB,用户空间中的代码和数据通过用户级页表映射到物理内存,内核空间中的代码和数据通过内核级页表映射到物理内存。并且我们知道,由于用户编写的代码不同,所以每一个进程在用户空间中的代码和数据都是不同的,同理可知,由于地址空间中内核区的代码和数据是操作系统本身就存在的,用户不能就行修改,所以内核空间中的代码和数据是不变的,永远相同的,从而我们就知道,对于所有进程来说,每一个进程不仅拥有唯一的虚拟地址空间,也拥有与之对应的用户级页表,但对于内核级页表来说,所有进程共享同一份,当然原因就是每一个进程虚拟地址空间的内核区代码是相同的。最终明白,这样设计的好处就在于,可以让进程切换和内核代码无关,任何进程都能访问到同一份内核代码。
搞懂了上述知识,此时我们就明白,为什么内核级代码可以被随意访问,因为内核级代码本身就存在于进程的虚拟地址空间上,如果该进程想要直接访问,就可以直接实现函数指针跳转到内核区, 然后通过内核级页表访问到物理内存中与之对应的内核代码和数据,与进程代码直接访问共享区上的动静态库,没有任何区别,都可以直接实现跳转访问,所以此时为了避免这个问题,用户态和内核态的概念就被提出,同理,这两种不同的执行模式,本质就是为了避免内核代码被随意访问,增加系统的安全性和稳定性。
操作系统如何区分用户态和内核态
搞定了上述知识,此时我们就明白了用户态和内核态的起源,本质还是为了配合体系结构,保证系统的安全稳定,不得不佩服前人的智慧,这个体系结构真的很牛,哎,感叹!此时我们再来看看,操作系统具体是如何区分用户态进程和内核态进程,不过在此之前,我们需要看看,操作系统对于一个用户态进程访问内核代码会如何处理,如下:如果一个用户态进程想要直接去执行同一虚拟地址空间上的内核级代码,首先面临的就是操作系统对该进程身份、执行级别的检测,发现该进程是用户态,那么操作系统就会拦截该进程,然后CPU就会拒绝访问对应的代码,并且导致对应寄存器的溢出标志位置1,引发硬件异常,最终操作系统再根据对应寄存器上存储的进程pid,找到该进程,向该进程发送相应的终止信号,进程被终止。所以本质操作系统还是通过软硬件结合的方式来区分用户态进程和内核态进程,在CPU中存在一个CR3寄存器,如果此时该寄存器为3,就表示该进程的执行级别为用户态,如果为0,则表示该进程的执行级别为内核态。如果再深入一点了解的话,此时面临的问题就是:谁在什么时候更改这个寄存器呢?面对这个问题,这个"谁"肯定没有疑问,一定是操作系统,那么操作系统到底是在什么时候去更改这个寄存器呢?这个问题本质也很好理解,同理上述所说,一个用户进程只有在两种情况下会去执行内核代码,一是时间片到了,二是调用系统调用接口,而时间片到了的本质,也就是在执行系统调用,因为保存当前进程上下文,切换下一等待进程,这个代码肯定不是用户来完成的,一定是操作系统内部本来就存在的内核代码,所以明白,操作系统提供的所有系统调用接口,内部在正式执行调用逻辑的时候,肯定存在一段修改该寄存器(CR3)的代码,这就可以很好的解释,为什么执行内核代码(系统调用),CR3寄存器一定为0,而执行用户级代码,CR3寄存器一定为3。
如何理解进程被调度
谈到进程调度问题,此时就不得不结合操作系统,首先我们一定要明白,电脑在开机过程中,本质就是在把操作系统的代码和数据加载到内存中,那么操作系统的代码和数据被加载到内存之后,它是如何启动的呢?此时就涉及到1号进程(systemd),想要让系统启动就一定需要一个进程,该进程就是systemd,因为所有进程之间是一个多叉树的关系,所以1号进程(systemd)就是所有进程的祖先,管理着所有的进程,如下图所示:
通过上述所说,此时我们就可以明白,操作系统的启动过程是由内核代码完成的,其中1号进程执行的就是与操作系统对应的代码。都知道,操作系统是一个管理软硬件资源的软件,那么具体是如何管理的呢?首先明白,对于操作系统来说,它肯定是一直处于工作状态,那么如何让操作系统一直处于工作状态呢?在系统内部是这样设计的,如下:在硬件中存在一个OS时钟,每隔一段时间,该OS时钟就会向操作系统发送时钟中断信号,操作系统接收到该信号之后,操作系统就会去执行对应的中断处理动作,操作系统在该OS时钟硬件的控制下,就可以定期的执行操作系统对应自己的任务,从而到达一直处于工作状态。
进程调度
搞定了上述知识,再来理解进程调度,那么就是顺理成章,进程调度的本质,就是某一个进程的时间片到了,然后该进程的上下文被保存,保存之后操作系统替换另一个拥有时间片的等待进程而已。再深入理解,就是操作系统去调用进程调度接口(schedule),执行该接口对应的代码,进而完成进程调度。那么操作系统是如何判断一个进程的时间片是否到了呢?同理上述所说,因为操作系统一直处于工作状态,接收到OS时钟的中断信号后,操作系统就会去执行中断处理动作,当然该中断处理动作,也就是操作系统需要完成的各种任务,其中就包括了检测当前进程时间片的动作,具体检测原理:由于进程被调度时间会被保存,此时只需要用当前检测该进程的时间减去保存时间,再对结果进行判断,就可以知道对应的进程时间片是否到了。
总:系统调用的本质就是在编写代码时,将对应系统调用接口的地址编写到我们自己的代码中,然后当程序运行起来之后,CPU处理该代码时,根据对应的地址,找到对应的系统调用代码,但在执行系统调用代码之前,会先执行对应修改寄存器(CR3)的代码,从而让操作系统检测时,检测出该进程是一个内核态进程,程序正常运行。并且同理,在内核代码执行完成之后,依然有相关修改寄存器(CR3)的代码,目的就是将执行模式从内核态变回用户态。
详解信号处理过程
理解内核态切换为用户态时,信号就会被处理
搞定了上述有关用户态和内核态相关的知识,此时我们再来谈谈信号被递达时的相关知识,也就是当一个阻塞信号被解除阻塞时,对应的信号为什么会被立即递达!首先明白,信号的产生是异步的,进程在接收信号的同时,可能也处于运行状态,只有当进程处于一个合适的状态时,该进程才会执行对应的信号。好比在日常生活中,如果你手头上正在做一件事,突然又收到消息,另一件事等待你去完成,那么这个时候,你肯定不是立即就去执行,而是等待一个合适的时候,才会去完成,同理,在操作系统内部,进程运行也是一样。并且通过上述讲解,我们对什么是内核态和用户态可以说是了如指掌,对于什么时候内核态会切换为用户态也有了明确的理解(时间片、调用系统调用),如下图所示:
如上图所示,此时发现,当进程因为调用系统调用接口,从用户态切换为内核态时,执行完对应系统调用代码时,操作系统就会对该进程pcb中的信号列表进行检测,看是否有信号需要被执行。但,此时会面临一个问题,也就是在进行信号检测时,如果该信号的执行动作是自定义动作,那么此时执行相应自定义代码时,应该使用用户态进程,还是内核态进程呢?具体分析如下:
首先明白,操作系统有没有权利执行我们的代码,也就是内核态进程可不可以执行用户代码,答案当然是可以,因为内核态的权限非常高,所以操作系统并不会像处理用户态一样去检测内核态(所以CR3寄存器表示为0还是为3此时并不重要)。但是,从理论上来讲确实是可以,但是从实际出发,是不允许内核态进程执行我们的代码,原因非常简单:因为如果用户代码中存在非法请求,本来很正常,该非法请求操作系统肯定是不允许的,因为你的权限不够,但是如果此时变成是内核态执行该用户代码,那么瞬间就会导致权限满足,导致非法请求被执行,用屁股想都知道不合理,所以在实际中,并不允许内核态进程执行用户代码,本质就是为了防止用户代码中存在非法行为,不会因为内核态的高权限导致某些非法操作被允许执行。所以显然,上述在执行自定义动作时,使用的就一定是用户态进程,而不是内核态进程。
当然同理,搞定了上述问题,也就是当内核态去执行自定义动作时,需要切换成用户态执行,那么执行完之后呢?面对这个问题,从内核态到用户态的切换我们就可以得出答案,第一次内核态切换为用户态时,是为了执行对应信号的自定义动作,并不是为了回到原用户态进程执行剩余代码,所以如果直接让执行自定义动作的用户态进程直接再去执行剩余代码,肯定就会因为找不到对应的代码而出错,所以正确的执行方法,是先让执行自定义动作的用户态进程切换为原内核态进程,具体过程本质还是在调用系统调用接口(sigreturn
),然后再让内核态进程切换为原用户态进程,同理具体该切换过程也是在调用系统调用接口(sys_sigreturn
),并且该接口的具体工作原理也就是恢复(找到)对应用户态变成内核态时被保存的上下文,从而实现继续向后执行代码。具体过程如下图所示:
sigaction接口
搞定了上述有关信号处理过程,此时我们再通过系统调用接口来实地操作一下(信号捕捉),当然此时用到的就是sigaction()接口,头文件:signal.h,基本使用方式:int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
值得注意的是,该接口在使用上功能比较丰富,所以使用起来比较复杂,但本质不变,同理signal接口,只是为了操作handler表而已,其中第一个参数signum同理表示的就是信号编号,第二个参数act,从类型来看就是一个结构体指针,第三个参数同理第二个参数,只不过此时第二个参数表示输入型参数,第三个参数表示输出型参数,同理结合之前学习过的知识,都知道,作为输出型参数本质就是为了获取修改之前对应信号编号的处理动作而已,具体先不谈,重点在于搞懂这个结构体类型具体如何使用,如下图所示:
如上图所示,此时我们明白该结构体中有5个成员变量,也就是说如果想要使用对应该结构体类型的参数,就需要将这5个结构体成员变量都给初始化,并且对应不同的初始化内容,此时sigaction()接口就会起不同的作用,例如此时sigaction结构体中的第一个成员变量sa_handler,可以看出该变量是一个函数指针类型,该变量在sigaction接口中起的作用和signal接口是相同的,如下代码所示:
可以发现,我们除了将sa_handler函数指针变量给了对应的handler函数之外,其它结构体成员变量都置为0,并且我们明白,如果此时将sa_handler赋值为常数SIG_IGN,那么此时就表示忽略该信号,如果将sa_handler赋值为SIG_DFL,那么表示执行默认动作,所以从本质上来看,sigaction()接口单独对于sa_handler变量来看和signal接口没有任何区别。
信号处理过程细节知识
明白,当某个信号的处理函数被调用时(信号被递达),内核会自动将当前信号加入进程的信号屏蔽字(也就是将该信号设置为阻塞信号),当信号处理函数返回时自动恢复原来的信号屏蔽字(解除阻塞),这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。明白了这点之后,此时就可以来谈谈sigaction结构中的另外一个成员变量sa_mask啦!也就是在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,此时我们就可以使用sa_mask成员变量来完成,说明这些需要额外屏蔽的信号,并且同理,当信号处理函数返回后,这些信号会自动解除阻塞,也就是恢复原来的信号屏蔽字。具体如下图所示:
如上图所示,首先明白的就是sa_mask变量本质就是一个sigset_t位图结构,本质就是在设置对应的位图结构,从而让sigaction()接口在使用sa_mask变量时,将sa_mask位图中对应比特位的信号编码阻塞,也就是添加到信号屏蔽字中。该sigaction结构体中剩余成员变量于实时信号有关,这里不多做讲解,其中就是sa_flags变量中还包含一些选项,今天我们先全设置为0就行,所以sigaction()接口相关知识,我们就了解到这,并且信号处理相关知识我们就搞定啦!