首先我们要知道信号和信号量没有任何的关系。
讲解信号我们会从下面的几个步骤开始讲解。
信号的概念
例如前一天晚上我们在手机上设置了一个闹钟,第二天早上闹钟响了,此时我们就知道我们应该起床了,这就是我们所说的信号的机制。
由此我们就能知道在生活中的信号是存在很多种的。
例如下面的这些:
我们通过红绿灯来理解一下信号。
首先就是你自己能够识别红绿灯,然后你知道对应的灯亮意味着什么你自己要做什么
那么我们为什么能够认识红绿灯呢?
是因为有人提前告诉过你。除此之外你自己在大脑中记录下来了整个红绿灯所关联的信息(红绿灯的信息,以及红绿灯之后的各种操作)。
由此我们就能够得到一个结论:信号在没有产生的时候,我们就已经知道了如何去处理信号。
那么当信号来的时候我们一定要去执行信号所对应的操作吗?当然不是。假设你在绿灯通行的时候,遇到了更加紧急的事情自然也是可以不去执行过马路的行为的。
因此我们就能够得到两个结论:
当我在等待信号产生的时候,产生信号的那一部分在做的事情,你是并不知道的,就是你在做你的事情,他在做他的事情。
由这两个结论我们能够推导出下一个结论:
因为当信号到来的时候,我们不一定要立即执行,所以需要具有一种能力:
那么结论中的我指的是进程。
即进程在设计的时候,就已经内置了如何处理各种信号的能力,进程和产生信号的来源是各干各的互相不干涉的,进程是不知道信号什么时候到来的。进程还具有暂时保存信号的能力,让其能在合适的时候处理信号(并不是一定是立即去处理信号)。
以下就是os在内部支持的信号列表:
然后每一种信号都是存在编号的。
我们之前就使用过一个九号的信号,这个九号信号我们发给进程之后,进程就会立即终止。
那么上面所说的异步又是什么意思呢?
我们之前说过同步的意思就是让执行的时候产生一定的顺序性。产生一定顺序性的本质是两个进程要互相知道彼此,并且要考虑彼此的感受,例如管道在写满之后,就不会让写端继续写了。
拿现实举例子,现在有一个老师和一群学生在上课,现在老师的书没有带,他让一个学生去帮他拿,如果是同步那么老师和学生就会一直等待到拿书的同学回来了,再继续上课,而异步则是虽然老师让一名学生去拿东西了,但是老师这边并没有停止上课,而是继续去上课。老师和去拿东西的学生各干各的。
那么最后什么叫做信号呢?
以上就是信号的概念。
信号的产生
前台和后台进程<jobs fg bg命令>
在理解信号的产生之前,我们来看一个小例子。现在如果你写了一个死循环的代码,当你在你的shell上执行这个代码之后,进程会一直往屏幕上打印东西,导致你无法输入命令(非后台进程),此时你使用ctrl+c就可以直接让进程退出。
而如果你在启动这个进程的时候使用
./<可执行程序的名字> &
那么启动的这个进程就变成了后台进程。
而后台进程是不会影响我输入指令的执行的。但是此时你再输入ctrl+c就会发现无法杀死这个进程。此时我们要杀死这个进程,首先就要查出这个进程的pid(使用另一个shell【同一台机器上】),然后使用kill -9 <进程pid> 就能够杀死哪一个后台进程了。
一般而言后台进程是给一些下载任务的。
如何区分前台和后台进程呢?一般来说前台进程只能有一个。而后台进程可以有多个。
使用jobs指令可以查看当前存在的后台进程。
上图就是我使用jobs指令后出现的后台进程。我们在启动后台进程的时候会打印一个数字
[编号]<进程pid>,这个编号就是我们使用jobs指令后的那一个编号。每一个后台进程都有自己的编号。如果现在我想将一个后台进程变成前台进程使用指令:
fg <后台进程编号>
这样就能将后台对应的进程变成前台进程。此时我们就无法使用命令行了,因为shell也是一个进程而前台进程只能有一个。当我们启动了一个前台进程之后,shell就不再是一个前台进程了,自然就无法为我们做命令行解释了。
所以要区分你是前台还是后台主要看的是你是否能够接收用户的输入。
此时使用ctrl +c就能够终止这个前台进城了。
所以ctrl+c能终止前台进程。
那么为什么shell也是前台进程,但是使用ctrl+c不能终止shell了,那么我们就能够知道了ctrl+c终止进程只是一般的情况。我们也可以让ctrl+c不终止进程。
最后还有一个结论:
当我们将一个前台任务退出之后,shell就会自动得被提到前台(前台进程只能有一个)
那么现在一个前台进程我要如何让其变成后台呢?
当一个前台进程在运行的时候使用ctrl+z这个进程就被暂停了,但是前台进程是无法被暂停的,那么这里的ctrl+z真正的作用是什么呢?
此时我们使用jobs去看这个进程的状态:
这个进程的状态就已经变成了被暂停了。
那么如何让其继续运行呢?
使用指令:
bg <后台进程编号>
总结指令:
这些指令就能让进程变成后台,以上指令对shell无效。
下面我们再来思考一个问题:
通过键盘产生信号
OS怎么知道键盘有数据输入了?
或者说OS是怎么知道外设中的数据已经就绪了呢?(鼠标点击等等)
有两种做法第一种就是OS定期轮询各个外设所对应的驱动程序,但是很显然这种做法是不可能的,设备太多了,时间长了,OS就慢了,时间短了,又太消耗OS资源了。第二种方法就是由我们的硬件来通知OS,如何通知呢?
使用的就是中断技术。
首先cpu上是存在很多的针脚,而硬件的很多电路直接和这些针脚相联系的。
此时我们的外设就能够从物理意义上向我们的cpu发送中断信息。
而我们的每一个针脚都是具有编号的,现在某一个硬件已经就绪了,就会给cou发送对应的高电频,而cpu中的寄存器就会将这个针角对应的编号写到寄存器中,所以cpu就能够知道哪一个外设就绪了。此时的这个数据就可以被程序读取了。此时我们就将我们的硬件行为转化为了软件行为。而写到寄存器中的信息我们称之为中断号。而每一个硬件就有了自己的中断号。硬件就绪了就往自己对应的中断号发送电频。此时cpu就能知道那一个硬件资源就绪了。此时cpu就不用轮询硬件了。
而为了更快的访问硬件在OS中存在一个函数指针数组:
而数组的下标就是对应硬件的中断号。每一种设备都有自己的中断号和自己对应的读取硬件的方法。这样就能将数据从外设拷贝到OS中的特定内存块。这就是由外设驱动的读取信息的方法。
而这张方法表我们称之为中断向量表(在OS启动的时候第一张启动的表)
这也是为什么OS能够知道外设/键盘存在数据了。
而我们的这张中断向量表,是否就和我们的信号列表十分相似。
每一个信号都有自己的编号,并且进程都知道每一个信号后面对应的执行方 法。由此我们知道了:
那么当我们在键盘当中输入ctrl+c时能够将给进程发送信号,而在有些时候键盘又作为了获取信息的硬件,也就是说从键盘获取到的数据一类是用来输入的,一类是用来做正常的控制的。OS接收到键盘的数据之后,如果是正常的数据该回显的自己会回显,该输出数据的就输出数据。而如果OS检测到是一个组合键,就不会回显或输入给进程而是去执行对应的动作(ctrl +c向进程发送信号)。
那么我们如何证明ctrl+c和kill -2是等价的呢?这里我们就要回归到一个知识,那就是我们的进程在一开始就知道对应的信号,和对应信号的处理方法。
那么从系统层面上进程是如何认识我们对应的信号的呢?很显然上面的信号列表中的数字就像我们数组中的下标了。
所以在os中每一个进程都要维护一张处理信号的表。
数组中的内容就是一个函数指针,当一个进程拿到对应的信号之后,就通过数组中的函数指针去执行对应的方法。
以上是一个基本的信息,但是我们还是无法证明ctrl+c和kill -2是等价的。
这里可以使用一个接口:
这个接口的作用就是能够让用户去修改一个信号的默认处理行为,函数的第一个参数就是信号的编号,第二个参数就是一个函数指针。
这里我们就要知道信号在合适的时候处理是存在三种方法的。
默认行为就是os内部自己写的处理信号的行为,而第二种忽略这个信号也是处理信号的一种方法,而最后的自定义就是我们上面说的修改一个信号的处理方法。
由此我们就能够知道了信号的从第一个产生方法:
如何理解呢?
当输入ctrl+c的时候,因为前台进程只有一个,os就会去分析这个信息发现这个信息不是普通的字符,此时os就不需要将这个信息交给进程了,OS会将这个信息直接解释成为命令(向目标进程发送信号的命令)然后os根据发来信号的进程的pcb找到这个进程的处理信号表,然后根据2号信号对应的方法就将进程终止了。
在上面我们知道使用kill -l命令就能看到所有的信号。
我们需要注意的一个知识点就是在所有的信号列表中是没有0号信号的。
如果存在0号信号那么这个信号代表的就是没有收到任何信号,这就很不对劲。
所以我们可以理解成信号在设计的时候就天然的没有设计0号信号。
除了没有0号信号之外,也没有32和33号信号,从1到31号信号设置为普通信号,而34到64号信号则为实时信号。这里我们主要去学习普通信号,实时信号这里不做学习。这两个的原理大部分都是一样的,但是各自有各自的特点。
现在我们知道下面的知识点:
而我们的信号被进程接收之后并不是一定要马上处理,所以一定需要进程需要具有暂时保存信号的能力。
我们知道每一个进程都有自己的函数指针数组,而对于普通信号而言在信号列表中的1到31号刚好可以充当数组的下标。
只有进场表示自己是否收到了某种信号,进程才能更好的去处理对应的信号,进程只能收到一个信号吗?肯定不是在某些情况下, 一个进程可能会同时收到多种信号,所以进程自己要将自己收到的多种信号管理起来(先描述再组织,我们描述的核心点为这个信号是否到来了,如何表达了使用的自然就是位图了)。在进程的pcb内部维护一张位图,然后使用比特位的位置决定信号的编号(0号+1就是第一个信号的位置),比特位的内容决定是否收到位置。
该位置为1表示收到该信号,为0表示没有收到信号。结构如下:
这样进程就能将收到的信号管理起来了。将4号位置由0改为1,就代表信号发送完成了。我们将使用位图管理信号的方式这样的信号叫做普通信号。
进程管理信号还有一种模式: 在进程pcb中设置一个节点,节点中包含的就是信号的编号和信号的处理方法。发信号就是new一个这样的节点,然后将这个节点放到进程的队列中。这种处理信号的方法我们称之为实时信号。
这里了解实时信号即可。
到这里我们需要知道每一个进程都有一张表,这张表记录了每一种信号的处理方法。
然后每一个进程对应的pcb中都有一张位图(包含进程是否收到某个信号)
那么我们如何理解os向进程发送信号呢?
其实
当使用kill向指定进程发信号时,根据进程的pid找到进程pcb然后修改进程对应的位图就完成了修改(由0设置为1)就完成了信号的发送。
最后这里需要注意无论之后我们学习多少种信号产生的方法,最后都是由os向目标进程发送信号。
那么这是为什么呢?为什么只能由os向目标进程发送信号呢?
因为os是进程的管理者。使用ctrl+c能够让进程收到信号就是因为ctrl+c被os识别到了之后,os再向当前的前台进程发送2号信号的(将前台进程位图中的2号由0设置为1,进程再根据位图调用对应信号的处理方法)。
每个进程对于信号都存在两个东西:
1.函数指针数组
2.信号位图
也正如此我们的进程才能够识别每一个信号,在信号还没有传达的时候知道怎么处理这个信号。而保存信号也就是将信号保存到位图中。
到这里我们也就能够知道signal这个函数是怎么发挥作用的了。
当进程接收到对应的信号之后,回去到函数指针数组对应的位置执行默认的方法,然后这个函数会将这个默认的函数方法修改为handler方法。
所以signal的本质就是修改方法数组。
signal函数的作用就是修改第signnum个信号的默认方法为handler。
下面我们来捕获一下19,20和2号信号。
代码:
因为ctrl+z的20号信号的处理方法,将中断这个前台进程变成了现在的处理方法,所以这里并没有让这个进程暂停,而是让这个进程直接退出了。
现在我们就知道了通过键盘能够产生三种信号
ctrl+c(默认终止进程)ctrl+z(默认暂停进程)ctrl+\(默认终止进程)
现在我们知道了每一种信号都有自己默认的处理方法,那么我们怎么知道某一个信号的默认处理方法是什么呢?
还有一个问题,假设现在我们捕获了2号信号,然后修改我们现在默认的方法,不让进程退出,那么我们的进程就不退出了吗?
第二个问题的答案是当然不会退出了。此时使用ctrl+c进程就不会退出了。
而要查看每一个信号的默认处理方法可以使用
man 7 signal(man指令有的服务器可能需要下载)存在一张表可以查看信号的默认处理方法。
从图中可以看到大部分的信号在进程收到后,都会终止进程。
那么我们能否将所有的信号都自定义捕捉了,然后修改处理动作。此时我们能不能完成一个进程用户怎么杀都杀不死呢?
这里我们先让9号信号捕捉尝试一下:
然后我们看到9号信号依旧杀死了目标进程。
这里我们就要知道了9号信号不能被自定义捕捉。除了9号信号之外还有一些其它的信号也不能被捕捉。
信号产生的第二种方式:
通过系统调用产生信号
在系统调用中存在一个函数kill。
这个调用能够一个指定的进程发送一个指定的信号。
通过这个接口我们就能够实现一个简单的kill命令。
运行结果:
这里我简单写了一个死循环的程序。最后你将可执行程序的名字修改一下,将其使用软连接链接到系统默认搜索的路径中就时一个简单的命令了。
其它的信号自然也是可以的。
这也正是第二种发送信号的方式。
那么除了kill接口之外,还有没有其它的函数接口呢?
还有一个函数raise
rasie能够给自己发送对应的信号。
我们来使用一下:
结果:
结果符合预期。
最后我们再来认识一个函数:
about的作用是引起进程正常的终止。这个函数的原理也是给进程自己发送一个叫做SIGABRT(6号信号)的信号。
捕获一下6号信号。
此时进程还能否正常退出呢?
可以看到进程依旧正常退出了。
也就是虽然这里捕获了6号信号,但是abort是一个函数,只要进程收到了了6号信号,依旧会将进程杀死。
总结:
着重记住红框的函数。
异常产生信号
首先我们在这里写一个异常错误。
运行这个代码之后会出现段错误是很正常的事情。
然后下一个错误:
出现的是浮点数异常。
我们先以除0异常为例子。
那么为什么除0会出现错误呢?
首先一个数据不能进行除0是数学上的规定。那么为什么程序上除0会出现异常呢?首先我们要了解一下异常的本质。
下面是一个cpu方框是一个寄存器。
当我们对数据进行除法的时候,我们要对a进行除法首先就要将a从内存中读取到cpu中,然后是除数0也要读取到寄存器中去。
然后除的结果未来就放到ecx寄存器中去。除了这些寄存器之外在cpu中还存在一种状态寄存器(不如status)。这个状态寄存器中存在很多的标志位,其中有的标志位是用来衡量计算结果的正确性的。其中有一个标记位记录当前运行结果是否溢出,我们称之为溢出标记位。
当使用10除以0的时候就无法得出结果了,因为cpu的状态寄存器中的溢出标志位直接被设置为1了。此时的结果也就没有意义了。
如果是正常运算溢出标志位为0,代表我们的结果是可信的。所以在硬件上除0错误是可以被表现出来的。
所以这个进程是如何运行的呢?首先我们的os将这个进程放入到运行队列中,然后将数据放到cpu中后os发现了cpu的硬件出现了问题,溢出标志位被设置为了1。而cpu内部是存在一些针脚,是用来检测cpu内部是否出现错误的。当cpu内存出现了错误之后,这些针脚就会通知os,os就会去运行对应的处理方法。处理的方法就是向特定的进程发送信号,然后我们的目标进程就会因为信号而终止。此时也就将我们的硬件问题变成了信号问题。
这就是为什么我们的代码除0之后进程就会直接终止。
总结就是:我们的代码在cpu中运算时,出现了硬件错误,而os作为软硬件的管理者,自然要将导致硬件错误的进程解决掉(发送信号给特定的进程),所以os不仅能将组合键解读成为信号,也能将异常解读成信号。也由此除0错误无论是在哪一种语言都会出错,因为所有语言最终都会被解读成为进程,而除0错误导致的硬件出错。会让这个进程被终止是必然的。
那么除0错误是被解释成为了几号信号呢?
通过这个我们可以知道是8号信号
再去查看一下8号信号的默认处理方式
这个core就是终止进程。
下面我们来捕获一下这个8号信号。证明一下除0错误最后确实是被转化为了8号信号。
运行结果:
结果和我们的预期一样的。但是这里存在一个问题,那就是明明我的代码中没有写任何的循环,为什么会这样不停的打印呢?一直在产生8号信号(一直在处理8号信号)呢?
我们再去查看一下这个进程当前的信息:
因为我们将8号信号的默认解决方法修改了,所以进程就不会终止了。我们之前说过cpu中的寄存器是属于cpu的,但是寄存器中的内容是不属于我们的cpu的而是属于当前正在运行的进程的。
寄存器中的内容我们称之为进程上下文。所以现在我们的一个进程从出现异常的时候,os将我们的进程直接杀掉就是处理异常的手段之一。
现在我们这里出现的错误就是因为status寄存器中的内容被写成了1,而这个内容是属于我们的进程的。现在将进程杀死,其实也就是间接的将status中的溢出标记位设置为0了。但是现在我们修改了默认的处理方法之后,os每一次调用这个异常进程都会让status寄存器中的溢出标记位重新设置为1。然后os会再次向这个进程写入8号信号,进程再次调用8号信号的处理方法。但是因为这个进程没有被杀死,这个进程就还会被调度,每一次调度就导致了status中的溢出标志位一直没有被设置为0,让cpu一直向os发送当前进程异常的信息,而os则每次都会去打印我们默认的处理方法(打印一句话),也就出现了上面的那种不断打印的信息。(总结就是:异常进程没有被终止,一直在被调度,由此导致一直在打印信息)所以常规的处理方法都是出现异常之后直接让进程终止。
除了这个异常之外还有一个野指针的异常,野指针问题的异常也是这样的。
这里出现的错误原因很简单,当代码要对野指针发生写入的时候,进程回去到自己的页表中寻找当前地址是否和物理内存存在映射。结果是在页表中没有找到这样的地址。也就是在虚拟地址转化的时候出现的错误。一般而言我们的在进行虚拟地址转化的时候是需要使用页表+MMU的,而这个MMU一般是集成在cpu中的。
所以当存在虚拟地址的时候,你将虚拟地址喂给MMU,MMU就会将这个虚拟地址转化为物理地址。
如果转化失败了MMU(内存管理单元)这个硬件也会出现报错。那么os自然也就会知道这个错误了。操作系统就会将引起这个错误的进程直接终止。
那么这是几号信号呢?
11号信号(SIGSEGV)
我们再去捕获一下11号信号
结果:
这里一直打印的原因和上面也是一样的,都是因为当前进程没有被杀死,一直在被调度,每一次调度都会导致cpu出现错误,去给os传送异常的信息,而os依旧是去处理11号信号(打印一句话,不会退出)。
由此我们就能得出两个结论:
第一个:第一个当我们写代码的时候出现了异常,进程一定会退出吗?现在我们就能知道了不一定(虽然大部分情况下出现异常就会让引发异常的进程直接退出)。
第二个:由上面我们也就知道了在vs当中我们如果出现了除0或者是野指针问题vs直接会将你的进程崩溃。这个崩溃和vs是没有直接关系的,崩溃的直接原因是我们的进程被windows操作系统发现了除0/野指针异常,直接将我们的进程终止了。
在c++中存在一个异常机制(try catch)。而这种异常处理机制和我们os的异常处理机制是很像的,throw丢出异常cath再匹配到哪一个exception。
我们需要知道的一个点是我们抛出这个异常的目的不是为了让你去修正异常,而是为了让我们在固定的地点打印出现异常的信息(大部分的异常时无法修正的,小部分可以修正很少)。抛出异常的机制更多的是为了让我们的执行流能够正常的结束的。
这里也是一样的,os明明能够让这些异常信号和9号信号一样不可被捕捉,但是任然允许了我们去捕捉这些异常的信号,不就是为了让我们能够在出现异常的地方打印一些提示信息,再让这个进程终止吗?用户不终止会导致错误。
我们还是要知道:
最后我们来看最后一种信号产生的方式:
软件条件产生信号
首先我们在学习管道的时候知道,如果我们的读端已经被关闭了,写端还在写,os就会像写端发送SIGPIPE的信号来让写端关闭,因为os不会做任何浪费资源的行为。
但是我们在这里思考一下,我们的管道在写数据的时候有没有涉及到任何的硬件呢?(自然是没有,虽然写数据涉及到了文件但是文件所处的那一片内存是我自己的),既然没有涉及到硬件那么os凭什么将我们的写端关闭呢?os在检测管道的时候,os检测到管道的读端已经被关闭了,然后os就向管道的写端发送信号了,而此时产生信号的方式不正是因为软件条件吗?
为什么呢?因为os是软硬件资源的管理者,硬件出现问题了os要处理,软件出现问题了os自然也要处理。也正如此,产生信号的方式可以来自于硬件也可以来自于软件。
下面我们再来理解一下os中的闹钟。
我们信号有31个但是并不是所有的信号都是因为异常而产生的(虽然大部分都是)。
但是我们的信号还有让进程暂停/继续的等等。所以不仅异常问题会引发信号的问题还有其它的方式也会引发信号。这里我们需要知道只要你异常了一定会引发信号,但是你引发了信号却并一定是因为异常。
下面我们就要知道了使用alarm可以在我们的进程中设定闹钟。
那么什么是闹钟呢?相当于在计算机中我们可以设定一个未来时间,当未来时间到来的时候,os就会来通知我们。在手机和Windows电脑上也可以设置(定时通知和关机)。由此我们能够知道闹钟这个东西在任何一个操作系统中都是应该支持的。在os中是能够设定多个闹钟的(os可以让多个人使用,多个人都可以设定闹钟),所以我们的os需要管理闹钟,如何管理先描述再组织。先设定好一个描述闹钟的结构体,再将结构体对象都放到一个stl容器中,此时管理闹钟就变成了对stl容器的增删改查。 所以在os中是存在描述闹钟的结构体的。
那么一个闹钟中应该有什么呢?
一定要有未来闹钟响的时间。那么使用什么能够让我们很好的去比对时间呢?自然是使用时间戳。还要存在一个时钟id,以及这个闹钟的设定人是谁.什么时候设置的,哪个进程设置的,响了之后的动作是什么等等。
那么现在存在了很多的闹钟每一种闹钟响应的时间又是不同的,那么我们要如何去管理我们的闹钟呢?
我们怎么知道在这些闹钟中哪些已经超时了呢?
那么我们可以对整个闹钟进行升序排序只要头部的闹钟没有超时,那么后面的闹钟都没有超时,如果头部超时了,就处理然后更换头部直到遇到不超时的头部闹钟(而有一个数据结构能完美适合那就是最大/最小堆《优先级队列》)。我们按照时间为键值,建立最小堆,要检测当前堆是否存在超时的闹钟只要看头部即可。如果堆顶没有超市代表没有超时的闹钟,如果存在那么就将堆顶的闹钟pop出来再处理,然后再判断堆顶。直到不超时的堆顶出现。下面我们来简单的认识一下alarm系统接口。
参数代表你想设计一个几秒后的闹钟,返回值一般是0,但是某些闹钟可能会提前返回,如果闹钟提前返回了返回值就是你曾经设定闹钟时的剩余时间。
下面我们就来使用一下这个函数接口。
这个接口会在设定时间到来的时候,推送一个SIGALRM(14,默认处理方法是终结这个进程)信号,下面写代码:
这个代码就能让我们看到秒种进程能够打印多少条信息。
4万多的信息。
下面我们捕获一下14号信号。因为闹钟到了之后,就是向进程发送了14号信号。
这里我们让闹钟响了之后打印一句话。再让进程退出。
这里我们修改了一下代码的逻辑:
这里在闹钟响之前在代码中一直++cnt。当闹钟响之后,会打印捕获的信息,然后再打印cnt的信息。
运行结果:
可以看到这一次打印的cnt被加到了5亿多。
第一个问题就是为什么会这样呢?在while循环中打印时,cnt只是4万多,而在没有打印的时候,cnt就被打印到了5亿多呢?
这是因为当我们使用cout的时候,是要往显示器中打印信息的,并且因为我这里使用的是远端服务器,还要先通过网络将信息传递给服务器。并且之前的那个代码涉及到了访问外设。
以你为在while循环中频繁访问外设效率很低。而如果不涉及到外设,只涉及到让cnt++。所以这个速度很快,从这我们也能看到外设速度是非常慢的。
这就是量化外设很慢的一段代码。
所以我们在写代码的时候要争取做到将所有信息计算完毕之后再统一打印。
alarm的返回值:
这里我让闹钟正常运行。最后打印出来的result应该是0,因为alarm正常响起,所以result应该为0。
下面我们将这个闹钟的时间延长一些,然后使用另外一个客户端向这个进程发送14号信号,再来看一下这个result.
运行结果:
需要注意的就是你直接在设定alarm处获取剩余时间是无法获取的。
下面我们修改一下代码:
这里设定一个闹钟,然后在闹钟响了之后,不让进程退出。
可以看到这个闹钟依旧只会响应一次。之后再也不会响应。
那么如果我想让一个闹钟响应一次之后,能够再次响应要怎么做呢?
每一次在一个闹钟响应之后,会再次设定一个闹钟。
运行结果:
这里我们讲解闹钟,就是要说明不仅硬件能够产生信号,软件也是可以产生信号的。
管道那个也是一种软件条件只不过是一种异常的状态。
最后还有一种
产生信号的方式也就是指令,但是指令的本质也是调用了系统调用这里就不再讲解了。
os中的时间问题
首先我们思考一下为什么我们一打开我们的电脑(关机很长时间之后时间也是正确的)就能够知道时间(没有连接网络)。
笔记本能够在关机很久之后时间还正确就是因为在计算器关闭之后,还有电池在给有关时间的硬件供电。这个硬件是一个计数器,在计算机关闭的时候因为存在供电所以这个硬件会一直在进行供电。当计算机重新开机之后,计算机会将硬件中的计数器转化为时间戳,让时间重新变得正确。
下一个点:
所有用户的行为,都是以进程的形式在OS中表现的。
包括你听音乐,看电视。
所以OS只要把进程调度好,就能完成所有用户任务
os给进程分配资源(cpu资源,网卡资源等等)当然OS也不仅仅只是分配资源,os还有其它的作用。
那么os也是程序,os的代码由谁来执行呢?
这里就涉及到了一个硬件叫做CMOS。
这里就涉及到了一个衡量cpu好坏的指标叫做cpu的主频是多少。cpu的主屏一方面衡量的是cpu执行指令的速度有多快。同时也能衡量cpu能够接收多高频的时钟中断的。
下面我们定一个定义:
而CMOS又在高频的向cpu发送时钟中断。并且不要忘了在os中是存在一张中断向量表的(里面是各种的方法)。
这里的6只是一个举例而已,CMOS向cpu的针脚发送时间中断,寄存器就会得到一个数字,然后通过中断向量表访问方法,而这个方法就是os的调度方法。
而所有的硬件都能使用这套方法去规划。
每一个硬件都有自己的针脚,然后对应的中断向量表中都有对应硬件的执行方法。
所以之后我们启动进程之后,硬件会推动os去执行我们的进程代码。
所以:
所以os要做好各种中断的陷阱的初始化工作。
到这里我们就能知道了os代码是由硬件CMOS推动着去执行的
核心转储
对于核心转储我们的切入点在下面这里:
首先我们来看一下全部信号的默认处理方法:
可以看到其中很多的信号都看到了处理方法为core,那么这个行为是什么呢?
可以看到凡是报core的(浮点数错误,非法内存访问),比较像真正的异常错误。
然后以Term作为默认的处理动作的,比如被键盘中断,被信号杀掉,管道读端关闭,写端任然在写的时候发送给写端的信号。
从上面可以看到有的错误是没有犯错只是单纯的被杀掉了,而有的则是出现了错误。
那么报Core和报Term有什么区别呢?
报Core的问题,一方面比较严重,需要用户关心(因为存在下一步,报错的原因尚不清楚,需要用户自己查明)。
而报Term的问题,原因是很清楚的(例如管道的那个错误,原因我们是很清楚的)。
那么报Core的问题,处理方式只有这个吗?当然不是,当我们一个进程因为Core问题导致终止时,os会除法Core dump(核心转储)。什么意思呢?
那么os会在当前崩溃的进程的目录下,形成一个以进程pid为命名的.code文件。这个文件里面保存了当前进程在内存中崩溃时的上下文数据(转储到了磁盘中)。
下面我写一个简单的代码造成一个core的错误,查看一下是否存在这个文件。
可以看到没有出现这个文件为什么呢?
我们先使用:
ulimit -a //查看一下系统基本的配置项
可以看到第一个core file size被设置为了0。
说明我当前的这个系统对于出现异常后不会产生core文件。待会打开就可以看到了。
那么为什么系统要写core文件呢?以及为什么系统默认是关闭这个core文件的产生呢?
这个问题我们后面回答。
首先我们要让我们看到core文件。使用指令:
ulimit -a //查看系统配置
ulimit -c <文件大小>//设置corefile文件大小
此时core文件大小就被修改了,需要注意这里只是内存级别的设置。如果你将客户端关闭了,再次登录服务器这个设置就会被清除。
可以看到这个设置确实是内存级别的。即只是给单个的bash设置的。
我们再去运行会出现异常的进程。
可以看到打印出的信息多了一个core dumped,并且产生了core.xxxx文件,并且每运行一次进程都会产生一次core文件(数字就是每一次进程运行时的进程pid)。
因为core文件是将进程在内存中的信息保存了下来所以这些core文件都是二进制的。
这就是core文件。
下面我们首先来解释为什么云服务器要将这个core文件给关闭。
首先就是这个core文件的大小是不小的。
并且当前我写的这个代码很简单,但是形成的这个core文件也是非常大的。那么你敢想象一下如果我们的代码稍微复杂一点这个文件的大小那有多大吗?
还有一点:
一般公司的服务都是不允许停止运行的,即使一个服务挂掉了,运维/自动化运维软件也会让这个服务重新上线,那么如果这个服务中本来就存在bug呢?你启动一次就挂一次,然后就会产生一个core文件,一个较为完整的服务代码量是很大的。那么产生的core文件也是很大的, 你挂一次就会产生一次core文件,这么多的core文件是会将服务器打满的。这不仅导致了服务挂掉了连带着服务器也跟着一起挂掉了(磁盘被打满了)。
以上就是线上的云服务器会将core dump选项关闭了的(如果是虚拟机一般默认是打开的)。
核心转储的作用是什么呢?
这个core文件能够支持我们程序员去完成后序的调试。
使用gdb能够帮助我们去解析这个core文件告诉我们是哪里出现了错误。
可以看到最后的信息确实告诉了我们是第8行的代码导致了除0错误(还有怎么启动的这个进程等等的信息)。
到这里我们就已经知道了什么是核心转储。
信号的保存
信号的三种状态
要理解信号的保存我们需要先理解一下概念:
第一个概念是信号递达,其实我们上面所说的处理信号也就是信号的递达。
在合适的时候处理信号存在三种方式:信号的忽略,信号的默认,信号的自定义(信号的捕捉)。
之前我们已经介绍了使用signal函数,能够使用signal函数捕获特定的信号。然后将特定的信号的默认处理方法修改为自定义的处理方法(部分信号除外)。那么如果我之前将一个信号捕捉了,现在我要恢复某个信号的默认处理方法要怎么做呢?
依旧是使用signal函数
signal(2,SIG_DEL)即可。
运行结果:
两次中止进程得到的答案都是不一样的。
那么如果我想让进程对这个信号进行忽略呢?
使用的宏是:SIG_IGN
signal(2,SIG_IGN)//这就是对2号进行的处理行为为忽略
可以看到在对2号信号进行忽略之后,再使用ctrl+c进程对于2号信号就没有反应了。
以上就是对于信号的三种处理方法(默认,自定义,忽略)。
因为signal这个函数的第二个参数需要的是一个函数指针,所以上面的SIG_IGN和SIG_DFL也是一个函数指针
那么为什么是0和1呢?如果这个信号没有被自定义处理,os就要去判断这个信号对应的这个函数指针是0还是1(用户自定义的方法绝对不是0或者1)。如果是1那么就会去调用这个信号默认的处理方法,是0就直接忽略这个信号。
将0和1强转为了函数指针类型。
这里我们需要正确理解对于信号的忽略行为,需要明确的是忽略也是对于信号的一种处理方式。
而对信号处理的总称为信号的递达。
下一个概念:
什么意思呢?我们之前说过在一个信号产生的时候,这个进程可能正在做更为重要的东西。这意味着产生的信号无法马上处理,需要在合适的时候处理也因此需要进程具有暂时保存信号的能力,而对于普通信号我们保存的方式是位图。由此我们就知道了当信号处在那一张位图中时,我们就可以认为这个信号处于未决状态。
下一个概念:
这里我们需要知道信号也是可以被阻塞的。那么阻塞是什么意思呢?
也就是信号在未决之后(将信号保存起来),暂时不递达(不处理),直到我们解除对于信号的阻塞。
总结阻塞就是我让这个信号一直处于未决状态,不让这个信号被处理,直到我们解除对于这个信号的阻塞。
那么信号处于未决状态代表这个信号一定被阻塞了,这句话正确吗?答案是不对,因为信号处于未决状态代表这个可能被阻塞了,也可能是这个信号还没有到达处理的时候。
那么如果某个进程已经收到了某个信号,并且这个信号已经被阻塞了,那么这个信号一定是处于未决状态的。
如果现在某个进程没有收到2号信号,那么这个进程能否对这个2号信号的递达动作进行设置呢? 当然可以,我们在代码中使用signal(2,...)不就是在这个进程没有收到2号信号的时候,就对2号信号的递达动作做了设置吗?
那么我们能不能在进程没有收到2号信号之前就对2号信号设置阻塞状态呢?
当然可以。
信号的三种表
这三个概念(信号未决,信号递达,信号阻塞)是需要在os中有体现的。
由此在进程中会维护三张表(而我们之前说的,os回向进程写信号,是写往哪里写入呢?就是往这三张表中写入)。
如果你要向这个进程发送1号信号只要将未决表中对应1号信号的数字由0修改为1即可。
而每一种信号都有自己默认的处理方法,这些方法的指针就储存在handler表中(函数指针数组,数组的下标就是对应的信号编号,内容除了默认是1,忽略是0的函数指针之外,其它的都是正常的函数指针)。
也因为这两张表的存在,我们的进程能够识别这是哪一个信号是否收到信号,以及当前信号的处理方法是什么。
也因此在进程没有收到信号之前就已经能够识别这个信号了。
最后还有一个block表。
未决表(pending表)和block表是结构完全一样的两张位图.
只不过未决表中的0和1代表当前的信号是否收到了。而block表中的0和1代表着当前的信号是否被阻塞了。
我们在看这三种表的时候,不能竖着看,应该横着看,也就是每一个信号都有自己的未决状态,阻塞状态,以及处理方法。
现在有了这三张表,下面我们要对信号进行操作无外乎就是对这三张表进行操作。即os要想办法让用户能够获取/修改block表中的内容。来达到对一个/多个信号进行屏蔽的功能。而要想让某个进程收到信号,也就是要让用户能够修改/获取pending表中对应信号的内容,以达到让某个进程收到/查询某个进程是否收到该信号的功能。最后还要提供给用户能够修改handler表的功能,以达到对信号的自定义捕捉的能力。
因为这三张表都是内核数据结构所以os要提供对应的系统调用。
但是前面的两张表都是位图,那么修改位图的接口也需要用户使用对应的位操作吗?这样的操作当然也是可行的,但是这样就需要用户能够理解这两张位图。由此os不仅提供了三张表的修改方法,还提供了一个数据类型(方面我们控制前两张表)。
下面我们再来理解一下这三张表的特征:
第一点的理解很简单,也就是当os向某个进程发送某个信号时,这个信号的pending表中的内容会由0改为1,如果这个信号已经被处理了,又会由1修改为0(当信号递达时要对pending表中这个信号的内容进行清除)。
第二点:如果某个信号的block表为1,代表着这个信号是无法被递达的。直到解除阻塞。
第三点:如果某个信号被阻塞了,但是这个信号的处理方法是用户自定义的方法,那么在这个信号的阻塞被解除前,这个方法是不会被调用的。
例如上图中的2号信号,虽然当前已经收到了2号信号但是因为这个信号被阻塞了,所以上图就不会去调用2号信号的处理方法。如果2号信号的block表中的内容由1改为0了,那么2号信号马上就会被递达,即使这个递达方式是忽略。
因为收到信号是使用的位图来表示的,并且单个信号只用一个位图来表示的。如果当前某个进程在短时间内(短到,该进程无法处理完一个信号)收到多个相同的信号,多个相同信号在pending位图中只能记录一次信号。即使你发送了10次信号,但是进程也只会接收一次信号,剩下的9次就被丢弃了。
或者说,每一次信号来的时候,因为当前这个信号已经被设置为1了,再设置也还是1。
然后还有是对信号阻塞的时候收到多次这个信号的处理请看下图。
当然以上的方法只适用于1到31号信号(普通信号)。还有一种实时信号能够做到你发送多少次信号就保存多少次。但是这里就不说明了。
回到三张表的时候,现在我们要如何对三张表进行设置呢?
如何修改信号的三种表
每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号 的"有效"或"无效"状态 ,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有 效"和"无效"的含义是该信号是否处于未决状态 。下一节将详细介绍信号集的各种操作。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。
现在已经有了对应的sigset_t的类型。
下面就是要对这种数据类型进行对应的操作。
首先我们要知道这个sigset_t其实就是一个由os提供的位图。
因为位图的操作涉及到位操作,所以os就提供了一批函数接口。
那么下面我们要对信号集进行操作直接调用这些函数即可。
现在数据类型已经就绪了,下面我们就来改上面的三种表(block表,未决表,方法表)。
我们先来修改block表。
使用接口:
set为你传入的sigset_t,oset是一个输出型参数,首先你对block表的操作都是要对block表中的内容进行修改,但是如果你之后想要恢复block表呢?所以进程会将老的block表的内容使用输出型参数的方式给我们返回。
第一个参数是how,how存在三个选项:
第三个参数的意思是拿你已经准备好的位图去覆盖当前进程中的阻塞信号集。
第二个参数:首先假设我们当前的这个进程对所有的信号都进行了屏蔽,每个信号的block状态都为1。假设现在你设置好了一个block表,这张表只有将123号信号进行屏蔽,你将你的这个表传入之后,会将进程中的表变成只有123号被放开,其他的信号任然处于屏蔽状态。解除对一个或者是多个信号的屏蔽。
第一个参数:在原先的block表中新增对新的信号屏蔽,如何新增你设置好了一个set表。block表就会新增set表中被屏蔽的信号。
下面我们来使用一下这个接口。
我们根据上面的知识已经知道了可以对一个或多个信号进行屏蔽,那么这里我们首先就来对2号信号进行一次屏蔽。
那么上面的代码在这里就设置了对2号信号的屏蔽吗?
在这之后你可以这么认为在这里就完成了对2号信号的屏蔽,但是这里我们需要严谨一点。上图中的block和oblock都是我们在我们自己的用户栈上开辟的空间(属于用户空间,不属于os)。那么这里就相当于你创建了一个位图,然后设置了一下2号信号所在的位置。就没有之后了。这里的block和oblock只是两个局部变量。所以在这里是没有完成对2号信号的屏蔽的。
只有调用了sigprocmask函数,将我们创建的block设置给进程内部之后才完成了对2号信号的屏蔽。
测试代码:
运行结果:
可以看到确实是无论我是输入了ctrl+c,还是使用另外一个客户端发送信号,这个进程对于2号信号都没有表现。证明这个进程确实忽略(屏蔽)了2号信号。
那么我们能否将所有的信号都设置为屏蔽呢?如果将所有的信号都屏蔽了,那么这个进程就无法杀死了。如果这个进程是个恶意进程,那么问题就很严重了。那么能否屏蔽所有的信号呢?我们尝试一下:
运行结果:
可以看到最后还是被9号信号给杀死了。这说明了9号信号是不可以被屏蔽和捕捉的。这也说明了不是所有的信号都允许被屏蔽的。而9号信号也被称之为管理员信号。
下面我们来修改未定(pending)表。
修改pending表不仅可以使用系统调用,我们之前学过的产生信号的哪几种方法都是修改了pending表的。所以对于pending表来说最重要的是如何获取pending表(获取当前进程的pending表)
需要使用到的函数接口:
这里的参数类型和block表是一样的,而这里的set是一个输出型参数。set中保存的就是当前进程的pending表。获取成功返回值为0,失败为-1,并且错误码被设置。
这个函数这里我们不单独使用,而是假设一种情景将上面的两个系统调用一起使用。
我们先让一个进程将2号信号进行屏蔽,然后我们再不断的打印这个进程的pending表。此时因为这个进程没有收到2号信号所以打印出的pending是全0的。然后我们向这个进程发送2号信号,因为这个进程屏蔽了2号信号,所以2号信号不会被递达。这个信号处于未决状态。因为我们一直在打印pengding表所以我们就能够看到位图中的2号比特位由0变成1,这样我们就完成了查看到一个进程接收信号的过程.
代码:
运行结果:
可以看到在2号信号没有发送之前,确实一直都是0,当2号信号发送了之后就变成了1。
那么后面如果我们再将2号信号取消屏蔽,那么2号信号就会马上被递达,那么我们就能看到这个1又变成了0,是不是这样呢?
我们继续写代码:
注意这里我捕获了2号信号,修改了2号信号的默认处理行为,让其不会让进程终止
运行结果:
可以看到确实是在解除了对2号信号的屏蔽,2号信号完成递达之后,pending表中2号信号的内容就由1改为0了。这样我们就看到了一个信号从无到有最后再到无的过程。
下面我们再来思考一个问题,我们现在已经确定了,再将信号递达了,pending位图中这个信号的内容就会变成0,那么变成0是在处理这个信号之前就变成了0,还是在处理完这个信号之后才变成0的呢?(这里假设是自定义的方法,那么是在执行了自定义方法之后,位图中的内容才变成0还是执行之前就已经变成0了呢{这里已经确定接下来一定会去执行自定义方法})。
我们测试一下:
这里我们就让我们的自定义方法在处理的时候打印一下pending位图,如果这里是1代表在处理方法之前pending位图就已经被设置为0了,否则就是在处理方法执行之后,才被设置为0。
运行结果:
可以看到处理的时候pending表就已经变化了,说明在执行处理方法之前这个pengding表中对应信号的内容就已经修改了(先后顺序就是:决定递达这个信号-》pending位图中的内容修改为0-》执行处理方法)。
信号的处理
也就是信号的递达。
其实对于信号的递达我们之前就已经学习了一个函数signal能够帮助我们完成对进程信号处理方法表的修改。
那么现在我们直到信号在合适的时候被处理,那么这里合适的时候到底是什么时候,以及彻底的处理流程是什么。我们在上面已经了解了一部分了。
首先我们解答什么是合适的时候?
以上就是一般信号处理的时机。普适时机。
那么这是什么意思呢?
我们之前所说的工作在地址空间的0到3G(代码区,堆区,栈区,共享区,执行自己的代码,命令行参数,环境变量都是在用户空间的)
还有一部分是内核也就是os,那么什么时候会进入内核呢?最典型的就是调用系统调用的时候,调用系统调用的时候我们就叫做陷入内核。
对于系统和用户的概念我们后面会再次说明,这里我们只需要知道在做信号检查的时候是从用户到内核返回的时候才做检查的。如何理解呢?
首先我们知道和进程有关的数据结构都是存在于内核中的。
也就是说我们现在的代码在用户区,在执行我们的代码的时候我们需要进入到os内部。当在os内部需要处理的事情完成之后,返回的时候我们就叫做从内核态返回到用户态。
这里我们只需要知道:
下面从名字上我们能够知道内核态的权利是大于用户态的。用户态在使用的时候是要一直处于受控的阶段的,不能让用户在os中随意访问任意的资源。我们必须要让用户再访问对应资源的时候,在系统的内部进行身份的变化(由用户态转变为内核态以较高的权限去访问资源,例如系统调用)
至于如何变化的。以及用户态和内核态的更多信息我们后面说明。
下面我们来看一张图:
这是进程的一张基本结构图,每个进程都有自己的虚拟地址空间,通过页表映射到物理内存上。我们思考一下:os在哪里呢?
首先在虚拟地中空间中存在0到3G的地址空间,这一段虚拟地址空间的特点就是允许用户随意去访问(通过变量名,指令,下标随意去访问空间)这就是用户自己的空间。但是总的虚拟地址空间是4GB现在用户使用了3G还有1G的内核空间。但是我们想一下加载到内存中的不仅是用户启动的进程啊,最早启动的软件可是os,那么os在哪里呢?无论你的进程如何,os一定是以高优先级先加载到物理内存中的。
然后我们平时调用系统调用的时候,系统调用的代码是写在我们自己的代码中的(但是我们自己并没有实现这些方法,同时我们并没有所谓的系统库这样的概念),现在的问题是,现在在os中包含一些方法集(比如系统调用)现在要让我们的进程去调用系统调用的代码那么进程要如何快速的找到os的代码去执行呢?。
那么现在我们就将建立物理内存和用户空间的页表我们称之为用户级页表
实际上在os当中还存在一种简单的映射关系。这个映射就是将整个os映射到1G的内核空间中(存在动态映射)
所以我们想要访问os中的代码只用跳转到内核空间。然后通过内核级页表就能够跳转到物理内存中了。访问完毕之后跳转返回即可。
这样的好处就是进程只用在自己的虚拟地址空间中就能够完成对os代码的访问了。
结论:
继续在默认情况下用户是无法访问地址空间中内核的1GB空间。
在os中如果有10个进程,10个进程就要维护自己的数据,和页表。
但是在整个os中os的代码只有一份,所以内核级页表在整个系统中只需要一张就足够了。
现在的图上只有一个进程,如果再创建一个进程呢?
此时我们的两个进程就能够看到同一份os的代码了。
所有的进程共性的就是3到4GB都指向了同一张内核级别页表。
由此我们就能得到一个结论:
也就是所有进程的3到4GB的内容都是一样的。由此我们的cpu就一直都能看到os。
由此只要我们的cpu在执行某个进程的代码,然后某个时钟中断到来了,cpu能马上通过当前这个进程马上找到os。这是对于整机和os来讲。
那么如果我的代码中存在一个系统调用就只需要在一个地址空间中进行跳转了。这样我们就将系统代码,共享库,自己的代码都在一张地空间上表现出来了。
总结:
这样我们自己的软件就能在自己的地址空间中进行跳转就能完成整个代码逻辑了。
现在cpu无论在执行哪一个进程的任务都能够随时快速的找到os的代码了。
深入了解用户态和内核态
现在我们已经知道了用户态和内核态的概念,那么现在如何判当前是处于内核态还是用户态呢?os凭什么允许用户去访问3到4GB的地址空间呢?这里所谓的用户态和内核态要如何去标识呢?
如何区分用户态和内核态这取决于cpu本身就有自己的工作机制。所谓的当前进程处于用户态还是内核态却决于cpu中存在的一个寄存器。cpu中存在很多很多寄存器,其中有一个cs寄存器。CS寄存器中存在两个比特位,这两个比特位用于表征当前寄存器所处的工作状态。当然这个工作状态不仅仅出现在CS寄存器中,在其它的很多地方也是存在的。这个cs寄存器本身的功能是为了更快的找到当前进程所对应的代码的。
两个比特位就有四种组合,这里我们只需要知道两种:然后一般使用1表示内核,使用3表示用户。
那么此时到底是处于内核态还是用户态就能够有的放矢了,因为我们只需要检测cs中的两个比特位的权限级别(1内核,3用户)。到这里所谓的修改用户状态(内核到用户,用户到内核)就是修改cs寄存器中最后的这两个比特位。
除了cs寄存器之外,在cpu内还有一套CR寄存器。
其中CR3寄存器用于保存当前正在运行的进程的页表的信息。
这个寄存器中所保存的用户级页表所在的地址是物理地址。因为这里需要快速找到这个进程的页表所在的位置,然后去完成用户代码的虚拟地址和物理地址的映射。
由此我们知道了在cpu中保存内核数据的寄存器一般保存的都是物理内存中的地址。(即便不是这种直接的物理地址,也能通过一种快速的映射让其找到对应的内核数据结构,这种映射关系是最简单的,因为os是最早开机的,所以os就能将自己的内核数据和代码放到物理内存的最低地址处,排列好。就能完成快速的寻找)。除非这个寄存器是被用户使用的,此时寄存器中储存的就是虚拟地址了,但是即便是被用户使用了,cpu也能通过MMU将这个虚拟地址转化为物理地址。即cpu在寻找用户写的代码的时候使用的也是物理地址。
那么CR1寄存器的又有什么作用呢?
在下面的情景中CR1寄存器就有作用了。
某天一个进程在访问自己的地址空间中的某一段区域时,发现这个区域在页表中没有完成映射,没有完成映射就要除法缺页中断,重新开辟物理内存和建立映射。再让用户重新访问,那么用户一旦重新访问了,那么用户曾经访问的那个引发缺页中断的虚拟地址就会被保存在这个CR1寄存器中。
例如在某些时候我们访问数据失败了,os是怎么知道我们哪里访问数据失败了的呢?就是通过这个CR1寄存器。
我们再回到CS寄存器中,现在我们知道只需要修改CS寄存器中最后两个比特位就能够修改状态,但是这个比特位是不是随意就能修改呢?如果随意都能修改,那么岂不是用户能够随意的去访问os中的代码了吗?
答案当然是不能随意改的。
所以os才为我们提供了系统调用,由此我们就知道了系统调用不仅为我们完成了各种各样的功能,还为我们完成了状态的更改。
之前我们说过cpu能够接收外部的各种外部中断。收到了中断之后就有了对应中断号,再中断号去查询中断向量表,再去执行中断向量表中的方法,整个的过程是由硬件去驱动软件来完成的。
cpu除了能够接收外部的中断,也可以接收内部的软中断(陷阱/缺陷)。比如int 80指令就能够陷入操作系统进行系统操作了,这条指令做的事情就是修改用户状态为内核态的。至于用户能否执行int 80指令,os是会对用户身份进行审核的(登录上Linux的普通用户就可以执行系统调用,但是访问os只能走系统调用这一条路)。
所以要区分现在处于用户态还是内核态,只需要看一下cpu中CS寄存器中的最后两个比特位是1还是3即可(仅限Linux)。
之前我们说过在从内核态返回到用户态的时候,会对信号进行处理。这里的前提条件就是用户已经进入过内核态了。
下图:在内核态中执行完任务之后,顺便就去到进程的保存信号的三张表中,查询是否需要处理信号。pending表为0直接返回,只有pending表不为0,并且对应信号的block表为0,才会去处理这个信号,如果处理信号的方式是SIG_DFL终止进程,终止进程在内核当中应该不难(os将进程从运行对列切换到非运行队列,修改pcb中的状态字段,将pcb接入到不同的队列中),那么如果是忽略呢?在内核中直接将这个信号的pending表由1修改为0。
处理信号必须要有pending并且没有block,然后处理有SIG_DFL,和忽略。这些都不难。最难的应该是信号的捕捉(用户自定义捕捉)。
此时当来到内核完成自己的任务之后,顺手就去检查一下是否存在信号需要处理,此时恰好就存在一个信号pending表不为0,block表为0,而且处理的方法是我们用户自己写的方法。现在要对这个信号做处理,就需要跳转到这个代码去执行,在跳转之前就会将pending表由1改为0。那么现在的问题是执行这个方法的状态是用户身份还是内核身份呢?
首先如果你是内核身份,那么这个身份是不会存在限制的,虽然在技术上可以实现以内核的身份去执行用户的代码,并且内核身份绝对是可以去访问用户的代码的。但是绝对不能这么做,因为不要忘了这个方法是用户自定义的方法,如果用户在这个方法中做了越权的事情呢?那么用户之后想要绕过os的检查只需要执行一次信号捕捉,然后在自定义方法中执行自己的越权行为不就可以了吗?由此os不相信任何人要表现在方方面面,所以这里是以用户1的身份去执行用户的代码的。由此在捕捉处理信号的时候有需要将身份从内核态修改为用户态。
那么现在如果自定义信号的处理方法已经处理完成了呢?难道就直接从这里跳转回去吗?
用户态跳转用户态。首先你就不知道你要往哪里跳转呢?你也不知道上一次是因为什么原因跳转到os中。你也不知道返回的时候要跳转到用户态代码的什么位置。这些信息储存在由用户态跳转到内核态时的函数栈帧的cpu的寄存器中。所以这里无法从用户态跳转到用户态(并且系统调用也有返回值,这里直接跳转这个返回值我们也是不知道的)。所以在执行完用户自定义的方法之后还需要一个系统调用,让其从用户态回归到内核态。
sigreturn系统调用。
这里需要注意的是拿open和fork系统调用举例:你在代码中写的是open和fork而os调用的时候调用的如下:
os是如何维护这些系统调用的呢?
在os中是存在一个数组,假设有200个系统调用,那么这个数组的大小就是200(函数指针数组),由此我们的系统调用也就存在了编号,这个编号也就是系统调用号。系统调用使用的是c完成。
那么我们在调用系统调用的时候只用将我们的系统调用号,放到cpu内部特定的寄存器中,再将用户态修改为内核态,然后os只要识别到这个特定的寄存器中的系统调用号,os就能够知道你要调用哪一个系统调用了。然后就可以去数组当中缩影这个对应的系统调用方法,然后调用这个方法最后使用特定的方法返回即可。
我们这里只需要知道os是使用数组的形式将这些系统调用维护起来的。
这也是为什么访问os只能通过系统调用去访问。因为数组下标只有这么多,你下标五年无论是多了还是少了,os都会直接将其阻止。
那么凭什么调用了自定义函数之后,就会去调用sigreturn呢?
函数调用的时候是会形成栈帧的,这个函数之后要返回的地方是会被记录下来的。所以我们的os在调用用户定义的方法的时候,os也可以修改这个函数的返回值,将用户定义的函数的返回值修改为系统调用即可。此时自定义函数的返回值就指向系统调用了。最后sigreturn就能让其回到内核态。
到这里一个基本完整的信号捕捉流程就完成了。这里面的细节很多。
下面总结一下:
用户使用某种方法嵌入os(调用系统调用)【状态改变】-》判断是否存在信号需要处理-》信号的处理行为为用户的自定义函数-》以用户态去执行用户的自定义函数【状态改变】-》调用sigreturn返回内核态【状态改变】-》再由内核态返回用户态【状态改变】,继续执行代码
图像表示:
其中红色的圈就是修改状态(修改CS寄存器中的权限标志位)。对于信号捕捉记住这张图,其中的交点就是信号检测的点。
那么用户启动的所有的进程都需要访问os吗?不一定的,例如我的代码中根本就没有系统调用,那这个进程的信号要怎么处理呢?假设这里我写了一个死循环,死循环中什么也没写。但是此时你使用ctrl+c这个进程依旧能够终止,但是明明在我的代码中,没有陷入内核啊,这是怎么回事呢?
这里我们需要明确我们的进程是需要调度的,一个进程的时间片如果到了那么os就会直接终止这个进程,然后让这个进程重新排队。很正常的行为,因为os在调度的时候就要接近公平的去调度。os把一个进程从cpu上剥离,也就是说我们的进程在运行的同时,os也通过实时中断的方式也在不断进程进行检测,进程时间片到了,os就直接将这个进程剥离下来了,下一次重新运行这个进程的时候,数据的恢复也是os做的。在恢复完之后,os就要运行进程中的代码了,此时os就要从内核态返回到用户态去执行代码。
由此我们的结论:无论你的进程代码中是否涉及到系统调用,在进程运行的时候一定会涉及到多次进程间切换,一旦切换了os就一定会从内核态返回到用户态,因此os依旧有无数次的机会进行信号的捕捉处理。
以上是对信号捕捉处理的理论部分。
下面是操作部分:
下面我们来进行信号的自定义捕捉。
这里我们首先介绍一个新的函数:
对于信号的捕捉处理,本质就是要对handing表进行修改。
之前我们使用过signal,能够通过信号的编号,修改特定信号的处理方法(除部分信号不能修改之外)这里的修改就是将我们的自定义的函数地址写到特定信号的handing表中。到这里我们可以发现,在os中数组是经常被使用的,无论是之间的文件描述符的本质,还是现在所说的信号捕捉的每一种信号的处理方法,还是每一种系统调用在os内部的实现。其实都是通过函数指针数组来完成的。
回到这个sigaction函数,这个系统调用最核心的作用就是修改handler表
第一个参数就是你要修改的信号编号,第二个参数是用来修改特定的信号的默认的处理动作(修改handing表的),第三个参数就是将这个信号原先的默认处理行为使用输出型参数的方法返回。
在这里我们可以看到这里是存在一个结构体的,然后这个结构体的名字和方法的名字是一样的,这个很正常因为在c语言中,是允许结构体的名字和函数的名字是一样的。这个结构体中的属性如下:
这个结构体不仅能在普通信号中使用在实时信号中也可以使用。
结构体的第一个就是你要将哪一个方法设置到这个信号的handing表中(普通信号),第二个sa_sigaction是用来处理实时信号的。然后后面的字段我们只关心sa_mask。这里我们先不管,我们先将这个函数使用起来。
运行结果:
可以看到这个进程的2号信号的默认处理方法确实被修改了。
和signal的功能是一样的。这些在这里都不是重点,这里我们将重点放到这个结构体中的sa_mask上。
我们首先说明这个字段的作用是什么。
下面是一个结论:
蓝色部分的意思就是如果一个进程收到了2号信号,并且去处理2号信号之前会将这个进程中2号信号的block表设置为1,这样当进程在处理这个信号的时候,如果这个信号再次产生就会被阻塞,不会被递达,直到进程将2号信号处理完毕,block表中2号信号的内容才会由1改为0。这也就说明了Linux系统不允许同一种信号在被处理的时候,继续再被嵌套式处理。只允许一个一个处理完再去处理。这是os自己做的,我们待会会去验证一下。那么如果用户额外设置了sa_mask字段,假设用户设置了456,这三个信号到sa_mask字段中
,那么os在处理2号信号的时候也会额外屏蔽456这三个信号。直到2号信号处理完毕,2456这4个信号才会解除屏蔽。这也就是sa_mask选项的作用。下面我们来验证两件事情,第一个处理某个信号的时候,这个信号是会被屏蔽
的,第二个如果设置了sa_mask那么就会额外屏蔽sa_mask中指定的信号。
首先正在处理的信号是一定会被屏蔽的。
代码:
可以看到在我们发送了一个2号信号之后,进程进入到了2号信号的处理代码,开始打印这个进程的pengding表,然后我们再发送了一个2号信号就可以看到这个进程的pending表中2号信号的内容由0改为1了,代表2号信号被阻塞了。
那么如果我们再发送一个三号信号呢?
可以看到这个进程就直接终止了,说明这个进程在进行信号捕捉的时候,只屏蔽了2号信号。
下面我们就将sa_mask字段进行增加。
继续运行:
发现3号信号也就被屏蔽了。
到这里我们就知道了sigaction中结构体的sa_mask的意义了。从这里我们也就能看到了os默认是不允许出现同种信号被嵌套处理的。但是在处理2号的时候,如果不屏蔽3号或者4号,那么这个进程在处理2号信号的时候也是可以响应3号或者4号或者其它信号的。
下一个问题:如果你是os的设计者针对于pengding位图的检测你会怎么去检测呢?
不要忘了pending位图说白了就是一个32比特位的数据,如果你想去检测直接检测这个peding位图是否存在数据直接判断这个数据是否为0就可以了,不为0那么就进入到下一个信号的处理流程。
下面我们去研究一下下面的这种情况:
首先我们让一个进程同时收到12345号信号,每个信号都捕捉了,那么这5哥信号也就被屏蔽了,然后在我们的代码中后面的一瞬间我们直接解除这5个信号的屏蔽。这5个信号在处理的时候是怎么样的呢?
即我们要去研究一下多种信号的情况。
代码:
我们在信号屏蔽的20秒内向这个进程发送2345这几个信号,然后在解除屏蔽的时候我们查看一下这4个信号是一起被递达,还是只有一个信号被递达。
如果只有一个信号被递达,那么这个信号被递达之后,直接打印的就是cnt,如果不是的话那么在这几个信号都被处理了之后,才会去打印cnt。
运行结果:
可以看到是所有的信号都被处理之后才重新运行进程自己的代码。
也就是在处理完一个信号之后由用户态转为内核态还会继续去检测信号,如果还有信号还会继续去处理。
然后这个处理顺序并不是按照2345来进行的。因为信号本身在内核也是存在优先级的问题的。比如现在存在两个信号一个是要让进程暂停,一个是要让进程终止,那么os是要将这个进程暂停还是终止呢?对于这个就需要确定出优先级,来保证哪一个信号先处理。这里不关心这个。我们这里只需要关心,所有的信号是会被处理了的。由此我们可以理解为,在信号处理返回的时候单个信号是不会做信号检测的,而多个信号是会再做信号检测的(或者信号处理返回的时候还会做信号检测)。
在这里总结一下:到目前为止,对于信号我们学习到了什么
主要学习到了上面四个方面的知识。
我们这里在解释一下,为什么os在处理2号信号的时候,会将2号信号自动屏蔽了呢?主要就是为了防止信号的捕捉方法被一直嵌套。但是这里我的3号没有屏蔽啊,难道这里就不担心3号重复再来吗?首先在os中这种情况是防不住的,并且一般短时间内发送的大量信号都是同类型的信号,而os只要做到处理单个信号的时候不要出现一个信号被重复捕捉导致主执行流无法返回的情况,而如果处理2号的时候不断来3号的话,处理3号的时候3号就会屏蔽自己。同理4号5号也是(除了9号,9号不可被捕捉和屏蔽)。如果处理4号处理完了4号又来,那么4号就会被一直递达,但是os这里是不允许在处理一个4号信号的时候,又去处理另一个4号信号。必须走完信号处理周期才允许下一个4号被处理。
信号的补充问题
补充问题1:可重入函数
假设现在这里存在一张单链表(不带头),然后现在我想往这个单链表中头插一个node1节点。
如何做呢?
现在我们就依据这个来说明一下某些信号的情况 。
然后我们将这个写成了一个insert方法。
现在如果我们的main函数在执行这个insert方法完成node1->next = head后,这个进程收到了一个信号并且这个信号被立即处理了。那么这个进程就去执行这个信号的处理方法了,但是很不幸,这个信号的处理代码也要头插一个node2节点。然后node2就被成功的插入到链表当中了,此时的head就指向node2了。到这里信号捕捉的方法也就完成了,那么就要继续回到曾经被中断的位置,继续运行此时head = node1.此时本来head指向的是node2,就变成了直接指向node1了。但是经过这样的巧合我们就会发现node2几点就被丢失了。上方图然后就造成了内存泄漏。此时这个insert函数被两个执行流一起进入了(一个是main执行流,一个是信号处理执行流)。此时一个函数被两个毫不相关的执行流一起进入了,我们称该函数的这种状态为该函数被重入了,简称该函数被重入,全程该函数被重复进入了。因为重入问题导致了node2节点丢失造成了内存泄漏。因为重复进入insert函数会造成问题,所以insert函数也就被称之为不可重入函数,如果insert函数被重入之后没有造成问题,那么我们就称insert函数为可重入函数(允许多个执行流进入)。
以上就是概念。但是现在这里不是只有一个进程吗?那么这里的多执行流指的是什么呢?
首先如果我在自己的代码中设定了信号的捕捉方法,可是在最后我的进程从开始到结束根本没有收到这个信号,那么这个信号的处理方法就不会允许,那么这里就只会运行main函数即主执行流。所以对于信号来说,如果收到了对应的信号就会去执行信号捕捉方法,没收到只执行main函数。所以有没有信号就决定了我们的代码中有一部分会不会执行,而且另一部分执行的代码和main执行流是毫无关系的。这两在执行过程中是并列的。所以我们就将这个两个称之为两个执行流。当然上面的情况在信号这里是很牵强的。因为这两个执行流在根本上还是在一个进程中的。在多线程中我们会允许多个执行流同时存在,到时候就好理解了。这里就相当于一个函数内部串行式的让两个不同的执行流进入同一个函数了,这种现象就叫该函数被重入了。
也就是说函数是可以被分成可重入和不可重入的。那么因为不可重入函数,会因为多个执行流的进入出现问题,那么可重入函数就比不可重入函数好吗?不可重入函数不好吗?
当然不是对于可重入函数和不可重入函数,我们不要带上哪个好哪个不好的有色眼镜。因为这个可重入和不可重入描述的是函数的特点。可重入你就在多执行流的情境下使用就可以了,不可重入你就不在多执行流的情况下使用就可以了(非要用加上保护)。并且到现在为止,我写的很多代码使用的很多库函数都是不可重入的。所以不要因为可不可重入就评判一个函数的好坏。更多的概念我们在多进程时会说明。我们之前遇到的父进程和子进程一起打印就是一个多执行流一起进入printf的情况,因为在进程中printf函数的实现一份就可以了。除此之外你的函数如果使用了全部变量基本都是不可重入函数。以及你使用的很多库函数和stl容器都是不可重入的,因为库函数在实现的时候,大部分都是使用了全局变量的。所以我们不要带上哪个好哪个不好的有色眼镜。因为这个可重入和不可重入描述的是函数的特点
补充问题2:volatile关键字
这个关键字来自于c语言。在c语言的作用是保持内存的可见性。在信号部分这个volatile的作用我们使用代码验证一下:
这个代码在我们没有发送2号信号之前,会一直不退出,在我们发出了2号信号之后flag改为了1之后,循环也就退出了,进程正常退出。
运行截图:
没有出现问题。这个暂时不是重点,我们知道我的编译器是有着很多种的优化选项的如下:
-O0 -O1等等。
现在我们就来使用一下这些选项:
首先是-O0
进程正常退出,没有问题。
我们再试试-O1(提高优化水平)
可以看到这里没有退出,这说明你让编译器增加一个优化级别如-O0和没有优化其实是差不多的,但是你使用更高的优化级别 -O1等等,此时我们的代码就不退了。这是正常的。
这里的代码逻辑没有出现问题,那么为什么这个代码卡在哪里了呢?
我们查询一下:
可以看到这个进程一直在running说明这个代码卡在了while循环那里。
那么这是为什么呢?
首先我们要知道while循环本身是一个计算,虽然也会做代码块的跳转,但是while循环也是计算在上面的代码种,while循环会不断的计算!flag的结果为真还是为假。因为计算分成两种一种是算术运算(普通的加减乘除),一种是逻辑运算(逻辑与或等等)。
那么flag是一个变量那么就一定是在内存中保存的,既然while循环是一个计算,那么只能交给cpu来执行,而cpu中就有对应的寄存器。
在没有优化的时候cpu会从内存中去取flag的值去判断,当flag的值改变之后,循环就结束了。优化的意思就是:cpu只有第一次才会将flag加载到寄存器当中,从此往后,cpu只需要在代码内部只检测这个值就可以了。因为os发现在main函数中没有人去改flag这个值,这个flag是一个只读的变量。由此就将flag优化到寄存器中,之后的信号将内存中的flag改为了1,和cpu
中的寄存器有何关系呢?由此这里就会出现无法退出。这里相当于形成了一个内存屏障。cpu只会去读取寄存器中的值,而寄存器只会读取一次内存中的值,那么为了打破这种屏障,每次访问必须到内存中读取值(防止编译器过渡的优化)而导致出现问题,我们就使用volatile这个变量保证flag这个变量在内存中的可见性。
这个保持的是cpu对内存中flag的可见性
运行结果:
选学部分:SIGCHLD信号
首先思考一下下面的问题:
这个信号的默认处理动作就是什么都不做有点像忽略,但是有一定的区别(在编码上)。
那么我们要如何测试子进程是否真的发送了这个信号呢?
这个代码会让子进程处于秒的僵尸状态,但是子进程一旦退出父进程就会收到这个信号,最后10秒结束子进程僵尸状态消失。
运行结果:
可以看到i这里确实得到了一个17号信号,但是为什么在得到了17号信号之后父进程就直接退出了呢?
因为当父进程在休眠的时候得到了一个信号,然后父进程就去到该信号的处理方法中去了,待信号处理完成之后,就不会再去执行休眠方法,所以父进程就直接退出了。 所以要达成处理完信号之后,先休眠一会再去接收子进程要修改代码:
这样就可以了,增加这几行代码。
运行结果就不显示了,因为我们确实验证了子进程在退出的时候向父进程发送了SIGCHLD信号。
因为子进程退出的时候会向父进程发送信息,如果现在父进程需要一直运行,子进程退出了,会向父进程发送信号,我们可以在信号捕捉方法那里对子进程进行回收。
修改代码:
这就是基于信号的让父进程能够不阻塞式回收子进程。
可以看到父进程确实基于信号回收了子进程。
但是子进程只有一个吗?
如果现在有10个子进程。并且恰好这10个子进程一起退出了。那么每一个子进程都会向父进程发送这个信号,此时父进程收到了10个信号,但是保存信号,17号信号zpending位图中最多只能记录两次(为什么是两次,一个正在处理,另一个被阻塞),剩下的都没了。那么此时就会造成僵尸进程的问题。
所以我们要循环式的回收所有的子进程。
代码如下:
运行截图:
可以看到这里只接受到了两个信号,但是我们还是将所有的子进程回收了。
但是如果出现下面的情况呢?
有10个进程有6个退出了,有4个没有退出。
哪怕只有一个退出了,都会将这6个子进程回收了,那么第七个子进程会继续·回收吗?因为当前并不知道是否还存在子进程,这里是通过waitpid的返回值来判断是否还存在子进程的。
答案是第七个进程也会继续被回收。但是这4个子进程不会退出的,这就导致了父进程一直被阻塞在这里了,这也就导致了父进程不会执行自己的代码了。
所以这里一定要设置为非阻塞等待。
因为等待失败返回值为0,所以这里需要id<=0。这是使用非阻塞循环,基于信号的对子进程的回收机制。但是对于子进程回收我们不需要做了。
所以我们只需要对这个信号做忽略,对于子进程的回收就不需要做了。
所以等待是不一定需要的,但是等待并不一定是为了解决僵尸问题的,如果你要获取子进程的信息,那么你就必须要等待。
以上就是对于SIGCHLD信号的学习。
希望这篇博客能对阅读的您有所帮助,非常感谢,如果发现了任何错误,欢迎指出,写的不好请见谅。