信号所有相关内容

既然谈信号,那么我们曾经有没有用到过信号呢?有的。我们曾经也用过信号查看的命令 。
我们现在写一个死循环程序myproc,并让其运行。然后用#ps axj | grep myproc可以查看到我们运行的程序。
然后用命令#kill -l可以查看到我们系统所有的信号。对我们来讲kill -l所有的信号呢,我们可以用命令查到对应的进程,然后我们不想要它了,我们就可以把它直接杀掉,也能让其暂停等。
我们可以#kill -9 ➕进程pid,此时改成进程就被干掉了。我们kill -l所罗列的一大堆内容就是我们称之为叫做信号。
我们刚刚写了一个小小的程序,让其运行起来之后,然后我们可以直接用对应的kill命令向指定的进程发送信号。当我们信号发送完毕呢,就可以读我们进程有一系列的操作了。
我们下面就要尝试着去理解信号相关的内容。

理解信号

什么是信号/产生后怎么做?

❓什么是信号?
我们在生活当中有没有见过信号相关的场景呢?红绿灯、下课铃声、信号枪、烽火台、旗语等。我有一写问题想问:
你们是如何得知这些东西的?比如说红绿灯你是怎么得知的?在这个世界上呢还有很多很多的信号,比如说航天飞机发射的时候,里面有很多好的信号和坏的信号,作为普通老百姓我们是无法理解其内部信号的。但是红绿灯这些常见的信号是知道。所以我们是如何得知这些东西呢?—有人教!(自己是能够认识这些场景下的信号,以及所表示的含义的。)
✳️我们一定要心里很清楚的就是,上面的那些红绿灯等信号,我们早就知道,所以对我们来讲,不用我们现场怎么样,而是我们早就认识他了。我早就认识他后,对我来讲,以前我还没有遇到这些信号之前,就有人教我了,后来遇到之后不断加固。反正就是我们对它能够识别到。
总之我为什么能得知这些信号,是因为你能够识别这些信号。

❓你们知道对应的信号产生时,要做什么吗?
就比如说红绿灯,红灯亮了停下来,绿灯亮了可以行走。或者闹钟,闹钟响了你知道要干什么,闹钟没响你也知道要干什么。这些说明什么呢?说明当我们有对应的信号产生时,我们要做什么呢?
我们早就知道了信号产生之后要做什么,即便当前信号还没有产生。(也就是说,当这个信号还没有发生,在我们能够识别这些信号前提条件下,即便是没有产生,我们也照样能过识别这个信号)
这叫做我们已经提前知道这个信号的处理方法!

✳️那么对我们来讲呢,第一你首先要能够识别到这些信号产生了,第二你已经提前知道了对应信号产生的处理方法了。
把这两点同时具备,你才有了能力叫做处理信号的能力。这都是我们生活中的场景。就相当于我们首先要能够识别到它,比如说红灯绿灯等乱七八糟的灯代表的含义别人一教,告诉我闹钟怎么用,然后一设置知道几点会响等乱七八糟的。然后别人告诉你红灯来了该怎么做,绿灯来了你该怎么做等等这些说明什么呢?
一、你能够早就识别到这些信号了;二、你知道对应的信号产生时你知道该怎么去处理。当你能做到这两点的时候,你此时就具备了对于特定信号的处理能力。

✳️上面所有的说的前提条件,叫做:即便这个信号还没有产生,我们已经具备了处理信号的能力。
比如说我们现在在上课,我们根本没有红绿灯, 压根没见到,但我们照样能够知道当红绿灯亮了该怎么做等等。这叫做信号在我们人的心里当中早早的在我们的心面种下了识别和处理这个信号的能力!
能力之所以被称作能力,就是因为已经预先具备了。
在这里插入图片描述
✳️所以信号是给进程发送的,进程要具备处理信号的能力。
那么它要具备什么能力呢?它要什么什么时候具备这个能力?
所以比如我们说的kill -9信号,信号既然是给进程发的,那么进程就要具备处理信号的能力。
1.那么该能力就预先已经早就有了的(你必须得早早的就具备了对这个信号有了一定的识别能力或处理能力)
2.进程能够识别对应的信号
3.进程能够处理对应的信号。
换而言之和我们人一样,作为进程来讲呢,他也是需要对我们来讲的处理信号的能力呀各方面。我们人能够提前识别信号,人能够知道这个信号之后对其做出相应的处理。进程也要能够识别对应的信号,也要能够处理相应的信号。而其中这种能力早就应该要有所具备的,这个早就具备了,是谁做的呢?
你人身上特定的信号响应,是谁教给你的?比如说闹钟响了你就知道要做什么,比如红绿灯亮了你就知道要做什么等等。你怎么这么聪明呢?你怎么就知道上面所有场景下未来要发生什么事情,以及你自己可能给自己想一些预定方案。那么对我们来讲,我们这种对特定的信号的处理能力都是社会教的。同样的当一个进程,在运行期间除了,按部就班的去执行自己的代码,随时随地该进程可能会收到来自我们任何地方给他所发送的信号,那么它必须能够提前识别到这些信号,然后进程也能够快速的处理这些信号。那进程我们说它已经具备了处理该信号的能力,那么请问进程识别和处理信号的能力,是谁给的呢?你的能力是社会给的。
当然是程序猿!因为是程序猿写的代码,而程序猿在写的时候,因为我们进程对信号的识别各方面东西,调度等,处理信号的所有动作他都属于操作系统内的代码。操作系统的代码也是程序猿写的,所以对我们来讲,这些能力预先具备,本质是程序员预定了一些代码,并且操作系统帮我们提供了。
说白了就是程序猿写的代码,它写的不就是操作系统,进程相关的信号处理,不就是操作系统内部的一些进程管理的代码吗?所以进程管理的相关代码,操作系统本身已经帮我们提供了。你也能理解我们的进程呢是生活在操作系统这么一个大社会的环境下的。一个进程在被各种调度和拿捏的时候,完完全全是很被动的,操作系统让它往哪里走,它就得往哪里走。操作系统说你去CPU运行吧,把你的PCB设置为R状态,然后你去运行队列排队哈。然后你说好,就去排队了。然后一个操作系统说,你要访问IO了,你不能再占有CPU资源了,你不能让CPU陪你等,你赶紧切走,切走后去磁盘上面等吧,把CPU腾出来让别人放到运行队列上运行。就如同在社会上,去银行办事情,比如你要办什么业务,你说你要取钱 ,银行工作人员说那你去排队吧,然后你就去排队了等。操作系统就是进程的环境,而我们少年呢在社会当中在历练之下产生对信号的处理。
我们进程信号是程序员早就在操作系统内部已经预制了很多很多对于信号的识别和处理能力。
所以对我们来讲呢,大家应该能理解,我们要说的一句话就是:
✳️对于进程来讲,在信号没有产生之前,进程早就知道了该信号该如何处理!
(对于进程来讲,即便是信号没有产生(同学们我们现在有没有给未来的某个进程发9号信号?没有,但我照样告诉你,未来有个进程启动了,你可以给它发9号信号直接杀掉他,这就是什么?这就是我知道我很清楚操作系统内置了对9号信号的处理,如果你要用的话,后面任何进程都会遵守这个规则。就好比将来没钱的时候一定会找你的父母要钱,因为这事社会规则,这是我们已经定制好的很多的游戏规则),我们进程已经具有识别和处理这个信号的能力了!)
所以对我们来讲,心里要清楚的一点是,信号是操作系统内已经内置的机制,在进程启动时就有了。关于它怎么处理以及怎么做到的,我们后面慢慢展开。
在这里插入图片描述

前台/后台进程

✳️我们之前说ctrl ➕ C可以杀掉一些进程,本质上ctrl ➕ C是向前台进程发送信号。如果我们启动我们写myproc程序,启动之后我们在屏幕上输入任何指令是没有任何反应的。
因为当前启动的myproc进程占有了你bash所对应的终端。所以当前bash无法对你做命令行响应。此时这种进程就是前台进程。
我们ctrl ➕ C就可以将其终止了。
但是如果我们输入#./myproc ➕ &,我们加了一个“&”符号,其意思是将该进程放到后台去运行。
放入后台运行后,他会在屏幕上给一串数字[n] ➕ xxxx。xxxx是典型的进程编号,n是什么?我们后面说。
在将myproc变为后台进程后,我们在命令后输入其他命令的时候,是可以有响应的。
但是我们再输入#ctrl ➕ C发现终止不了myproc这个进程,所以我们将此进程就称之为后台进程。所以要用另一个命令来杀掉后台进程。
我们可以输入#jobs命令,就会出现[n]+ …/myproc&,其中这叫做我们后台有一个后台进程任务,它的编号就是n号。
所以我们可以此时将此进程提到前台来。#f表示front,g表示ground,然后输入空格 ➕ n,那么此时就叫做把n号任务放到前台来 。所以完整命令就是#fg n就将其放到前台来。后面再#ctrl ➕ C便能杀掉了。
刚刚我们进程myproc是后台进程它向前台打印hello world的时候,我们也在输入我们的命令。所以在前后台进行混打的时候,其实你自己在输入的时候,虽然会将我们输入的命令打乱了,因为多进程同时往一个显示器文件里面写,出现我们所谓的混乱情况是很正常的,因为它缺乏访问控制。
虽然信息交叉在一起,但是我想告诉大家的第一个道理就是:如果你当前的一个后台进程不断向我们前台打印,其实并不影响我们输入,因为输入和输出用的是不同文件。比如说你键盘输入了abc,这三条信息虽然是在显示器看到的,其实大家想想,根据我们的冯诺伊曼体系,其实abc一定是先被我们的内存里面的进程先拿到的,比如说是bash,它先拿到了,那为什么你显示器能看到呢?本质上就是因为,人家把数据给你显示器文件拷贝类一份,这叫做回显。其实不回显也可以的,比如说我们Linux上输入密码,你输入进去了吗?输入进去了,但是没有在屏幕上看到任何东西。所以人家可以不给你回显的。所以输出和输入看起来打印是交叉的,但是在底层两个完全不会干扰。
但我现在看起来就是不舒服,就将打印的代码注释掉了。
下面我们重新操作一下:./myproc启动我们的进程,其为前台进程,#ctrl ➕ C就将其终止了。然后我们再来一个./myproc &把它弄成后台进程,我们可以多输入#./myproc &,也就是多起几个后台进程。那么我们怎么查看这些后台进程呢?输入#jobs。就能看到我们后台所起的任务。然后我现在想把哪个任务提到前台就提到前台。拿是根据什么来拿?jobs之后不是有进程编号吗?我们并不是用进程编号,而是用[n]里面的n,任务号。比如说我想把4号后台任务提到前台怎么做呢?输入#fg 4 ,此时就把4号后台任务放到了前台。然后#ctrl ➕ C就终止掉原先的4号任务了。
那么如果我现在把3号后台任务也提到前台,提到前台后呢,我后悔了,我又想将它提到后台,该怎么做呢?我们先进行#ctrl ➕ Z ,然后再jobs此时发现3号此时出Stop状态。因为我#ctrl ➕ Z了所以此任务处于暂停状态。暂停状态后呢,我们命令行就可以照样输入被响应。我们把3号任务提到前台然后#ctrl ➕ Z,之后再输入#bg 3,此时就又变成了后台任务,然后再jobs,看到他在后台运行起来了。这就叫做我们的任务管理。
我们输入#sleep 1000 | sleep 2000 | sleep 3000 | cat &,此时我们用这行,命令模拟了一个批量化任务,比如说你将来要做一件事情,这件事情呢前面要分析几十G的数据,然后把数据做统计等乱七八糟的,反正你要做一大堆的任务。这些任务呢不用做输出,并且非常耗时,你呢总不能到前台去等它吧,此时你就可以把它放到后台。放到后台之后呢,你jobs就会看到这一堆的进程合起来被作为任务放在了后台去运行。你想终止掉它们怎么办呢?#fg n(其中n是编号),然后就将它们提到了前台,再#ctrl ➕ C就将它们终止了。这叫做前后台任务。

对我们来讲呢,我们刚刚想要说的最重要的事情是什么呢?倒不是说前后台进程,以我们现在的认识,前后台进程想操作它们其实很简单,然后我们就把它们使用一下fg、bg等。
我们想说的最重要的是,以前哎呀,一个进程失控了,我们就说无脑ctrl ➕ C,但是他只能用来处理前台进程,后台进程呢无法无脑ctrl ➕ C,你只能通过kill 命令 或者有任务编号,将其提到任务前台。

分时/实时 信号/操作系统

在我们Linux当中用户ctrl ➕ C的时候,它是怎么终止掉这个进程的呢?其实呢,当我们在键盘上输入ctrl ➕ C的时候,它是产生了一些硬件中断然后操作系统识别的。然后操作系统将其解释成了信号发送给前台进程,前台进程呢,操作系统就是你妈妈,她给了你进程的不好眼神,这个进程立马就知道完了我接下来要干什么了。我识别到了一个信号,以及我知道要怎么处理了。所以操作系统,会进一步的,因为信号的产生该进程会自己退出。但是里面还有很多细节没讲,我们知道就可以。
所以我想告诉大家的就是,对我们来讲,刚刚只是告诉大家两个事实。第一个事实是:当我们在键盘上#ctrl ➕ C的时候,其实硬件行为被解释成了信号,发送给了我们对应的进程,当然该操作怎么证明呢,后面会给出。

下面再给大家说一下,请记住:在一个进程运行的生命周期当中,这个进程收到信号的时候呢,是在任何时候都能收到信号!
记住了,当一个进程收到信号的时候,它是在任何时刻都能收到,随时随地可能收到信号。那么我们的进程的运行和信号的产生对于进程来讲它是不可预测的。也就是说,这种情况呢,信号相对于进程本身按部就班的代码执行呢,信号的产生,他其实是一种异步的产生。
再说一遍哈,当我们进程运行的时候,那么此时这个进程在运行的时候呢,那么我们的信号在产生的时候,是在任何时刻都可能产生的。可能由用户产生,也有可能由操作系统产生。这个产生呢对于进程来讲是异步的。这个就有点像我们点完外卖,就把手机放在一旁,我们也不看什么时候外卖到。那么此时我们正在打游戏或者写代码,那么此时你可能会知道将来有一个外卖员会到,但他具体什么时候会到来,你压根就不知道,那么在你打游戏期间,这个外卖员可能辛苦的在送外卖,你在做你的事情,他在做他的事情,当对应的外卖员到你的门口,敲你门的时候,在敲门那个时刻你才知道,信号或者是外卖员到了。但是在没有敲门之前,你根本就无法得知现在外卖员的状态,他现在到哪儿了?什么时候敲我门?你无法得知。所以呢我们信号的产生,对于进程自己的代码来讲,这个行为就是异步的!
上面呢产生了一堆一堆的概念,对于这部分内容我们稍后再把重点概念给大家重新理解一下。
我们kill -l可以查看所有的信号,我们目前看到的最多的就是9号、18、19号。现在给大家正式说一下,我们Linux当中呢,我们一共有62个信号,没有0号、31号、32号信号。信号天然的被分成了两批,第一批是1~31号;第二批是34~64。
1~31这31个信号,我们称之为普通信号,普通信号呢就是我们要学的,也是最常规的信号。那么34~64信号呢,叫做实时信号,实时信号呢它们的名字当中都会出RT在里面。
那么实时信号和普通信号有什么差别呢?对我们来讲可能不太知道有些概念。比如说早些时候,我们操作系统设计发展是由自己的发展过程的,像我们很早期的时候呢,有些地方他可能需要有些系统具有某种特性。不知道大家有没有听过一种系统叫做实时操作系统,而我们现在主流所用的并不是这些,我们用的呢都叫做分时操作系统,换句话说呢,就是基于时间片轮转,基于我们优先级抢占的操作系统调度算法。
在系统里面呢,也有一类叫做实时操作系统。什么叫做实时呢? 实时系统的意思就是只要任务到来了,如果可能,你这个操作系统必须得给我立即处理,而且处理的时候,响应得必须够快。而且处理的话必须得给我处理完,才能执行下一个。这种呢其实就是一种实时操作系统。它对于新任务的响应速度是要非常非常高的。
而我们这种分时呢,更加强调的是公平调度,现在主流的操作系统,无论是客户端还是服务器,全都是这种分时操作系统。
在有些场景里面呢,他们用的操作系统是需要有实时特性的。比如说车载系统,有些车载系统选择操作系统的时候,它选择的操作系统不能是分时。一个用户他自己呢,向播放音乐和开空调然后想踩刹车,在车载系统里面呢,明显要的就不是公平!它对于特定的事情响应是快速而且实时!一旦这种场景当中呢,操作系统的特性可能不太一样。
所以对我们来讲,我们未来要谈论的是普通信号。刚刚说的对于系统由分时和实时,对于信号来说也是一样的。
我们说的普通信号呢,等会儿会讲,像这种信号是有一个特点,就是不用你立马去处理它,而且呢它出现大量重复信号时,它可能只会处理一个。但我们下面的实时信号呢,你必须得给我立即处理,并且呢还不能给我信号丢失,这就是我们对应的实时信号。Linux当中内核是支持实时信号的,你也能理解,操作系统本身设计就是满足各种场景。服务器的场景满足,车载系统或者各种嵌入式设备他也要满足。对我们而言,它里面肯定也要保证我们操作系统的各种各样的特性。我们学习到的是1~31普通信号。

在这里插入图片描述
对我们来讲呢,1~31就是普通信号,这种普通信号呢对我们同学来讲学习成本并不是很高。但是第二个问题,我想和大家谈的就是:
同学们,你们有没有发现信号的左侧的数字和右侧的名称。
我想告诉大家,左侧的信号比如1、2、3、4…等这样的数字,这叫做信号的编号。右侧的大写东西,这叫做信号的,可以称之为名称。而信号的名称呢和我们左侧的数字其实是一回事。今天学到这里就可以懂了,它这个东西肯定也就是个宏,每一个宏的大写名字就是我们表=表里面的大写东西,宏的值呢就是左侧的编号。我们后面研究的是1~31,这31个普通信号。

外卖小哥例子聊信号

我们接下来在正式谈信号产生之前呢,我想和大家再聊一个问题。
我点了一个外卖,这个外卖小哥什么时候来,我也不知道,在点外卖之后呢,我觉得他要有很长时间才能到,所以我开了一把游戏。那么我开了一把游戏呢,此时我正在打游戏呢,突然外卖小哥就敲门了,敲门之后怎么办呢?然后我呢,正在打游戏,此时我不想去开门,我想等一等。等上一两分钟,等游戏打完了我就去开门。然后外卖小哥在门口也敲门了,敲了半天们,你也不给人家响应,也不处理。此时你呢,你最多说等一下,等一下,然后你继续打游戏。可是外卖小哥已经到来了呀,此时你怎么不去立马开门呢?这是第一个场景。
第二个场景:你今天在宿舍里面,你在宿舍里面做一件很重要的事情,比如说你在写作业或者打游戏,然后你的朋友给你打电话,然后说张三下来,我们去吃饭吧。你说,等一等,我一会儿就下来,还得等我一下。然后你朋友说好吧。此时你呢就继续忙你的事情,等忙完之后,你去找你朋友去吃饭了。
那么其中,外卖小哥敲门叫做信号到来。你的朋友给你摇电话这叫做信号到来。但是你不是立马就去开门,也不是立马就下楼吃饭。当然立马的话是最好的。但是你也不一定能够立马开门和下楼。同学们请告诉我为什么你不能立马下楼和开门呢?信号不是到来了吗?在我们刚刚所讲的场景当中,信号不是已经来了吗?已经来了的信号,你为什么不立即去执行呢?
这个事件,站在外卖小哥视角,这件事不是围绕着你外卖展开的,你外卖到了,我作为客户,我就应该立马去取吗?我是一个难缠的客户,我现在就是不想立马去取。外卖小哥你等一等吧。世界不是围绕着外卖小哥转的。那么为什么呢?因为当你的外卖到来的时候,别人可能在忙着更重要的事情,这个世界也不是围绕着你朋友转的!你的朋友叫谁吃饭,你就得立马下来,让谁来就必须立马来,是这样吗?不是的!因为当你的朋友叫别人的时候,此时别人有可能有更重要的事情。世界不是绕着任何一个人转的,世界有自己的运转规则。每一个人都有他自己更重要的事情。

✳️因为信号的产生是异步的,
(也就是说信号是随时随地都有可能产生,在信号没产生之前,我可能在做着其他事情。信号产生之后,我可能在继续做其他事情,但是信号在整个产生的过程中,你怎么产生的?你为什么产生?和我做其他事情没有任何关系。)
当信号产生的时候,对应的进程可能正在做更重要的事情,
(比如说我的进程正在忙着IO或者在和别的进程竞争资源,我进程正在执行重要的事情,那么如果我正在做更重要的事情时候)
我们进程可以暂时不处理这个信号!

这个道理应该是要明白的,外卖小哥外卖送到了,但我现在有个更重要的事情。第二个场景,你在宿舍里面学习,或者更重要的事情。现在宿友叫我去吃饭,还得忙一阵子。不是说打电话就走,因为还要忙更重要的事情。
所以对我们来讲,一个进程收到信号,这个进程收到该信的时候,该信号可能不会被立即处理。那么所以我们这里产生的一个结论就是,因为我们操作系统呢或者一个进程收到信号时,对于这个信号而言呢,你是让我收到了,但你怎么能确定是你的信号更重要还是我忙的事情更重要呢?这个完全就交给进程去判断了。所以我们产生了一个非常重要的结论叫做:
✳️我们的进程可能不要立即处理这个信号!

首先我们能理解一个信号产生之后,进程不需要立即处理这个信号,着什么急呢,因为进程更在忙着更重要的事情。这个世界本来就是这样,外卖小哥敲我门的时候等等情况,我都不一定是立马去执行对应的动作,我可能也要做其他更重要事情。所以进程呢可以不立即去处理这个信号
可是当我们打游戏的时候,没事了是不是就应该去处理这个事情了,可是你怎么知道外卖小哥到门口了呢?他给你发信号的时候敲你门了,你说等一等,当你5分钟把游戏打完了,你怎么知道外卖小哥在外面等呢?当你的朋友在宿舍底下打电话的时候,你知道说再等一等吧,朋友说好。当10分钟过去了,你怎么知道你朋友在楼底下等呢?有人说,你这不是废话吗,是我让他等我当然知道。
假设你是一个记忆性并不是很好的人,电话一挂事就忘了,当外卖小哥给你发了信号,敲了你门,你说等一等。然后继续打游戏,5分钟过后,你跑去睡觉去了,请问此时最受伤的是不是外卖小哥?
✳️这里告诉我们一个道理,首先我们必须明白,进程不需要立即处理这个信号,根本就不是代表这个信号不会被处理!
但是信号此时已经给你发了,你说5分钟10分钟或者1个小时之后处理。你说前提是什么?你把游戏打完,你怎么知道外卖小哥在等你呢? 其实根本就不是你说让他等的,是因为你记住了!你大脑里知道外卖小哥在门口。
✳️换句话说,你进程可以不立即处理这个信号,但是,你必须记住这个信号已经来了!(信号有吗?什么信号来了?)
既然进程对信号不会做立即处理,那么前提,它未来一定要处理!所以他必须得先把这个信号记住,然后才能后续在合适的时候处理这个信号。

信号的处理

对我们来讲呢,因为信号产生是异步的,当信号来的时候,我可能在处理更重要的事情,我有更重要的事情我就没法立即处理你,但并不是代表我不会去处理你。你等一等吧,我一会儿去处理你。但是,你最终5分钟、19分钟等才去处理它,那么你得先把这个信号记住。你得知道信号来了,是什么信号来了。然后你才在合适的时候处理它。 你得记住外卖来了,有人敲门了,应该是外卖小哥,你得知道这件事情,当你把游戏打完,你才更好的处理它。
你已经知道外卖小哥到来了,过了一会儿你去处理它对不对。那么接下来呢,对我们来讲,我想问同学们一个问题,如果一个信号到来的时候,你后面会怎么处理它呢?
第一个有没有可能,当外卖小哥给你打电话叫你取外卖,后来两分钟后你把门打开了,然后外卖拿了就开始吃了。这是处理信号对不对。这叫做你处理外卖的时候的一个默认动作。
✳️当我们处理这个信号的时候,第一种叫做:默认动作
(快递来了,你就自己去取快递;外卖来了,你就默认的拿进来就吃;红绿灯亮了,红灯亮你就停,绿灯亮了你就是过马路。这都叫做默认动作)
除了这些默认动作还有其他方法吗?比如说这个外卖,你和男/女朋友吵架了,是它的错,然后你很生气在宿舍里面打游戏,它为了给你道歉,还给你点了外卖,然后还给你发了消息怎么怎么样…你依旧很生他的气,此时外卖小哥跟你说外卖到了,你说,好的知道了,你就把它扔到外面吧。外卖小哥说行。你就继续玩你的游戏。你根本就不会去吃外卖,不会去处理。这叫什么?
对我们来讲,你这样有没有处理这个信号呢?你跟外卖小哥说放外面。你处理了!有人说,没有呀。你要准确的区分一下,你要处理的是敲门的外卖小哥还是外卖能理解吗?你处理了吗?你处理了!你的处理方法就是,我干脆忽略。我把外卖小哥处理一下,让他忙自己的事情,让外卖小哥把外卖放到外面,所以你处理了。我就是什么都不想做,什么都不做。这叫做忽略!
所以第二种做法就是:忽略。
然后还是外卖小哥,他敲门了,然后你还是在打游戏,然后外卖小哥说同学你好,你外卖到了。然后你游戏打完,就把外卖拿进来。拿进来之后你没有把这个外卖吃了,你把这个外卖给了不懂事的弟弟,说,哥给你点了外卖,你吃吧。此时你处理了这个信号吗?你处理了!你有没有默认你来处理这个信号呢?你自己来把外卖吃掉呢?没有。
所以第三种:自定义动作。
就好比你自己过红绿灯的时候,红灯亮了,你就摇摇头,跳一跳做出百米冲刺的动作,当绿灯亮了你一跳一跳的过去。你就是跟别人不一样,这就叫做自定义动作。
所以除了这三种处理信号的行为,还有其他行为吗?没有了。我们处理信号就是归类成这三种。生活中也是如此。
你男女朋友吵架了,送给你小礼物,平时送给你礼物很开心,默认动作是把它戴在你身上等等…;或者你说,嗯知道了,然后把礼物放在一边,你处理了吗?处理了,只不过这叫做你忽略了它;或者你把这个礼物送给了弟弟妹妹等你处理了,只不过叫做自定义动作。

信号的处理,我们用专业点的词叫做信号的捕捉或者信号抵达处理动作。

从我们刚刚处理过程来看,有三个大阶段:信号产生前、信号产生中、信号处理后。
所以我们学信号,就要结合上面的一些基本的概念,我们要信号的整个周期全部学完,这就是我们要学的信号。

两个问题:异步 和 进程记住这个信号

异步

回答两个问题:第一个呢是异步;第二个呢就是必须记住这个信号已经来了。

异步:首先信号是由,未来我们会讲四五种产生方式,有很多种产生方式。至于在产生这些信号的时候呢,对我们来讲,和我们进程本身的执行他们两个是同时在跑的,你跑你的我跑我的,互不干扰,其实这就是一种异步的表现。
给大家举一个例子,比如正在上课,上课的时候有一名同学说他饿了,想要去吃饭,然后老师就对同学说,行吧你去吃饭吧,同学们我们先等一等吧暂停一下,等这位同学20分钟把饭吃完。其中这名同学就去吃饭了,而我们在干什么呢?我们在等这名同学,其中呢,我们的节奏是会受到他人的影响,这就叫做同步。
但如果说这名同学去吃饭,其他同学们继续上课,他回来的时候继续跟着我们上,这叫做什么?这叫做那名同学在吃饭的期间,我们也同时在上课,这叫做异步。
换句话说呢,其实信号的产生,在进程的任何时刻都可能会产生,也就是说信号你怎么产生的?产生的过程是什么?为什么要产生这个信号?是谁产生的?我作为进程,完全不关系,你跑你的,我跑我的,最后只不过出了一个结果,那么就是你给我发了一个信号,仅此而已。这就叫做异步。

进程记住这个信号

那么这是第一点,第二点呢,我们把必须记住这个信号已经来的问题搞清楚。其中对我们来讲呢,进程他不会立即处理这个信号,但是它可以记住这个信号,下面我们的问题就是,进程是如何记住这个信号的呢?在哪儿呢?这个问题我会讲两次,第一次先告诉大家一个基本结论,让我们有一个基本的理解。
刚刚我们讲过,信号的产生,其实对我们来讲呢,其实分为普通信号和实时信号。我们呢只谈普通信号。
普通信号是从1~31连续的小整数。我们今天,要表示的概念,对于信号来讲,我们是为了记住它,我们是为了记住哪方面的问题呢?
第一个叫做:有没有产生?
第二个叫做:什么信号产生?
结合我们此时的信号编号从1~31,那么同学们我们应该怎么去记录这个信号呢?它这个信号一定是被我们的进程记住了。被进程记住了,而且每个进程都要记住自己所对应的信号。所以信号呢,它的一个,我要记住这个信号,信号的相关信息是会被保存在进程的PCB中的!
也就是所谓的信号产生之后呢,信号是会被记录在我们进程PCB里面,我相信当我现在说这些的时候,我们肯定能理解了。
因为信号是发给进程的,只要我把信号给你记录在你PCB里面不就完了吗?对不对。
第二个问题,PCB里面包含了进程的所有属性,包括这个进程是否收到信号,以及收到什么信号。我们现在猜一猜信号从1~31连续的整数,而且我们要记录信号有没有产生,以及什么信号产生了,同学们你觉得我们应该采用什么样的结构在PCB中保存该信号呢?
很简单。在进程的PCB当中,task_struct我们其实在它的属性里面,具备一个叫做uint32_t sig 叫做位图。位图当中呢,比如我以8个比特位为例,可能我说的不够严谨,但是帮助大家理解。假设现在来了一个信号,从右向左即代表从低向高。所以从右向在依次为1、2、3…
假设我将第2个比特位置为1,即0000 0010,0表示没收到,1表示收到。将第2个比特位置为1说明该进程收到一个2号信号。那么我们说的这个简单位图能表示你说的两个要素吗?(1.有没有产生?2.什么信号产生?)能的!
什么信号的产生我们用的是比特位,比特位的位置表示的是几号信号产生。应该是能理解的。比如说你说的从右向左,依次是第一个比特位、第二个比特位…分别代表的1号信号、2号信号…
那么有没有对应的信号产生,我们叫做对应的比特位的内容:1表示产生,0表示没有。
什么信号的产生,我们叫做比特位的什么位置被置为1.
所以用位图结构就能表示这31个普通信号了。
task_struct
{
uint32_t sig;//位图:0000 0000
}

就这么一点点的认识,我就想告诉大家,我们可以通过位图的方式来进行对信号做相关的记录。有没有产生我们都知道。
那么接下来我们再来谈一个概念,那么请问PCB这个结构体,他是不是属于内核的数据结构?是的!因为你定义的进程对象,给每个进程都定义一个task_sturct结构体变量,把属性一填,这些所有的操作都是操作系统干的。 既然这是内核的数据结构,所以修改该结构内的位图结构只有谁有这个权利能直接修改这个task_struct内的数据位图呢?
毫无疑问只有操作系统!因为操作系统是进程的管理者,所以操作系统的所有属性的获取和设置,只能由操作系统来!
上面我们所讲的所有内容,我们连信号的毛都没见到,什么东西我们都没讲,但是我们照样可以得到我们上面所得到的所有的结论!这是什么给我们的?这是对操作系统的理解带来的能力!
所以我们一会儿要讲信号的整个生命周期,你首先要意识到的是,那么接下来尤其是这里的信号产生前,那么未来呢我们一定要知道,无论信号怎么产生,最终一定只能是操作系统帮我们设置的!
你一会儿讲各种的产生,但无论如何最后底层一定只能是操作系统告诉我们这个信号产生了。我们应该是能理解的,至于操作系统怎么做,要么是系统调用,要么是他自己产生,稍后再说。
在这里插入图片描述

信号的整个生命周期

信号产生前、

学什么呢?叫做用户层产生信号的产生方式。也就说我们要把,信号通常是怎么样产生的问题给大家全部搞定。

通过终端按键产生信号

最典型的代表是SIGINT。我们正式来学习信号的相关内容。

首先呢终端键盘ctrl ➕ C就叫做产生信号。其实就是向我们的前台进程发送2号信号。
那么其中对我们来讲,你怎么到ctrl ➕ C的时候就是向前台进程发送2号信号呢?所以我们可以先来了解一个函数叫做:signal()函数。信号这部分不难,然后接口也比较简单。但是我们得把里面的顺序和条理搞清楚。首先我们来讲signal() 调用

#include <signal.h>
sighandler_t signal(int signum, sighandler_t handler);

函数参数解释:
🌟第一个参数 signum:它代表的就是你要对哪个信号设置捕捉动作。这个第一个参数就是我要对特定的信号设置捕捉动作。

🌟第二个参数:handler:能不能告诉我这个参数是什么呀?没错就是函数指针。那么函数指针呢,那么其中这个函数是允许我们用户自定义对信号的处理动作。
我们刚说了对信号的处理动作有3种:1.忽略;2。默认动作;3.自定义动作。
大部分信号都有默认动作,而我们signal()中的参数handler方法可以让进程对特定的信号完成对信号的自定义动作的设置。所以我们可以用这个函数来进行我们后面的研究。
这个参数呢是一个sighandler_t类型,它是一个返回值为void,参数为int的一个函数指针类型。
给一个函数调用,传进去一个函数指针,这种特性呢我们一般都是设置一个叫做“回调方法”。
关于回调方法呢我们会在网络环境里面经常用到。 我们在C语言上讲无类型排序的时候讲过qsort,其里面可以设置比较方法。包括C++里面sort的比较方法等等比较大小都是可以的。

所以对我们来讲当我们调用signal()函数,是对某一个信号设置一个回调方法。意思就是说,不要再给我默认行为了,也不要给我忽略了。我想捕捉然后自定义方法处理它。
下面我们先写一个小Demo帮助大家成功的验证。

我想先验证一个我们键盘是可以创建信号的。
我们之前说了ctrl ➕ C其实是对前台进程发出2号信号嘛,所以我们就对2号信号做捕捉,看是不是对。
我们用#kill -l命令查看2号信号的大写名称是SIGINT。然后设置回调方法handler。因为说了该参数的类型是一个返回值为void,参数为int的函数指针类型。所以写一个void handler(int)的函数。其中我们这里就叫做对该信号的回调捕捉。是自定义动作哈。
写完该程序后我们将其运行起来。运行起来后,我已经对2号信号捕捉设置完了,进程已经开始运行了,那么请问这个进程这个handler()方法,有没有在我们没进行任何操作的时候被调用呢?
没有!**void handler(int)这不是调用hander方法,这里是设置了一个回调。**什么意思呢?当SIGINT 产生的时候,该方法才会被调用!
什么意思呢?就是我们刚刚写的void handler(int)调用的它,我们在这里注册这个SIGINT给handler()方法做处理,不是在调用handler()方法,只是设置了一下,当SIGINT信号产生的时候,才会去调用
如果不产生SIGINT,该方法不会被调用!
这里不是在调用这个函数方法,只是设置了一下,设置了当这个2号信号产生的时候,你才来调用我这方法,它仅仅是做了这样一个动作!

所以在我们程序运行期间,若不发出2号信号根本就不会调用,会去执行我们的死循环,直到捕捉到2号信号才会去调用我们handler()方法!

那么这个SIGINT是什么呢?你刚告诉我它是一个叫做2号信号,好它是二号信号,那么又是什么呢?
下面我们正式给大家介绍。当我们按键盘的ctrl ➕ C的时候,该进程在执行死循环的期间会去执行我们的handler()方法。按一次就会去执行一次。
那么我想告诉大家的是,同学们你们的进程呢出现了问题,那你就ctrl ➕ C就能终止这个进程。
其实ctrl ➕ C本质就是给前台进程产生了2号信号,发送给目标进程

void handler(int signo)-----➡️给捕捉到的2号信号做回调
{
	cout << "我是一个进程,刚刚获取了一个信号;" << signo << endl;
}
int main()
{
	/*for(int sig =1; sig <= 31; sig++)
	{
		signal(sig,handler);
	}*/
	
	这不是调用hander方法,这里是设置了一个回调。当SIGINT信号产生的时候,才会去调用
	如果不产生SIGINT,该方法不会被调用!
	
	signal(SIGINT,handler);----➡️对2号信号做捕捉,然后接着执行下面的代码,若在执行下面的代码期间发出了2号信号,则会调用handler方法!
	这就是上面讲的为什么是设置了一个方法,而不是去调用一个方法
	
	sleep(3);
	cout << “我的进程已经设置完了” << endll

	sleep(3);
	while()
	{
		cout<< "我是一个正常运行中的进程:" << getpid() << endl;
		sleep(1);
	}
}

✳️所以信号产生的方法有:键盘产生
所以对我们来讲,ctrl ➕ C本质就是给前台进程发送2号信号给目标进程,目标进程默认对2号信号的处理,是终止自己。

自己是可以终止自己的,比如exit()什么的,自己把自己终止掉。所以同学们,自己终止自己说白了也是操作系统把你杀掉的。
所以目标进程默认对2号信号处理自己是终止自己。但今天我们写了代码后调用signal()函数是默认吗?不是!
我们今天更改了对2号信号的处理,(我们更改的方法就是当收到2号信号就去执行我们对应设置的方法,这叫什么?更改了对2号信号的处理)设置了用户自己定义处理方法
我们对信号2号信号设置了自己的自定义的处理动作,这就是signal()函数方法的含义!这就是我们键盘产生的信号。除了键盘产生的信号之外呢,还有一个我们可以称之为,除了ctrl ➕ C终止进程,还有ctrl ➕ “\“它产生3号信号。
证明ctrl ➕ ""是3号信号同上面的代码一样的。

那我们现在重定义了2/3号信号了,我的进程无法被他们杀死了,那我的进程一直在那里死循环怎么被终止掉呢?
那我们再开一个小窗口,输入#ps axj | grep myproc然后看到该进程PID之后,我们再用kill -9 ➕ PID杀掉进程。这样我们就讲myproc进程杀掉了。
我知道我们有很多同学立马就反应到了,那我是不是可以做一件事情,我们再对9号信号处理方法进行重定义 。
我发现一个现象,如果我们自定义捕捉了,比如以前2号信号默认是终止进程,现在变成自定义捕捉之后,那我把你9号也设置了,9号设置了此时这个进程是不是就变成了一个刀枪不入的进程?甚至我用for循环将1~31的普通信号全部重定义一遍!那好吧,既然ctrl ➕ C是终止进程,2、3、9号信号是杀掉我这个进程。现在全部被设置成自定义捕捉。
那么我们这个进程是不是就立马刀枪不入了?谁都杀不了我呢?我们证明了确实2、3号信号确实杀不了!但是我们给它发9号信号,照样能将其杀掉!
同学们,你能想到的将所有的信号都设置成自定义捕捉,你能想到的,将来因为后面我们慢慢学就会知道,一个进程的所有异常情况都跟信号有关系,所以你现在知道,我把所有的信号都屏蔽掉,那你就处理不了了。你想多了,如果这个地方是这样的话,操作系统是不是就有bug了。因为我所有的进程创建的时候,写一个恶意进程,然后我把所有信号全屏蔽掉,那你最后怎么杀都杀不掉我,这就是bug。对我们来讲,操作系统也意识到这一点了,就是9号信号不能被设置捕捉动作,9号信号永远都是默认动作,它是管理员信号。所以其他信号杀不掉,9号信号几乎所有都能杀掉,除了我们曾经的D状态的僵尸进程,其他进程都能杀掉。

我们现在已经能捕捉2号信号,并且也已经证明了2号信号其实是通过键盘产生的,3号信号也是通过键盘产生的。所以我们就得到了第一个结论就是键盘产生信号。注意了这叫做键盘产生不叫做键盘发送。
❓那我们来讨论一个问题:键盘产生信号,是谁给进程发送的信号呢?(就是你再怎么产生,那最终是谁发送的呢?)有同学说不就是我键盘ctrl ➕ C就是键盘 给这个进程发送的呀。所以我们现在理解的概念就是要和其他人理解的就要不一样了。
首先键盘你是产生了信号,不是你发的,你没资格向进程发信号。因为你的键盘本质上也是要操作系统去管的,那么操作系统内的所有进程,其中对我们来讲都叫做操作系统内的相关核心数据,它不允许用户直接访问操作系统内部的所有数据,要么就是通过操作系统调用访问,否则就会出现一些意外的访问。如果键盘直接发给了进程,那是不是相当于用户直接通过自己的行为直接修改了内核的数据呢? 实际上是的,所以这样的设计缺陷很大。
所以键盘产生的信号,只是产生,但是发送得由操作系统。一般是怎么做的呢?我来和大家说一下。
一般键盘和我们对应的叫做操作系统之间的协作呢有没有听过一个名词叫做**“中断”**。我们原理说一下。
✳️中断原理:(了解就行)
其实我们以前讲冯诺伊曼体系的时候,我们是这么说的,我们在数据层面上呢外设是不直接与CPU直接交互的,那么外设有数据的话必须得搬到内存,在CPU里做相关的数据计算,然后再写回到内存里,再刷新到外设上。但是我们当时是站在数据角度。其实外设可以和CPU直接沟通,从硬件角度上是可以的。主要倒不是数据层面,而是控制信号层面,硬件层面上。
CPU呢有很多帧脚,这些帧脚是可以接受外部的信号的,也就是电脉冲。电脉冲信号通过主板再通过一些我们对应的一些硬件设备是可以给CPU特定的帧脚发信号的。CPU有很多帧脚,它这些帧脚呢除了和你们所谓听过的总线相连,还有一些帧脚是可以处理外部信号的。
但也并不是说,照你这么说我们,所有的键盘和外设可以直接给CPU发信息,也不是。其实在我们能看到的这些硬件当中,还有一些硬件是属于CPU和我们外设之间的设备。
其实我们的冯诺伊曼体系是最古老的体系,它也是我们计算机的骨架,但并不代表它是我们当代计算机的主体。比如说你以为你的电脑里面只有一个芯片吗? 不一定只有一个芯片。比如说我们经常听到的磁盘,我们CPU要把数据从外设搬到内存里,其实在当代计算机里呢,CPU其实除了本身的芯片之外,还有很多芯片,比如DMA芯片,其主要和磁盘打交道。DMA相当于让CPU不用再把数据从外设拷贝到内存了,你没听错。老式的计算机传数据是要CPU参与的。现在不需要了。现在我们的设备当中是存在一个个DMA的硬件,CPU要进行我们对应的IO, 它给DMA发指令,DMA再把你磁盘的数据搬到内存里。DMA里面有对应的芯片帮我们做一定程度拷贝计算。
第二个呢,其实还有一种芯片,显卡。显卡里面也有自己的芯片,一张显卡的芯片我们不叫CPU,叫做GPU。以前在公司内部有很多的服务器上也配置了显卡,但其实大部分这些显卡的资源用不上。因为服务器它没有显示器,只有网卡和外部进行IO通信。我们笔记本上有显示器,有时候就会有卖家说你是要独显还是集显。像独显就会给你配上GPU。
所以呢我想告诉大家的是,我们的计算机体系其实是非常复杂的,简化的就是冯诺依曼,不简化的话里面有各种各样的设备。CPU外围设备,比如外部设备输入输出,还有内存,除此之外呢,还有主板和各种总线设备。比如我们CPU在和其他设备打交道时,有些设备的工作方式,本来就不是传统的字符流的,行缓冲或全缓冲的这样设备。比如说磁盘的工作就是全缓冲的设备。工作方式就是把数据缓冲过去,数据好了就发。
其实在我们计算机里有相当多的硬件其实是通过中断来设置的。比如我给你举个例子,当我们按键盘的时候,怎么知道你的键盘按下来了呢?怎么知道你键盘是哪一个键按下去了呢?这是第一个。第二个就是,你难道不好奇吗?我们经常说同学们我们有一个进程现在在磁盘上等待或写入,当磁盘把数据给我们写入完毕之后呢,其中我们操作系统就知道写完了,然后操作系统就可以直接把我们的进程如何如何, 把PCB放到我们对应的运行队列里,然后继续运行。难道你不好奇吗?内存是硬件,磁盘也是硬件,磁盘把数据写完了, 操作系统怎么知道它写完了?所以你能理解吗?
因为我们在写数据的时候有可能包括我们刷新的各方面,有可能磁盘不就绪,那就有可能阻塞了其他进程。操作系统怎么知道磁盘上的数据写完了呢?再比如说,我们未来还会学网络,你把数据拷贝给了网卡,然后发出去。今天我是一台计算机收到了信息。网卡里收到了消息,收到消息之后,那请问计算机操作系统怎么知道网卡上面有数据了呢?
你想想如果是一个两个设备,我定期去轮询检测一下,检测一下你这个数据上有没有数据不就完了吗?可是计算机里面的设备太多了,软件硬件都有。难道你操作系统每一个都要轮询吗?轮询的时候你还要不要处理其他事情?所以问题就是操作系统它怎么知道某些硬件当中的条件就绪了?
其实就是通过一个叫硬件中断来完成的!
所以我想告诉大家的就是,不要觉得操作系统的万能是与生俱来的,不要将其神话了。它的万能是它自己之前做了特别多的工作。是程序员为了设计操作系统,做了相当多的工作。我们要看到别人背后的工作,才有意识去理解比人背后工作的能力。所以操作系统不是与生俱来的万能的,是经过了很长的发展的。它不仅仅涉及到了软件的发展,硬件的发展也是一样的。所以呢我们的比如网卡上有没有数据,磁盘有没有把数据写完?用户有没有把键盘按下来?包括你移动鼠标,点击左键还是右键?等等很多很多的硬件层面上做完了,你操作系统怎么知道?难道要一个一个的轮询吗?这样效率是不是太低了,所以我们再硬件层面上有一个东西叫做中断。
中断的本质上就是你硬件天然的可以向通过外部的总线向我们CPU特定的帧脚去发送我们的电信号。这个发送呢,倒也不是说外设直接对应的向我们的CPU发送的,而是还有一种硬件单元呢叫做825脚,这个硬件单元呢其实是为了收集外部硬件的电信号。然后将其转化成特定的信号。特定的中断编号。你没听错,中断也有编号。中断的编号呢对应的825脚的硬件呢,实际上是可以直接向我们对应的CPU的特定帧脚去发的。CPU的每一个帧脚都有编号,这是硬件。所以当CPU内部已经收到,比如说十个八个,比如1~ 8编号,对应的8个中断,2号帧脚呢,它有电信号了,其实说白了,CPU是怎么收到这个信号的呢?其实相当于你的电脉冲产生的0/1二进制序列或二进制比特位,最终电冲脉形式放到CPU里面,CPU对应的特定帧脚当中对应的寄存器值由0置1了。所以CPU就识别到这里发生中断了。中断来了,CPU就要高优先级处理这个动作,这都是硬件。硬件之后,CPU识别到有个1了,然后比如我们2号帧脚编号比特位置1了,证明有一个中断了,然后我们软件上就开始配合了。其中呢,我们的操作系统在load开机的时候,她会给每一个中断load一张表。这张表你可以想象成它是一个数组,数组呢可以想象成函数指针数组。也就是说,这个数组的下标就是对应的中断编号,数组的内容指向的是一个方法。当我们有中断来的时候呢,我们就可以通过中断编号,执行CPU识别到是哪个中断,然后就可以CPU直接跑过去执行我们中断数组编号,索引这个数组,然后执行对应的方法。来处理这个中断。
我们把这个表称作向量中断表,这个中断向量表呢,它的下标就是中断号,内容就是他要处理的我们叫做所谓的中断对应的一个方法。这张表以及这张表所指向的各种方法全部都是由操作系统提供的,那么这个表里面呢,可能指向的是操作系统内的一个方法去调用,比如说调用键盘底层驱动方法,把键盘的数据识别并读取到内存当中。所以当你按键盘时,整个逻辑就是,当你按键盘时,首先我们键盘就会通过我们硬件向CPU帧脚发送中断,然后CPU直接不再执行我们的任务,而是跑过去执行对应的中断方法。这个方法是操作系统的代码,操作系统就帮我们去把外设数据的拷贝到内存,这个时候就把数据拿到了,至此操作系统也就知道了有对应的进程已经就绪了,已经好了。这是硬件上的。
其中呢我们中断的时候,即便有中断向量表,然后我们再下来呢就执行操作系统的代码,所以当你按下键盘ctrl ➕ C的时候,首先是CPU识别到有人按键盘了,然后呢我们就用CPU去执行操作系统提供的中断向量表的方法,把我们键盘上的ctrl ➕ C两个按键读到内存里面了,操作系统读到ctrl ➕ C,然后对此做解释,解释发现在我们操作系统的对应的编码方法当中知道ctrl ➕ C是要给目标进程发信号的,所以操作系统就直接向目标进程开始发送信号了。
刚刚说的呢就是为了给大家引入一个概念,它属于计算机的组成原理学科。上面所说的所有动作都是我们对应的硬件动作。这个东西呢其实讲来学嵌入式或者做什么纯/偏硬件工作,要用到的。
关于计算机硬件的东西,我们同学应该可以去了解一下,我们以前为什么说操作系统难学,等等其他难学,是因为以前很多东西都是割裂的,你知道计组在干嘛?你知道计算机组成原理和操作系统和你C/C++语言有什么关系吗?你其实不太清楚,因为我们学校讲的内容每个老师都是自己讲自己的。其实很多学科本来都是糅在一起的,互相有交叉的,没有交叉你就搞不清楚各个学科定位,不知道在干什么,你当然学不好。其实我们软件上,对于硬件的必要了解是挺重要的。向我们学习计组的时候比如触发器那些东西,包括寄存器,学对应的内存刷新等等其实我们了解一下没坏处。
我们学到了中断之后,以后对于你怎么知道硬件上把事情做完了?你现在往磁盘上写,操作系统说,你这个进程要访问磁盘,磁盘现在忙着呢,你怎知道磁盘忙着?你怎么知道忙完呢?很简单呀,因为忙完了,它是会给我们发中断的,发中断了,那么对我操作系统而言我才能识别到,识别到就能够在中断上下文当中知道有磁盘已经就绪了,然后把进程的相关属性设置好,放到运行队列里。这其实我对我们有很多很多好处。

其实当你按键盘的时候入ctrl ➕ C等,你按的每个按键可以理解成,将来我们的系统内部可以完成一个映射表,每按任何一种的组合方式,单独按每一个键,其实都可以被映射到某一个具体的数字。这数字是我们约定好的,那么操作系统就立马识别到你按的是什么按键,你对应的是什么功能。比如我们按1、2、3…等这些按键映射到的都是一个方法就是读取方法。就是直接把数据读进来。如果你ctrl ➕ C/V识别到组合键到话,它调用的就是特殊处理方法,这些都是键盘驱动程序以及操作系统组合共同完成的。不管怎么样,操作系统最终都能识别到你按的什么键。然后操作系统要给指定的进程发送信号。
其实发送这个词很不准确,因为这个词很容易让同学们天马行空的思考一个操作系统是怎么给进程发信号的,其实没那么复杂!
其实操作系统怎么给进程发信号,我给大家一个说法:
首先我们说了,操作系统一定能找到每一个进程的task_struct,它也一定能找到当前显示器上的前台进程的task_struct。然后每一个进程内部都有自己的uint32_t sig,它里面包含的就是位图。那么我们刚刚讲过了,对于我们对应的操作系统而言,操作系统它是这个对应的进程的管理者,只要是管理者,有且只有我们对应的操作系统来对它内部的数据做修改。所以操作系统向该进程发信号,具简单!
操作系统只要拿到了对应的信号,在你这位图里,将对应的位置置为1就完成对应的发送!
所以与其叫发送,不如叫操作系统向进程写入信号!
说白了一个操作系统他正在这里做一个基本的动作,然后呢你按了一下键盘的按键,首先通过中断的方法告诉操作系统说有键盘按下了,操作系统说行,那我去帮你处理,CPU直接调用我们曾经中断向量表当中的方法,把对应的组合按键读进来,然后操作系统识别到这个按键是ctrl ➕ C,然后操作系统直接把ctrl ➕ C直接解释成一个方法,向进程写信号的方法。说白了就是函数,然后就是找到对应的task_struct的进程,然后就直接把找到的task_struct进程当中的sig位图由0置1,此时操作系统内部信号发送完成。

当我们有了上面一堆理解的时候,我们其实也已经知道了,除了我们刚刚讲的信号发送的第一种方式之外还有一种叫做软件条件,包括kill命令可以向特定的进程发信号。
当我们实际上调用kill发信号的时候,本质是一样的吗?是一样的。你调用kill无非是没有什么中断产生。没有什么键盘产生,而是直接调用的是系统调用接口,直接让操作系统帮你去做的。
我们刚刚把键盘产生的那一块说重一点点,我们一会儿再来给大家展开来谈第二种第三种第四种等若干种方式。

还有一个问题我们还没给大家正式提出来,就是这个信号不会被立即处理这个问题还没谈,不过没关系,现在我们先来把对应的信号产生方式给大家讲一讲。
然后我们下来谈谈后面的,后面的信号你无论怎么产生,最后都是由操作系统向对应的进程写入的。脑海里立马想到操作系统去向PCB写1就可以了,就这么简单。键盘产生我们已经搞定,接下来谈谈调用系统函数向进程发信号。

调用系统函数向进程发信号

通过调用系统调用函数,向进程发信号。最典型的就是我们要讲的kill命令,kill函数和我们待会儿要学的raise()和abort()函数。
通过系统接口完成对进程发送信号的过程。对于信号来讲,我们应该是能知道了就是对信号做捕捉可以用signal()函数。
除了你用到的kill命令,它本身也是一个系统调用接口函数。

kill()函数
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

函数介绍:
它可以向指定进程发送指定信号。

返回值介绍:
成功就是0,失败就是-1.

后面我们要知道,杀进程也要有权限,你只能杀掉你启动的进程。比如说你是张三能够杀掉李四的进程吗?当然不能。
我们用代码实现写一个kill命令:
比如说我想这样输入:#mykill ➕信号编号 ➕某一个进程pid。如mykill 9 1234。
所以命令行参数一共3个。若用户传入的不对则会打印Usage用户手册。
若你传入符合规则则我们就要调用kill()命令了。当调用kill命令的时候,首先argv[1],argv[2]都是指针类型,则我们要将其转为整数所以要用到atoi()函数,用了之后呢还不行,因为atoi()函数的返回值是int类型,又要强转才行!强转又有一个知识了可以这样:static_cast<pid_t>atoi(argv[2]);
然后我们有了mykill.cc,现在我们又创建一个myproc.cc形成myproc程序。
然后经过g++ -o $@ $^ -std=c++11编译形成了两个可执行程序。相当于我自己做了一个实验,然后我们开两个终端,一个让myproc进程跑,然后另一个终端执行mykill来测试。

mykll.cc

//我想写一个kill命令
//mykill 9 1234

static void Usage(const std::string& proc) 
{
	cerr << "Usage:\n\t" << proc << "signo pid" << endl;
}

int main(int argc, char* argv[])
{
	if(argc !- 3)
	{
		//相当于你不知道传的规则,那我提醒你怎么传,给你打一个Usage手册
		Usage(argv[0]);
		exit(1);
	}
	//若直接传argv[1]和argv[2],是不对的,他们两个char*类型的,应该将其转为整数,所以可用atoi()函数将字符串转为整数。
	//除了这样还是不对atoi(agrv[2]), atoi(agrv[1]),因为atoi(agrv[2])与kill的参数类型不匹配。
	//因为atoi返回的是int类型,而我们要的pid_t类型,所以我们要强转一下,此时就要提到static_cast<pid_t>atoi(agrv[2])了
	if(kill(static_cast<pid_t>(atoi(agrv[2])), atoi(agrv[1])) == -1)
	/*if(kill(argv[1], argv[2]) == -1)*/
	{
		cerr << "kill:" << strerror(errno) << endl;
		exit(2);
	}

	return 0;
}

✳️下一个我们再来一个raise()函数接口

#include <signal.h>
#include <signal.h>
int raise(int sig);

函数介绍:
如果kill是给任意进程发任意信号,那么raise()就是我给自己发任意信号。

函数打样:
int main()
{
	while(1)//循环给自己发2号信号,并且将其捕捉到
	{
		//再强调一下,signal()函数不是调用handler方法,我们仅仅是注册!
		signal(2, handler);----➡️为了证明raise()函数是自己给自己发指定的信号
		raise(2);
	}
}

✳️我们还有一个abort()函数接口

#include <stdlib.h>
void abort(void);
函数介绍:
它和kill()raise()函数又不一样,kill()是可以给任意进程发送任意信号;raise()是向自己发送任意信号。
而abort()函数是向自己发送 SIGABRT 信号,即6号信号。并且该信号虽然可以被捕捉执行自定义动作,但最后还是会执行默认动作终止掉程序。

至此我们终于讲完了第二点:通过系统接口完成对进程发送信号的过程。有了之前讲的基础,现在理解还是比较容易的。 这些系统接口也是让操作系统向目标进程写信号,写比特位,写完之后在合适的时候进程去处理它的信号,信号处理的时候,你的进程就被终止。
下面我们继续第三点:用软件条件产生信号

用软件条件产生信号

首先说一下软件条件这东西理解起来是稍微费解一点的,不过我想给大家举一个例子。
就是操作系统里面呢,有时候,我们一个进程等待的时候,它可以去等待比如说一个硬件,这个硬件准备好了我再去访问。当然一个进程也可能等某种软件的产生。
那什么叫做某种软件呢?其中有种信号呢,我们还没有讲,今天我们先来讲alarm()函数。那么alarm()函数调用的时候呢,是在操作系统底层下对应的内核呢,让内核给我设置了一个定时的功能。就好比你在家里面,你自己不想定闹钟,你叫你爸爸说,爸爸明天我要上学,你给我定一个6点半的闹钟,闹钟响了,到时候你能不能叫下我。然后你爸说,行。然后你就去睡觉了,你爸晚上就把闹钟定好。定完闹钟后,你就去睡觉了,你就不管了。当第二天,闹钟把你爸叫醒了,你爸再去把你叫醒。其中你爸就是操作系统,你就是进程,你呢通过一定的方法,跟你爸说,爸你能不能给我定一个闹钟,然后你爸说,行。这个时候你爸就帮你定了。
那么我们的闹钟设置在内核里面使用软件的方式来做的,当然肯定跟硬件也有关系的。这块关于时间问题,我告诉大家,在计算机里面,时间问题是一个很复杂的话题,不过我们今天暂时不考虑它 。我们对于闹钟的理解呢,就是我调一下这个alarm()函数,然后设定未来超时的秒数。比如说3秒钟,3秒之后这个闹钟就响了。就好比,你跟你爸说你明天叫叫我,第二天6点半了,闹钟响了,你爸来叫你。你爸叫你的时候就叫做给你在发送一个软件条件,产生的闹钟信号。
那么就好比呢,以前是闹钟叫你,这是硬件叫你。现在变成了是你爸来叫你,是人在叫人,它是软件在叫软件。就是这个道理。所以呢这个接口用起来也很简单。
我们再聊一下,你们认为操作系统是什么呢?那么我来告诉大家,操作系统就是一个死循环,简单一点就是死循环。这就是为什么,难道你不好奇吗?当我操作系统启动的时候,我不关机操作系统就不退出,这不就是一个死循吗?我们经常会说操作系统会帮我们做这做那,底层硬件好了,操作系统会帮我们把进程从阻塞队列里面放到运行队列里。操作系统当中底层网卡有数据了,操作系统会帮我们对应的所有工作做了,将数据拷贝到内存,等等。那谁推着操作系统做呢?是我们对应的硬件。有人会说,不对呀,硬件不是归操作系统管吗?是的,确实是归操作系统管,但并不代表,操作系统就不要听硬件的安排。这句话有点矛盾,我来举个例子:在公司里面,每一家公司除了有个老板,我们还会给这个老板配一个秘书。这个秘书要不要被老板管呢?肯定要呀。但是老板早上、晚上干什么、下周等等都是由这个秘书一手安排的。也就是说秘书受操作系统管理,但是这个秘书也要推动这个老板去做一系列的管理工作。所以对我们来讲呢,操作系统是真正的管理者,它是跟老板一样是公司最大的人 ,他忙成那样,那是谁推着它完成任务呢?是秘书给他安排行程,告诉他做什么。秘书既受老板管,也要推动老板。所以操作系统是一个死循环,在硬件里面呢,有一堆的时钟硬件,时钟硬件一般大家可以理解成由我们CPU和相关组件自动形成,它会不断的给操作系统发送,时钟中断。说白就是它每隔1纳秒或者很短时间给CPU说,CPU呀操作系统在跑了,因为有时钟中断嘛,CPU就去跑操作系统代码。那CPU是怎么去跑操作系统代码呢。很简单,有中断,那就有中断向量表,有中断向量表就要执行对应的方法,对应的方法就是由操作系统提供。所以呢我们操作系统会定期收到,操作系统内部会做自动计时,会统计从开机到现在累计了已经被中断多少次。这个中断特别快。不知道有没有听过,叫做CPU的主频。有时候说这个CPU是双核或者多少HZ。主频是什么?但是我知道频率,频率不就是一秒钟操作多少次的概念吗?也就是说CPU在它正常的工作下响应中断主频速度是多快。这个主频速度是多快 ,就意味着操作系统被调度的频率越高。所以一般主频越快的,它的计算机效率越高,价格越贵。操作系统就是死循环,操作系统的驱动方式就是通过中断来做,然后操作系统内部呢有很多中断的方法,然后时钟中断不断不断,非常快不断的去给我们CPU发送中断,CPU就疯狂的去调用操作系统时钟中断方法去执行操作系统代码,来完成调度切换等算法!
所以对我们来讲,操作系统本身就有计时的功能,开机之后就有计时功能,能计时操作系统在内部软件上就可以自己设置很多软件相关的定时器。比如说,你们的闹钟是怎么设置的?不就是给未来自己定一个时间,然后时间到了,检测是否已经超时了。所以计算机操作系统内核里面也有很多时钟,在内核中,他其实设置好未来时钟,你的主频不断去推动你的计算机做时间上的线性增长,然后在做增长的同时,计算机不断去对比你当前时间喝未来时间是否是大于还是超过。时间戳嘛,所以超过了之后,对应的注册方法就应该超时了,这就可以叫做我们的定时器。
下面给大家来一个软件条件。

你说的呢我大概理解,这个闹钟呢其实就是用操作系统接口的方式设置闹钟。
我们调用alarm()函数,并将其参数设为1,也就是未来1秒钟之后就会是超时。
那么闹钟1秒钟之后响,一般而言如果你不对这个闹钟做任何自定义处理动作,当我们进程收到信号时,很多的进程都会默认终止自己,然后alarm(1),1秒之后,会收到一个SIGALARM信号 ,即14号信号,也就是1秒钟之后我的进程就会终止。所以我一会儿程序跑起来,告诉我这个程序在做什么。
运行1秒钟之后,闹钟响了,接下来请告诉我这个代码是在做什么?我发现他是跑起来了,但是1秒钟之后这个进程就终止了,这一切都在我们的意料之中。那我们写的程序是什么呢?
我们写的程序是不是:想统计一个我们进程1秒钟会将我的cnt++到多少,我们发现是cnt加到20000左右,真的是这样吗?
我们再向我们的代码加上signal()函数,然后捕捉一下14号信号。
闹钟一秒之后会发闹钟,自动调用我的回调handler方法。在没有到1秒钟的时候cnt一直++,但是不打印了。当你1秒钟到了,我handler方法也会将cnt的值打印出来。
我们发现不打印之后,由刚刚cnt一直++,加到2万次左右,变为现在cnt一直++变为5亿次!所以效率扩展很多倍!
所以由此可以看出IO真的很慢!

arlarm()函数
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
void handler(int signo)
{
	cout << "我是一个进程,刚刚获取了一个信号;" << signo << << "cnt:" << cnt << endl;
}

函数打样:
int main()
{
	signal(SIGALARM, handler);
	alarm(1)----➡️这里的时间就是你未来的时间,设为1,就是你未来1秒钟之后就会超时
	int cnt = 0; 	while(1)
	{
		cnt++;
		/*printf("hello : &d\n",cnt++);*/
	}
	return 0;
}

软件异常产生信号

我问一个小问题:你们以前有没有在写代码的时候出现异常崩溃?
比如说我们会写 int a = 10 / 0;则程序一运行起来就崩溃了。会报浮点数错误,这个不是重点。重点的是进程崩溃了,异常退出。Linux中崩溃呢其实,常见的程序一崩,进程就会异常退出了。但是在windows下呢,在VS环境下除0错误,进程直接就崩溃了,会弹出一个崩溃的窗口。告诉你某某内存无法访问,出现了越界的情况。好那对我们来讲,曾经学习C/C++的时候,我们的程序突然崩溃了,同学们以前呢, 我们学语言只能说访问某些地方崩溃了,然后我们追溯问题时,发现时除0了,或者是我们自己写的代码越界了。在windows当中这些错误一旦跑去来都是运行时崩溃弹窗,我们今天不再讨论运行时崩溃,在代码层面上是什么原因。
而今天我们要讨论的是,这里的所谓的崩溃本质是什么呢?也就是说为什么我们的代码会崩溃呢?我自己写的代码➗0了 。虽然我知道在语言层面上➗0就是错误,但为什么会崩溃呢?原因是因为,让我们先做个测试。
我们对所有的信号用signal()进行捕捉执行handler方法。对信号处理的方法注册完毕之后,进阶再将越界的代码放开,接下来我们想看看现象是什么。接着运行起来。我们发现打印出来是11号信号,然后我们#kill -l查看所以信号,发现11号信号大写名称是SIGSEGV;同样的我们将野指针的问题再放开;我们继续将➗0的错误放开。

void handler(int signo)
{
	cout << "我是一个进程,刚刚获取了一个信号;" << signo << << "cnt:" << cnt << endl;
}

for(int sig = 1; sig <= 31; sig++)
{ 
	signal(sig, handler);
	int a[10]= {0};
	a[100000] = 1;
	int* p;
	*p = 1;
	int a = 10 / 0;
}

我想告诉大家以前我们在Windows当中一运行就崩溃,在Linux当中进程崩溃。
进程崩溃的本质是:该进程收到了异常信号!
我们以前自己写代码的时候,如果有逻辑问题或者代码问题,程序一运行就直接崩溃了,所谓的崩,我们以前叫做崩溃,实际上说法不太准确,应该是进程崩溃了。进程崩溃了一定是你➗0了各方面导致你操作系统出问题,然后操作系统直接向你该进程发送信号,通过信号来终止该进程。
以前的代码为什么会崩溃了,我现在大概知道了,终于知道以前代码一跑就崩溃,除了我们的代码有问题,我代码写的确实有问题,崩就崩呗,也很正常,可是为什么会崩呢?你说代码有问题,代码有问题为什么会崩呢?是因为收到了信号,为什么会收到信号呢?

首先我们来谈谈➗0错误。 我说一个东西,看大家能不能理解哈,当我们在➗0的时候一定是在CPU内做计算,尤其是算术运算,算术运算在CPU内部除了,有你要参与计算的数据,比如说 10和0等,要load到CPU的寄存器当中,除此之外,CPU内的寄存器里面还有一种寄存器叫做状态寄存器。
这个状态寄存器是用来干什么的呢?它是用来表征本次计算是否出现问题。没有出问题就没什么问题,如果有问题状态寄存器当中的特定标记位会被置位。
也就是说当我们➗0的时候,CPU内的状态寄存器会被设置成为(你想想当一个数字➗0了,它的状态寄存器有一个标记位是用来统计你这次计算是否有越界情况,其中呢,我们状态寄存器就会被设置成)有报错:浮点数越界
换句话说CPU内部的寄存器(硬件)出现异常,操作系统就会识别到CPU内部有报错啦—>1.是谁干的?2.是什么报错(操作系统–>构建信号)–>给目标进程发送信号---->目标进程就要在合适的时候—>处理信号—>一般默认终止进程
这就叫做硬件异常产生信号的一种场景。
我们还写了越界&&野指针产生异常,又是怎么产生的呢?大约在很早之前我们就说过
我们在语言层面上使用的地址(指针),其实都是虚拟地址(比如说你越界了或者是野指针了,你用的对应虚拟地址一定是一种非法的虚拟地址)
---->转成物理地址—>访问物理内存—>读取对应的数据和代码的

如果我们的虚拟地址有问题,地址转化的工作是由(MMU(memory manager unit内存管理单元—硬件) ➕ 页表(软件))完成 [所以我们把虚拟地址转化成物理地址是一种软硬结合的方式来做的。对于我们来讲虚拟地址有问题,转化工作是由它们两来做的] 转化过程就会引起问题—>表现在硬件MMU上( 换句话说我们计算机上有很多的硬件,它一旦报错了,它硬件里面也有一些状态的标记位,来标明本次转化是失败的,表现在MMU上,是谁让它转化的呢?)—>OS发现硬件出现问题(要确认两个问题,哪两个呢?刚刚是CPU内状态寄存器出问题了,现在是谁的呢?)
这里就出现了1.谁干的?2.是什么报错(OS–>构建信号)[然后操作系统就要把你对应的MMU上转化方面的报错,转化成信号发送]---->发送信号给进程(进程收到了该信号怎么办呢?)–>目标进程就要在合适的时候—>处理信号—>一般默认终止进程

上面讲的错误就是:因为硬件异常,而导致OS向目标进程发送信号,进而导致进程终止的现象!
我们在学C/C++语言的时候,我们经常听到,我代码一跑起来代码就崩溃了,同学们我们代码崩溃了吗?这里就要有深刻的理解了。
崩溃是因为有问题,比如➗0或者越界等问题崩溃我也能理解,那为什么出现这些问题计算机就直接将我的进程杀掉了?
因为操作系统不允许对硬件有错误,一旦你有错误了,操作系统是软硬件的管理者,硬件都出错了,上面都报了软件引起我硬件报错,那你还不改?
所以操作系统最简单粗暴的做法就是,你报错了,赶紧把你的进程拿过去改一改,这个进程有问题直接终止你就可以了。这就叫做硬件异常!

❓崩溃了一定会导致进程终止吗?
我们以前写的所有C/C++代码,我们在VS2019下一写一崩就退出,那么崩溃了一定会导致进程终止吗?不一定!
那怎么确认呢?因为你刚刚的所有行为都是默认行为去处理的。所以怎么办呢?我们可以把对应的信号捕捉一下。
我们代码写好了捕捉,一旦你➗0了,一旦捕捉到了,我就打印一条消息,然后exit()退出,那么如果我不要exit()不退出呢?按照我们之前的理解,没有信号捕捉的这一步,我一崩,那么程序就直接崩了。但是我们现在捕捉一下,我们用➗0来做个例子。
代码运行起来后,我们发现疯狂在调用我们设置的函数。那我们的进程崩溃了吗?答案是:没有!我们用#grep axj | grep mykill可以看到我们的mykill进程还在运行中。
有人说,那为什么现在不崩了呢?因为当前你的➗0错误,➗0错误之后可以看到,我当前的进程本来➗0错误,一运行它就完成了,可是它死活不完成。然后我们现在signal()注册了方法,它疯狂的调用注册的方法,进程压根就不退出。为什么不退出呢?原因就是因为你捕捉了这个进程。
下面给大家说一下,首先如果你不添加这里的信号捕捉,我们来试试。可以看到直接就终止了。当我们放开捕捉动作,它就不终止。
很简单,因为以前的默认动作是终止程序,现在改了,变成了handler()方法,它只是打印一句话,没有对信号在做其他处理,没有做终止处理,所以就不会终止程序。但是你没有解决这个问题,操作系统就不断的给你发信号让你退出。
所以对我们来讲,我们出现了异常崩溃了,默认的是终止,但你对这个信号做了捕捉,一旦捕捉了,你没有手动终止这个进程,那么此时这个进程已就不终止。
这说明什么?进程终止不终止,其实也并不一定完全由操作系统决定,它是由用户去决定的,用户你自己去决定该怎么处理,反正就是告诉你出问题了。你该怎么处理就怎么处理,出现异常你就去解决,你可以不解决它,不解决它就一直会给你发信号。在大部分情况下我们都是把它终止了,这就是我们对应的崩溃。
所以以前在windows下一定会终止吗?不一定,只要你把它信号捕捉了,我们就可以让他不终止。

int main()
{
	signal()
	while(1)
	{
		int a = 10 / 0;
	}
}

我们C++语言体系里面有一个:try catch,想问大家有没有学过C++里面的抛出异常呢?抛异常其实也很好理解不算是多难理解 。
我问一下大家如果我们在try catch抛出异常的时候,我catch的时候不终止这个进程,这个进程会不会结束呢?如果以前在学习抛异常的时候,我不退出,那么这个进程会不会结束呢?
一般同学你看看写的try catch,一旦catch到异常了,我们直接就把它给终止了,不终止呢?那么进程会不会退出呢?
我们将abort()屏蔽掉,然后启动程序。然后可以看到我们成功将异常抛出去了,大家能看到我们显示屏是已经显示它抛异常了。我这里直接throw异常,然后catch捕捉它。我们throw一下,大家可以看出来,我们在catch(int& e)这里收到了一个值,我们抛了异常之后,打印了一句话 。
其实我们的进程根本就没退出。出异常了,它可以不退,然后你只能自己在泡完异常之后,用abort()函数或者exit()函数将其进程退出。也就是说呢一般我们在C/C++里面,自己抛出异常的时候呢,异常问题是需要我们自己处理。一般我们的用户都是不处理的,直接就,相当于直接把它终止了。
其实我就是想告诉大家,当我们try{}里面执行异常的时候,其实就是发信号。你可以理解成try{}cathc{}这种语句,它最后其实相当于,首先得让我这个进程知道,你发生了异常,所以我们try{},这个动作有点像发信号吧,这个catch{}相当于我们捕捉方法,一旦出现异常就执行catch{}里面的方法,非常像。
我们是想说明什么呢?我们就是想说明一个最简单的道理:当我们进行我们C++异常的时候,它怎么知道我异常了。C++里面有exception里面有种类,它怎么知道我是什么类型报错,还会告诉我呢?
本质上是因为我这个进程出现了信号!
所以换句话说,如果我们自己catch{}到这个异常,但是我不终止你。告诉同学们一般出现异常问题,一般也就是打印下它的报错,对应的就是输出一下日志就完了,然后进程就直接终止了。但是对我们来讲心里要清楚,一旦出现异常了进程会终止,它是必须得终止的。因为没办法,已经出问题了,你还怎么解决。有很多问题呀,比如➗0、越界等问题。它不是逻辑问题,它是属于我代码写的有问题,所以它try{}catch{}直接抛出,最多我们打印信息就退出了。这是我们C++里面处理这个异常的语法形式。

try
{
	throw 10;
	while(true)
	{
		int a =10;
		a /= 0;
	}
}
catch(exception& e)
{
	cout << "oops,我异常了" << endl;
	cerr << e.what() << endl;	
	//abort();----➡️指定发出6号信号,默认动作是终止进程
}

✳️通过上面的实验当中呢,我们基本可以总结思考一下:
·上面所说的所有信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者
·信号的处理是否是立即处理的?在合适的时候
·信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
·一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?–即使我没收到任何信号,我也对每个信号的处理动作都了解。这是在编码 设计操作系统中设计信号机制的时候在一开始的时候就写好了的。
·如何理解OS向进程发送信号?能否描述一下完整的发送处理过程? ----一句话:对进程的数据结构内的位图写比特位。

进程等待中core dum字段

在这里插入图片描述
我们在讲进程等待的时候,说过一个概念,进程等待时我们的父进程如果需要它必须得对我们对应的子进程的状态进行获取,要获取子进程的状态,那我们就需要通过设置status参数,调用wait/waitpid()来获得参数。其中,我们说只研究低16位,其里面的次低8位代表进程退出时的它的退出状态,俗称进程退出码。这个概念我们应该很熟了。
第二个,低7位叫做该进程是否收到信号。今天我们就非常理解了,进程退出时收到信号,一定是我的进程处于异常终止。异常终止就是代码还没跑完,结果是什么样子就完全不确定了。异常呢,每一种错误收到的对应的信号编号都是不一样的。所以对我们来讲,可以根据编号的不同来识别最终你是什么类型的报错。这就叫做我们所对应的进程终止时对应的信号编号的问题。
其中呢我们有一个标志位叫做core dump。只不过我们现在用的云服务器是一种生产环境,是一种线上环境。一般呢看不到core dump。

✳️那什么叫做core dump呢?
我们以前在讲进程等待的时候,少讲了一个东西,我们说后面再说。我们讲了一个叫退出信号我们讲了,一个叫退出状态我们讲了。还有一个core dump我们没讲。它表明当前进程在异常退出时是否发生core dump。
我们写了下面的代码来测试。然后我们讲程序运行起来。发现一运行打印出来的exitcode:0、signo:11,(不就是我们对应的段错误的信号编号嘛)、core dump flag:0 。
然后我们再来一个➗0的错误。然后我们后面运行发现core dump依旧是0.
我们现在#man 7 signal查看更加详细的信号手册。我们看到我们刚刚出错后发的8、11信号,发现它们两的动作都是Term(Terminal)叫做终止。这终止的意思就什么都不干就是终止。然后在有些其他信号Action那一栏是Core而不是Term。
其中这个Core的意思呢就是:我们不单单要终止,我们还要发生一个叫“core dump”的名词。但是我们根本就没有发现所谓的core呀!我代码打印出来core dump flag不是0嘛?
输入#ulimit -a,这是因为在线上的生产环境里面,我们对应的core file size(是否允许你形成core文件 )这里的内容是被设置为0的。禁止你发生core dump的。那么我们现在把它打开输入命令#ulimit -c 100000设置大点。设置完成后我们再看core file size就是100000这么大了。
我们在运行我们写的代码,看到core dump:1就被设置成1了。我们8号信号在信号详细手册里的Action选项里他8号信号他本身也有的,所以他本身就应该要产生core文件的。进程退出的时候产生core文件core.2357,并且core dump标志位被设置成1了。更关键的是我们用vim core.2357打开后发现是乱码。
我们首先先回答一个问题,我们一个进程是异常退出的时候,如果收到了某些信号,某些异常时,系统为了便于用户调试,会告知用户你在异常退出时我要给你出发core dump机制。
core dump机制什么意思呢?它就是核心转储。所谓的核心转储,说人话就是,向普通的1、2信号,它的中断都是来自于我们键盘等外部设备的。 而一旦像3、4、5、6等信号尤其是3、4、6、8、11这几个信号,很显然有很多的时候呢是我们自己程序内部自己终止的情况。所以外部的情况出问题,那你外部的问题和我没关系。但如果是内部的话,有可能是需要你调试的。所以像这种仪异常呢,在你子进程被等待,父进程获取子进程退出信息时,如果是被3、4、6、8、11等Action选项是core的信号终止,那么他对应的core dump标记位会被置1,其二并且会在当前目录下形成一个大文件core.xxxx(其中xxxx是产生core文件的对应的进程是谁,是哪个进程引起发生core文件)
对我们来讲呢,这种机制叫做核心转储。那么核心转储的本质又是什么呢?说人话就是core dump会把进程在运行中 ,(我们肯定有各种各样的数据和代码信息) 对应的的异常上下文数据,core(核心) dump(转储)到磁盘上,方便调试。

int main(int args, char* argv[])
{
	pid_t id = fork();
	if(id == 0)
	{
		//子进程
		/*int* p = nullptr;
		*p = 1000;*/
		int a = 10 / 0; 
		exit(-1);
	}
	
	//父进程
	int status = 0;
	waitpid(id, &status, 0);
	printf("exitcode: %d, signo: %d, core dump flag: %d\n",(status>>8) & 0xFF,  status & 0x7f, (status>>7) & 0x1);
}

如果你自己在线上环境当中,你在运行的时候,默认情况下core dump会关着的。若你把它打开了,如果你的进程在运行过程当中出现了异常,而且这个异常恰好是你代码本身导致的,因为就直接决定了是外部的问题(手动发信号终止)还是内部的问题(比如➗0、越界等代码问题),并且对应的硬件异常上下文呢,他就会把代码异常的上下文数据包括我们进程在运行时各种临时数据全部给我们dump到磁盘上,方便我们调试,并且如果dump了 ,他会将我们所对应的叫做退出信息status(当中的我们可以称之为)---->core dump标志位置为1
这个就是为什么要有core dump的原因。这也是core dump作为标志位的意义。
说白了你其实就是想告诉我,进程如果出现异常,如果是我自己子进程代码内部有问题,有问题之后呢会根据我们的信号,在默认处理的时候会帮我们去做一些 ,因为是内部异常嘛,异常之后就会帮我们做一些core dump操作。core dump操作会把我们进程在内存当中的上写文数据给我们dump到磁盘里。所以我们上面提到的给我们形成的core.22357文件的属性中文件大小是557057。这个数据大小是548KB。其实这也不小了。
我们有了core dump之后呢,我们又有什么问题呢?我们继续写代码。其中core dump文件呢是我们自己进程内部出现问题,然后我们接下来 出现问题之后呢,我们操作系统帮我们把我们进程的上下文数据dump到磁盘里。我们知道这个意思了,有什么用呢?
我们代码写好了之后,在Makefile里面给我们mykill.cc g++编译中加上 -g选项支持我们调试。
我们mykill.cc编译好运行后,毫无疑问只有begin那行打印出来了,然后接着下一行是告诉我们是出现段错误。因为我们上面已经是将core dump打开了,所以也就给我们在当前目录下形成了core文件。
然后我们#gdb mykill,来调试我们的编译后形成的mykill文件。#gdb mykill之后呢我们直接输入core-file,把我们刚刚又形成core.24993文件load进来。回车键之后就直接定位到了当时出现异常的那一行代码。我是在*p = 10000这一行当中进程被终止了是收到了11号信号:段错误,这一行当中出现的错误。就这么一下我们就定位到了问题。
如上就是我们core文件的意义。我们像这种情况相当于我自己调了一个代码调了半天,怎么搞都搞不定,太麻烦了,搞了半天也不知道代码在哪儿出问题了。
哪有这么麻烦,直接把core dump一打开,然后我gdb跑起来,将core文件load进去,就直接帮我们定位到错误的地方,这时候就只需要解决为什么会出错了。
所以我们这种以前调试时知道有问题,但我们得找这个问题,然后才能去分析这个问题。现在呢就是你知道有问题,首先你直接可以通过core dump跑起来,看看在哪一行出问题了,然后知道哪一行出问题,然后再分析这行为什么有问题。那么这种调试的策略呢我们叫做事后调试。这也是一个小技巧。
其实这就对我们不难理解,所以有core文件后呢,我们在调的时候,实在搞不定了我们就core dump,形成core文件,将其加载到gdb里面就好了。
那么这里有一个问题:为什么线上的云服务器它默认是core file size给置为0,也就是关掉?
我们将我们有错误的代码多运行几次。然后发现它确实不断的在生成core文件。然后此时我们输入#ulimit -c 0将core dump关掉,此后我们再运行程序,也就不会生成core文件。
我们一般生产环境里面,在服务器上core dump一般被关掉的。
把core dump开启不是挺好的吗?我们如果进程内部有错误,我们直接就可以把core文件拿过来调不就好了吗?
首先core dump必须得配合gdb 和 -g选项 来使用。能用-g编译代码一定是调试过程,调试的时候一旦我们都上了云服务器,已经在生产环境了。这叫做我们对应的产品就已经是release了! 即便你core dump也没有任何意义。你只知道它崩了,但无法去调试。第二个一般而言呢,对我们来讲记住了,尽量还是云服务器一定要把core dump关掉 。因为有些大公司内部,比如你写的服务,你自己写的代码,100行代码就出现几个bug,随时随地运行出现问题。一旦服务挂掉了其实最重要的问题不是排查问题和解决问题,而是立即重启。尤其是天猫什么的双11搞活动,1秒钟的交易额上亿,你直接服务器一挂掉,给企业带来多大损失,让国家少收多少税。所以服务是绝对不能轻易挂掉的,但一挂掉根本就不是gdb调一下,而是立即重启。而有些公司里面呢有很多的内部自动重启的策略,如果我服务挂掉了,会自动立即重启服务,这个过程是自动的。同学们,你敢想吗?是服务器本身有问题,而不是因为外部服务器压力太大什么的问题。就是你代码写的有问题一运行就挂一运行就挂。那么我们公司的一些运维策略呢,它可能在不断的给你重启,不断的重启,你想想一晚上试试。我说的不是机器而是服务重启。有很多的软件把我们线上的服务监控起来。每分钟给你重启上万次。每次重启给你core dump一下,不多说一次重启多500KB文件,一晚上的话磁盘里面会被打满大量文件。因为垃圾文件过多,导致我们磁盘空间打满,之后就有可能会危及到操作系统,一旦操作系统挂掉,问题就大了。所以线上环境当中我们是不允许core dump。万一出问题,就会有可能不断重启不断产生core文件,导致磁盘空间占满出现后面的一系列问题。
core dump还是尽量关掉,因为有一些公司里面动不动好几十万台机器,一些搞基础操作系统交付的,比如说给服务器重新安装操作系统的工程师,那么多机器,其实机器一多,就很难保证,这些机器里面有没有什么对应的问题,如果有问题了对他来讲,有时候可能忘记关闭了core dump,就可能在线上生产环境上有些目录下出现core dump现象。

mykill.cc
int main()
{
	cout << "begin......" << endl;
	int* p = NULL;
	*P = 10000;
	cout << "end........" << endl;
	
}

前面我们都是解决的信号产生前的问题,这个我们已经搞定了,我们准备下一个阶段了:信号产生中。

信号产生中

曾经你给我们讲过:信号产生和进程运行是异步的,那么后来呢我识别到有信号产生了,知道有对应的信号产生的时候呢,对应的进程可能做着更重要的事情,所以呢我们可以暂时不处理这个事情,是可以的。如果我们暂时不处理这个信号,就要把这个信号保存起来或者自定义动作等。
我们不暂时处理它,那么我们在什么处理呢?我们是在合适的时候。这里我们当时说,进程就必须得把这个信号先保存起来。接下来它是怎么保存的呢?信号处理方法在哪儿呢?以及还有一个信号阻塞的概念。我们要把这些慢慢的讲一下。

先讲一些信号概念:3张表

·实际执行信号的处理动作称为信号递达(Delivery)----就是信号的处理:1.忽略一个信号、2.执行默认动作、3.自定义捕捉

·信号从产生到递达之间的状态,称为信号未决(Pending)。(一个信号产生的时候,我们计算机不一定非得立马去处理这个信号,我们把这个信号暂时保存起来,这个信号暂时保存起来也就意味着已经产生了,但是还没有被递达,也就是还没有被处理。在已经产生和没有被递达之间,这个信号就会在我们某些区域被暂时保存一段时间,这种信号状态我们称作“信号未决”)

·进程可以选择阻塞 (Block )某个信号。 (当我们实际上做后续的信号处理的时候,我们还有一种概念叫做信号已经产生了,它当前没有被递达,这是未决状态。如果可以的话你可以随时去递达它,去执行对应的动作。但是我们也可以选择阻塞这个信号,所谓阻塞这个信号呢就是,如果一个进程收到了一个信号使其状态变为未决状态,也就是保存起来了,然后呢,如果没有被阻塞,该信号就可以在合适的时候被递达。如果你将该信号阻塞了,那么该信号将永远要处于未决状态,直到你解决对他的阻塞。所以被阻塞的信号,在产生时,保持在未决状态,知道你解除了对该信号的阻塞,才能执行递达动作,除此之外一切工作都是徒劳。如果你把这个信号阻塞了,你再怎么给他发这个信号都不会去处理。)

·被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.

·注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作

✳️信号在内核当中的表示
在这里插入图片描述
在我们的操作系统内核当中呢,我们的进程叫做task_struct结构体里,它是会包括3张表的。
第一张表叫做Pending表 (上面提到的未决表),它是一张位图,你可以理解成它有32个bit位,它的Pending表里面呢就是我们在之前所谈到的一个操作系统向目标进程发信号,发的时候就把信号放在了对应的Pending这个位图的某一个位置,将0置1 。
第二张表是handler表,如果Pending是一个位图的话呢,但handler表不是位图,它其实是一个函数指针数组。函数指针数组呢它里面,如果说Pending表里面的比特位的位置,第一个、第二个、第三个等等,代表的就是哪一个信号。比特位的位置为0/1,代表的是哪一个信号是否被收到。那么对应的你是第一个、第二个等等信号你就有对应的方法。1号信号是SIG_DFL,2号信号是SIG_IGN,还有指定用户自定义方法等等。
就目前来看呢三张表当中Pending和handler表我们应该横着看。什么意思呢?就是你Pending第一个比特位就是1号信号,若收到信号则为1否则为0;第二个比特位就是2号信号,有收到信号吗?有,那就置为1。同理下面的3、4、5…信号。
因为进程有至少我们现在看到的Pending 和 handler两个表,所以信号就具备了,第一个识别信号的能力 (你是什么信号?信号来了没?几号信号来了?只要你把对应的位图由0置1,我就知道了是哪一个信号是否到来这个概念);第二个,我也具备处理信号的能力 (你的信号编号就作为该函数指针的数组下标,直接就可以访问到对应的自定义方法或者是默认方法)
所以其实当我们在进行进程的操作的时候,大家应该很清楚一点,对我们来讲操作系统发信号是修改进程PCB当中的数据,说白了就是修改我们的Pending表,将Pending表中的对应位置由0置1。比特位的位置代表信号的编号,比特位的内容代表是否收到信号。所以Pending就决定了该进程能识别信号。
还有一个handler,它是一个函数指针数组,函数指针数组呢,它里面放的就是一个个函数的地址。当有对应的信号来了就调用对应的方法。其实说白了我们以前调用的signal()函数,是一个系统调用,你不是用它注册了一个回调函数吗?把函数名写进去了吗?所以signal()函数就是在改hangler表里面的内容,当你用signal()函数的时候,你有没有发现你的第一个参数就是信号编号,第二个就是函数指针。说白了就是,第一个就是数组下标,第二个就是数组里面写什么内容。 所以至此我们就可以通过下标的方式,然后去修改handler表里面的指针指向的哪一个方法,默认的就是SIG_DFL、SIG_IGN等等就是handler表里面的默认方法。
我们把Pending 和 handler搞定了,其实就是一个位图 和 一个函数指针数组,返回值为void ,参数为int的一个函数。一般在信号处理的时候呢,它会将信号编号作为参数自动传进来。

还有最后一个表是Block表,叫做阻塞信号集。阻塞信号集就是我们的Linux给我们提供的一种方法,有的时候我的一个进程就不想处理所谓的2号信号等等或者其他信号。但是你防不住别人给你发呀,我们想象一下,如果我们没有Block这张表,你拦不住别人给你发信号,所以你的Pending位图一定会被设置,但是你拦不住比人发,一旦收到了信号,在合适的时候系统会自动帮我们去处理对应的信号,调用对应的方法。所以你既然拦不住别人给你发,但是我可以拦得住我自己去递达这个信号。那怎么拦呢?所以我设置了一个Block表。这个Block是一个位图,它的位图结构和我们的Pending位图是一摸一样的。也就是说Block和Pending位图其实是一摸一样的。那怎么个一摸一样法呢?
就是Pending位图里面有0/1序列,只不过大家的含义当中有一半是一样的。什么是一样的呢?第几个比特位就代表的第几个信号编号。不一样的呢?比特位的内容在Pending表代表的是否收到信号,而在Block表代表的是否阻塞该信号。
说阻塞有一点点不太好理解,我换一种说法,就是Block对应的位图比特位,为0还是为1。为1 的时候表示它会拦截对应的信号去执行对应的方法。换句话说,即便我Pending收到了这个信号,但只要Block表对应的比特位为1,那么对不起,这个信号你无法去递达。即便我Pending收到了这个信号,不好意思,因为我Block了你,你即便已经Pending了,但是你不能够进行handler,除非我将我的Block表去除对应的位置的block,此时你的信号才能被递达。
所以以上就是Block、Pending、handler表。

所以对我们来讲呢,这里的Pending代表的就是我们当前是否收到信号,如果没有block的话,那么在合适的时候,信号就直接从我们的handler中调用方法去处理。但是现在的问题是,你拦不住别人给你发,因为是操作系统给你发的。但是剩下的就交给你这个进程了。我进程可以通过Block表来阻塞你这个信号的递达。所谓阻塞你这个信号的递达呢就是,我收到了这个信号,我可以暂时选择不递达,除非我解除对应信号的block,此时这个信号才能被递达。
❓那么我现在有一个小问题:请问阻塞和忽略这两个的概念是一样的吗?
我们以前讲过,有一个处理信号的动作就是忽略该信号。我们刚刚又学了一个block阻塞信号。那么忽略和阻塞有什么区别呢?
首先忽略信号,它是处理信号的一种,只不过处理的方式是忽略,说白了就是什么都不做。所谓什么都不做就是,将你的Pending位图由1再置为0就完了。
而阻塞信号不是去处理你这个信号,而是拦截你,不让你处理对应的信号。
忽略是处理信号的一种。block阻塞不是信号处理动作,而是拦截一个信号递达,不让该信号进行快速递达,而是你暂时等一等,因为有时候就是不想对特定信号做响应。

·每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,说白了就是内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中, (第一个信号既1号信号SIGHUP没有被Pending也就是没有收到信号,也没有被block,处理动作就是默认动作SIG_DFL,这是第一个;第二个呢就是2号信号SIGINT,它Pending表对应的位置是1,说明已经收到了2号信号,但是因为Block表中对应的位置是1被block了,所以这个信号没有办法去递达或者说是执行处理动作,所以 Pending表中对应的位置一直是1,除非你后面解除对它的block;第三个呢,也就是3号信号,他从来没有收到信号,从来没有产生过不代表我们不可以拦截这个信号,即也可以将其再Block表中对应的位置置为1。这个呢就是我们上面图中处理信号的3个动作)SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
所以上面例图中Block表、Pending表、handler表应该都是横着看的。怎么看呢?我们应该先前看当前的Pending位,再看block,再看handler。
也就是说呢,只有你的Pending位为1了,为0表示没信号嘛所以我什么都不管,如果为1了说明有信号了,再看Blcok表有没有被屏蔽,若Block表为1说明被屏蔽了,那就不递达,什么也不做就结束。若Block表为0,也就意味着当前信号可以被递达处理,再看handler方法。
我讲个例子:我们每一次在上学时候,每一个老师都会去布置作业。布置作业的时候呢,正在上课,老师说,同学们今天的作业是xxx的xxx页等等。然后作业一布置了,难道同学们立马就收拾了书包,准备回到宿舍里面写作业嘛?不是。因为老师现在正在给同学们上课,老师将作业布置了,同学们课后才能在合适的时候去做作业。下课之后你还要和你同学开两把游戏呢,所以想合适的时候去处理作业。所以呢,一下课时间久了,就怕忘记作业是什么了,所以同学们一听到老师要布置作业,大家就拿出自己的小本子去记录作业。为什么你要把作业记下来呢?原因很简单,因为你要在后面挑合适的时间去处理它。正常情况下给同学们代课的老师有好几个,很多人。所以同学们将每一个老师布置的作业都记下来,合适的时候再去处理它就可以。所以呢我们在坐的同学里面,不管你将来怎么处理,你都要写。所以呢同学们在写作业的时候都有不同的写法,有的同学就是默认动作,老师布置了那我就写呗,老师怎么要求怎么来。还有的人就是采取忽略,也就是作业记下来了,可就是不写。有的同学是自定义动作,找人代写等。所以完成作业的时候,有的默认、忽略、自定义动作。可是给同学们代课的这么多老师里面,我特别讨厌一个老师,所以他布置的作业我记下来呗,我在心里记下来一张表凡事A老师的作业我都写,B老师的作业拦截他我不写。有一天觉得这个老师讲的还可以,感觉可以去写写作业。所以你对老师的喜好,就拦截了你是否要去处理这个老师的作业。 其中这个老师布置作业你该记还是会记,只不过你多加了一层喜好,你写不写是你的事,你喜不喜欢这个老师,你拦不拦截它完全由你自己定。我拦不住老师要布置作业,我记下来,但我就是不写,因为我屏蔽了他,当我想写的时候,就解除屏蔽。
Linux内核针对Block、Pending、handler表设计了对应的操作系统接口,让我们可以去拦截某些信号,你收到没?我收到了,能不能递达?取决于我有没有block它。对于我们普通程序猿是可以去阻拦某些信号工作的。

❓下面呢我有一个问题:假设我把2号信号在Block表对应的位置,置为1了, 阻塞住它,可如果对方连续给我发了10几个2号信号呢?你的比特位只能记住一个呀!你想想你Pending位图表每个信号编号只有一个bit位来标识嘛。
此时会出现什么现象?对方给我发了10几个2号信号。对不起,只有一个2号信号被记下来,也就意味着未来也就只有一个2号信号会被递达。这就叫做剩下的信号直接被丢弃掉了。这也就是为什么我们在键盘里ctrl ➕ C了十几次,最后只会将我们的一个进程终止。

·SIGINT信号(见上面的例图)产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。

·SIGQUIT信号 (见上面例图)未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号

因为例图有3张表,未来我们学习信号的时候,学习到的系统调用接口大部分都是围绕这3张表展开的。

提供信号接口函数编码

接下来要讲的这块会稍微恶心一点,因为接口会比较多一些,如果下来自己在书上看, 会花特别大的时间去看,因为它里面有很多很多的概念我们是很难去理解的。

认识“sigset_t”类型:
我们上面讲的Block表和Pending表都是位图结构,并且你告诉我它是一个整数,但是不同的系统在对这种结构的实现是不一样的。首先能肯定, 大家都是位图,但是不同的系统调用对于它的实现会有差别。对我们现在来讲呢,你内核里面的数据结构,未来我想在用户层面上,去对你的内核结构做设置,包括我想获取一下你内核当中的数据。其中我们必须得使用操作系统给我们提供的特定类型。
所以接下来就有一个类型叫做“sigset_t”。这个类型是操作系统给我们专门针对信号所构建的一个用户级的数据类型。我们的每一个信号呢,不管Block表还是Pending表都只有一个bit位来表示未决和阻塞的概念。所以未决和阻塞两张表在用户层想要获取或设置的话,我们一般用到的类型是sigset_t类型。
sigset_t类型呢,我们把它称作信号集。所以这是一个用户层的信息表示,是操作系统提供的。这个类型可以表示每个信号的有或者无的概念。在阻塞信号当中呢就表示 有没有被阻塞的这么一个概念。在未决信号集呢表示 有还是没有被Pending起来 。我们的这个类型是由我们的Linux系统给我们提供的新类型。这个类型呢在不同的系统里,他的大小是不一样的。通过看内核,知道这个类型是数组管理起来的一个位图结构。(在学位图的时候也讲过,一个位图结构本来就是 由数组来实现的。)所以呢这个类型呢,因为他是不同的平台,不同的位数大小差别,所以虽然他是位图,但你绝对不能使用位操作。你不能自己直接使用位操作对他里面的成员做按位与(&) 、 按位或(|)、取反这些概念。所以操作系统必须得提供使用特定的信号函数来做,我们稍后会说。
所以呢,我们一般呢会因为有sigset_t信号集的存在,所以我们就有了对Block表和Pending表,在用户层面上,我们有Block信号集和Pending信号集这两个概念。而我们一般把Block信号集叫做信号屏蔽字,这是一个概念,叫做“Signal Mask”,也就是说有点像我们的“umask”,它不就是拦截你对应的权限嘛。Block

就是要屏蔽你要递达的信号。所以Block信号集又叫做信号屏蔽字。
后面我们不再叫Block表 和 Pending表了,我们都叫做Block信号集(或者信号屏蔽字)和Pending信号集。

因为我们刚刚看了sigset_t这种类型,它本身在我们Linux系统上是数组,不同系统数组里面的位数也是不一样的,并且禁止直接用位操作来对其进行操作 。所以你要使用sigset_t类型,他的里面每一个类型bit位都表示有/无这种概念的时候,具体的存储实现是依赖于系统本身的。使用者根本就不关心的。
但是呢我们使用者划的红线就是,我们不能对它里面的二进制位图做任何修改,包括你想用printf打印它里面的变量也没有任何意义。不能手动改,那怎么办呢?所以我们必须得有专门的接口。

✳️针对sigset_t而设计的函数接口

#include <signal.h>

int sigemptyset(sigset_t *set);
函数介绍:
对信号集做清空,把它里面内容全清0.

int sigfillset(sigset_t *set);
对信号集全置1

int sigaddset(sigset_t *set, int signum);
在特定的信号集当中,把特定的信号加进来

int sigdelset(sigset_t *set, int signum);
在特定的信号集当中,将特定的信号去掉

int sigismember(const sigset_t *set, int signum);
判断一个信号是否在该信号集当中

换句话说上面的一批接口就是针对sigset_t位图结构天然设计好的,各种各样的增删查改操作。就是已经给你配好,你直接去用就行。

sigprocmask()函数

数据类型我们上面已经搞定了,我们要学习的接口一定是关于Block、Pending、handler这三种的接口。设置信号屏蔽字,获取Pending表,修改自定义回调方法。
对我们来讲的第一个函数接口就是sigprocmask()。

sigprocmask()---------➡️信号屏蔽字有关
 #include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

函数介绍:
sigprocmask()是可以去更改我们的进程的信号屏蔽字的!
sig:signal就是信号的意思;proc:process就是进程的意思;mask:就是掩码的意思
所以这个函数的作用呢就是:可以更改或者获取特定调用进程的屏蔽字,也就是BLock信号集。 


函数参数介绍:
🌟第一个参数 how:所以你到底是要做什么动作?函数的第一个参数就决定了,就是你是想进行对应的什么操作呢?
	第一个操作:“SIG_BLOCK”,其意味着想向我们当前信号屏蔽字里面添加信号,所以在添加的时候呢,这里可以用“SIG_BLOCK”操作,让传入的第二个参数set信号集,来向对应的进程进行添加或新增你要屏蔽的信号。它的操作相当于底层的mask = mask | set。
	第二个操作:“SIG_UNBLOCK”,意思说,你想将我们sigset_t set信号集当中保存的信号,把在这个传入的sigset_t set信号集当中的信号,在我们的信号屏蔽字当中解除对他的屏蔽。它的操作相当于底层的mask = mask & ~set。
	第三个操作:“SIG_SETMASK”,上面两个操作单纯的就是新增。比如我以前对1号block了,你现在又要对2号block。那好吧你直接把2号添加到set信号集里面。
	而“SIG_SETMASK”,覆盖式的指定你的信号屏蔽字变成你所传入的全新的set信号集。所以这叫做覆盖式的重新设置。相当于让我们底层的信号屏蔽字等于set信号集。

🌟第二个参数 set:是输入型参数,代表的是新增或者是删除或者是覆盖式的重新添加的你的信号集。

🌟第二个参数 无论是你想新增还是删掉还是想重新覆盖式的,我们进程都有默认的信号屏蔽字,那么我们sigset_t* oset呢,
o可以理解成old,也可以理解成output输出。
这是一个输出型参数,当你无论想做任何信号屏蔽字的操作,它会通过该参数把你老的原先的的信号屏蔽字直接返回来。
返回的目的是为了将来万一你想恢复你曾经的信号屏蔽字呢。如果你不想返回设置为nullptr就行了。

所以sigprocmask()是可以去更改我们的进程的信号屏蔽字的!
sigpending()函数

✳️如果说上面的sigprocmask是用来修改一个进程的信号屏蔽字的,那么我们还有一个Pending信号集也有对应的函数sigpending()。

sigpending()函数--------➡️获取进程的Pending信号集
#include <signal.h>
int sigpending(sigset_t *set);

函数介绍:
获取进程的Pending信号集,所以用起来非常简单。

函数参数介绍:
🌟第一个参数 set:它呢就是帮我们去获得当前进程的Pending信号集,说白了这一个参数就是一个输出型参数!
	它呢调用的时候直接一调用就可以把我们的当前进程内部的Pending位图信息全部给我们拿出来放到set信号集里面。

返回值介绍: 
成功就是0,失败就是返回-1

现在呢我们已经学习了两个大接口,以及前面signal()函数。这三种接口呢sigprocmask()改的是Block表,sigpending获取的是Pending信号集。signal()函数设置的是handler方法
现在呢我们对信号的三张表呢的设置接口全有了。
下面给大家做一个实验,这个实验怎么做呢,仔细听我描述。
》根据你刚刚所讲呢,想做一个这样的实验。比如说,我在最开始的时候,因为我在进程默认的时候,他没有任何信号是被屏蔽的,也就是Block信号集是全为0的。第二个呢,也是没有任何信号在Pending信号集中被pending的。因为我们所有的信号都没有向当前进程发送。第三个所有信号的处理动作都是默认的default。
》换句话说最开始进程启动的时候,对信号的处理是最干净的处理方式。下面的问题呢就是我想做一个什么动作呢?
》如果你说我刚开始想不断的尝试去获取一下当前进程的Pending信号集呢?好废话少说上代码。
》我们想不断获取当前进程的pending信号集,所以获得进程的Pending信号集用的接口是sigpending(),其参数是一个信号集类型。我们现在想要不断获取Pending信号集的话,我们就的先来一个sigset_t类型的 pendings即sigset_t pendings获取进程所有被pending的信号。
》我们每次在获取前呢,肯定要讲pendings这个变量全清0,那就用到sigset_t的操作了sigemptyset()函数,将信号集清空。清空之后呢,就尝试去获取当前进程的Pending信号集,就用到sigpending()函数。pengdings获取成功后,我们就用ShowPendings()函数来打印一下。在ShowPendings()函数中我们想知道信号有没有被Pending的话就要在for循环里面使用sigset_t类型的sigismember()函数检测你的pendings信号集和对应的信号。
》那这里检测什么呢?检测特定的信号,我才不管你现在你pendings信号集哪个是0哪个是1反正我知道一共有31个信号,我把31个信号遍历一次,每一个信号我就看在不在你的集合里。如果在就说明我们获取到的进程Pending集里面是包括你这个信号的,说明这个信号就Pending了。
》说白了就是我们打个死循环,然后我们疯狂的去把pendings清空,然后去获取当前进程的Pending信号集,然后for循环遍历进行打印。
》写完之后运行代码,如我们预期都是0,然后我们发送kill -2 ➕进程pid,然后进程直接被干掉,但是没有打印出最后一次的Pending信号集。因为我们今天的代码只对其进行获取Pending,但是Pending有个问题,一旦Pending获取出来并打印了 ,那么这个Pending信号呢就可能就立马相当于一发很快就递达了。递达了之后你也看不到了对不对。而且一旦递达了,你也看不到的原因是这个信号当前默认动作就是终止进程,也就是一收到2号信号,直接终止。终止的时候你连后续的打印都没有执行,你就看不到变化。那怎么办呢?
》我们再做一个工作:调用我们signal()函数,对我们的2号信号进行自定义捕捉一下。自定义捕捉就意味着当我们后续再收到2号信号的时候,2号信号收到就会自动执行我们对应的handler方法。
》然后我们再启动我们的程序,此时我们给他发2号信号,但是呢因为我们是Pending信号集,此时一收到对应的信号,同学们你想一下,收到了2号信号,然后呢,此时这个2号信号很快就被递达了。虽然我们口口声声说信号不会被立即递达,但是我们说的这个会不会被立即,是相比较于CPU的速度。所以你也看不到任何现象,反正递达之前是全0,递达之后也是全0说明Pending信号集没有信号,但是我们依旧看到它被处理,看不到变化。那怎么办呢?
》没办法我们只能再来一个。那好吧既然我们看不到,原因就在于我收到了对应的信号,然后呢它可能很快就把它递达了,递达之后,Pending信号集依旧是我们所对应的0,所以看不到变化。这样我干脆把我2号信号block了。也就是说我不让你这个2号信号递达,即便你收到了。所以我先把你屏蔽掉,然后呢在我们启动前5秒里,我什么都不做,你打印出来的Pending信号集就是全0,然后5秒之后,我给你发2号信号,我一发2号信号,这个2号信号不会被递达,因为我把它block了,所以它只能再Pending表里面。只能在Pending表里面,我就应该看到Pending表里面的2号信号由0置为1的变化了。
》要屏蔽2号信号呢,就要调用sigprocmask()函数,由函数参数的值这里很显然我们需要有两个信号集参数。那我们就再加sigset_t bsig,obsig(b表示Block的意思),然后用之前将他们两个进行初始化一下,就是全部清空一下用到sigemptyset()函数。然后我们就要向我们bsig信号集里面添加要屏蔽的信号这就用到sigset_t类型的sigaddset()函数了。将2号信号添加到bsig信号集了,但是并没有将其设置进你的内核里面就要用到sigprocmask()函数,并且要选择其操作方法:SIG_BLOCK 和 SIG_SERMASK这两个都行。
》我们一旦屏蔽了2号信号,然后我们再设置了对2号信号的捕捉,最后你自己再获取Pending位图,前面的一段时间呢,我们肯定是什么都没有的。我们先让它跑一会儿,然后给他发2号信号,2号信号被屏蔽肯定就不会被递达,2号信号已经被Pending了,它不会被递达,那就只能被我们看到它被Pending了。代码运行起来后却如我们所想。
》我们做的过分一点,既然要屏蔽我们就将所有的普通信号屏蔽,看看实验结果。是的和我们预想一样,展示的Pending信号集置为1的越来越多,并且众望所归的9号信号确实无法对其做自定义捕捉,它依然能杀掉进程。

这个代码写完了吗?还没有。
》正如你所说,我确实看到一些信号被屏蔽了,没有被递达,没有被递达的话,被Pending到我们对应的位图当中。那如果我想恢复呢?一旦我将它恢复了我立马能看到啥?你这个Pending位图由1再回到0,因为你信号递达之后就会由1变为0。
》我们先定义一个cnt计数器,当cnt <=10的时候即10s一内所有信号都是被屏蔽的,你随便发什么信号它的Pending位图对应位置都会变为1。如果10s之后,我想把指定的信号解除屏蔽。想解除屏蔽,怎么解除呢?比如说我想把2、3号信号解除一下。我们先做一个全部恢复的实验,我们不是有一个obsig这么一参数吗?它原先是作为sigprocmask()函数的第三个参数,现在让其作为第二参数传进去,那就把原先的信号集给恢复了不是吗。
》你给我发了很多信号,Pending位图有很多位置都是1,我现在把所有的信号解除block,一解除block,我们应该会看到一批信号都会被递达,并且要看到下次轮询的时候,一以前Pending位图的一串1会变成全为0.。我们把代码运行起来后,正如我们所想。

void handler(int sig)
{
	cout << "我是一个进程,我刚刚获取了一个信号:" << sig << "cnt:" << cnt <<  endl;	
}

ShowPendings(sigset_t* pendings)
{
	for(int sig = 1; sig <= 31; sig++)
	{
		//如果对应信号在pendings集里面则为真,则输出1;否则为假,输出0
		if(sigismember(pendings, sig))
		{
			cout << "1";
		}
		else
		{
			cout << "0";
		}
	}
	cout << endl;
}
int main()
{
	3.屏蔽指定的信号
	//为sigprocmask()函数的参数做准备
	sigset_t bsig,obsig;//先定义两个信号集
	sigemptyset(&bsig);//对两个信号集做初始化工作
	sigemptyset(&obsig);
	
	for(int sig =1; sig <=31; sig++)
	{
		sigaddset(&bsig, sig);//将所有的信号都屏蔽掉了
		signal(sig, handler);//所有信号都被自定义捕捉了
	}
	/*sigaddset(&bsig, 2)//将我们指定要屏蔽的2号信号添加到信号集里面*/
	
	sigprocmask(SIG_SETMASK, &big, &obig);//设置用户级的信号屏蔽字到内核中,让当前进程屏蔽掉2号信号
	

	2.signal自定义捕捉指定信号
	signal(2, handler);
	
	sigset_t pendings;
	1.不断的获取当前进程的pending信号集
	int cnt = 0;
	while(true )
	{
		cnt++;
		//1.1信号清空
		sigemptyset(pendings);---➡️每次获取前先对信号集清空
		//1.2获取当前进程的Pending信号集
		if(sigpending(pendings) == 0)
		{
			//1.3打印一下当前进程的Pending信号集
			ShowPendings(pendings);
			sleep(1);
		}
		if(cnt = 10)
		{
			cout << "解除对所有信号的block" << endll
			sigprocmask(SIG_SETMASK, &obsig, nullptr);
		]
	}
}

信号产生后

进程内核态和用户态–引出处理信号时机

有一个话题我们从来没有研究过,你说产生信号前,各种产生,你说硬件、软件等等产生了。产生中呢,我知道有3张表可以保存,可以屏蔽,还可以自定义处理方法。那么之后呢,如果我准备处理了,这个信号怎么处理呢?你上面说了那么多,什么时候才递达呢?你说会被递达,你也说他不会立即递达,那么请问什么时候会递达呢?为什么是这个时候呢?那么我们是不是要研究一下信号是如何被递达达。所以第三个阶段叫做理解信号的捕捉!
》相当于现在我们已经把信号产生前,怎么产生的我们已经搞定了,信号中间怎么保存和屏蔽还有怎么递达的我们也搞定了,我们还有一个问题没有回答,这个问题就是:当我们信号产生的时候,进程可能正在忙着其他事情,所以进程没有办法立即去处理我们的信号,没有办法立即处理,那它就得在合适的时候去处理。那么第一个问题就是:
❓信号的处理是什么时候去处理,不要再给我说是合适的时候了,给我具体一点?
》我们也就不给同学们废话了。我先直接和同学们说一个很好理解的结论就好了。
结论:当当前进程从内核状态,切换回用户态的时候,进行信号的检测与处理的时候!

从现在开始我们要学习一个内核态 和 用户态。这两个概念不好意思我们都没正式说过。什么叫做从内核态到用户态?内核态和用户态是什么东西?它们又有什么区别?对我们来讲什么情况下属于用户态,什么情况下属于内核态?什么时候内核会返回用户,我怎么去理解这个内核态?接下来我们要把这些问题讲一下,那么信号的处理过程就完了,但是呢我们不能这样干,我们得吧这个东西至少给大家说清楚。
》不知道大家曾经在讲进程概念的时候,给大家说过一个东西,叫做进程地址空间。
》进程地址空间俗称mm_struct,我们的task_struct指向我们的mm_struct进程地址空间,然后我们还有一个物理内存,对我们来讲我们曾经还讲过一张叫页表的结构,这张页表结构呢,把我们的每一个代码段、数据段、已初始化、未初始化、堆区、栈区等映射到自己表里面。然后进程就可以通过地址空间加页表的方式找到某些物理内存区域,访问我们的数据。
我们这张页表,我们把它称作用户级页表,每一个进程都有一份,而且大家的用户级页表都是不一样的!。
》我们曾经谈的页表都叫做用户级页表,每个进程都有一份。而且,虚拟地址可能一样,但大家一定被映射到不同的物理内存。被映射到不同的物理内存就一定意味着而且大家的用户级页表都是不一样的!
》用户级页表只负责建立地址空间当中0~3G的空间,其中我们3~4G这段空间,我们叫做内核空间。来同学们,你从来没有好奇过吗?就是操作系统的代码是怎么被执行的?操作系统它怎么能够通过系统调用就能让我们的进程执行对应的代码呢?比如我通过系统调用,我调用系统调用就能在我的代码里面调起来这个接口,怎么做到的呢?
》用户级页表,只负责0~3G用户级数据的关系,除了这张页表,系统里还存在一张页表,也是一样!做虚拟地址到物理地址之间的转化,这张页表我们称作为内核级页表!
》所谓的内核级页表,专门来负责什么呢?专门来负责3~4G之间对应数据的映射。一句话,操作系统在不在物理内存里面被加载呢?毫无疑问,在!它也必须在。
》它必须在物理内存里怎么办呢?假设物理内存中有一段空间是给操作系统的代码和数据,你想想,进程管理的所有数据结构都在内存里面,对吧。所以操作系统必须得在对应的物理内存里面。
》所以呢,我们每一个进程,它的3~4G的使用,通过内核级页表映射到物理内存中操作系统的代码和数据。而内核级页表是所有进程共享的,只有一份,前提是你得有权利访问。如果你没有权利,这个内核表你有,但你防不了。同学们所以对我们来讲呢,那么我们的进程在映射时,0~3G是用户级页表,找到的就是用户的数据。3~4G的空间呢对应的就是内核级页表,访问的就是内核的代码和数据。
》那么其中对于一个进程是如此,对于任何一个进程也都是如此。任何一个进程比如说,一个新的进程他一定有自己的用户级页表,它的用户级页表呢经过我们的代码、数据等的映射,映射到了我们的物理内存某个区域,所以用户级的进程呢指向的就是它自己的代码和数据即0~3G。而我们新的进程的进程地址空间3~4G映射的时候依旧是内核级页表,只有一个。
》所以有了这样的认识,无论进程怎么切换,我们都可以找到内核的代码和数据,前提是你只要能够有权利!
》说白了就是,我们每一个进程都有自己独立的用户级页表,指向内存的不同的区域,对应的就是进程自己用户的私有数据。但所有进程呢,3~4G 全部都指向的映射的是同一张页表 ,找到的就是同一个内核级页表。然后呢,我们的进程如果可以,它也可以直接访问我们对应的操作系统的代码。所以无论将来你进程再怎么切换,只要进程在执行,在被调度,那么我们操作系统的代码一定可能被执行,这是其一。
》其二对我们来讲呢,我怎么知道我当前是处于有没有权利去访问内核级页表呢,乃至访问内核数据呢? –要进行身份切换。进程如果是用户态的也就只能访问用户级页表;进程如果是内核态的那么能访问内核级和用户级的页表。
》所以换句话说,进程也有它的用户态和内核态这一差别。
》那么问题来了,我怎么知道 我是用户态呢还是内核态呢?是因为我们
CPU内部有对应的叫做状态寄存器CR3寄存器,有对应的bit位标识当前进程状态
:**0表示内核态;3表示用户态。**换而言之,我们的操作系统内部有对应的寄存器,对我们来讲呢它里面 能够表示我们的内核态还是用户态。也就是说呢我们是可以 通过设置对应的寄存器的CR3寄存器各种值,然后我们再去访问设置我们的状态。
》那么问题又来了:我可以随意去改这个状态吗?–不能!那一般我们会在什么情况下改我们对应的状态呢?1.系统调用的时候;2.时间片到了要进行进程间切换(时间片到了,时间一到,硬件上呢就直接帮我们去更改我们对应的CR3寄存器标志位,改成我们的0号状态,一旦变为内核态,时间片到了,内核态一设置,此时我们就有权利访问3~4G,3~4G我们能访问了,怎么办呢?我们时间片到了,我们就可以通过内核级页表找到操作系统内的调度算法,在进程的上下文里面执行对应的进程调度,来完成进程切换。)3.其他条件…
》总之我们是可以去更改它的状态的。 **所以内核态和用户态相比较,内核态可以访问所有的代码和数据,不是意味着一定要这么干,用户态只能访问自己的。**换句话说,内核态拥有最高权限。
》换句话说,如果我们要调用系统调用,当你要进行系统调用的时候,你用的你调用的代码是操作系统给你的,但那个代码你不要想的那么简单。编译器编译的时候,它的代码里面有各种各样的,如中断等方法去陷入操作系统。当陷入操作系统说白了,他一定要第一个将自己的CR3寄存器的状态标记为设为0 ;第二将我们的映射表不再用用户级页表,而变成内核级页表。这些页表的起始地址,也是会load到我们的CPU寄存器里的,然后呢用内核级页表,此时就可以更改PCB指针指向内核的3~4G的地址空间里。因为我们用的是同一个地址空间,每一个进程都有3~4G的地址空间。找到3~4G的地址空间后通过内核级页表映射到物理内存,然后找到操作系统调度的代码然后在我们的进程的上下文当中去执行我们的调度算法。完成调度,调度之后就相当于自己把自己放下去,然后把别人拿上来。拿上来返回的时候,再把CR3寄存器恢复成我们对应的3,变成要用用户级页表,然后把页表load成新切换进来的页表。那么CPU继续指向新切进来的代码。这就叫做用户态和内核态。
》对我们来讲呢我们要清楚的认识,只要地址空间mm_struct我们可以不变,你地址空间不变,然后我们只要把页表一切,页表只要一切换, 我们此时就可以去有能力访问操作系统了,只要你权限够。权限呢它会在CPU的寄存器里面保存我们CPU内的访问权限,可以由我们对应的 0内核态 3用户态,来识别你身份允许你是否访问操作系统。这就是我们所对应的基本的用户态和内核态。

当我们把用户态和内核态全搞定了,当进程从内核态切回用户态时,❓那什么时候会进入内核态呢?✳️答案是:系统调用或者进程时间片到了,那么我们操作系统要执行进程切换了,它要进入内核态来执行1.系统调用代码2.进程切换代码。然后执行完毕之后,再返回到用户态时,再检测对应的进程的信号情况。如果有就处理,没有就继续执行用户的代码了。所以这就是我们进程处理信号的时机。
在这里插入图片描述


✳️课外了解
我们不要小看CPU,更不要小看操作系统,我觉得有些细节可以和大家说说。比如说,CPU怎么知道当前执行的是那个进程呢?你说CPU调度某个进程的时候他怎么知道呢?----CPU调度一个进程时,进程的PCB结构体地址是会被load进CPU的寄存器里面的;第二CPU怎么知道我对应的页表呢?你也不用担心,因为页表是一个软件,它的起始地址也是会被load进CPU的寄存器里面的;第三CPU怎么知道我当前进程执行到代码的什么位置呢?你也不用担心,当你的进程被CPU调度的时候,你的进程对应的代码的执行位置也是会被load进CPU的寄存器的;第四CPU怎么知道我当前进程是属于用户态还是内核态?我有没有资格去访问内核级页表?你更不用担心,因为CPU内照样有一堆的寄存器帮我们去秒速,当前是用户态还是内核态;第五我的当前进程呢怎么找到用户级页表呢?你也不用担心,因为我们CPU内部也有寄存器能把我们的用户级页表起始地址load进CPU当中。我想给大家说的是,操作系统它是一个软硬件结合的软件,不要小看,编写操作系统刚开始是,比如说开机 ,包括对很多硬件的识别,在启动的时候,它全都是汇编写的,恨不得二进制写,但基本都是汇编写的。然后呢后面有C语言后呢,不影响,因为它是通过软硬件结合方案去做的,而我们一定要首先建立一个认识,CPU内的寄存器,我们可见的,经常在写自己代码时有一些数据比如eax、ebx、ecx…等肉眼能看到的。同样的还包含了我们一大批并不可见的寄存器,这些不可见的寄存器不是给你用的,是给操作系统用的。所以我们经常见到很多人说,我的操作系统一会儿状态切换,一会儿 页表、一会儿上下文切换等等乱七八糟的,包括它怎么快速找到我当前调度的进程呢?它怎么知道我进程的地址空间和页表呢?CPU内一半寄存器是给操作系统去用的。操作系统自己在做调度切换和内存管理各种工作的时候,他也会用CPU内的寄存器。所以不要傻傻的认为只有你一个人在用,也不要认为只有你的数据会放在CPU寄存器里面。
》所以对我们来讲,我们的计算机它实际上在你的认知里面,我们的CPU上跑一个进程,我们是可以通过寄存器快速找到这个进程的PCB,快速找到这个进程的用户级页表,快速判定这个进程对应的是属于用户态还是内核态。为什么?因为上面所有工作,操作系统都可以通过读取CPU内的寄存器做的。有人问怎么读到的? 非常简单,我们寄存器里面有一条指令move,我们的操作系统在自己的代码当中可以直接对某些寄存器做判断,是能做到这些的。


✳️
用户态和内核态是一种既定状态,这种状态是我们一个进程在运行时,可能来回变化的两种状态。用户态执行的就是我自己写的代码,我写的for循环、我写的函数调用等一大堆东西。而我们内核态执行的就是操作系统代码,说白了就是在你当前进程的上下文当中执行的是操作系统代码,比如说我们讲打开文件、malloc、new等你以为你咋语言上用到的很多函数,你一调用就有结果了,但实际上这些所有的所说的接口他的底层都有操作系统的身影。比如说你malloc申请内存的时候,是你要的吗?并不是,是操作系统给你分配的。所以我们一定是以这样运作的。
》上面的是用户级代码,下面是内核级代码。其中你自己编程写了一份代码,你的这份代码当中,写了很多很多的代码,其中,在你并不知情的情况下,你可能早就经历了无数次内核到用户、用户到内核的切换。有同学就纳闷了,没有呀,我好像从没有切换到内核过。你当然没有感觉呀,为什么呢? 因为第一所有的操作系统对你提供的服务是接口式的,所以你感受最深的最多是调了接口;第二你现在用的C/C++或其他语言,你将来所有要学的乱七八糟东西都是通过语言封装的,所以你肯定感受不到,但根据冯诺伊曼体系,以及我们曾经讲过的操作系统与硬件的层状结构。你比如说你打印、读写磁盘,未来你还要读写网卡、读写网络,再包括你访问内存申请和释放。所有这些动作你有资格吗?你有资格去访问显示器吗?你有资格访问磁盘吗?你有资格去访问内存网卡吗?对不起,你没有这个资格。有同学说,我有呀,调用一下接口不就完了。那同学们这些接口是谁给你提供的?有人说是C++提供的,操作系统提供的等等。但归根结底所有软硬件都属于操作系统管理范畴,被操作系统管理。你想用人家的资源,你要不要经过给别人操作系统打个招呼,让人家操作系统帮你做呢?就好比你要在银行里面存钱,你最终通过窗口把你的钱存到银行了,但不管怎么存的,都必须由银行的工作人员把你的钱拿到仓库里,或者从仓库里拿出来。这才是正常工作方式。
》所以我们无数次的调用了某些访问软硬件的资源的一些代码。那你便无数次的叫做进入操作系统执行操作系统内的代码。所以你一定存在无数次陷入内核态。既然有陷入,那么有进去就要有回来。所以你一定会经历无数次的用户到内核,内核到用户的切换!
》这就是我们站在系统级角度,上帝视角去看待我们很多语言的现象,还有我们现在编的系统编程的现象。你要比别人认识的更多一层。你不能把自己置身于语言的上下文当中去理解。你要站在上帝视角。
我们的程序,会无数次直接或间接的访问系统软硬件资源(管理者是OS),本质上,你并没有自己去操作这些软硬件资源,而是必须通过操作系统OS–>你便无数次的陷入内核 (在这里就必定要涉及到,因为你没有资格执行操作系统代码,作为普通用户身份你没有资格,所以你要做两件事情1.切换身份;2.切换页表—>调用内核的代码–>完成访问的动作—>结果返回给用户 (返回时也要做的两个动作1.切换身份;2.切换页表)–>得到结果
》有人会说,你说的不对,就比如说我写C/C++的时候,printf()、open()、cin、cout等函数操作,可是如果我在用户层写出while(1)死循话这种代码呢?我没有调用任何系统级硬件资源,也没有访问系统级的软件资源,难道你会说它也会陷入内核做我们对应的状态切换吗? 我就写个while()循环,我偏不调用你说的打开文件、打开网卡,我偏不调用你说的文件接口、显示IO接口,今天我就写个死循环,这个代码就是我自己写的, 执行while()死循环一定是执行我的,那么我当前有没有陷入内核或者 内核到用户、用户到内核的切换呢?—必须有!—>是你是个死循环,但你也有自己的时间片----> (你再怎么死循环,跑段时间我也要将你剥离下来)时间片到了的时候(到了的时候我们操作系统会收到中断信息,来告诉CPU这货已经跑了很长时间了,赶紧把它切出去,所以此时我们操作系统收到中断,会强制的把你这个进程剥走了)---->状态切换成内核态,更换成内核级页表---->(你说切不切?凭什么?你说上下文保护就保护,凭什么?凭的就是我把状态一切换,那我们现在开始找到操作系统,还不够然后开始,把你这个进程从CPU拿下去的同时)保护上下文,执行调度算法—>选择了新的进程—>恢复新进程的上下文—>切换回用户态,更换成用户级页表—>执行的就是新进程的代码!
》有人说不对呀,ni上面写的知识我这个进程陷入到内核,我陷入到内核我自己把自己剥离了,放到某些等待队列里,你现在告诉我是执行新的代码了呀。同学们别忘了,当你被剥离下来之后,你下次进程再被切回来的时候,执行的就是:恢复新进程的上下文—>切换回用户态,更换成用户级页表—>执行的就是新进程的代码,这一逻辑了。所以你又回重新从内核切换回用户。
》所以即便是你这个进程什么事都没干,照样也要经历内核到用户,用户到内核的切换。
》我们的重点倒不是去理解内核和用户怎么去切换,我们不考虑,我们只要知道这回事,讲这个是为了便于给同学们说道说道,要不然只说一个用户态和内核态的差别,那么对我们来讲太不友好了。

信号捕捉的动作

下面我回到最开始的问题,我们今天在讲的叫做进程在什么时候进行对新信号的处理呢?那么我们说了一个结论叫做:从内核态切换回用户态的时候,做信号的处理。那这个怎么理解呢?画一张图正式学习,信号捕捉的动作。

你的代码有很多行,你的进程呢因为缺陷陷阱或异常或者系统调用或者被我们操作系统调度,反正你的这个进程呢之所以能称作进程,是因为在内核一定有当前进程的PCB。当我们的CPU在执行我们的代码的时候,当执行到某个地方,它一定会因为一些原因,而导致我们进入了内核。所以又要想到用户态 和 内核态了。比如说执行到你的第X行代码,比如这一行是一个open()调用,那么他一定会陷入内核。
》陷入内核怎么办呢?假设这行代码是一个当前打开文件的操作open(),陷入内核态那么操作系统就帮我们去执行调用open()接口,那么执行的就是open()的代码,帮我们做什么呢?帮我们从磁盘当中读取这个文件的属性,在内核当中创建该文件的struct_file结构体,该文件所匹配的inode ,以及该文件所对应的路径信息,全部给我们设置好。设置好之后呢,将我们的新的文件的地址struct_file地址填到当前进程PCB所对应的文件描述符表里面,然后我们将下标返回,进而我就可以继续执行了。这是我们以前讲的。
》今天呢,它把内核级代码open()系统调用执行完之后,比如文件打开了,正常情况下是不是要准备返回到我们用户态代码open()那一行继续往后执行代码。但是呢今天呢,实际上操作系统在准备返回到你用户态open()调用系统之前,就是继续执行你的用户代码open()之后的代码之前,他其实不是这么简单就直接返回了!
》而是,既然我都来到了内核态,我为什么要直接返回呢,我先不返回, 我先返回之前查一查当前进程的信号集Pending && Block。换而言之呢,当它做我们对应的返回之前,本来就在内核态,对于进程PCB的所有结构,其实它都可以对数据做检查,所以在返回时候,就对进程PCB的Pending信号集和Block屏蔽字做检查。做检查,如果Pending信号集里面所有信号都为0,Block屏蔽字里所有bit位都是0,那么就没什么可做的了,也就没有没什么信号要处理,所以此时他就直接返回到我们用户态的open()代码继续向后执行。这是常规情况。
》如果此时,我们再返回的时候发现,有信号产生呢?同学们举一个例子。当我们的进程它在返回的时候呢,它有自己的信号信息,那么其中呢,它的这个进程呢,在返回时,从我们对应的内核态转换成用户态,他顺手可以做检查。检查的时候就可以查你这个进程所对应的Pending信号集、Block屏蔽字、handler方法这3张表。如果发现Pending信号集都为0,则没有信号要处理,则直接返回到用户态,继续向后执行你的代码。那如果检测到呢?
》比如说发现你的当前2号信号是被Pending的,如果发现2号信号是Pending的,但是呢发现你2号信号又是被block,所以呢我们的操作系统呢也没办法给你做任何事情,因为我们这个信号是被block的,所以无法被处理,无法被递达,所以操作系统照样依旧返回。
》那么我们如果是Pending信号为1,并且此时这里的block为0,那说明什么呢?所以当它返回时就发现这个2号信号是1了,并且这个2号信号没有被block,怎么办呢?那我们就处理它呗。然后操作系统就继续找handler表,发现你的handler方法是ignore,就是忽略处理。所以忽略有多简单呢?操作系统说,知道了是忽略,直接该信号在Pending信号集里面由1置为0,然后就处理完了这个信号,紧接着就返回到用户态的代码处。这是第一种方式。第二种方式,比如说我们的1号信号,它对应的方法是Default默认,如果是默认那它就一定会指向操作系统默认的处理办法,一般都是终止这个进程。终止还不简单吗?终止这个进程怎么终止呢?你现在就在内核态里面要终止这个进程,很简单,把这个进程不要再CPU上面跑了,并且把这个进程所有的代码全部释放掉,释放完之后,保留PCB设置为僵尸状态,将PCB内的信号编号填充成我们对应我收到的信号,比如是2,此时退出码一设,然后进程状态设为Z僵尸状态,此时这个进程就退出了,也不需要再返回到用户态了。
》所以对我们来讲,如果进程在返回的时候,做信号的检测处理呢,是一个非常容易的事情,其中呢,如果没有信号就直接返回,若有信号被阻塞就不处理继续返回,若有信号没有被阻塞那么我再处理你,若handler方法是Default就执行操作系统默认方法要么终止进程,该core dump的core dump,直接终止你,说白了就是把你从运行队列里拿下来,并且把代码给你释放了,还要将我收到的信号 写入到PCB的sig变量当中,然后退出码一设置,进程状态设为Z状态,那么该进程就退出了。那么对我们来讲呢,如果是忽略的话,那很简单,将该进程的Pending信号集对应的位置设为0,然后再返回,此时就说完了两种处理信号的方法(或者说是递达状态,一个是默认,一个是忽略)。所以呢,在我们正式讲一些恶心的话之前呢,我们先说一个结论:进程的信号在被合适的时候处理,从我们的内核态返回到用户态的之前,我们先做检查,要处理的去处理。
》我们刚刚讲的前面说的一大堆,还有内核态和用户态说的一大堆,主要是**回答同学们两个问题:1.如何理解内核态和用户态;2.进程的生命周期中,会有很多次机会去陷入内核(中断、陷阱、系统调用、异常…等等),反正呢就是我们可以通过无数次的操作来进入到我们所对应的陷入内核。既然陷入内核就一定会存在很多次的机会进行内核态返回到用户态!!那么其中对我们来讲呢只要你能够从内核态返回到用户态,那么其中救有机会处理这个信号。
》所以呢对我们来讲从内核态返回到用户态,做信号检测。那么你刚刚说了,有信号产生,那么Pending信号集有1,block是0,那我就处理你。block为1说明我收到了Pending信号,不好意思,我没法帮你递达 ,因为你被block了。所以我们前面做了实验,我们将一个信号block了,即便发信号也处理不了。我们写的sleep()函数也要陷入内核,也要从内核态切换回用户态,因为当你休眠的时候,进程不是运行的,不是运行就意味着一定要把你从CPU上剥离下来,剥离下来让你去等待队列里等待,当你sleep时间到了,操作系统再把你从我们的等待队列里放到运行队列里再执行,运行一会儿再把你放到我们的等待队列里,所以每一次slepp都是一对用户到内核,内核到用户的切换。
》其实最恶心的是,那如果我的方法是自定义方法呢?信号的处理动作一共有3种,忽略、默认和自定义。忽略和默认我们虽然还没有给大家正式写过,但我们觉得它的处理相当简单粗暴。自定义动作反而是最恶心的。也就是我们信号机制允许一个用户给自己的进程设置进程信号的自定义方法。那么我们接下来怎么去处理自定义的动作呢?
》首先必须意识到,问一个问题:这里所谓的自定义代码,是属于用户代码还是内核代码?说白了就是这个方法是用户提供还是操作系统提供?答案是:是由我们用户提供的。那么此时呢在你用户的代码里面有一部分是信号捕捉动作叫做void handler(int sig){…}。假设自己的一个进程通过系统调用陷入内核,系统调用进入内核后把代码执行完了,要打开的文件也打开了,操作系统该做的全都做了,做完之后准备返回的时候,首先做PCB检查,对于PCB检查,检查什么呢?那就是检查PCB的3张表。比如Pending表为1,Block表为0,好了,我确认了我收到了一个2号信号,我现在应该处理它了,而且他没有被阻塞,我可以处理了,并且我们的方法不会再是所谓的默认、忽略方法,而变成了用户自定义方法。所以此时我们操作系统就必须得跳转过去执行自己写的代码(内核态是可以访问用户态的数据和代码的),当把代码执行完,信号就处理完了,处理完之后,此时我们能不能从这个状态返回到用户态的代码处继续返回呢?–答案是:不能!
》我来解答一下:我能不能直接回到用户态执行的代码处?我现在处理信号,处理完信号,那么信号处理完,自定义捕捉动作执行完了,那我就直接返回到用户态执行的代码处不就行了吗?一个最简单的道理,我们刚刚打比方说的调用的是open()系统接口,在内核态执行完open()代码准备返回的时候,是不是open()有文件描述符直接返回呀。那么当你返回的时候,你现在准备返回时,打了个岔,跑过去处理自定义捕捉动作了,你直接从这跳转过去,请问open()的返回值你怎么给?有没有呢?没有!你没有给,你也给不到。那么其中呢我们必须得因为这个返回值数据呢,比如文件描述符值fd = 5;这个数据被获取成功了是在操作系统内,如果你在跳转到自定义地方处理信号的代码处,处理完信号你怎么把fd交给用户。对不起没有,所以你必须得通过处理完信号的时候,你当前不能直接由处理信号的逻辑,直接跳转到用户态open()后面的代码,这是不允许的,而且你也做不到。刚刚知识举一个例子告诉大家,因为你曾经调的open()接口它返回的值我们需要用户态的。但是呢还有很多很多的原因,比如说呢你在调用的时候,我们曾经是要做一个系统调用,然后我们要正常返回,正常返回的时候呢,是要把包括我们所对应的状态要切换回用户态,包括数据要返回,此时我们用户层到用户层无法做到这个工作。包括在用户层你无法知道你当时是在代码哪出被切换到内核态的,你不知道被切进来的上下文数据,所以你也没办法去恢复它,而且我们也严重不推荐。
》所以对我们来讲,当它直接跳转到执行自定义方法处的代码,执行完了,它还需要再回去,回到哪儿呢?回到当时做检查的地方(内核数据结构处),信号已经处理完了,处理完怎办, 处理完后走到内核当中,在内核里面通过特定的系统调用,把我们通过特定的返回把我们的代码呢、寄存器状态恢复出来,让他继续再跑到我们对应的当前进程open()那里继续往后执行。
》我就想告诉大家一个事实,当我们在内核态返回用户态时,检查到有对应信号,并且没有被block,而且方法是自定义,所以操作系统就必须得跳转到自定义的方法处,执行你用户自定义的代码。截止到现在,我们同学应该是绝对能听懂的!当我们把信号处理完之后,不能直接返回到曾经的用户层上调用open()的代码处,而是重新回到内核,然后再从内核里正常返回。后半部分:从跳转到用户层执行完自定义方法代码处回到内核再从内核回到用户,估计有的同学还是无法理解,没关系我们先吧前半部分讲清楚。
》也就是说呢,当我们有信号了要处理了。那你就处理呗,处理就直接执行我们自定义方法。
》❓来,下面我们的问题是什么呢:请问当我们执行自定义方法时,以谁的身份执行?很显然我现在是一个内核,我正在做返回时,顺便去检测一下进程的Pending信号集,Block和handler表,发现有一个信号是1, 然后没有被block,方法还是自定义,所以我当前就要去执行自定义方法了,那么此时当我们去执行这个方法时,以谁的身份去执行呢?以我们现在的理解呢,很显然我们要执行这个代码,首先我们直接想到的就是内核。—也就是由我们的
内核身份去执行这部分自定义代码。**执行完之后,我再回到我的在内核里面调用这个方法的地方,然后再返回到用户态交给用户。这不是挺好的吗?
》同学们,内核有没有这个能力呢?答案是:非常有,而且它想做它一定能做到,因为内核态本身权限级别一定比用户态高的多。它有没有能力去访问你这部分代码呢,说白了在地址空间上有 3~4G的地址空间直接返回到我们的用户态的代码。这里呢对我们来讲,操作系统一定可以做到,但是,这就是我们前面就提到了,当前的内核,它就是能把用户级页表换成内核级页表,那么他在自己内部也照样也可以直接把内核级页表换成用户级页表,切换成用户级页表,然后CPU的身份不变,身份就是0号身份,也就是内核态,它能把自己用户级页表放下去,它也能把用户级页表放上去,所以这都没问题,如果愿意它都可以。但是但是,不好意思,并不能用内核身份来做!
只能用叫做用户级身份,那么就有同学纳闷了,你说我操作系统现在识别到有信号并且没有被block,还要去执行自定义方法,那我顺手把你代码执行一下,然后执行完之后再返回不是挺好的吗?那为什么我们不能够直接去以内核身份去执行这部分代码呢?
》**原因非常简单!因为这部分代码是用户写的!**假设我们执行信号捕捉自定义函数时用的是内核的身份,然后我今天就直接信号捕捉的代码,那个方法里面可是什么东西呀,我什么给你写成程序替换,或者我直接给你调用什么rm命令,给你删除我们根目录下的所有文件,然后包括给你做一些曾经我的用户身份没有权限做的事情。别忘了它可是操作系统内核,它什么都能做,它什么都能做那其中对我们来讲呢,我们用户写的代码,如果此时写的是恶意代码,而且你还用内核的身份去执行,会不会有问题呢?是不是操作系统内核的身份就被人利用了!
》所以内核它可以,但是我不愿意!因为如果我以内核的身份,我又不相信你任何的用户写的代码,万一你写的是恶意代码,是不是相当于被狸猫换太子了,让操作系统做了一件你本来没有权限做的事情,所以在这里不能用内核身份只能用用户身份,所以这就是内核保护自己。所以换而言之,当你从内核里面跳转到执行自定义方法代码处,**也伴随着内核态切换回用户态的身份!**当我们操作系统识别到自定义方法后,我们再从内核态切换回用户态,以用户态的身份去执行自定义方法,执行完之后,再返回到我们内核里面,此时返回的时候这个过程是自动的。因为signal()方法调完的时候,操作系统是在编译器编译的时候给你设置了很多东西,包括它其实它也有自己的方式,当它在返回时要再做一件事,因为你不能直接返回到用户的代码处,即在用户层的open()调用的地方。所以只能再返回到内核,此时又涉及到用户态切换回内核态。
》所以对我们来讲,我们发现,如果我现在因为一些系统调用,此时我就由用户态变成了内核态;当它内核里系统接口代码调完了,准备检查3张表,发现Pending表有1,Block表为0,handler方法是自定义,然后跑过去执行自定义方法,身份又从内核换成用户,然后我们以用户身份去执行自定义方法,执行完后,再回到内核,回到内核,第一系统调用这个接口调完了;第二这个信号处理完了,然后此时再内核里再由我们对应的经过特殊返回,返回到曾经调用open()接口的代码处,此时又要涉及到内核态切换回用户态。至此,这就是信号的捕捉过程。
》然后我们结论的一句话:进程的信号在合适的时候被处理,什么合适的时候呢?在内核态返回用户态的时候,做检查,如果有必要做处理。这就是信号捕捉哈。
在这里插入图片描述
在这里插入图片描述
✳️快速记忆方法
我们用户态有自己的代码,然后在用户态的代码里面有一部分是我用户写的自定义捕捉信号代码,我在用户态执行我的代码时,因为某些原因我要陷入内核了。陷入内核执行完我们的代码,然后呢顺便做一下信号检测,检查的时候,发现我们是需要做用户级处理的,然后切换到我们对应的用户态,执行完对应的处理方法,执行完之后,再经过操作系统调用返回到内核,返回到内核后,再经过特殊的系统调用返回到用户态当时陷入内核的代码处继续向后执行,这就是最好呢哥哥处理过程,这就是我们谈论的捕捉自定义捕捉信号的处理过程。
在这里插入图片描述
好我们再继续,我们把图再快速画一下,我们处理信号过程的画法就是这样的。
在这里插入图片描述
这是什么东西呀?叫无穷大。我们再画一根横线,一定要将交点放在横线的下方。横线往上是用户态,横线往下内核态。
》我们因为一些系统调用陷入到内核,然后执行完陷入内核的代码,准备返回时,交点处就是表示,做信号检测。若没有信号要处理直接从这个交点处返回到用户态继续向后执行代码。如果此时走到交点处需要自定义捕捉,就要到用户层调用用户写的自定义方法代码,执行完之后,还要再次陷入内核,通过系统的特殊调用再返回到用户态执行一开始陷入内核的代码处继续向后执行。
》这个无穷大的画法顺序,就是整个信号的处理过程。更重要的是,一会儿从用户到内核,一会儿内核到用户,状态变化变来变去的,那么其中,我画一个无穷大也要注意画法的顺序,以贯穿一条横线的无穷大,将无穷大的交点放在横线下侧,当前我们的无穷大和对应的横线有多少个交点,就证明有多少次的状态切换,所以我们一数发现有4个交点,那么就是有4次状态切换,并且方向决定了时从内核到用户和用户到内核。

在这里插入图片描述
》所以信号捕捉过程就是一个无穷大的画法加一根横线。然后无穷大的画法就使得方向就全有了,各种用户到内核,内核到用户的状态切换,画一根横线,将交点置于横线下侧,里面有几个交点,就证明有几次的状态切换。
》信号捕捉过程呢,我们知道一个时机,叫做从内核态返回到用户态的时候,操作系统顺手就检测了这个进程,检测当前进程又没有信号,要不要处理。其实操作系统在返回的时候,没有你想的那么简单,除了检测信号之外,它有时候也会检测 当前进程的时间片到了没,如果他的时间片,甚至还会检测运行队列里有没有优先级更高的进程,所以当它在返回的时候,除了检测你信号之外,还可能会引发进程切换,那么他在这个时候也会做很多检查。不要简单就认为操作系统就是简单的调用再返回,它返回的时候也会做很多事情。
》第二个呢,我们的信号捕捉动作呢,就是一个无穷大再拉一根横线,4个交点就有4次状态切换, 那么如上就是信号的处理,叫信号的处理后,也叫如何捕捉信号。那么下面再正式学习信号捕捉的函数。

学习信号捕捉函数

Pending信号集我们有sigpending()函数来获取,Block信号集有sigprocmask()函数来获取。那么handler表呢?handler表不是已经有了一个signal()函数了吗?这个signal()函数有点简单,我们还有一个接口叫做sigaction()

sigaction()函数
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
函数介绍: 
它呢用来对特定信号设置自定义捕捉。这个接口和signal()方法是一摸一样的

函数参数介绍:
🌟第一个参数 signum:代表的就是,我们要对哪个信号实施用户自定义捕捉。

🌟第二个参数 act:在仔细看的时候,知道sigaction()是一个函数,它的内部参数里面也有一个struct sigaction,这个结构体的名字和函数名是一样的,
并且第二个参数叫做act,第三个参数名叫做oact。当我们看到这个的时候应该会觉得似曾相识。

struct sigaction结构体里面 struct sigaction {
void (*sa_handler------>(就是我们传说中的对信号的一个处理方法,说白了自定义方法写在这个里面,充当我们的回调))(int);
void (*sa_sigaction---->(函数指针,不处理它))(int, siginfo_t *---->(实时信号结构体我们不考虑), void *);
sigset_t sa_mask;---->(稍后会解释)
int sa_flags----->(设为0我们也不管他);
void (*sa_restorer----->(这个也不管,设为NULL就可以))(void);
};

其中这个函数除了能处理普通信号,实时信号它也能处理,但我们不考虑实时信号,所以结构体里面很多字段我们不考虑。哪些不考虑呢?上面结构体里面说了。所以我们只考虑sa_handler和sa_mask。
》接下来我们正式认识一下sigacrion()函数。其中对我们来讲sigaction()函数它的作用是什么呢?
》可以更改指定相关联的默认动作,设置成功就为0,失败就返回-1。
》它的第二个函数参数 act:就是你要设置成什么动作,第三个参数呢就是我的老的动作是什么。如果老的动作不要就设为NULL,要的话就传入一个对象进去。此时呢就跟我们前面讲的sigprocmask()的函数参数有点类似 。
》对我们来讲结构体的第一个字段sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值常数SIG_DFL表示执行默认动作,赋值一个具体的函数指针表示自定义信号捕捉。其中对我们来讲呢,我们先来把这个函数用一用,然后我们再来谈谈其他选项。
》我们先准备两个struct action类型的对象act、oact为后续的函数穿参做准备。其中该结构体有很多字段我们不用,如果不用就设为特定值。然后我们就来捕捉信号用到sigaction()函数,我们来捕捉2号信号,动作呢就是&act,老的信号捕捉方法就是&oact ,我们至此就完成了对2号信号的捕捉。然后你前面不是说要自定义捕捉嘛那就把handler方法写一下。 写完之后,我们写一个死循环。然后运行代码。运行后呢,进程一开始没有什么信号,然后我们给他发信号,也看到确实能去执行我们的handler方法,也就是能够对2号信号做捕捉。 顺便我们把其他的也一讲。
》如果你想去执行所谓的默认动作,其实默认动作他本身就是默认的 。那么我们就可以讲act.sa_hadnler改为act.sa_handler = SIG_IGN;SIG_IGN就是一个宏,它其实就是把1强转成我们对应的指针类型。这就是忽略信号处理动作。那么就还有act.sa_handler = SIG_DFL,默认处理动作。
》struct sigaction结构体里面,你说sa_sigaction字段是实时信号我们不考虑,然后sa_restorer字段我们也不考虑,sa_flags默认为0我们也不考虑 。那这个sa_mask是什么意思呢?
》好,让我们来认识一下 。当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 (我们可以试试,根据我们以前所学的内容,我们先在自定义的handler方法里面加一个sleep(20),这个目的是为了增加处理handler信号的时间,只要在handler函数里面都叫做正在处理这个信号,这是其一,其二呢,正在处理这个信号的时候,我们如果再给它发同样的信号,比如2号信号,这个2号信号不会因为你曾经设置了对2号信号的捕捉,而重复调用这个函数,因为它被block了,那是不是这样呢?截止到现在,我们的进程都是单进程,它的执行流呢因为某些信号来了,跑过去执行处理信号的时候,在main()函数的死循环它也是不会去执行的。当然我们也可以在我们自己写的handler方法里面用sigpending()函数获取一下信号集。我们定义一个sigset_t sigpending给我们的sigpending()函数作为参数,获取Pending之后呢,再在handler方法里面写一个while(true),将sigpending()函数放进去不断获取当前进程的信号集。我们回头看看,一旦我收到2号信号后,我就回不到main()函数里面了,回不来干什么呢?我自己不断的在handler方法里面执行死循环。我们现在让我们的代码跑起来,跑起来之后呢,刚开始在执行main()函数while循环里面的"main running",接下来我就发2号信号,收到之后呢就去执行我们handler方法,执行的时候不好意思,handler方法永远都不会退出,意味着我们永远都会正在处理2号信号。下面的问题是,当它正在处理2号信号期间,如果再来2号信号呢?那么这个2号信号默认是被屏蔽的!也就是我们不允许我们的普通信号被重复的提交。如果我们还想再屏蔽器其他信号的话,那么我就用到的了sa_mask的字段了!怎么操作呢?sigaddset(&act.sa_mask, 3)所以这次当我们正在处理2号信号的时候,多次发送是默认会屏蔽掉2号信号,那么用了这个,那么同时也会帮你把3号信号也屏蔽掉!这就是有sa_mask的意义。此时我们再运行代码,我们多次发送2号信号的话,从第二次开始会屏蔽2号信号,因为你看到Pending位图上2号位置是1,接下来我们再发送3号信号,对不起3号信号也被拦截了!因为我们设置了,当正在捕捉2号信号的时候,我们也想把3号信号拦住屏蔽掉。)
》所以我们的sigaction函数更大的意义在于,当我们实际在做信号处理的时,操作系统不允许你嵌套式的同时在递归式的处理多个信号。因为你想想,平时我们自己写代码的时候,程序崩溃或异常了,你疯狂摁 ctrl ➕ C,一次发了几十几百个,如果此时我们对2号信号做捕捉这些接口呢都要嵌套式响应,那么问题就大了。另外自定义方法里面也可能调系统调用,所以呢也有无数次从内核返回到用户态过程,所以我们绝对不能让一个信号被递归式处理,而是最多让你串形处理!
》我们下面再说一个小小的技巧,未来呢我可能要对很多的信号进行捕捉处理,比如说你将来就想对特定信号做捕捉处理,但是信号太多了,那我们怎么去编写一个合理的方法,然后对信号做处理呢?所以我们可以这么干,比如我想捕捉2号信号,然后呢我有一个方法Handler方法,然后呢再加上3、4、5号信号做捕捉,然后呢你可以对所有的信号做不同的处理方法,但不同的方法维护起来可能会难受一些,此时呢我们就可以来对他们做一个,称作自定义方法。我们让所有信号注册同一种方法,然后我们void Handler(int sig),接下来就有两种写法:
》第一种switch,可能你项目里也就用了几个信号,所以你此时就只需要几个case就好了。此时对我们来讲呢,handler方法做一个统一的方法,任何将来的信号,我都只注册一个方法,然后根据你信号编号去设置不同的回调方法。其中就会有同学说你这不是扯淡吗?那么为什么不直接注册信号的时候,把Handler2()、Handler3()…分别注册在signal(2, Handler2)…这个地方呢?这样子可以的,但是这样的话代码耦合度、维护成本有点高。后面我们就写Handler2、Handler3…等等的实现。这个就是我个人比较推荐大家以后做信号捕捉的方法。
》第二种将所有的方法都push到一个vector里面,在signal里面有一个sighandler_t的类型,函数指针类型。所以我们就可以写vector<sighandler_t> handler。后面呢你就可以写各种各样的方法,然后处理时,用信号编号做vector的下标,但这个做法有局限性,这需要你对所有的信号进行捕捉。但其实我们又可以用unorder_map<int, sighandler>来弄了。
》 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号蔽字。 sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段,有兴趣的同学可以在了解一下
》所以我们信号捕捉方面全部讲完。

Makefile:
mysignal:mysignal.cc
	g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
	rm -f mysignal
------------------------------------------------------------------------
mysignal.cc:
void handler(int sig)
{
	cout << “获取到一个信号,信号的编号:” << sig << endl;
	sigset_t pendings;
	while(true)//意味着永远都在处理2号信号
	{
		sigpending(&pendings);
		//打印Pending信号集内的内容
		for(int i =1; i <= 31; i++)
		{
			if(sigismember(&pendings, i))cout << "1";
			else cout << "0";
		} 
		cout << endl;
		sleep(1); 	
	}
	sleep(20)//先睡20秒,增加我们处理信号的时间
}

int main()
{
	struct sigaction act, oact;----➡️为sigaction()函数传参做准备
	/*act.sa_handler = handler;*/ //自定义处理动作
	/*act.sa_handler = SIG_IGN;//忽略处理动作*/
	act.sa_handler = SIG_DFL;//默认处理动作
	act.sa_flags = 0;----➡️对于不用的字段我们设为特定值
	sigemptyset(&act.sa_mask);
	//还有一些其他字段我们暂时先不管

	sigaction(2, &act, &oact);//就完成了对2号信号的捕捉
	
	while(true)
	{
		cout << "main running" << endl;
		sleep(1);
	}
	return 0;


上面提到的小技巧:

void Handler2()
{
	......
}
void Handler3()
{
	.....
}

void Handler(int sig)
{
	switch(sig)
	{
		case 2:
			Handler2(sig);
			break;
		case 3:
			Handler3(sig);
			break;
			Handler(sig)
		case 4:
			Handler(sig)
			break;
		case 5:
			Handler(sig);
			break;
}

signal(2, Handler)
signal(3, Handler)
signal(4, Handler)
signal(5, Handler);

可重入函数

现在呢我们对信号呢,有所能够处理了,下面给大家解释一个场景,这个概念我们未来会在多线程要用的。

这里是一个node结点,不知道你们还记不记得你们学习链表的时候,那里有一个叫做链表头插。其中呢链表分带头节点和不带头节点的链表。给大家说一下,带头的和不带头的单链表,写起来呢,是带头的单链表更好写,因为它不需要初始化链表,包括insert时候处理指针传参的问题等。
》下面对于现在的这张链表,如果我们想采用头插的方式向链表当中进行头插。假设我们今天带头节点更简单一点,头节点叫做hand。
》假如我想插入新节点怎么插入的?你得首先有一个新节点new_node,然后让这个节点的next直接指向hand的下一个。让后hand节点的next指向new_node,至此我们就完成了一个头插。改变指向的问题本质上就是修改指针的内容。如果我们把这两条语句用所谓的C/C++写出来,得先有创建newnode的过程,然后就是newnode->next = hand -> next;所以我把我hand下一个节点的地址写到了newnode里面,此时就完成了第一步。现在呢我要开始newnode头插进去了,所以hand -> = newnode;把newnode的地址放入到 hand的next里面,这就完成了第二步。
》下面我们要讲的问题是,假设今天的代码里面,main()函数,main()执行流正在执行链表的insert(node1),它要把node1的结点头插到链表里,调用的函数是insert,同时呢对应的main()函数代码,也有一个sighandler()方法,信号的捕捉方法嘛,它里面也要进行insert(node2)。那么就以它为例,相当于什么呢?在我们的main()函数执行流里面有一个链表头插,在我们signalhandler()信号捕捉呢又有一个插入node2结点的过程。同学们相当于一个方法呢,即可能在main()函数里被调用,又可能在我们的sighandler()方法里面被调用,那么此时我们来算一算。
》现在有2个节点,然后你现在main()函数不是调用insert(node1)插入node1吗?那我就插入node1呗,那就先让node1的naxt指向hand的下一个节点。可是同学不好意思,当我们正在执行hand -> next = node1的时候,也就是当前在main()函数里面执行,因为你这个进程的时间片到了,那么这个进程被切走了,也就是把node1 -> next = hand -> next指向完,下次回来的时候想执行到hand -> next = node1这里,但是当他被切走之后,它大概等一小会儿,操作系统又把你拿回来了,拿回来是不是就要从内核态返回到用户态,完成进程的再一次恢复的过程。所以一定要涉及从内核到用户的切换,可是在从内核切换回用户态的时候,检测到这个进程还收到了一个信号,比如2号信号,然后呢在正式返回到hand -> next = node1之前,要先去执行sighandler()方法。怎么办呢?去处理呗,所以不好意思,sighandler()里面也有一个insert方法,换而言之在这个时刻这个insert方法既在main()函数里被调用,又在sighandler()方法里面被调用。此时这一个insert方法既被main()执行流去调用,又被sighandler()信号捕捉流被调用。
》那么这个被多个执行流重复进入的现象,我们叫做该函数被重入了! 说白了什么叫做重入,就是被重复进入。
》以前在我们学习单进程下的,没有信号的时候,我们写的代码,重上到下,会存在这样的问题吗?我们那时候信号听都没听过,所以根本不存在这样的问题。但今天就有了。
》那就正常的去处理信号呗,然后sighandler()的inset插入后,我node2也指向原先hand下一个的结点,所以node1和node2的写一个是同一个了。在node2的next被插入好后,没人打扰他,然后他紧接着就让hand -> next = node2,至此就完成了对node2对头插工作。sighandler()方法处理完后,最后返回到曾经插入node1时的代码处,因为node1是在hand -> next = node1这一步被切走的,回来后继续把这步完成。所以把hand指向由node2,变成了自己node1。
》所以对于我们main()函数而言,它成功的把node1头插了,但曾经在自定义信号捕捉的函数里插入的node2没了!这个现象叫什么呢?我们此时发现,本来我们已经把node2插入进去了,此时呢一旦恢复到main()执行流,那么接着执行完未完成的insetr代码后,node2就出现了该结点找不到的情况了。
》造成了结点的丢失,所以这叫做内存泄露,这种级别的内存泄露概率非常低,因为他要有无数个巧合,但是软件的运行速度太快了,而且软件动不动几年运行,你能保证在这么长的时间里不会出现这种情况吗? 而且我们还是举的例子,未来还有没有可能出现其他情况呢?
》所以在这种情况下,我们本质上是因为,在不同的main()执行流和信号捕捉执行流里,都调用了insert()方法。这种情况我们称该insert()方法叫做我们该函数被重入了。那它有没有什么问题呢?这种出现重入的问题,导致程序运行结果不正确,我们称该函数为不可重入函数。
》反之如果一个函数只访问自己的局部变量或参数,再重复被多个执行流调用的时候,不会出现任何问题,这种函数叫做可重入函数。
》重入和可重入函数不是强调对错,而是在强调该函数的特点,我们在未来和之前学的大部分函数,90%的函数全部都是不可重入的,也称作不能被多个执行流同时调用的。我们在C++的STL里面大部分接口,都是不可重入的,所以它是一种特性。我们未来大部分用的和我们自己写的函数确实都是不可重入函数,但是也是会存在一些可重入的函数。
在这里插入图片描述
》像可重入的函数呢对我们今天而言,我们暂时不考虑。
》我们为什么要说呢?原因在于在我们之前的写代码里面,全部的逻辑都是单进程的,包括我们讲的信号,他有多个执行流,但依旧属于这个进程的上下文。它是由于我们操作系统的状态切换,然后执行不同的代码块而导致的一种伪的多执行流情况,其实他还是在一个进程的上下文里,但是确实会面临着我们可重入的问题。 我们还不用担心,因为目前我们还都是在单进程的环境里面,不会出错的。那如果我们将来学习了多线程呢?在将来我们学习的多线程里面有多个执行流的时候,一个函数被多个线程共享,所以一个函数有极大概率被多线程同时调用,那么重入的现象就会存在。
》但一般同学们呢现在呢也不用担心,我们想想刚刚的现象为什么会出现可重入的现象呢?主要是我们用了一个全局的链表 ,这个链表呢是被main()函数和sighandler()方法都能看到。
》然后一般一个函数不可重入,主要是因为调用了malloc/free或者用了标准的IO库函数,包括C++一些STL容器,大部分只要使用了特定的数据结构,而且数据结构的增删查改操作,指针操作,太多了就有可能不可重入。

volatile—保证内存可见性

volatile是我们C语言当中非常重要的一个关键字,今天呢也不讲语言,而且volatile有很多特性,今天重点想通过信号场景来给同学们把volatile关键字讲讲他是在干什么的。
》下面我们做一个小小的例子:
》其中对我们同学来讲呢,我们写了一个flags = 0;在逻辑反这里呢,就永远为真,那么while条件就永远成立,我们下面呢在做一个小工作,我们用signal()函数,用自定义方法handler()将全局的flags值改为1。
》我们现在运行代码,刚开始while条件一直为真,然后过个几秒钟我们给他发2号信号,发了之后,这个flags值一定是由0变为1,当自定义捕捉函数执行完会回到while循环处来继续进行我们while条件判断,刚开始flags = 0,逻辑反就为真,循环变永远继续,但一旦2号信号执行了,flags被改为1,然后再flags逻辑反就为假了,while循环退出,打印下面的一句话。正如我们所说。
》刚刚的现象是我们的预期现象,它是符合我们标准的,但是我们并不排除同样的一份代码,将来在不同的编译器下,甚至在不同的编译级别之下。其中呢我们gcc有特别多的选项,我们学的选项应该就有五六个了,除此之外呢,还有一些选项,比如说- O1、-O2、-O3…等这些O呢代表优化级别,一般呢我们优化级别越高,编译器在自己编译代码时, 会自动对你的代码做逻辑检查。
》比如说,编译器在看你这份代码时,它呢只知道你的代码块里发现一个问题,对于全局变量flags,当前while循环只是做检查,没有做修改。没有做修改的话,编译器就极有可能将这个flags优化。优化到哪里呢?每次while条件判断,读取flags。 while检查是计算吗?循环检查不就是比较吗,这叫做逻辑判断,判断真假,所以是逻辑运算,是运算。既然是计算了,那就只能由CPU执行,由CPU执行,那么默认检查的情况下 ,他是从你当前的值读进CPU,读进去做判断,然后得到结果。每次循环检查都要读下这个值,这是从内存读到CPU的,正常情况下呢,你编译器就应该这么做。但是你的编译器在一些很高的优化级别里,因为不同的编译器优化的级别不一样,这就和编译器有关了。
》我们用的VS、gcc等等编译器,不管怎么样,此时不同的编译器, 在编译的时候,优化级别由差别。有的编译器里面再main()执行流里面对这个flags没有做任何修改,所以它会很自作聪明的帮你做一件事情,叫做:把你的flags值呢优化到寄存器里面,因为默认情况下这个值就是0, 所以它优化到寄存器里,从此在做while循环检测时,他只做一件事情,它只从寄存器里面进行读取,只做数据读取,所以寄存器里面的值就不会做修改了,while循环永远成立。
》但你一旦有信号来了,把flags改为1了,对不起,你只是改的内存的值,改完之后,寄存器的值,因为优化了,编译器并不知道你是有多执行流的,因为你没有在你的main()函数里调用handler()方法,你只是设置了这个方法。编译器只能检测语法,不能检测逻辑,所以对于编译器来讲,就自作聪明的把这个flags优化到寄存器里面,而如果此时你调用handler()把flags值改为1了,改的只是内存的值。
》改完之后,回到while循环判断,依旧是用的寄存器的flags值0,有可能造成和我们预期不一样的现象。怎么办呢?
告诉编译器,不准对我们的flags做任何优化,每次CPU做计算的时候,那内存的数据,都必须在内存中拿!!
》换句话说,如果你将flags优化到了寄存器里,CPU拿寄存器里面的值做检查一定是不断的在CPU和寄存器之间循环,而覆盖掉了我们内存,看不到我们内存当中的数据,那么这种现象呢,叫做我们的寄存器并没有保存保证内存的可见性。如果此时我们编译器优化时,你CPU不准读取数据时,你必须把数据读取到寄存器里,然后再从寄存器里面判断,再下次检测你必须得读到CPU内部,然后再做检查,每次操作必须得从内存里面拿。这种就叫做保持内存的可加性。
》可是呢我们该怎么做到你说的让我们某一个变量始终是禁止编译器优化的,必须得以内存的方式,在内存当中读取,怎么办呢?
》那么就在定义变量的时候给它添加volatile关键字:volatile int flags = 0;那么这个动作就是保持内存的可见性!其中就是禁止编译器优化到寄存器当中,让CPU只在寄存器里面读,不准出现这种现象,每次做检查的时候,必须得给我规规矩矩的在内存里面拿。

volatile 和 const这两个关键字可以同时修饰同一个变量吗?含义冲突吗?该变量代表什么含义?
》这两个关键字在编译器级别是可以同时修饰一个变量的,而且两个关键字并不冲突。volatile俗称易变关键字,而const用来修饰常量。名字上看到这两个关键字好像冲突,实际上不冲突而且互相补充的。教材翻译成易变其实不太好。volatile代表必须得在内存里面读,const代表不能被修改,所以并不冲突。

volatile int flags = 0;

void handler(int sig)
{
	flags = 1;
	printf("更改flags:0 -> 1\n");
}

int main()
{
	signal(2, handler);
	while(!flags );
	printf("进程是正常退出的\n");
	return 0;
}

了解最后一个话题:SIGCHLD信号

我们以前在讲,当父进程创建子进程,子进程退出的时候,父进程是不是得必须等待子进程,父进程必须得等待子进程呢,那么对我们来讲就意味着必须得主动的去等待这个子进程,不等待就有僵尸嘛对不对,但是今天想告诉各位同学一个结论,当然接下来内容仅对Linux负责。
》关于进程退出:
子进程退出的时候,不是同学们想的那样,默默的退出(父进程阻塞式调用,回收子进程资源,不是你们想的那样,它退出的时候很高调),自动给父进程发送SIGCHLD信号。
》如何证明?
》有同学说那不是很简单,我把子进程杀掉不就完了?同学们没你想的那么简单,因为你把子进程的杀掉了,给我们父进程发了SIGCHLD,但是父进程对SIGCHLD的处理动作是忽略的,那你什么也看不到呀。那怎么办呢?我们可以让父进程给自己注册一个对SIGCHLD的捕捉,然后子进程创建出来之后,把子进程干掉,干掉之后我们就能看到父进程会自动执行它的信号捕捉方法,如果执行了,就说明它收到了我们对应的我们称之为SIGCHLD的信号。所以证明这个事情是不难的。所以我们应该要具备猜测只是和验证知识的能力了。
》代码写完后,我们运行起来,我发送信号,应该要看到两个现象,第一个子进程确实没了;第二个我们此时父进程会调用自定义捕捉信号打一句话。
》确实如我们所料。
》我们在查看详细SIGCHLD信号讲解的时候,它里面说了一句话,如果是17号信号,我们子进程可能stop或teminal,刚刚我们验证的是termianl终止,哪还有stop暂停呢,那怎么去把这个进程暂停呢?我们在讲进程状态很早之前,我们讲过一个信号叫做19号信号,叫做暂停进程,那么我们再启动程序给子进程发19号信号。
》确实也如我们所料。当我们解除对他的停止既发送18号信号,也发现给父进程发了17号信号。所以不仅仅如我们前面所说只有当子进程退出时会给父进程发信号,当一个子进程被暂停和暂停后在让其继续运行的时候都会给父进程发同样的17号信号。

void handler(int sig)
{
	cout << "子进程退出了,我收到的信号是:" << sig << endl;
}


int main()
{
	signal(SIGCHLD, handler);
	pid_t id = fork();
	if(id == 0)
	{
		while(true)
		{
			cout << "我是子进程" << endl;
			sleep(1);
		}
		exit(0);
	}
	while(true)
	{
		cout << "我是父进程" << endl;
		sleep(1);
	}
	return 0;
}

有什么用呢?

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值