【Linux】进程信号

进程信号

信号引子:

生活角度的信号
你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,
你该怎么处理快递。也就是你能“识别快递”
当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那
么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不
是一定要立即执行,可以理解成“在合适的时候去取”。
在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知
道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动
作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快
递(快递拿上来之后,扔掉床头,继续开一把游戏)
快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

技术应用角度的信号

用户输入命令,在Shell下启动一个前台进程。
. 用户按下 Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程
. 前台进程因为收到信号,进而引起进程退出

前台进程and后台进程

前台进程:

是在终端中运行的命令,那该终端就是进程的控制终端,一旦这个终端关闭,进程也随之消失

我们在前台进程中,我们可以通过ctrl+c打断

image-20231127160337251

后台进程:

后台进程也就做守护进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。 不受终端控制,它不需要终端的交互;Linux的大多数服务器就是使用守护进程实现的。比如Web服务器的httpd等。

Ctrl+C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程
结束就可以接受新的命令,启动新的进程。

显然我们让该程序放在后台后,我们用Ctrl+C不可以打断进程

image-20231127160502549

但是按命令是有效果的,因为这是后台进程,不会影响命令。如果想要查看该后台进程,可以按jobs,看到当前后台进程:

image-20231127160721431

如果想要将后台进程变为前台进程,可以按fg [jobs中对应的号],这里对应的是1,就是fg 1。这时,按ctrl+c就可以结束进程了。

Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生
的信号。

前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行
到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步
(Asynchronous)的。

信号概念:

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

用kill -l命令可以察看系统定义的信号列表

image-20231127161015873

每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定 义 #define
SIGINT 2

我们发现这里有62个信号,其中34以上的是实时信号,我们本章就只讨论31号信号以下的,不讨论实时信号,这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal

image-20231127161249310

信号处理常见方式概览

(sigaction函数稍后详细介绍),可选的处理动作有以下三种:

  • 忽略此信号。
  • 执行该信号的默认处理动作。
  • 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉
    (Catch)一个信号(自定义函数接口)

信号的过程:

信号产生前:

信号是会被进程记住的有没有产生 + 什么信号产生),实际上进程信号会被记录在pcb中,总所周知,pcb是一个结构体,因此就够提里面有一个位图变量来记录接受什么几号信号

假设接收了5号信号:image-20231127161822687

进程接收了几号信号,就会在第几位bit位上置为1,

pcb是内核的数据结构,因此我们并没有权限去修改pcb结构体里面的位图变量,因此只有OS才有权限去修改

所以无论信号怎么产生,最终一定只能是OS来进行信号的设置。

产生信号:

因为接受信号后,只有OS才有权限去修改pcb结构体的位图变量,因此只有OS才可以发送信号,发送信号的方式有很多种。

通过终端按键产生信号

SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一
下。

这个其实就是我们刚刚用Ctrl+C去终止进程,我们也可以在命令行中发送其他信号给os,由OS发送给进程

但实际上除了按ctrl-c之外,按ctrl-\也可以终止该进程:

image-20231127162513745

ctrl+c本质是2号信号,Ctrl+\本质是3号信号

在具体展开说明之前,来看下信号的接口函数:

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
  • sighandler_t:返回值为void,参数为int的一个函数指针

  • signum:对哪个信号设置捕捉信号

  • handler:是一个函数指针,这个函数允许用户自定义对信号的处理动作

    这里的SIGINT就是2号信号:

    #include<iostream>
    #include<signal.h>
    #include<unistd.h>
    using namespace std;
    void handler(int signo)
    {
        cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl;
    }
    int main()
    {
        //SIGINT:2号信号
        //这里不是调用handler方法,这里只是设置了一个回调,让SIGINT(2)产生的时候,该方法才会被调用
        //如果不产生SIGINT(2),该方法不会被调用!
        signal(SIGINT, handler);
        sleep(3);
        cout << "进程已经设置完了" << endl;
        sleep(3);
        while (true)
        {
            cout << "我是一个正在运行中的进程:" << getpid() << endl;
            sleep(1);
        }
        return 0;
    }
    

image-20231127163151779

上述代码并不是调用handle函数,而是设置了一个回调,当我们调用2号信号,我们才回调handle函数,如果不调用2号信号就不回调

ctrl + c本质就是给前台进程发送2号信号给目标进程,上述结果中我们每按一次ctrl-c,就获得一个2号信号,目标进程默认对2号信号的处理,是终止自己,但是现在我们更改了对2号信号的处理,这就是我们设置了用户自定义处理动作。为了终止该进程,我们使用了ctrl-\来终止该进程。
上述测试结果也足矣说明键盘是可以产生信号的!

我们不止可以对2号信号设置handle函数,我们也可以对3号信号设置handle函数,如果我们对所有的信号设置handle函数,那我们是不是可以让进程当枪不入杀不死呢?

我们来验证一下是否可以对9号信号设置handle函数:

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void handler(int signo)
{
    cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl;
}
int main()
{
    for (int sig = 1; sig <= 31; sig++)
    {
        signal(sig, handler);//设置所有的信号的处理动作,都是自定义动作
    }
    sleep(3);
    cout << "进程已经设置完了" << endl;
    sleep(3);
    while (true)
    {
        cout << "我是一个正在运行中的进程:" << getpid() << endl;
        sleep(1);
    }
    return 0;
}

image-20231127163920800

我们发现我们依然可以通过9号信号将进程杀死!!!

结论是31个信号,我们可以对30个信号进行设置,但是我们唯独不可以对9号信号设置handle函数

总结用户层产生信号的方式:键盘产生

注意这个是键盘产生的信号,不是键盘发送的信号,是OS发送的信号。
问:OS是如何发送信号的?

OS能找到每个进程的take_struct,也能找到当前显示器上前台进程的take_struct,每一个进程的take_struct内部都有一个位图,OS在拿到了对应的信号后,将这个对应的位置由0设为1,OS就完成了信号的发送(OS发送信号,也可以说成是写入信号)

Core Dump

首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁
盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,
事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许
产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,
因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许
产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c
1024

我们之前在进程等待waitpid中提及过core dump但是我们并没有进行讲解,今天我们来了解一下core dump

pid_t waitpid(pid_t pid, int *status, int options);

waitpid函数的第二个参数status是一个输出型参数,用于获取子进程的退出状态。status是一个整型变量,但status不能简单的当作整型来看待,status的不同比特位所代表的信息不同,具体细节如下(只关注status低16位比特位):

低7位代表的是进程是否收到信号(异常终止),其中有一个标记位(第8位)叫核心转储core dump。

image-20231127164909380

在具体展开讨论前,先来写如下的一个异常代码:

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdlib>
using namespace std;
int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        // 子进程
        int *p = nullptr;
        *p = 1000; // 野指针问题
        exit(1);
    }
    // 父进程
    int status = 0;
    waitpid(id, &status, 0); 
    printf("exitcode: %d, signo: %d, core dump flag: %d\n", \
    (status >> 8) & 0xFF, status & 0x7F, (status >> 7) & 0x1);
    return 0;
}

image-20231127165530900

这里core dump为什么是0呢?在解释此现象之前,先来解释下core dump:

我们使用如下的指令来查看更详细的信号手册:

image-20231127170928881

Term和Core都代表着终止进程,但是Core在终止进程的时候会进行一个动作:核心存储

什么是core dump(核心存储)呢?

在云服务器中,核心转储core dump是默认被关掉的,我们可以使用 ulimit -a 命令查看当前资源限制的设定:

image-20231127171212909

我们发现第一行core file size的文件大小为0,为什么为0呢?

因为默认就为0,这个大小是可以设置的,我们可以利用ulimit -c size去设置core file size

core文件的大小设置完毕后,就相当于将核心转储的功能打开了

image-20231201162029187

我们这个时候运行一下我们之前的Test可执行文件,我们发现这个时候core dump flag变为1,证明核心存储功能确实打开了,并且我们多了一个core.11356文件(后面的数字代表此进程pid)

我们可以利用du -k core文件名来查看文件大小,单位为KB

image-20231201163000103

core dump标志位的作用:

当我们程序异常退出后,如果是收到信号终止,系统会将core dump标志位置为1,并且生产一个core.xxxx的文件,像一些外部的错误则跟我没关系了

#include <iostream>
using namespace std;
int main()
{
    cout << "begin ..." << endl;
    int *p = nullptr;
    *p = 1000;
    cout << "end ..." << endl;
    return 0;
}

上述代码发生野指针错误,我们发现运行后报错出现了core dumped,并且出现了一个core文件

我们通过gdb来调试一下这个文件来看看是怎么个事!

显然运行到一半的时候,它接受到11号信号,然后终止了

image-20231201164100578

**注意:**事后用调试器检查core文件以查清错误原因,这种调试方式叫做事后调试。如上就是core dump的好处(便于调试)

为什么core dump不是默认开启呢?

虽然core dump的好处很明显(便于调试,直接定位错误),但是假象一下,如果有一天你的代码本身发生了错误,不是外部错误,万一有些解决策略就是把服务不断重启,那么就会出现一个问题,一运行就挂,每次重启就core dump一下,且赠送你一个几百kb左右大小的core文件,若重启了一晚上,那么你的磁盘全是core文件,磁盘上全是垃圾文件,那么OS就可能收到影响。若扩大到企业级那风险可就大了,即使你限制了core文件的大小,但这些垃圾文件总归是不好的。

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

kill函数

kill -信号名 进程pid

image-20231201181216001

这个型号名也可以用信号编号来代替

kill -信号编号 进程pid

image-20231201181340212

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

#include<sys/types.h>
#include<signal.h>
int kill(pid_t pid,int sig)

kill函数函数本质上就是向pid进程发送sig信号,发送成功返回0,发送失败返回-1

raise函数

raise函数可以给当前进程发送指定的信号(自己给自己发信号)。

#include <signal.h>
int raise(int signo);

发送成功返回0,否则返回一个非零值。

abort函数

abort函数使当前进程接收到信号而异常终止。

#include <stdlib.h>
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值。
由软件条件产生信号

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

alarm函数的返回值:

  • 若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置
  • 如果调用alarm函数前,进程没有设置闹钟,则返回值为0

我们来试一下1s内,云服务器可以运行多少次呢?

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
int cnt = 0;
int main()
{
    alarm(1);
    //统计一下我们的进程在1s钟,cnt++多少次
    while (1)
    {
        printf("hello : %d\n", cnt++);
    }
    return 0;
}

image-20231201185820583

我们发现它可以运行7w多次呢!!!

其实呢我们的云服务器在1s内可以运行的次数远大于2w次,但是我们这里为什么只是运行7w呢?

有两个因素在影响,一个就是我们cnt++的时候,一次++我们就printf一次,外设之间io操作的时间比++的时间慢得多,另外就是我们在云服务器中,我们的数据需要用过网络传输,因此运行次数会少得多

为了尽可能避免上述问题,我们可以先让cnt变量一直执行累加操作,对SIGALRM14号信号进行捕捉,在1s后进程收到SIGALRM信号后再打印累加后的数据:

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
int cnt = 0;
void handler(int signo)
{
    cout << "我是一个进程,刚刚获取了一个信号:" << signo << "cnt: " << cnt << endl;
    exit(1);
}
int main()
{
    signal(SIGALRM, handler);
    alarm(1);
    while (1)
    {
        cnt++;
        // printf("hello : %d\n", cnt++);
    }
    return 0;
}

image-20231201190525292

此时可以看到,cnt变量在一秒内被累加的次数变成了4亿多,由此也证明了,与计算机单纯的计算相比较,计算机与外设进行IO时的速度是非常慢的。

硬件异常产生信号

硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

我们都遇到过程序由于异常而崩溃的情况,诸如除0错误,非法访问野指针,越界错误等:

img

我们通过对31个信号先进行捕捉,然后在代码除0错误,非法访问野指针,和越界错误

#include <iostream>
#include <signal.h>
#include <cstdlib>
using namespace std;
int cnt = 0;
void handler(int signo)
{
    cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl;
    exit(1);
}
int main()
{
    for (int sig = 1; sig <= 31; sig++)
    {
        signal(sig, handler);
    }
    // int b = 10;
    // b /= 0;
 
    // int *p = nullptr;
    // *p = 100;
 
    int a[10];
    a[10000] = 100;
    return 0;
}

img

我们发现除0错误是8号信号,而非法访问野指针和越界错误都是11号信号

综上,进程崩溃的本质就是进程在运行过程中收到了操作系统发来的信号而被终止。那么操作系统是如何识别到一个进程触发了某种问题呢?

image-20231212134351116

综上,进程崩溃的本质就是进程在运行过程中收到了操作系统发来的信号而被终止。那么操作系统是如何识别到一个进程触发了某种问题呢?

越界&&野指针问题:

我们在语言层面的指针(虚拟地址)通过MMU和页表将其转换为物理地址,再用物理地址去访问物理内存从而获取相应的代码和数据

  • 如果虚拟地址有问题,地址转化的工作是由MMU内存管理单元(硬件)+ 页表(软件)做的。转化过程就会引起问题,表现在硬件MMU上,此时OS就发现硬件出现了问题(谁干的 && 是什么报错)

而当虚拟地址转换为物理地址出错时:MMU(硬件)会出现错误OS会发现,将错误包装成信号,进而向进程发送信号,进程处理信号后默认会终止进程

image-20231212135925402

除0问题:

我们都知道cpu内部有一堆寄存器,当我们在做类似除0这类算数运算的时候,我们是先将这两个操作数分别放到cpu的寄存器中,然后进行算术运算并把结果写回寄存器当中。此外,cpu当中还有一组寄存器叫做状态寄存器,它是用来表征本次计算是否出现问题,来标记当前指令执行结果的各种状态信息,如有无进位,有无溢出等待。而OS是软硬件资源的管理者。如果本次计算出现了问题,那么状态寄存器当中特定的标记位会被置位。也就是说,当我们除0的时候,cpu内部的状态寄存器会被设置成为:有报错,浮点数越界。此时OS就会马上识别到当前cpu内部有报错啦(谁干的 && 是什么报错)
此时OS就将识别到的硬件错误包装成信号——》向目标进程发送信号——》目标进程在合适的时候处理信号——》默认终止进程。

阻塞信号:

信号其他相关常见概念

实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

这里提到了阻塞信号,而我上面提到过忽略信号,阻塞和忽略有什么区别呢?

  • 忽略信号:是处理信号的一种,只不过处理的方式是忽略,什么都不做,将pending位图由1置0
  • 阻塞信号:不是处理信号,是拦截信号,不允许去处理信号
在内核中的表示

每个信号都有一个block(阻塞)和pending(未决),以及一个信号处理动作handler

  • block:阻塞信号集,和pending都是位图,对应的比特位为1,就会拦截对应的信号去递达对应的方法,即使pending为1收到了信号也没用。
  • pending:用来识别信号中对应信号的位置,若为1,就说明收到信号,为0,说明没收到信号。
  • handler:用来处理信号,信号的编号就作为这个函数指针的数组下标,直接可以访问到对应的自定义的方法,或者系统默认的处理方法。

从上图可知:

SIGHUP信号没有阻塞也没有产生过,当它递达时执行默认处理动作。

SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前
不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。

sigset_t

每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号
的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有
效”和“无效”的含义是该信号是否处于未决状态。

#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
	unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
 
typedef __sigset_t sigset_t;

block阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

信号集操作函数

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置位,表示该信号集的有效信号包括系统支持的所有信号。
  • sigaddset函数:在set所指向的信号集中添加某种有效信号。
  • sigdelset函数:在set所指向的信号集中删除某种有效信号。
  • sigemptyset、sigfillset、sigaddset、sigdelset函数都是成功返回0,出错返回-1。
  • sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。

注意: 在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号处于确定的状态

#include <stdio.h>
#include <signal.h>
int main()
{
	sigset_t s; //用户空间定义的变量
	sigemptyset(&s);
	sigfillset(&s);
	sigaddset(&s, SIGINT);
	sigdelset(&s, SIGINT);
	sigismember(&s, SIGINT);
	return 0;
}

sigprocmask

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。

如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。

如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。

假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

image-20231212151021851

返回值说明:

  • sigprocmask函数调用成功返回0,出错返回-1。

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递
达。

sigpending

#include <signal.h>

int sigpending(sigset_t *set);

读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

我们通过上述的函数来练习一下:

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdlib>
using namespace std;
void handler(int signo)
{
    cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl;
    exit(1);
}
 
// 打印信号集
static void showPending(sigset_t *pendings)
{
    for (int sig = 1; sig <= 31; sig++)
    {
        // 检测特定的信号在不在此pending集合里
        if (sigismember(pendings, sig))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}
int main()
{
    // 3、屏蔽2号信号
    sigset_t set, oset;
    sigemptyset(&set);
    sigemptyset(&oset);
    // 3.1、添加2号信号到信号屏蔽字中
    sigaddset(&set, 2);
    // 3.2、设置用户及的信号屏蔽字到内核中,让当前进程屏蔽掉2号信号
    sigprocmask(SIG_SETMASK, &set, &oset);
    // 2、signal
    signal(2, handler);
    // 1、不断的获取当前进程的pending信号集
    sigset_t pendings;
    while (true)
    {
        // 1.1、清空信号集
        sigemptyset(&pendings);
        // 1.2、获取当前进程(谁调用,谁获取)的pending信号集
        if (sigpending(&pendings) == 0)
        {
            // 1.3、打印一下当前进程的pending信号集
            showPending(&pendings);
        }
        sleep(1);
    }
    return 0;
}

ctrl+c(2号信号)后,这里一直被阻塞,因此2号信号一直处于未决状态,所以我们看到pending表中的第二个数字一直是1。

image-20231212152732419

我们把设置信号的处理动作全部统一成一个方法,并且定义一个计数器cnt变量,让它printf跑上个20s钟,在这20s内,相当于信号都是被屏蔽的,20s后,我们把指定的2号信号解除屏蔽

#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstdlib>
#include <cstring>
#include <string>
using namespace std;
int cnt = 0;
 
void handler(int signo)
{
    cout << "我是一个进程,刚刚获取了一个信号:" << signo << endl;
    // exit(1);
}
 
// 打印信号集
static void showPending(sigset_t *pendings)
{
    for (int sig = 1; sig <= 31; sig++)
    {
        // 检测特定的信号在不在此pending集合里
        if (sigismember(pendings, sig))
            cout << "1 ";
        else
            cout << "0 ";
    }
    cout << endl;
}
int main()
{
    cout << "pid: " << getpid() << endl;
    // 3、屏蔽所有的信号
    sigset_t set, oset;
    sigemptyset(&set);
    sigemptyset(&oset);
    // sigfillset();//把信号全部置位
    for (int sig = 1; sig <= 31; sig++)
    {
        // 3.1、添加sig号信号到信号屏蔽字中
        sigaddset(&set, sig);
        // 2、signal
        signal(sig, handler);
    }
    // 3.2、设置用户及的信号屏蔽字到内核中,让当前进程屏蔽掉sig号信号
    sigprocmask(SIG_SETMASK, &set, &oset);
 
    // 1、不断的获取当前进程的pending信号集
    sigset_t pendings;
    while (true)
    {
        // 1.1、清空信号集
        sigemptyset(&pendings);
        // 1.2、获取当前进程(谁调用,谁获取)的pending信号集
        if (sigpending(&pendings) == 0)
        {
            // 1.3、打印一下当前进程的pending信号集
            showPending(&pendings);
        }
        sleep(1);
        cnt++;
        if (cnt == 20)
        {
            cout << "解除对2号信号的block..." << endl;
            sigset_t sigs;
            sigemptyset(&sigs);
            sigaddset(&sigs, 2);
            sigprocmask(SIG_UNBLOCK, &sigs, nullptr);
        }
    }
    return 0;
}

image-20231212153642345

捕捉信号:

内核态 && 用户态

上文我们说到进程收到信号后,并不会马上处理,而是会在一个合适的时间去处理!

这个合适的时间就是从内核态转换为用户态的过程中,进行信号的检查和处理

在了解内核态和用户态之前,我们先来了解一下内核空间和用户空间

我们之前学过进程地址空间,知道我们的进程地址空间(4G),进程地址空间可分为内核空间(1G)和用户空间(3G)

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

image-20231212160930915

内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系

如何理解进程切换?

在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。
执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。
回到一开始的问题:何为内核态与用户态?

内核态通常用来执行操作系统的代码,是一种权限非常高的状态。
用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。
进程收到信号之后,并不是立即处理信号,而是在合适的时候,这里所说的合适的时候实际上就是指,从内核态切换回用户态的时候。

当前进程如何具备权利,访问这个内核页表,乃至访问内核数据呢?

要进行身份切换:

  • 进程如果是用户态的——只能访问用户级页表
  • 进程如果是内核态的——就可以访问内核级和用户级的页表

如何确认我为用户态还是运行态呢?

  • CPU内部有对应的状态寄存器CR3,用比特位标识当前进程的状态。0为内核态,3为用户态。

用户态什么时候转换为内核态呢?

  1. 需用进行系统调用时
  2. 当前进程的时间片到了
  3. 产生了异常中断陷阱的情况

与之对应的,从内核态切换为用户态有如下几种情况:

  1. 系统调用返回时。
  2. 进程切换完毕。
  3. 异常、中断、陷阱等处理完毕。

其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态。

内核态和用户态的区别是什么呢?

内核态:可以访问所有的代码和数据(不是意味着它一定要访问所有的)—— 具备更高权限
用户态:只能访问自己的
我们的程序,会无数次直接或间接的访问系统软硬件资源(管理者是OS),本质上,你并没有自己去操作这些软硬件资源,而是必须通过OS -> 无数次的陷入内核(1、切换身份;2、切换页表)-> 调用内核的代码 -> 完成访问的动作 -> 结果返回给用户(1、切换身份;2、切换页表)-> 得到结果。

即使是像while(1)这样的代码也是会进行内核态和用户态 切换的。因为它也有自己的时间片 -> 时间片到了的时候 -> 切换至内核态,更换内核级页表 -> 保护上下文,执行调度算法 -> 选择了新的进程 -> 恢复新进程的上下文 -> 切换至用户态,更换成用户级页表 -> cpu执行的就是新进程的代码!

内核如何实现信号的捕捉

image-20231212185107864

当cpu在执行你的代码时,一定会因为某些原因由用户态进入内核态(如上的open调用),执行完此代码后,理论上应该直接返回,但是现在是直接去进程PCB里查看其信号列表(pending & block), 如果pending和block均为0,则没有信号需要处理直接返回;
若pending为1,而block为0,且handler为自定义方法,此时OS就会从内核态切换到用户态(注意:这里一定要切换,虽然内核态可以完成用户态的操作,但是如果用户写的是一段恶意代码,那么因为内核态的权限过大,无论什么代码都会执行,就会导致OS受到恶意攻击,而切换成用户态就可以因为权限小而不会去执行该代码),以用户态的身份执行此自定义handler方法,执行完自定义方法后不能直接返回给我的代码,而是返回到先前在内核检测信号的位置,然后通过特定的系统调用再返回给我的代码。

image-20231212191327184

上图过于复杂,下图帮我们进行记忆

image-20231212191417027

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

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

  • signum:对哪个信号实施信号自定义捕捉
  • act:若act指针非空,则根据act修改该信号的处理动作
  • oldact:若oldact指针非空,则通过oldact传出该信号原来的处理动作

其中,参数act和oldact都是结构体指针变量,该结构体的定义如下:

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:

将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。
注意:所注册的信号处理函数的返回值为void,参数为int,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然这是一个回调函数,不是被main函数调用,而是被系统所调用。

结构体的第二个成员sa_sigaction:

sa_sigation是实时信号的处理函数。
结构体的第三个成员sa_mask:

当某个信号的处理函数被调用,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。
结构体的第四个成员sa_flags:

sa_flags字段包含一些选项,这里直接将sa_flags设置为0即可。
结构体的第五个成员sa_restorer:

该参数没有使用

可重入函数

先前我们学习链表的时候,都清楚链表头插的过程:(如下带哨兵位头节点的单链表)

image-20231212193343533

下面主函数中调用insert函数向链表中插入节点node1,此时某信号处理函数也调用了insert函数向链表中插入节点node2,乍一看好像没什么问题:

image-20231212193517359

下面我们来分析一下,对于下面这个链表:

image-20231212193526309

1、首先,main函数中调用了insert函数,想将结点node1插入链表,但插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回到用户态之前检查到有信号待处理,于是切换到sighandler函数:

image-20231212193638678

2、而sighandler函数中也调用了insert函数,将结点node2插入到了链表中,插入操作完成第一步后的情况如下:

image-20231212193650914

3、当结点node2插入的两步操作都做完之后从sighandler返回内核态,此时链表的布局如下:

image-20231212193708640

4、再次回到用户态就从main函数调用的insert函数中继续往下执行,即继续进行结点node1的插入操作:

image-20231212193738781

最终结果是,main函数和sighandler函数先后向链表中插入了两个结点,但最后只有node1结点真正插入到了链表中,而node2结点就再也找不到了,造成了内存泄漏。

总览过程如下:

像上例这样,insert函数被不同的控制流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。
而insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入(Reentrant)函数。

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

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

volatile

volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量
的任何操作,都必须在真实的内存中进行操作

  • 19
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

学IT的小卢

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

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

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

打赏作者

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

抵扣说明:

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

余额充值