Linux系统编程:进程信号

目录

1.快速认识信号

生活角度的信号

技术应用角度

部分Linux系统中产生信号的方式

signal

 前台进程vs后台进程

朴素的认识信号的本质

信号概念

2.产生信号

2.1通过终端按键产生信号

基本操作:

2.2系统命令产生信号

kill

2.3系统调用函数产生信号

kill

raise

abort

2.4由软件条件产生信号

SIGPIPE信号

alarm函数

重复闹钟

闹钟取消

基本alarm验证-体会IO效率问题

2.5由硬件异常产生信号

模拟除0问题

模拟野指针问题

子进程退出core dump

3.保存信号

3.1信号其他相关常见概念

3.2在内核中表示

3.3sigset_t

3.4信号集操作函数

sigprocmask

sigpending

4.捕捉信号

4.1信号捕捉的流程

内核空间与用户空间

 内核角度下的系统调用

内核态与用户态

内核如何实现信号的捕捉

4.2sigaction

4.3穿插话题-操作系统是怎么运行的

硬件中断

时间中断

死循环

软中断

写时拷贝?缺页中断?

5.可重入函数

6.volatile

7.SIGCHLD

重谈僵尸进程


信号的形成,随时间一般是以下三个步骤

先从信号产生说起。

1.快速认识信号

生活角度的信号

你在⽹上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”

当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。 那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的⾏为 并不是⼀定要⽴即执⾏,可以理解成“在合适的时候去取”。

在收到通知,再到你拿到快递期间,是有⼀个时间窗⼝的,在这段时间,你并没有拿到快递,但是 你知道有⼀个快递已经来了。本质上是你“记住了有⼀个快递要去取”

当你时间合适,顺利拿到快递之后,就要开始处理快递了。⽽处理快递⼀般⽅式有三种:1. 执⾏默 认动作(幸福的打开快递,使⽤商品)2. 执⾏⾃定义动作(快递是零⻝,你要送给你你的⼥朋友) 3. 忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏)

快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

基本结论:

1.如何识别信号?识别信号是内置的,进程识别信号,是内核程序员写的内置特性。

2.信号不论有没有产生,都知道该如何处理,也就是说信号的处理方法在信号产生之前,就已准备好。

3.处理信号并不是立马就处理,而是等待合适的时机处理。

4.信号涉及 信号到来 信号保存 信号处理

5.信号如何处理?a.默认 b.忽略 c.自定义 ,后续都叫做信号捕捉。

技术应用角度

部分Linux系统中产生信号的方式

kill -l 命令可以帮助我们查看一些信号,一共62个信号(这些信号就是宏),1~31是普通信号,

34~64是实时信号。

当我们写了一段死循环代码时,最好的终止方式就是使用ctrl+c进行终止

为什么Ctrl+C之后,该进程就终止了?

当用户按Ctrl+C时,这个键盘输入会产生一个硬中断,会被操作系统获取,并解释成信号,这里其实就是2号信号SIGINT,然后操作系统将该信号发送给目标前台进程,前台进程收到2号信号就会退出了。

对于信号的处理,OS会提供相应的系统调用,修改进程对于信号的处理动作。

signal

我们可以利用signal函数对信号进行捕获,并干预之后的处理逻辑(注意signal函数只是设定了信号捕捉后的行为处理方式,并不是使用signal就会调用处理方法,需要捕获对应信号才能调用处理方法)

第一个参数是要捕获的信号编号,第二个参数就是要进行的操作。

第二个参数可以填一些系统提供的宏,也可以自己创建一个函数(一定得返回值void参数类型int,之后调用直接传函数名就行,系统运行到这会回调,把第一个参数传进函数的参数)

比如用系统提供的宏 SIG_IGN就会忽略对捕获到的信号的处理。我们也可以自己写一个函数,传进去,如果函数里边没有对进程进行关闭,那么程序ctrl+c的话就关不掉,不过可以用kill -9 PID的形式把进程关闭。

示例:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handler(int sig)
{
	printf("get a signal:%d\n", sig);
}

int main()
{
	signal(2, handler); //注册2号信号
	while (1){
		printf("hello signal!\n");
		sleep(1);
	}
	return 0;
}

比如这段代码会一致死循环打印hello signal,我们signal捕获后通过自定义的函数,在捕获到信号后进行对应逻辑操作,但是函数体内部没有关闭进程,所以每次我们ctrl+c捕获信号后就会输出一句get a signal

这也证明了ctrl+c确实是向目标进程发了2号信号。也证明键盘可以向目标进程发送信号。

 前台进程vs后台进程

命令行启动的进程,默认叫做前台进程。在执行程序文件命令./xxx后边加上&,也就是 ./xxx &会让该进程进入后台运行。ps ajx查进程状态的时候,前台进程状态后面会有一个+号,后台进程没有。

Linux一次登陆状态的时候,系统会启动bash进程,启动其他进程,但是系统任何时刻只允许一个进程处在前台,其他进程处在后台。

因为键盘只有一个,允许获取键盘输入的进程,就是前台进程。后台进程无法从键盘获取数据。所以进程放在后台的时候ctrl+c终止不掉。后台进程在跑的时候,我们仍然可以输入命令做出一些操作,因为命令行启动的进程都是前台进程,和后台进程不冲突。

fork之后,父进程退出,子进程运行,子进程会变成fork进程,父进程变成PID为1的进程,同时子进程会自动变成后台进程。所以为啥当年变成孤儿进程的子进程的死循环代码ctrl+c终止不掉了(但是我们可以通过kill命令给进程发信号把它杀掉),因为它变成了后台进程。

朴素的认识信号的本质

收到信号之后,并不是一定立马处理,而是等待合适时机进行处理。这就意味着信号需要记录下来,那么信号记录在哪?怎么记录?

信号其实记录在进程的task_struct中,至于怎么记录,有没有发现31这个数字的特别之处?对了!就是32位 位图,这个位图也在task_struct里,哪一位是1,就说明是哪个信号。

问题1:如何理解给进程发送信号?

只需要修改目标进程的task_struct信号位图的特定位置(0->1)即可。(插一嘴,为什么程序员这么喜欢起名词(╯‵□′)╯︵┻━┻)本质就是向目标进程进行写信号。

问题2:进程如何识别信号?

通过位图对应的位置是0还是1,判断是哪个信号。


task_struct是内核的数据结构对象,修改位图,本质是修改内核数据。修改内核数据我们有资格吗?当然没有。修改内核数据结构只有OS才能办到。所以,无论信号的发送方式是怎样,最终都是通过OS向目标进程发送信号的。

前台进程在运行过程中用户随时可能按下ctrl+c而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是  异步的。(指两件事没有时序上的依赖关系,随时可能被外部因素介入改变代码执行顺序)

信号概念

信号是进程之间事件异步通知的一种方式,属于软中断。

信号处理的时候,大部分信号的默认处理动作,都是终止进程。可以看到以下大部分信号的默认Action都是Term也就是终止。

2.产生信号

当前阶段:

2.1通过终端按键产生信号

基本操作:

比如前面的Ctrl+C(SIGINT)

或者 Ctrl+\ (SIGQUIT)可以发送终止信号并生成core dump文件,用于事后调试。

ps:并不是所有的信号都是可以被捕捉的,比如9号信号(SIGKILL)和19号信号(SIGSTOP)不可被捕捉(就算被捕捉也不会执行你自定义的捕捉后续逻辑,而是不管咋样都执行对应的系统调用)这俩个其实也叫做管理员信号,不到万不得已不会用到这个信号。

键盘也是硬件,OS需要知道键盘是不是被按下。如果你按的是abcdefg这样的单键,那这些数据就被放到前台进程的缓冲区里,让进程通过read等方式将数据读取;如果按的是Ctrl+单键,OS会将组合键转换成相应的信号。

OS是怎么知道键盘上有数据的?

外设上有数据时会通知CPU。

键盘会和CPU自身一些针脚相连,输入数据,会向CPU发送硬件中断(就是高电压),CPU识别自身针脚具有硬件中断信息,于是CPU执行OS中处理键盘数据的代码,于是OS就开始将数据从外设读到内存,等待进一步处理。

几乎每一种设备,都要在内核中,内置一些处理方法,来进行处理中断的请求。

所以OS会提供一张 中断向量表,里边记录各种设备的处理方法。未来设备发送硬件中断,CPU收取中断后通过查表的方式找到对应处理方法。

2.2系统命令产生信号

kill

kill命令本质就是调用系统调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。

2.3系统调用函数产生信号

kill

参数看下图,返回值大于0代表信号产生成功。

用法

raise

等同于 kill(getpid(),sig),进程自己给自己发信号,参数就是你要发的信号的编号

abort

abort给进程自己发指定信号(6号信号 SIGABRT)

(kill给任意进程发送任意信号,raise给进程自己发任意信号,abort给进程自己发指定信号)

作用是让当前进程接受信号 异常终止,

就像exit一样,调用一定会成功,所以没有返回值。

2.4由软件条件产生信号

SIGPIPE信号

SIGPIPE是一种由软件条件产生的信号,在前一章进程间通信中我们提到:一个管道,读端关闭,写端一直写,这就叫做软件条件不满足,那么OS会通过信号直接将进程kill掉。

alarm函数

故名思意,就是设置了一个闹铃,告诉内核seconds后向当前进程发送一个信号SIGALRM(14号信号)

SIGALRM信号默认行为也是让进程直接Term(终止)

下面代码在输出三次I am a process后会出现一行 Alarm clock,随后进程终止

重复闹钟

此外,我们也可以让闹钟重复进行,一般来说调用alarm后再过你指定的秒数后进程就会退出了。但是还记得那条时间线吗,在进程结束前的最后一步是信号处理,也就是说我们可以在接受到信号的signal函数的第二个参数:处理信号函数,里边再设定一个闹钟,这样就会出现无数闹钟。(main函数里alarm执行->指定秒数之后,发出SIGALRM信号->signal捕捉信号->跳转处理信号函数->内部继续alarm->新的alarm读秒结束触发->signal捕捉信号->死循环)

闹钟取消

我们先看alarm的返回值

alarm函数的返回值是 之前一次未触发的闹钟的剩余秒数,当秒数为0代表这个闹钟已经执行过了。

比如这段代码

当上一个函数alarm还未结束,我就又创建了一个alarm,使得上一个闹钟被取消,此时alarm的返回值是1。

在刚才的代码后面写一段死循环,运行后发现闹钟再过10秒后就触发,默认情况下进程就Term了,如果你用signal写了处理信号的函数,那么就执行你的处理信号的函数。

结论:alarm调用一次,在该进程设置一个闹钟,当再次设置闹钟新时间,会取消上一次的闹钟,返回值为上一次闹钟触发的剩余时间。

此外,当你在闹钟触发之前,设置闹钟新时间为0,就意味取消这个闹钟。

OS必然要管理闹钟,关于alarm也是有相应结构体的,创建一个alarm就创建了一个alarm结构体,

值得注意的是,alarm结构体里会对进程的PCB也就是task_struct进行回指。为什么要进行回指?因为alarm触发默认情况下可以关闭进程,具体就是向进程的信号位图第14个位置由0置1完成信号的写入。


那么当我们不断创建新的alarm更新触发时间,也就会不断创建alarm的结构体,不同的alarm的触发时机不一样,这些结构体是怎么组织起来的,我怎么知道到底触发哪个alarm???组织的方法有很多,这里介绍一种好理解的--->最小堆

我们用alarm未来的绝对超时时间,来建立最小堆,新创建一个alarm,将这个数据加入堆尾,向上调整,如果绝对时间更小,那么和父节点做交换,未来系统只需要关注堆顶alarm是否超时即可。

判断超时是OS做的,OS如何做到判断超时?

没错,就是时间戳,系统内部是在对时间做计数的(OS是有在维护时间戳的),alarm绝对超时时间=创建alarm的时间+alarm设定的秒数。


基本alarm验证-体会IO效率问题

观察下面这段代码,以及输出结果,当我们用printf输出的时候1秒内我们能打印到十万多次,该用signal函数在处理信号函数内部输出,发现可以打印4亿多次。同样是打印,为什么相差了这么多数量级?

printf函数以及cout本质是IO,cnt++是一种内存操作。右边代码只在捕获信号后IO一次。左边每次循环都IO一次,后者在1秒时间内只在使cnt递增,这是一种内存操作。

所以IO相比内存操作更容易使效率降低。

2.5由硬件异常产生信号

比如像除以0,和野指针问题报错,导致进程崩溃,因为软件问题,被OS识别,给目标进程发送了信号,然后进程处理信号,默认终止了进程。

模拟除0问题

像除0,OS识别后会给目标进程发送8号信号(SIGFPE)导致进程提前终止,但是如果我们写自己的处理信号函数,用signal来捕获,就会发现一直在疯狂输出处理信号函数里的内容,进程一直都没有终止。

#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
// v1
int main()
{
signal(SIGFPE, handler); // 8) SIGFPE
sleep(1);
int a = 10;
a/=0;
while(1);
return 0;
}

启动进程后会发现屏幕上在疯狂输出catch a sig:8,这意味着8号信号一直在产生被我们捕获,这是为什么?

实际上OS会检查应用程序的异常情况,在CPU中有一些控制寄存器和状态寄存器,主要用于控制处理器的操作,通常由操作系统代码使用。状态寄存器可以简单理解为一个位图,对应着一些状态标记位,溢出标记位。OS会检测是否存在异常状态,有异常状态就会调用对应的异常处理方法。

CPU执行代码的时候,一定是在调度某个进程。除0操作会在硬件上先报错,因为操作系统是软硬件资源的管理者,所以它一定知道报错了。此外,内核有一个struct task_struct*current永远指向CPU正在调度的进程,OS通过这个指针找到是哪个进程引起的硬件报错。CPU内部的寄存器本质上是:当前硬件的上下文,OS找到引起问题的进程就会把进程kill,如此硬件的上下文便不再存在,CPU报错就没了。如此OS就恢复了CPU的正常工作。

除零异常后,我们并没有清理内存,关闭进程打开的文件,切换进程等操作。所以CPU中还保留着上下文数据以及寄存器内容,CPU会一直调度该进程,所以除零异常会一直存在,就有了我们看到的一直发出异常信号的现象。下面访问非法内存也是如此。

模拟野指针问题

野指针问题会给目标进程发11号信号(SIGSEGV),同样的我们写自己的处理信号函数,用signal来捕获,就会发现一直在疯狂输出处理信号函数里的内容,进程也一直都没有终止。

#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
signal(SIGSEGV, handler);
sleep(1);
int *p = NULL;
*p = 100;
while(1);
return 0;
}

输出结果会报一个段错误(segment fault)

访问一个变量时,一定要先经过页表映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作。

其中页表属于一种软件映射关系,实际上虚拟地址映射到物理地址的时候还有一个硬件叫做MMU,它是一种负责处理CPU的内存访问请求的计算机硬件。虚拟地址映射到物理地址的时候,虚拟地址会先传给MMU,MMU计算出对应的物理地址,再通过物理地址进行相应访问(和页表的作用是一样的)。

MMU作为硬件单元,也有相应的状态信息。当我们访问不属于我们的虚拟地址时,转换时就会出错,MMU会将错误信息写入到自己的状态信息中,这时硬件上面的错误信息也会立马被OS识别,进而向对应进程发送SIGSEGV信号。也就是说野指针问题,本质也是触发CPU硬件报错。

子进程退出core dump

在进程控制那篇博客讲解进程等待的时候,我们提到一个子进程在进行结束的时候,如果正常终止,会有退出状态;如果退出异常,会有退出信号(0~7)位。第7位就是core dump标志位,置为1时表示要进行core dump(核心转储)

一个进程异常退出,我们必然要弄清异常退出的原因。这个我们通过低七位的退出信号就能推出,大致的原因,此外如果推出的是9号/2号信号,摆明就是要把我进程干掉,那么我直接退出就行。但是如果是其他信号,比如8号信号11号信号(除0,野指针),信号告诉你有这些问题,那么此刻最想知道的不就是 到底是哪一行代码有问题。那么我们就需要进一步追踪,这种需要进一步追踪再关闭进程的,它的信号状态就是Core(不需要追踪就是Term)。

核心转储:进程在运行时,出现异常情况(除0,野指针),OS会在进程结束的时候,会把进程当前运行的上下文数据,转储到当前目录下,形成一个core文件。未来我们会用这个文件进行调试,进而知道代码中,到底哪一行导致的进程崩溃。

该功能默认在云服务器上被禁用。

ulimit -a 可以查看 当前进程全部使用资源限制情况

可以看到我们的core文件被禁用了

ulimit -c 【大小】就可以修改core文件大小了

这样再次运行我们的野指针代码,报错不仅会报段错误,后边还会紧接一个(core dump)

而且还会多一个core文件

未来做调试gdb的时候向命令行输入core-file core 就能直接定位出错行

为什么云服务器要默认禁用core???

如果每次运行进程都要生成core文件,core文件里会有内存数据(可能会有敏感数据比如密码,另外黑客拿到core文件也能知道进程什么地方容易崩溃),而且每次运行都生成core文件,非常占用磁盘空间,会导致服务器卡死。

信号产生的方式有很多,但最终,给进程发信号(写信号)的一定是操作系统。

3.保存信号

当前阶段

3.1信号其他相关常见概念

实际执⾏信号的处理动作称为信号递达(Delivery)

信号从产⽣到递达之间的状态,称为信号未决(Pending)。

进程可以选择阻塞 (Block )某个信号。

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

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

作。

3.2在内核中表示

每个信号都有两个标志位分别表⽰阻塞(block)和未决(pending),还有⼀个函数指针表⽰处理动作。信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。

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

SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数sighandler。

进程能识别信号,本质是通过三张表:block表,pending表,handler表。

所以signal(my_signum,my_handler);本质是在修改handler表,在表中下标为my_signum的地方将其设置为my_handler。

如果一个信号被block了,即便是我们收到了对应的信号,不能被递达,直接解除屏蔽,才能被递达。

3.3sigset_t

从上图来看,每个信号只有⼀个bit的未决标志, ⾮0即1, 不记录该信号产⽣了多少次,阻塞标志也是这样表⽰的。因此, 未决和阻塞标志可以⽤相同的数据类型sigset_t(结构体套数组,这就是位图的实现)来存储, sigset_t称为信号集, 这个类型可以表⽰每个信号的“有效”或“⽆效”状态, 在阻塞信号集中“有效”和“⽆效”的含义是该信号是否被阻塞, ⽽在未决信号集中“有 效”和“⽆效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask), 这⾥的“屏蔽” 应该理解为阻塞⽽不是忽略。

信号屏蔽字就像umask那样

3.4信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不需要对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。

#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);

函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。

函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。

注意:在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

这四个函数都是成功返回0,出错返回-1。sigismember是一个bool函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含返回1,不包含则返回0,出错返回-1.

sigprocmask

进程信号掩码控制器

控制当前进程的信号屏蔽字,决定哪些信号会被阻塞(暂时不递送),相当于给信号直接装一个开关阀门。

目的是检查和设置 block表,主要还是设置

第一个参数

SIG_BLOCK 添加信号到屏蔽字

SIG_UNBLOCK解除屏蔽

SIG_SETMASK直接替换整个屏蔽字

假设当前的信号屏蔽字为mask,第一个参数how可以这样设置

第二个参数是一个输入型参数,和第一个参数搭配使用,将set中的数据传到当前进程信号屏蔽字

第三个参数是一个输出型参数,复制修改之前的进程信号屏蔽字,如果你不需要旧的信号屏蔽字,第三个参数设置为NULL。这样做是为了修改之后还能恢复之前的信号屏蔽字,比如这样

sigpending

读取当前进程的未决信号集,通过set参数传出

调用成功则返回0,出错则返回-1

我们可以利用以上函数完成一个简单小实验

实验步骤如下:

1.使用上述函数屏蔽2号信号。

2.使用kill命令或组合按键向进程发送2号信号。

3.此时由于2号信号被阻塞,会一直处于pending(未决)状态。

4.使用sigpending函数获取当前进程的pending信号集,打印进行验证。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void printPending(sigset_t *pending)
{
	int i = 1;
	for (i = 31; i >0; i--){
		if (sigismember(pending, i)){
			printf("1 ");
		}
		else{
			printf("0 ");
		}
	}
	printf("\n");
}
int main()
{
    //1.屏蔽2号信号
    //1.1用户层面,建立位图
	sigset_t set, oset;
	sigemptyset(&set);
	sigemptyset(&oset);

	sigaddset(&set, 2); //SIGINT
      
    //1.2设置内核信号屏蔽字
	sigprocmask(SIG_SETMASK, &set, &oset); 

	sigset_t pending;
	sigemptyset(&pending);

	while (1){
        //2.1获取当前进程的pending信号集
		sigpending(&pending); 
        //2.2大于pending信号集中的信号,1表示未决
		printPending(&pending); 
		sleep(1);
	}
	return 0;
}

Ctrl+C后就能看到二号信号被pending了,但是由于2号信号被block,所以进程没有结束,一旦解除2号信号的屏蔽,2号信号递达到后,进程就会直接kill。

此外通过实验也能发现,9号信号和19号信号(管理员信号)无法被屏蔽,尽管我们手动block了9号和19号信号,当给进程发送这两个信号,进程仍然可以终止。

4.捕捉信号

当前阶段

4.1信号捕捉的流程

内核空间与用户空间

每个进程都有自己的进程地址空间,该进程地址空间分为内核空间和用户空间

用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。

内核空间存储的实际上是操纵系统代码和数据,通过内核级页表与物理内存之间建立映射关系。

内核级页表是一个全局的页表,它用来维护 操作系统的代码 和进程之间的关系。因此,在每个进程的进程地址空间中,内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容,但用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的。

每一个进程都有自己的用户级页表,但是共用一个内核级页表。

所以什么是进程切换?

所以,进程切换就是:
1.在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。

2.执行操作系统的代码,将当前进程的代码和数据剥离下来,换上另一个进程的代码和数据。无论进程怎么切换,怎么调度,每一个进程都共用一个内核级页表,找到同一个内核,也随时能找到内核。

代码执行时有两种模式:内核态执行,用户态执行。访问内核空间必须处于内核态,访问用户空间必须处于用户态。

 内核角度下的系统调用

内核中有一个全局的系统调用函数指针数组,用于系统调用中断处理程序,作为跳转表。每一个系统调用都会定义一个系统调用号,本质就是该系统调用在数组里的下标。

用户层通过寄存器(比如EAX)将系统调用号给操作系统。

操作系统通过寄存器或者用户传入的缓冲区地址,将返回值给用户。

系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号(本质就是系统调用表的下标),自动查表,执行对应的方法。

内核态与用户态

首先:

内核态通常用来执行操作系统的代码,是一种权限非常高的状态。

用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。

前面我们提到,进程收到信号,并不是一定立即处理,而是等待合适时机处理(不考虑信号屏蔽的情况下)这个合适的时机,就是进程从内核态切换回用户态的时候,OS会检测当前进程的三张表,来决定是否处理信号。

内核与用户态是如何切换的?

用户态——>内核态:

1.进行系统调用时。

2.当前进程的时间片到了,导致进程切换。

3.产生异常、中断、陷阱等。

与之相对:

内核态——>用户态:

1.系统调用返回时。

2.进程切换完毕。

3.异常、中断、陷阱等处理完毕。

从用户态转换为内核态,称之为陷入内核。陷入内核的目的是需要执行操作系统的代码。比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须陷入内核(由用户态切换为内核态)。

内核态与用户态的本质:

CPU内部,有一个cs(code segment)段寄存器,其有两个比特位专门来表示是内核态 还是用户态。CPL(当前权限级别):0表示内核态,3表示用户态,且只有这两个执行级别。

页表里存在标志位CPL,存储指定条目的执行级别,进行进程地址空间和物理内存空间映射的时候,会检查页表的CPL和CPU里cs段寄存器的CPL是否匹配(相等),不匹配会MMU报错。

为什么说OS强制使用系统调用访问内核?

用户态如果需要访问内核空间,就必须更改cpl为0,不然页表权限检查会阻止访问。如何更改?int 0x80,syscall -> cpl->0 和软中断绑定,这两种机制都会使CPL变0,CPU强制所有CPL切换必须跳转到OS预设的入口函数,然后根据系统调用号查找系统调用表执行相应功能。而且无法在用户态直接访问内核内存,权限不匹配会被MMU阻止。所以说,操作系统强制使用系统调用才能访问内核。

内核如何实现信号的捕捉

当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态的时候,就会检查信号的三张表。(此时仍然处于内核态,有权力查看当前进程的三张表)

在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。接着:

如果待处理信号的处理动作是默认或者忽略,则执行该信号的默认处理动作或者忽略后,清除对应的pending标志位。

如果待处理信号的处理动作是自定义函数,换句话说就是该信号的处理动作是用户提供的,那么处理信号时就需要先返回用户态执行对应的自定义函数,执行完之后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位。

如果继续向下检查pending表,没有新的信号要递达,就直接返回用户态,从主控制流程上次被中断的地方继续向下执行。

ps:sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。

举例说明:

一张图巧记处理信号状态切换过程:

4.2sigaction

捕捉信号除了用signal函数,还可以用sigaction函数,该函数原型如下:

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

sigaction函数可以 读取和修改  与指定信号相关联的处理动作。调用成功返回0,出错返回-1。

参数说明:

signo是指定信号的编号。

如果act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。(act输入型参数,oact输出型参数)

其中act和oact指向sigaction结构体,该结构体原型如下:

struct sigaction {
	void(*sa_handler)(int);
	void(*sa_sigaction)(int, siginfo_t *, void *);
	sigset_t   sa_mask;
	int        sa_flags;
	void(*sa_restorer)(void);
};

结构体的第一个数据项sa_handler:

如果将其赋值为:

SIG_IGN,然后传给sigaction表示忽略信号;

SIG_DFL,表示执行系统默认动作;

一个函数指针,表示用自定义函数捕捉信号(和signal一样),或者说向内核注册了一个信号处理函数,(返回值是void,带一个int参数),通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。

ps:当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止,防止任意信号进行递归处理。

结构体第二个数据项:sa_sigaction

sa_sigaction是一个实时信号的处理函数。

结构体第三个数据项:sa_mask

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,如果还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时会自动恢复原来的信号屏蔽字。不需要的话直接sigemptyset(&结构体.sa_mask);

结构体第四个数据项:sa_flags

sa_flags字段包含一些选项,一般都设置为0。


例如,下面我们用sigaction函数对2号信号进行了捕捉,将2号信号的处理动作改为了自定义的打印动作,并在执行一次自定义动作后将2号信号的处理动作恢复为原来默认的处理动作。

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>

struct sigaction act, oact;
void handler(int signo)
{
	printf("get a signal:%d\n", signo);
	sigaction(2, &oact, NULL);
}
int main()
{
	memset(&act, 0, sizeof(act));
	memset(&oact, 0, sizeof(oact));

	act.sa_handler = handler;
	act.sa_flags = 0;
	sigemptyset(&act.sa_mask);

	sigaction(2, &act, &oact);
	while (1){
		printf("I am a process...\n");
		sleep(1);
	}
	return 0;
}

运行代码后,第一次向进程发送2号信号,执行我们自定义的打印动作,当我们再次向进程发送2号信号,就执行该信号的默认处理动作了,即终止进程。

4.3穿插话题-操作系统是怎么运行的

硬件中断

中断向量表是操作系统的一部分,启动就加载到内存中了。CPU执行中断向量表中的方法,就是执行OS代码。

通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。

由外部设备触发的,中断系统运行流程,叫做硬件中断。

硬件中断和信号,是两套独立的技术体系,信号是一种软件方式模拟中断行为。

时间中断

进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?

外部设备可以触发硬件中断,但这个是需要用户或者设备自己触发,有没有自己可以定期触发的设备?

OS核心就是中断向量表,OS在固定时钟频率下一直在触发硬件中断,CPU每隔一个时间点就执行一次进程调度,完成对进程的控制。

所以OS的运行,是在时钟源的中断下,以固定的频率一直触发中断处理,执行操作系统代码。

死循环

所以OS可以是一种什么都不做的软件,只需要是一个死循环即可。等待时钟源触发中断处理,执行对应的操作系统代码。所以为啥你开机操作系统就开,不关机操作系统就一直不关。

OS在被启动的时候,先初始化一些资源。OS本质是一个死循环,在初始化一些资源之后就会一直调用pause函数在那等待时钟源每隔一段时间触发中断处理,来调度自己。

每当发送时间中断信号,OS就会执行一些被规定好的检查工作,比如说看看当前正在被调度的进程的时间片是否到了,如果到了就把进程从CPU上剥离下来,如果没到,那么两次检查期间,CPU就在执行进程内部代码。

所以硬件中断不一定会出现,但是时间中断每隔一点时间就会来一次。OS就像一个躺在中断上的软件集合。( •̀ ω •́ )y

细节补充:

当前CPU计算机,没有外部时钟源,集成到CPU内部了。

CPU固定频率收到对应的中断,触发一次时钟中断时间是固定的,我们可以通过时间中断被触发的次数*中断触发的时间间隔得到开机时间,开机时先从网络获取当前时间,之后不用联网也能更新时间。

进程的时间片本质也就是一个计数器。

软中断

软中断:通过汇编指令集,也就是通过软件的方式,让CPU进入中断的处理例程中,称为软中断。

上述外部硬件中断,需要硬件设备触发。

也可以因为软件原因触发上面的逻辑,为了让OS支持进行系统调用,CPU也设计了对应的汇编指令(int或者syscall),可以让CPU内部触发中断逻辑

问题:

• ⽤⼾层怎么把系统调⽤号给操作系统? - 寄存器(⽐如EAX)

• 操作系统怎么把返回值给⽤⼾?- 寄存器或者⽤⼾传⼊的缓冲区地址

• 系统调⽤的过程,其实就是先int 0x80、syscall陷⼊内核,本质就是触发软中断,CPU就会⾃动执⾏系统调⽤的处理⽅法,⽽这个⽅法会根据系统调⽤号,⾃动查表,执⾏对应的⽅法

• 系统调⽤号的本质:数组下标!

写时拷贝?缺页中断?

写时拷贝:

父进程创建子进程,内核将父子进程的虚拟内存页表标记为只读,同时将这些页的物理内存映射关系设置为共享引用。CPU检测到对只读页的写操作,触发页错误异常,MMU生成错误码,OS识别后意识到进程需要拷贝空间,触发软中断,重新执行中断向量表中的中断方法,申请空间,然后把申请来的物理地址更新页表,标记为可写,此后继续完成我们未完成的事情。

缺页中断:

malloc申请一定大小空间,并不会立即在物理内存里申请这样大小的空间,只需要在虚拟地址开辟即可,并不会映射具体的物理地址。CPU访问的虚拟地址在页表中无有效映射,MMU自动触发缺页中断,中断执行中断向量表中的中断方法,这个方法会检查发现是内存空间没开辟,开辟后重新填页表,继续完成未完成之事。

ps:所以说操作系统是一个死循环,是一个基于中断工作的软件集合。

缺页中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断, 然后⾛中断处理例程,完成所有处理。有的是进⾏申请内存,填充页表,进⾏映射的。有的是⽤来 处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。

• 操作系统就是躺在中断处理例程上的代码块。

•外设硬件引起的错误,叫做 硬件中断

• CPU内部的软中断,⽐如int 0x80或者syscall,我们叫做 陷阱(不是指出错,而是用户主动陷入内核)。

• CPU内部的软中断,⽐如除零/野指针等,我们叫做 异常。(出错了,但不是外设引起的,是用户操作导致CPU内部硬件出问题)

所以缺页中断也叫做缺页异常

信号处理,不是立即处理,而是需要先从内核态,返回用户态的时候,进行检查信号三张表,进行信号捕捉。回头看这张图就全部看得懂了

进程代码即使只有一个死循环,什么都不做,依然会从用户态到达内核态,因为存在时间中断,进程时间片在进行调度。OS在调度进程的时候,一直在进行内核到用户,用户到内存的切换。

5.可重入函数

观察下面一段代码

1.main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步也就是node1->next=head的时候,假设此时有一个硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数

2.sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,此时布局就是图3那样

3.再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断了,现在开始做第二步,结果就如同图4所示,main函数和sighandler先后向链表中插入两个节点,但是最后只有一个节点真正插入链表了,造成了内存泄漏。

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之如果一个函数只访问自己的局部变量或参数,则称为可重入函数。(Reentrant)像上边的insert就是不可重入函数。

为什么两个不同的控制流程调用同一个函数,访问它的同一共局部变量或者参数就不会造成错乱?

如果一个函数符合以下条件之一则是不可重入的:

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

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

6.volatile

volatile是C语言的一个关键字(易变关键字),该关键字的作用是保持内存的可见性。

在下面的代码中,我们对2号信号进行了捕捉,当该进程收到2号信号时会将全局变量flag由0置1。也就是说,在进程收到2号信号之前,该进程会一直处于死循环状态,直到收到2号信号时将flag置1才能够正常退出。

#include <stdio.h>
#include <signal.h>

int flag = 0;

void handler(int signo)
{
	printf("get a signal:%d\n", signo);
	flag = 1;
}
int main()
{
	signal(2, handler);
	while (!flag);
	printf("Proc Normal Quit!\n");
	return 0;
}

直接gcc编译以下,运行程序,结果和我们想的一样,按下Ctrl+C后进程就结束了。

ps:一般编译的时候,编译器会对我们的代码进行优化,xhsell里gcc编译默认不进行优化,gcc优化分四个级别 O0,O1,O2,O3。默认就是O0

如果我们提升一下编译等级,就会发现,不管怎么Ctrl+C,进程都不会终止了,这是为什么???

首先handler方法一定被调用了,那么flag一定被置1了,那为什么while循环不终止?

首先我们需要知道:代码中的main函数和handler函数是两个独立的执行流,而while循环是在main函数当中的,在编译器编译时只能检测到在main函数中对flag变量的使用。

全局变量flag一定是内存空间里存在的一个变量,执行代码时CPU会对我们的变量做检测以及逻辑运算,CPU往往会做如下几步:

1.将内存当中的变量读到寄存器

2.CPU内部拿到数据进行逻辑运算

3.将处理好的变量写回内存

对于像while(!flag)只读取flag不涉及flag的修改的代码,没有第三步。

没有优化的时候,输入2信号调用handler导致flag被修改,这样就有第三步了,内存中flag的值被修改,while循环每次也是从内存中读取flag,这样就能终止进程。

优化后,变量由内存写到寄存器eax,变成了一个寄存器变量,未来检测变量,汇编出来的代码不用再访问内存,而是直接访问寄存器内部我们变量对应的值,也就是说只做检测不做更新。

编译器认为while循环里对flag只读,于是就忽略了第三步,flag的旧值一直存在寄存器,未来while循环读取flag变量也是直接到寄存器访问变量。调用handler方法只是将内存中的flag修改了,如此一来因为编译器优化,内存数据被屏蔽了,这显然不是我们想要的。如何解决?在flag变量定义前加上volatile关键字,保持内存可见性。

volatile作用:禁止编译器进行优化,保持内存可见性。(每次读取变量必须在内存里读)

屏蔽内存数据在汇编角度就是这样的,将内存数据写入寄存器放在了loop循环外面

7.SIGCHLD

重谈僵尸进程

进程⼀章讲过⽤wait和waitpid函数清理僵⼫进程,⽗进程可以阻塞等待⼦进程结束,也可以⾮阻 塞地查询是否有⼦进程结束等待清理(也就是轮询的⽅式)。采⽤第⼀种⽅式,⽗进程阻塞了就不 能处理⾃⼰的⼯作了;采⽤第⼆种⽅式,⽗进程在处理⾃⼰的⼯作的同时还要记得时不时地轮询⼀ 下,程序实现复杂。

其实,⼦进程在终⽌时会给⽗进程发SIGCHLD信号(17号信号),该信号的默认处理动作是忽略,⽗进程可以⾃定义SIGCHLD信号的处理函数,这样⽗进程只需专⼼处理⾃⼰的⼯作,不必关⼼⼦进程了,⼦进程终⽌时会通知⽗进程,⽗进程在信号处理函数中调⽤wait清理⼦进程即可。

下边代码中,⽗进程fork出⼦进程,⼦进程调⽤exit(2)终⽌,⽗进程⾃定义SIGCHLD信号的处理函数, 在其中调⽤wait获得⼦进程的退出状态并打印。

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
void handler(int sig)
{
    pid_t id;
    //如果创建多个子进程,while循环保证所有子进程退出被回收
    while( (id = waitpid(-1, NULL, WNOHANG)) > 0) {
        printf("wait child success: %d\n", id);
    }
    printf("child is quit! %d\n", getpid());
}
int main()
{
    signal(SIGCHLD, handler);
    pid_t cid;
    if((cid = fork()) == 0){//child
        printf("child : %d\n", getpid());
        sleep(3);
        exit(1);
    }
    while(1){
        printf("father proc is doing some thing!\n");
        sleep(1);
    }
    return 0;
}

事实上,由于UNIX 的历史原因,要想不产⽣僵⼫进程还有另外⼀种办法:⽗进程调 ⽤sigaction将 SIGCHLD的处理动作置为SIG_IGN,这样fork出来的⼦进程在终⽌时会⾃动清理掉,不会产⽣僵⼫进程,也不会通知⽗进程。系统默认的忽略动作和⽤户⽤sigaction函数⾃定义的忽略通常是没有区别的,但这是⼀个特例。此⽅法对于Linux可⽤,但不保证在其它UNIX系统上都可 ⽤。比如看下面一段代码调用signal函数将SIGCHLD信号的处理动作自定义为忽略

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

int main()
{
	signal(SIGCHLD, SIG_IGN);
	if (fork() == 0){
		//child
		printf("child is running, child dead: %d\n", getpid());
		sleep(3);
		exit(1);
	}
	//father
	while (1);
	return 0;
}

此篇完。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_dindong

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值