linux之进程信号(信号保存 & 信号处理)

目录

  • [一. 阻塞信号](#一. 阻塞信号)
    • [1.1 信号的相关概念](#1.1 信号的相关概念)
    • [1.2 在内核中的表示](#1.2 在内核中的表示)
    • [1.3 sigset_t及其相关操作](#1.3 sigset_t及其相关操作)
      • [1.3.1 sigset_t介绍](#1.3.1 sigset_t介绍)
      • [1.3.2 sigprocmask详解](#1.3.2 sigprocmask详解)
      • [1.3.3 sigpending详解](#1.3.3 sigpending详解)
      • [1.3.4 阻塞信号实验](#1.3.4 阻塞信号实验)
      • 信号保存总结:
  • [二. 信号的捕捉](#二. 信号的捕捉)
    • [2.1 什么时候处理信号呢?](#2.1 什么时候处理信号呢?)
    • [2.2 重谈进程地址空间](#2.2 重谈进程地址空间)
    • [2.3 信号的处理时机](#2.3 信号的处理时机)
    • [2.4 sigaction函数](#2.4 sigaction函数)
  • [三. 可重入函数](#三. 可重入函数)
  • [四. volatile关键字](#四. volatile关键字)
  • [五. SIGCHLD信号](#五. SIGCHLD信号)

一. 阻塞信号

1.1 信号的相关概念

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

1.2 在内核中的表示

  • 在内核中进程的信号保存是用一个pending位图来保存的,每个比特位表示不同的信号,类似于数组下标,比特位为1表示收到该比特位对应的信号,为0表示没有收到
  • 阻塞信号也是用位图表示的,和pending位图结构一样,0表示没有阻塞该信号,1表示阻塞该信号
  • 进程需要对每个信号有自己的处理动作,在内核中用函数指针数组保存着,每个下标对应这对该信号的处理动作,如果我们没有设置,就是信号的默认处理动作,而我们设置了的话,就是我们设置的动作,所以其实我们之前调用signal系统调用的本质就是修改这个函数指针数组
  • 信号被阻塞后,即使收到该信号也不会被递达,但是pending位图会被置1,直到取消阻塞状态才会被递达,pending位图才能被置0.
  • 由上图可得SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
    总结一下:
    所以我们接下来对信号的操作系统调用,本质就是对这3张表进程操作

1.3 sigset_t及其相关操作

1.3.1 sigset_t介绍

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。

因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。

接下来将详细介绍信号集的各种操作。

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

#include <signal.h>
int sigemptyset(sigset_t *set);                  //将信号集合set清空
int sigfillset(sigset_t *set);                   //将信号集set设置所有信号都存在
int sigaddset (sigset_t *set, int signo);        //将指定信号signo添加进信号集set
int sigdelset(sigset_t *set, int signo);         //将指定信号signo从信号集set中删除
int sigismember(const sigset_t *set, int signo);//判断signo是否存在信号集set中,存在返回1否则0

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

1.3.2 sigprocmask详解

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

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1 

参数详解:
how and set

SIG_BLOCK: 将set中的信号集合添加到当前的信号屏蔽字中,相当于,mask = mask | set

SIG_UNBLOCK:将set中的信号集合从当前的信号屏蔽字中移除, 相当于,mask = mask & ~set

SIG_SETMASK: 将set中的信号集合替换为当前的信号屏蔽字, mask = set

oset

之前的信号屏蔽字

1.3.3 sigpending详解

NAME
       sigpending - examine pending signals

SYNOPSIS
       #include <signal.h>
       int sigpending(sigset_t *set);
       读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。

1.3.4 阻塞信号实验

实验一:

实验思路:

  1. 我们先对2号信号自定义捕捉,为了继续做实验准备
  2. 将2号信号屏蔽,持续20s,20s后解除屏蔽
  3. 在20s内先没有收到信号之前,打印信号全为0
  4. 收到2号信号后,2号信号对应的比特位为1,而且一直没有被递达,所以20s之内一直保持1
  5. 20s后,恢复原来的位图结构即解除对2号信号的屏蔽,信号递达,递达行为即步骤1,之后打印全为0
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

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

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

int main()
{
    sigset_t bset, oset;
    sigemptyset(&bset);
    sigemptyset(&oset);
   
    // 0. 对2号信号进行自定义捕捉
    signal(2, handler);

    // 1. 先对2号信号进行屏蔽 --- 数据预备
    sigset_t bset, oset; // 在哪里开辟的空间???用户栈上的,属于用户区
    sigemptyset(&bset);
    sigemptyset(&oset);
    sigaddset(&bset, 2); // 我们已经把2好信号屏蔽了吗?并没有设置进入到你的进程的task_struct
    // 1.2 调用系统调用,将数据设置进内核
    sigprocmask(SIG_SETMASK, &bset, &oset); // 我们已经把2好信号屏蔽了吗?ok

    // 2. 重复打印当前进程的pending 0000000000000000000000000
    sigset_t pending;
    int cnt = 0;
    while (true)
    {
        // 2.1 获取
        int n = sigpending(&pending);
        if (n < 0)
            continue;
        // 2.2 打印
        PrintPending(pending);

        sleep(1);
        cnt++;
        // 2.3 解除阻塞
        if(cnt == 20)
        {
            cout << "unblock 2 signo" << endl;
            sigprocmask(SIG_SETMASK, &oset, nullptr); // 我们已经把2好信号屏蔽了吗?ok
        }
    }
    // 3 发送2号 0000000000000000000000010

    return 0;
}

实验现象:

实验二:

实验思路:

直接把所有普通信号都给屏蔽了

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

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

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

int main()
{
    // 4. 我可以将所有的信号都进行屏蔽,信号不就不会被处理了吗? 肯定的!9
    sigset_t bset, oset;
    sigemptyset(&bset);
    sigemptyset(&oset);
    for (int i = 1; i <= 31; i++)
    {
        sigaddset(&bset, i); // 屏蔽了所有信号吗???
    }
    sigprocmask(SIG_SETMASK, &bset, &oset);

    sigset_t pending;
    while (true)
    {
        // 2.1 获取
        int n = sigpending(&pending);
        if (n < 0)
            continue;
        // 2.2 打印
        PrintPending(pending);
        sleep(1);
    }
    return 0;
}

实验现象:和之前signal实验现象相似,9和19号信号无法屏蔽,其他可以

信号保存总结:

sigset是用户层的位图结构,通过系统调用接口,可以让sigset和进程的block、pending、handler表建立联系,获取或修改它们。

二. 信号的捕捉

2.1 什么时候处理信号呢?

之前说过,信号会在合适的时候被处理,那么究竟是什么时间呢?从内核态切换到用户态的时候,会进行信号的监测和处理。

但是,内核态、用户态又是什么呢?

内核态: 内核态代码运行在处理器特权级别最高 (通常是 0 级) 的模式下。这意味着内核态代码可以直接访问所有硬件资源,包括内存、I/O 设备、CPU 指令和寄存器等。它不受任何限制,可以执行任何操作,包括修改操作系统自身。

用户态: 用户态代码运行在较低的特权级别 (通常是 3 级)。这意味着用户态程序只能访问操作系统分配给它的资源,而不能直接访问硬件。它受到操作系统的严格控制,以防止恶意程序或错误程序损害系统。 用户态程序试图执行特权操作(例如直接访问内存地址)会导致操作系统中断其执行。

那么我们什么时候进行用户态与内核态之间的切换呢?

系统调用,时间片,异常中断等。

拿系统调用来说:操作系统给我们提供了一些系统调用,让我们执行操作系统的代码和数据,但是操作系统不相信任何人,这两句话不是互相矛盾嘛?所以当我们执行系统调用的时候需要进行身份切换,由用户态切换成内核态。

我们怎么知道我们是处于内核态还是用户态呢?

这需要cpu来配合,在cpu中,有一些寄存器保存着进程的资源,比如存入插入寄存器cr3保存着进程的页表,而有一个特殊的寄存器ecs,它的后两个比特位存储着表示进程是用户态还是内核态,其中00表示内核态,11表示用户态

2.2 重谈进程地址空间

  1. 主机开机的时候,操作系统会首先被加载进内存
  2. 每个进程都有自己独立的用户代码和数据,但是操作系统的代码和数据都一样,所以有多少个进程就有多少个用户级页表,而内核级页表只有一份
  3. 当发送进程切换的时候,进程地址空间的3-4g属于内核空间,每个进程都一样,所以不用切换内核空间
  4. 当我们使用系统调用的时候,本质就是访问操作系统的代码和数据,操作系统不相信任何人,所以需要将身份由用户态切换为内核态

2.3 信号的处理时机

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

可以发现当我们执行自定义捕捉函数sighandler的时候,从内核态切换为了用户态,这是为什么?

其实如果操作系统想要这么做的话很容易,但是为了安全性考虑,操作系统没有选择这样做,因为操作系统不相信任何人,万一sighandle函数内部做了异常的行为呢?

上图简单记忆法:

**处理信号时,pending位图中的比特位什么时候由1变0,处理前还是处理后? **

处理前,代码验证

c 复制代码
#include <iostream>
#include <unistd.h>
#include <cstring>
#include <signal.h>
using namespace std;
void PrintPending()
{
    sigset_t set;
    memset(&set, 0, sizeof(set));
    sigpending(&set);
    for (int i = 31; i >= 1; --i)
    {
        if (sigismember(&set, i))
        {
            cout << '1';
        }
        else
        {
            cout << '0';
        }
    }
    cout << endl;
}
void handler(int signum)
{
    cout << "catch a sig: " << signum << endl;
    while(true)
    {
        PrintPending();
        sleep(1);
    }
}


int main()
{
    signal(2, handler);
    while (true)
    {
        cout << "i am a process : " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

可以看到,我们处理信号时,pending位图已经置0,所以是处理前

2.4 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指向sigaction结构体:

struct 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: 指定信号signo的处理方式

sa_sigaction:用来处理实时信号的,本文不考虑

mask:当正在处理signo信号期间,signo会自动加入信号屏蔽字,mask也可以再屏蔽更多的信号

其他两个不考虑

实验:

思路:

对2号信号进行自定义捕捉,并设置mask为12(默认自带的)34, 处理动作为不断打印oending位图,并且打死循环,若第一次收到2号信号,将会进入打印pending位图的死循环,若再发1.2.3.4信号,pending位图对应位置变1,而且一直不递达

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <cstring>
#include <signal.h>
using namespace std;
void PrintPending()
{
    sigset_t set;
    memset(&set, 0, sizeof(set));
    sigpending(&set);
    for (int i = 31; i >= 1; --i)
    {
        if (sigismember(&set, i))
        {
            cout << '1';
        }
        else
        {
            cout << '0';
        }
    }
    cout << endl;
}
void handler(int signum)
{
    cout << "catch a sig: " << signum << endl;
    while(true)
    {
        PrintPending();
        sleep(1);
    }
}


int main()
{
    struct sigaction sa;
    struct sigaction osa;

    memset(&sa, 0, sizeof(sa));
    memset(&osa, 0, sizeof(osa));
    sa.sa_handler = handler;
    sigset_t mask;
    memset(&mask, 0, sizeof(mask));
    sigaddset(&mask, 1);
    sigaddset(&mask, 3);
    sigaddset(&mask, 4);
    sigaddset(&mask, 5);

    sa.sa_mask = mask;
    sigaction(2, &sa, &osa);
        
    while (true)
    {
        cout << "i am a process : " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

现象:

三. 可重入函数

  • main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
  • 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?

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

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

四. volatile关键字

该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下

c 复制代码
//test.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int flag = 0;
void handler(int signum)
{
    cout << "flag 0 to 1 " << endl;
    flag = 1;
}
int main()
{
    signal(2, handler);
    //在优化条件下,flag可能会被优化到寄存器中
    while(!flag);
    cout << "process quit normal" << endl;
    return 0;
}
//makefile
mytest:test.cc 
	g++ -o $@ $^ -g -std=c++11
.PHONY:clean
clean:
	rm mytest

标准情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 , while 条件不满足,退出循环,进程退出


g++编译器默认优化为O1即不优化,若改为更高的O2或O3,编译器认为main执行流中flag不会被修改,就优化到了寄存器,之后的修改是对内存中flag的修改,不影响寄存器中的值,造成了while一直不退出

c 复制代码
//makefile
mytest:test.cc 
	g++ -o $@ $^ -g -O2 -std=c++11
.PHONY:clean
clean:
	rm mytest

此时现象:

如何解决呢?很明显需要 volatile来保持内存的可见性,防止优化flag

c 复制代码
//test.cc
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
volatile int flag = 0;
void handler(int signum)
{
    cout << "flag 0 to 1 " << endl;
    flag = 1;
}
int main()
{
    signal(2, handler);
    //在优化条件下,flag可能会被优化到寄存器中
    while(!flag);
    cout << "process quit normal" << endl;
    return 0;
}

五. SIGCHLD信号

  • 之前进程控制讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
  • 其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,进程在信号处理函数中调用wait清理子进程即可。
    测试:
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <ctime>
using namespace std;

void handler(int signo)
{
    pid_t rid;
    //当收到一次17号信号时,在这期间其实又有子进程退出,也能通过waitpid非阻塞回收
    //从而解决了此时信号信号屏蔽字带来的问题
    while((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
    {
        cout << "wait child success: " << rid << endl;
    }
}
int main()
{
    signal(17, handler);
    srand(time(nullptr));
    for(int i = 0; i < 10; ++i)
    {
        pid_t id = fork();
        if(id == 0)
        {
            //模拟不同子进程再不同的时间退出
            sleep(rand() % 5 + 3);
            cout << "i am child process, pid: " << getpid() << " ppid: " << getppid() << endl;
            _exit(0);
        }
    }
    while(true)
    {
        cout << "i am father process, pid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}
  • 事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用。请编写程序验证这样做不会产生僵尸进程。
c 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <ctime>
using namespace std;

// void handler(int signo)
// {
//     pid_t rid;
//     //当收到一次17号信号时,在这期间其实又有子进程退出,也能通过waitpid非阻塞回收
//     //从而解决了此时信号信号屏蔽字带来的问题
//     while((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
//     {
//         cout << "wait child success: " << rid << endl;
//     }
// }
int main()
{
    signal(17, SIG_IGN);
    srand(time(nullptr));
    for(int i = 0; i < 10; ++i)
    {
        pid_t id = fork();
        if(id == 0)
        {
            //模拟不同子进程再不同的时间退出
            sleep(rand() % 5 + 3);
            cout << "i am child process, pid: " << getpid() << " ppid: " << getppid() << endl;
            _exit(0);
        }
    }
    while(true)
    {
        cout << "i am father process, pid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

可以看到,此时子进程没有僵尸进程状态。

相关推荐
怎么昵称都被占用啊43 分钟前
【Linux系统环境中使用二进制包安装Apache】
linux·运维·apache
我们的五年2 小时前
【C++课程学习】:C++中的IO流(istream,iostream,fstream,sstream)
linux·c++·学习
工程师焱记2 小时前
Linux 常用命令——文件目录篇(保姆级说明)
linux
牛马大师兄3 小时前
网络编程 | UDP套接字通信及编程实现经验教程
linux·网络·网络协议·ubuntu·udp
千千道3 小时前
QT的TCP通讯
linux·服务器·qt·tcp/ip
JaneZJW3 小时前
Linux C编程:文件IO(概念、打开、读、写、关闭)
linux·c语言·stm32·单片机·嵌入式
九州~空城3 小时前
Linux中的基本指令(一)
linux·运维·服务器
Dong雨3 小时前
Linux虚拟机安装与FinalShell使用:探索Linux世界的便捷之旅
linux·运维·finalshell
大帅哥_4 小时前
Linux的几个基本指令
linux·服务器
davenian4 小时前
< OS 有关 > 阿里云:轻量应用服务器 的使用 安装 Tailscale 后DNS 出错, 修复并替换 apt 数据源
linux·服务器·ubuntu·阿里云·tailscale