1. 信号的概念

  1. 信号的概念:是一种软件中断机制,是linux系统提供的让用户(进程)给其他进程发送异步事件通知的一种方式,用于进程间通信或通知进程发生了特定的事件(如:硬件异常、软件条件等)。

  2. 每个信号都有一个编号和宏定义名称,在#include<signal.h>头文件中进行宏定义,编号132为普通信号、编号3464为实时信号(此处不作讨论)。

kill -l 、man 7 signal

  • 功能:用户查看linux系统定义的所有信号列表,如:信号在什么条件下产生、信号默认处理动作是什么等。

  1. 信号产生的原因:系统需要确保进程能够随时响应外部事件,并及时作出响应的处理。

2. 进程如何看待信号

  1. 进程具有识别并处理不同信号的能力。

进程在启动时,就已经设置好了识别特定信号的方式,且在信号产生之前,进程就知道如何处理这些信号,因为进程在启动时,OS会为其分配一个信号处理表(handle表,即:函数指针表),用来记录每个信号对应信号的处理函数,且信号的识别方式也是通过它。

  1. 当信号产生时,如果进程正在处理更重要的事情(如:处于临界区或执行不可中断操作),而暂时不能处理到来的信号,为了确保信号不会丢失,OS会将这个信号暂时保存起来。

这种暂时保存是通过进程控制块(task_struct)中的pending位图来记录的,pending位图用来记录哪些信号已经被发送但尚未处理,位图中的每一位对应一个信号,如果该位为1,则表示该信号已经被发生但尚未处理,一旦这个信号被处理完毕,OS会将此信号从位图中清除(由1置为0)。

  1. 信号产生时,如果进程正在内核态执行,一般不会立即处理,而是等从内核态切回到用户态之前进行检查是否有未处理的信号,如果有未处理的信号且满足信号处理条件,就会在此时处理信号。

  2. 信号的产生具有不确定性和临时性,我们无法准确预料何时会有信号产生,因此信号是异步发送的。

信号的产生是由外部事件触发的(如:用户输入、硬件异常等),不是进程主动请求的,这体现了异步发送的不可预测性。

信号的产生与接收信号的进程的执行流是不同步的。当信号产生时,进程可能正在执行其他任务或处于某种状态,而信号的到达不会中断或阻塞当前进程的执行流,而是在合适的时候处理,这体现了异步发送的独立性。

由于信号是异步发送的,进程可以根据自身需求选择在何时以及如何处理信号(如:设置信号函数或忽略某些信号等),这体现了异步发送的灵活性。

3. 信号的产生

3.1. kill命令

kill -信号编号 进程的PID

3.2. 终端按键

  1. 用户可以通过键盘输入特定的组合键来产生信号,如:Ctrl+c会产生SIGINT信号(2号信号,终止进程),Ctrl+\会产生SIGQUIT信号(3号信号,终止进程)、Ctrl+z会产生SIGSTP信号(20号信号,暂停进程的执行)等。
#include<iostream>
#include<cstdio>
#include<csignal>
#include<unistd.h>
#include<sys/types.h>

using namespace std;

void handle(int signo)
{
    cout << "handing signao " << signo << endl;
}

int main()
{
    for(int signo = 1; signo <= 32; signo++)
    {
        signal(signo, handle);
    }

    while(true)
    {
        printf("I am a process, pid: %d\n", getpid());
        sleep(3);
    }

    return 0;
}

3.2.1. 核心转储core dump

  1. 进程接收到信号后默认处理动作为终止时两种常见的方式,分别为term、core,两者主要区别在与core具有核心转储功能,即:生成核心转储文件。

term:进程在接收到信号后,有机会执行清理操作并优雅地退出,通常是通过SIGTERM(信号编号15)来实现的。

  1. 核心转储(core dump):是指在进程因收到了特定信号而异常终止时,OS将进程在内存的核心数据(如:地址空间、与调试有关的信息等)转储到磁盘中,形成一个核心转储文件core(Ubuntu系统)或core.pid(Centos系统)文件。

问1:为什么要存在核心转储?

核心存储文件包含了进程异常终止时的内存状态、寄存器值、调用栈等调试信息,有助于协助程序调试,从而快速定位到错误原因(如:进程为什么退出、进程执行到哪行代码退出)。

  1. 事后调试:可以通过核心存储文件对已经异常终止的进程,使用调试器进行调试,以便定位到错误原因,因为进程异常终止通常是有Bug(如:非法访问内存导致的段错误等)。

  2. 云服务器为了节省磁盘空间、避免资源浪费或出于安全考虑,对以core方式终止的进程进行了特定的设定,默认关闭core文件的生成。

ulimit -a

  • 功能:显示当前用户的所有资源限制。

ulimit -c

  • 功能:打开核心转储功能,并设置或查询core文件的大小限制。

  1. 确定子进程是否发生核心转储,需要检查这两个条件:a. 系统是否开启了核心转储功能,b.子进程异常退出的默认处理动作是生成core文件(Action : core)。

如果以上两个条件都满足,但在当前目录下未生成core文件,解决方法如下:

sudo bash -c “echo core.%p > /proc/sys/kernel/core_pattern”

  • 功能:设置核心转储文件的生成路径和格式。

当进程异常终止时,OS会根据core_pattern文件中的设置生成核心转储文件。

core.%p表示核心转储文件名为core.pid,为了防止未知的core dump一直运行,导致服务器磁盘被打满,因为程序每次运行都是全新的进程,pid均不同,因此通常将其设置为core,表示核心转储文件名为core,其大小是固定的;

3.2.2. OS如何知道键盘在输入数据

一、OS如何检测和处理键盘输入

  1. 效率问题:通过硬件中断机制,OS无需定期检测键盘是否被按下,大大提高了系统的效率和响应速度。这是因为硬件中断是异步发生的,当键盘被按键按下时,会立即触发中断,CPU会立即响应并处理该中断。

  2. 中断向量表:OS在启动时,会初始化一张中断向量表,这张表实际上是一个函数指针数组,每个下标对应一个中断编号,每个元素对应对应一个具体的中断处理函数。

  3. 硬件中断机制:当键盘上某个键被按下时,键盘控制器会向CPU发送一个硬件中断信号,这个信号通常是通过主板上一个固定的针脚发送的,该针脚与CPU某个特定中断输入引脚相连。

  4. OS响应中断:CPU收到中断信号后,会将中断编号保存在寄存器中,并且会要求OS根据中断编号查找中断向量表中的中断处理函数,并执行该函数。

对于键盘的输入,OS提供了读取键盘数据的方法,通过这个函数,OS就可获取键盘中输入的数据。

  1. 数据处理:OS读取数据后,会对数据进行判定,如果是字符,将其放入缓冲区中,供后续的程序通过read系统调用读取;如果是控制命令(组合键,如 : ctrl c),OS会将其解释为信号,并发送给当前正在运行的、与信号相关联的进程。

二、发送信号的本质

  1. 给进程发送信号的本质是将信号写入进程的PCB中,而PCB是内核数据结构,只有OS才有资格写入,用户只能通过调用OS提供的系统调用来写入信号。

  2. 无论信号的产生有多少种,最终都是OS负责将信号写入到目标进程的PCB中,并触发相应的信号处理机制。

3.3. 系统调用

3.3.1. kill

int kill(pid_t pid, int sig) ;

  • 功能:对任意进程发送任意信号。
  • 返回值:成功返回0,失败返回-1。

Tisp:kill命令底层封装了系统调用kill函数。

#include<iostream>
#include<cstdio>
#include<sys/types.h>
#include<signal.h>
#include<errno.h>
#include<cstring>

using namespace std;

void Usage(char* argv[]) 
{
    cout << argv[0] << " -signumber PID" << endl; 
}

int main(int argc, char* argv[]) //模拟实现kill命令
{
    if(argc != 3) Usage(argv); //用法错误
    int pid = stoi(argv[2]), signo = stoi(argv[1] + 1);
    int n = kill(pid, signo); //底层封装了系统调用kill
    if(n < 0) 
        cerr << "kill error: " << strerror(errno) << endl;
    
    return 0;
}

3.3.2. raise

int raise(int sig);

  • 功能:给当前进程发送任意信号。
  • 返回值:成功返回0,失败返回非0值。

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

using namespace std;

void handle(int signo) 
{
    cout << "get a signal, number is " << signo << endl;
}

int main()
{
    signal(2, handle); //设定信号捕捉的方法

    int cnt = 4;
    while(cnt--)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        if(cnt == 2) raise(2); //给当前进程发送任意信号 —— 给自己发送2号信号
        sleep(1);
    }

    return 0;
}

3.3.3. abort

void abort(void);

  • 功能:终止进程,且给当前进程发送SIGABRT信号(6号信号)。

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

using namespace std;

void handle(int signo) 
{
    cout << "get a signal, number is " << signo << endl;
}

int main()
{
    signal(6, handle); //设定信号捕捉的方法

    int cnt = 4;
    while(cnt--)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        if(cnt == 2) abort(); //给当前进程发送指定信号(6号信号)
        sleep(1);
    }

    return 0;
}


abort后续行为:即使捕捉了SIGABRT并返回了处理函数,abort函数仍然会尝试执行其标准的终止流程,这包括调用raise(SIGABRT),然后执行一些清理操作,并最终调用_exit(1)来终止程序,,因此它不关心循环中是否还有未执行的代码。

3.4. 软件条件

3.4.1. SIGPIPE信号

  1. SIGPIPE信号(13号信号)是由操作系统内核检测到的管道写端已关闭这一软件条件触发的信号。

  2. 产生条件:当一个进程向已经关闭写端的管道中写入数据时,OS会向该进程发送SIGPIPE信号,其默认行为是终止进程。

  3. 为什么向已关闭写端的管道写入数据,被视为软件条件?

这一过程涉及操作系统内核中软件代码来管理管道的状态、检查写入条件以及产生和处理信号。这与硬件条件(如:物理设备的状态变化)不同,是由物理设备的物理特性决定的。

管道是通过OS中特定的数据结构来实现的,但这些数据结构及其操作逻辑都是由OS软件来管理的;当一个进程进行写入时,OS会检查管道的状态,包括写端是否关闭,OS会检测到写端已关闭这一错误条件,会进行响应产生SIGPIPE信号,以通知进程发生了错误,这一过程是由操作系统内核中的软件代码来实现的,因此它属于软件条件范畴。

3.4.2. SIGALRM信号

一、SIGALRM信号

  1. SIGALRM信号(14号信号)是由软件条件触发的信号。

  2. 产生条件:通过调用alarm函数来设置一个定时器,在second秒后定时器到期,OS会给当前进程发送SIGALRM信号,其默认行为是终止进程。

二、系统调用接口alarm

unsigned int alarm(unsigned int seconds);

  1. 功能:设置一个定时器,定时器在seconds秒后到期,OS会向当前进程发送SIGALRM信号,这个信号可以被进程捕捉处理(signal函数),或执行默认处理动作(终止进程)。

  2. 返回值:调用失败:返回UINT_MAX,并设置错误码以指示错误的原因。

    调用成功:a. 如果调用alarm函数前,已经设置了一个全新的定时器且在运行,则alarm函数会取消之前的定时器,用新的定时器代替,此时,alarm函数返回值。b.如果调用alarm函数前,没有设置全新的定时器,则alarm返回值为0。

在调用alarm函数前,设置了alarm(0),表示取消之前设置的定时器,返回值为离之前设置的定时器剩余的时间。

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

using namespace std;

void handle(int signo) 
{
    cout << "get a signal, number is " << signo << endl;
}

int main()
{
    signal(14, handle); //对SIGALAM信号进行捕捉

    alarm(50); 
    
    int cnt = 5;
    while(cnt--)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        sleep(1);
        if(cnt == 3)  
        {
            size_t n = alarm(0); //取消之前设定的定时器
            cout << "alarm(0) retval " << n << endl; //返回值为离之前设置的定时器剩余的时间
        }
    }

    return 0;
}

  1. alarm只会执行一次,如果想让其在某段时间内每隔指定秒数触发SIGALRM信号,就需要在信号处理函数(signal)中捕捉SIGALRM信号,在此处理函数中设置一个全新的定时器。
#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<signal.h>

using namespace std;

int cnt;

/*如果在信号处理函数中设置了alarm(2),并且接着是一个死循环,
现象:在2秒过后OS尝试发送SIGALRM信号,由于进入了死循环,
意味着处理函数不会返回,导致新的信号无法被处理,因为无法进入该信号处理函数*/
void handle(int signo) 
{
    cout << "catching..." << endl;
   int n = alarm(2); //在上一个定时器调用前,设置一个全新的定时器
   cout << "alarm(2) retval" << n << endl; //离之前设置的定时器剩余的时间
}

int main()
{
    signal(SIGALRM, handle); //设置SIGALRM信号的捕捉方法
    alarm(10); //设置一个定时器
    
    while(true)
    {
        cout << "I am a process, pid: " << getpid() << endl;
        sleep(1);
    }

    return 0;
}

  1. OS需要管理大量的定时任务,如:定期将数据从内核缓冲区刷新到外设,或执行其他需要定时控制的任务。alarm函数是一个系统调用接口,是用户空间和操作系统内核交互设置定时任务的一种方式,而在OS内部,必然存在大量的定时器,OS需要对它们进行管理,只需要判断当前时间是否超过了定时器的超时时间。

创建描述定时器的结构体,该结构体通常包含以下信息:设置定时器进程的PID、定时器的超时时间、触发时要发送给进程的信号等。

组织定时器结构体,使用最小堆,堆顶始终表示最近的一个超时的闹钟。

  1. 为什么SIGALRM信号被视为一个软件条件?它是由OS内部的软件逻辑所控制的,这个逻辑涉及到定时器的管理、检查和超时处理,最终导致SIGALRM信号的发送,而不是由外部硬件事件直接触发的。

3.5. 硬件异常

  1. 硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。

eg1:当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释为SIGFPE信号发送给进程。

eg2:当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

3.5.1. 除零异常

int main()
{
    int a = 0, b = 3;
    int c = b / a; //除数为0,会导致除零错误,接收到SIGFPE信号(8号信号)

    return 0;
}

  1. 除零异常

当CPU执行除法运算,如果除数为0,会导致除零错误,会触发一个除0异常;

CPU进行计算时会出现溢出的情况,这会导致CPU会更新EFLAGS寄存器中相应的标志位(如:OF、ZF等);

OS会识别到这些标志位的变化,则OS的异常处理机制会捕获这个异常,并发送SIGFPE信号(8号信号)给进程;

如果进程设置了该信号的自定义捕捉handle方法,那么该方法会被调用。

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

using namespace std;

void handle(int signo) //未终止进程,导致异常一直存在
{
    cout << "get signumber: " << signo << endl; 
    sleep(1); 
}

int main()
{
    signal(SIGFPE, handle);

    int a = 0, b = 3;
    int c = b / a; //除数为0,会导致除零错误

    while(true)
        sleep(1);

    return 0;
}

问:发生除零错误,触发除0异常,收到SIGFPE信号,为什么会一直执行自定义捕捉handler方法?

  • 进程中设置了对SIGFPE信号的自定义捕捉handle方法,发生除零错误时,会收到SIGFPE信号,那么该方法会被调用,因为在此方法中未终止进程,导致异常状态仍然保存在进程的上下文数据中,OS会将你这个进程切走,进程上下文会保存在PCB中,当此进程被OS再次调度时,上下文会进行恢复,OS识别到错误仍然存在,就会再次发送信号。

3.5.2. 野指针异常

  1. 野指针异常

野指针是指向无效内存地址的指针,是虚拟地址,当一个进程尝试通过野指针访问内存时,MMU会尝试将虚拟地址转化为物理地址,如果找不到对应的页表项或权限不匹配,则转化失败,MMU会触发页面错误异常。

CPU会捕获这个异常,并将导致错误的虚拟地址存储在cr2寄存器中,并设置EFLAGS寄存器中相应的标志位。

OS会识别到这些标志位的变化,并发送SIGSEGV信号(11号信号)给进程。

如果进程设置了该信号的自定义捕捉handle方法,那么该方法会被调用,若handle方法中未设置终止进程,那么每次上下文恢复时,OS识别到错误仍然存在,就会再次发送信号。

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

using namespace std;

void handle(int signo) //未终止进程,导致异常一直存在
{
    cout << "get signumber: " << signo << endl; 
    sleep(1); 
}

int main()
{
    signal(SIGSEGV, handle);

    int* p = NULL; //非法访问野指针,会导致野指针异常
    *p = 10;

    while(true)
        sleep(1);

    return 0;
}

  1. 总结:除零和野指针异常最终都会表现为硬件级别的异常(程序出现的错误最终都会在硬件层面上有所表现),CPU会触发相应的异常处理机制,OS通过捕获这些异常,并向进程发送信号来通知它,如果进程设置了信号的自定义捕捉方法,需要在该方法内采取适当的措施来清除异常状态或终止进程,否则异常会被重复触发。

4. 信号的保存

当信号产生时,如果进程正在处理更重要的事情(如:处于临界区或执行不可中断操作),而暂时不能处理到来的信号,为了确保信号不会丢失,OS会将这个信号暂时保存起来,那我们就来看看内核中如何保存信号。

4.1. 信号常见概念

  1. 信号递达:实际执行信号的处理动作。

  2. 信号未决:信号产生到递达之间的状态。

  3. 进程可以选择阻塞某个信号。

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

Tips:注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是抵达之后可选的一种处理动作,形象来说忽略是视而不见,阻塞是看不到。

4.2. 在内核中的表示

  1. task_struct是Linux内核用于描述进程的数据结构,与信号相关的信息主要存储在task_struc的signal字段中。
struct task_struct {
    // ...
    struct signal_struct *signal;  // 指向信号结构体
    // ...
};

struct signal_struct {
    // ...
    sigset_t blocked;  // 被阻塞的信号集合
    sigset_t pending;  // 待处理的信号集合
    struct sigaction *actions;  // 信号处理函数指针数组
    // ...
};
  1. blocked位图:存储当前进程阻塞(屏蔽)的信号集合。

当一个信号被阻塞时,即使该信号被发送给进程,进程也不会立即处理它,而是将其暂时保存起来,直到进程解除对此信号的阻塞。

在block位图中,比特位的位置表示信号编号,比特位的内容表示信号是否被阻塞,如果某一个位为1,则表示该信号被阻塞。

  1. pending位图:存储当前进程待处理的信号集合。

当一个信号被发送时,它会被添加到这个集合中,对应比特位的内容由0变为1,直到信号递达,OS才会将此信号从pending位图中清除,对应比特位由1置为0(细节:pending位图先被清0,在递达)。

在pending位图:比特位的位置表示信号编号,比特位的内容表示是否收到信号,如果某一位为1,则表示该信号已被发送但尚未处理。

  1. handle函数指针数组:存储信号处理函数的地址。

当一个信号被发送,且未被阻塞,在合适的时候,此信号需要被处理,OS会根据信号编号来在这个数组中找到对应的处理函数,并调用该函数来处理信号。

数组下标表示信号编号,数组的内容表示信号递达时的处理动作,包括默认SIG_DEF、忽略SIG_IGN、自定义捕捉函数(handle)。

Tips:POSIX.1允许信号在递达之前产生多次,在Linux中是这样实现的,普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列中。

4.3. 信号集sigset_t

一、sigset_t

  1. sigset_t:在Linux系统被称为信号集,是用于表示一组信号集合的数据类型,实际上是一个位图,每个位对应一个信号。

sigset_t用于表示当前进程的阻塞信号集时,它被称为阻塞信号集或信号屏蔽字。

sigset_t用于表示当前进程已发送但尚未处理的信号集合时,它被称为未决信号集。

  1. 每个信号通常只有一个未决标志和阻塞标志,且这两个标志都只有两种状态(“有效"或"无效”,非0即1),不记录该信号产生了多少次,因此未决标志和阻塞标志可以使用相同的数据类型sigset_t来存储,这个类型可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,在未决信号集中"有效"和"无效"的含义是信号是否处于未决状态。

  2. sigset_t类型对于每个信号用一个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些bit依赖于OS的实现,且不同的OS或OS实现采用内部的存储方式不尽相同,从使用者的角度不关心底层的具体的存储方式,不直接访问或解释sigset_t内部的数据(如:printf直接打印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);
int sigismember(const sigset_t *set, int signo); 

int sigemptyset(sigset_t* set);

  • 功能:初始化set所指向的信号集,使其中所有信号的对应bit清0,表示该信号集不包含任何有效信号。
  • 返回值:成功时返回 0,失败时返回 -1,并设置errno以指示错误的原因。

int sigfillset(sigset_t* set);

  • 功能:初始化set所指向的信号集,使其中所有信号的对应bit全部置为1,表示该信号集的有效信号为系统支持的所有信号。
  • 返回值:成功时返回 0,失败时返回 -1,并设置errno以指示错误的原因。

int sigaddset(sigset_t* set, int signum)、int sigdelset(sigset_t* set, int signum);

  • 功能:用于向/从信号集中添加/删除一个信号,signum是添加/删除的信号编号。
  • 返回值:成功时返回 0,失败时返回 -1,并设置errno以指示错误的原因。

int sigismember(const sigset_t* set,int signum);

  • 功能:检查一个信号是否存在于信号集中,signum是要检查的信号编号。
  • 返回值:如果signum是set中的一个成员,则返回非零值(通常为 1);如果signum不是set的成员,则返回 0;出现错误时返回 -1 并设置 errno以指示错误的原因。

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

问:调用信号集操作函数后,有没有将数据(信号的设置)写入到内核中,从而修改PCB中有关信号的字段呢?

  • 没有,因为此处的sigset_t类型的变量,是在用户空间中开辟的一块空间,它用于在用户态表示一个信号集,这些函数允许用户空间的程序对信号集进行操作,这些操作实际上是对用户空间的信号集进行修改,若想要将这些修改后的信号集写入到进程内核的PCB中,还需要通过特定的系统调用。

4.4. sigprocmask

int sigprocmask(int how , const sigset_t* set , sigset_t* oldset);

  1. 功能:读取或更改当前进程的信号屏蔽字。

  2. 返回值:成功返回0,失败返回-1,并设置errno以指示错误类型。

  3. 参数:set参数:指向信号集的指针,它指定了新的信号集; oldset参数:指向信号集的指针,用于存储修改前的信号屏蔽字。how参数指定如何修改当前的信号屏蔽字。

a. 如果只想读取当前的信号屏蔽字,将set设置为NULL,oset为非空指针,则读取到的信号屏蔽字通过oset传出。
b. 如果set为非空指针,修改当前的信号屏蔽字,how指示如何修改,如果不需要保存旧的信号屏蔽字,则oset可以被设置为NULL。
c. 如果oset和set都为非空指针,先会把原来的信号屏蔽字保存在oset中,再根据set和how来修改信号屏蔽字。

4.5. sigpending

int sigpending(sigset_t* set);

  1. 功能:获取当前进程的未决信号集。

  2. 返回值:成功返回0,失败返回-1,并设置errno以指示错误类型。

  3. set参数:指向信号集的指针,用于存储当前进程的未决信号集,它会将当前进程的未决信号集复制到set指向的信号集中。

应用场景1实现:屏蔽2号信号 -> 获取当前进程的pending位图,并打印 -> 给进程发送2号信号 -> 获取当前进程的pending位图,并打印 -> 解除2号信号的屏蔽。

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

using namespace std;

void PrintSig(sigset_t pending) 
{
    cout << "pending bitmap: ";

    for(int i = 31; i > 0; i--)
    {
        if(sigismember(&pending, i))
            cout << "1";
        else cout << "0";
    }
    cout << endl;
    sleep(1);
}

void handle(int signo) 
{
    cout << "2 signo 递达中" << endl;
    sigset_t pending;
    sigemptyset(&pending);
    int n3 = sigpending(&pending);

    PrintSig(pending);  //细节:先清空pengding位图,在递达
    cout << "2 signo 递达完毕" << endl;
}

int main()
{
    signal(2, handle);  //设置2号信号捕捉的方法

    //1.阻塞2号信号
    sigset_t set, oldset;
    sigemptyset(&set);
    sigaddset(&set, 2);
    sigemptyset(&oldset);
    int n1 = sigprocmask(SIG_SETMASK, &set, &oldset);
    assert(n1 == 0);
    cout << "block 2 signo success! pid: " << getpid() << endl;
    sleep(1);

    int cnt = 0;
    while(true)
    {
        //2.获取penging位图
        sigset_t pending;
        sigemptyset(&pending);
        int n2 = sigpending(&pending);
        assert(n2 == 0);

        //3.打印pending位图
        PrintSig(pending);
        cnt++;

        //4.解除对2号信号的阻塞
        if(cnt == 8) 
        {
            cout << "release 2 signo block!" << endl;
            n2 = sigprocmask(SIG_UNBLOCK, &set, &oldset); //解除完后,立即递达
            assert(n2 == 0);
        }

    }
    
    return 0;
}

应用场景2:对所有信号进行阻塞,观察收到所有信号时是否都未被递达,进程只能正常退出吗

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

using namespace std;

 void PrintSig(sigset_t pending) 
 {
    cout << "pending bitmap: ";

    for(int i = 31; i > 0; i--)     
    {
        if(sigismember(&pending, i))
            cout << "1";
        else cout << "0";
    }
    cout << endl;
    sleep(1);
 }

int main()
{
    sigset_t set, oldset;
    sigemptyset(&set);
    sigemptyset(&oldset);
    cout << "begin block all signo! pid: " << getpid() << endl;
    for(int i = 31; i > 0; i--) //将1~31号阻塞
    {
        sigaddset(&set, i);
        int n1 = sigprocmask(SIG_SETMASK, &set, &oldset);
        assert(n1 == 0);
    }

    while(true)
    {
        sigset_t pending;
        sigemptyset(&pending);
        int n2 = sigpending(&pending); //获取pending位图
        assert(n2 == 0);

        PrintSig(pending); //打印pending位图
    }

    return 0; 
}

5. 信号的处理

5.1. 信号处理常见三种方式

  1. 执行默认处理动作。

  2. 忽略。

  3. 自定义捕捉信号函数handle。

这个处理函数的执行要求内核切换到用户态,这种方式称为捕捉一个信号。

5.2. 信号捕捉的方式

信号捕捉:信号的处理动作是用户的自定义函数,信号递达时就调用这个函数,这称为信号捕捉。

5.2.1. signal

sighandler_t sigal(int signum,sighandler_t handler);

功能:设置信号处理函数。

参数:signum参数:要处理的信号编号、handler参数:信号处理函数指针,handler可以是用户自定义的函数指针,也可以是预定义的常量(SIG_IGN,忽略信号,或SIG_DFL,默认动作)。

5.2.2. sigaction

int sigaction(innt signum,const struct sigaction* act,struct sigaction* oldact);

  1. 功能:读取或修改与指定信号相关联的处理动作。

  2. 返回值:成功返回0,失败返回-1,并设置errno以指示错误原因。

  3. 参数:signum参数:指定要捕捉或处理的信号编号; oldact参数:输出型参数,如果为非空指针,保存了信号原来的处理动作; act参数:指定了信号新的处理动作。

sa_handler:指向信号处理函数的指针。 如果sa_handler被赋值为常数SIG_IGN,表示忽略信号、如果sa_handler被赋值为常数SIG_DFL,表示执行系统的默认处理动作、如果sa_handler被赋值为一个函数指针,表示自定义函数捕捉信号。

sig_flags:此处设置为0即可。 sa_sigaction:是实时信号的处理函数,此处不做解释。

sa_restorer:此处不管。

  1. 当某个信号的处理函数被调用时,OS自动将当前信号加入到进程的信号屏蔽字中,直到信号处理函数返回时,解除对当前信号的屏蔽,这样防止了信号被嵌套式的捕捉处理,如果此信号再次被产生时,它会被阻塞到当前处理结束为止。 如果调用信号处理函数时,除了屏蔽当前信号之外,还希望自动屏蔽其他信号,就可以用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
struct sigaction {
    void (*sa_handler)(int);  // 信号处理器函数指针
    void (*sa_sigaction)(int, siginfo_t *, void *);  // 异步安全信号处理器函数指针
    sigset_t sa_mask;  // 信号掩码
    int sa_flags;  // 标志位
    void (*sa_restorer)(void);  // 用于恢复上下文
};
#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<signal.h>
#include<cassert>

using namespace std;

 void PrintSig(sigset_t pending) 
 {
    cout << "pending bitmap: ";

    for(int i = 31; i > 0; i--)     
    {
        if(sigismember(&pending, i))
            cout << "1";
        else cout << "0";
    }
    cout << endl;
    sleep(1);
 }

void handler(int signo) 
{
    cout << "get signo: " << signo << endl;
    sigset_t pending;
    sigemptyset(&pending);
    while(true)
    {

        int n3 = sigpending(&pending);
        assert(n3 == 0);

        PrintSig(pending);  
    }
}

int main()
{
    struct sigaction act, oact;
    act.sa_handler = handler;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, 3);
    sigaddset(&act.sa_mask, 4);
    sigaddset(&act.sa_mask, 5);
    
    int n = sigaction(2, &act, &oact);
    assert(n == 0);

    cout << "I am a process! pid: " << getpid() << endl;
    while(true)
        sleep(1);

    return 0;
}

5.3. 信号什么时候被处理的

5.3.1. 用户态、内核态

问:信号什么时候被处理的?

  • 进程从内核态切换回用户态的时候,信号会被检测并处理。
  1. 用户态:当进程执行自己编写的代码或调用库函数时,通常处于用户态。

在用户态下进程的权限受到限制,不能直接访问内核数据和代码或执行特权指令,只能访问自己地址空间。

用户态下的代码通常是应用程序或库函数代码,它们通过系统调用等方式向OS请求服务,以完成各种功能。

  1. 内核态:进程进行系统调用或陷入到OS内部执行特定的任务,通常处于内核态。

在内核态下进程具有最高的权限,可以直接访问内核所有资源(如:硬件设备、内核数据结构、内核代码和数据等),并且可以执行一些特权指令。

内核状态下的代码通常是操作系统内核代码。

  1. 在计算机体系结构中,CPU特权级别是用来区分当前进程的运行模式(如:内核模式、用户模式等)的一种机制,从而保护了系统的安全性和稳定性,存放在CS段寄存器中。 在x86架构中,特权级别通常分为4个级别,分别为0、1、2、3。

内核态:CPU处于最高特权级别,0级。

用户态:CPU处于最低特权级别,3级。

  1. 系统调用(内核态下执行)、库函数以及用户提供自定义函数(用户态下执行),不仅是调用一个函数,还涉及身份切换。

当进程执行系统调用函数时,OS会自动将进程的身份由用户态变为内核态,让内核执行系统调用对应的任务,完成任务后,再将进程的身份由内核态转变为用户态,以便进程在用户态执行后续的代码。

5.3.2. 重谈地址空间

  1. 用户空间存储用户程序的代码和数据,用户空间通过用户级页表与物理内存建立映射关系,用户空间通常占据地址空间范围[0, 3]GB。

  2. 内核空间存储OS的代码和数据,内核空间通过内核级页表与物理内存建立映射关系,内核空间通常占据地址空间范围[3, 4]GB。

  3. 每个进程都有一个用户级页表,用户级空间确保了每个进程地址空间相互隔离。

  4. 内核级页表在整个OS中只有一份,使得所有进程共享相同的内核级页表,指向相同的OS代码和数据。

Tips:a. 进程无论如何切换,都能找到OS;b. 指向系统调用或访问系统数据,其实还是在自己的地址空间内进行跳转。

5.4. 内核如何实现信号的捕捉

       ![](https://cdn.nlark.com/yuque/0/2024/png/42574816/1728637030076-60509ee1-4d6d-458d-8920-c2ceb2bf3d34.png)

  1. 在执行主控制流程(main函数)的某条指令时,发生中断、异常或系统调用,使得进程需要从用户态切换到内核态。

  2. 当内核处理完毕后准备返回用户态的main函数时,内核会检查penging位图中是否有可以递达的信号。

  3. 如果可以递达的信号处理动作是默认或忽略,在执行完信号的处理动作后,就在pending位图中清除信号对应的标志位,如果没有其他信号需要递达,就直接返回到用户态,从主控制流程中上次被中断的地方继续向下执行。

如果可以递达的信号处理动作是执行自定义捕捉函数,因为自定义捕捉函数的代码是在用户空间的,就得要切换到用户态执行对应的捕捉函数。

  1. 执行完后通过特殊的系统调用sigreturn再次切换到内核态,如果没有其他可以递达的信号,就直接返回到用户态,从主控制流程中上次被中断的地方继续向下执行。

5.5 可重入函数

  1. 可重入函数:指一个函数可以被多个任务(线程、进程)并发执行,而不会出现错误或不一致的结果。不可重入函数概念与之相反。

这意味着函数可以被不同的控制执行流调用,函数在执行过程中可以被中断,然后再另一个执行流中再次被调用,而不会破坏函数的正确性和数据的完整性。

重入:像上例,insert函数被不同的控制流调用,在第一次调用还未返回时就再次进入该函数,这称为重入。

insert函数访问一个全局链表,有可能因为重入而造成错乱(node2节点丢失),这样的函数就成为不可重入函数。

如果一个函数只访问自己的局部变量或参数,则称为可重入函数。

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

独立的栈空间:每个控制流程都有自己独立的运行环境,其中包括独立的栈空间。意味着每个控制流程在调用函数时,都会在自己的栈上创建该函数局部变量和参数的副本。

操作的独立性:每个控制流程都在操作自己的局部变量副本,它们的操作不会影响其他控制流程的局部变量副本。

函数执行的独立性:每个控制流程对函数的执行也是独立的,只在自己的上下文中进行,不会影响其他控制流程。

  1. 如果一个函数符合以下条件之一,则是不可重入的:a. 调用了malloc和free,因为malloc也是使用全局链接来管理堆的; b. 调用了标准I/O库函数,因为标准库的很多实现都以不可重入的方式使用全局数据结构。

5.6. volatile关键字

  1. 编译器(gcc、g++)提供了多个级别的优化,-O0不启用优化、-O1启用基本的优化、-O2启用更高级别的优化,-O. . . ,数字越大,优化级别越高。

  2. 当编译器对代码进行优化时,它会减少访问内存的次数,以提高程序的运行速度。

eg:在循环中频繁访问某个变量g_val,编译器可能会将存储在内存中的g_val的值,拷贝到CPU的寄存器中,因为访问寄存器的速度比访问内存的速度快的多,如果修改了g_val值,则是对内存中的g_val进行了修改,这个修改不会自动反映到存储在寄存器中的g_val值。

int g_val;

void handler(int signo)
{
    (void)signo; //骗过编译器,不要警告,此变量不光光是定义,在后面还使用了它
    g_val = 1;  //对存放在内存中的g_val值进行修改,不会自动更新寄存器的值
    cout << "g_val由0变为1" << endl;
}

int main()
{
    signal(2, handler); 
    /*编译器会作优化,将存放在内存中的g_val值拷贝到CPU寄存器中,
    之后直接从寄存器中读取此变量的值*/
    while(!g_val) ; 
    printf("process normal quit!\n");
    
    return 0;
}

  1. volatile关键字的作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许作优化,对该变量的任何操作,都必须在真实的内存中进行操作。
volatile int g_val;
    
void handler(int signo)
{
    (void)signo; //骗过编译器,不要警告,此变量不光光是定义,在后面还使用了它
    g_val = 1;  //对存放在内存中的g_val值进行修改,不会自动更新寄存器的值
    cout << "g_val由0变为1" << endl;
}

int main()
{
    signal(2, handler); 
    /*编译器会作优化,将存放在内存中的g_val值拷贝到CPU寄存器中,
    之后直接从寄存器中读取此变量的值*/
    while(!g_val) ; 
    printf("process normal quit!\n");
    
    return 0;
}

5.7. SIGCHLD信号

  1. 子进程在终止时会给父进程发SIGCHLD信号(17号信号),该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程只需要在信号处理函数中调用wait清理子进程即可。
#include<iostream>
#include<cstdio>
#include<unistd.h>
#include<signal.h>
#include<cassert>
#include<sys/types.h>
#include<sys/wait.h>

using namespace std;

void clean(int signo)
{
    if(signo == SIGCHLD)
    {
        /*在同一时刻多个子进程退出,会同时向父进程发送SIGCHLD信号,
        但pending位图只会记录一次,即只清理了一个子进程的资源,
        所以使用while循环*/
        while(true) 
        {
            /*若为阻塞等待,只有部分子进程退出,由于while循环,
            会再次调用waitpid,就会一直在这阻塞,所以使用非阻塞轮询等待*/
            pid_t rid = waitpid(-1, nullptr, WNOHANG);
            if(rid > 0)
                cout << "child wait success!" << endl;
            else break;
        }
    }
}

int main()
{
    signal(SIGCHLD, clean);

    for(int i = 0; i < 100; i++) //父进程创建100个子进程
    {
        pid_t id = fork();
        if(id == 0)
        {
            cout << "I am child process, pid: " << getpid() << endl;
            exit(0); //100个子进程全部退出
        }

        sleep(1);
    }

    return 0;
}
  1. 要想不产生僵尸的另一种方法:将SIGCHLD信号的自定义捕捉函数的捕捉方法handler设置为忽略SIG_IGN,这样fork出来的子进程在终止时自动清理掉,不会产生僵尸,也不会通知父进程。

与系统默认处理动作中的忽略无区别,但这是个特例,这只适用于父进程不需要知道子进程的执行情况,反之,还得在自定义捕捉函数中设置waitpid获取子进程的退出码、退出信号。

int main()
{
    //子进程在终止时自动清理掉,不会产生僵尸,也不会通知父进程
    signal(SIGCHLD, SIG_IGN); 

    for(int i = 0; i < 100; i++) //父进程创建100个子进程
    {
        pid_t id = fork();
        if(id == 0)
        {
            cout << "I am child process, pid: " << getpid() << endl;
            exit(0); //100个子进程全部退出
        }

        sleep(1);
    }

    return 0;
}
Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐