【Linux】进程信号(下)

发布于:2025-04-10 ⋅ 阅读:(38) ⋅ 点赞:(0)

在上一篇中,我们详细探讨了信号的预备知识和产生方式(如硬件异常、终端输入、kill命令、系统调用等)及其背后的操作系统行为。信号作为进程间异步通信的核心机制,其生命周期远不止“产生”这一环节——信号的保存处理才是实现可靠异步事件响应的关键。

一、信号其他相关常见概念

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

二、信号保存

阻塞信号

在Linux信号机制中,阻塞(Block)与未决(Pending)是信号生命周期中两个关键状态,但其概念常被混淆。通过一个生动的类比,我们可以清晰理解它们的本质区别与联系:

假设你是一名学生,数学老师布置的作业类比为操作系统发送的信号:

  • 信号产生:数学老师(进程)布置作业(发送信号),你将其记录在作业本上(未决状态)。
  • 未决(Pending):作业已记录,但未开始处理(信号已接收但未被处理)。
  • 阻塞(Block):若你因讨厌数学老师,决定暂时不处理他的作业(阻塞信号),但仍会记录所有布置的作业(未决状态),直到你改变态度(解除阻塞)。
  • 信号抵达(Deliver):最终你选择完成作业(执行默认处理)、草草应付(忽略)或按自己的方法解题(自定义处理)。

 阻塞 vs 忽略:本质区别

  • 阻塞(Block)
    • 延迟处理:信号被标记为未决,暂不处理,直到解除阻塞。
    • 主动控制:即使信号未产生,也可预先设置阻塞(如“无论数学老师是否布置作业,我都拒绝处理”)。
  • 忽略(Ignore)
    • 处理方式之一:信号抵达时直接丢弃,属于信号处理的三种动作之一(默认、自定义、忽略)。
    • 后果:忽略后信号不会被保存,也不会触发后续处理。

Linux信号处理的内核结构

在Linux内核中,信号的产生、保存与处理通过三张核心数据结构实现:未决信号集(Pending)阻塞信号集(Block)和信号处理函数表(Handler)。这三张表共同管理信号的全生命周期,是理解信号异步处理机制的关键。

Linux通过两个位图管理信号状态:

  • 未决信号集(Pending):记录已接收但未处理的信号。
  • 操作系统向进程发信号,其实就是向目标进程的判定位图当中进行比特位设置。把比特位直接从0至1就代表的当前我们对应的进程收到了信号。
  • 阻塞信号集(Block):定义哪些信号被暂时屏蔽(即使收到信号,也保持未决)。
    • 若信号被阻塞,其后续接收的实例会被丢弃(多次SIGINT仅保留一次)。
    • SIGKILL(9号)和SIGSTOP(19号)不可被阻塞或忽略,确保管理员始终拥有进程控制权。

信号处理函数表(Handler)

  • 本质:函数指针数组,每个元素对应一个信号的处理方式。
  • 索引:数组下标为信号编号-1(如SIGINT对应下标1)。
  • 处理方式:
  •         SIG_DFL:默认处理(如终止进程、忽略等)。
  •         SIG_IGN:忽略信号。
  •         自定义函数:用户定义的信号处理函数。
    #include <iostream>
    #include <vector>
    #include <signal.h>
    #include <unistd.h>
    
    // #define BLOCK_SIGNAL 2
    #define MAX_SIGNUM 31
    
    using namespace std;
    
    // static vector<int> sigarr = {2,3};
    static vector<int> sigarr = {2};
    
    static void show_pending(const sigset_t &pending)
    {
        for(int signo = MAX_SIGNUM; signo >= 1; signo--)
        {
            if(sigismember(&pending, signo))
            {
                cout << "1";
            }
            else cout << "0";
        }
        cout << "\n";
    }
    
    static void myhandler(int signo)
    {
        cout << signo << " 号信号已经被递达!!" << endl;
    }
    
    int main()
    {
        for(const auto &sig : sigarr) signal(sig, myhandler);
    
        // 1. 先尝试屏蔽指定的信号
        sigset_t block, oblock, pending;
        // 1.1 初始化
        sigemptyset(&block);
        sigemptyset(&oblock);
        sigemptyset(&pending);
        // 1.2 添加要屏蔽的信号
        for(const auto &sig : sigarr) sigaddset(&block, sig);
        // 1.3 开始屏蔽,设置进内核(进程)
        sigprocmask(SIG_SETMASK, &block, &oblock);
    
        // 2. 遍历打印pengding信号集
        int cnt = 10;
        while(true)
        {
            // 2.1 初始化
            sigemptyset(&pending);
            // 2.2 获取它
            sigpending(&pending);
            // 2.3 打印它
            show_pending(pending);
            // 3. 慢一点
            sleep(1);
            if(cnt-- == 0)
            {
                sigprocmask(SIG_SETMASK, &oblock, &block); // 一旦对特定信号进行解除屏蔽,一般OS要至少立马递达一个信号!
                cout << "恢复对信号的屏蔽,不屏蔽任何信号\n";
            }
        }
    }

进程为什么能够识别信号

因为程序员在设计信号这一套体系或者机制的时候,在内核当中已经为每个进程都设置好了。它对应的三种结构分别称为未决信号集(Pending)阻塞信号集(Block)和信号处理函数表(Handler)。这三个结构组合起来就能够去完成识别信号的一个目的。


阻塞 ≠ 进程阻塞

  • 信号阻塞:控制信号的接收与处理流程,属于信号管理机制。
  • 进程阻塞:进程因等待资源(如I/O)进入睡眠状态,属于调度范畴。二者毫无关联。

三、信号的捕捉

当一个进程正在运行时,他收到了一个信号。那么该信号在被收到之后,可能并不会被我们立即处理,而是在内核态返回用户态的时候才进行处理该信号。

用户态&&内核态

在谈这两个概念之前,我们先回顾一下用户代码和内核代码。我们所写的代码,诸如数据结构的代码、算法的代码,还有自己所写的所有的代码,在编译运行之后,全部都是运行在用户态的。但是在用户态的时候,我们难免会访问两种资源。第一种叫做操作系统自身的资源,第二个叫做硬件资源。比如说我们现在有一个进程,他想调用getpid(),这个就叫做获取操作系统自身的资源,因为要访问内核数据结构,这些数据是由操作系统去维护的。我们也可能在未来访问我们的硬件资源,诸如调用printf,write,read的方法来读写磁盘,读写文件,或者读写显示器,这都叫做访问硬件资源。无论是操作系统自身的资源,还是硬件资源,它都属于操作系统及其操作系统之下。我们用户在自己写的代码当中,为了访问这些资源,你必须直接或间接的去调用操作系统提供的接口。这批接口我们称之为系统调用。我们在调用系统调用的时候,其实除了你自己调系统调用这件事情。还有一个很重要的事情就是普通用户无法以自己用户态的身份来执行系统调用,必须让自己的状态变成内核态。所以系统调用一定会比普通的你自己写的函数调用成本要高。所以频繁调用系统调用的程序,效率一般都不会特别高,所以我们一般尽量避免叫做频繁调用系统调用

一个进程,它在实际执行时,他一定要把自己的上下文信息投递到CPU当中。在CPU当中一定会存在着大量的寄存器,而寄存器我们一般可以将寄存器划分为两类。可见寄存器与不可见寄存器。无论是哪一种,其中这些寄存器当中有相当大的一部分都和进程强相关的。在进程当中是有特定用途的,比如会有一些寄存器直接就能够指向当前正在运行的进程的PCB,还有一些寄存器可以保存当前进程的用户级页表。CPU内部还集成了MMU地址转化单元,它根据页表起始地址在CPU内部就完成了虚拟到物理的转化。

虚拟地址到物理地址转化的时候是采用页表这种软件结构和MMU这种硬件结构,软硬件结合的方式。在寄存器中,硬件的速度远远大于软件的速度,因为软件还要从内存和CPU来回迁移。但是硬件一旦电路划分好,它的可维护成本比较高,维护性比较低。软件可以灵活调整更改,软件来维护页表,硬件进行地址转化。进程等于内核数据结构+进程的代码和数据。数据在磁盘中,程序通过exec加载到内存中,操作系统会自动创建虚拟到物理之间的一个映射关系。可执行程序在编译的时候已经按照虚拟地址的方式在编译器内部给我们编译好了。加载好之后,操作系统会帮助我们创建对应的内核数据结构。

CR3寄存器里面有对应的比特位用来表征当前进程的状态,表征了当前进程的运行级别。对应的数字如果为零,表示内核态,如果为三,表示用户态

 

凡是和当前进程强相关的,我们也就是说这个寄存器上直接保存或间接保存的是这个进程的相关数据。我们都称之为当前进程的上下文数据。当进程在切换的时候,它除了切换PCB地址空间页表这些东西,它其中还要帮我们去切换进程的是CPU内的匹配它对应的上下文。寄存器只有一套,但寄存器里面的值可以有多套。每个进程在切走的时候,可以把自己的上下文带走,当进程回来的时候再把自己的上下文拿回来。

  • 用户态(毕业生身份)
    • 权限限制:如同小学毕业生只能访问教室等基础区域
    • 操作限制:无法直接操作硬件或访问核心系统资源(类似无法进入校长办公室)
  • 内核态(教师/校长身份)
    • 特权模式:拥有系统完全控制权(可访问所有区域)
    • 关键能力:直接操作硬件、管理内存、调度进程(类似审批教学计划)
    • 典型场景:执行系统调用、处理中断、驱动硬件

再次理解地址空间

我们今天介绍一下地址空间里面的内核空间。内核空间的映射的就是让当前进程去映射到操作系统的。每个进程都有自己的代码和数据,一定被放在物理内存的不同区域。为了进程保证独立性,每个进程都有自己独立的用户级页表。其实还在操作系统内部,它还维护了一张内核级页表。内核级页表是操作系统为了维护从虚拟到物理之间的操作系统级别的代码所构建的一张内核映射表。在开机的时候,是要把操作系统加载到内存的,因为操作系统只有一份,所以它的代码和数据在系统内就是唯一的,所以内核级页表只需要一份就足够了。所以每个进程都可以用内核级页表的方式去访问访问操作系统的代码和数据。访问操作系统的接口其实只需要在自己的地址空间上进行跳转即可。操作系统本身要做好一些权限管理,很多东西不允许你访问,最多只让你访问系统调用接口。

当我们的进程它在执行我们对应的想跳转到这个区域的时候,那么它必须得保证自己要更改一下我们当前进程所对应的运行级别,CPU内对应的就是CR3寄存器。实际上系统调用接口,它在最开始的时候,还是在用户态执行的,然后在系统调用的开头通过Int 80陷入内核的方式使得用户态转为内核态,在系统调用结尾再转换到用户态,会把你的用户权限从我们对应的叫做三号状态改成零号状态,然后才让你跳转到这个区域去访问操作系统的代码和数据。

无论是内核态还是用户态,一定是当前这个进程正在运行。无非就是我当前的执行级别是用户态还是内核态的。然后用的页表是用户级页表还是内核级页表,访问的资源是操作系统的还是我自己的,其中我们在状态变化的时候,意味着我们要做一大堆的资源切换。诸如我们要修改我们的状态寄存器,陷入内核进行函数跳转等等。这些都比较费时间, (我们好不容易切换成内核态了,为了提升效率,操作系统要多做一些事情)所以从内核态返回用户态时,他要做信号捕捉处理。那什么时候我会进入内核态呢?最典型的就是系统调用。第二个叫做进程切换。还有包括异常、缺陷、陷阱等等。一个进程在切换时,他没有被执行完,一定被放到运行队列或者等待队列。放过去的时候,他就一定要处于内核态,以操作系统的身份把我才能放过去。

能不能以内核态的身份执行用户态代码?

从技术上可以。但是从设计方面一定不可以,因为操作系统不信任任何人提供的接口。操作系统它无法直接去识别到你的逻辑究竟是非法还是合法。所以操作系统虽然他觉得他自己能,但是不敢。如果直接以内核态的身份去执行用户的代码很容易,这份代码如果被恶意分子利用,就可以以一个内核态的身份去进行一定程度的越权,进行一系列的非法动作。所以当我们在进行我们对应的捕捉,处理这个信号的时候,绝对不能以内核态的身份去执行,他要重新将身份更改为用户态。

信号捕捉全过程

 简略图如下:因为横线和无穷大有四个交点,所以它一定在信号处理过程之中,如果是自定义动作,一定是需要四次状态切换。

相关系统调用函数

sigset_t

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

信号集操作函数

#include <signal.h> 
int sigemptyset(sigset_t *set);// 初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
int sigfillset(sigset_t *set); //初始化set所指向的信号集,使其中所有信号的对应bit置位,表示 该信号集的有效信号包括系统支持的所有信号。
int sigaddset (sigset_t *set, int signo);//新增
int sigdelset(sigset_t *set, int signo);//清除
int sigismember(const sigset_t *set, int signo);//用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1

  • 注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>

// volatile: 保持内存可见性!
// volatile int quit = 0;

void handler(int signo)
{
    // 1. 我有非常多的子进程,在同一个时刻退出了

    // waitpid(-1) -> while(1)
    // 2. 我有非常多的子进程,在同一个时刻只有一部分退出了
    // while(1)
    // {
    //     pid_t ret = waitpid(-1, NULL, WNOHANG);
    //     if(ret == 0) break;
    // }

    // printf("pid: %d, %d 号信号,正在被捕捉!\n", getpid(), signo);
    // printf("quit: %d", quit);
    // quit = 1;
    // printf("-> %d\n", quit);
}

void Count(int cnt)
{
    while (cnt)
    {
        printf("cnt: %2d\r", cnt);
        fflush(stdout);
        cnt--;
        sleep(1);
    }
    printf("\n");
}

int main()
{
    // 显示的设置对SIGCHLD进行忽略
    signal(SIGCHLD, SIG_IGN);
    signal(SIGCHLD, SIG_DFL);

    printf("我是父进程, %d, ppid: %d\n", getpid(), getppid());

    pid_t id = fork();
    if (id == 0)
    {
        printf("我是子进程, %d, ppid: %d,我要退出啦\n", getpid(), getppid());
        Count(5);
        exit(1);
    }

    while (1)
        sleep(1);

    // signal(2, handler);
    // while(!quit);
    // printf("注意, 我是正常退出的!\n");
    return 0;
}

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

// using namespace std;

// void Count(int cnt)
// {
//     while(cnt)
//     {
//         printf("cnt: %2d\r", cnt);
//         fflush(stdout);
//         cnt--;
//         sleep(1);
//     }
//     printf("\n");
// }

// void handler(int signo)
// {
//     cout << "get a signo: " << signo << "正在处理中..." << endl;
//     Count(20);
// }

// int main()
// {
//     struct sigaction act, oact;
//     act.sa_handler = handler;
//     act.sa_flags = 0;
//     sigemptyset(&act.sa_mask); // 当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中
//     sigaddset(&act.sa_mask, 3);
//     sigaction(SIGINT, &act, &oact);

//     while(true) sleep(1);

//     return 0;
// }

当某个信号的处理函数被调用时,操作系统会自动将当前信号加入到进程的信号屏蔽字(block)里面。然后当信号处理函数返回时,自动恢复为原来的信号屏蔽值。这样的话就保证当我们在一个时间段内大量的信号同样的信号产生时,我们并不会因为这个信号被重复高频次的调用而产生函数出现问题。比如说递归了或者是重复进入了这样的情况。那么同类型的信号捕捉方法,它一定是要串行执行的,这也减少了系统设计的一个难度或者成本。

四、volatile关键字

我们站在信号的角度理解一下这个关键字

#include <stdio.h>
#include <signal.h>
int flag = 0;
void handler(int sig)
{
    printf("chage flag 0 to 1\n");
    flag = 1;
}
int main()
{
    signal(2, handler);
    while(!flag);
    printf("process quit normal\n");
    return 0;
}

标准情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 , while 条件不满足,退出循环,进程退出。优化情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显, while 循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。 while 检测的flag其实已经因为优化,被放在了CPU寄存器当中。如何解决呢?很明显需要 volatile。

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

结语

以上就是我对 【Linux】进程信号(下) 的理解,觉得这篇博客对你有帮助的,可以点赞收藏关注支持一波~😉