Linux 进程信号

Linux 进程信号,第1张

       

前几天一直在搞算法,没来得及更新博客。今天就来谈谈进程信号。主要分信号发送前、信号发送中、信号发送后三个阶段来讲。这个知识还蛮重要的~

目录

信号背景知识

信号发送前

键盘产生信号

signal

一个小疑问

总结

进程异常产生信号

演示一

演示二

硬件上理解进程崩溃的本质 

core dump

core dump 事后调试

编码打印查看core dump

总结

通过系统调用产生信号

kill

raise

 abort

 软件条件产生信号

alarm

alarm补充

总结

信号发送中

如何理解OS给进程发送信号?

那如何向信号位图中写入? 

信号相关的概念

信号递达

未决

阻塞

信号保存三张表

 pending

handler 

block 

总结 

代码验证->函数接口

sigset_t

信号 *** 作集函数

sigprocmask

sigpending

总结

信号发送后

内核态&用户态

内核级页表

信号处理

默认处理动作

 忽略处理动作

自定义处理动作 

精炼化理解

总结

sigaction

补充


信号背景知识

       这就好比我们没有见过红绿灯,但是我们在心中知道如果红绿灯时的处理方式(红灯停,绿灯行)。本质上我们是被教育过的结果,所以我们处理红绿灯的动作是在没有见到红绿灯前是知道的。 

信号的发送是谁去做呢?

信号发送前

信号在发送前,需要产生信号,那么信号产生的方式有哪些呢?具体又做了些什么呢?

键盘产生信号

在做试验前,,我们先认识一个系统调用接口:

signal

int signum:要捕捉的信号编号

sighandler_t handler:是一个返回值为void,参数是int的函数指针。对捕捉的信号的处理方法。

注意:只有当收到signum信号时,才会去做对应的handler(自己实现的)方法。

代码练习+实验:

void handler(int signo)
{
    printf("get a signal: %d, pid: %d\n", signo, getpid());
}
int main()
{
    //通过signal注册对2号信号的默认处理动作,改为我们的自定义动作
    signal(2, handler);//handler和&handler一样都是表示地址

    while(1)
    {
        printf("hello wmm!, pid: %d\n", getpid());
        sleep(1);
    }
    return 0;
}

我们使用signal完成对2号信号的自定义处理过程,我们通过键盘ctrl+C看是否可以向进程发送信号

演示如下:

我们发现signal可以完成对2号信号的捕捉。 

一个小疑问

signal能完成对9号信号的捕捉吗?

我们来演示一下:

void handler(int signo)
{
    switch (signo)
    {
    case 2:
        printf("hello wmm, get a signal: %d\n", signo);
        break;
    case 3:
        printf("hello wmm, get a signal: %d\n", signo);
        break;
    case 9:
        printf("hello wmm, get a signal: %d\n", signo);
        break;
    default:
        break;
    }
}
int main()
{
    for (int sig = 1; sig <= 31; sig++)
    {
        signal(sig, handler);
    }

    while (1)
    {
        printf("hello wmm!, pid: %d\n", getpid());
        sleep(1);
    }
    return 0;
}

演示结果:

我们发现我们向目标发送2号和3号信号的时候,处理动作是我们自定义的处理动作,但是我们发送9号信号却没有被捕捉到,而是直接执行系统的默认处理动作,杀死该进程。 

总结

       信号的产生方式其中一种就是通过键盘产生。键盘产生的信号只能终止前台进程(./tset &这种后台进程只能用kill命令来终止)。

一般而言,进程收到信号的处理方式有三种情况:

1、默认动作 --- 一部分是终止,暂停等。

2、忽略动作 --- 是一种信号的处理方式,只不过动作是什么也不干。

3、(信号的捕捉)自定义动作 --- 我们刚刚用signal方法,就是在修改信号的处理动作:默认->自定义动作。

注意:9号信号不能被捕捉!

进程异常产生信号 演示一

我们用代码演示一下:

int main()
{
    int* ptr = NULL;
    *ptr = 100; //err
    return 0;
}

运行一下这个野指针错误问题的代码:

我们发现出现了错误,这时候我们用signal捕捉一下,看看是进程收到了哪个信号:

void handler(int signo)
{
    printf("hello wmm, get a signal: %d\n", signo);
}
int main()
{
    for(int sig = 1; sig <= 31; sig++)
    {
        signal(sig, handler);
    }
    int* ptr = NULL;
    *ptr = 100; //err
    return 0;
}

 运行结果:

我们发现是收到了11号信号。 

演示二

我们再来一个错误代码演示一下:

void handler(int signo)
{
    printf("hello wmm, get a signal: %d\n", signo);
}
int main()
{
    for(int sig = 1; sig <= 31; sig++)
    {
        signal(sig, handler);
    }
    int a = 10;
    a /= 0;
    return 0;
}

我们使用整数除以0的浮点数错误的代码,这时候我们看看进程会收到什么信号:

硬件上理解进程崩溃的本质 

不管是上面的段错误还是浮点数错误又或者是其他错误,在windows or Linux下,进程崩溃的本质,是进程收到了对应的信号,然后执行进程的默认处理动作(杀死该进程)。

为什么进程崩溃?因为进程收到了信号。为什么收到信号?因为进程崩溃了。这样理解的话我们发现我们一直陷入到逻辑怪圈里,这时候我们需要在硬件上去理解为什么会收到信号:

       既然OS是硬件的管理者,那么OS就要对硬件的健康进行负责。通常软件上的错误(比如:野指针、除0错误等)通常会体现在硬件上或其他软件上。OS这时候会检测到硬件的异常,然后OS就去找导致问题出现的进程,然后给该进程发送信号,来终止进程。 

core dump

首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c 1024

当一个进程崩溃的时候,我们不仅想知道崩溃的原因,并且也想知道崩溃的位置!

在Linux下我们通过core dump标志位是可以做到的。

 注意:不是所有的异常终止都会设置core dump标志位!

core dump 事后调试

在云服务器上,core dump默认是关闭的:

[cyq@VM-0-7-centos 进程信号]$ ulimit -a

我么查看发现core file size大小是0,默认是不能存储信息的。

这时候我们主动去设置一下,core dump就可以使用了: 

[cyq@VM-0-7-centos 进程信号]$ ulimit -c 1024

我们在这里假如给个1024。 

我们使用之前编译的浮点数错误的代码:

void handler(int signo)
{
    printf("hello wmm, get a signal: %d\n", signo);
}
int main()
{
    for(int sig = 1; sig <= 31; sig++)
    {
        signal(sig, handler);
    }
    int a = 10;
    a /= 0;
    return 0;
}

我们使用debug方式编译:

[cyq@VM-0-7-centos 进程信号]$ gcc test.c -o test -std=c99 -g

运行后我们发现是这样的结果:

core dump没被打开前运行时是没有这个(core dumped)的提示。并且我们发现该目录下多了一个core.460的文件。这个文件实际就是进程崩溃时在内存上该用户的数据,写入磁盘时创建的文件。

.460:460就是该崩溃进程的PID

我们然后使用gdb和core-file来查找进程崩溃的地方:

[cyq@VM-0-7-centos 进程信号]$ gdb test
(gdb) core-file core.460 

 以上就是在Linux下时候调试的步骤。

编码打印查看core dump
int main()
{
    if(fork() == 0)
    {
        while(1)
        {
            printf("i am child...\n");
            int a = 10;
            a /= 0;
        }
    }
    int status = 0;
    waitpid(-1, &status, 0);
    printf("core dump: %d\n", (status>>7)&1);
    return 0;
}

运行结果:

printf("core dump: %d\n", (status>>7)&1):我们拿到第8位的标志位&1就可以查看对应core dump是否被设置成1没。 

总结

1、进程产生信号在硬件层面的理解。

2、core dump标志位。

3、core文件事后调试。

云服务器设置core file size -> -g编译并运行可执行程序 -> gdb 可执行程序 -> core-file core.PID文件

通过系统调用产生信号 kill

pid_t pid:进程的pid ,表示给指定进程发送信号

int sig:给指定进程发送几号信号

代码练习:

void Usage(const char* proc)
{
    printf("Usage: \n\t %s who signo\n", proc);
}
//   ./test PID signo
int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        return 1;
    }
    int who = atoi(argv[1]);
    int signo = atoi(argv[2]);

    kill(who, signo);
    printf("who: %d signo: %d\n", who, signo);
    return 0;
}

我们借助环境变量参数来使用。(之前讲过,就不在这里赘述了)

我们提前写一个死循环的程序,a.out。

运行结果如下:

[cyq@VM-0-7-centos 进程信号]$ ./可执行程序 要发送信号的进程的PID 发送几号信号

我们发现我们在命令行输入./test 27586 9就相当于向27586进程发送9号信号,进而终止27586进程了。因为在./test中调用了kill系统调用。

raise

int sig:给自己这个进程发送几号信号。 

这个接口是自己给自己发送信号的。

举个栗子:

void handler(int signo)
{
    printf("get a signo: %d\n", signo);
}
int main()
{
    signal(8, handler);
    int cnt = 3;
    while(cnt--)
    {
        printf("hello wmm\n");
        sleep(1);
    }
    raise(8); //自己个自己发送信号
    return 0;
}

我们提前对8号信号进行捕捉,然后再主程序3s后执行raise语句,给自己发送8号信号:

运行结果:

 abort

自己给自己发送6号信号。 

举个栗子:

void handler(int signo)
{
    printf("get a signo: %d\n", signo);
}
int main()
{
    for(int sig = 1; sig <= 31; sig++)
    {
        signal(sig, handler);
    }
    int cnt = 3;
    while(cnt--)
    {
        printf("hello wmm\n");
        sleep(1);
    }
    abort(); //自己个自己发送信号
    return 0;
}

运行结果:

 软件条件产生信号 alarm

unsigned int seconds:设置秒数。表示多长时间后向进程发送SIGALRM信号。 

返回值:距离闹钟响还剩余的秒数。

注意:如果second是0,表示取消闹钟,但返回值依然是上一次设置闹钟距离响时剩余的时间。

alarm本质上就是设置闹钟。

举个栗子:

int main()
{
    int ret = alarm(30);
    printf("hello wmm: ret %d\n",ret);
    sleep(5);

    int res = alarm(0);//取消闹钟
    printf("hello wmm: res %d\n",res);

    return 0;
}

我们来看alarm的返回值:

我们用代码捕捉alarm发送的信号: 

void handler(int signo)
{
    printf("get a signo: %d\n", signo);
}

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

    alarm(3);
    while(1)
    {
        printf("hello wmm\n");
        sleep(1);
    }
    return 0;
}

注意,我们设置的时间要在进程退出前能达到。

我们对SIGALRM信号进行捕捉后。

运行结果:

我们看到alarm(3),3s后,进程收到了14号信号。 

alarm补充

我们对比两组代码。

涉及IO时:

int main()
{
    alarm(1);
    int count = 0;
    while(1)
    {
        count++;
        printf("hello wmm: %d\n",count);
    }
    return 0;
}

运行结果:

经过多次测试,袋盖就在15200附近。 

纯CPU计算:

int count = 0;
void handler(int signo)
{
    printf("hello wmm: %d\n", count);
    exit(1); //不能使用return
}
int main()
{
    signal(SIGALRM, handler);
    alarm(1);
    while(1)
    {
        count++;
    }
    return 0;
}

测试结果:

通过和上面的数进行对比可以发现进程中有没有IO交互,效率差别还是很大的! 

总结

信号发送中 如何理解OS给进程发送信号?

那如何向信号位图中写入? 

我们只关心1~31号普通信号,其余的我们不关心。

[1, 31]这个连续的整数我们会想到什么?答案是,数组! 

所以我们可以用一个uint32_t sigs这样的位图结构来保存信号。

比特位的位置表示收到了几号信号,比特位的内容表示是否收到信号。 

信号相关的概念 信号递达

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

处理动作:默认、忽略、自定义捕捉。

未决

信号从产生到递达之间的状态,称为信号未决(Pending)。

本质上这个信号被暂存在task_struct信号位图中。

阻塞

本质上是OS允许进程暂时屏蔽指定的信号。

1、该信号依然是未决的。

2、该信号不会被递达,知道解除阻塞!方可递达。

注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

信号保存三张表

     

 pending

用来保存是否收到信号,收到几号信号。

实际上它就是个位图结构。

比如在图中表示收到了2号信号,因为在2号信号位置比特位为1。

handler 

对特定信号的处理方法。

handler表也是一个位图结构。

handler的类型是:void (*handler[31])(int) ,一个函数指针数组。对应的下标里面是对应特定信号的处理动作。

所以当pending表和handler表结合起来并横着看:

block 

表示信号是否被阻塞。阻塞位图,也叫信号屏蔽字。

本质上也是位图结构。

总结 

我们把三张表结合看,实际上这三张表是横着看的:

 实际上信号检测就是横着看这三张表的。

我们写一个伪代码来体验一下:

               

代码验证->函数接口 sigset_t

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

信号 *** 作集函数

       sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来 *** 作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。

sigemptyset:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号。
sigfillset:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
注意:在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号

返回值:

前四个信号集函数返回值,调用函数成功返回0,失败返回-1。

sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

sigprocmask

const sigset_t* set:输入型参数,将要添加的信号集输入。

sigset_t* oldset:输出型参数 ,输出旧的信号集。

返回值:调用成功返回0,调用失败返回-1。

int how:设置方法,如下介绍:

针对mask信号集 *** 作。

 代码测试练习:

int main()
{
    //虽然sigset_t是一个位图结构,但是不同的OS实现是不一样的,
    //不能让用户直接修改该变量!需要使用特定的函数

    sigset_t iset;
    sigset_t oset;
    
    //所有bit为清0
    sigemptyset(&iset);
    sigemptyset(&oset);

    //添加某种特定的信号
    sigaddset(&iset, 2);
    sigprocmask(SIG_SETMASK, &iset, &oset);
    while(1)
    {
        printf("hello wmm\n");
        sleep(1);
    }
    return 0;
}

我们完成对2号信号的屏蔽,演示结果:

我们发现我们ctrl + c发送2号信号的时候, 进程是没有反应的。实际上就是对2号信号屏蔽了。注意,9号信号不可以被屏蔽!!!

总结:sigprocmask可以修改进程的信号屏蔽字(阻塞信号集)。

sigpending

 读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。 

sigset_t* set:输出型参数,可以拿到该进程的pending位图~

代码测试练习:

void show_pending(sigset_t* pending)
{
    printf("sur process pending: ");
    fflush(stdout);
    int i = 0;
    for(i = 1; i <=31; i++)
    {
        if(sigismember(pending, i)) //检测i信号是否在pending位图里面
        {
            printf("1");
        }
        else
        {
            printf("0");
        }
    }
    printf("\n");
}
int main()
{
    sigset_t iset;
    sigset_t oset;

    //bit位清0
    sigemptyset(&iset);
    sigemptyset(&oset);

    //添加某种有效信号
    sigaddset(&iset, 2);

    //设置当前进程的信号屏蔽字
    sigprocmask(SIG_SETMASK, &iset, &oset);

    int count = 0;
    sigset_t pending;
    while(1)
    {
        sigemptyset(&pending);
        sigpending(&pending);//获取当前进程的位图
        show_pending(&pending);

        sleep(1);
        count++;
        if(count == 10) //10s后
        {
            //把老的信号恢复过去
            sigprocmask(SIG_SETMASK, &oset, NULL);//不需要接收输出
            printf("恢复2号信号,可以被递达\n");
        }
    }
    return 0;
}

show_pending负责打印位图,我们先屏蔽2号信号,10s后,解除2号信号的屏蔽,我们来看一下这个执行过程:

实际上我们没有看到由1变0的结果, 因为解除对2号信号的的阻塞后,进程收到2号信号,执行默认处理动作,就终止进程了。

至于handler表的函数接口已经介绍过了,就是signal,可以捕捉信号,并指定处理动作。

总结

信号发送后

我们知道,信号到来时,不一定是被立即处理的,而是在合适的时候。什么是合适的时候呢?我们讲之前,先补充一些知识:

内核态&用户态

内核级页表

       实际上每个进程的地址空间中的内核空间内容都是一样的,它们共用一个内核级页表,这样就可以找到同一个OS的代码和数据了!!

       用户的身份是以进程为代表的。进程切换实际上是该进程的时间片到了,把该进程拿掉,把等待队列中的进程拿上去,虽然进程变了,但是他们的内核地址空间是一样的,不影响进程!

信号处理

"合适"的时候,就是指当进程从内核态转换为用户态的时候,在这之前要进行信号检测!

信号处理动作有三种方式,在这里主要介绍自定义捕捉。

默认处理动作

 忽略处理动作

自定义处理动作 

       对于信号的捕捉处理动作,代码是在用户态实现的,所以进程要执行自己的处理动作就必须要将身份从内核态转换为用户态,处理完对应的动作后,再使用一个特殊的系统调用sys_sigreturn 将进程身份由用户态转换为内核态,然后再从内核态转换为用户态。(注意,用户态不能直接返回上次主执行流中断的地方)

精炼化理解

上面信号检测的过程是不是比较复杂?别急,我们仔细看那个过程,是不是就是数学中无穷大的符号?

第一个交点:在执行主执行流程序中,因为中断、异常、系统调用进入到内核。 在内核处理完异常后,准备返回用户模式之前,先处理当前信号可以抵达的信号(信号检测)。

第三个交点:用户身份从内核态转换为用户态,执行自定义的代码。

第四个交点:执行完自定义动作之后,通过特殊的系统调用从用户态转化为内核态。

第二个交点:用户身份从内核态转换为用户态。

为何一定要切换成为用户态,才能执行信号捕捉方法?

内核态的权限过大,如果直接去执行用户的代码,可能会存在风险。 

总结

          

sigaction

int signum:要处理的信号 

act:输入性参数,我们指定的方法,在这个结构体里面保存,然后传进去。

oldact:输出型参数,返回旧的结构体变量

至于const struct sigaction结构体:

我们不关心实时信号,在这里只用关注普通信号即可。

sa_handler:相当于signal函数里面的handler,指定处理动作。

sa_mask:和siget_t定义出来的信号集变量一样。

代码练习:

void handler(int signo)
{
    printf("get a signo: %d\n", signo);
}
int main()
{
    struct sigaction act;
    memset(&act, 0, sizeof(act)); //对这个结构体变量初始化
    act.sa_handler = handler;

    //本质就是修改当前进程的handler函数指针数组里面特定的内容
    sigaction(2, &act, NULL);

    while(1)
    {
        printf("hello wmm\n");
        sleep(1);
    }
    return 0;
}

测试结果:

本质上sigaction和signal作用一致,都可以完成对某个信号的捕捉动作。 

补充

       当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

       如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

1、简单就是说,如果一个信号的处理动作正在执行时,该信号会被阻塞,直到该信号动作处理完之后,该信号就解除阻塞。(就算该信号传递再多次,pending表中只记录1次)

2、在处理自定义捕捉动作期间,除了屏蔽当前正在处理的信号,还想屏蔽其它信号,使用sa_mask可以屏蔽多个信号。

代码测试练习:

void handler(int signo)
{
    printf("get a signo: %d\n", signo);
    //在这里,7s内,2号信号被阻塞,即使收到2号信号也不会重新执行
    sleep(7);
}
int main()
{
    struct sigaction act;
    memset(&act, 0, sizeof(act)); //对这个结构体变量初始化
    act.sa_handler = handler;

    sigemptyset(&act.sa_mask); //所有bit位置0
    sigaddset(&act.sa_mask, 3); //在处理2号信号期间屏蔽3号信号---可以同时屏蔽多个
    //本质就是修改当前进程的handler函数指针数组里面特定的内容
    sigaction(2, &act, NULL); //对2号信号自定义


    while(1)
    {
        printf("hello wmm\n");
        sleep(1);
    }
    return 0;
}

运行结果:

也就是说sa_mask是在指定信号被处理期间作用的。当要指定的信号没有在执行其处理动作时,sa_mask里面保存的信号是不会被屏蔽的。

看到这里,给博主点个赞吧~

                      

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/1498109.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-06-25
下一篇 2022-06-25

发表评论

登录后才能评论

评论列表(0条)

保存