信号的产生、处理

一、信号的概念

信号是linux系统提供的一种,向指定进程发送特定事件的方式。收到信号的进程,要对信号做识别和处理。信号的产生是异步的,进程在工作过程中随时可能收到信号。

信号的种类分为以下这么多种(用指令kill -l查看)

其中1-31号信号是普通信号,32-64号信号是实时信号(暂且不关注)。

二、信号的产生

1、通过kill命令,向指定的进程发送指定信号

通过代码验证:利用 man 2 signal查看接受信号的系统调用:

其中handler是一个函数指针,返回值为void,参数为int,int表示的是接收的是几号信号;该系统调用是将信号进行自定义处理,也就是捕捉信号,捕捉之后的信号一般都不会进行自己的默认处理(有例外)。下面用代码验证kill命令向进程发送的信号:

cpp 复制代码
#include<iostream>
#include<signal.h>
using namespace std;

void handler(int signal)
{
    cout<<"receive signal:"<<signal<<endl;
    exit(1);//异常退出

}

int main()
{

    sighandler_t n=signal(4,handler);
    while(1)
    {
        cout<<"I am working! pid:"<<getpid()<<endl;
        sleep(1);
    }

    return 0;
}

可以看到接受4号信号后,实现了自定义处理。

2、键盘产生信号

Ctrl + c 产生2号信号;Ctrl + \ 产生3号信号;Ctrl + z 产生20号信号

只需修改sighandler_t n=signal(2,handler);中的2为3 、20即可自定义处理不同的信号。

3、系统调用产生信号

①kill

kill(pid,signal) pid表示给指定的进程发送。signal表示发送几号信号。

cpp 复制代码
#include<iostream>
#include<signal.h>
using namespace std;

void handler(int signal)
{
    cout<<"receive signal:"<<signal<<endl;
    //exit(1);//表示异常退出

}

int main()
{

    sighandler_t n=signal(2,handler);
    int cnt=10;
    while(cnt--)
    {
        cout<<"I am working! pid:"<<getpid()<<endl;
        sleep(1);
    }
    kill(getpid(),2);
    //等价于 raise(2)
    //abort();//发送6号信号
    

    return 0;
}

②raise

raise(int signal) 表示向本进程发送signal号信号,代码如上

③abort

abort发送6号信号。

注意:

①为了防止有程序对所有信号都自定义捕捉而恶意不退出,有9号信号不允许自定义捕捉。

②如何理解信号的发送?

真正发送信号的是OS

信号在进程的task_struct结构体中其实是一个32位int位图的形式,这就是为什么普通信号有31个,且从1开始而不是从0开始。发送信号的过程其实是OS在修改指定进程pcb中的信号的指定位图。因为只有OS有这个权限去修改进程的内核结构对象,所以只有OS能发送信号。

4、软件条件

①在管道学习中,已知当管道读端关闭之后,写端如果还在写,则会发送13号信号SIGPIPE让写端进程退出。这是一种软件条件。(写端写入操作的条件不满足)

②闹钟:设定几秒后收到一个14号SIGALRM信号

cpp 复制代码
#include<iostream>
#include<signal.h>
#include<cstdlib>
#include<unistd.h>
using namespace std;

void handler(int signal)
{
    cout<<"receive signal:"<<signal<<endl;
    exit(1);//表示异常退出

}

int main()
{

    sighandler_t n=signal(14,handler);
    unsigned int ret=alarm(5);
    while(1)
    {
        cout<<"I am working! pid:"<<getpid()<<endl;
        sleep(1);
    }
    /*int cnt=5;
    while(cnt--)
    {
        cout<<"I am working! pid:"<<getpid()<<endl;
        sleep(1);
    }*/
    //kill(getpid(),9);
    


    //raise(2);
    //abort();//发送6号信号

    return 0;
}

OS在对一些超时的程序,会设定一个闹钟,超出这个时间之后就会向指定进程发送14信号终止。

时间:通过时间戳(整数)来比较。

操作系统要对闹钟做管理:先描述再组织

闹钟结构体struct alarm

{

time_t expired;//未来的超时时间=seconds+Now();

pid_t pid;

}

对于闹钟结构体,采用最大堆最小堆的组织方式,高效组织。这样以未来的超时时间作为基准进行堆排序,只要最近的超时的闹钟未超时,后面的闹钟就都不会超时。

闹钟的返回值表示的是上一个闹钟的剩余时间。

alarm(0):表示的含义是取消闹钟,返回上一个闹钟的剩余时间。

5、异常

①除0异常

当系统有除0异常时,OS会发送8号信号SIGFPE使进程退出,当运行以下代码时,系统会一直打印:

cpp 复制代码
#include<iostream>
#include<signal.h>
#include<cstdlib>
#include<unistd.h>
using namespace std;

void handler(int signal)
{
    cout<<"receive signal:"<<signal<<endl;
    //exit(1);//表示异常退出

}
int main()
{
    sighandler_t n=signal(8,handler);
    int a=4;
    int b=a/0;
    return 0;
}

②野指针异常

当系统有野指针异常时,OS会发送11号信号SIGSEGV使进程退出,当运行以下代码时,系统会一直打印

cpp 复制代码
#include<iostream>
#include<signal.h>
#include<cstdlib>
#include<unistd.h>
using namespace std;

void handler(int signal)
{
    cout<<"receive signal:"<<signal<<endl;
    //exit(1);//表示异常退出

}

int main()
{
    sighandler_t n=signal(11,handler);
    int * p=nullptr;
    *p=100;
    return 0;
}

③产生异常的原理

a、对于除0操作

计算都要通过CPU计算,CPU内部有一个寄存器eflag,内部有一个溢出标记位,当有除0操作时,CPU是将其转换为加法,而除0操作会一直加,该硬件就会出错,然后将错误传递给OS,OS接受到这种错误之后就会处理,处理方式就是向目标进程发送信号。

当我们自定义处理信号之后,为什么会一直打印?寄存器只有一套,但是寄存器里面的数据是属于每一个进程的;当我们自定义信号处理后进程不退出后,随着进程的切换调度,寄存器先是被其他进程使用,而当本错误进程又切换到运行队列中时,寄存器会恢复上下文,此时发现又出错,又交给OS,然后就会再次发送一次信号,所以就又会打印。

所以推荐接受信号后退出进程,否则就会一直卡在错误代码(这里是除0的代码)。退出进程后,寄存器的错误数据就会清空,进程自己的异常自己承受,不用让OS承担。

b、对于野指针操作

在CPU内部有一个硬件MMU,MMU将页表的虚拟地址转换为物理地址;CR3寄存器存储的是页表的地址,MMU+CR3得到物理地址;当给定指针p指向空的时候,又对其进行赋值,MMU+CR3转换的时候,接收到的是一个错误的虚拟地址,会将错误的虚拟地址放进CR2;此时OS发现了CR2内有错误的地址,就向进程发信号了。

三、信号的处理(基础)

1、默认处理与忽略

使用 man signal 7指令查看所有信号的默认处理方式

默认处理方式为终止进程的有Core Term两种,这两种的区别:

Term:异常终止;

Core:异常终止,但会形成一个debug文件。

对于云服务器来说,默认形成debug文件的功能是关闭的;

ulimit -a查看被关闭的功能;ulimit -c 10240打开core file功能,当系统错误时会形成debug文件(core文件),这里面存储的是进程退出前运行的信息,是进程退出的时候的镜像数据。

centos系统的core dump形成的文件,文件名是以进程id为结尾的,这可能会导致无数的core文件,因为每次运行的进程id可能不同;而ubuntu系统对该bug做了修正,每次都会覆盖。

为什么云服务器要默认关闭core file(核心转储)功能?当云服务器挂了之后,有一些监控程序会让该服务器自动重启,而如果一个程序每次重启就报错,就会形成大量core 文件,会将磁盘占满。

core文件如何使用:当程序出错退出时,在makefile 文件中如果带有一个 -g选项,那么后续gdb的时候输入core-file core 就可以定位哪一行出错。core是协助我们进行debug的文件,进行事后调试。

代码验证core dump标志位

core dump标志位为1说明形成了core文件。

代码:

cpp 复制代码
#include<iostream>
#include<signal.h>
#include<cstdlib>
#include <sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
using namespace std;

void handler(int signal)
{
    cout<<"receive signal:"<<signal<<endl;
    //exit(1);//表示异常退出

}
int Sum(int start,int end)
{
    int sum=0;
    for(int i=start;i<=end;i++)
    {
        sum /=0;
        sum+=i;
    }
    return sum;
}

int main()
{
    pid_t id=fork();
    if(id==0)
    {
        //child process
        int total=Sum(0,100);
        exit(0);
    }

    //father process
    int status;
    pid_t rid=waitpid(id,&status,0);
    if(rid==id)
    {
        //等待成功
        printf("exit code: %d,exit sig: %d,core dump:%d\n",(status>>8)&0xff,(status&0x7f),(status>>7)&0x1);
    }


    return 0;

}

/*int main()
{
    sighandler_t n=signal(11,handler);
    int * p=nullptr;
    *p=100;
    return 0;
}*/


/*int main()
{
    sighandler_t n=signal(8,handler);
    int a=4;
    int b=a/0;
    return 0;
}*/


/*int main()
{

    sighandler_t n=signal(14,handler);
    unsigned int ret=alarm(5);
    while(1)
    {
        cout<<"I am working! pid:"<<getpid()<<endl;
        sleep(1);
    }
    /*int cnt=5;
    while(cnt--)
    {
        cout<<"I am working! pid:"<<getpid()<<endl;
        sleep(1);
    }
    //kill(getpid(),9);
    


    //raise(2);
    //abort();//发送6号信号

    return 0;
}*/

结果:

2、自定义

利用signal系统调用,实现自定义处理,具体代码在前面。

四、信号的保存

1、一些新概念

实际执行信号的处理动作称为信号递达。

信号从产生到递达之间的状态,叫做信号未决。(临时在PCB中保存而未被处理)

有的信号会被进程阻塞。阻塞一个信号,那么对应的信号一旦产生,将永不递达,一直未决,直到主动接触阻塞。阻塞描述的是信号要不要被递达的特点。

注:①一个信号是否处于阻塞状态,和它是否未决并没有关系。②阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

2、信号保存的深入理解

(1)pending表(未决信号集)

一张位图(32位整数位图,但只用了31位),其中比特位的位置代表信号编号,比特位的内容代表信号是否收到(0 ->未收到,1->收到)。

(2)handler表

是一个函数指针数组。普通信号是1-31号数字编号,这些编号对应的handler数组的下标,OS为每一个进程都维护了这样一个handler表,例如收到2号信号就那种2号信号去索引信号处理方法。

在系统调用signal(2,handler)中,我们能够实现对2号信号的自定义捕捉的原理,其实是拿着2号信号的编号,将自己写的自定义函数指针填进2号信号对应的handler数组中的处理方法,而原默认处理方法就被覆盖了。

(3)block表

一张位图,和pending类型完全一样,其中比特位的位置代表信号编号,比特位的内容代表信号是否阻塞(0 ->未阻塞,1->阻塞)

两张位图+函数指针数组,实现了让进程识别信号。

五、信号集操作

sigset_t : linux系给用户提供的一个用户级的数据类型,但禁止用户手动设置该位图的值,而是提供了一系列信号机操作函数。

1、sigprocmask:对block位图进行修改的系统调用。

其中how:

其中old_set表示返回修改之前的位图。

返回值0成功,返回值-1失败;

2、sigpending:获取当前进程的pending位图

只用来获取,改变是通过产生信号的那5种方式。其中set是输出型参数。

3、代码验证

屏蔽2号信号;向2号信号不断发送,再获取pending信号集,就能看到由0变1的变化。再解除对2号信号的屏蔽,看到由1到0的变化。

cpp 复制代码
#include<iostream>
#include<signal.h>
#include<cstdlib>
#include <sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
using namespace std;

void handler(int signal)
{
    cout<<"receive signal:"<<signal<<endl;
    //exit(1);//表示异常退出

    //验证pending位图清0的操作是在handler处理前还是处理后
    cout<<"-------------------"<<endl;
    sigset_t pending_bit;
    sigpending(&pending_bit);
    PrintPending(pending_bit);

    cout<<"-------------------"<<endl;

}
void PrintPending(sigset_t& pending_bit)
{
    for(int i=31;i>=1;i--)
    {
        if(sigismember(&pending_bit,i))
        {
            cout<<1;
        }
        else cout<<0;
    }
    cout<<endl;
}


int main()
{
    sighandler_t n=signal(2,handler);
    sigset_t new_bit;
    sigset_t old_bit;
    sigemptyset(&new_bit);
    sigemptyset(&old_bit);

    sigaddset(&new_bit,2);
    //屏蔽2号信号
    sigprocmask(SIG_BLOCK,&new_bit,&old_bit);
    int cnt=20;
    while(1)
    {
        sigset_t pending_bit;
        sigpending(&pending_bit);
        cout<<"pid:"<<getpid()<<"pending:";
        PrintPending(pending_bit);
        cnt--;
        if(cnt==0)
        {
            //解除对2号信号的屏蔽
            cout<<"解除对2号信号的屏蔽: ";
             sigprocmask(SIG_SETMASK,&old_bit,&new_bit);

        }
        sleep(1);
    }
    return 0;
}

解除屏蔽,一般会立即处理当前被解除屏蔽且被pending的信号。

pending位图对应的信号在被递达之前清0。

六、信号的处理(底层理解)

三种处理方式:

cpp 复制代码
    signal(2,SIG_IGN);//忽略处理
    signal(2,SIG_DFL);//默认处理
    signal(2,handler);//自定义捕捉

1、信号处理的时机和信号捕捉

信号可能不会被立即处理,而是在合适的时候处理;合适的时候指的是进程从内核态返回到用户态的时候。

当OS从内核态准备切换到用户态之前,先处理当前进程中可以递达的信号,查看信号的handler指针数组;如果是SIG_DFL,大部分就直接退出进程了;如果是SIG_IGN,那么就直接忽略;而如果是自定义捕捉,就要回到用户态执行信号处理函数(而不是回到主控制流程)。

OS不能在内核态直接执行信号处理函数,因为该函数内可能会对OS内核做修改,这会影响到OS的安全。所以该信号处理函数只能在用户态下执行。由于信号处理函数和main函数是两个执行流,不能切换,所以OS要继续回到内核态,然后再返回到用户态往下执行。

信号捕捉的过程:要经历4次状态的切换。

2、内核态和用户态

(1)深入理解地址空间------内核空间

对于每个进程,其【3,4】GB的地址空间是内核空间,其实就是OS;除了用户级页表之外,还有一个内核级页表,因为OS在电脑启动时就被加载到内存中了,是第一个软件,内核级页表指向的就是内存中的OS。这就是为什么,无论进程如何切换,我们都能找到OS。

我们进程访问OS,就是在本身的虚拟地址空间中访问的,和访问库函数(在共享区)和本身写的函数(在正文代码区)一样;区别是,因为OS要保证自己的安全,用户在地址空间内访问OS时,只能通过系统调用。

而内核级页表只需维护一份就够了,也就是说所有的进程的内核空间都是一样的,内核级页表在系统中只存在一份。

(2)键盘输入数据的过程

当键盘上有按键被按,则会通过硬件中断的方式触发CPU中断;每种设备(包括磁盘、网卡等)在事前已经被分配了中断号;当CPU触发中断时,其实是该设备对应的中断号被写入CPU寄存器中;OS在启动时已经构建了一张函数指针表,CPU则会根据中断号索引到函数指针表中的函数,键盘的中断号对应的函数就是将键盘中的数据读到内存中。

这套机制类似信号,其实是先有硬件中断,然后信号是根据这种机制用软件模仿出来的。

(3)如何理解OS正常运行?

OS是如何运行的:

操作系统的本质是一个死循环+时钟中断不断调度系统任务

如何理解系统调用:

系统提供了一张函数指针数组,只要找到特定数组下标的方法,就能执行系统调用了。具体的如何实现系统调用,如下图:

(4)内核态和用户态

也就是说,CPU保证了什么时候可以访问内核空间的地址。

注意:OS由用户态进入内核态并不一定是进行了系统调用,系统在调度时,当某个进程的时间片到时,会从调度队列中剥离下来从而从用户态进入内核态,该过程是可能发生在代码的任意位置的,所以在代码执行的任一位置都可能发生;而该进程再次被调度时则是从内核态回到用户态的过程,该过程会检测信号。

3、信号在处理时默认屏蔽本信号

另一种处理信号的方式:sigaction

其中与该函数同名的结构体结构:

代码如下:

cpp 复制代码
#include<iostream>
#include<signal.h>
#include<cstdlib>
#include <sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
using namespace std;

//sigaction的使用 signal mask的用处
//当前如果正在处理2号信号,默认2号信号会被屏蔽
//对2号信号处理完成的时候,会自动解除对2号信号的屏蔽 避免递归处理

void PrintPending(sigset_t& pending_bit)
{
    for(int i=31;i>=1;i--)
    {
        if(sigismember(&pending_bit,i))
        {
            cout<<1;
        }
        else cout<<0;
    }
    cout<<endl;
}
void handler(int signal)
{
    cout<<"receive signal:"<<signal<<endl;
    //sleep(100);
    while(1)
    {
        sigset_t pending;
        sigpending(&pending);
        PrintPending(pending);
        sleep(1);
    }
    exit(1);
}

int main()
{
    struct sigaction act,oact;
    sigemptyset(&act.sa_mask);//设置处理2号信号时对2号屏蔽的同时,对其他信号也进行屏蔽
    sigaddset(&act.sa_mask,3);
    //有很多信号无法被屏蔽 
    act.sa_handler=handler;

    sigaction(2,&act,&oact);
    while(1)
    {
        cout<<"I am a process,pid:"<<getpid()<<endl;
        sleep(1);
    }
    return 0;
}

代码结果:

从结果中可以看出,在sa_mask中加入3时,处理2号信号的过程中3号信号也被屏蔽了。

当前如果正在处理2号信号,默认2号信号会被屏蔽;对2号信号处理完成的时候,会自动解除对2号信号的屏蔽,这是为了避免递归处理。

七、可重入函数与volatile

1、可重入函数

在执行头插时,有两行代码;在执行第一行代码时发生信号捕捉(OS从用户态进入内核态的方式不止系统调用一种);而信号捕捉内部也有头插,这样最后得到的结果中,信号捕捉内部头插的节点则会丢失;这种函数称为不可重入函数。

STL的绝大多数函数,都是不可重入函数。函数是否可重入是函数的特点,对于不可重入函数,在多线程时就需要注意;可重入函数一般只会使用内部的局部变量而不会使用全局变量。

2、volatile

代码:

cpp 复制代码
#include<iostream>
#include<signal.h>
#include<cstdlib>
#include <sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
using namespace std;

int gflag=0;
//volatile int gflag=0;

void changedata(int signo)
{
    cout<<"receive signo:"<<signo<<endl;
    gflag=1;

}

int main()
{
    signal(2,changedata);
    while(!gflag);//while不要其他代码
    cout<<"process exit normal"<<endl;
    return 0;
}

正常编译运行后结果:

但是,编译器知道在main函数执行流中没有对 gflag做修改

CPU有两种运算:算数运算和逻辑运算

对于while(!gflag)语句,其实是CPU不断对gflag做检测,而gflag是在内存中的数据,那么CPU进行判断的话就需要不断的将gflag加载到CPU中

但编译器是可以优化编译的, -O查看所有优化级别

将优化级别改为-O1,就会出现下列问题,即发送2号信号,修改gflag之后进程仍然无法退出。

这是因为优化之后,编译器发现main函数执行流没有对gflag做修改,那么直接将gflag的值放到寄存器中;修改gflag的值只是修改内存中的gflag,而对寄存器中的值没有影响。这是寄存器隐藏了内存中的真实值

这是编译器的优化导致的问题。如何保持内存的可见性?利用volatile关键字。改完之后运行结果:

八、SIGCHILD信号

子进程退出时,会给父进程发送sigchild信号。sigchild的默认处理是ignore。

验证sigchild的存在:

cpp 复制代码
#include<iostream>
#include<signal.h>
#include<cstdlib>
#include <sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
using namespace std;


//子进程退出时会给父进程发sigchild信号

void DoOtherthing()
{
    cout<<"I am doing other thing"<<endl;
}
void notice(int signo)
{
    cout<<"get signal: "<<signo<<" pid: "<<getpid()<<endl;
    pid_t rid=waitpid(-1,nullptr,0);//waitpid的第一个参数为-1表示等待任意一个子进程
    if(rid>0)
    {
        cout<<"wait child success,pid:"<<rid<<endl;
    }
}

int main()
{
    signal(SIGCHLD,notice);
    pid_t id=fork();
    if(id==0)
    {
        //child process
        cout<<"I am child process,pid:"<<getpid()<<endl;
        sleep(5);
        exit(1);
    }
    //父进程
    while(1)
    {
        DoOtherthing();
        sleep(1);
    }
    return 0;
}

代码结果:

但以上在信号捕捉中进程等待的问题是:

如果有多个子进程,这些子进程同时退出,那么以上代码只会等待一次,只会回收一个子进程;所以要将代码改成while循环的形式不断回收;

如果有多个子进程,有些进程退出,有些进程永远不退出。此时waitpid就会阻塞,但由于现在是在信号捕捉的逻辑中,那么就会一直停在这而不会去执行main函数中的主逻辑。此时应该选择非阻塞方式等待。代码改成下面的形式:

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <cstdlib>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
using namespace std;

// 子进程退出时会给父进程发sigchild信号

void DoOtherthing()
{
    cout << "I am doing other thing" << endl;
}
void notice(int signo)
{
    cout << "get signal: " << signo << " pid: " << getpid() << endl;
    while (1)
    {
        pid_t rid = waitpid(-1, nullptr, WNOHANG); // waitpid的第一个参数为-1表示等待任意一个子进程
        if (rid > 0)
        {
            cout << "wait child success,pid:" << rid << endl;
        }
        else if(rid<0)
        {
            cout << "wait child success done" << endl;

            break; // rid<0说明等待失败,父进程已经没有子进程等待了
        }
        else{
            cout << "wait child success done" << endl;

            break; // 非阻塞等待方式

        }
    }
}

int main()
{
    signal(SIGCHLD, notice);
    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            // child process
            cout << "I am child process,pid:" << getpid() << endl;
            sleep(1);
            exit(1);
        }
    }

    // 父进程
    while (1)
    {
        DoOtherthing();
        sleep(1);
    }
    return 0;
}

由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。

等待的目的:①获取子进程的退出信息②使子进程不再是僵尸进程

如果不关心子进程的退出信息,则可以直接设置对SIGCHILD信号的处理动作为SIG_IGN,这是最简单的方式。系统默认的IGN和用户设置的SIG_IGN是不一样的

相关推荐
IC 见路不走34 分钟前
LeetCode 第91题:解码方法
linux·运维·服务器
翻滚吧键盘1 小时前
查看linux中steam游戏的兼容性
linux·运维·游戏
小能喵1 小时前
Kali Linux Wifi 伪造热点
linux·安全·kali·kali linux
汀沿河1 小时前
8.1 prefix Tunning与Prompt Tunning模型微调方法
linux·运维·服务器·人工智能
zly35001 小时前
centos7 ping127.0.0.1不通
linux·运维·服务器
小哥山水之间2 小时前
基于dropbear实现嵌入式系统ssh服务端与客户端完整交互
linux
ldj20202 小时前
2025 Centos 安装PostgreSQL
linux·postgresql·centos
翻滚吧键盘2 小时前
opensuse tumbleweed上安装显卡驱动
linux
cui_win3 小时前
【内存】Linux 内核优化实战 - net.ipv4.tcp_tw_reuse
linux·网络·tcp/ip
CodeWithMe6 小时前
【Note】《深入理解Linux内核》 Chapter 15 :深入理解 Linux 页缓存
linux·spring·缓存