0


初识linux之信号

信号的声明周期主要分为三个部分,即信号产生、信号保存和信号递达。但是在这之前,要先知道什么是信号。所以在这一篇里将按照概念预备->信号产生->信号保存->信号递达的顺序讲解。

一、信号的概念

大家应该知道,在system V标准中,存在着一种进程间通信方式——信号量。要注意,信号量和信号其实完全是两个东西,它们之间毫无关系。就好比老婆饼和老婆一样,虽然名字相似,但是两者之间有非常大的区别。

区分好了信号和信号量之后,就可以来了解信号了。在介绍系统中的信号的概念前,先来初步推导一个信号的是什么。

  1. 信号与进程的关系

以红绿灯举例,在现实生活中,公路上很多地方都存在红绿灯。大家在看到红绿灯时都会对它的状态产生一定的反应,例如红灯停,绿灯行。这就说明我们是可以识别红绿灯的。而要识别红绿灯的基础就是,我们要认识红绿灯,并能对它有行为产生。这很好理解。要知道“红灯停,绿灯行”,那我们就要有两个动作,首先是能识别当前红绿灯的颜色,第二个就是根据颜色的不同产生不同的行为。

但是大家有没有想过,为什么我们可以识别红绿灯呢?这其实就是因为在过去,有人教育过我们,告诉我们在不同的灯亮时要做出不同的行为。而我们的大脑也记住了红绿灯的属性和对应的行为。

当你在公路上等红绿灯时,如果绿灯亮了,你就会穿过公路。但是,如果当绿灯亮时,你的一个好朋友从后面喊你名字,让你先别走,等他一起。你听到后还会过红绿灯吗?很明显不会,因为此时比起绿灯行这一行为,你有了更为重要的事要做。这就说明了,当信号到来时,我们并不一定会立即处理信号。因为绿灯随时可能亮,即信号随时可能产生,而此时你可能有更为重要的事要做。

如果此时绿灯亮起,但我们在做其他事,假设这个绿灯亮60s,前30s我们在做其他事。而到了后30s,我们就要准备过马路了。但是,在做完事情的30s后,为什么我们会继续过马路呢?那是因为我们的脑海中保存了绿灯这一信号。

同样的,你在公路上等红绿灯,当绿灯亮起时,一般人的默认动作都是过马路。但是有些人可能比较独特,他过公路前要先跳个舞或者唱首歌才会过。此时他的动作就和一般人不同,属于自定义动作。还有一些人,在绿灯亮起时,可能突然想起自己有其他事要做,就不过马路了。此时他们就是忽略了绿灯这一信号,做其他事去了,这就是忽略动作。这也就说明了,在面对信号时,有三种处理动作,即默认动作、自定义动作和忽略动作。

从上面的例子中,就可以推导出四个结论。

  1. 认知信号,就必须要认识信号的属性和有对应的行为

  2. 当信号到来时,我们不一定会立即处理信号

  3. 信号需要被记录

  4. 处理信号时,我们可能有不同的动作,归纳起来就是默认动作、自定义动作和忽略动作。

首先要有一个共识,那就是“信号是操作系统给进程发的”。再结合上面的四个结论,就可以推导进程与信号的关系了。

(1)信号传递给进程,进程需要通过认识和动作来识别信号。

(2)当信号到来时,进程可能在执行更重要的代码,无法立即处理信号。

(3)进程本身需要有对信号的保存功能

(4)进程在处理信号时,一般有三种动作(默认、自定义、忽略)。进程处理信号这一行为,被叫做“信号被捕捉”

  1. 信号的保存

上文中说了,为了让进程在执行完自己的代码后要记得处理信号,所以进程本身需要有对信号的保存功能。那么信号被保存在哪里呢?大家都知道,进程有自己的进程PCB,里面保存了进程的各种属性,当然,也就包括了信号。所以,信号其实是被保存在进程的PCB中的,保存的是是否收到指定信号。

那么进程如何保存信号呢?在谈这个之前,先在linux下输入“kill -l”命令查看linux系统中有哪些信号:

可以看到,进程中一共有62个信号。其中,131号信号是普通信号;3464号信号是实时信号,在这里,我们只讲普通信号,不讲实时信号。

大家应该都对31这个数字不陌生了。既然这里有1~31号信号,还要保存它们,那我们就可以用一个32bit位的数据来保存它。即一个unsigned int类型的数字。在这个数字中有32个bit位,每个bit位的位置就被看成是一个信号,而对应的数字0、1就是是否接收到信号。例如如果进程接收到了2号信号,那么就把这个数字的第二个bit位由0置1,这就表示该进程此时接收到了2号信号。当信号处理完后,再将第二个bit位由1置0即可。

通过使用上述的位图,就可以很轻松让一个进程保存31个信号的接收情况了。

  1. 信号的发送

信号发送的本质,其实就是修改PCB中信号的位图结构。

二、信号的三种动作

大家知道,进程的PCB是由OS来管理维护的,既然如此,那么谁有权利修改PCB中保存的位图结构呢?很明显,只有OS有这个权利。所以,所有的信号都是由OS向目标进程发送的。既然是由OS发送的信号,如果用户想要向一个目标进程发送信号,就必须要使用系统调用。

  1. 信号的默认动作

在上文中讲过,信号一共有三种动作,即默认、自定义和忽略。先来看信号的默认动作。首先写上以下测试代码:

该程序每隔1s就会打印一次。运行该程序的话就可以看到程序在不断打印。但是在它打印时,按下键盘上的“ctrl c”组合键:

此时程序被终止。其实这里的“ctrl c”组合键就是向该进程发送了一个2号信号。大家可能不知道这些信号的作用是什么,现在在linux下输入“man 7 signal”命令,并往下翻,就可以看到对各个信号的解释:

查看里面的2号信号,可以看到,在Aciton下写的是“Term”,这其实是单词“terminate”的缩写,意思是终止。再看解释,这里对2号信号的解释是“从键盘中断”。那么2号信号的作用就很明显了,就是一个从键盘上发送给进程的信号,用于中断进程。而上面所按下的“ctrl c”组合键,就是向进程发送了2号信号进而让进程结束。

在上图中所写的进程的动作,其实都是信号的默认动作。但是上文说过,信号有三种动作,那么除了默认动作,就应该还有自定义动作和忽略动作。

  1. 信号的自定义动作

信号的动作是可以让用户自定义的。要实现这一行为,可以使用signal()函数。

在这个函数里面,它的第一个参数signum是信号的编号。而第二个参数大家可能不太理解。此时就可以看看该函数上面的描述。很明显,这里是重命名了一个返回值为void,参数为int的函数指针。既然第二个参数是一个函数指针,那就说明该参数就是我们要给对应信号设置的自定义动作。

为了验证这一点,写下如下测试代码:

在这个程序里面,就是捕获了2号信号,并对该信号设置了一个自定义动作。运行该程序:

可以看到,虽然我们在程序中写了捕获函数,但是运行时却并没有作用。原因是这里仅仅只是设置了信号捕获,并没有进行调用。只有当对应的信号被传入时,signal()函数才会起作用。按下“ctrl c”组合键,向进程传入2号信号:

此时2号信号被传入,但是却没有终止程序,而是打印了一句话。这也就说明该信号此时的默认动作被修改成了我们所设置的自定义动作。当然,此时有人就可能会有一个问题,既然此时2号信号的默认动作被修改,无法终止进程,那么我们怎么终止这个进程呢?很简单,其实大部分的信号的默认动作都是“终止进程”,所以我们还可以用“kill -信号编号 进程pid”命令显式调用其他信号终止指定进程。

三、信号的产生/发送方式

  1. 通过键盘产生信号

产生信号的第一种方式,就是用键盘热键产生信号。这一方式很容易理解。写出如下测试代码:

当该程序运行起来时,就会死循环打印一句话。要终止该进程很简单,直接用“ctrl c”组合键即可。

这就是通过键盘向进程发送了一个2号信号,终止了该进程。

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

2.1 kill()函数

要调用系统函数向进程发送信号,就可以使用kill()函数。

该函数中的第一个参数pid为要发送的进程的pid,第二个参数sig是进程的编号。为了更好的演示,写出以下测试程序:

mykill.cpp文件:

mytest.cpp文件:

在这里,用一个mykill程序输入对应的命令,用于终止mytest程序。首先运行mytest程序,该程序会循环打印。

然后再运行mykill程序:

此时出现如下报错。这就是我们自己写的报错提示。原因是该程序中限定了main()函数的第一个参数的数量要等于3,即输入的命令+参数要有3个才可执行。现在我们再运行mykill程序,但是将进程的pid和信号传入:

通过这一方式,我们用命令行的方式手动终止了一个进程。在mykill程序里。这其实就模拟出了命令行命令“kill -信号编号 进程pid”。这同时也说明了kill命令的底层其实就是调用了“kill()函数”来实现的。只是这里我们用一个程序模拟出来了kill命令。

2.2 raise()函数

该函数不同于kill()可以给任意进程发送信号,它只能给自己发送信号。

该函数只有一个参数sig,代表的就是信号的编号。写出以下测试程序:

该程序原本会循环打印,直到cnt大于10。但是在这里,当cnt >= 5时,就调用了raise()函数,并传入了3号信号。运行该程序:

可以看到,程序的结果如我们所料,当cnt = 5时,程序就被发送了3号信号,终止了该程序。

2.3 abort()函数

abort()函数会给自己发送一个指定信号,即6号信号SIGABRT。该信号的默认行为也是终止进程。

写出以下测试代码:

运行该程序:

可以看到,该程序也是当cnt = 5时就被自动终止了。

  1. 硬件异常产生信号

在这里,只演示两种硬件异常产生信号,即浮点异常和段错误。其他情况就不再演示

3.1 浮点溢出异常

上面的两种产生信号的方式,都是我们自己手动调用信号。但是,信号其实并不是非要用户手动发送的。写出如下测试程序:

在这个程序里面,定义了一个整型a,然后用a除以了0。运行该程序:

可以看到,本来这个程序应该死循环打印的,但是却在打印一次后就被终止了。其中报错为“浮点异常”。那么为什么在这里除以0会导致进程终止呢?这其实就是因为当前进程受到了OS发送了8号信号,告知此时浮点异常,将程序终止。这样看可能不太明显。将程序修改为以下样式:

该信号中调用了signal()函数,如果该程序没有被传入8号信号,signal()函数就不会被调用。运行该程序:

运行后,该程序就会一直打印hander()函数中定义的内容。这也就证明了,当程序中有数字除以0时,会发生浮点异常,此时OS会向该进程发送8号信号。

但是这里又有一个问题,那就是这里确实发生了错误,但是为什么会一直打印这句话呢?难道是因为OS一直在对该进程发送8号信号吗?有人可能会说,a/=0被放在了循环里反复执行,那当然会一直发送8号信号了。现在将a/=0 拿出来放在外部:

运行该程序:

可以看到,虽然将a/=0放在了外部,只执行一次,但这里还是在反复地打印。这就说明,这一现象和a/=0的执行次数无关。

在谈这个反复发送信号的问题前, 先来看看另一个问题。虽然说OS会向进程发送信号,但是操作系统是怎么知道要给进程发送几号信号的呢?

大家知道,当一个进程在运行时,其实是需要被加载到CPU中的,而进程的数据就会被加载到CPU的寄存器中。假设现在a/=0这一代码被加载到CPU中进行运行,a、0和结果分别放在一个寄存器eax中。但是在CPU中其实还有一个特殊的寄存器,即状态寄存器。当a/=0被运算时,因为这一结果是无限大,所以会导致溢出问题。当出现了溢出问题时,就会将CPU中的状态寄存器中的“溢出标记位”由0置1,就表示在该进程中有溢出错误。然后CPU再将这一错误告诉OS,OS就根据拿到的结果向该进程发送8号信号,终止该进程。

理解了OS是如何知道要给进程发送哪个错误后,再来看为什么上面的进程会打印。

原因很简单,大家都知道,每个进程在CPU中都有自己的时间片规定它能运行多久,运行时间到了后,就会切换成另一个进程来运行。在进程运行时,需要将它的上下文数据加载到寄存器中,离开时,也要自己保存好寄存器内的数据。因此,当这个进程出现错误时但并没有被终止而是继续运行直到被切走时,就会导致保存在寄存器数据时会将状态寄存器中的“1”也一并保存,当又轮到该进程时,该进程在加载数据时也会将保存的溢出标记位1也一并加载进去。此时CPU又会检测到该进程运算异常,并将结果发送给OS,OS再次向该进程发送信号。

因此,上面反复打印的原因就是,该进程出现错误但未退出,在被反复加载到CPU中时会导致OS反复向该进程发送信号。

所以,在以前我们写的C、C++代码出现错误时,都是导致硬件出现异常,然后硬件再将异常发送给OS,让OS向进程发送信号,然后进程再在合适的时机处理该信号。

3.2段错误

在过去写代码时,想必大家都或多或少遇到过段错误。这其实就是因为出现了野指针进行了非法访问。写出以下代码:

该程序就会发生非法访问。运行该程序:

程序结果符合预期。此时它的错误解释为段错误。有了上面的浮点溢出异常的知识,大家应该都知道原因就是出现了野指针访问,导致OS接收到了这一错误,并向该进程发送了11号信号(段错误),终止了该进程。

那么在这里,OS系统又是如何知道进程出现了非法访问的呢?

原理很简单。大家知道,一个进程中是有自己的虚拟地址空间的。当我们写了"int *p = nullptr; p = 100;”时,就需要将p通过页表映射到物理地址空间。但是在映射时,其实并不是只通过页表进行映射,而是页表+MMU。MMU是存在于CPU上的一个存储单元。当进程中访问了nullptr,即0号地址时,依然是要通过页表+MMU进行映射的。但是当这一地址被加载到MMU中时,MMU就会发生异常,而该异常也会被OS所接收,进而使得OS向该进程发送11号信号。

现在再来看以前我们写的代码的野指针错误就可以认识到,当发生野指针错误时,是会在虚拟地址向物理地址映射的过程中导致MMU异常,当OS接收到了这一异常后,OS就会向对应进程发送11号信号终止该进程。当然,我们也可以选择捕获这一进程,但没有意义。

  1. 信号的意义

其实,大部分信号的默认动作都是终止进程。但是大家有没有想过,既然作用都是终止进程,那为什么还要有这么多种信号呢?只用一个信号不就可以了。

原因其实很简单,就好比在现实中,无论是我们被人殴打了,还是我们的家进小偷了,甚至是和别人发生了财产纠纷,我们的处理方式都只有一个,那就是报警。信号也是一样,虽然最终的默认动作都是报警,但是各种信号是用于应对不同的状况的,方便进程出现问题是让我们进行追溯。

再来看这张图,其实就可以看到,在各种信号的描述里,就包括了OS在哪些情况下会对进程发送哪种信号。例如里面的8号信号,就是发生浮点错误;11号信号,就是发生越界访问。

  1. 由软件条件产生信号

在学习linux的管道通信时,想必大家都知道,当管道的读端进程关闭时,写端进程也会被关闭。原因还是当读端进程被关闭后,写端就没有必要再继续写的必要了,此时OS会接收到读端关闭的信息, 然后向写端进程发送13号信号SIGPIPE终止该进程。

在上面的例子中,就是由软件条件所产生信号。当然,这样看不太直观,所以在这里,就介绍一个由软件条件产生的信号——SIGALRM。

5.1 SIGALRM信号

要产生SIGALRM信号,可以使用alarm()函数。

从名字上看,alarm的意思是“闹钟”。因此,这个函数的作用就是,当过了多少秒后,就向自己发送一个SIGALRM信号。所以该函数的参数seconds就是要经过的时间,以秒为单位。写如下代码进行测试:

运行该程序:

可以看到,当该程序运行5s后,OS就向该进程发送了14号信号SIGALRM终止了该进程。

再将代码修改成如下所示:

在该程序中,将捕获14号信号。运行该程序:

可以看到,这里确实是捕获了14号信号,这也就证明了alarm()函数会向自己发送14号进程。但是大家再看一下上面的结果。在硬件异常中,当信号被捕获并且没有终止进程时,会循环打印。但是在这里,信号被捕获后却只打印了一次,然后继续运行。这就说明,这个闹钟其实只是一个一次性闹钟,当闹钟响后,信号就不会再被发送。

alarm()函数的返回值是距离该闹钟响的时间。例如设置了一个10s的闹钟,如果在2s的时候就发送了一个SIGALRM信号给进程,那么alarm()函数的返回值是8。如果alarm()中的参数设置为0,就表示取消闹钟。

例如在程序中加上一个alarm(0):

运行该程序:

当经过5s后,alarm()函数并没有被调用,因此signal()函数也不会被调用,而是继续运行。

那为什么“闹钟”被说成是用软件条件产生的呢?首先要知道,alarm()其实就是用软件实现的。在OS中,任意一个进程都可能有闹钟,为了方便OS管理,这些闹钟就也要有自己的结构体。假设alarm的结构体如下:

OS在管理时就可能按照每个闹钟的超时时间的大小用链表构建一个小堆,然后OS再周期性的检查这些闹钟,当闹钟时间到了,就将14号信号发送给对应的进程。在这一过程中,闹钟的实现由软件实现,发送条件是结构体内的时间,于是就成为一种软件条件。

  1. 进程退出时的核心转储

查看关于信号的描述:

在Action里面可以看到,信号有几种不同的行为。其中Term和Core都是指的终止进程。既然行为都是终止进程,但却用了不同的名字,那就说明这两种终止行为之间是有差别的。它们之间的差别就体现在是否会进行核心转储。其中,Term方式终止仅仅终止进程;而Core终止,在终止进程的同时,还会形成一个文件。

11号信号是段错误,且是Core终止,所以写以下程序来测试:

此时就发生了如下报错。注意,在这里尽量把越界访问设置大点。因为虽然在这个程序里面仅仅申请了10个int的空间,OS也只为a分配了40byte的空间,但是我们并不知道OS给这个函数分配了多少空间。在实际上,只要a访问的位置处于这个函数的数据块内,就不会出现报错。例如将a[10000]改为100:

此时并没有出现报错。因为虽然发生了越界访问,但依然是在该OS给该函数分配的数据块内部访问,不会报错。这也就导致如果我们不小心出现了类似的越界访问时,程序并不会报错,但结果却会出现错误。

重新运行程序:

当设置回10000时,就会报错。因为此时访问的空间已经不在OS给该程序分配的数据块内了,可以被检测到。虽然这里出现了报错,但是大家并没有看见什么特殊情况。这是因为,这里使用的是云服务器,在云服务器上,core file选项是默认关闭的。输入“ulimit -a”命令:

其他的先不用管,看第一行的内容,显示core file size的大小是0。此时就是关闭状态,要打开,就可以使用“ulimit -c 空间大小”来设置:

此时就给core分配了1024个数据块,且也将core file打开了。此时再运行上面的程序:

在这次运行的报错中,就多了点内容。再执行“ll”命令:

可以看到,在当前路径中,就多了一个“core.32529”文件。而“核心转储”的含义就是,“当进程出现异常时,会将进程在对应时刻时,在内存中的有效数据转储到磁盘中”。形成的文件一般以“core.进程pid”命名。所以“core.32529”文件中的的32529就是对应进程的pid。

那这个核心转储文件有什么用呢?当一个进程出现错误时,我们最关心的就是出现错误的原因和位置。“核心转储”文件的作用就体现在调试上。在形成可执行文件时加上“-g”选项。然后用gbd进行调试:

当进入调试后,就输入“core-file 文件名”:

可以看到,此时就直接将进程出现错误的原因和出现错误的代码的位置显示出来了。因此,核心转储就是为了方便我们在程序出现错误时定位而诞生的。当然,这一机制只适用于“core终止”的进程,Term终止的进程就没有这一机制。

  1. 可捕捉信号

在上文中讲过,我们可以通过信号捕捉的方式,修改信号的动作,在修改的动作里面,甚至可以让进程不再退出。这就有一个问题了,如果一个进程将所有的信号都捕获并修改了动作,那是不是就是说该进程就无法被结束了呢?从理论上来看,确实如此。但是在实际中,OS并不会运行用户捕获所有的信号。例如9号信号:

该信号的描述就是结束信号。而该信号是无法被用户捕获的。写以下程序测试:

运行该程序,并传入9号信号:

可以看到,此时虽然传入了9号信号,但该信号未被捕获,而是直接将进程结束了。这其实也是OS的一种保护机制,避免出现一个捕获了所有信号的恶意进程,导致无法将其结束。当然,还有一些信号也是无法被捕获的, 这里就不再过多讲解了,有兴趣的可以自己尝试。

  1. 总结

通过上面信号产生的内容。我们对进程与信号的关系就要有以下几个认识。

(1)所有信号的产生,都是由OS来执行发送的,因为OS是进程的管理者。

(2)信号传递给进程时,进程并不会立即处理信号,而是等到合适的时候再处理。

(3)信号没有被处理时,需要被保存在进程PCB中,以位图形式保存。

四、信号保存

  1. 信号相关概念

在了解信号保存前,要先有以下的认识。

(1)当实际执行信号的处理动作时,被称为“信号递达(Delivery)”。

例如给一个进程发2号信号终止该进程,当进程被终止时就是“信号递达”。再比如捕获2号信号,并修改它的处理动作为打印一段字符串,当打印这段字符串时,也是信号递达。

(2)信号从产生到递达之间的状态被称为“信号未决(Pending)”。

大家知道,一个进程接收到一个信号后,并不会立即处理这个信号。在信号已经发出但进程未执行该信号的处理动作之间的时间,就是“信号未决”。就好比你点了一份外卖,当你拿到外卖时并不会立即吃,可能会先做点别的事,在你拿到了外卖但又没有吃这份外卖的这段时间,就可以看成信号未决。

(3)进程可以选择阻塞(Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程接触对此信号的阻塞,才执行递达的动作。

这就好比你很讨厌某个人,所以你把他的QQ设置为了黑名单,无论他有没有给你发送消息,你都看不见他的消息。此时,你屏蔽他人qq的行为,就可以看成阻塞。如果某一天你和他和好了,于是你将他解除黑名单,此时你们的聊天记录里面就会多出他以前给你发的消息。这一解除屏蔽看到以前的消息的行为,就是进程解除了对信号的阻塞,执行递达。

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

上文中说过,信号有三种动作,其中一种就是忽略。忽略就好比你讨厌一个人,但你没有拉黑他的qq,他给你发的消息你依然正常接收看见,但你并不会回复他,这就是忽略。阻塞则是你屏蔽了他,无法看到他的消息。两者之间是有差别的。

  1. 信号在内核中的结构

大家知道,信号产生后,需要在进程的PCB中以位图形式来进行保存。其实这个位图就是一个unsigned int类型,叫做pending。该类型占32个bit位,其中,每个bit位的位置表示信号编号,0或1表示该信号是否递达。

与此同时,因为一个进程可以选择阻塞某个信号。所以,在进程的PCB中还有另外一个unsigned int类型的位图,它的bit位的位置表示信号编号,而0、1表示是否被阻塞。当一个信号产生时,进程都会将该信号与阻塞的位图结构进行对比,对应的位置上为0则未阻塞,递达;为1则阻塞,不抵达。

当信号递达后,进程就要执行信号的处理动作。因此,在进程的PCB中还有一个hander_t hander[32]数组,hander_t是一个函数指针重命名。该数组的下标+1表示的就是信号编号,对应的位置中的内容保存的就是信号的处理动作。

因此,当我们调用signal()函数捕获信号并修改信号的处理动作时,并不是修改了该信号原本的处理动作的内容,而是将hander_t hander[32]中对应位置上的内容的函数指针修改为了我们自己定义的内容的地址。

  1. 内核态与用户态

上文中说过,信号产生时,进程并不会立即处理信号,而是要等到合适的时候。这里的这个“合适”,其实指的就是当“进程从内核态返回到用户态”的时候。

在这之前,我们写的代码其实都是在用户态中执行的,当进程中遇到了系统调用或者要访问硬件资源时,进程就需要进入内核态中,以OS的身份执行代码。所以,用户态和内核态的区别就是,内核态所拥有的执行权限更高。

那一个进程如何从用户态进入内核态呢?我们知道,进程是在CPU中运行的,CPU中又有许多寄存器。这些寄存器又分为可见寄存器和不可见寄存器两大类。这些寄存器里面就包含了当前进程执行位置的上下文数据。这些寄存器有不同的分工,例如有的寄存器里面保存了进程的PCB的地址,有的寄存器里面保存了进程的页表的起始地址。而在这些寄存器里面有一个CR3寄存器,它里面保存了很多bit位,表征了当前进程的运行级别,0表示内核态,3表示用户态。所以一个进程要从用户态进入内核态,就是将CR3寄存器中的内容由3置0。此时,该进程就有了更高的执行权限。

  1. 进程进入OS执行对应方法的过程

大家知道,在进程的PCB中有一个指针指向了一块虚拟内存空间,里面保存了进程的数据和代码。进程在CPU中运行时,需要将对应调用的虚拟地址传入CPU,CPU再通过地址找到对饮调用在虚拟地址空间中的位置,然后再将这些地址通过页表映射到物理内存空间上找到对应调用的物理内存地址。

但是,虚拟地址空间其实是分为两个部分的,即“用户空间”和“内核空间”。假设虚拟地址空间是4GB,那么03GB就是用户空间,34GB就是内核空间。一般来讲,用户只能访问其中的用户空间,无法访问内核空间。

既然虚拟地址空间有两个部分,那么理所当然的,虚拟地址空间其实是有两份页表的。一份页表是“用户级页表”,用于对用户空间的映射,另一份是“内核级页表”,用于内核空间的映射。

我们知道,每个进程都有一份独立的用户级页表,因为每个进程中的代码和数据各不相同。但是OS只有一个,OS系统中的代码也只有一份。所以,虽然每个进程都有一份独立的用户级页表,但是共用一份“内核级页表”。同时,无论进程如何切换,它所切换的也仅仅是用户级空间的内容,不会去修改内核空间的内容。因此,虽然每个进程都有一份独立的内核空间,但是它们上面的内容都是一样的,保存的都是与系统相关的内容,如系统调用。

进程有了这份内核空间后,要执行系统调用就很简单了,直接从当前位置跳转到虚拟地址空间中的内核空间中找对应函数的地址,然后再通过内核级页表映射找到物理内存空间上的对应函数的地址。

但这里又有一个问题,用户是不允许直接访问内核空间的,在进程进行跳转之前,它依然处于用户态,是没有权限进入内核空间的,那么它是在什么时候从用户态切换为内核态的呢?其实就是在进行系统调用的时候。在系统调用接口的起始位置,OS进行一定处理,使用“lnt 80”来让进程陷入内核,即修改CPU的CR3寄存器中的值为0。此时进程就陷入了内核态,可以进入内核空间执行代码。

  1. 信号的捕捉过程

在上文中说了,当进程在用户态时,如果要进行系统调用等操作,就需要陷入内核。但是当进程陷入内核后,例如进行了系统调用,进程并不会直接返回,而是会去该进程的PCB中查找与信号相关的三个参数并进行比对,如果在位图里面显示没有信号需要递达,就返回。但是如果进程通过比对pending位图和block位图的时候发现,有信号需要被递达,那么它就会去信号对应的函数指针数组中找对应的方法,在找方法时又会出现两种情况。

第一种就是该信号使用的是操作系统中提供的默认方法,此时进程直接在内核态中调用对应的默认方法处理信号即可。

第二种就是数组中保存的对应方法的地址被用户修改成了他自己写的方法的地址,此时这个地址所对应的函数存在于用户态,所以进程此时就又需要用将自己切为用户态,然后在用户态中调用信号的处理方法。当调用完成后,进程并不是直接返回它进入内核之前的位置,因为此时它无法找到自己原本在用户态的位置。所以它必须重新将自己切回内核态,再在内核态中调用特定的函数找到自己原本运行到的位置,并在跳转时将自己从内核态切为用户态。

从文字上看可能不太容易理清逻辑,所以可以将整个过程看成如下图:

上图是包含了无需递达信号,需要递达信号,用默认动作处理和需要递达信号,用用户自定义动作处理三种情况。如果仅仅只看使用用户自定义动作进行处理的情况,就可以看成如下图:

  1. 信号递达

6.1 sigset_t

系统中真正的位图并不是一个简单的整数就完成了,而是会套一些东西。每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储。sigset_t就称为信号集。这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。其中,阻塞信号集也叫做当前进程的“信号屏蔽字”。这里的屏蔽是指阻塞而不是忽略。

6.2信号集操作函数

信号集内的数据存储是依赖与OS来完成的,所以用户无权直接修改,必须要使用相关的系统调用函数来操作。

6.2.1 初始化信号集为全0

使用sigemptyset()函数可以将一个信号集清为0,即该信号集中不包含任何信号。

6.2.2 初始化信号集为全1

sigfillset()函数可以将信号集置为全1,即该信号集中包含所有信号

6.2.3 向信号集中增加信号

如果想向信号集中增加某个信号,就可以使用sigaddset()函数

6.2.4 删除信号集中的某个信号

如果想去除信号集内的某个信号,就可以使用sigdelset()函数

6.2.5 查询信号集中的某个信号

如果想查询信号集中是否存在某个信号,就可以使用sigismember()函数

6.2.6 对阻塞信号集操作

如果要操作阻塞信号集,就要使用sigprocmask()函数。

该函数的第一个参数是标志你想如何操作阻塞信号集。需要传入宏。该函数一共有3个宏,如下图:

第一个宏SIG_BLOCK的作用是将向当前进程的阻塞信号集中添加set信号集中所包含的信号。

第二个宏SIG_UNBLOCK的作用是将当前进程的阻塞信号集中出现在set信号集中的信号解除屏蔽。例如如果set信号集中包含了1号信号,该函数就要将阻塞信号集中的1号信号解除屏蔽

第三个宏SIG_SETMASK的作用是将当前进程的阻塞信号集设置成和set信号集一样。

再来看第二个参数sigset_t* set,这个参数就是要传入的信号集的指针,与第一个参数强相关。

第三个参数oset是一个输出型参数,用于记录阻塞信号集被修改前的样子。当后面需要恢复信号集时,就可以用oset中保存的内容。

6.2.7 读取pending信号集

如果想获取当前进程的未决信号集,就可以使用sigpending()函数。它的参数是一个输出型参数,用于接收信号集内容。

获取成功返回0,失败返回-1

  1. 写一个demo代码查看进程的未决信号集

有了上面的函数,就可以写一个简单的程序,修改当前进程的阻塞信号集和打印当前进程的未决信号集。

上面就是一个简单的程序。该程序会将信号2设进giant进程的阻塞信号集中,并打印出该进程当前的未决信号集。

运行程序后就可以看到如上结果。在发送2号信号前,pending信号集为全0。当发送了2号信号,因为该信号被阻塞,所以被保存在了pending信号集中。

当然,还可以修改一下代码,让这个程序经过一定时间将block信号集恢复为原样。

在打印中加上如上判断即可。运行该程序:

可以看到,当经过了10s后,该程序就退出了。原因就是10s后2号信号不再被阻塞,进程开始处理未决信号集中保存的2号信号,程序退出。而上面的程序中的最后一句话未打印的原因就是此时程序退出,下面的代码未被执行。如果想执行这句话,将打印放到sigprocmask()函数上方即可。

8.信号捕捉

在这里,介绍两个捕捉信号的方法。

8.1signal()函数

signal()函数大家应该都不陌生了,该函数可以用于捕获一个信号,并让进程用用户设置的自定义方法处理该信号:

这一函数很简单,就不再过多赘述。

8.2 sigaction()函数

sigaction()函数的作用和signal()函数的作用是一样的,但是它的功能比signal()函数多。

第一个参数signum,就是要捕获的信号的编号。

第二个参数act是一个结构体指针,先来看文档中关于该结构体的描述:

对于这个结构体,只需要关注图中圈出来的两个参数即可。其它参数目前还不用关心。首先一个参数很明显,是一个函数指针重命名,这个参数就是用户的自定义方法。第二个参数sa_mask先不用管,在下面进行解释。

第三个参数oldact是用于保存进程的原信号的内容的。假如我们修改了 一个进程的信号, 可以用oldact进行恢复。

写出以下测试程序:

运行该程序:

可以看到,程序可以正常捕获信号。

但是,如果修改程序,让这个程序在处理信号时需要多消耗时间:

运行该程序,并在该程序处理信号的时候继续继续发送2号信号:

可以看到,虽然发送了5次2号信号,但是该进程却只递达了2次2号信号。原因很简单,当进程正在递达某个信号时,OS会自动将当前信号加入该进程的信号屏蔽字中。在上图的程序中,进程正在处理2号信号,在进程处理2号信号的过程中,OS就将2号信号加入了该进程的信号屏蔽字,使得该信号无法被再次递达。

那为什么会递达两次呢?原因就是,当进程完成了信号的捕捉动作后,OS又会自动接触进程对该信号的屏蔽。并且虽然进程阻塞了信号,但是该信号依然会被OS加入该进程的未决信号集中,2号信号的bit位由0置1。又因为未决信号集中每个信号只有一个bit,只能记录一次,当进程解除了对该信号的阻塞后,就会自动递达该信号,导致信号的2次递达。

到现在再来看sa_mask。该参数可以用于添加需要屏蔽的信号。被添加进该信号集的信号,在当前进程递达其他信号的时候,也会被OS自动屏蔽。

将程序修改如下:

运行该程序,反复发送2号信号,然后发送3号信号:

可以看到,当2号信号正在被递达的时候,发送3号信号也无法结束该进程。只有当2号信号被递达两次后,此时进程没有再递达信号,因此OS将3号信号解除屏蔽,此时未决信号集中存在3号信号,所以进程需要递达3号信号,当3号信号被递达后,进程结束。

上面的测试也就说明,进程处理信号的原则是串行处理同类型的信号的,不允许递归。

五、特殊情况

  1. 可重入函数

要理解可重入函数,先来举一个例子。假如现在我们写了一个main()函数,该函数中需要进行单链表的插入:

假设当mian()函数的执行流执行到插入,并且刚好执行完“p->next = head”,该链表中就头插入了一个节点,但是该节点插入并没有完全插入:

此时,该进程因为某种原因,比如该进程在CPU中的时间片到了,于是该进程陷入内核并被挂起。但再次轮到该进程执行时,该进程在内核中被唤起。在唤起后该进程需要检测信号。如果此时该进程检测信号发现需要递达某个信号后,就会去调用该信号的处理方法。而在该信号的处理方法中,也调用了insert()函数插入一个node2节点,该进程就需要执行该函数:

完成插入后,链表就会变成如上所示。当进程递达信号后,如果未结束,就会重新回到main执行流中继续执行在陷入内核前未执行完的insert()函数,将node1插入到该链表中。但是在陷入内核前,执行流已经完成了“p->next = head”,所以它会直接执行“head = p”代码:

当执行完后,链表就会变成如上图所示。可以发现,此时node2节点丢失了。

一般而言,我们认为,main执行流和信号捕捉执行流是两个执行流,它们的执行并不会影响对方的执行。但是在这里,就导致了节点丢失的问题。

因此,如果在main中和在handler中,一个函数被重复进入,会出问题,这种函数就叫做“不可重入函数”;而如果不会出问题,就被叫做“可重入函数”。

注意,函数是否可重入,并不是一个需要解决的问题,而是函数的一种特性。所以并没有解决方法。一般而言,只能自行注意。

要衡量一个函数是否可重入,可以用以下两个方法判断:

(1)调用了malloc或free()。因为malloc也是用全局链表来管理堆的。

(2)调用了标准I/O库函数。标准I/O库中的很多实现都是以不可重入的方式使用全局数据结构的。

而在实际使用中,一般来讲我们所用的函数中,有70%以上的函数都是不可重入函数。

  1. volatile关键字

在了解这个关键字之前, 先写如下程序:

运行该程序后,结果如上图。从结果上看,并没有什么奇怪的。但是大家应该听过一句话,那就是“编译器会对我们的程序进行优化”。这个优化到底是什么样的我们其实也无从得知。在linux中输入“man gcc”。在这个文档的底行输入“/--O1”,就可以找到下图中的内容:

这其实就是编译器的优化级别。一般来讲,编译器的优化级别都是-O1、-O2。但是我们是可以手动修改生成的程序的优化级别的。只需要在编译命令中加入指定的优化级别即可:

加入了优化级别后,再来运行该程序:

可以看到,当向该进程传入2号信号后,2号信号被捕捉,在这里就将quit修改为了1。但是,虽然quit被修改为了1,进程却没有退出。在没有加优化级别的时候,进程都会正常退出, 那为什么加了优化级别后,进程就无法正常退出了呢?

从现象上看,结果肯定是编译器优化导致的。原理也很简单。大家知道,一个进程在运行时,是需要将自己的数据加载进CPU中的寄存器里的。以上述程序为例,在正常情况下,每次运行“while(!quit)”语句时,都需要从内存中将quit读取到寄存器上,然后根据quit的值来进行判断。如果这个值在main执行流中进行了计算,CPU就会将数据再重新写进内存里。当然,在上面的程序中quit并不需要返回,所以不考虑这一步。

但是,在我们所写的程序中,quit变量仅仅只是用于判断,并没有进行修改。此时,编译器就将quit变量的值直接放到了寄存器内。当程序需要使用quit时,直接使用寄存器内保存的quit值即可,无需再回到内存中读取quit再加载进寄存器。这就导致输入2号信号后,又有了一个handler执行流,虽然该执行流中就将内存中的quit修改了,但是CPU在使用quit时,并不会返回内存中读取quit,而是继续使用保存在寄存器内的临时变量quit = 0。因此,while(!quit)中所使用的quit并不是内存中已经修改过的1,而是修改前保存在寄存器内的0.

要知道,这是编译器的默认优化行为,每个系统和编译器的优化级别和优化方案都可能不同,所以这种情况在某些编译器中是可能发生的。为了防止这种情况发生,就有“volatile”关键字。该关键字的作用是“保持内存可见性”。简单来讲,就是无论某个变量的作用是什么,是否被修改,只要需要使用这一变量,就都需要返回到内存中读取该变量的值,再将它的值加载进寄存器使用。

因此,修改程序,给quit加上volatile关键字并再次运行:

此时进程就可以正常结束了。

  1. SIGCHLD信号

大家知道,当子进程退出,父进程没有退出的情况,如果父进程不使用waitpid()函数来回收子进程的资源和退出信息,就会导致子进程成为僵尸进程。但是在实际上,当子进程退出时,父进程是知道子进程退出了的。因为当子进程退出时,OS会向父进程发送SIGCHLD信号。但是为了不妨碍父进程执行代码,所以SIGCHLD信号的默认动作时忽略。也就是说,父进程虽然知道子进程退出了,但是并没有对其进行处理。

写以下代码来进行测试:

运行该程序:

可以看到,此时就捕获了一个17号信号,该信号就是子进程退出时OS发送给父进程的信号。

在这里再讲一个问题,假设一个父进程有10个子进程,在某一时刻有5个子进程要退出。此时如果父进程调用waitpid()去等待子进程,虽然其中有5个子进程会退出,但是还有5个子进程不会退出。如果父进程要等待全部子进程,就会导致在子进程未退出时,父进程依然需要等待子进程结束,此时就会导致父进程被阻塞,难以执行其他操作。所以,遇到这种情况时,就要在waitpid()的第三个参数中写入“WNOHANG”,表示非阻塞等待。即当遇到阻塞时,就不再等待:

如果将waitpid()中的等待进程的pid填为“-1”,就表示等待任意进程。

讲完这个问题, 再来看第一个问题。

运行上面的程序,注意,该程序中没有写waitpid()。所以我们运行起来后,在父进程未结束时,子进程就结束了。此时会导致子进程成为僵尸进程。输入“ps axj | grep 进程名”查看进程,可以看到,此时确实存在一个僵尸进程。

现在我们知道,当子进程退出时OS会向父进程发送SIGCHLD信号,但是该信号的默认动作是忽略。如果我们既不想写waitpid()函数,又想让父进程在接收到这一信号的时候立即就回收子进程的资源和退出信息,就可以在signal()函数的第二个参数中输入“SIG_IGN”显式忽略信号:

注意,在这个程序里面,依然没有写waitpid()来等待子进程结束。运行该程序:

同样是子进程结束,但父进程未结束,此时就没有出现僵尸进程。因为当SIGCHLD信号被递达时,父进程就立即去回收了子进程的资源和退出信息。

在OS中,17号信号的默认动作是 ign:

虽然在OS中的ign和用户显式设置的SIG_IGN都是忽略,但是实际动作是不一样的。OS的ign是父进程忽略了SIGCHLD信号后,什么都不做,继续做自己的事。而用户显式设置的SIG_IGN是父进程虽然要忽略这一信号,但是会先去将子进程的各种资源信息处理回收再忽略。

但是要注意,传“SIG_IGN”这一做法只在linux环境下可行,其他环境如unix下不保证该方法可行。

标签: linux 运维 服务器

本文转载自: https://blog.csdn.net/Masquerena114514/article/details/129771357
版权归原作者 网络天使莱娜酱 所有, 如有侵权,请联系我们删除。

“初识linux之信号”的评论:

还没有评论