【Linux】进程信号 --- 信号的产生 保存 捕捉递达-1

本文涉及的产品
公网NAT网关,每月750个小时 15CU
简介: 【Linux】进程信号 --- 信号的产生 保存 捕捉递达-1

被爱情困住的是傻子

d1bba957345f439996199da5a6082e05.jpeg


一、信号的预备知识

1.通过生活例子来理解信号


1.

关于信号这个话题我们其实并不陌生,早在以前的时候,我们想要杀死某个后台进程的时候,无法通过ctrl+c热键终止进程时,我们就会通过kill -9的命令来杀死信号。

查看信号也比较简单,通过kill -l命令就可以查看信号的种类,虽然最大的信号编号是64,但实际上所有信号只有62个信号,1-31是普通信号,34-64是实时信号,这篇博文不对实时信号做讨论,只讨论普通信号,感兴趣的老铁可以自己下去研究一下。

ba6121dbb82d4a9583d1a260a6157d22.png


2.

在生活中不乏关于信号的场景,比如红绿灯,闹钟,手机消息提示音,上课的铃声,田径场起跑的枪声等等,那么信号从产生到被处理的具体过程是怎么样的呢?


我们拿红绿灯来举例子,首先我们能够认识红绿灯其实是因为有人教育过我们,我们的大脑中有红灯停绿灯行的意识,其次如果我们站在马路对面,现在已经绿灯亮起了,我们可以选择忽略这件事,也可以选择先不管他,我现在正打王者呢,绿灯亮不亮和我没关系,我都被推到高地了,此时由于我们有着更重要的事情要去做,所以我们先把处理红绿灯信号这件事放在脑后了,也就是他的优先级比较低,等会儿再说红绿灯的事情,但我们的脑海中是有红绿灯这回事的,这个信号被保存在了我们的脑海里,等我们王者打完之后,我们想起来原来红绿灯亮了啊,此时我们忙完别的事之后,我们要进行过马路了,也就是处理红绿灯信号,处理时我们也可以分为三种处理行为,一般情况下的默认行为就是大家都绿灯过马路了,那我也跟着过马路吧,另一种行为就是忽略,我们处理红绿灯信号这件事了吗?处理了,我们选择忽略这件事,继续开下一把排位赛,这也

是我们的处理行为。最后一种就是自定义行为,假设你的妈妈从小告诉你在绿灯亮的时候,你要先在马路边跳一段舞,然后在过马路,所以当别人绿灯亮的时候,其他人的默认行为就是直接过马路,你先来旁边跳起来了,这就是自定义行为。


所以在信号产生和信号被递达处理之间,还有一个时间窗口,这个时间窗口其实就是用来保存信号,因为我当前正在做别的事情呢,处理红绿灯什么的等会儿再说,等我忙完的。忙完之后,在进行信号处理时,我们的行为可以选择默认或忽略或自定义等行为。

从我们暂时不处理信号,把这件事先排到后面来看,其实可以看出,信号的产生和我们当前做的事情是异步的,也就是说,两者相当于两个执行流,互相是不影响的,信号该发送就发送,我该做啥事就做啥事,等我做完之后再去处理你这个信号。


2.迁移到进程上来理解信号


1.

如果将这样的生活例子迁移到进程上呢?其实道理是类似的。

进程为什么能够认识信号呢?其实是由于编写系统代码的程序员所规定的,程序员让进程能够对不同的信号产生不同的响应。进程本质上就是程序员所写的属性和逻辑的集合,信号的含义都是程序员所赋予的,而且进程这样的数据结构也是程序员所建立出来的,所以进程能够认识信号,本质上就是程序员告诉他的。

信号是发送给进程的,那么进程能不能先不处理这个信号呢?比如当前进程正在处理别的信号,或者进程此时被挂起了并未处于运行状态,那么如果这个时候操作系统给进程发送信号呢?进程都不运行了,还处理啥信号啊?诸如以上这样的情况,进程都不会立即处理该信号,那么在到信号被递达处理之前这段时间窗口,信号就会被保存起来,等到进程在合适的时候去递达处理该信号。所以,进程当前正在做的事情和处理信号这件事依旧是异步的。

当进程已经到了合适的时候,进程会去处理这个信号,处理的行为也是三种,默认,忽略,自定义,大概有60%的信号的默认处理动作都是Term终止进程。进程处理信号这个动作,有专业的名词叫信号捕捉或者是信号递达。


2.

在有了上面的认识之后,我们可以用现有的知识推导出一些结论。

我们知道信号是发送给进程的,如果进程当前并不处理这个信号,那么信号就需要被保存,以便于将来在合适的时候处理该信号,那么这个信号应该被保存在哪里呢?其实应该被保存在PCB struct task_struct{}里面,进程收到了哪些信号,进程要对信号做怎样的处理,这些信息都属于进程的信息,那么这些信息就理应被保存在PCB里面。

话又说回来,既然信号需要被保存,那么信号应该被保存在哪里呢?其实在PCB里面有对应的信号位图,操作系统用信号位图来保存信号的,31个普通信号,我们可以选择用32个比特位的unsigned int signal整数来进行保存。比特位的编号代表信号的编号,比特位的0或1代表进程是否接收到该信号。


3.

那么信号发送其实就好理解了,所谓的信号发送实质上就是修改PCB种对应的信号位图结构,将对应的比特位编号由0置1,这样就完成了进程对于信号的接收了。


另一方面,PCB是内核数据结构,修改位图其实就是修改内核数据结构,想要访问硬件或内核系统资源,则一定绕不开操作系统,因为操作系统是软硬件资源的管理者,那么修改位图这件事也一定绕不开操作系统,而操作系统为了保证自身和他管理的成员的安全性,所以他必须提供系统调用接口,让用户按照操作系统的意愿来访问内核资源或硬件,不能随意的想怎么访问就怎么访问。所以如果我们作为用户想要向进程发送信号,那么就一定得通过系统调用接口来完成这样的工作,所以我们以前所用到的kill指令,其底层一定需要调用系统调用接口。



二、信号的发送(修改PCB的信号位图)

1.通过键盘发送信号(kill指令 和 热键)


1.

最常用的发送信号方式就是一个热键ctrl+c,这个组合键其实会被操作系统解释成2号信号SIGINT,通过man 7 signal就可以查看到对应的信号和其默认处理行为等等信息。

我们并未对2号信号做任何特殊处理,所以进程处理2号信号的默认动作就是Term,也就是终止进程。平常在我们终止前台进程的时候,大家的第一感受就是只要我们按下组合键ctrl+c,进程就会被立马终止,所以我们感觉进程应该是立马处理了我们发送的信号啊,怎么能是待会儿处理这个信号呢?值得注意的是,我们的感官灵敏度和CPU的灵敏度是不在同一个level的,我们直觉感受到进程是立马处理该信号的,但其实很大可能进程等待了几十毫秒或几百毫秒,而这个过程我们是无法感受到的,但事实就是如此,进程需要保存信号等待合适的时候再去处理信号。

04a6f28816544a028cc4e238a54fb2d2.png


2.

下面介绍一个接口叫做signal,它可以用来捕捉对应的信号,让进程在递达处理信号时不再遵循默认动作,而是按照我们所设定的方法函数进行递达处理,这个自定义的方法函数就是handler,signal的第二个参数其实就是接收返回值为void参数为int的函数的函数指针,所以在使用handler时我们需要传信号编号和处理该信号编号时所遵循的自定义方法的函数名即可。

signal函数的返回值我们一般不关注,signal函数调用成功时返回handler方法的函数指针,调用失败则返回SIG_ERR宏。

6d91e9f1212f4016b6bb284ff99fb9fc.png


SIG_ERR宏其实就是-1整型被强转成函数指针类型,其余的两个宏可以作为参数传到signal的第二个参数,分别代表当进程收到对应的signo信号时的处理行为,SIG_DFL是默认行为,比如进程的默认行为是终止进程,但我们将其处理行为改为了忽略,但此时又想将行为改回默认行为,此时就可以用SIG_DFL这个宏。SIG_IGN是忽略行为,如果此时进程对于signo的处理行为是终止,那我们可以手动将其处理行为改成SIG_IGN忽略,也就是什么都不做,惰性的将对应的信号位图中的比特位再由1置为0,然后什么都不干,这就是忽略行为。

87e20dd835b54060a129bda6e912896f.png


3.

通过代码运行结果可以看出,当我们向进程发送2号信号时,进程此时不会再被终止了,而是打印出了一条信息"进程捕捉到了一个信号编号是2的信号",此时进程处理2号信号的行为就变成了自定义行为,去执行我们自己设定的handler方法。

那我们是不是就无法通过向进程发送2号信号来杀死进程了呢?答案是 是的,但我们还有其他的手段,通过kill -9指令可以杀死进程。假设我们把所有的信号都捕捉了,并且捕捉后的处理行为也不终止这个进程,那么是不是这个进程就金刚不坏,哪个信号都没有办法杀死他呢?答案并不是这样的,9号信号是管理员信号,是操作系统给自己留的底牌,这个信号被规定为无法捕捉,所以即使你使用signal捕捉这个信号也是没有用的,操作系统必须保证自己有能够杀死终止任意一个进程的能力,这个能力就是通过9号信号来达到的。

580169ed9b4b44f48979c326ef2f917f.gif



4.

实际上除热键ctrl+c外,还有一个热键是ctrl+\,这个组合键会被操作系统解析为3号信号SIGQUIT,这个信号的默认处理行为是Core,除终止进程外还会进行核心转储,Core于Term有什么不同?这个话题放到信号的递达处理部分进行讲解。


f6d570544a404429b2cdc5635bd52c57.png



5.

有很多人误以为只要显示写了signal函数,这个函数在main执行流里面就会被调用。这样的想法完全是错误的,我们显示写signal函数其实相当于注册了一个信号处理时的自定义行为,然后这个自定义行为handler不会平白无故被调用的,只有当对应信号发送给进程时,这个handler才会被调用,否则这个函数是永远不会被调用的。

你可以把main和handler看作两个执行流,没有信号时,只有main一个执行流在执行代码,接收到对应的信号时,会从main执行流转移到handler执行流,等到handler执行流执行结束后,再回到main中刚刚执行到的那一行代码继续向下执行剩余代码。808ab63937b349f4b6ab9b4cea4e582b.png


808ab63937b349f4b6ab9b4cea4e582b.png


6.

另外补充一个知识点,linux规定,当用户在和shell交互时,默认只能有一个前台进程,所以当我们自己编写的程序运行时,bash进程就会自动由前台进程转换为后台进程。

除上面的情况外,如果某一个进程由于被发送19号信号SIGSTOP停止后,再被发送18号信号SIGCONT重新继续运行时,这个进程也会由原来的前台进程转换为后台进程。

对于前台进程我们可以用2号信号SIGINT进行进程终止,但后台进程无法用SIGINT进行进程终止,我们可以选择9号信号终止进程。fdc8b1c685b54f2092a35b0f11ce8444.png

abe232731fec4cc0aaa6ed7a02193983.png


2.通过系统调用发送信号(kill系统调用 和 raise、abort库函数)

1.

其实除上面那种用组合键或者是手动的通过kill指令加信号编号的方式给进程发送信号外,我们还可以通过系统调用的方式给进程发送信号。操作系统有向进程发送信号的能力,但是他并没有这个权力,操作系统的能力是为用户提供的,用户才有发送信号的权力,操作系统通过给用户提供系统调用赋予用户让OS向进程发送信号的权力。就像我们将来可能都会变成程序员,我们有写代码的能力,我们的能力是服务于公司或老板的,让我们写代码的权力来自于老板。


2.

注意你没看错,kill不仅是指令,他还是一个系统调用,这个接口用起来非常简单,参数分别为进程id和信号编号,通过kill系统调用和命令行参数的知识,我们也可以实现一个kill指令,我们规定运行mysignal时,命令行参数形式必须为./mysignal pid signo的形式,通过命令行输入的信号编号和进程id,在mysignal可执行程序中向id进程发送对应的信号,这样的功能不就是kill指令的功能吗?

0779fbc738364c68863d4244d52e582c.png


59154a8a540b494f9a8cdca67454d1fe.png



bb30092142524833880a452b77b393f0.png



0ddc972a88b5408b8c8dc86bd6396595.png


da1631dbb0e94399b30e8819021798d5.gif



3.

有一个库函数是raise,它可以用来给自己所在进程发送信号,其实他底层调用的还是系统调用kill(pid_t pid, int sig),这接口没啥意思,说白了就是变相的用kill系统调用给自己的进程pid发送指定信号而已,换汤不换药。

b6cf3fbe17624777b41f2f00623a79f0.png


4.

还有一个接口是abort,这个接口就是什么参数都不用传,它会自动给异常进程发送信号SIGABRT,默认处理动作就是终止该进程,abort有中止的意思。这个接口说白了也是变相的使用kill系统调用给自己进程发送6号SIGABRT信号而已,换汤不换药。


dc4c8e3ab4cb4fbb804b9aefcf50ce9e.png


458a53ac345a4ce3babe9549db903013.png



5.

我们上面所说的raise和abort都在man 3号手册,这代表他们都是库函数,而kill在2号手册,是纯正的系统调用。但3号手册的库函数可以分为两类,底层封装了系统调用的库函数和没有封装系统调用的库函数,很明显,raise和abort库函数就是底层封装了kill系统调用的库函数。就连kill指令底层其实也是封装的kill系统调用来实现的。

由此可以看出,想要修改PCB中的信号位图,也就是修改内核资源,必须通过操作系统来完成,而操作系统会给用户提供对应的系统调用接口,让用户按照内核意愿来修改内核资源。


6.

在上面的内容中我们已经见到了许多的信号,比如SIGINT, SIGQUIT, SIGABRT, SGIKILL等,他们在递达处理时的默认动作都是终止进程,那搞出来那么多信号还有什么意义呢?他们的默认处理动作都是一样的呀!

信号的意义并不在于其进程递达处理信号的结果上,而是在于是由于什么原因而产生的信号,不同的事件会产生不同的信号,通过信号的不同我们能够定位出进程是由于什么异常而退出的,这能帮助我们快速定位代码错误所在。

就像C++的异常一样,那么多的异常种类,在捕获异常之后,进程不都终止了吗?那还要那么多的异常干什么啊?道理不就和信号类似吗,异常的意义也不在于异常的处理结果上,而是程序员能够通过异常的种类代表产生错误的不同事件来判定出程序的错误所在。


3.硬件异常 通知内核 向进程发送信号

3.1 除0错误(OS怎么会知道给当前进程发8号信号?进程只除0一次为什么handler疯狂被调用递达处理8号信号呢?)


1.

除我们主动调用系统调用或通过键盘发送指令外,软件本身其实也可以自发的发送信号,比如这个部分所讲的硬件异常导致软件自发的发送信号。

从下面代码运行结果可以看出,当发生除0错误之后,代码运行之后,打印出了一条错误信息Floating point exception然后进程就退出了,在通过kill -l指令查找后,不难确定进程其实是收到了8号信号SIGFPE而退出的。

如果想要证明确实是8号进程导致的进程退出,我们可以用signal捕捉一下8号信号,然后进行自定义处理,看看进程在运行时是否会调用我们自定义的handler方法。

f4cd44a27e344b05a949e80dac00343f.gif

fa56da3e2c4649759fe1e6be873fe3c8.png


a70ca80f889d470f94c99a7f4c036827.png



2.

可以看到,第一次在死循环里面我们除0一次,然后当程序运行的时候,signal疯狂捕捉8号信号SIGFPE,那我们可以将其理解成是由于除0代码放在死循环里面导致的,因为在死循环里面,不断进行除0错误,那么OS就不断的给进程发送8号信号,signal就会不断的被捕捉,handler方法就会不断的被执行,从而导致显示器上疯狂打印handler里面的输出信息,进程捕捉到了一个信号,信号编号是8号。

上面确实可以这么理解,没有丝毫问题。那我们就赶快把除0代码放到死循环外面啊,放到外面8号信号SIGFPE就不会一直发送了,那signal就只会捕捉一次8号信号,handler也就只会被执行一次,打印一行输出信息即可,但!结果和我们所想的一样吗?当然不一样!程序依旧还是疯狂捕捉SIGFPE信号,handler中的输出信息还是像鞭尸一样疯狂的输出,这是怎么回事捏?image.gif


image.gif

3.

经过你上面的两个代码运行结果来看,此时我有两个问题,一个是操作系统怎么知道要给我这个进程发送8号信号呢?另一个问题,我都已经把除0代码放到死循环外面了,就除0一次而已啊,你signal怎么还给我疯狂捕捉8号信号呢,这是怎么回事啊?


问题1:CPU中有很多很多的寄存器,这些寄存器就相当于CPU的工作台,其中有些寄存器位状态寄存器,用于标识这次CPU的计算结果是否正确,状态寄存器标识每次CPU的计算结果是否正确其实也是通过状态位图来解决的,如果计算结果正常那么对应的标志位就是0,如果计算出现错误对应的比特位就会由0置1。除0其实就相当于除无穷小,那么CPU计算出来的结果就会很大很大,可能已经超出INT_MAX了,此时状态寄存器中的溢出标志位就会由0置为1,那么这是不是代表CPU计算出错了呢?当然是啊!

那么操作系统要不要知道CPU计算出错了呢?当然要知道!因为操作系统是软硬件资源的管理者,你硬件计算都出异常了,我操作系统能不知道吗?所以操作系统就会知道当前在CPU上运行的进程导致CPU出现计算错误了,并且CPU计算错误是由于溢出,那么此时操作系统就会给对应进程发送8号信号SIGFPE,进程收到该信号后,在合适的时候会处理这个信号,处理时默认的行为就是终止该进程,这就能解释为什么操作系统知道要给具体哪个进程发送8号信号了。因为进程在CPU上运行的时候,进程相关的上下文数据都被临时加载到CPU的寄存器上了,操作系统一读取寄存器内容,进程的相关数据还不是轻轻松松都拿到了吗?根据CPU的计算异常种类,向进程发送个8号信号对于操作系统还不简单吗?

所以总结成一句话就是,CPU计算发生异常,操作系统知晓CPU发生的计算异常种类后,向当前在CPU上正在运行的进程发送对应的8号信号,进程在合适的时候处理该信号,默认处理行为就是终止退出进程。


189255f6532f4570b9ed5ea6e04774ac.png


操作系统作为软硬件资源的管理者,什么都知道!

54455112dcba4a048d867ed13049791f.png


问题2:问题1是基于进程递达处理信号时是默认处理行为,也就是终止退出进程,我们想知道为什么OS会给进程发送8号信号。问题2是基于我们通过signal捕捉8号信号,自己定义handler方法,想要验证进程的确就是由于收到8号信号而退出的,但发现除0即使就除了一次,但handler依旧被疯狂的调用,我们想知道这是为什么。所以问题1和2基于的场景是不同的,老铁们注意一下。

进程收到信号后,在合适的时候进行递达处理后,一定会终止退出吗?这是不一定的!那如果进程没有退出的话,他是不是还有可能被CPU进行调度呢?当然有可能被重新调度,这也是我们常说的进程切换。我们知道寄存器中的数据是临时数据,当进程被切换时,CPU中这一套寄存器的内容又会被重新加载为新的在CPU上运行的进程的数据(CPU的寄存器中的内容只属于当前正在执行的进程的上下文数据,进程切换时会进行进程的上下文数据保护,下次调度时会进行上下文数据恢复,下面的图描绘的很详细,这里不赘述)所以当除0的进程被重新调度到CPU上运行的时候,对应的状态寄存器里面的溢出标志位又会由0置为1,此时CPU又会出现计算异常,操作系统知晓后又会给进程发送8号信号,那么signal又会捕捉到8号信号,handler方法又会被再一次调用,所以这就是为什么我们只除0一次,但8号信号依旧多次被捕捉,handler依旧被多次调用的原因,本质上就是因为我们自定义8号信号递达处理的行为,我们并没有让进程退出,那么进程就有可能被CPU重新调度,此时相同的问题就会重复多次的发生,况且CPU的运行速度那么快,就算是进程切换,我们的除0进程可能在1s内还是会被重复调度很多很多次,所以CPU的速度很快很快!不要用我们的感知去衡量。

f2163d9ac31c4fc99d87d1aba9359fff.png

52de8d1a1c4342049037cb1f7d2e9698.png


4.

那么对于这样的问题,我们能否修正这个错误呢?比如将状态寄存器的溢出标志位重新再置为0?答案是不能,因为状态寄存器是由CPU自己维护的,并且CPU也要被操作系统管理,而用户是没有权力访问和修改CPU上寄存器的数据的。

这一点也不难理解,用户能做的工作从权限角度来讲是比较有限的,当程序已经在CPU上跑起来的时候,此时用户是什么都无法做的,他只能在一旁看着CPU取程序的指令并执行指令,至于用户想要修改或维护此时CPU计算异常这样的事情,是无法做到的,我们唯一能做的就是看到进程的运行结果或中断运行进行报错,一旦程序开始运行,如果出错我们也只能进行事后调试。


5.

从除0错误这个例子我们就能够对语言级别产生的除0错误有一个新的认识了,实际上语言级别我们进行除0时,也是由于硬件CPU计算溢出导致操作系统给进程发送SIGFPE信号,信号的默认处理动作就是终止进程,下面代码就是在VS上跑的,可以看到进程退出。

9d6941e5e08f4b45969c36b21d9a3e8a.png



3.2 访问空指针指向的空间(OS怎么会知道给当前进程发送11号信号呢?)

1.

另一个常见的问题就是空指针访问,这个问题本质其实也是由于硬件异常导致的软件自发向进程发送信号。与除0相同,为什么进程会在报错一条信息Segmentation fault之后会退出呢?我们通过kill -l的命名推测是由于操作系统给进程发送了11号信号SIGSEGV从而导致进程退出,从11号信号的默认处理动作我们也知道Core也是会终止进程的。

2ac076db9921405ab8f69151e3fbcffe.png


2.

那问题又来了,操作系统怎么知道要给当前这个进程发送11号信号呢?

(首先我们需要了解一下页表和MMU)


页表是操作系统维护的一种内核数据结构,用于存储虚拟地址到物理地址之间的映射关系,当进程运行时,他的地址空间mm_struct会被划分为许多固定大小(一般是4KB)的块,这个块我们称之为页(Page),每个页在虚拟地址和物理地址中都有唯一性的标识,页表就是用来维护两个部分标识之间的映射关系的。页表是由操作系统来维护和进行管理,操作系统会给每个进程都分配一个独立的完全属于该进程的页表,实际上这个页表就是用户级页表(信号被捕捉的完整流程部分会讲到这个知识内容)。


而MMU是内存管理单元,是集成在CPU内部的一个硬件部件。当CPU访问内存时,CPU其实访问的是虚拟地址,MMU此时就会通过查找内核数据结构页表来完成CPU访问的虚拟地址到物理地址的转换,物理地址就是实际硬件上的地址,是内存芯片或其他物理设备上的物理位置,最终CPU访问的地址就是经过MMU转换后的物理地址,MMU转换虚拟地址这一步骤是实现虚拟内存机制的关键所在。而页表则负责存储虚拟地址和物理地址之间的映射关系,方便MMU在进行虚拟地址转换时通过页表来进行快速查找虚拟地址对应的物理地址。


在大多数操作系统中,内核将0号虚拟地址保留给操作系统本身,而不允许应用程序进行访问,并且页表内部也没有存储0号虚拟地址到物理地址之间的映射关系,操作系统没有将0号虚拟地址映射到物理内存的任何一个页帧上,所以在MMU尝试将0号虚拟地址转换为物理地址时,查询内核数据结构页表时,此时MMU就会发生错误,无法将0号虚拟地址进行转换。MMU会检测到这个错误并触发空指针异常,操作系统作为软硬件资源的管理者,知晓空指针异常之后,就会给当前正在CPU上运行的进程发送11号信号SIGSEGV,在进程收到信号之后,合适的时候会去处理这个信号,默认处理动作就是Core,会终止当前进程。

8c088c8ff9b5464d8ff080b6786ffa92.png


4.由软件条件产生信号

4.1 管道:读端关闭,写端一直写。


1.

在进程间通信IPC部分我们谈到过匿名管道和命名管道的读写四大特征,其中的一个特征其实就隐含了软件异常所产生的信号,当读端关闭时,操作系统会给写端发送13号信号SIGPIPE,13号信号的默认处理行为就是Term终止当前进程,也就是终止写端进程。

所以读端关闭这一软件条件,触发了操作系统向进程发送信号,这就是由软件条件所产生的信号。55f6dd12841f46b395d30c0ab3938061.png


55f6dd12841f46b395d30c0ab3938061.png

4.2 alarm定时器


1.

通过alarm闹钟,我们可以计算出1s内CPU能够累加数据多少次,下面测试的代码中其实分了两种情况进行测试,一种是每次将累加数据之后的结果打印到显示器上,一种是在1s内只进行数据的累加,等到1s到了的时候,我们捕捉信号在handler里面进行累加后数据的值的打印。


声明:cnt是一个静态全局变量,我想让cnt只具有内部链接属性,handler和main当中都能用cnt,cnt的初始值为0

d7543249965a40edadaaa92108a71493.png

b5f9d6c68df84accb0d9ea519ed0a61d.png


2.

当我们采用每次将信息输出到显示器上时,cnt累加达到的数据仅仅是53820,其实主要是因为我们多次的访问了显示器硬件,也就是进行了IO,向显示器文件进行output,另外由于我用的是云服务器,所以还需要将数据通过网络传输到我的本地电脑,所以1s内的时间大部分都消耗在等待显示器就绪和网络传输资源上了,CPU计算的时间却占比很小。所以打印出来的cnt大小仅仅为5w多。

当我们将1s的时间全部放到CPU计算上来,等到1s过后定时器alarm响了,会给进程发送13号信号SIGALRM,此时用signal捕捉信号,在handler方法里面输出cnt的值,输出过后exit退出子进程即可。从打印结果可以看到,如果将时间全部用来进行CPU的计算,CPU还是非常快的,1s计算了大概5亿多次,和上面的5w次差了大概1w多倍数,可以看到一旦访问外设CPU的执行速度就会慢下来,因为等待硬件就绪很慢,硬件就绪的时间和CPU计算的时间根本不在一个量级。


a11c2c5fc9c241c0aa44911e90a28ed4.png


87986dc9bd6e4bb5854c778982e069af.gif



3.

话又说回来,那为什么alarm闹钟是软件条件异常呢?

闹钟实际就是软件,他不就是数据结构和属性的集合吗?所以闹钟本身就是软件,当前进程可以设定闹钟,那么其他进程也可以设定闹钟,所以操作系统内部一定会存在很多的闹钟,那么操作系统要不要对这些闹钟进行管理呢?当然要,管理的方式就是先描述,再组织。所以闹钟在操作系统中实际就是内核数据结构,此内核数据结构用于描述闹钟,组织的最常见方式就是通过链表,但闹钟的组织方式也可以通过堆,也就是优先级队列来实现。


下面是闹钟内核数据结构的伪代码,其内部有一个闹钟响铃的时间,表示在当前进程的时间戳下,经过所传参数second秒后,闹钟就会响铃,这个响铃时间即为当前进程时间戳+second参数大小。

另外闹钟还需要一个PCB结构体指针,用于和设置闹钟的进程进行关联,在闹钟响了之后,便于操作系统向对应进程发送14号信号SIGALRM,此信号默认处理动作也是终止当前进程。

1f60b4786d27416f96f509ec6daa6bc3.png


OS会周期性的检查这些闹钟,也就是通过遍历链表的方式,检查当前时间戳超过了哪个闹钟数据结构中的when时间,一旦超过,说明此闹钟到达设定时间,那么这个时候操作系统就该给闹钟对应的进程发送14号信号,如何找到这个进程呢?通过alarm类型的结构体指针便可以拿到alarm结构体的内容,其结构体中有一个字段便是PCB指针,通过PCB指针就可以找到闹钟对应的进程了。9c9e9b43fd094a9b9b5b734644f55860.png


9c9e9b43fd094a9b9b5b734644f55860.png


除链表这样经典的组织方式之外,另一种组织方式就是优先级队列,priority_queue,实际就是堆结构,按照闹钟结构体中的when的大小建大堆,如果堆顶闹钟的时间小于当前进程时间戳,则说明整个堆中所有的闹钟均为达到响铃的条件。如果堆顶闹钟的时间大于当前进程时间戳,那就要给堆顶闹钟对应进程发送14号信号了,检查过后再pop堆顶元素,重新看下一个堆顶闹钟是否超时,大概就是这么一个逻辑。


b79a24096552420ead0942dcd01472d0.png



5.总结一下


1.

上面我们谈到了四种产生信号的方式,有通过键盘产生信号,通过系统调用产生信号,由于硬件异常导致软件自发的产生信号,由于某些软件条件产生信号等等,老铁们不难发现,这四种产生信号的方式最终都落到了操作系统本身身上,键盘的kill或组合热键不是通过kill系统调用吗?系统调用不就是操作系统提供的接口吗?硬件异常不还是操作系统知晓后给进程发送信号吗?由于软件条件而产生的信号,最终不还是通过操作系统来向进程发送信号吗?

那为什么所有发送信号最终都要落到操作系统上呢?因为进程接收信号的本质就是修改PCB中的信号位图,而修改PCB这样的能力只有操作系统才具有,所以只要发送信号最终都绕不开操作系统,因为操作系统是进程的管理者。


2.

只要进程收到信号,那么信号就一定被处理吗?并不是这样的,进程会在合适的时候处理该信号。那在合适处理和收到信号之间有一个时间窗口,这个时间窗口内信号被保存在哪里呢?信号会被保存到PCB的信号位图里面。


3.

如何理解OS向进程发送信号呢?发送信号的本质就是OS修改进程PCB结构体中的信号位图,将对应比特位由0置1即为进程接收到信号。

一个进程在未收到信号的时候,能否知道自己要对合法信号做什么处理呢?当然可以知道,这个工作早被编写系统的程序员完成了,他们让进程能够知道自己对不同的信号该做什么样的处理。


三、信号的保存(PCB内部的两张位图和一个函数指针数组)

1.未决 阻塞 递达概念的抛出


1.

信号会在合适的时候被进程处理,执行信号处理的动作,称为信号递达,信号递达前的动作被称为信号捕捉,我们一般通过signal()或sigaction()进行信号的捕捉,然后对应的handler方法会进行信号的递达处理。当然如果你不自定义handler方法的话,那递达处理的动作就不会由handler执行,操作系统自己会根据默认或忽略行为对信号进行递达处理。


2.

信号被保存,但并未被递达处理叫做信号未决!意思就是此时进程已经收到信号了,但信号尚未被进程递达,此时称之为信号未决。


3.

还有一种状态是信号阻塞,此状态下即使信号已经被收到,但永远不会被递达,只有信号解除阻塞之后,该信号才会被递达。

信号是否产生和信号阻塞是无关的, 就算一个信号没有被产生,没有被发送给进程,但进程依旧可以选择阻塞该信号,意味着将来如果进程收到了该信号,那该信号也不会被递达,只有解除阻塞之后才可以被递达。


4.

注意阻塞和忽略是两种完全不同的概念,阻塞指的是信号被阻塞,无论进程是否收到该信号,进程永远都不会递达这个信号。而忽略是进程收到该信号后,对信号进行递达时的一种处理行为,进程在递达时可以选择忽略该信号,也就是直接将信号位图(实际是pending位图)中对应的比特位由1置0之后不再做任何处理。



2.通过内核数据结构和伪代码理解概念

1.

在内核中操作系统为了维护信号,为其创建了三个内核数据结构,也就是三张表,分别为pending表,block表,handler表,前两个表有专业的称呼叫做pending信号集和block信号集,当进程收到信号时,对应pending位图中的比特位就会由0置1,当某个进程被阻塞时,对应block位图中的比特位就会由0置1。

当调用signal捕捉函数时,如果处理行为采取自定义,则用户层定义的handler函数的函数名就会被加载到对应的内核数据结构handler表里面,内核调用handler进行自定义处理时,就会去handler表里面进行查找。指针数组的下标代表不同的信号编号,指针数组的内容代表对应信号被递达时调用的handler方法。

如果一个信号想要被递达,最多需要进行两次检测,第一次判断其是否为阻塞信号,如果是则判断结束,该信号一定不会被递达。如果不是则进行第二次判断,pending信号集中比特位是否为1 ,如果为1说明该进程确实收到了对应的信号,那就进行递达即可,如果为0说明该进程没有收到对应信号,则不进行递达。6b5c36c79cd34761b079163cb2415c6d.png


6b5c36c79cd34761b079163cb2415c6d.png

2.

下面是PCB源码中的部分字段,正好对应我们所说的三个内核数据结构,我上面所画的图是为了帮助大家理解信号在内核中是怎么被操作系统维护的,原理和源码中是相似的,但具体源码的实现肯定要比我们上面所画的复杂很多,如果有老铁感兴趣,可以自己下去研究一下源码是如何实现的。


f273afafea5b491b99c5eb3589961f11.png


四、信号的递达处理(捕捉信号:忽略 默认 自定义)

1.信号默认处理动作Core和Term的区别(核心转储话题 + 越界访问检查不出来)


1.

这个问题其实已经在上面的文章中产生不少次了,那么多的信号默认处理动作都是终止进程,那他们有什么区别呢?实际上Term的处理动作只是单纯的终止进程,而Core除终止进程外,还会多做一件事,就是核心转储core dump。


2.

在介绍核心转储话题之前,先来谈一下以前在语言阶段我们常见到的越界访问问题,有时候越界访问能检查出来,有时候却检查不出来,其实是由于访问的位置不同而导致的,当访问的位置可能已经超过了数组的有效空间,但没有超出数组所在函数栈帧的有效空间,OS对于正在运行的程序是有可能检查不出来越界访问的,同时g++编译器在编译阶段也没有查找出来越界访问问题,这就有可能导致数据已经被修改,但用户还有可能不知情的情况产生。

此时可以通过编译器选项或其他检查工具或插件进行越界访问的检查,同时我们在编写代码的时候也要注意一些,不要写出越界访问的代码。

098bd5fd70854546bf705587d0713cfd.png


编译器负责编译代码时进行越界访问的检查,OS负责在程序运行时对越界访问进行检测。

fafb31e3e3664a9fab3b5f29e2052072.png


我自己在测试的时候,100,1000的数组index位置,g++都没有检查出来越界访问,index到10000的时候检查出来了。


87e77a0d81e34bfeacf8daf531d55d8a.png

2.

云服务器默认关闭了core file的选项,所以当发生越界访问也就是段错误时,不会触发核心转储,核心转储实际上是将出现异常的进程的二进制数据转移存储到磁盘上,此时就会生成一个名为core.xxxxx的普通文件,这个文件的后缀是当前异常进程的pid。

当我们利用ulimit -c选项设置core file的大小的时候,就可以产生对应的文件了,否则云服务器默认是关闭core file选项的,也就是不给用户生成对应的核心转储文件。13a4c9f2b2624d2690071bbb2ff54dc5.png


13a4c9f2b2624d2690071bbb2ff54dc5.png

3.

那么这个核心转储文件有什么用呢?

他主要用来帮助我们进行事后调试,当gdb进程之后,我们通过core-file 指令 再加对应的异常进程的核心转储文件,回车之后立马就可以帮助我们快速定位问题出错的位置,直接告诉我们是在main函数的第46行出现了段错误。

所以通过核心转储文件快速定位程序问题所在,是一种不错的调试策略。

7d05b570cbfe44bd91dcd0311d2c9c9d.png


2.信号被捕捉的完整流程(进程在 合适的时候 处理信号)

2.1 内核态和用户态(调用系统调用触发软中断,处理器由用户态切换到内核态)


1.

我们上面老是说进程会在合适的时候处理信号,那么什么时候是合适的时候呢?答案是,从内核态返回用户态的时候,进程会在这个时候处理信号。

需要知道的是,我们所写的代码在编译后运行时,其实是以用户态的身份去跑的,但用户态的代码难免会访问内核资源或硬件资源,而这些资源都是由操作系统管理的,所以想要访问这些资源则一定绕不开操作系统,那么操作系统就需要提供系统调用接口,让用户以操作系统的意愿去访问内核或硬件资源,因为操作系统不相信任何用户,所以操作系统必须自己实现系统调用,这个实现代码也就是我们常说的内核代码,然后把代码的接口提供给用户,用户只能通过这些系统调用接口来访问,不能自己随意访问内核或硬件资源。


2.

所以例如printf() write() read() getpid() waitpid()等等接口,前部分需要访问显示器或键盘等硬件,后部分需要访问内核资源PCB,这些接口的底层一定是离不开系统调用接口的,因为他们都直接或间接的访问了内核或硬件资源。

再比如stl容器的各个接口,这些接口中有没有某些接口底层一定调用的也是系统调用呢?当然是有的!所有的stl容器都需要扩容,仅凭这一点就可以确定他们底层要调用系统调用了,因为扩容实际上就是在访问物理内存这一硬件资源,实际是先访问mm_struct,然后再通过MMU去访问内存硬件资源,那这些接口也一定绕不开操作系统,因为操作系统是软硬件资源的管理者,那这些接口底层也一定封装了系统调用。

(实际上按照我个人理解来看,访问硬件资源本质还是访问内核资源,因为所有的硬件都需要被管理,操作系统会在内核里面创建对应硬件的内核数据结构,对其进行描述和组织,所以你访问硬件说到底还是访问内核资源)

7c50d3823f254285b2a1d9c76bac71eb.png


3.

当代码运行到系统调用接口时,要执行对应的内核代码了,程序能否以用户态的身份去执行系统调用的内核代码呢?这当然是不可以的!因为在用户态下,进程只能访问受操作系统授权的用户空间的代码,用户态的进程运行级别太低,内核并不相信用户,所以如果想要执行内核代码,则进程的运行级别必须由用户态切为内核态,内核态下,进程可以访问内核代码或其他内核资源,等到系统调用结束之后,当然也不能以内核态的身份去执行用户态的代码,因为用户态的代码有可能被恶意利用去攻击操作系统,而内核态的执行权限大,所以在系统调用结束后,为防止发生意外,进程的运行级别还需要由内核态切换为用户态,此时如果某些代码想要攻击操作系统,用户态的执行权限是不够的,他无法访问任何内核资源或硬件,自然就保证了系统的安全性。


4.

当调用系统调用接口,也就是执行内核代码时,我们称进程陷入了内核态,由于执行系统调用时和执行之后各需要进行一次身份的切换,所以系统调用往往要费时间一些,所以应尽量避免频繁调用系统调用接口,因为这会降低程序运行的效率。

所以stl的空间配置器在实际开空间的时候,往往要给用户多扩容一些,因为他怕你稍微还需要多用一些空间时再次调用系统调用,而这样会降低程序运行的效率。


5.

在linux系统中,当用户进程调用系统调用时,会提前执行一个int 0x80汇编指令(也称为中断指令),此指令会触发一个软中断(也称为陷阱),这个指令会让处理器从用户态切换为内核态,便于内核能够访问进程的上下文数据(这个上下文数据就是内核资源),其实内核访问进程的上下文数据还是通过处理器来实现的,不过此时处理器已经切换为内核态,能够取到相应的进程上下文数据


2.2 CPU工作原理(与其说是进程级别的切换,不如说是处理器级别的切换)


1.

我们知道CPU中有一套寄存器,寄存器中保存的永远是临时数据,寄存器就是CPU的工作台,凡是和当前进程强相关的寄存器,寄存器内部数据称为当前进程的上下文数据,在进程切换时要进行上下文数据的保护,也就是将被轮换下去的进程的上下文数据暂时存到操作系统的某一块特定空间区域中,便于下次进程被轮换上来的时候能够进行上下文数据的恢复。

寄存器大致可以分为可见寄存器和不可见寄存器,其中有一个特殊的寄存器叫做CR3寄存器,他便为不可见寄存器,用户是无法对其进行修改的。还有一些其他的寄存器比如EBX EDI ESI等(我们这里方便叙述用cur寄存器来替代),保存的是指向当前运行进程的PCB指针。

4783bd0bc5e440c0bba1fe23a9c05bc0.png



2.

实际上这个CR3寄存器内部存储的是页表的地址,当进程运行级别是用户态时,这个CR3寄存器内部存储的是用户级页表的物理地址,当进程运行级别是内核态时,这个CR3寄存器内部存储的是内核级页表的物理地址。

通过这个CR3寄存器存储内容的变化,就可以实现进程运行级别的切换。

这个页表地址有那么牛吗?变一变CR3存储的页表地址就能实现进程运行级别的切换?页表能有这么厉害呢?没毛病!页表确实挺牛的!你想要访问内核资源,这些内核数据结构或代码可能位于物理地址空间的不同位置上,所以想要找到他们就必须通过内核级页表,那么MMU进行地址转换时,会去CR3寄存器内部取内核级页表的地址,通过这个内核级页表才能实现内核资源的访问,因为内核级页表存储了内核资源从虚拟地址到物理地址转换的映射关系。

在进程切换时,操作系统会将新的进程的页目录表的物理地址加载到CR3寄存器中,MMU会根据新的页目录表地址进行虚拟到物理地址的转换。


3.

实际上进程运行级别的切换,说到底还是处理器由用户态切换为内核态,或由内核态切换为用户态,你可以这么理解,进程在CPU上运行,如果此时处理器是用户态级别,那么处理器的寄存器存储的内容什么的是不包括任何进程的内核资源的,处理器无法取到进程中PCB,mm_struct,页表,文件描述符表,block信号集……等等信息,只有当处理器为内核态级别的时候,他就可以取到进程的内核资源了,并将这些资源加载到寄存器里面,那么内核就可以通过CPU的寄存器读取到进程的内核资源,进程如果想要执行内核代码,CPU也可以通过进程内部的内核空间找到对应的内核代码并执行。

所以与其说成是进程的运行级别的切换,不如说成是处理器级别的切换,不过处理器级别的切换底层还是通过CR3寄存器存储内容发生变化来实现的。


2.3 再谈进程地址空间


1.

进程该如何找到操作系统的代码并执行呢?其实是通过进程地址空间中的内核空间来完成的。在内核中实际除了用户级页表之外,还有一张内核级页表,这个页表可以将物理内存中的操作系统代码映射到每一个进程的地址空间中的内核空间,这个内核级页表专门用于进程访问内核资源时进行内核数据结构或代码的虚拟地址到物理地址之间的转换。

与用户级页表不同的是,内核级页表只需要存在一份就够了,因为所有的进程访问的内核代码都是同一份的,而每个进程都有自己独立的用户级页表是因为每个进程的代码是不同的,需要经过各自独立的页表进行映射才能找到物理内存上对应的进程的代码。

那怎么执行内核代码呢?也很简单,在进程地址空间的上下文进行跳转即可,进程运行时其进程的上下文数据都会被加载到CPU的寄存器里面,进行地址的跳转即可找到内核代码的虚拟地址,经过MMU映射后便可执行内核代码。


a6c6dd7531154af78ee1d715fa3311ff.png





















































































































相关实践学习
每个IT人都想学的“Web应用上云经典架构”实战
本实验从Web应用上云这个最基本的、最普遍的需求出发,帮助IT从业者们通过“阿里云Web应用上云解决方案”,了解一个企业级Web应用上云的常见架构,了解如何构建一个高可用、可扩展的企业级应用架构。
相关文章
|
29天前
|
网络协议 Linux
Linux查看端口监听情况,以及Linux查看某个端口对应的进程号和程序
Linux查看端口监听情况,以及Linux查看某个端口对应的进程号和程序
133 2
|
29天前
|
Linux Python
linux上根据运行程序的进程号,查看程序所在的绝对路径。linux查看进程启动的时间
linux上根据运行程序的进程号,查看程序所在的绝对路径。linux查看进程启动的时间
46 2
|
24天前
|
Linux C语言
C语言 多进程编程(四)定时器信号和子进程退出信号
本文详细介绍了Linux系统中的定时器信号及其相关函数。首先,文章解释了`SIGALRM`信号的作用及应用场景,包括计时器、超时重试和定时任务等。接着介绍了`alarm()`函数,展示了如何设置定时器以及其局限性。随后探讨了`setitimer()`函数,比较了它与`alarm()`的不同之处,包括定时器类型、精度和支持的定时器数量等方面。最后,文章讲解了子进程退出时如何利用`SIGCHLD`信号,提供了示例代码展示如何处理子进程退出信号,避免僵尸进程问题。
|
25天前
|
NoSQL
gdb中获取进程收到的最近一个信号的信息
gdb中获取进程收到的最近一个信号的信息
|
14天前
|
存储 监控 安全
探究Linux操作系统的进程管理机制及其优化策略
本文旨在深入探讨Linux操作系统中的进程管理机制,包括进程调度、内存管理以及I/O管理等核心内容。通过对这些关键组件的分析,我们将揭示它们如何共同工作以提供稳定、高效的计算环境,并讨论可能的优化策略。
19 0
|
26天前
|
Unix Linux
linux中在进程之间传递文件描述符的实现方式
linux中在进程之间传递文件描述符的实现方式
|
27天前
|
开发者 API Windows
从怀旧到革新:看WinForms如何在保持向后兼容性的前提下,借助.NET新平台的力量实现自我进化与应用现代化,让经典桌面应用焕发第二春——我们的WinForms应用转型之路深度剖析
【8月更文挑战第31天】在Windows桌面应用开发中,Windows Forms(WinForms)依然是许多开发者的首选。尽管.NET Framework已演进至.NET 5 及更高版本,WinForms 仍作为核心组件保留,支持现有代码库的同时引入新特性。开发者可将项目迁移至.NET Core,享受性能提升和跨平台能力。迁移时需注意API变更,确保应用平稳过渡。通过自定义样式或第三方控件库,还可增强视觉效果。结合.NET新功能,WinForms 应用不仅能延续既有投资,还能焕发新生。 示例代码展示了如何在.NET Core中创建包含按钮和标签的基本窗口,实现简单的用户交互。
49 0
|
18天前
|
Linux Shell
Linux 中 Tail 命令的 9 个实用示例
Linux 中 Tail 命令的 9 个实用示例
59 6
Linux 中 Tail 命令的 9 个实用示例
|
14天前
|
设计模式 Java Linux
Linux的20个常用命令
Linux的23个常用命令
Linux的20个常用命令
|
23天前
|
Linux 应用服务中间件 nginx