🎯 整体学习目标

通过本系列博客,您将全面掌握Linux信号机制,从基础概念到内核实现,从用户态接口到底层原理,建立完整的信号处理知识体系。

本篇博客思维导图:

一、信号的基本概念:

1、生活中的信号类比:

比如,你现在正在放假,你自己一个人在家看书,那么这个时候你家的门铃响了,这个门铃就是一个信号。

那么对于这个信号你有几种处理方式呢?

默认处理:直接开门去招待客人。

自定义处理 :你经过猫眼去看一下,这个人是谁?我认不认识?如果认识再帮他按电梯,那么如果不认识选择不理会。

忽略处理:当没有听见,继续看书。

这就是生活中信号的例子,那么我们如何将这个生活中的例子去联系到Linux中的信号呢?

这个简单的例子中的过程与Linux信号处理非常相似:

信号产生:门铃响起了。

信号识别:通过你的经验告诉你,这个声音是门铃信号(进程能够识别信号)。

信号处理实际:你可能不会立即开门,而是会选择合适的时间(信号递达时机)。

处理方式:你有多种处理方式(信号的默认、自定义、忽略)。

由此,我们通过生活中的一个例子,正式引入了信号的基本概念!

那么信号的主要过程可以分为如下几个过程:

在讲解四个主要过程之前,我们先梳理一下进程对信号的处理机制:

1、识别机制:进程能辨别信号类型,并且知道对应的处理方式。

2、保存机制:存储位置:

task_struct中的信号位图(下面我们会具体说)。

3、处理特性:具有异步性,也就是信号本身在任何代码位置到达。

那么下面,我将具体讲解上述四个过程:

二、预备操作:

什么是预备操作呢?信号的预备操作主要是指信号阻塞,也叫信号屏蔽,这个信号阻塞与我们进程中那个阻塞只是同名但是是不同的概念,不要混淆,信号阻塞的意思是暂时阻止某些信号被处理,即使信号已经产生了,也不会会立即递达,比如我们过年开车回老家,那么这个我们已经出发了,但是路上堵车了,我们虽然出发了,但是不能立即回到老家!

1、信号捕捉:

什么是信号捕捉?信号捕捉是指当进程收到信号时,不再执行系统的默认处理动作(每个信号都有默认的处理动作,比如2号信号就是终止进程),而是执行用户自定义的函数,与c++中继承中的重写比较类似,但是不同!

那么如何实现信号捕捉呢?下面我将为大家介绍一下signal这个函数。因为后续所有辅助代码几乎都涉及到了这个函数。

signal()函数:

#include<signal.h>

typedef void(*sighandler_t)(int)(函数指针);

sighandler_t signal(int signum,sighandler_t handler)

返回值:

如果成功,则为指向前次处理程序的指针,若失败,则为SIG_ERR。

如果 handler为SIG_IGN,那么忽略类型为signum的信号。

如果handler为SIG_DFL,那么类型将类型为signum的信号恢复为默认行为(通俗的讲就是重置)。

否则:hander就是用户定义函数的地址,这个函数称为信号处理程序,只要进程收到一个位signum类型的信号就会调用这个程序。换句话说,就是收到第一个参数的信号就去调用第二个参数这个函数。

所以说:

信号捕捉=信号+自定义处理函数。

执行时机:信号到达时立即执行。

2、信号阻塞:

信号阻塞是指进程暂时阻止某些信号被递达,被阻塞的信号会保持在"未决"状态,直到进程解除阻塞。

那么这里就涉及到了下面几个概念:

递达:实际执行信号处理。

未决:从产生到递达的中间状态。

阻塞:阻止信号递达(不同于阻塞)。

三、信号的产生:

信号的第一个产生方式:

1、使用kill命令产生信号:

#include <iostream>
#include<iostream>
#include<unistd.h>
using namespace std;
int main()
{
    while(true)
    {
        cout<<"我是一个进程"<<getpid()<<endl;
        sleep(1);
    }
}

如上代码会返还一个进程的pid,那么我们可以通过kill -(信号)进程的pid这个命令来向此进程发送信号。

2、从键盘产生信号:

那么比较常见的方式之一就是从键盘产生信号,为什么我们按下就会ctrl+c就可以退出程序呢?实际上按下ctrl+c的本质是操作系统给正在运行的该进程发送了一个信号,那么如何验证这一点呢?

注意,操作系统中的信号并不是64个,而是从1到31的常规信号与34到64的实时信号组成。

那么下面我将通过代码来验证,我们输入ctrl+c的命令就是在给进程发送2号信号。

#include <iostream>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int sig)
{
    cout<<"捕捉到了一个信号:"<<sig<<endl;
    if(sig==2)
    {
        cout<<"我捕捉到的信号是ctrl+c发送的2号信号"<<endl;
    }
}
int main()
{
   signal(SIGINT,handler);//这个函数说明,如果发送的信号是SIGIN,那么就调用我们自己写的hanlder函数
   cout<<"请输入命令"<<endl;
   while(1)
   {
    sleep(1);
   }
   return 0;
}

那么由此,我们验证了,ctrl+c之所以能退出异常的程序的本质就是操作系统向该进程发送了一个信号,而这个信号就是2号信号,并且,二号信号的默认动作就是终止该进程。

那么同理:

ctrl+\就是发送三号信号(终止进程并产生core dump)。

ctrl+Z就是发送20号信号,暂停该进程,那么在这里,我就不一一进行验证了。

3、通过kill/raise函数来产生信号:

#include<iostream>
#include <cstdio>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include<sys/wait.h>
using namespace std;
int main()
{
    pid_t pid = fork();
    if (pid == 0)
    {
        // 子进程
        cout << "子进程启动"<< getpid()<< endl;
        while (1)
        {
            cout << "子进程运行中..." <<endl;
            sleep(2);
        }
    }
    else
    {
        // 父进程
        sleep(5);
        cout <<"父进程发送SIGTERM信号给子进程" << endl;
        kill(pid, SIGTERM);
        wait(NULL);
    }
    return 0;
}

由此可见,那么操作系统通过kill函数发送了一个SIGTERM的信号,导致该进程被终止了,这也验证了,我们是可以通过调用kill函数来向进程发送信号的!那么这个raise也同理,这里我就不验证了。

补充:

头文件 #Include<sys/types.h>与#Include<signal.h>

int kill(pid_t pid,int sig);那么第一个参数就是要发送信号的进程pid,第二个参数就是要发送的信号,如果成功则返回0,失败就返回-1。

4、软件条件:

1、通过alarm函数发送信号:

进程可以通过调用alarm函数向自己发送SIGALRM信号,alarm函数安排内核在secs指定秒数内发送一个SIGALRM信号给调用进程,就相当于给进程设置一个闹钟,那么如果这个秒数是0的话那么不会调度新的闹钟,alarm函数调用都将取消任何待处理闹钟,返回待处理的闹钟再被发送前还剩的描述,如果没有任何待处理的闹钟就返回0。

#include<iostream>
#include <cstdio>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include<sys/wait.h>
using namespace std;
void alarm_handler(int sig)
{
    cout<<"收到ALARM信号,信号编号:"<<sig<<endl;
}
int main()
{
    int remaining;
    signal(SIGALRM,alarm_handler);
    cout<<"设置闹钟,3秒后响应"<<endl;
    remaining=alarm(3);//第一次设置闹钟
    cout<<"闹钟还剩"<<remaining<<"秒"<<endl;//这里会输出0,因为之前未设置果闹钟
    sleep(1);
    remaining=alarm(2);//第二次设置闹钟,新闹钟会取消旧闹钟
    cout<<"闹钟还剩"<<remaining<<"秒"<<endl;
    pause();//程序会卡在这里,直到收到SIGALRM信号
    return 0;
}

那么上面的代码能够说明什么?

新闹钟会取消旧闹钟,我们先设置闹钟为3秒后响应,然后我们sleep一秒再重新设置闹钟为2秒后响应,那么此时新闹钟会取消旧闹钟,这里pause的作用是让进程挂起,等待信号,当收到信号后调用我们自己写的alarm_handler(),然后输出对应的信号,这也间接地证明了,调用alarm函数,操作系统会向该进程发送一个信号,并且这个信号是14号信号。

特性:与现实中的闹钟相同,设置时不会立即触发,只在到达设置时间后才触发。

闹钟管理原理:

内核实现机制:

数据结构:内核采用结构体来管理闹钟,包含超时时间,进程指针等。

组织方式:采用最小堆等高级数据结构来管理大量闹钟,定期检查堆顶元素。

触发条件:当系统时间大于等于预设时间时,向对应进程发送SIGALRM信号。

而这一实现,完全符合操作系统中先描述再组织的设计哲学,先使用结构体来进行描述,然后使用高级数据结构将它们组织起来。

2、管道读端关闭产生信号:

管道通信需要两个进程配合,一个负责读取数据(也就是我们所说的读端),一个需要写入数据(也就是我们所说的写端),那么如果把读管进程关闭文件描述符,而写端继续不断写入会导致操作系统进行干预。操作系统不允许无意义的资源浪费,无数据读取,却不断写入数据会被视为无效操作,那么此时操作系统会向写入的进程发送SIGPIPE(13号)信号强制终止进程。

#include<iostream>
#include <unistd.h>
#include <signal.h>
#include <string.h>
using namespace std;
void pipe_handler(int sig) {
    printf("捕获到SIGPIPE信号: %d\n", sig);
}

int main() {
    int fd[2];
    char buf[100];
    
    // 设置SIGPIPE信号处理
    signal(SIGPIPE, pipe_handler);
    
    // 创建管道
    pipe(fd);
    
    if(fork() == 0) {
        // 子进程:关闭读端后立即退出(读端关闭)
        close(fd[0]);
      cout<<"子进程关闭读端后退出"<<endl;
        return 0;
    }
    
    // 父进程:关闭读端,尝试向无读端的管道写入
    close(fd[0]);
    sleep(1); // 确保子进程先退出
    
   cout<<"父进程尝试向无读端的管道写入..."<<endl;
    int result = write(fd[1], "hello", 5);
    
    if(result == -1) {
        perror("写入失败");
    }
    
    return 0;
}

所以:信号本质就是该信号的触发条件完全由软件条件(读端关闭)引起,与硬件无关。

总结:

软件条件本质:

触发条件:完全由程序状态等软件因素所决定

管理方式:操作系统通过维护数据结构来检测这些条件。

与硬件不同的是:整个过程无任何硬件参与。

5、硬件异常产生信号:

1、除0错误:

观察下面的代码,如果在linux环境中运行,会发生什么现象呢?能否正常运行呢?

#include<iostream>
#include <unistd.h>
#include <signal.h>
#include <string.h>
using namespace std;
int main()
{
    int a=10;
    int b=0;
    b=a/b;
    cout<<b<<endl;
    return 0;
}

我使用cout<<打印b的值了啊,为什么没有输出呢?却输出了一个浮点数例外,这是为什么啊?

答案就是发生了/0错误,所以当发生这个错误的时候,那么操作系统就会向该进程发送一个信号来告诉进程,进程:你里面发生错误了!那么发送的这个信号默认行为就是终止该进程,所以输出b这条语句并不会被执行,所以,我们就看不到b的值了。

那么这个信号是几号信号呢?答案是SIGFPE,也就是8号信号,那么如何来验证这一点呢?

下面我将写几行代码来验证这一点:

#include<iostream>
#include <unistd.h>
#include <signal.h>
#include <string.h>
using namespace std;
void flo_handler(int sig)
{
    if(sig==8)
    {
      cout<<"我收到了一个信号,这个信号是"<<sig<<"号信号"<<endl;
    }
}
int main()
{
    signal(SIGFPE,flo_handler);
    int a=10;
    int b=0;
    b=a/b;
    //cout<<b<<endl;
    return 0;
}

这里还有一个细节需要注意:

我代码里面也没有写循环啊,为什么会一直输出:我收到了一个信号,这个信号是8号信号呢?

信号处理函数执行完毕后,进程会回到原来被中断的地方继续执行,如果那个地方仍然会触发信号,就会形成死循环。

这就是为什么会一直输出这句话的原因,所以如果不想一直输出这句话,我们只需要在处理信号函数最后加上一个exit(0)即可。

深入理解:

寄存器内容与进程上下文:CPU寄存器物理上 只有一套,被所以进程所共享,进程切换会保存/恢复寄存器状态(包括状态寄存器),溢出标志位作为进程上下文的一部分被持续保留,并且,这个状态寄存器由CPU硬件自主维护,用户程序无法进行修改,那么这个进程无法自行清除溢出标志,以至于每次恢复上下文时异常状态都会重新加载,所以这个地方会无限输出这句话。

那么为什么这么设置呢?答案是保证系统能够持续处理顽固性硬件错误!

这样就不会重复打印了。

接下来,再看一个由硬件异常产生信号的例子:

2、野指针问题产生信号:

野指针:指针变量指向无效内存。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string.h>
using namespace std;

int main()
{
    int *ptr = NULL;
    *ptr = 10;
    return 0;
} 

这个代码从c/c++语法上是一个典型的错误,就是写出了野指针,那么在操作系统层面上来看,为什么这么写是不对的呢?

11号信号:进程访问内存越界,无权限访问。

那么为什么这么写可以运行, 但是会直接退出呢?答案是操作系统就该进程发送了一个信号,而这个信号的默认动作就是终止该进程,那么如何来验证这个信号是11号信号呢?

下面,我将通过在此基础上加上几行代码来验证这个信号就是11号信号:

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string.h>
using namespace std;

void Seghandler(int sig)
{
    cout << "我收到了这个信号,这个信号是" << sig << "号信号" << endl;
    exit(0);//注意这里要加上退出,否则会出现之前的问题,会一直循环打印!
}
int main()
{
    signal(SIGSEGV, Seghandler);
    int *ptr = NULL;
    *ptr = 100;
    return 0;
}

这里野指针产生信号的问题就写到这,既然提到了指针,那么下面就再次从操作系统的层面上重新理解一下指针:

指针的深入剖析:

进程内存和管理基础:

PCB和页表,每个进程都有自己的PCB和,页表负责构建虚拟地址空间到物理内存的映射关系。

地址分类:

虚拟地址空间:进程使用的地址空间。

虚拟地址:虚拟空间上的具体地址。

物理地址:通过页表进行映射后实际访问的内存地址。

所以指针的本质:如int * ptr,ptr本身是变量,存储的是虚拟地址。对*ptr进行操作时。实际上是通过ptr存储的虚拟地址访问内存,指针解引用时访问的是虚拟地址空间密码的地址,所以,指针的本质就是虚拟地址,通过虚拟地址访问物理内存!

那么这个所谓的地址是如何进行转换的呢?通过mmu这个硬件组件(集成在CPU上),负责进行地址转换。

页表:存储虚拟地址到物理内存的映射关系。

那么上面的野指针问题是如何一步一步产生的?

答案是先由MMU检测到非法访问,再由MMU产生硬件异常,其次操作系统会捕获异常,然后发送SIGEGV(11号信号给进程),那么这个信号默认处理方式为终止,所以产生野指针的时候,进程会直接终止。

核心转储:

进程异常终止时,操作系统将其内存状态保存到磁盘文件的过程,这个文件包含了进程崩溃时的完完整内存映像。其意义是为了方便我们后续的调试,以及再没有原始环境的情况下重现问题。

打开ulinit  -a,可以查看所有资源的限制,然后使用ulimit -c 1024,设置核心转储文件大小最大为1024个块,在生成核心转储文件之前,我们需要确保核心转储是启用的。设置完之后,我们再来运行我们之前的野指针代码,我这里把报错信息改成了英文的,之前一直是中文的。

我么可以看到在原来的报错后面多了个core dumped,这个core dumped就是我们所说的核心转储。

再ll,这个就是我们的核心转储文件:

使用gdb Testsignal(野指针的代码)之后再使用core-file core.2975,就可以看到我们的错误信息了。

直接精准定位到了156行。

总结:

为什么所有的信号产生最终都要有操作系统来进行执行?因为操作系统是位于硬件与软件之间的一种软件,换句话说,操作系统是进程的管理者,每个进程都要被这个管理者所管理。

信号如果不是立即被处理,那么信号是否需要被进程所记录,那么如果是记录下来,记录在哪里最为合适呢?

所以,在继续向下讲之前,我先介绍一种数据结构-------位图。

位图:

位图是一种数据结构,有些书里面可能称之为位数组,下面的内容里就称为位图了,那么位图是一种以空间换时间的数据结构,它使用bit位来表示某个值是否存在,每个bit位对应一个可能的值,如果该位为1,表示该值存在,如果为0,则宝石不存在,需要注意的就是位图的大小取决于值的取值范围。

在c++里面,我们通常使用一个vector来进行实现,如果对vector不够了解的话,可以看一下这篇文章:https://blog.csdn.net/2501_91607282/article/details/148427618?spm=1001.2014.3001.5501

四、信号的发送:

概念的澄清:并非真正的发送

在Linux信号机制中,“发送信号”这一术语是一个极易引起误解的抽象。它并非像管道那样,在两个进程的地址空间之间进行实际的数据传输。其本质,是由操作系统内核(作为管理者)直接修改目标进程内部内核数据结构的一个特定操作。信号的“产生”与“记录”是近乎瞬时完成的。

2. 核心本质:对三大数据结构的操作

所谓“发送信号”,实质上是内核执行的一个系列动作,核心是修改目标进程task_struct中的以下三个关键部分:

第一步:设置未决状态——修改pending位图
内核将目标进程pending信号集中对应此信号的比特位由0置为1。此操作仅表示信号已到达进程,处于等待处理的状态,并不代表会立即执行。

第二步:检查阻塞状态——查询block位图
内核检查该信号在目标进程的block(阻塞)位图中对应的位。
若该位为1,表示进程当前主动屏蔽此信号。内核不会进行任何额外操作,信号将持续保持其“未决”状态,直到进程解除阻塞。
若该位为0,则信号具备被处理的资格。

第三步:触发处理流程——关联handler数组
当信号未被阻塞,且进程从内核态返回用户态前,内核会检测到这些未决且未被阻塞的信号。
此时,内核依据信号编号作为索引,查询handler函数指针数组,以决定最终行为:是执行默认操作(SIG_DFL)、忽略(SIG_IGN),还是调用用户自定义的函数。

3. 总结:

可以将“信号发送”理解为一个简明的决策过程:

置位目标进程pending位图 → 检查该信号是否被阻塞 → 若未阻塞,则在合适时机触发handler数组中定义的行为。

五、信号的保存:

1、信号在内核中的数据结构:

在linux内核中,是通过三个数据结构来表示信号的,下面我将一一介绍:

pending:未决的。当信号被置于pending位图中时,表示该信号处于未决状态。

储存位置:每个进程创建时在内核的task_struct结构体中包含pending位图,初始状态下,位图默认为0,共31个bit位,对应linux标准信号数量。

比如:

一个进程收到3号信号,那么pending位图就会把第三位从0变为1,表示该信号已经到达,等待处理,处理完了就会把第三位从1再置为0,表示处理完了。

block:阻塞的,底层也是一个位图的数据结构,决定着哪些信号暂时不能被接收。

当某个信号被阻塞时,该位被置为1,即使这个信号被发送到进程,也是被暂时保存起来,不会进行处理。

handler数组:

这个handler就是一个函数指针数组,当handler指向用户函数时,内核会保存当前用户态的上下文,以及修改用户态函数栈帧,使其返回到信号处理函数,并设置sigreturn的调用路径用于恢复。

handler:这个指针数组的每个下标都是一个信号的执行方式。至于上面所说的用户态和内核态,后续会详细讲!上面的三种数据结构如果用文字来描述可能有些抽象,下面将用一张图来描述一下:

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志1也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

struct sigaction 内核组成:

struct sigaction {
    __sighandler_t sa_handler;      // 主要的处理函数指针
    unsigned long sa_flags;         // 标志位控制行为
    sigset_t sa_mask;               // 执行时的阻塞掩码
    void (*sa_restorer)(void);      // 恢复函数(内部使用)
};

那么接下来,来看一段代码:

#include<iostream>
#include<cstdio>
#include<sys/wait.h>
#include<unistd.h>
#include<stdlib.h>
#define MAXSIZE 1024
using namespace std;
void handler1(int sig) 
{
    pid_t pid;

    if ((pid = waitpid(-1, NULL, 0)) < 0)
	perror("waitpid error");
    printf("Handler reaped child %d\n", (int)pid);
    sleep(2);
    return;
}

int main() 
{
    int i, n;
    char buffer[MAXSIZE];

    if (signal(SIGCHLD, handler1) == SIG_ERR)
	perror("signal error");

    for (i = 0; i < 3; i++) {
	if (fork() == 0) { 
	    printf("Hello from child %d\n", (int)getpid());
	    sleep(1);
	    exit(0);
	}
    }

    if ((n = read(STDIN_FILENO, buffer, sizeof(buffer))) < 0)
	perror("read");

    printf("Parent processing input\n");
    while (1)	; 

    exit(0);
}

我明明发送了三个信号给父进程啊,怎么就给我回收两个呢?剩下那一个为什么没有回收呢?

pid为3176的子进程不但没有回收,怎么还变成僵尸进程了?

问题出在哪里呢?问题就在于我们的代码没有考虑信号可以阻塞和不会排队等待这样的情况。父进程接收并捕获第一个信号,当handler1还在处理第一个信号时,第二个信号就被传送并添加到待处理信号集合里。但由于SIGCHLD信号被SIGCHLD处理程序阻塞,第二个信号不会被接收。此后不久,当handler1还在处理第一个信号时,第三个信号到达。因为已经有一个待处理的SIGCHLD信号,第三个SIGCHLD信号会被丢弃。一段时间之后,处理程序返回,内核注意到有一个待处理的SIGCHLD信号,就迫使父进程接收这个信号。父进程捕获这个信号,并第二次执行handler1。在处理程序完成对第二个SIGCHLD信号的处理之后,已经没有待处理的SIGCHLD信号了,因为第三个SIGCHLD信号的所有信息都已经丢失了。由此,在编程中要注意:信号不可用于对进程中发生的事件计数。

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

2、信号集操作函数:

在介绍信号集操作函数之前先来介绍一下sigset_t类:

本质:

一个不透明的数组,每个信号对应一个二进制位(1-31对应标准信号,32-64对应实时信号)

大小为128字节。

#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 sigismemberconst sigset_t *set, int signo);

这五个函数是Linux信号编程中的基础工具箱,用于操作sigset_t类型的信号集合,并不会直接修改进程的信号状态,而是为sigprocmask,sigaction等系统调用做准备数据。

1、sigemptyset:

初始化set为空集,所有信号位清0。

内部机制为将sigset_t所有位设置为0,使用前提必须在其他操作前调用,避免未定义行为,新声明的sigset_t变量内容不确定,必须初始化。

2、sigfillset 

信号集填满

所有信号位被置1

应用场景:

需要阻塞所有信号的关键代码段

配合sigdelset排除特定信号。

需要注意的就是:即使填满了,SIGKILL和SIGSTOP仍不可阻塞

3、sigaddset :

信号添加:将指定信号对应的位设置为1。

4、 sigdelset:

信号删除。

5、sigismember:

测试成员是否在集合中。

返回值:返回1(存在)、0(不存在)、-1(错误)。

补充:

sigprocmask 函数改变表示当前已阻塞信号的集合的 blocked 位向量,具体的行为依赖于 how 的值。
SIG_BLOCK:添加 set 中的信号到 blocked 中(blocked = blocked | set)。
SIG_UNBLOCK:从 blocked 中删除 set 中的信号(blocked = blocked &~set)。
SIG_SETMASK:blocked = set。
如果 oldset 非空,blocked 位向量以前的值会保存在 oldset 中。

sigaction

sigaction:

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

signum:要处理的信号编号 act:sa_handler:信号处理函数指针 sa_mask:处理期间要阻塞的信号集合  sa_flags:行为控制标志  oldact:保存原来的信号处理方式(可设为NULL)。

下面我将使用上述的几个函数为大家写一段简单的代码,帮助大家更好地理解:

#include <iostream>
#include <signal.h>
#include <unistd.h>
using  namespace std;
// 简单的信号处理函数
void handler(int sig)
{
    cout << sig << endl;
}

int main()
{
    sigset_t block_set, old_set;

    cout << "信号阻塞测试开始" << endl;

    // 设置信号处理
    signal(SIGINT, handler); // Ctrl+C

    // 1. 创建空的信号集
    sigemptyset(&block_set);

    // 2. 添加SIGINT到阻塞集
    sigaddset(&block_set, SIGINT);

    // 3. 阻塞SIGINT
    sigprocmask(SIG_BLOCK, &block_set, &old_set);

    cout << "SIGINT已被阻塞 - 现在按Ctrl+C不会起作用" << endl;
    cout << "等待5秒..." << endl;

    // 等待期间按Ctrl+C会被阻塞
    sleep(5);
    cout << "解除SIGINT阻塞..." << endl;

    // 4. 恢复原来的信号掩码
    sigprocmask(SIG_SETMASK, &old_set, NULL);
    cout << "现在可以按Ctrl+C来终止程序了" << endl;

    // 等待信号
    while (1)
    {
        pause();
    }

    return 0;
}

这个代码虽然很简单,但是却很好地演示了一个过程,首先我先把2号信号设计为阻塞,什么是阻塞呢?就是这个信号会递达,但是并不会立即处理,所以这个时候我从键盘输入ctrl+c并不会去捕捉2号信号,也就是调用handler(),但是在等待了5秒钟后,我解除对2号信号的阻塞,那么就可以顺利地调用这个2号信号了,而此时我们对2号信号进行了捕捉,所以它的默认操作不再是退出,而是打印sig的值,这里由于ctrl+c与这个2混在了一起,所以观察的不太明显,但实际上这就是在调用handler这个函数,因为此时我们已经恢复了2号信号,不再阻塞它了。

具体的过程如下:

3、深入理解信号的捕捉过程:

很多初学者认为信号捕捉像是在'拦截'信号,但实际上这是一种误解。信号捕捉的真正本质是重新定义信号的处理行为。信号捕捉并不是真正意义上的捕捉,而是对信号进行重定义,也就是说,当我们对信号进行捕捉的时候,那么这个信号对应的操作不再是执行默认的操作,而是按我们自己所设定的操作去执行。这个过程我们就称之为信号捕捉,所以,信号捕捉的本质实际上是对信号进行重定义。这里的重定义与c++语法中的不同,这里的重定义,我们需要理解为重新定义。

当我们调用 signal(SIGINT, handler) 时,并不是在捕捉SIGINT信号,而是在告诉操作系统:'以后收到SIGINT时,请执行我的handler函数,而不是你默认的终止操作。这是一种典型的行为重定义,体现了Unix机制与策略分离的设计哲学。

那么这个信号的捕捉具体过程是怎么样的呢?

在此之前,我们先了解一下用户态和内核态:

用户态(User Mode)是普通应用程序运行的模式。在该模式下,程序只能执行非特权指令,不能直接访问硬件设备或敏感的系统资源。如果程序需要执行特权操作,必须通过系统调用接口请求操作系统内核代为完成。这种限制保护了系统稳定性,防止应用程序错误影响整个系统。

内核态(Kernel Mode)是操作系统内核运行的特权模式。在该模式下,代码可以执行所有处理器指令,包括特权指令,能够直接访问硬件设备和所有内存区域。内核负责管理系统资源、处理设备I/O、进行进程调度等核心功能。

两种模式之间的切换通过特定的机制实现。当用户态程序需要执行特权操作时,会通过软中断或系统调用指令触发从用户态到内核态的切换。处理器会自动保存用户态上下文,切换到内核栈,并跳转到相应的内核处理程序。当内核完成服务后,会恢复之前保存的用户态上下文,返回到用户态继续执行应用程序。

信号捕捉过程中,当信号的处理行为是用户自定义函数时,其完整的递达流程涉及四次用户态与内核态之间的切换:

第1次切换:

用户态 → 内核态

触发条件:当前进程由于中断、异常或系统调用而陷入内核。

内核操作:在处理完这些事件后,在返回用户态之前,内核会检查该进程是否有待处理且未被阻塞的信号。

第1次切换后,内核决定处理信号:如果检查到有需要递达且动作为用户自定义处理函数的信号,内核不会立即恢复原用户进程的上下文,而是准备执行信号处理函数。

第2次切换:

内核态 → 用户态(执行处理函数)

内核操作:内核会构建一个临时的用户态环境,将返回地址设置为用户注册的信号处理函数(例如sighandler),并设置好必要的参数。

切换目的:进程返回用户态,但并非回到原程序被中断的位置,而是开始执行信号处理函数sighandler。信号处理函数与main函数是两个独立的控制流,使用不同的堆栈空间。

第3次切换:

用户态 → 内核态(清理现场)

触发条件:用户信号处理函数sighandler执行完毕并返回时,会自动调用一个特殊的系统调用sigreturn(在较新的系统中可能是rt_sigreturn)。

内核操作:再次进入内核态后,sigreturn系统调用会清理信号处理过程中使用的临时数据和栈帧,恢复进程在第一次进入内核时保存的原始上下文。

第4次切换:

内核态 → 用户态(恢复执行)

内核操作:内核使用恢复的原始上下文。

切换目的:进程返回用户态,此时才恢复main函数的执行上下文,从当初被信号中断的位置继续执行。

系统调用的作用:

核心功能:作为用户态访问内核资源的唯一合法通道。

性能特点:

高开销的原因:需要完成身份切换和上下文保存/恢复。

比如:STL空间适配器采用预分配机制减少调用频次。

那么什么情况下需要系统调用,什么情况下不需要系统调呢?

如果涉及资源申请(如内存),硬件访问(IO),那么就需要进行系统调用,否则不需要进行系统调用,纯CPU计算即可完成这个操作。

3、信号保存涉及的硬件:

寄存器分类:根据是否可以直接编程访问分为可见与不可见寄存器。

可见寄存器:程序员可直接操作的寄存器如eax、ebx、ecx等通用寄存器。

例子:

通用寄存器:eax、ebx、ecx、edx。

指针寄存器:eip(程序计数器) ebp(栈底指针) esp(栈顶指针)

特点,在c/c++中等编程中可直接使用,参与运算和内存访问。

不可见寄存器:CPU内部使用,程序员不可直接访问的寄存器。

例子:状态寄存器,通过比特位记录CPU运行状态。

自动由CPU设置,程序可通过条件跳转指令间接使用。

寄存器与进程的关系:

上下文数据:这在进程那里我们讲解过,寄存器中保存的与当前进程直接或间接相关的数据。并且寄存器还具有强相关性,当寄存器内容与进程执行状态密切相关时即构成进程上下文。

进程上下文切换:CPU寄存器只有一套,但每个进程都有自己的寄存器值集合,进程切换时保存当前寄存器值到PCB,恢复新进程的寄存器值,需要注意的是就是寄存器的值随进程切换而切换,实现进程隔离。

那么问题来了,用户态进程如何执行内核代码呢?是通过系统调用接口先从用户态切成内核态再去执行,防止你对操作系统搞破坏(为操作系统的安全性考虑),所以在执行之前,必须切成内核态。

4、再谈地址空间:

为什么要再谈地址空间,首先我们在信号这里可以更好地进行理解状态切换,也为后续我们学习线程奠定主要基础。

进程是通过系统调用或者访问硬件资源将自身从用户态切位内核态,实际上执行系统调用的还是进程,只不过此时它的身份变了,不再是用户态了,而是切到了内核态。在继续向下讲之前先来看一张图,这张图涵盖了主要框架,下面我在此基础上进行补充与说明:

如何区分用户态和内核态呢?我们之前已经说了寄存器,现在具体说说是如何通过寄存器来表示当前的形态的,当访问3-4GB空间时,OS(操作系统)会检查CR3寄存器,如果状态为3就是我们的用户态,直接终止其访问,如果为0也就是内核态,那么就允许其访问,而这个过程在软件层是通过我们的int 80指令来完成的。什么时候才会进行状态切换?其一就是进行系统调用,其二就是访问硬件资源这在上面已经说了。

六、信号的递达:

1 、递达的时机:

信号递达并非在信号产生或发送时立即执行,而是在特定的内核时机被触发。核心时机如下:

从内核态返回用户态之前:这是最常见、最关键的递达时机。当进程因系统调用、中断或异常进入内核态,在处理完这些事件后,即将返回用户态恢复进程执行前,内核会进行信号检测与处理。

进程被调度执行时:在进程调度器中,一个进程被选中并切换到CPU上运行时,内核可能会检查并处理其待处理的信号。

显式解除信号阻塞时:当进程使用sigprocmask等函数解除对某个信号的阻塞后,内核会立即检查该信号是否处于未决状态,如果是,则可能立即递达。

2 、递达的处理方式:

递达的处理方式分为三种:

默认、自定义、忽略:

默认处理:执行系统预定义的行为。常见默认行为包括终止进程、终止并生成核心转储、忽略信号或暂停进程。

忽略处理:内核直接清除该信号的未决状态,不做任何其他操作,进程继续执行。

自定义处理:内核需要切换到用户态,执行进程预先注册的信号处理函数。这是最复杂的一种处理方式,涉及用户态与内核态的多次切换。

3 、递达的核心过程:

信号递达的核心过程可以概括为以下几个步骤:

检查未决信号:内核检查进程的pending位图,寻找值为1的位。

检查阻塞状态:对于每个未决信号,检查其在blocked位图中对应的位。如果该位为1,表示信号被阻塞,暂不处理。

执行处理动作:对于未阻塞的信号,根据handler数组中的函数指针,执行相应的处理操作。

清除未决状态:在处理动作执行完成后,内核会将该信号在pending位图中的对应位清零,表示该信号已处理完毕。

七、可重入函数与不可重入函数:

可重入函数

函数执行过程中可以被中断,之后再次被调用不会影响正确性

不依赖全局变量、静态数据、硬件资源等共享状态

仅使用局部变量和传入参数

不可重入函数:

函数执行过程中如果被中断并重新进入,可能导致数据错乱

通常使用了全局变量、静态数据、malloc/free等非线程安全操作

再来看本篇文章最后一段代码:

#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
// 全局计数器
int count = 0;

// 危险函数:操作全局变量
void dangerous()
{
    count = count + 1; // 第一步:读取count
    sleep(1);          // 在这里可能被信号中断!
    count = count + 1; // 第二步:修改count
    cout << "count = " << count << endl;
}

// 安全函数:只用局部变量
void safe()
{
    int local_count = 0; // 局部变量,每次调用都是新的
    local_count = local_count + 1;
    sleep(1);
    local_count = local_count + 1;
    cout << "local_count = " << local_count << endl;
}

// 信号处理函数
void signal_handler(int sig)
{
    cout << "信号发生!" << endl;
    dangerous(); // 在信号处理中调用危险函数
}

int main()
{
    signal(SIGALRM, signal_handler);

    cout << "程序开始" << endl;

    // 设置2秒后发信号
    alarm(2);

    cout << "调用dangerous函数:" <<endl;
    dangerous();

    cout << "调用safe函数:" << endl;
    safe();

    return 0;
}

主程序开始执行 dangerous()执行到 count = count + 1 (此时count=0)

进入 sleep(1) 时,信号来了!

信号处理函数也开始执行 dangerous()

信号处理中的 dangerous() 把count从0变成1,再变成2

主程序从sleep醒来,继续执行 count = count + 1

但此时count已经是2了,不是原来想的1!结果count变成了3,而不是期望的2

换句话讲:信号处理就像随时可能有人闯进来,所以必须用独立包间(可重入函数)。

至此,本章内容全部结束,后续还会持续更新Linux,本文篇幅过长,难免有所错误,如发现错误,可在评论区指出,本人也会进行持续更正。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐