自学内容网 自学内容网

Linux从0到1——信号【信号的概念/信号的产生/Core Dump/信号的保存/信号的处理/可重入函数/volatile/SIGCHLD】


1. 信号的概念


1.1 生活角度的信号


1. 生活中的信号

  • 红绿灯、下课铃声、狼烟、闹钟、防控劲爆、狼烟、快递电话。

2. 深入理解“红绿灯”

  • 大家不妨思考一个问题,我们为什么认识红绿灯,什么叫做“认识”?
    • 认识红绿灯,即我们知道,对应的灯亮了,意味着什么,要做什么。
    • 我们之所以“认识”红绿灯,是因为有人告诉过我们。
    • 所以,输出一个结论:信号没有产生的时候,其实我们已经知道,如何处理这个信号了。

3. 深入理解“快递电话”

  • 当快递小哥给你打电话,通知你快递到了时,你可能正在打团,此时你没办法即使处理这个快递,只能打完团再去拿。
  • 你并不清楚,快递小哥何时会打来电话。
  • 所以,输出两个结论:
    • 信号的到来,相对于我正在做的工作,是异步(互不影响)产生的。
    • 信号产生了,我们不一定要立即处理它,而是合适的时候处理。这就要求我们要有一种能力,将已经到来的信号暂时保存。

“我们”就是进程。


1.2 技术应用角度的信号


1. 什么叫做信号?

  • 信号是一种向目标进程发送通知消息的一种机制。

2. 使用kill -l查看系统中的信号列表

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

在这里插入图片描述

  • 编号31以上的是实时信号,本章只讨论编号31及以下的普通信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明(使用指令man 7 signal查看)。

在这里插入图片描述

  • 细节:没有0号信号,0号位置用于标识进程正常退出。没有32和33号信号。

1.3 前台进程和后台进程


1. 进程在运行时,前台进程只能有一个,后台进程可以有多个

  • 判断一个进程是前台进程还是后台进程,只需要看它有没有能力接收用户输入(因为键盘只有一个)。
  • ctrl + c可以终止前台进程,无法终止后台进程。不过这只是一般情况,shell大多数时候也是前台进程,但是ctrl + c无法终止shell自己(后面会讲,这是因为进程可以对信号做特殊处理)。
  • 前台进程不能被暂停ctrl + z),如果被暂停,该前台进程必须立即被放到后台。因为前台进程只有一个,如果被暂停了,就没人来接收用户的信号了,OS就挂掉了。
  • 当我们启动一个前台进程时,shell进程会被OS自动挂到后台;当我们想暂停一个前台进程时,shell进程会被OS自动挂到前台,然后将被暂停的进程挂到后台。

2. 小实验,熟悉前后台切换的操作

  • 实验用代码:
#include <stdio.h>
#include <unistd.h>

int main()
{
    while(1)
    {
        printf("I am a process, pid: %d\n", getpid());
        fflush(stdout);
        sleep(1);
    }
    return 0;
}
  • ./[可执行程序] &可以将进程放到后台执行:

在这里插入图片描述

  • 使用jobs指令,可以查看后台进程(每个后台进程都有一个任务编号)。

在这里插入图片描述

  • 使用fg [任务编号]可以将后台进程放到前台运行。

在这里插入图片描述

  • ctrl + z可以暂停当前进程,使用bg [任务编号]可以将暂停的后台进程在后台重新启动。

在这里插入图片描述


1.4 中断


1. 问题引入

  • 我们都知道,OS在一些情况下需要等待硬件资源就绪,比如说等待键盘输入。但是OS又是如何知道键盘上有数据输入了呢?

2. 解答上述问题

在这里插入图片描述

  • 以前我们说过,CPU不直接和外设相连,现在我们要将这种说法纠正一下。在数据层面上,CPU是不直接和外设相连的,但是在控制信息层面上,CPU有一圈针脚,通过一个8259集成电路版,和外设相连(每一个针脚对应一个外设)。
  • 这样一来,外设就可以直接向CPU发送信息了,我们把这样的信息称为中断信息。
  • CPU上的针脚是有编号的,称为中断号。当键盘被按下时,键盘会向CPU发送光电信号,点亮对应的针脚。CPU会将这个针脚编号(中断号)记录下来,放在寄存器中,进而通知操作系统,该硬件已经就绪了。

3. 中断向量表

  • 为了能够更快速的对外设进行响应,操作系统内部设置了一张中断向量表,本质就是一个指针数组。数组的下标就是对应设备的中断号,数组中存的是特定硬件的读取方法。

在这里插入图片描述

  • 当一个外设就绪,向CPU发送中断信息后,OS会将手头的工作全停下来,然后读取中断号。之后拿着这个中断号,在中断向量表中进行索引,找到该硬件的读取方法,并执行它。

4. 信号的本质,其实就是用软件来模拟中断的行为

  • 在收到信号之前,OS已经知道了如何处理这些信号,即OS“认识”这些信号。这些处理方法,同样被储存在一个函数指针数组中。信号的编号和数组的下标一一对应。
  • 信号的处理方法有三种:
    • a. 默认行为;
    • b. 忽略;
    • c. 自定义行为(信号捕捉)。

2. 信号的产生


2.1 认识接口signal


在这里插入图片描述

1. 作用

  • 捕获信号,自定义处理方法。

2. 参数

  • signum:信号编号。
  • handler:这是一个函数指针类型的数据,传我们自定义的信号处理方法。

3. 返回值

  • 返回该信号的前一个处理方法,或者在出错时返回SIG_ERR。如果出现错误,errno会被设置以指示原因。

2.2 通过键盘产生信号


1. 小实验

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

void handler(int signo)
{
    std::cout << "signo: " << signo << std::endl;
    exit(0);
}

int main()
{
    signal(2, handler);// 捕获2号信号
    while (true)
    {
        std::cout << "running... pid: " << getpid() << std::endl;
        sleep(1);
    }    
    return 0;
}
  • 编译并运行:

在这里插入图片描述

  • 现象:当ctrl + c按下的一瞬间,进程收到了2号信号,并且执行了自定义handler方法。

2. 实验结论

  • 键盘可以通过组合键的方式,向前台进程发送信号。

ctrl + c被按下时,CPU首先收到中断信息,得知键盘被按下了,进而读取键盘上的数据。OS对收到的数据进行解析,发现是ctrl + c组合键,OS将其解释成2号信号,发送给当前的前台进程。触发自定义处理方法,最终exit退出。

3. 键盘常见组合键对应的信号,及其默认处理方法

  • ctrl + c:向前台进程发送2号信号SIGINT,默认动作为终止进程;
  • ctrl + \:向前台进程发送3号信号SIGQUIT,默认动作为让该进程退出;
  • ctrl + z:向前台进程发送20号信号SIGTSTP,默认动作为暂停该进程,并将其挂到后台。

大家可以自己动手,捕获这些信号,做实验。


2.3 通过系统调用产生信号


1. 认识kill接口

在这里插入图片描述

  • 作用:
    • 向任意进程发送任意信号。
  • 参数:
    • pid:目标进程pid
    • sig:信号编号。
  • 返回值:
    • 成功返回0,失败返回-1。

2. 小实验:模拟实现kill

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

// 输入非法时,提示使用方法
static void Usage(const std::string &proc)
{
    std::cout << "\nUsage: " << proc << " -signumber process\n" << std::endl;
}

int main(int argc, char *argv[])
{
    if (argc!=3)
    {
        Usage(argv[0]);
        exit(0);
    }
    
    int signumber = std::stoi(argv[1]+1);
    int processpid = std::stoi(argv[2]);

    kill(processpid, signumber);

    return 0;
}

3. raise接口

在这里插入图片描述

  • 给自己发送指定信号,成功返回0,失败返回非0值。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

void handler(int signo)
{
    std::cout << "get signo: " << signo << std::endl;
}

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

    while (true)
    {
        raise(2);
        sleep(1);
    }    
    return 0;
}
  • 编译并运行:

在这里插入图片描述

  • 进程不断给自己发送2号信号,并且由于我们自定义了2号信号的处理方法,所以即使收到了2号信号,ctrl + c了,该进程也不会终止。

4. abort函数(在3号手册中)

在这里插入图片描述

  • 给自己发送6号信号,默认情况下,直接abort终止进程。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

void handler(int signo)
{
    std::cout << "get signo: " << signo << std::endl;
}

int main()
{
    signal(6, handler);
    abort();

    while (true)
    {
        std::cout << "running... pid: " << getpid() << std::endl;
        sleep(1);
    }    
    return 0;
}
  • 编译并运行:

在这里插入图片描述

  • 发现6号信号虽然被捕捉了,我们也自定义了6号信号的处理方法,但是程序依然终止了。

2.4 异常


1. 做实验:模拟除0异常,观察现象

void handler(int signo)
{
    std::cout << "get signo: " << signo << std::endl;
    sleep(1);
}

int main()
{
    signal(8, handler);
    int a = 10;
    a /= 0;// 除0错误
 
    return 0;
}
  • 编译并运行:

在这里插入图片描述

  • 发现当前在死循环处理这个8号信号SIGFPE,这是为什么?

2. 理解异常的本质

  • a /= 0的运算是在CPU内执行的,假设寄存器eaxa的值10,ebx存操作数0,ecx存运算结果。CPU内还有专门的状态寄存器status来检测各硬件的状态,执行该/=操作后,ecx寄存器中的值明显溢出,该溢出状态被status寄存器记录,修改溢出标记位为1。
  • 随后,CPU内部触发中断,将该溢出信息传给OS,OS将其解释为给目标进程发送对应异常信号,进而执行对应的处理方法。

在这里插入图片描述

  • 寄存器中的内容只属于当前进程,不属于CPU。将当前异常进程直接干掉,就是OS默认处理异常的手段。这样下一个进程被调度时,会直接覆盖CPU内的进程上下文数据(其中就包括状态数据),异常信号也就被恢复了。
  • 小实验中,之所以在死循环处理8号信号,是因为我们提供的自定义处理方法中,并没有将进程终止。这样一来,该进程就还会被调度。当该进程再次被调度时,其中的上下文状态数据还是异常的(溢出标志位一直是1),CPU就会又触发中断,通知操作系统来处理异常,如此循环往复,就形成了死循环。

异常的本质就是硬件异常,然后CPU触发中断,告知OS处理异常,中断进程。

3. 理解野指针异常

  • 空指针指向0号地址,而0号地址在页表中,是不存在映射关系的,及没有对应的物理内存。虚拟地址到物理地址的转化,是通过CPU中的一个硬件,MMU实现的,当发生空指针的解引用问题时,MMU就会转化失败,找不到对应的物理内存,这时MMU就异常了,CPU也就要触发中断了。
  • 野指针异常一般会报段错误(Segmentation fault),OS会向目标进程发送11号信号SIGSEGV

2.5 由软件条件产生信号


SIGPIPE就是一种由软件条件产生的信号,在“管道”中已经介绍过了。本节主要介绍alarm函数和14号SIGALRM信号。

1. alarm接口

在这里插入图片描述

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

2. 实验一:外设有多慢

  • 代码1:
int cnt = 0;

void handler(int signo)
{
    std::cout << "get signo: " << signo << " cnt: " << cnt << std::endl;
    exit(0);
}

int main()
{
    alarm(1);
    signal(14, handler);
    while(true)
    {
        std::cout << "alarm: " << cnt++ << std::endl;
    }

    return 0;
}
  • 编译并运行:
    • cnt加到了9万多次。

在这里插入图片描述

  • 代码2:
int cnt = 0;

void handler(int signo)
{
    std::cout << "get signo: " << signo << " cnt: " << cnt << std::endl;
    exit(0);
}

int main()
{
    alarm(1);
    signal(14, handler);
    while(true)
    {
        cnt++;
    }
   
    return 0;
}
  • 编译并运行:
    • cnt直接加到了5亿多。

在这里插入图片描述

  • 可见外设的速度有多慢,所以代码中应尽量减少和外设的交互。

3. 实验二:alarm的返回值

int n = 0;

void handler(int signo)
{
    n = alarm(0);// 取消上一个闹钟,拿到剩余时间
    std::cout << "get signo: " << signo << " n: " << n << std::endl;
    exit(0);
}

int main()
{
    std::cout << "pid: " << getpid() << std::endl;
    n = alarm(30);// 设置一个30秒的闹钟
    std::cout << "n: " << n << std::endl;
    signal(14, handler);
    while(true)
    {}
 
    return 0;
}
  • 该函数的返回值是0,或上一个闹钟的剩余时间。

在这里插入图片描述


3. OS发送信号的底层原理(补充知识)


对于普通信号来讲,进程收到信号之后,会用位图来表示自己是否收到该信号。

  • 比特位的位置决定信号编号;
  • 比特位的内容决定是否收到信号(1表示收到,0表示没收到)。

这个位图存储在进程PCB中。

struct task_struct 
{
// ...

// 例:0000 0010 收到2号信号
uint32_t sigmap;// 信号位图
}

OS向进程发送信号,本质就是修改PCB中的位图。 无论信号有多少种产生方式,永远只能让OS向目标进程发送信号。这是因为,OS是进程的管理者。

每一个进程还都有一个自己的函数指针数组,数组的下标和信号编号强相关。


4. Core Dump 核心转储


当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁
盘上,文件名通常是core.[pid],这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。

使用ulimit -a来查看系统中的相关配置:

在这里插入图片描述

当前服务器默认不允许产生core文件,core文件大小设置为0。可以使用ulimit -c [szie]来修改core的文件大小。这个修改是内存级的,退出再登入它就复原了,并且只在当前窗口有效。

将当前shellcore file size改为10240,打开生成core文件功能。

在这里插入图片描述

编译并运行如下会产生异常的代码,生成core文件:

int main()
{
    int a = 10;
    a /= 0;

    return 0;
}

在这里插入图片描述

core文件的使用:

  • 先编译形成可调试的可执行程序,然后使用gdb调试生成的可执行程序,使用core-file指令,刚刚生成的core文件加载进入,就可以看到出错的位置了。

在这里插入图片描述

为什么Core Dump功能默认是关闭的?故事时刻:

  • 一般而言,部署在服务器上的服务一旦自己挂掉了,是会有对应的软件,帮助该服务重启的。因为有一些服务,我们希望他24小时在线,即使挂掉了,也要快速重启。
  • 大家可以看到,core文件是很大的,如果一个比较挫的程序员,写了一个很挫的服务,动不动就挂掉,然后该服务还被一直重启。此时就会生成一大堆的core文件,一晚上过后直接把磁盘打满,服务器最后也挂掉了。
  • 所以云服务器上,Core Dump的功能默认是关闭的。

5. 信号的保存


5.1 信号其他相关常见概念


1. 实际执行信号的处理动作,称为信号递达(Delivery)

  • 实际的处理动作又可以细分成三种:
    • 信号的忽略;
    • 信号的默认处理动作;
    • 信号的自定义捕捉。
  • 以处理2号信号为例,上代码:
signal(2, SIG_IGN); // 忽略2号信号
signal(2, SIG_DFL);// 以默认动作处理2号信号
signal(2, handler);// handler是自定义方法,用自定义方法处理2号信号
  • 其中,SIG_DFLSIG_IGN为两个宏,其定义如下:
#define SIG_DFL ((__sighandler_t) 0)/* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */

忽略信号,也是对信号的一种处理,处理动作就是忽略他。

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

  • 将信号存在信号位图中,就叫信号未决。

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

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

5.2 信号在内核中的表示


在这里插入图片描述
task_struct中存了三张表,分别是阻塞block表,未决pending表,函数指针表handler。数组下标对应信号编号,看对应信号的状态时,要横着看。

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志(bit位由0置1),直到信号递达才清除该标志。

在上图的例子中:

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

如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?

  • POSIX.1允许系统递送该信号一次或多次。
  • Linux是这样实现的:普通信号(31号及之前)在递达之前产生多次只计一次,而实时信号(31号之后)在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。

5.3 信号集操作


1. sigset_t类型

  • sigset_t本质上就是一个位图,但是对于使用者来说,只需要把它当成是一种数据类型即可。
  • sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

2. 信号集操作函数

  • sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作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置1,表示该信号集的有效信号包括系统支持的所有信号。注意,在使用sigset_ t类型的变量之前,一定要调用sigemptysetsigfillset做初始化,使信号集处于确定的状态。
  • 初始化sigset_t变量之后就可以再调用sigaddsetsigdelset在该信号集中添加或删除某种有效信号。
  • sigismember可以判断当前信号集set中,是否有signo信号。

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

3. sigprocmask

  • 调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1
  • 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果osetset都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据sethow参数更改信号屏蔽字。
  • 假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

在这里插入图片描述

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

  • 实验:屏蔽2号信号。
    • 如果2号信号被屏蔽成功了,则handler方法无法执行。
void handler(int signal)
{
    std::cout << "signal: " << signal << std::endl;
}

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

    sigset_t block, oblock;
    // 初始化信号集
    sigemptyset(&block);
    sigemptyset(&oblock);

    sigaddset(&block, 2);    // 将2号信号添加进信号集
    sigprocmask(SIG_BLOCK, &block, &oblock);    // 屏蔽2号信号
    while(true)
    {
        std::cout << "I am running..." << std::endl;
        sleep(1);
    }
    
    return 0;
}

在这里插入图片描述

有一些信号是无法被屏蔽的,例如9号无法被屏蔽。

4. sigpending

#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。 
  • 小实验:打印当前进程pending表。
void handler(int signal)
{
    std::cout << "signal: " << signal << std::endl;
}

void PrintPending(const sigset_t &pending)
{
    for(int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}

int main()
{
    std::cout << "pid: " << getpid() << std::endl;
    signal(2, handler);

    // 1. 屏蔽2号信号
    sigset_t set, oset;
    sigemptyset(&set);
    sigemptyset(&oset);
    sigaddset(&set, 2);
    sigprocmask(SIG_BLOCK, &set, &oset);

    // 2. 让进程不断获取当前进程的pending表
    int cnt = 0;
    sigset_t pending;
    while(true)
    {
        sigpending(&pending);
        PrintPending(pending);

        sleep(1);
        cnt++;
        if(cnt==10)
        {
            std::cout << "解除对2号信号的屏蔽,2号信号准备递达" << std::endl;
            sigprocmask(SIG_SETMASK, &oset, nullptr);
        }
    }
    
    return 0;
}

在这里插入图片描述

  • pending表刚开始为全0,没有收到信号。后来我们给该进程发送了2号信号,但是2号信号被阻塞,此时pending表中2号信号的位置由0置1。程序运行10秒钟后,解除对2号信号的阻塞,2号信号被递达,pending表由1置0。

如果一个信号被递达了,那么在该信号被处理之前,pending表中的位图就会被修改。


6. 信号的处理


6.1 用户态和内核态


1. 进程如何找到操作系统的代码?

在这里插入图片描述

  • 之前我们所学习的程序地址空间,都是用户空间。今天我们就来认识一下内核空间。
  • 进程要想使用系统调用,就需要访问OS代码。每个进程都有1GB的内核地址空间,这一部分空间和通过内核级页表,映射到OS代码在物理内存中的位置。
  • 就如同曾经的库函数调用一样,调用系统调用接口,也是在进程的地址空间中进行跳转的。但是这个跳转过程,涉及用户态和内核态身份的变换
  • 内核级页表在整个OS中仅有一份,所有进程共用一份内核级页表。所以无论进程如何调度,CPU都可以通过进程地址空间的内核空间,直接找到OS。
  • 用户态:只能访问自己的【0,3GB】。
  • 内核态:可以让用户以OS的身份访问到操作系统的【3,4GB】。

2. 如何控制用户态和内核态的身份变换?

在这里插入图片描述

  • CPU一些寄存器(如CS寄存器)的最后两个bit位,保存了该进程是出于用户态还是内核态。2bit一共有4个值,但是我们只使用两位,1表示内核态,3表示用户态。切换用户态和内核态只需要修改这两个bit位即可。
  • CPU中还有一个CR3寄存器,它保存的是当前进程的一些页表信息(存的直接是页表的物理地址)。
  • CR1保存的是最后一次引发缺页中断的地址。

3. 进程从内核态返回用户态时,进行信号的检测和处理(下面模拟了一次信号检测和处理的流程)

  • 用户态是一种受控的状态,能够访问的资源是有限的;内核态是一种操作系统的工作状态,能够访问大部分的系统资源。系统调用的背后,就隐藏了身份的变化。

在这里插入图片描述

  • 先在用户态执行用户代码,遇到系统调用后,切换为内核态,进入内核。主要任务还是执行系统调用,在系统调用返回时,会顺便做一次信号的检测和处理。
  • 进入内核态后,可以访问到进程关于信号的那三张表。先看pending表,如果pending表为0,表示没有收到该信号,直接返回。如果pending表为1,需要再看block表,为1就直接返回,为0就再看handler表。大多数默认处理方法都是终止进程,对于处于内核态的进程来说,这很好实现。如果处理方法是SIG_IGN忽略,则直接将pending表由1置0即可。最难处理的是自定义方法。
  • 大家思考一个问题,进程是以用户态还是内核态执行自定义方法?答案是用户态,因为操作系统不信任任何用户。自定义方法是用户自己定义的,万一在里面写了一些越权行为,以内核态去执行该方法,就是一个巨大的bug。所以进程在执行自定义方法之前,要先切换为用户态。
  • 当进程执行完自定义方法之后,无法直接留在用户态,继续向后执行代码。因为该handler方法根本不知道程序已经运行到哪一行了,以及一些在内核态才能看到的信息。所以要先通过系统调用sigreturn返回内核态,拿到相关信息后,再由内核态返回用户态,继续向后执行代码。

4. 总结

  • 信号检测和处理时,一共进行了四次用户态和内核态之间的切换。

在这里插入图片描述

5. 要是不调用系统调用,是不是就不会发生用户态和内核态之间的切换?那么信号的检测是否就无法发生?

  • 进程是会不断被调度的,一旦一个进程被从CPU上剥离下来了,再想重新调度它时,就需要将之前的上下文信息交给CPU,这是内核态。然后再回到用户态,执行自己的代码。
  • 结论:无论进程是否调用系统调用,整个进程的生命周期里,一定会涉及非常多次的进程间切换,一旦切换了,就一定会涉及到从内核态返回到用户态,所以当前进程,依旧有无数次机会,进行信号的捕捉处理。

6.2 sigaction


1. sigaction函数的介绍

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact); 
  • sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传出该信号原来的处理动作。

  • actoact指向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表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
  • 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
  • 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段。

2. 小实验:自定义处理2号信号

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

void handler(int signo)
{
    std::cout << "get a sig: " << signo << std::endl; 
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigaction(2, &act, &oact);

    while(1) sleep(1);
    return 0;
}
  • 编译并运行:

在这里插入图片描述

3. 验证对当前信号的自动屏蔽功能

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

void Print(const sigset_t& pending)
{
    for (int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}

void handler(int signo)
{
    std::cout << "get a sig: " << signo << std::endl; 
    while(1)
    {
        sigset_t pending;
        sigpending(&pending);
        Print(pending);
        sleep(1);
    }
}

int main()
{
    std::cout << "pid: " << getpid() << std::endl;
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigaction(2, &act, &oact);

    while(1) sleep(1);
    return 0;
}
  • 编译并运行,运行几秒后不断向进程发送2号信号:

在这里插入图片描述

  • 可以发现,调用信号处理函数时,再次向进程发送该信号,pending表中该信号的值会变为1,说明收到了该信号,并且该信号被阻塞了,无法递达。
  • OS不允许对同一个信号嵌套处理,但是在处理2号信号的同时,不影响3号信号的处理。

3. 验证sa_mask对其他信号的屏蔽

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

void Print(const sigset_t& pending)
{
    for (int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}

void handler(int signo)
{
    std::cout << "get a sig: " << signo << std::endl; 
    while(1)
    {
        sigset_t pending;
        sigpending(&pending);
        Print(pending);
        sleep(1);
    }
}

int main()
{
    std::cout << "pid: " << getpid() << std::endl;
    struct sigaction act, oact;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, 3); // 将3号信号加入sa_mask

    sigaction(2, &act, &oact);

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

在这里插入图片描述


6.3 如何处理同时收到多个信号的情况?


写一段代码,让进程先屏蔽2,3,4,5号信号,然后过20秒后再解除屏蔽。

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

void handler(int signo)
{
    std::cout << "get a sig: " << signo << std::endl;
    sleep(1);
}

void Print(const sigset_t& pending)
{
    for (int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}

int main()
{
    std::cout << "pid: " << getpid() << std::endl;
    signal(2, handler);
    signal(3, handler);
    signal(4, handler);
    signal(5, handler);

    sigset_t mask, omask;
    sigemptyset(&mask);
    sigemptyset(&omask);

    sigaddset(&mask, 2);
    sigaddset(&mask, 3);
    sigaddset(&mask, 4);
    sigaddset(&mask, 5);

    sigprocmask(SIG_SETMASK, &mask, &omask);

    int cnt = 20;
    while(true)
    {
        sigset_t pending;
        sigpending(&pending);
        Print(pending);

        cnt--;
        sleep(1);
        if (cnt == 0)
        {
            sigprocmask(SIG_SETMASK, &omask, nullptr);
            std::cout << "cancel 2, 3, 4, 5 block" << std::endl;
        }
    }

    return 0;
}

解除屏蔽之前,给该进程一次性发送2,3,4,5信号,观察现象:

在这里插入图片描述

可以发现,当一次性收到多个信号时,即pending表中有多个位置值为1时,需要先处理完所有的信号,再继续向后执行代码。信号的处理是有优先级的,并不是先收到哪个信号就先处理哪个,这个我们不做研究。

在这里插入图片描述

拿这个图来说,收到多个信号时,就是在右边那个圈里多转几圈,把所有信号处理完后再继续执行代码。


7. 信号的其他补充问题


7.1 可重入函数


在这里插入图片描述

main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。

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

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

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

7.2 volatile关键字


1. 写代码,看实验现象

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

int flag = 0;

void handler(int signo)
{
    std::cout << "signo: " << signo << std::endl;
    flag = 1;
    std::cout << "change flag to: " << flag << std::endl;
}

int main()
{
    signal(2, handler);
    std::cout << "getpid: " << getpid() << std::endl;
    while(!flag);
    std::cout << "quit normal!" << std::endl;
}
  • 有些同学可能多少了解过,编译器是会对代码做优化的,一般的优化级别是-O0,默认不做任何优化。但是更高的优化级别还有-O1-O2,我们来分别使用不同的优化级别,跑一下上述代码:

  • 使用-O0级别的优化,进程在收到2号信号后,修改flag,正常退出。

在这里插入图片描述

  • 使用-O1级别的优化,进程在收到2号信号后,也确实修改了flag,但是死循环卡住了,这是为什么?

在这里插入图片描述

2. 解释

  • 优化情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,为何循环依旧执行?
  • 因为,while 循环检查的flag,并不是内存中最新的flag。不做优化的情况下,CPU会一直访问内存,将内存中flag的值更新到寄存器中,进行逻辑判断。但是一旦做了优化,CPU发现main函数中,没有人对flag变量做修改,所以它就不访问内存了更新寄存器中的值了,而是每次根据寄存器中旧的flag值,做逻辑判断。

在这里插入图片描述

  • 如此一来,就在CPU和内存之间,形成了一道内存屏障。为了避免这种现象,防止编译器进行过度的优化,就设计出了volatile关键字。

3. volatile保持内存可见性

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

volatile int flag = 0;

void handler(int signo)
{
    std::cout << "signo: " << signo << std::endl;
    flag = 1;
    std::cout << "change flag to: " << flag << std::endl;
}

int main()
{
    signal(2, handler);
    std::cout << "getpid: " << getpid() << std::endl;
    while(!flag);
    std::cout << "quit normal!" << std::endl;
}

在这里插入图片描述

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

7.3 SIGCHLD信号


1. 验证SIGCHLD信号的存在

  • 子进程退出时,是要给父进程发送信号的。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

void handler(int signo)
{
    std::cout << "get a signo: " << signo << std::endl;
}

int main()
{
    signal(SIGCHLD, handler);
    pid_t id = fork();
    if (id == 0)
    {
        std::cout << "child running...." << std::endl;
        sleep(5);
        std::cout << "child exit" << std::endl;
        exit(0);
    }

// 不能直接写sleep(10);
    int cnt = 10;
    while(cnt--)
    {
        std::cout << "cnt: " << cnt << std::endl;
        sleep(1);
    }

    waitpid(-1, nullptr, 0);
    return 0;
}
  • 子进程先跑五秒后退出,父进程跑十秒。可以看到,子进程退出后,父进程收到了来自子进程的17号信号。

在这里插入图片描述

小细节:想让父进程休眠10秒,不能直接写sleep(10);,因为处理信号的动作,会自动中断休眠。只能分10次一秒一秒的休眠,这样它只会中断十次中的一秒,影响不大。

2. 基于SIGCHLD信号回收子进程

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>

void handler(int signo)
{
    std::cout << "get a signo: " << signo << std::endl;

    pid_t id = 0;
    while ((id = waitpid(-1, nullptr, WNOHANG)) > 0)
    {
        std::cout << "回收进程:" << id << std::endl;
    }
}

int main()
{
    signal(SIGCHLD, handler);
    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            std::cout << "child is running..." << std::endl;
            sleep(5);
            exit(0);
        }
    }

    while(true) sleep(1);   // 父进程不退出
    return 0;
}

在这里插入图片描述

  • 细节:
    • 我们一般会遇到多进程的情况,并且这多个子进程,可能同时结束。这就会导致信号之间出现覆盖,父进程只收到一个SIGCHLD信号,而wait需要执行多次。 所以在handler方法中,需要循环执行waitpid,直到没有子进程,waitpid返回-1,循环结束。
    • 还有一种情况,多个子进程中,只退出一部分,还有一部分永远不退出。此时如果waitpid采取阻塞等待的方式,将会让父进程卡死在handler方法中,所以我们给waipidWONHANG,让其以非阻塞方式等待。

3. Linux支持手动忽略SIGCHLD信号,不需要手动等待子进程

  • 事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigactionSIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。
  • 此方法对于Linux可用,但不保证在其它UNIX系统上都可用。请编写程序验证这样做不会产生僵尸进程。
#include <iostream>
#include <unistd.h>
#include <signal.h>

int main()
{
    // Linux支持手动忽略SIGCHLD信号,所有的子进程都不需要父进程等待了,退出时自动回收z状态   
    signal(SIGCHLD, SIG_IGN);   
    for (int i =0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            std::cout << "child is running..." << std::endl;
            sleep(5);
            exit(0);
        }
    }

    while(true) sleep(1);   // 父进程不退出
    return 0;
}

在这里插入图片描述



原文地址:https://blog.csdn.net/weixin_73870552/article/details/142252603

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!