Linux | 进程信号 | 信号的产生 | 进程处理信号过程 | 进程pending,block,handler设置 | 用户态、内核态

信号的概念

在日常生活中,我们看到绿灯选择过马路,看到红灯选择等待,在这个场景中,绿灯和红灯就是一个信号,过马路和等待就是我们接收信号后所做出的处理信号的行为。也就是说,信号被发送之后,由认识该信号的接收者接收,接收者需要对信号做出特定的反应

在操作系统中,进程就是信号的接收者,所以进程要接收信号就必须具有两点能力

1.在接收信号前,或者说信号产生前,认识该信号
2.拥有接收信号后,做出处理的能力

所以,对于进程来讲,即使信号还没有产生,就已经具有识别和处理信号的能力了。

Ctrl + C信号

当运行的程序陷入异常,并无法退出时,我们通常的做法是无脑ctrl + c,使运行的程序退出在这里插入图片描述
比如上面这段程序,通过死循环地sleep休眠,模拟进程无法退出的情况,这时只要Ctrl + C就能退出这个程序

这里提到Ctrl + C的目的主要是想说明前台进程和后台进程的概念,Linux系统中只能运行一个前台进程,比如./mypro使系统运行我的mypro程序,那么mypro就成为了前台进程,此时不论你向命令行输入什么指令,命令行都无法获取到指令并执行在这里插入图片描述
因为输入的指令要被获取就需要由一个前台进程运行指令,由于前台进程只有一个,而现在的前台进程是./mypro,所以命令行的指令无法被执行。

运行程序时在最后加上&,就能把该程序放到后台运行,后台运行的程序可以存在多个,但是后台不能被Ctrl + C终止

使用jobs指令可以查看后台运行的程序

在这里插入图片描述
在这里插入图片描述
打印的信息中,[ ]里为进程的作业号

使用fg 进程作业号,就能将进程放到前台运行,比如fg 1,将1号进程放入前台

用fg将进程放入前台后就能使用Ctrl + C使其退出了 在这里插入图片描述
Ctrl + C本质上是一种硬件行为,在键盘上敲下Ctrl + C,产生硬件中断,这种中断被操作系统解释为信号,并发送给前台进程,前台进程接收信号,对信号做出响应,即退出前台进程。

根据Ctrl+C这一个例子,我们知道Ctrl+C就是向前台进程发送了一个信号,由于前台进程早就认识了这个信号,并且也具有处理信号的方法,所以Ctrl+C信号的传输才能成立。

Linux中的信号

kill -l指令可以查看Linux下的所有信号

在这里插入图片描述
其中1 ~ 31为普通信号,34 ~ 64为一种实时信号(其中含有有RTM字段,real time),这篇博客讨论的信号为普通信号。对于kill -l打印出的信号信息,左边的数字为信号的编号,用来标识信号,右边的字符串为左边数字的宏定义。

在谈信号之前,先说明一个概念:异步。举个简单的例子,当你点了一份外卖,你就可以放下手机,去忙自己的事,不用在意外卖什么时候做好,什么时候开始派送,只有在送达时,外卖员给你打电话,你收到电话(信号)才知道外卖到了。在这个过程中,外卖的制作,运送,在外卖被送到之前,有关外卖的一切都不会影响到你的状态 ,只有在外卖被送达时,你才会去接收。

从进程的角度说,对于信号为什么发送,是谁发送,进程完全不关心,因为在接收信号之前,信号并不会影响进程的状态,只有在接收信号之后,进程才会对信号做出响应,这就叫作进程与信号产生是异步的。

由于进程与信号的产生是异步的,所以当进程接收信号时,进程可能在处理更重要的事,暂时不能立即处理信号,因此就需要将信号存储起来,而信号存储在进程的哪里?对于进程存储信号的位置,我们也需要聊一聊。

通过刚才kill -l打印的信号信息,我们知道一共有62个信号,有这么多的信号,一个进程要怎么知道当前接收到的信号是哪个?进程的task_struct中有一个sig变量(大概是这个名字),通过该变量的比特位的位置表示信号的种类(用来区别信号),通过比特位的内容表示当前进程是否接收到这个信号,一个比特位有0和1两个数表示两种状态,进程用这样的位图结构来记录信号的信息。

而作为位图结构的sig变量是存储在task_struct中的,task_struct是一个内核结构,也就是说只有操作系统有权利修改这个信号位图,所以无论信号是谁产生的,最终都是由操作系统设置进程的信号位图

进程接收信号这一动作,可以分为三个过程,信号产生前,信号产生时,信号产生后,下面分别对每个过程的细节进行解析

信号产生前

由终端产生信号

刚才所说的Ctrl + C实际就是用户层产生信号的一种方式:由键盘(终端)产生,Ctrl + C产生的信号是2号信号,可以使用signal函数验证这个观点在这里插入图片描述
signal可以设置对信号的自定义处理方法,signum是要自定义递达方法的信号编号,信号的递达方法为handler函数指针指向的方法,signal函数将函数指针handler作为参数

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

using namespace std;

void handler(int signo)
{
    cout << "当前进程获取到一个信号: " << signo << endl;  
}

int main()
{
	// signal只是设置了一个信号的响应方法,并没有发送信号
    signal(SIGINT, handler);
    sleep(2);
    cout << "自定义信号设置完成" << endl;
    sleep(2);
    while (1)
    {
        cout << "当前进程正在运行,pid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

运行以上程序,当程序打印"自定义信号设置完成"的提示后,2号信号的处理方法就成为了自定义函数,接着按下Ctrl + C,如果Ctrl + C产生的信号是2号SIGINT信号,程序会调用自定义处理函数handler,打印信息。
在这里插入图片描述
Ctrl + C没有使进程终止,而是调用2号信号的自定义handler打印信息,所以Ctrl + C产生的信号为2号信号。准确的说是硬件中断产生了2号信号,2号信号被发送给前台进程,前台进程接收到信号后做出退出的响应。

虽然硬件产生了2号信号,但信号并不是由硬件发送信号给进程,信号的发送者应该是操作系统,因为进程记录信号的位图结构在内核中,只有操作系统有权限修改内核结构,所以硬件产生的信号必须经过操作系统发送给进程。但是“发送”这个说法其实有些不准确,进程怎么知道此时接收到了什么信号,只有通过位图结构的0和1才能知道,而只有操作系统才能向位图结构写入数据,将0变为1,所以进程接收的信号是由操作系统写入的,因此,写入信号的说法比发送信号更加准确。

在这里插入图片描述
除了用硬件发送信号,调用系统接口也能发送信号。kill,向任意进程发送任意信号,该函数需要进程的pid与要发送的信号码sig,有了这个函数,可以实现我们自己的kill指令。

// mykill.cc,利用命令行参数实现mykill指令
#include <iostream>
#include <signal.h>
#include <cstdlib>
#include <cstring>
#include <sys/types.h>
using namespace std;

int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        cout << "Usage:" << argv[0] << " signo pid" << endl;
        exit(1);
    }

	// abs确保信号字符串转化是一个正数
    if (kill(atoi(argv[2]), abs(atoi(argv[1]))) == -1)
    {
        cerr << "kill: " << strerror(errno) << endl;
        exit(2);
    }

    return 0;
}

// mypro.cc,该程序死循环地打印数据
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

using namespace std;

int main()
{
    while (1)
    {
        cout << "当前进程正在运行,pid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

先运行mypro程序,再运行mykill杀死mypro程序。
在这里插入图片描述
发送信号的另一个函数——raise:给自己发送任意信号
在这里插入图片描述

// mypro.cc,不断地给自己发送SIGINT信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

using namespace std;

void handler(int signo)
{
    cout << "当前进程获取到一个信号: " << signo << endl;  
}

int main()
{
	// 设置SIGINT信号的自定义方法
    signal(SIGINT, handler);
    // 不断地向自己发信号
    while (1)
    {
        sleep(1);
        raise(SIGINT);    
    }
    return 0;
}

在这里插入图片描述
测试raise的代码运行结果

abort:向自己发送终止信号
在这里插入图片描述
将上面代码的arise改为abort,程序休眠一秒后直接退出了
在这里插入图片描述

在这里插入图片描述
通过kill -l打印的信息,可以得知abort发送的信号SIGABRT是6号信号,对其进行自定义行为的设置
在这里插入图片描述

// mypro.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/types.h>

using namespace std;

void handler(int signo)
{
    cout << "当前进程获取到一个信号: " << signo << endl;  
}

int main()
{
	// 对SIGABRT信号进行自定义行为的设置
    signal(SIGABRT, handler);
    // 不断地给自己发送SIGABRT信号
    while (1)
    {
        sleep(1);
        abort();    
    }
    return 0;
}

在这里插入图片描述
虽然可以设置SIGABRT信号的自定义行为,但是该信号总是会触发进程的终止,循环只执行一次,进程只接收到一次SIGABRT信号,调用了一次自定义handler方法就退出了。SIGABRT与SIGKILL,9号信号类似,只不过9号信号不能设置自定义行为,9号信号总是能终止一个进程,而SIGABRT可以设置自定义行为,但是依然会终止进程。

由软件条件产生信号

除了直接调用系统接口产生信号,由硬件条件产生信号,还可以由软件条件产生信号。就比如,平常是闹钟这个硬件叫你起床,今天是你父母叫你起床,起床的信号由人产生,发送给人,相当于信号由软件条件产生,接收信号的也是软件。
在这里插入图片描述
alarm是一个闹钟函数,参数为seconds,在seconds秒后,该函数会向自己发送一个SIGALRM信号,该信号会使终止进程在这里插入图片描述

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

using namespace std;

int main()
{
	// 闹钟将在1秒后响起
    alarm(1);
    int cnt = 0;
    while (1)
    {
        cout << cnt << endl;
        cnt++;  
    }
    return 0;
}

以上程序在1秒的时间内不断地输出cnt的值,并且++cnt,在1秒后,进程退出,打印操作停止,运行以上程序
在这里插入图片描述
可以看到cnt的值最终为122720

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

using namespace std;

int cnt = 0;

void handler(int signo)
{
    cout << "当前进程获取到一个信号: " << signo << cnt << endl;  
}

int main()
{
    alarm(1);
    signal(SIGALRM, handler);
 
    while (1)
    {
        //cout << cnt << endl;
        cnt++;  
    }
    return 0;
}

在这里插入图片描述
同样是cnt在1秒内不断的++,只是把cout的标准输出注释掉了,并且通过捕获SIGALRM信号,使其输出cnt的值,运行程序,发现cnt最后增加到14亿的大小了。通过这两份代码的比较,我们可以直观地认识到IO的速度与cpu相比究竟有多慢

硬件异常产生信号

int main()
{
    int a = 1;
    int b = 0;
    cout << a / b << endl;
    return 0;
}

在这里插入图片描述

int main()
{
    int* p = nullptr;
    *p = 1;
    return 0;
}

在这里插入图片描述
上面两段代码运行后产生崩溃的原因:浮点数异常和段错误是我们平常编写代码时最经常犯的错误,并且这样的错误是不能通过编译检查出来的,只有运行程序才会发生异常。如果在使用visual studio遇到这样的情况,程序在运行后会弹出一个提示框,上面有一个红色的大叉(已经刻骨铭心),提示你程序运行出错。无论是在哪个平台,程序崩溃的本质都是:硬件异常,操作系统识别到硬件异常,将硬件异常转换为信号,向目标进程发送信号,使其终止。

当发生除0错误时,cpu的状态寄存器会被设置,记录cpu当前遇到了错误,并指出这个错误是什么,由谁产生的?cpu识别到状态寄存器的报错后,会处理该错误信息,将其转换为信号,向产生错误的进程发送信号,目标进程接收信号后在合适的时间处理信号,一般都是直接退出。

我们在语言层面上使用的地址都是虚拟地址,虚拟地址要经过转化,成为物理地址才能在物理内存上正确的读取数据。地址的转换工作由MMU(硬件)+页表(软件)进行,如果地址转换出现问题,MMU的状态会被设置,错误信息会被保存到其状态寄存器中,操作系统识别到MMU的报错后,将错误信息转换成信号,发送给目标进程,目标进程接收信号后,直接退出。

所以在学习了信号之后,我们对程序崩溃就有了一个全新的理解,以前程序崩溃,我们只知道是我们写的程序出现了问题,现在我们知道程序崩溃是由于底层的硬件报错,操作系统识别到错误信息,由于操作系统是硬件与软件的管理者,因为你的程序导致了硬件出现了错误,操作系统不允许硬件的错误产生,所以就对产生错误的进程发送信号,进程接收信号后退出运行。

core dump

在聊进程等待时,我画了一个图
在这里插入图片描述
该图是waitpid函数中,输出型参数status的结构,低7位表示进程的终止信号,次低8位表示进程的退出码,当进程收到信号退出时,低7位的终止信号会被设置,当进程正常退出时,次低8位的退出状态会被设置。其中status的第8位没有被使用到,而这个没有被使用到的比特位表示的含义就是core dump标志。如果进程core dump,该位为1,没有则为0

那么什么是core dump,翻译成中文就是核心转储的意思,转储什么呢?如果进程因为接收到信号被终止,core dump就会将导致程序崩溃的代码上下文数据转储到一份文件中,这份文件会在当前工作路径下生成。

如果Linux系统是跑在云服务器上的,core dump默认是关闭的,用ulimit -a可以查看当前机器能生成的core文件大小,用ulimit -c 文件大小设置core文件的大小,将core文件大小设置为1024就代表着core dump开关打开。如果程序被信号所杀,将会生成一个大小为1024的core文件,core文件用于调试,保存了程序出错的信息
在这里插入图片描述

在这里插入图片描述
生成的core文件,后缀表示的数字为被杀进程的pid
在这里插入图片描述
gdb 可执行文件名 core文件名进入调试模式,可以快速地定位导致程序崩溃的代码出现的位置,我的程序因为13行的*p = 1导致其被系统终止。

至于为什么线上环境默认都是关闭core dump的原因,因为如果将程序放在线上跑了,那么该程序很可能是一个已经发布的服务,如果服务挂掉了,不可能再让程序员通过生成的core文件调式代码,重新上线服务吧。所以一般服务挂掉后,都会有监控程序重启服务,如果这时core dump是打开的,就会生成一个core文件,如果服务一直挂掉,core文件就会一直生成,直到磁盘爆满,充满垃圾文件。所以一般线上环境都会关闭core dump选项。

总结:core dump以事后调试的方式,准确的定位错误产生的位置

信号产生时

说完了信号产生前的种种情况,现在聊聊信号是怎么被进程记录的。在进程的task_struct中,有三个表,它们分别是block阻塞字,pending未决表,handler方法集

当进程接收到信号后,处理信号的操作被叫做信号递达
进程在信号产生后,信号递达前的状态,我们称为信号未决
此外信号还有一种状态:信号阻塞,拦截指定的信号,信号被拦截了,就不能被递达
直到信号阻塞结束,信号才能被递达
信号阻塞和忽略是两种不同的状态,忽略是信号递达后的一种情况,而信号阻塞时,信号连递达都没有递达

pending表是存储信号未决状态的一种位图结构,block表则是记录信号是否阻塞的位图表,handler就是存储信号递达方法的函数指针数组。之前说过,进程与信号是异步的,接收到信号后,操作系统只是将pending表的数据修改,表示信号处于未决状态。如果进程不打算处理这个信号,pending表的信息会一直保留,直到进程处理这个信号,该信号对应的pending才会被置0。
在这里插入图片描述
上图中,SIGHUP信号由于在pending表中对应的比特位为0,未处于未决状态,信号不会递达。如果SIGHUP信号的pending为1,由于其阻塞字为0,信号会被递达,即执行handler表中对应下标的方法SIG_DFL(默认)。

SIGINT的pending为1处于未决状态,但其阻塞字为1,表示信号被拦截,不能被递达,所以在其阻塞字变为0之前,handler方法SIG_IGN(忽略)不会被执行。

SIGQUIT的pending为0,虽然此时的进程没有接收到SIGQUIT信号,但由于其对应的block为1,也就是说就算进程接收到SIGQUIT信号,其也会被拦截,无法递达。

所以,之前说过系统向进程发送信号的说法是不准确的,应该说是系统向进程写入信号,而写入信号的具体操作就是修改进程的pending表,将对应信号的pending由0置1,就表示该信号被进程接收,由进程决定之后的处理操作。

sigset_t

Linux使用结构体sigset_t来表示信号的阻塞或未决状态,sigset_t被称为信号集,对应着pending表或者block表的结构
在这里插入图片描述
sigset_t在底层封装了一个数组,以目前的知识理解这个数组,数组中的不同元素表示不同的信号,一个元素的不同数值表示一个信号的不同状态。这个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); // 信号的删除,以上四个函数成功返回0,出错返回-1
int sigismember(const sigset_t *set, int signo); // 判断signo信号是否存在于信号集中,存在返回1,不存在返回0,出错返回-1

在使用sigset_t对象前,需要使用sigemptyset或者sigfillset函数将其初始化,之后才能使用对应的操作函数,设置信号集的相关函数以及其作用已经在上面列出

sigprocmask

使用sigprocmask可以读取或修改进程的信号屏蔽字
在这里插入图片描述

how:访问进程信号屏蔽字的方式
set:使用set信号集进行进程的信号集访问
oset:输出型参数,在进程的信号集修改之前,将原信号集保存到oset表中,以供可能的set恢复操作,如果不需要保存原来的set,将其设置为空即可

其中how参数有三个可以传入的宏

SIG_BLOCK:此时的set包含了我们想要屏蔽的信号,相当于mask = mask | set
SIG_UNBLOCK:此时的set包含了我们想要解除屏蔽的信号,相当于mask = mask & (~set)
SIG_SETMASK:此时的set等于我们想要设置的信号屏蔽字,相当于mask = set

使用sigpending可以获取进程的pending表在这里插入图片描述
其中的set参数是一个输出型参数,系统将进程的pending表写入到你传入的set参数中,调用sigpending函数后,set就是当前进程的pending表

有了可以对sigset_t对象设置的函数,获取当前进程的pending表的函数以及设置当前进程的信号屏蔽字的函数,可以写一段demo来测试这几个函数。

首先创建sigset_t对象bset,并对其初始化,接着设置bset,调用sigaddset,使其添加我们需要屏蔽的信号,屏蔽信号的操作完成后,将设置好的bset作为参数调用sigprocmask函数,设置当前进程的信号屏蔽字,使进程真正的屏蔽我们设置的信号。最后不断地调用sigpending函数,获取当前进程的pending表,并打印pending表。

运行程序后,我们可以向程序发送信号,如果信号被屏蔽,从打印的pending表我们可以看出其被设置为有效,但因为信号被屏蔽,无法递达,所以该位置一直是有效的状态

void show_pending(sigset_t *pendings)
{
    for (int signo = 1; signo <= 31; signo++)
    {
        if (sigismember(pendings, signo) == 1) // 信号存在于pending表中
        {
            cout << "1";
        }
        else if (sigismember(pendings, signo) == 0)
        {
            cout << "0";
        }
        else
        {
            cerr << "sigismember: " << strerror(errno);
        }
    }
    cout << endl;
}

int main()
{
    // 打印当前进程的pid
    cout << "pid: " << getpid() << endl;
    // 创建set集并对其初始化
    sigset_t bset;
    sigset_t obset;
    sigemptyset(&bset);
    sigemptyset(&obset);

    // 设置set集中的屏蔽字
    for (int signo = 1; signo <= 9; signo++)
    {
        sigaddset(&bset, signo);
    }

    // 将设置好的set集作为参数,调用sigprocmask,设置当前进程的信号屏蔽字
    sigprocmask(SIG_SETMASK, &bset, &obset);

    // 创建用来接收当前进程pending表的set集
    sigset_t pendings;
    while (1)
    {
        if (sigpending(&pendings) == 0) // 获取pending成功
        {
            show_pending(&pendings);
        }
        sleep(1);
    }

    return 0;
}

在这里插入图片描述
上面的demo中,我屏蔽了1~9号信号,向进程发送1号到4号信号,通过打印的pending表,我们可以看到对应信号被设置为有效。当9号信号被发送后,进程直接终止,但是9号信号不是被屏蔽了吗?这是因为为了安全,Linux不允许9号信号的屏蔽,自定义方法的创建与信号的忽略,使用9号信号总是可以杀死一个进程。

// mypro.cc
void show_pending(sigset_t *pendings)
{
    for (int signo = 1; signo <= 31; signo++)
    {
        if (sigismember(pendings, signo) == 1) // 信号存在于pending表中
        {
            cout << "1";
        }
        else if (sigismember(pendings, signo) == 0)
        {
            cout << "0";
        }
        else
        {
            cerr << "sigismember: " << strerror(errno);
        }
    }
    cout << endl;
}

void handler(int signo)
{
    cout << "当前进程获取到一个信号: " << signo << endl;
}

int main()
{
    // 打印当前进程的pid
    cout << "pid: " << getpid() << endl;

    // 将前31个信号设置自定义递达方法·
    for (int signo = 1; signo <= 31; signo++)
    {
        signal(signo, handler);
    }

    // 创建set集并对其初始化
    sigset_t bset;
    sigset_t obset;
    sigemptyset(&bset);
    sigemptyset(&obset);

    // 设置set集中的屏蔽字
    for (int signo = 1; signo <= 9; signo++)
    {
        sigaddset(&bset, signo);
    }

    // 将设置好的set集作为参数,调用sigprocmask,设置当前进程的信号屏蔽字
    sigprocmask(SIG_SETMASK, &bset, &obset);

    // 创建用来接收当前进程pending表的set集
    sigset_t pendings;
    int cnt = 0;
    while (1)
    {
        if (sigpending(&pendings) == 0) // 获取pending成功
        {
            show_pending(&pendings);
        }
        cnt++;
        if (cnt == 15)
        {
            // 将所有的信号屏蔽解除
            sigset_t unblock;
            sigemptyset(&unblock);
            sigprocmask(SIG_SETMASK, &unblock, nullptr); // 如果不想接收原来的block表,将第三个参数设置为空即可
            cout << "所有信号解除..." << endl;
        }
        sleep(1);
    }

    return 0;
}

继续修改demo,将前31个信号设置自定义递达方法,同样的对前9个信号设置阻塞,程序运行15秒后,解除对所有信号的屏蔽。运行程序后的前15秒中,对进程发送一些信号,因为有些信号被屏蔽,所以打印的pending可以看到哪些信号被写入了进程,当信号的屏蔽被解除后,由于信号的pending为1,而block为0,所以进程会递达这些pending为1的信号,而递达方法是我们自定义的,进程递达时会打印一条语句,以此证明该信号被递达

在这里插入图片描述

信号产生后

聊了信号产生之前以及信号产生时的一些情况,现在要谈的就是信号产生后的情况,信号产生后进程会怎么做,进程在什么时候处理信号

由于信号产生和进程的运行是异步的,即信号被写入进程时,进程可能无法及时递达该信号,那么进程会在什么时候递达信号呢?只有在进程从内核态切换为用户态时,信号才会被递达,那么内核态和用户态又是什么呢?

所以这里需要解释内核态和用户态两个概念

用户态和内核态

在这里插入图片描述
在聊进程地址空间时,我画了这张图,进程地址空间的虚拟地址可以通过页表映射为物理地址,准确的说这里的页表指的是用户级页表,除此之外还有内核级页表。我们之前接触的进程地址空间其实只是真正的地址空间中的一部分,进程地址空间分为两部分,用户空间和内核空间,用户空间存储的就是用户的数据,内核空间存储的是系统的数据。对于两种空间,页表也分为两种:用户级页表和内核级页表。

上面的图只画出了进程地址空间的用户空间,可以看到在命令行参数环境变量区的上面还有一大块区域,这块区域就是内核空间(操作系统也是软件,或者说是一个运行起来的程序,运行程序就需要将其加载到内存上,内核空间通过内核级页表映射物理内存中的操作系统数据)。用户级页表负责用户空间到内存的映射,内核级页表负责内核空间到内存的映射。每个进程都有自己的进程地址空间,每个进程的用户级页表肯定不相同,因为每个进程的数据以及所存储的地址肯定不相同,但每个进程的内核级页表却是相同的,内核相关的数据被加载到内存上,而内核的数据只有一份,也就是说一旦内核的数据被加载到了内存中,它的物理地址就已经确定了,但除了物理地址,每个进程中内核的虚拟地址也是相同的,也就是说每个进程中内核的虚拟地址的具体范围都是固定,相同的。

所有进程使用同一份内核级页表,只要进程有访问内核级页表的权限,就可以访问内核的数据,此时虽然是通过进程对系统数据进行访问,但由于每个进程的内核空间都是相同的,只要进程有访问内核的权限就可以访问系统的数据,所以从某种角度上来说,进程只是访问内核的一个工具。

只要进程有权限,就能访问内核的数据,那么操作系统怎么表示进程的权限?cpu有一个字段CPL,CPL用0~3表示4种状态,其中0表示内核态,3表示用户态,数值越大,权限越低。所以操作系统可以根据CPL保存的数据判断一个进程的身份,一个进程要提高权限只要修改CPL中的数据

当进程使用的页表为用户级页表时,进程处于用户态,用户态只能访问用户级的数据。使用的页表为内核级页表时,进程处于内核态,内核态可以访问用户级与内核级的数据。

内核的数据与进程地址空间的映射有点像动态库,动态库被加载到内存中,其代码与数据只有一份,当进程需要调用动态库时,就跳转到进程地址空间的共享区,通过共享区在页表中对动态库的映射,调用动态库。而当进程需要访问内核的数据时,就需要跳转到进程地址空间的内核空间,通过内核级页表访问内核数据,但是动态库的跳转是在用户空间上的跳转,而内核的跳转是在用户空间与内核空间之间的跳转,此时的进程就需要有足够的权限,因此调用系统级别的函数时,函数肯定会先提高进程的权限(就像设置CR3的数据为0一样)将进程的身份切换为内核态,然后再进行内核级别的操作

而我们的程序,其实在我们不知情的情况下,进行了无数次的陷入内核操作。试想一下,你的程序需不需要访问磁盘,显示器,内存,网卡等等设备,也就是说一个程序或多或少的都要对软硬件资源进行访问,本质上,我们不能直接访问这些资源,因为软硬件资源的管理者是操作系统,对这些资源的访问需要经过操作系统的同意,得到操作系统的同意后,由操作系统代替我们访问这些资源。

举一个很简单的例子,上层语言对流输出有不同的封装,C语言的printf函数,C++的cout操作符,这些封装最后都会调用系统接口,因为流输出访问了显示器这个硬件资源,当进程调用系统接口时,就需要从用户态切换成内核态,也就是切换身份获得更高的权限,以访问系统级别的接口,当访问结束,进程再从内核态切换成用户态

上面的例子是当进程访问系统级别的接口时,发生的陷入内核的操作,除此之外,陷入内核也经常发生在时间片的切换时。简而言之,操作系统为每个进程分配了时间,因为一个系统中肯定不止一个进程在运行,要使多个进程同时运行(并发运行),就需要为所有进程分配合理的时间,这个时间就是时间片,当时间片结束,而进程还未运行完成,就需要将进程的上下文数据保存起来,将当前进程放入等待队列,接着操作系统就会从等待队列取出其他进程,恢复其上下文数据,在该进程的时间片中运行该进程。所以保存上下文数据,将进程放入与取出等待队列,都是系统级别的操作,因此时间片切换时也会发生陷入内核,内核态与用户态的切换

聊了这么多的内核态和用户态,只是为了信号的递达做铺垫。当一个信号处于未决状态时,进程什么时候会递达该信号?答案是当进程从内核态切换成用户态时,刚才我们聊过,一个进程会发生无数次陷入内核,也就是从用户态进入内核态的操作,因此一个进程也会发生无数次的从内核态进入用户态,进程在这时就会检测pending表中是否有信号处于未决状态,如果有并且其未被阻塞,进程就会递达该信号。信号递达后,进程就切换成用户态,将陷入内核的操作结果返回给进程。
在这里插入图片描述

以上的情况建立在信号的handler方式为默认或者忽略(系统设置的方法)时,信号的递达方式有三种,默认,忽略,自定义方式。其中默认方式肯定是内置在内核数据中的,此时处于内核态的进程可以直接执行,而忽略方式就是什么都不干,这两种handler方式在递达后都可以直接返回用户态。

如果handler方法为自定义方法,进程会怎么执行呢?很显然,自定义的handler方法由用户自己建立,即这部分代码处于用户空间,虽然内核态的进程可以访问用户空间的代码,但从安全的角度考虑,如果自定义的handler方法是一段恶意代码,它可以利用进程的内核态权限进行危害操作系统的操作。

所以虽然内核态的进程可以直接执行自定义的handler方法,但出于进程安全问题的考虑,必须将当前进程切换为用户态再执行handler方法,以保护内核数据,handler方法执行完,进程需要再次陷入内核,因为进程上次陷入内核需要返回一些数据,这些数据只有处于内核态的进程才能访问,所以进程需要返回内核态,将内核数据返回。

在这里插入图片描述
至此可以将进程陷入内核与脱离内核的过程总结为一张图,线段与中间的直线交点表示两个状态的切换,箭头表示了是从内核到用户还是从用户到内核,中间的交点表示进程对pending表的检测

sigaction

除了signal可以设置信号的handler方法,sigaction也可以设置handler方法,不过相对signal复杂
在这里插入图片描述

sig:要设置的信号
act:信号的自定义行为
oact:更改自定义行为之前的信号行为

其中的act和oact类型为struct sigaction在这里插入图片描述
sigaction函数是对所有信号的行为进行设置,而我们只需要对普通信号进行设置,所以我们只需要用到结构体sigaction里面的sa_handler,sa_mask,sa_flags这三个成员,其他的成员是给实时信号使用的,我们不用特地设置

// mypro.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <cstring>
#include <sys/types.h>

using namespace std;

void handler(int signo)
{
    cout << "捕捉到一个信号:" << signo << endl;
    sigset_t pending;
    sigemptyset(&pending);
    while (1)
    {
        sigpending(&pending);
        for (int i = 1; i <= 31; i++)
        {
            if (sigismember(&pending, i) == 1) cout << "1";
            else if (sigismember(&pending, i) == 0) cout << "0";
            else cout << "sigismember fail" << endl;
        }
        cout << endl;
        sleep(1);
    }
}

int main()
{
    struct sigaction act, oact;
    // 设置struct sigaction的三个成员
    act.sa_handler = handler;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);

    sigaction(2, &act, &oact);

    while (1)
    {
        sleep(1);
    }
    return 0;
}

上面的demo对2号信号设置了自定义handler方法,在设置完后进入死循环休眠,只要对该进程发送2号信号,handler方法就会被执行,而Ctrl+C对应的信号就是2号信号
在这里插入图片描述
对进程发送2号信号,可以看到成功的执行了handler函数,因为handler死循环的打印当前进程的pending表,所以该信号一直处于递达的状态,因为一直在执行handler函数

在一个信号的递达方法被调用时,系统将自动地阻塞该信号。从上面demo的运行结果就可以看出,再次按下Ctrl+C,用于2号信号的handler还在运行,所以系统将其设置为阻塞,因此进程pending表的2号信号位置被设置为有效之所。

系统将信号设置为阻塞,是为了防止递达方法被多次递归调用,如果多次递归调用将会导致系统变得复杂,运行的成本增加,甚至出现无限递归问题。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值