Linux 信号

目录

1 信号

2 信号产生

2.1键盘产生信号

signal

2.2 调用系统接口发送信号

kill

raise​编辑

abort

2.3 硬件异常产生信号

2.4 软件条件产生信号

alarm

Term和Core

3 信号的保存

4 信号的递达

内核态与用户态

信号集

sigprocmask

sigpending

sigaction

SIGCHLD信号

可重入函数

volatile


1 信号

信号我们之前使用过,但是可能不知道信号到底是什么以及它的原理等,那么这篇文章我们就来介绍一下Linux的信号的相关知识点。

首先信号是什么?我们前面还学过一个大小叫做信号量,他俩有什么关系吗?答案是什么关系都没有,就如同老婆饼里没有老婆,信号和信号量也是没有任何关系的。

那么我们信号量部分会讲什么内容呢? 我们要讲的是关于信号的三个时刻的内容,首先是信号的产生,其次是信号的保存,最后是信号的处理 这三个阶段。

以前我们经常使用kill -9来杀死一个进程,那么除了kill -9 还有哪些信号呢?我们可以使用kill -l命令来查看

信号的个数还是非常多的,首先每个信号前面都有一个数字,这叫做信号的编号,而后面的大写的字符串也就是信号的名字,其实就是用#define的宏,他们的值就是前面的信号编号的值,在使用信号时,既可以使用编号,也可以使用后面的名字或者说宏。 

我们发现信号编号有  1-31  和  34-64  ,那么这两个区间的信号有什么区别呢? 

1-31 编号的信号叫做普通信号,34-64编号是实时信号。 而我们学习信号重点学的是普通信号。

那么信号到底是什么呢?

首先要从我们日常生活中的信号入手,我们生活中最常见的信号就是马路上的红绿灯,而我们知道,红灯停绿灯行黄灯亮了等一等,这一句话基本贯穿我们的童年,所以我们能够知道红绿灯的每一个信号或者说颜色的含义以及我们要做的行为。但是,红绿灯信号可能随时产生,比如我们走一半绿灯变红灯了,难道我们就停在马路中间吗?或者说我们正在有其他的更重要的事情在做,但是绿灯亮了,这时候我们必须要过马路吗?这都不是必须的,也就是说当我们收到对应的信号时,并不一定要马上就执行他的行为,而是应该在合适的时候去做这些行为,比如说忙完手头的重要的事。同时,我们在绿灯过路口的时候,必须是走着过马路吗?默认情况下好像都是走着过马路,但是我们也可以跑着过,也可以骑自行车过,等等,并不是说我们的行为就只能是小时候长辈以及老师们教我们的,也可以自己决定。

对于我们的操作系统的信号也是一样的,首先我们有一个共识,就是信号是发给进程的,就像我们以前使用kill -9来杀死一个进程一样,我们也是需要指定进程的pid的。

一个进程收到一个信号,首先他要能够识别信号的含义以及知道要产生的行为,信号的含义以及默认的行为是程序员已经写好了植入到操作系统里面的。而进程也可能随时收到信号,收到信号之后也不是必须立即执行,而是要在合适的时间执行相应的动作,比如操作系统发信号过来的时候进程正在往显示器的缓冲区中写数据,并不会说就立马停下来处理这个信号了,而是要在写完之后再来处理。

那么,从收到信号到处理信号之间就有一个时间窗口,所以进程必须要有保存信号的能力

而收到信号之后,我们可以执行操作系统为进程设置好的默认行为,也可以是忽略该信号,还可以是执行我们的自定义的i行为

进程处理信号有一个专业的名称叫做信号被捕捉,捕捉动作有三种,默认动作、忽略动作和自定义动作。

我们说了进程必须要有保存信号的能力,那么应该要保存在哪呢?答案是进程的PCB

那么如何保存呢?我们只考虑普通信号,编号从1到31,一共是32个信号,同时,我们要保存的是进程是否收到某个信号,这是两态的,也就是只有是(1)和否(0)两种状态。那么进程是不是可以直接用一个 unsigned int 的整型来保存收到的信号呢?一个无符号整型是四个字节也就是32个比特位,每一个比特位都能够用来代表一个信号,而这个比特位的内容(0或1)来代表是否受到该信号,0表示未收到,1表示收到了,也就是把一个无符号整型当成一个位图来用。

用一个位图结构来保存信号的收到与否

当然实际上的保存可能不是用一个无符号整型,而是用其他的位图结构,但是不管怎么说,他肯定使用位图结构来保存在PCB中的。

既然我们大概知道了信号保存方式,那么我们怎么理解发送信号呢?

发送信号的本质就是修改PCB当中信号的位图结构

把某个信号的对应位由0变为1,那么就是向该进程发送了该信号,而把对应位由1置0,同时完成了相应的捕捉动作,那么就是处理信号。这是不是就好理解了。

而进程的PCB是属于内核数据结构,也就是由操作系统来定义和维护的,那么修改这个信号的位图结构也只能由操作系统来修改,我们自己的进程是没有这个权力来修改进程的PCB的。

无论我们使用哪个方式来给进程发送信号,最终都是要由操作系统来向目标进程发送信号也就是修改PCB中的信号位图结构的。那么操作系统就一定要提供发送信号和处理信号有关的系统调用

那么我们也就能够理解,以前使用的kill 命令,他在底层一定通过系统调用来完成的。

总结下来,信号的流程一定是有三个阶段的,一是产生信号也就是发送信号,二是保存信号,三是捕捉信号。 我们可能会理解发送信号就是修改位图,而保存信号也是修改位图,那么为什么不把他们归到一起呢?我们这里说的保存信号是一种状态,表示的是信号已经送达给进程了,但是进程还没有处理的这个时间段的状态。

那么我们接下来就是细讲这三个阶段的有关的知识,同时在信号部分的接口我们也是穿插在这三个部分中来讲解的,当然接口的位置不一定是于该阶段有关的,但是我们可能也会先拿出来用,以便更好地解释我们所看到的现象。

2 信号产生

2.1键盘产生信号

在学习Linux的命令的时候,我们讲过一个热键Ctrl c ,用来终止某个前台进程,当时我们是无法理解他是怎么做到的。

在本质上,这个组合键会被操作系统识别,将其解释为2号信号SIGINT,INT就是终端的缩写,我们可以使用man查看7号手册的signal部分看一下2 号信号的默认动作。

他的默认动作是Term,也就是termina,终止进程,它的含义是从键盘中中断。

那么我们怎么证明是ctrl c 是转换为发送二号信号呢?

我们需要使用到一个接口 

signal

signal是一个系统调用,它的作用是用设置信号捕捉的动作,也就是信号的处理动作。

首先该函数有两个参数,第一个signum是信号的编号,handler则是一个函数指针,在上面已经声明了类型的重命名,同时这个函数返回值是void,同时参数为int,也就是信号的编号,有且只能由这一个参数这个函数将来会作为处理该信号的方法,也就是我们说的自定义动作。同时我们还可以将第二个参数设置为SIG_IGN或者SIG_DFL,这两个其实就是一个宏,我们再将信号的保存的结构的时候会详细讲一下。

同时函数描述中也标识了,有的信号时不能被捕捉或者忽略的,只能是执行操作系统设定的默认行为,也就是我们的SIGKILL和SIGSTOP

要注意的是:

signal函数知只是设置了指定信号的捕捉方法,并不代表当前会或者一定会调用该方法,而是对将来的涉资,在未来是否调用该方法取决于是否受到了指定的信号

那么,我们将2号信号的捕捉方法设为我们自定义的函数,那么是不是当我们发送2号信号时,就会执行我们定义的方法。接下来我们验证一下ctrl c最终是不是给我们的进程发送了2号信号

我们使用ctrl c 之后,屏幕上打印了我们的自定义方法的内容,所以其实就是因为收到了2号信号所以我们才能够使用ctrl c来终止前台进程的。

那么现在我们无法使用Ctrl c来终止这个前台进程了,除了使用 kill -9 来杀掉该进程,我们还有没有其他的办法呢?

有的,我们也可以使用 ctrl  \ 来终止前台进程,那么从上面的ctrl c可以推断,ctrl \ 也肯定是被操作系统转换为了发送某个信号来终止进程的,那么是哪个信号呢?我们可以使用他来终止进程来看一下。

它提示是 Quit 这个信号,我们可以在信号表中查一下是几号信号

他就是3号信号,怎么证明呢?我们也可以使用把上面的方法设置为3号信号的捕捉方法。

3号信号的默认行为也是终止进程,但是他和2号信号有点不同

他的动作是Core 的终止进程,而2号信号是Term终止进程,二者的区别后面会讲

这个时候我们就也还能通过kill -9来终止他了

这就是第一种信号产生的方法,其实就是通过读取键盘的输入,操作系统将特定的键盘输入转换为了信号,然后发送给特定的进程

同时我们发现,好像大部分的信号的默认处理动作都是终止进程,那么设置这么多的信号有什么意义呢?

信号的意义不是由收到信号之后的处理行为决定的,信号的不同,代表发生了不同的事件,这才是信号的意义,发生不同的事件之后处理动作是可以相同的。

2.2 调用系统接口发送信号

操作系统也给我们提供了一系列接口让我们能够来给指定的进程发送信号

kill

kill 其实也是一个系统调用接口

kill接口需要两个参数,第一个是进程的pid ,表示给指定pid的进程发,而sig则是信号编号。

同时下面也标注了他的一些特殊用法,

第一个参数传0,表示向调用kill的进程所属的组里面的所有进程发送sig信号

第一个参数为-1,表示向除1号进程之外的所有的有权限发送信号的进程发送sig信号。

第一个参数小于-1,则表示向组id为 -pid的所有进程发送sig信号

第二个参数sig为0,表示发送空信号,我们可以通过返回值来判断当前进程是否有权限向指定进程发送信号。

当然我们正常使用的话也很少用这些特殊的情况,只需要记住第一个参数是目标进程的pid,第二个参数是要发送的信号编号就行了。
该函数信号发送成功就返回0,失败就返回-1

比如我们可以封装这个接口,来实现一个命令行的kill。

要封装这样一个程序,我们的main函数需要接收保存命令行参数,kill命令需要三个命令行参数,第一个是kill程序,第二个是 -信号编号,第三个是进程pid,我们只需要将它们转换为数字传给kill 就完成了

#include<iostream>
#include<unistd.h>
#include<cstdlib>
#include<sys/types.h>
#include<signal.h>
#include<cstring>
#include<cerrno>
#include<string>


int main(int argc,char*argv[])
{
    if(argc!=3)
        exit(1);
    int pid=std::stoi(argv[2]);
    int signum=std::stoi(argv[1]+1); //把信号编号的 - 忽视
    int ret= kill(pid,signum);
    if(ret==-1)
        std::cout<<strerror(errno)<<std::endl;


    return 0;
}

该程序所达到的效果就是kill命令的效果。

kill是操作系统给我们提供的向指定进程发送信号的接口,用户可以调用该接口,但是最终发送信号的操作还是由操作系统完成。

raise

raise只有一个参数就是要发送的信号编号,该函数是用于给调用该函数的进程发送信号的,他其实是一个C语言接口,在底层封装了 系统调用kill。没什么好讲的

abort

该函数是用来引起进程退出的,他发送其实就是6号信号,但是在发送之前会先解除进程对6号信号的阻塞,关于阻塞我们也会聊到,

其实这个接口也没什么好说的,最终也还是会落到 系统调用接口 kill上,不过是封装了而已。而系统调用kill发送信号最终还是落到了操作系统自身上,由操作系统完成发送信号的操作。

2.3 硬件异常产生信号

信号并不一定只能由用户来显式调用接口来发送,操作系统内部也会自动产生信号,最典型的就是硬件异常产生的信号。

什么是硬件异常呢?

我们以前有没有写过除 0 或者 空指针解引用的代码?每次执行这样的代码程序都会崩溃,为什么会崩溃呢?其实就是因为操作系统发送了信号。我们可以看一下除0操作系统发送的是什么信号

这个信号就是我们的8号信号SIGFPE

可是我们并没有自己发送8号信号,而进程又收到了该信号,所以这其中肯定是操作系统自动发送的该信号了,而操作系统自动发信号又是由于硬件异常引起的。

我们知道,代码的执行是由cpu来进行的,当我们进行除 0 运算是,cpu中会有一些通用寄存器来保存我们的操作数和运算结果。而如果除数是0,0在计算机中其实就是无穷小,一个数除无穷小那他的结果就是无穷大,可是cpu的寄存器能够保存下来无穷大的值吗?答案当然保存不了。 而cpu内部除了我们的普通的寄存器之外,还有一个寄存器叫做状态寄存器。状态寄存器中有一个比特位是溢出标志位,当我们的运算结果溢出的时候,这个状态寄存器的溢出位就会从0置为1,代表本次运算处于溢出状态,溢出状态也就是得到的结果是错误的,也就代表了本次计算结果是没有意义或者非法,不需要被采纳。所以除0操作虽然cpu可以运算,但是结果是错的。由于运算结果溢出,状态寄存器的溢出标记位变为1,就触发了cpu的运算异常,这时候cpu会通知操作系统。而操作系统会来识别cpu的异常类型,其实也就是去看状态寄存器的哪一位出了问题从而识别出错误类型,同时操作系统是也会知道cpu当前正在调度的进程是哪个,也就是出错的进程,再然后操作系统就会向出错的进程发送对应的信号来终止进程。

如果我们将8 号信号捕捉了,然后会发生什么呢?我们也可以在代码修改试一下

我们会发现,好像一直在调用捕捉8号信号的方法,这就意味着操作系统一直在向该进程发送8号信号,为什么呢?

在我们自定义的方法中,并没有让进程退出,所以进程收到8号信号在之后还是还是在运行状态,而由于状态寄存器异常,这时候cpu也不会继续去执行后续的代码。我们以前说过,cpu的寄存器只有一套,是属于cpu自己的,由cpu来维护,但是cpu的寄存器中的数据是进程的,在进程被切换时会保存在进程的上下文中,下一次调度时首先会加载进程的上下文数据,然后再开始执行后续的代码。 cpu也不敢往后执行,知道进程时间片耗尽,被切换下去,但是cpu 中的数据还是会被保存到进程的上下文中,而下一次轮到该进程被调度时,cpu首先加载他的上下文数据,加载进来之后,状态寄存器的溢出位为1,则又会通知操作系统来识别异常和发送信号。 为什么这一次调度的时候状态寄存器的溢出位还是1呢?因为寄存器是由cpu维护的,而进程是没有权限去修改他的状态寄存器的,同时由于硬件异常,正常情况下,该进程会收到信号然后退出,而我们的进程由于死皮赖脸并没有退出,但是cpu又由于该进程引发了硬件异常,不敢让这个进程执行后面的代码,也不敢随便把状态寄存器恢复,而状态寄存器作为寄存器的一员,他的数据也会作为上下文的一部分保存到进程中,所以就每次调度该进程时这个异常状态也会被设置。

那么空指针或者野指针的解引用为什么会导致我们的程序崩溃呢?这时候收到的是11号信号,表示的是段错误,我们也不进行验证了,各位可以自己去验证一下。

这为什么也会引发硬件异常呢? 我们的cpu在进行指针的访问之前,首先是要将虚拟地址加载到寄存器中 ,然后通过页表转换为物理地址来进行访问,cpu中有一个专门的 MMU内存单元用来将虚拟地址转换为物理地址,其实他就是去访问进程的页表的数据,从未进行转换。如果要解引用的是空指针,由于空指针也就是 0号地址,这些低地址一般操作系统是不会给用户用的,所以页表中不可能有 0 号地址映射的物理地址,MMU就转换不出物理地址,这时候MMU会拦截进程的访问,同时会发送异常通知操作系统,操作系统会来识别异常并且发送对应的信号给进程 。

那么为什么有时候我们对栈上的一些轻微的越界访问不会报错呢?比如我们在栈上开辟一个10个元素的数组,但是我们访问到10号下标可能会报错,这里的报错并不是操作系统的报错,而是C语言对数组越界的检查,而C语言对越界的检查是抽查,当我们访问不在该数组的边界附近,而是访问比如说14或者15号下标的时候,我们的程序并不会报错或者崩溃,但是当我们的下标过于离谱,比如说下标为几万,那么还是会崩溃,为什么呢? 

我们在栈上开辟的数组,他的空间是在我们的函数的栈帧内的,之所以我们访问数组的边界的越界下标会报错,这是C语言在语言层面就拦截下来进行报错了的,还没有涉及到我们的MMU,当我们访问边界稍微远一点的下标时,MMU会去页表中找我们的映射关系, 由于我们并没有超出我们当前的函数的栈帧范围,所以我们目前访问的空间确实是当前进程的,也就是能够通过页表转换出来物理地址,所以访问并不会报错。而当下标过于离谱时,肯定是因为超出了函数栈帧,MMU无法转换出物理地址,所以才导致操作系统识别到异常并发送信号来终止我们的进程。

以上就是操作系统由于硬件异常而自动发送信号。

2.4 软件条件产生信号

在进程间通信的时候,我们曾经试验过,当管道的通信读方和写方其中有一个关闭文件描述符时,我们的进程就会被终止,是因为受到了SIGPIPE信号,这是因为操作系统为了防止我们浪费资源而终止的。但是这里的整个过程和硬件没有一点关系,为什么操作系统会自动发送信号呢?这就是由于软件条件引起的,什么软件条件呢?就是读端关闭或者写端关闭这样的条件。

那么软件条件产生信号还有其他的场景吗?

alarm

这个函数是用来给调用该函数的进程发送 alarm 信号的,他的参数是一个整数,其实就是一个倒计时,该函数的作用就是在倒计时(单位是秒)结束后发送SIGALRM信号给当前进程。

我们可以简单理解为就是为进程设了一个闹钟,闹钟时间到了就会发送信号给进程,SIGALRM信号的默认动作也是终止进程。这就叫做基于软件条件所设定的闹钟,这也是一种发信号的方式。

那么这个函数有什么应用呢?我们可以捕捉他发的信号来做一些特别的事情,比如说可以用来统计一秒内计算机的累加的次数?

我们是不是会感到奇怪,计算机或者说cpu的处理速度这么慢吗? 并不是,其实这里的时间大部分都是消耗在了等待外设的过程中,并不是cpu的处理速度慢,而是与外设的io的速度慢,可以只在捕捉SIGALRM信号时打印一次cnt的值,就可以知道计算机在这一秒内进行了多少次累加计算。

这时候要把cnt定义为全局变量,因为捕捉函数只能有一个参数,且只能是自动传的信号编号。

这时候我们也就能理解为什么我们老是说 cpu 的速度和外设的速度差距很大了,看都能看出来,这都不是一个数量级了。

当然我们也可以设置其他捕捉的方法,来完成其他的我们想要进程做的事。

同时我们也可以在闹钟的捕捉方法里再来设置一个闹钟,这时候我们就可以得到周期性的闹钟了,有时候可以匹配一些特殊的场景。

同时我们给alarm参数传 0  ,传0的意思就是取消前面所设定的闹钟。

Term和Core

我们发现有很多信号的默认行为都是 Term 和Core ,他们都是终止进程,这两个方式有什么区别呢?

Term 的终止方式叫做正常结束,这种情况下操作系统终止程序,回收进程资源之后就不需要去做额外的工作了。

而Core的终止方式的话,操作系统除了结束该进程,还需要去做一些额外的工作,也就是我们所说的核心转储

但是我们上面使用 ctrl c  和 ctrl \ 的时候好像没什么区别啊,除了打印的提示信息有所差异之外,但是打印信息不同是正常的。

我们并没有看到Core退出有什么特别的地方,这是因为我们使用的是云服务器,而云服务器是默认关闭核心转储的,我们可以使用 ulimit -a 来查看一下系统的资源

我们可以看到分配给 core file 的大小是 0 个数据块 ,也就是相当于关闭了核心转储的选项。

我们可以手动设置一下,使用ulimit 命令,后面加上这些资源对应的选项,如上图,core资源的选项是 -c  ,最后再加上一个值,这个值就表示我们要分配的数据块的个数。

这时候我们再来试验一下 Term 和 Core  的终止方式有没有区别。

我们直接用一个含有空指针解引用的代码来试一下他终止之后会发生什么。

这时候进程在退出的时候,除了关于信号的提示信息,还多了一条提示,core dumped ,也就是核心转储。同时在我们的当前目录下形成了一个新的文件,文件名以core 开,文件的后缀是一串数字,数字就是进程的pid。

这个临时文件有什么用呢?或者说核心转储有什么意义呢?

当一个进程运行时出现崩溃的情况,我们最想要知道的肯定就是他为什么会崩溃以及在哪个位置哪行代码崩溃了,但是我们一行一行的调试起来就非常费劲,尤其是这种命令行式的调试,太麻烦了。所以操作系统为了方便我们后期做调试,在进程被终止的时候,它会将文明进程在运行期间出现崩溃的代码的相关上下文数据全部 dumped 转存到磁盘中,这样做最终目的还是为了支持调试

那么怎么用这个文件呢?

先不说其他的,首先编译的时候我们要带上 -g 选项来生成包含调试信息的可执行程序,然后等待进程崩溃,形成 core 文件。

然后打开我们的gdb,这时候我们就不需要打断点等操作来找错误了,我们再gdb的命令行使用 core-file 再加上出现崩溃时生成的那个 core 文件,gdb就自动定位到了出错的代码。

而对于Term 终止方式,则不会进行核心转储。因为term一般是我们主动杀掉的进程。

到了这里,大家可能会有一个疑惑,如果我们把所有的信号都用signal设置自定义捕捉方法,不进行推出的话,是不是进程万毒不侵了? 

操作系统在设计的时候肯定就已经考虑了这方面的事,所以有些信号时无法设置自定义方法的,比如我们的 9 号信号和19号信号SIGSTOP,这两个信号只能执行默认方法。

3 信号的保存

我们前面知识简单的说信号是通过位图结构来保存,但是具体是怎么保存的还是没讲清楚,在讲内核中号的保存之前,我们首先要介绍几个概念:

1 实际执行信号的处理动作称为信号递达

2 信号从产生到递达之前的这个状态,称为信号未决(pending),也就是信号收到了但是还未处理

3 进程可以选择阻塞(block)某个信号

4 被阻塞的信号产生后将保持在未决状态,直到进程对该信号解除阻塞,否则该信号永远不会递达。 

5 阻塞和未决是不一样的,被阻塞的信号如果产生一定处于未决状态,而未决状态的信号则不一定阻塞

6 阻塞和忽略也是不一样的,忽略是信号递达的一种处理动作,而阻塞的话该信号就不会递达。

信号在内核中的表示

在pcb中,并不是只用一个位图结构来进行信号的存储就够了,我们要用三个数组结构分别来保存每个信号 是否阻塞,是否产生以及递达的动作的函数地址。

首先是第一个用来表示信号是否被阻塞的 block 位图,我们叫做 block表 。既然是一个位图结构,我们也可以理解为是一个 4 字节的无符号整型,每个比特表示一个信号。

实际上操作系统不是用一个整形来保存的,而是用一个结构体中套大数组的位图结构来实现。

默认情况下,block表中的值都为0,也就是默认不阻塞所有信号。

pcb中用来表示是否收到信号是用一个 pending 位图也就是pending表,而 pending 的每个数据位的值就是表示进程是否收到该信号。

最后还有一个 handler 表,handler表中就是存储的信号的捕捉方法,我们使用signal方法进行自定义设置捕捉方法修改的就是这个表。实际上他就是一个函数指针数组,我们也能理解。

那么,一个信号被递达至少就需要两个条件,首先他在block表中没有被阻塞,其次他在pending表中对应位置的值为1,也就是收到了该信号。

如果一个信号没有产生,我们也是可以设置阻塞的,这是两个不同的数据位图。

最后的问题就是,因为信号保存在 pending 位图,那么就只有两种状态, 0 和 1 ,分别代表未收到和收到该信号,但是如果我们连续发送多个相同的信号,是不是在pending位图中 也只能记录一个呢?对于我们的普通信号而言就是这样的,因为pending位图只有两种状态,所以只能记录是和否 收到该信号,如果进程已经受到了该信号,但是还处于未决状态,在该信号被递达的这段时间内再次受到的这个相同的信号其实是没有意义的,相当于丢失了,每个信号都只能统计一次。

如果不想信号丢失怎么办?内核中还有一种信号叫做实时信号。实时信号的保存是用一个队列来保存的,每收到一个信号都会在队列中新增一个系欸但来保存,节点中包含了信号的编号和信号的处理方法等信息。

4 信号的递达

我们说i信号产生之后不会立即被处理,而是等到合适的实际,那么合适的时间是什么时候呢?

从内核态切换为用户态的时候。

内核态与用户态

我们以前两个概念:用户代码和内核代码。 

用户代码就是用户在自己的程序中写的所有的代码,比如自己写的算法,调用的库的函数等,这些代码全部都是运行在用户态的。但是我们在用户态执行用户代码的时候,难免会访问两种资源,操作系统的资源和硬件资源,比如getpid这种访问pcb资源的,或者printf这种要访问硬件资源的,这些资源在计算机软硬件层状结构中都是在操作系统或者操作系统的更下层的,用户要使用这些资源就必须直接或者间接调用操作系统提供的系统调用接口。但是系统调用接口的代码实现是在操作系统内核代码中的,我们无法以用户态的身份执行这些代码,必须切换成内核态。也就是说,在执行这些内核代码的时候,虽然执行代码的还是进程,但是他的身份确是内核,相当于操作系统暂时将一部分更高的权限借给了你,但是尽管如此,你在执行内核代码时还是会受到一些限制,不可能让你随心所欲利用这份权力。

所以,其实内核代码的执行是要比用户代码的执行更复杂的,因为这其中还要进行用户态到内核态的身份切换,同时执行完内核代码之后又需要从内核态切换回用户态。所以,频繁调用系统接口的程序的效率一般不会特别高,我们要尽量避免频繁调用系统调用或者说将需要调用系统调用的地方尽量放在一起,以减少身份切换的次数,来提高程序的运行效率。

那么到底什么是内核态什么是用户态呢?我们目前只能认识到用户态的权限比较低,内核态的权限比较高,但是这两种身份又是可以切换的,那么我们又怎么知道我们的程序当前实在用户态还是内核态呢?

很简单,我们的程序的代码归根结底还是需要cpu调度执行的,进程的上下文信息都保存在cpu的寄存器中,那么cpu在执行到一些内核代码时,他就必须要知道进程的身份,而进程的所有信息此时都是存在cpu 的寄存器中的,所以反推回来我们直到,进程当前的身份肯定是在cpu的寄存器中有所标识的。cpu中有一类寄存器叫做CR3寄存器,CR3寄存器中用比特位来表征当前进程的运行级别,比如其中一个寄存器的两个比特位用来表示进程的身份是用户态还是内核态,用 00 标识内核态,用11标识用户态,那么cpu只要去读取该寄存器中的值就能知道当前进程处于什么运行级别。

但是我们就又有一个问题了,当前进程不是正在占用cpu吗,怎么能够跑到内核中去执行代码呢?难道内核代码在进程的地址空间中?

不知道大家是否还记得,进程地址空间的 3-4G这一个G的空间被称为内核空间,之前我们将程序地址空间的时候都是讲的 1-3 G 的用户空间,并没有细讲内核空间的作用。

那么如今我们大概就能理解了,我们的用户代码以及所有的定义的变量以及申请的共享资源等都是映射在用户空间的,而内核空间自然就是映射的操作系统的内核的数据以及代码,只不过我们进城一般是没有权限去访问到内核空间的内容,除非切换到内核态。

之前讲的用户空间是通过一张页表来将虚拟地址映射映射到物理地址的,且这个页表是每个进程所独立的,我们把这个页表称为用户级页表。 而操作系统内部还维护了一张内核级页表,内核级页表是操作系统为了维护从虚拟到物理地址的操作系统级的代码映射所构建的一张表。在我们的计算机中,操作系统的代码在内存中只会存在一份,是在开机的时候加载到内存中的,同时操作系统的内核级代码是所有进程所共享的,同时内核级页表也是所有进程都是同一份,这就决定了,在每一个进程的用户空间中,可能有所不同,但是内核空间都是一样的,完全一样。

所以一个进程要访问操作系统的代码,只需要跳转到进程自己的进程空间的内核空间就能看到,然后跳转到物理地址上跳转执行内核代码。 

所以进程中调用了系统调用接口,无非就是从用户空间跳转到内核空间去执行代码,执行完之后再跳转回到原来的位置继续执行,那么这样一来,并不需要什么切换进程的操作才能执行内核代码,只需要在进程自己的上下文当中就能搞定了。

那么用户凭什么能够访问内核的代码和数据呢?

这取决于进程的运行级别是否是内核态,运行级别又保存在cpu的CR3 寄存器中。那么当操作系统识别到进程想要访问内核空间时,首先就需要进程权限认证或者说身份认证,也就是去读取CR3寄存器的数据,如果你当前是用户态,就会立马终止你的行为,如果你是内核态才允许访问内核空间。

但是我们在调用系统接口之前一定是处于用户态的,什么时候会切换为内核态呢?我们好像根本就没有写过切换进程运行级别的代码啊?这一点我们不需要担心,诸如我们的系统调用等需要切换到内核态去执行的,他们的其实的代码其实还是跑在用户态的,在用户态跑的这写起始代码会将进程从用户态切换到内核态,然后再去内核执行剩下的操作。 在Linux中有一个中断汇编指令 Int 80,称之为陷入内核,这个汇编指令就是修改寄存器状态,让我们的进程切换到内核态,在我们的系统调用中的地址的汇编指令中都有陷入内核的指令操作。

那么我们说信号捕捉会在从内核态返回用户态的时候进行,那么意思就是当进程由于一些原因(系统调用,进程切换等)陷入内核执行内核代码,当执行完这些内核代码之后,最终还是要返回到用户态去执行用户代码。但是呢,由于我们所了解的,陷入内核的代价其实不算小,陷入内核也不容易,所以操作系统会在返回用户态之前一内核态的身份做一些额外的工作,就比如进程的PCB中检测我们的信号以及递达信号。

操作系统在检测信号时,首先会遍历 block 表,当该信号未被阻塞时,再去查看pending位图的对应数位,如果pending位图中对应位为1,则说明该信号需要被处理,这时候回去查看handler表执行对应的捕捉方法。 如果对应的信号要执行默认动作,比如终止进程,那么由于当前进程还是处于内核态的,他是有权限去释放进程自己的资源来终止进程的。对于默认动作和忽略动作而言,他们的递达都很简单,因为默认方法和忽略方法都是在内核代码中的,关键是我们的自定义动作,自定义动作是用户代码,那么应该要以用户态执行,这时候还需要对应的调用将我们的进程切换到用户态来执行自定义捕捉方法,执行完之后还需要返回内核态继续检测信号,这是很复杂的。

那么有一个问题,内核态的权限是很大的,那么我们能不能直接以内核态的身份来执行用户态的代码呢?

从技术上来说一定是可以做到的,但是从操作系统的设计角度来说,这是绝对不能发生的。我们的操作系统是所有软硬件的管理者,他的权限十分之大,同时我们的系统调用都是一些基本不会出错的操作代码,以内核态的身份执行操作系统自己的代码这是没什么问题的,但是,我们用户自己写的代码,是可能会存在非法操作的,操作系统不会相信任何人,他无法识别到用户写的代码的逻辑是否非法,等到它发现用户逻辑非法,那就已经是硬件或者其他方面出现了问题了,这时候就已经造成错误了,为时已晚。所以操作系统是不容许以内核态的身份去执行用户代码的,而以用户态的身份执行用户代码,即便出错了,由于权限受限,他的错误的影响也有限,同时能够追根溯源找到错误的源头。

所以在捕捉方法的时候,自定义方法肯定是要执行的,只不过不能以内核态身份执行,要先经过特定的调用,将自己的身份重新切换到用户态。从内核态切换到用户态肯定是要比从用户态切换内核态要简单的,因为内核态有很大的权限,能够直接在cpu中去修改CR3寄存器,使进程回到用户态。

那么执行完一次自定义动作之后就直接能够回到原来的位置接着往后执行了吗? 还不能,由于执行自定义动作的时候是出于用户态的,用户态是看不到cpu中的有些寄存器的,所以进程当前不知道上一次跳转的位置在哪里,而是要先回到内核态,同时,由于进程可能不止收到一个信号,可能还有其它的信号或者在自定义方法执行的时候又收到了新的信号,那么进程从内核态回到用户态的时候还是需要去检测是否有信号未递达的。

总结一下信号的捕捉流程:

1 首先进程由于一些原因需要切换到内核态执行内核代码

2 内核代码执行完之后,再返回用户态之前,会利用内核态的高权限去检测是否有信号需要递达

3 有信号需要抵达的话,如果是默认动作或者忽略动作,那么直接在内核态执行处理动作,再继续检测其他的信号

4 如果是自定义动作,那么需要从内核态切换到用户态执行自定义方法,执行完自定义方法之后,会返回内核态继续检查信号

5 所有信号被递达之后,切换为用户态,返回原来的用户代码位置接着执行后续代码。

信号在递达之前,也就是实际处理执行处理动作之前,pending位图就已经由1置0了,而不是执行完才置0

信号集

PCB中,block表和pending表是一样的数据结构,只是一个用于标识信号是否阻塞,一个用于标识是否收到该信号,操作系统为了让用户更好的处理信号,提供了一种数据类型  sigset_t 类型,在用户层,阻塞和未决都采用这一个数据类型来表示,被称为信号集,block我们就叫block信号集,pending就叫pending信号集。其实在这个信号集中好使用 0 和 1 来表征两种状态,阻塞和未阻塞或者收到与未收到。 这个类型是为了让用户更方便设置PCB中的block表和pending表而设置的用户级数据结构,同时我们也可以用该类型来读取进程的阻塞信号集和pending信号集。

 阻塞信号集我们也称之为信号屏蔽字。

信号集的操作:

信号集是一种用户级的数据结构,并不一定是真正的PCB的底层结构。同时我们也不知道sigset_t的具体的底层是什么,不同的操作系统可能实现的方式也不一样,而作为使用者,我们不一定要了解它的底层,只需要直到对其进行操作的相关接口就行了,我们对信号集的任何操作,都要使用操作系提供的接口来完成,而不是简单的与或非操作,毕竟真实的信号机百分之百不是用一个整数来作为位图结构的,

相关接口:

相关接口及其作用如图

那么我们在使用 sigset_t 类型时,首先我们要使用 sigemptyset() 或者 sigfillset() 先将我们创建的信号集变量初始化,否则它里面是一些随机值。

后面三个接口就是用来设置信号集以及获取信号集内容的接口,如果我们要获取每个信号是否被阻塞或者pending,我们则需要用一个循环使用 sigismember() 来判断每一个信号是否在该信号屏蔽字或者pending信号集中。

那么如何获取进程的信号屏蔽字和pending信号集呢?

sigprocmask

该函数有三个参数,第一个是选项,也就是如何设置阻塞屏蔽字,第二个是输入的信号集的地址,第三个是一个输出型参数。

第一个参数有三次中选项  SIG_BLOCK 相当于做加法 ,SIG_UNBLOCK 相当于做减法, SIG_SETMASK相当于赋值操作。

当我们的第二个参数,输入型参数为nullptr时,则不会改变进程的信号屏蔽字,当第三个参数输出型参数为nullptr时,则说明不需要保存旧的信号屏蔽字。

因为调用sigprocmask设置信号屏蔽字是一次系统调用,会切换到内核态执行,如果是使用该函数接触对某一信号的阻塞,如果pending位图中该信号保存了,那么就会在返回用户态之前捕捉信号。 其实就是因为sigprocmask是一次系统调用,而操作系统在从内核态返回用户态之前会检测信号。

如何获取pending进程的信号集

sigpending

未决信号集则只能读取而不能设置,所以他就只有一个输出型参数了。

接下来我们写一段代码来看一下,当进程正在执行某一个信号的捕捉方法时,再收到该信号,会不会递归式的或者同一时刻执行多次该信号的捕捉方法?


void handler(int signum)
{
    std::cout<<"收到了"<<signum<<"号信号, 倒计时十秒"<<std::endl;
    int time =10;
    while(time)
    {
        printf("%2d\r",time--);
        fflush(stdout);
        sleep(1);
    }
    std::cout<< std::endl;
    std::cout<<signum<<"号信号捕捉完毕"<<std::endl;
}


int main()
{
    signal(2,handler);
    while(1);
    

    return 0;
}

然后我们可以测试一下连续 ctrl c发送2号信号会发生什么。

从该现象我们就能看出来,当该信号的捕捉方法正在执行时,该信号不会再次被执行,而是在执行完该信号之后再去检查对应的pending位图看一下是否又收到了该信号。 

但是我们也发现,在2号信号正在被处理时,他的捕捉方法还没有执行完,我们就可以使用3号信号来终止该进程,这说明,每个信号的捕捉方法被执行的时候,不影响操作系统检查和执行其他信号的捕捉方法,只会限制当前信号自己的执行,也就是禁止出现同时执行两次同一个信号的捕捉方法。

那么操作系统是怎么做到的在该信号正在被捕捉时不会再次执行他的捕捉方法呢?

我们前面讲了阻塞 ,当我们把一个信号阻塞之后,进程任然能够收到该信号,但是该信号会一直处于未决状态,直到接触对该信号的阻塞才会去递达该信号。

而操作系统就是依靠阻塞来实现这样的策略的,当一个信号被递达的时候,操作系统会自动将屏蔽该信号,捕捉方法执行完之后再解除该信号的屏蔽。

怎么证明呢?

我们可以捕捉方法里面打印一下信号屏蔽字。同时我们也可以打印一下pending信号集,来验证一下我们所说的在执行捕捉方法之前就已经将该信号的pending位置为0了,这就是为什么我们在2号信号的处理过程中再次发送2号信号,新发送的2号信号能被保存下来的原因,所以我们连续发多次2号信号(在第一次捕捉方法执行完之前),最终会执行两次捕捉。

void handler(int signum)
{
    std::cout<<"收到了"<<signum<<"号信号, 倒计时十秒"<<std::endl;
    int time =10;
    sigset_t block;
    sigset_t pending;
    while(time)
    {
        sigemptyset(&pending);
        sigprocmask(0,nullptr,&block);
        sigpending(&pending);
        printf("信号屏蔽字:");
        for(int i=32;i>=1;--i)
        {
            printf("%d",sigismember(&block,i));
        }
        printf("\n pending 信号集:");
        for(int i=32;i>0;--i)
        {
            printf("%d",sigismember(&pending,i));
        }

        printf("\n");
        //printf("%2d\r",time--);
        //fflush(stdout);
        sleep(1);
    }
    std::cout<< std::endl;
    std::cout<<signum<<"号信号捕捉完毕"<<std::endl;
}


int main()
{
    signal(2,handler);
    while(1);
    

    return 0;
}

同时我们也可以自定义一下三号信号的捕捉方法,看2号信号的执行是否为影响3号信号的捕捉。

我们在3号信号的处理方法中并没有打印信号屏蔽字和pending位图,大家可以把打印的代码复制一遍过去,最后说结论,我们从图中就可以看出来,当我们的2号信号的捕捉执行流正在运行的时候,由于我们使用了printf,他里面肯定会调用系统调用,会切换到内核态,而在printf的执行切换到内核态返回用户态之前,操作系统又会去检查信号,这时候检测到收到了3号信号,这时候就会其执行3号信号的捕捉方法,那么2号信号的捕捉执行流就相当于暂停了,直到3号信号捕捉完了,才回到2号信号的捕捉方法的原位置继续执行。 

同时,我们也能发现,当我们的三号信号的执行流正在运行的时候,再次发送2号信号并不会像发送3号信号一样在3号信号的printf的时候执行2号信号,这是因为上一个2号信号的捕捉执行流还没有结束返回,所以2号信号还在阻塞中,这时候当然不会这种捕捉2号信号。

进程处理信号的原则是 串行的处理同类型的信号,不允许递归式或者同时重复处理,所以会将正在处理的信号自动添加到屏蔽字中

sigaction

如果我们想要在一个信号递达的时候,不仅屏蔽当前信号,再额外屏蔽其他信号,我们就可以使用sigaction函数来设置捕捉方法和屏蔽信号集。

这个函数的参数更加复杂,他不是传的sigset_t ,而是传另外一个数据类型,struct sigaction ,这个结构体中有字段来存储要设置的捕捉方法以及要屏蔽的信号集。 而其他的三个参数则是与实时信号有关,我们不用管,设置为nullptr和0就行。第三个参数我们也不管了,他是输出型参数,用于保存原来的状态。

那么我们接下来就是用sigaction设置2号信号的捕捉方法,同时在捕捉2号信号的时候屏蔽掉3号信号。

void handler(int signum)
{
    std::cout<<"收到了"<<signum<<"号信号, 倒计时十秒"<<std::endl;
    int time =10;
    sigset_t block;
    sigset_t pending;
    while(time--)
    {
        sigemptyset(&pending);
        sigprocmask(0,nullptr,&block);
        sigpending(&pending);
        printf("信号屏蔽字:");
        for(int i=32;i>=1;--i)
        {
            printf("%d",sigismember(&block,i));
        }
        printf("\n pending 信号集:");
        for(int i=32;i>0;--i)
        {
            printf("%d",sigismember(&pending,i));
        }

        printf("\n");
        sleep(1);
    }
    std::cout<< std::endl;
    std::cout<<signum<<"号信号捕捉完毕"<<std::endl;
}


int main()
{
    struct sigaction act;
    sigset_t block;
    sigemptyset(&block);
    sigaddset(&block,3);
    //act.sa_flags=0;
    act.sa_mask=block;
    act.sa_handler =handler;
    //act.sa_restorer=nullptr;
    //act.sa_sigaction=nullptr;
    int ret=sigaction(2,&act,nullptr);
    if(ret==-1)
    {
        printf("strerror(roobo)");
    }
    while(1);
    

    return 0;
}

SIGCHLD信号

这个信号和我们以前讲过的进程等待有关系,是我们的17号信号。

前情回顾:子进程退出之后,会处于一种僵尸状态,等待父进程去回收僵尸,获取子进程的退出信息。而如果父进程调用waitpid等待子进程的时候,如果子进程还没有退出,我们可以选择阻塞式的等待也可以选择非阻塞轮询的方式来等待,回收完子进程僵尸之后waitpid会返回子进程的pid。

其实子进程在死亡变成僵尸之前,是会通知父进程自己已经死亡的,怎么通知呢?就是给父进程发送 17 号SIGCHLD信号,我们以前一直没有聊是因为没有学习信号的概念。但是为什么我们不知道或者说好像并没有收到这个信号的相关提示呢?他的默认动作是什么?

我们还是打开7号man手册,看一下17号信号的默认动作

我们发现17号信号的默认动作是Ign,也就是ignore,忽略动作,这就是为什么我们一直没有注意到它的原因,因为他的递达就是一种忽略动作。

怎么证明收到了该信号呢?

我们可以设置自定义捕捉方法。


void handler(int signum)
{
    printf("收到了%d 号信号\n",signum);
}

int main()
{
    signal(17,handler);

    pid_t id=fork();
   
    if(id==0) //子进程
    {
        int time=5;
        while(time--)sleep(1);
        exit(1);
    }
    //父进程
    while(1); //什么也不做,就等着子进程退出

    return 0;
}

我们能看到父进程确实是收到了17号信号。

那么捕捉17号信号有什么意义呢?

我们可以在该信号的捕捉方法中去等待子进程,但是我们如何将子进程的pid传给捕捉方法呢?

其实不需要传,waitpid 函数当我们将参数等待的子进程的pid设为-1,就是等待任意子进程。

同时,如果我们有多个子进程,比如说创建了100个子进程,那么我们在自定义捕捉方法中就需要用循环的方式来等待子进程。而这里有会有一个问题,就是如果只退出 其中一部分子进程,剩下的子进程没有退出,那么父进程岂不是会阻塞在 17 号信号的捕捉方法的waitpid中?直到所有子进程都退出,waitpid才会等待失败,这不是影响了父进程要做的事吗?

所以我们还就是需要采用非阻塞的等待方式,在循环中只要waitpid的返回值位0了,就说明当前没有子进程退出,也就说明,此时已经退出的子进程已经全部回收完毕,那么捕捉就可以结束了,直到后面的子进程退出时再次发送17号信号过来。

其实,我们也可以不等待子进程,如果我们不想自己回收子进程的僵尸,可以用signal或者sigaction设置17号信号的捕捉方法为 SIG_IGN ,也就是显式的设置该信号的忽略动作,这时候其实是相当于直接忽略该信号。那么操作系统在识别到我们的进程将17号信号显式设置为SIG_IGN之后,操作系统就明白了我们的父进程不想处理子进程的僵尸,那么子进程退出之后就会自动由操作系统回收,而不需要麻烦父进程。

既然这里的SIG_IGN和17号信号的默认动作Ign不一样,那么我们也能知道 SIG_IGN和Ign在数值上一定是不一样的。

对于17号信号,ign的默认的忽略处理方式如果父进程不回收的话,会导致子进程的退出状态丢失。而SIG_IGN则表示父进程忽略该信号,也就是父进程不会对该信号做出任何动作来响应,但是操作系统会自动去回收子进程僵尸。

以上就是信号的全部知识点,下面是两个其他的知识点,跟信号也有一点关系,放在这里,同时为后面的多线程做铺垫。

可重入函数

可重入函数是什么呢?

有以下场景:假设我们要执行一个单链表的头插,头插的逻辑很简单

void insert(int val,node*&phead)
{
    node*newnode=new node(val);
    node->next=phead;
    phead=newnode;
}

如果我们在一个信号的捕捉方法里面也调用insert函数对链表进行头插,这一点肯定是能实现的,比如我们把phead定义为全局的。 那么我们发送该信号的时候也会执行一次链表的头插,这也没问题。

但是比如我们在调用链表的头插的时候,在 phead = newnode 这行代码的前面,我们的进程陷入内核了,同时进程收到了该信号,那么这时候就会出现这样的情形

也就是说,我们的信号的捕捉方法调用insert插入的节点丢失了,造成了内存泄漏。

我们的头插的逻辑并没有问题,而是因为 main 执行流正在执行 insert 时,由于一些信号捕捉的逻辑,我们在信号的捕捉中又调用了insert方法,我们一般把main执行流和信号捕捉执行流称为两套执行流,我们再main执行流进入insert执行头插期间,信号捕捉执行流也进入了insert,而信号执行流调用完之后,进程返回main函数执行流执行后续的代码,修改 phead ,这就导致了我们的代码的结果出现了预料之外的记过或者说错误。

一般而言,我们认为main执行流和信号捕捉执行流是两个执行流,虽然在理论上他们是串行的,不会同时执行,但是我们发现信号捕捉方法中的insert不是正常调用的,而是通过回调的方式,他可能会导致main函数的执行流中断而执行这个回调的insert函数,我们在这里将其看成两个执行流更好理解。

我们将这样的insert函数称为不可重入函数。

这种在main执行流和信号捕捉执行流中被同时进入,会出现一些问题的函数我么可以称为不可重入函数。而那些不会有问题的函数就是可重入函数。

一般我们的接口基本都是不可重入的。

符合以下条件之一的函数就是不可重入函数: 调用了malloc或free,对资源进行申请和释放的 ;或者调用了标准io函数的,因为标准io库的很多实现都已不可重入的方式使用全局数据结构。

volatile

这是一个C语言中的关键字,在C语言的关键字章节肯定有,但是我们没有讲,因为他跟cpu和内存有关系。

我们可以看一下下面的这个程序:

#include<unistd.h>
#include<sys/types.h>
#include<signal.h>
#include<cstdio>

int val=1; 

void handler(int signum)
{
    val=0;
    printf("由于收到%d号信号,val已经被修改了,现在的val的值时:%d\n",signum,val);
}

int main()
{

    signal(2,handler);
    while(val);
    printf("程序退出\n");
    
    return 0;
}

这段代码的逻辑应该是,以val作为while的循环条件,cpu每次去内存中取val的值到寄存器中,然后判断真假,正常的话他会一直死循环,而当我们的进程收到2号信号之后,执行2号信号的自定义捕捉方法,将val的值有1置0了,那么下一次cpu取内存中取val 的值判断就发现val变成了0,那么while循环就结束了,所以程序会退出。

我们正常编译这段代码执行出来的结果和上面的分析是一样的。

但是大家有没有听过一个概念叫做编译器优化,经典的就是 我们生成的可执行程序文件有两个版本,一个时debug一个是release,我们曾经就说过,debug版本是加了很多调试信息的吗,用来给程序员用的,而release则是用来给用户使用的,编译为release版本的话,编译器是会对其作一些优化来提升性能的。而我们的编译器默认也是会进行优化的,优化等级有O,O1,O2,O3,Os,Ofast这几个吗,编译器的默认优化等级一般是O1或者O2。

那么当我们将编译器的优化等级提高,我们上面的程序还会收到2号信号之后正常退出吗?设置优化等级我们可以在 g++ 编译的时候加上选项直接一个 - 加上优化等级。

这时候我们就发现了一个问题,2号信号发送给进程了,同时val的值也被修改了,但是为什么死循环没有退出?

不用说他肯定是和编译器的优化有关,因为我们就改了一下编译选项,其他所有地方都没有动。同时,我们的循环是跟val有关,而val又是需要cpu加载到寄存器中去判断真假的,他肯定和val以及寄存器有关了。 

我们的cpu一直在重复做三个工作,取指令,分析指令和执行指令 ,如果执行完指令用户需要返回结果,那么还会将结果写回内存。那么这跟我们上面出现的问题有什么关系呢?

在我们不做优化的时候,cpu每次进入while循环的判断,都会去内存将val 的值从内存中读到寄存器中,然后执行判断,由于不需要返回结果,所以也不会将结果写回去。 而当收到了信号之后,进程会去执行吸纳后的捕捉方法,在捕捉方法中,cpu也是会去内存中取到val的值放在寄存器,然后对其进行赋值,而由于对val 进行了更新,所以返回之前,cpu还会将更新之后的val 的值写回去。那么在返回main执行流之后,再去内存中读取val的值就变成0,那么循环就结束了,进程就推出了。

而当我们设置优化级别为03时,编译器识别到,在我们的main执行流里面,我们的val值好像只是用来作为循环的判断条件,除此之外我们就没有任何用到val的地方了,这个val还是一个全局变量,它的生命周期是从定义到进程退出,有点类似于const的常量了,那么编译器就不会让cpu每次都去内存中读取val来判断,而是直接将val加载到寄存器之后,之后每次判断都是那寄存器中的值进行循环的判断,而不会真正每次都去读内存的val值,因为这毕竟也会牺牲效率。 而当我们信号捕捉之后把val 的值修改为了0,但是他修改的是内存中的val 的值,和我们进行优化之后与加载到寄存器中的val的值没有关系,而返回main执行流之后cpu每次判断循环用的还是寄存器中的那个值,所以循环一直在继续。

这就叫做因为寄存器的存在而遮盖了物理内存中val变量存在的这个事实,while循环判断时,cpu没有真正去物理内存中读取变量的值,而是直接在寄存器中读取预加载进来的值。

虽然这样优化在很多情况下确实能够提升效率,但是像什么的场景就会导致程序运行错误。

那么为了避免这样的问题,我们可以在对应的变量定义时加上 volatile 关键字。

volatile关键字在C\C++范畴中的常见作用就是 保持内存可见性 ,加上这个关键字之后,编译器就不会将这个变量优化到寄存器中,而是每一次循环判断都会去读取物理内存的值来判断。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值