【Linux】第八篇:进程信号


在这里插入图片描述

1. 信号入门

在讨论进程间的信号之前,我们可以先讨论一下出现在生活中的信号。

闹铃,红绿灯,钓鱼的浮标甚至老师的脸色都是信号,而我们面对信号的处理方式是多样的,当信号一出现我们立马执行行动,或是先记住信号然后在合适的时间去执行相应的活动,抑或是对信号置之不理。

但是不管如何,信号所对应的行为都已经提前被我们记录在大脑中,比如听到闹钟就起床,红灯停绿灯行,浮标晃动说明鱼已上勾,看到老师的脸色不在课堂上说话等等。等待信号的过程,于你而言是异步的,在此之前你完全可以做自己的事情。

进程中的信号

信号是为了让进程具有处理突发事件的能力

为理解信号,先从我们熟悉的场景说起:

  1. 我们在shell中启动前台进程。
  2. 用户按下 Ctrl+c 中断,这个键盘输入产生的是硬件中断。
  3. 操作系统会将此中断解释成一个信号,记录在该进程的PCB中(也可以说发了一个SIGINT信号给该进程)。
  4. 进程再执行代码前首先处理PCB中记录的信号,发现有一个信号待处理,而这个信号的默认处理动作是终止进程,所以直接终止进程而不再执行此用户的代码。
[sjl@VM-16-6-centos signal]$ cat signal.c 
#include <stdio.h>

int main()
{
    while(1)
    {
        printf("i am waiting\n");
        sleep(1);
    }
    return 0;
}
[sjl@VM-16-6-centos signal]$ ./signal 
i am waiting
i am waiting
i am waiting
i am waiting
^C
[sjl@VM-16-6-centos signal]$ 

⚠ 注意:ctrl+c/z/\所产生的信号只能发给前台,而一个命令的结尾加上 &可以放到后台运行,这样shell就不必等待进程结束就可以接收新的命令,启动新进程。

shell可以同时运行一个前台程序和多个后台程序,只有前台进程才能接受像ctrl+c这种键盘产生的信号。前台进程运行中用户随时可能按下ctrl+c而产生一个信号,所以信号相对于进程的控制来说是异步(Asynchronous)的

前后台进程处理

  • 我们暂停一个进程后就会使其成为后台进程并且stop

使用 jobs指令查看后台的所有进程,再使用 fg+任务号让其运行成为前台进程。(如果后台只有一个进程可以省略任务号),bg+任务号命令用于将后台暂停的作业开始运行,使前台可以执行其他任务。该命令的运行效果与在指令后面添加符号&的效果是相同的,都是将其放到系统后台执行。

在这里插入图片描述

Linux系统中的信号

  • 使用 kill -l 命令查看系统定义信号列表

在这里插入图片描述

每个信号都有一个编号和宏定义名称,编号从1开始到31为普通信号,

信号是如何记录的?

实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块当中的。我们都知道进程控制块本质上就是一个结构体变量,而对于信号来说我们主要就是记录某种信号是否产生,因此,我们可以用一个32位的位图来记录信号是否产生。

在这里插入图片描述

34及以上为实时信号,本篇只讨论编号34以下的信号。

在路径 /usr/include/bits/signum.h 中查看信号的宏定义。

之前谈过进程需要对信号有所准备,所以进程要为信号配置信号处理函数,当某个信号发生的时候,就默认执行这个函数即可,通过 man 7 signal 可以查看各个信号的处理方法和具体含义。

Signal     Value     Action   Comment
──────────────────────────────────────────────────────────────────────
SIGHUP        1       Term    Hangup detected on controlling terminal
                              or death of controlling process
SIGINT        2       Term    Interrupt from keyboard
SIGQUIT       3       Core    Quit from keyboard
SIGILL        4       Core    Illegal Instruction

SIGABRT       6       Core    Abort signal from abort(3)
SIGFPE        8       Core    Floating point exception
SIGKILL       9       Term    Kill signal
SIGSEGV      11       Core    Invalid memory reference
SIGPIPE      13       Term    Broken pipe: write to pipe with no
                              readers
SIGALRM      14       Term    Timer signal from alarm(2)
SIGTERM      15       Term    Termination signal
SIGUSR1   30,10,16    Term    User-defined signal 1
SIGUSR2   31,12,17    Term    User-defined signal 2
SIGCHLD   20,17,18    Ign     Child stopped or terminated
SIGCONT   19,18,25    Cont    Continue if stopped
SIGSTOP   17,19,23    Stop    Stop process
SIGTSTP   18,20,24    Stop    Stop typed at terminal
SIGTTIN   21,21,26    Stop    Terminal input for background process
SIGTTOU   22,22,27    Stop    Terminal output for background process

……

第一列是信号的宏名称,第二列为信号编号,第三列为默认处理动作,最后一列介绍了什么条件下产生该信号。

信号的产生与处理简介

产生信号的条件

  1. 用户在终端按下某些键时:ctrl+c(SIGKILL),ctrl+z(SIGSTP),ctrl+/(SIGQUIT)

  2. 硬件异常通知内核,内核向进程发送相应信号。

    例如进程执行了除以0的指令,CPU的运算单元会产生异常,内核将此异常解释为SIGFPE信号,再发送给进程。

    又如进程访问了非法内存地址,MMU产生异常,内核将异常解释为SIGSEGV(段错误)信号发送给进程。

  3. 一个系统调用 kill(2) 函数可以发送信号给另一个进程。

  4. 可以用kill(1)命令发送信号给某个进程, kill(1)命令也是调用kill(2)函数实现的,如果不明确指定信号则发送SIGTERM信号,该信号的默认处理动作是终止进程。

  5. 当内核检测到某种软件条件发生时也可以通过信号通知进程,例如闹钟超时产生SIGALRM信号,向读端已关闭的管道写数据时产生SIGPIPE信号。

所有的信号归根结底都是OS发向进程的。

信号的处理动作

  1. 执行默认操作。

    操作含义
    Term终止进程
    Core终止当前进程并且Core Dump(之后介绍)
    Ign忽略信号
    Stop中断进程
    Cont若进程中断,则继续进程
  2. 自定义处理信号行为 (捕捉信号)

    捕捉信号后,我们可以为信号自定义信号处理函数。

  3. 忽略信号

    当我们不希望处理某些信号时,就可以忽略不做任何处理。

注意: 有两个信号是无法捕捉(自定义)和忽略的,SIGKILLSIGSTOP,他们用于在任何时候结束或中断进程。

注册信号处理函数 —— signal 系统调用

要为一个信号自定义处理函数,可使用signal系统调用

  • 函数声明
#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
  • 参数

    • signum 指出要捕获的信号编号或者宏名称(比如:2 或者 SIGINT)
    • handler 参数是sighandler_t类型的函数指针,用于指定sig信号的处理函数。
  • 返回值
    成功则返回前一次调用signal函数时传入的函数指针。或者信号signum对应的默认处理函数指针SIG_DEF。

    失败返回SIG_ERR,并设置errno。

  • 示例代码

我们对信号 SIGINT 进行自定义行为:

#include <stdio.h>
#include <signal.h>
#include<unistd.h>
void handle(int signum)//对信号SIGINT的自定义行为
{
    printf("get signal : %d\n",signum);
}
int main()
{
    signal(SIGINT,handle);//捕捉SIGINT(写编号:2也可以)
    while(1)
    {
        printf("waiting a signal\n");
        sleep(1);
    }
    return 0;
}

从终端按键(ctrl+c)和kill指令来验证信号自定义行为:

在这里插入图片描述

注册信号处理函数 —— sigaction 系统调用

(之后会讲)

2. 产生信号

通过终端按键产生信号

之前谈过 ctrl+c 是给前台进程发送信号:SIGINT

这里再介绍一个终端按键信号 ctrl+\ 发送的信号 SIGQUIT

SIGQUIT 的默认处理动作是终止进程并且 Core Dump

在这里插入图片描述

Core Dump

我们在进程篇的waitpid函数中介绍的status结构理包含了Core Dump,他是status的从低到高的第7个比特位(从0计数)。

在这里插入图片描述

现在对Core Dump 做出解释:

当一个进程要异常终止时,意味着程序中存在bug,为了方便用户事后可以debug,那可以选择把进程用户空间的内存数据全部保存在磁盘上,文件名通常是 core,这称为 Core Dump (核心转储),此时status的Core Dump会由0置为1

进程的异常退出,事后我们使用调试器(gdb)检查core文件以查清错误原因,这叫做 Post-mortem Debug

一个进程允许产生多大的core文件取决于进程的Resource Limit(该信息保存于PCB中)。

我使用的是云服务器,现在通过指令 ulimit -a 查看资源限制:

在这里插入图片描述

可以看到我们的core文件的资源被限制为0,那意味着目前我们的核心转储功能默认是被关闭的。

默认不允许产生core文件,因为core文件中可能包含用户密码等敏感信息,而这并不安全。

在开发调试阶段可以使用ulimit命令更改Resource Limit,允许产生core文件。在我的云服务器中允许core文件最大为1024K:

ulimit -c 1024

在这里插入图片描述

我们写一个死循环的小程序,分别使用SIGINT和SIGQUIT来终止它,看一下谁会产生的core文件:

//signal.c
#include <stdio.h>
#include <signal.h>
#include<unistd.h>

int main()
{
    while(1)
    {
        printf("waiting a signal\n");
        sleep(1);
    }
    return 0;
}

在这里插入图片描述

可以看到当我们键入 ctrl+\ 终止进程后会提示 (core dumped),表明核心转储成功,文件夹中也多了一个core.pid的文件,后面的pid则是发生这次核心转储的进程PID。

注意到core文件还是不小的:

  • du指令 查看磁盘占用空间

    • -a 显示目录中所有文件大小
    • -k 以KB为单位显示文件大小
    • -m 以MB为单位显示文件大小
    • -g 以GB为单位显示文件大小
    • -h 以易读方式显示文件大小
    • -s 仅显示总计

在这里插入图片描述

当我们服务端程序为提供不间断服务而设定为进程异常不断重启时,那可能就会在磁盘中产生大量core文件,所以core dump在线上默认是关闭的。

调试core文件

既然了解核心转储文件,那我们的目的就是为了分析进程异常退出的原因,于是接下来便是对 core 文件进行调试,定位问题。

有两种调试方法

  1. gdb 执行文件 core.pid
  2. gdb 执行文件 + core-file core.pid

这次我们改写一下程序,使其发生除0错误,此时将会产生SIGFPE信号,同样会core dump。

//signal.c
int main()
{
    while(1)
    {
        printf("waiting a signal\n");
        sleep(5);
        int a=1/0;
    }
    return 0;
}

在这里插入图片描述

该步骤gdb命令调用过程如下:

gdb 可执行程序 core文件

在这里插入图片描述

此时就能看到在哪一行出现的异常了

在这里插入图片描述

还有一种调试方法,首先进入 gdb signal 然后键入

core-file core.pid

我们将这个方法来检测一下段错误(SIGSEGV)的程序

//signal.c
int main()
{
    while(1)
    {
        printf("waiting a signal\n");
        sleep(5);
        int *p=NULL;
        *p=10;
    }
    return 0;
}

成功定位错误位置:

在这里插入图片描述

status 中的 core dump 位

如果进程是异常退出的那么,status中的0~6位存储的是进程异常的信号,第7位core dump 由0置为1,那么我们来验证一下。

实验代码依旧用除0的错误代码演示,不过这会发生在子进程中,我们使用父进程waitpid来获取子进程的status:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
    if(fork()==0)
    {
        printf("waiting a signal\n");
        sleep(5);
        int a=1/0;//异常位置
        exit(0);
    }
    int status;
    waitpid(-1,&status,0);
    printf("exit code :%d,Core Dump:%d,signal:%d\n",
    (status>>8)& 0xFF,(status>>7)&1,status & 0x7F);
    //依次输出退出码,CoreDump和信号。
    return 0;
}

在这里插入图片描述

调用系统函数向进程发信号

之前谈过使用键盘按键只能对前台进程发送信号,无法发送至后台的进程。

如果要对后台进程发送信号,可以使用shell指令:kill -信号编号 进程PID

我们对一个死循环的后台程序,使用kill指令向其发送SIGSEGV信号。

在这里插入图片描述

11是SIGSEGV的信号编号,也写成 kill -11 19722

kill 函数

kill命令是调用kill函数实现的。kill函数可以给一个指定进程发送指定的信号

  • 函数声明
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

我们可以写一段程序将kill函数包装成一个kill的shell指令:

//mykill.c
#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
#include <stdlib.h>
void Usage(const char* proc)
{
    printf("Usage:%s signo PID\n",proc);
}
int main(int argc,const char* argv[])
{
    if(argc!=3)
    {
        Usage(argv[0]);
        return 0;
    }
    int signo=atoi(argv[1]);
    pid_t pid=atoi(argv[2]);
    kill(pid,signo);
  
    return 0;
}

我们写的mykill程序使用起来和kill指令一样:mykill signo PID

为了便于使用,我们将mykill执行程序添加进系统指令的搜索路径(PATH)下,从而运行时可以不加路径。

在这里插入图片描述

我们可以写一个死循环输出的程序来试试:

在这里插入图片描述

raise 函数

进程自己可以给自身发送函数

#include <signal.h>
int raise(int sig);

在一个循环中给自己发送SIGINT信号,这里我们同时捕捉SIGINT信号进行输出:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handle(int signum)
{
    printf("get a signal : %d\n",signum);

}
int main()
{
    signal(2,handle);
    while(1)
    {
        printf("Hello,pid:%d\n",getpid());
        sleep(1);
        raise(2);
    }
    return 0;
}

在这里插入图片描述

abort 函数

进程给自己发送SIGABRT信号

#include <stdlib.h>
void abort(void);

abort函数总是会成功的,所以没有返回值。

延用上面的代码,不过我们改为对6号信号(SIGABRT)进行捕捉:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void handle(int signum)
{
    printf("get a signal : %d\n",signum);

}
int main()
{
    signal(6,handle);
    while(1)
    {
        printf("Hello,pid:%d\n",getpid());
        sleep(1);
        abort();
    }
    return 0;
}

可以看到虽然我们捕捉并自定义了6号信号,但是操作系统还是为我们终止了进程。

在这里插入图片描述

由软件条件产生信号

在进程间通信篇讨论管道时,我们谈过当管道的读端关闭的时候,操作系统会给写端发送SIGPIPE信号,于是写端进程会被终止,这便是有软件产生信号的一种方式。

这里再介绍一种软件产生信号的方式:alarm函数和SIGALRM信号。

  • 函数声明
#include <unistd.h>
unsigned alarm(unsigned seconds);

当seconds的时间流尽后,系统发送SIGALRM信号终止进程。

  • 返回值

初次设定alarm函数时,返回值为0 。之后再次设定alarm函数,返回值为上一个alarm函数的剩余时间,并且会取最新的alarm函数的seconds重置发送SIGALRM的时间。

  • 示例代码1
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    printf("set alarm : 50 sec\n");
    int remain=alarm(50);
    printf("remain:%d\n",remain);

    printf("wait 5 sec\n");
    sleep(5);

    printf("reset alarm : 3 sec\n");
    remain=alarm(3);
    printf("remain:%d\n",remain);
    while(1)
    {
        printf("waiting...\n");
        sleep(1);
    }

    return 0;
}

在这里插入图片描述

  • 示例代码2

测试自己的服务器在1秒钟可以累加多少

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

在这里插入图片描述

由于我们每进行一次累加就进行了一次打印操作,而申请IO操作的效率很低,其次,网络传输也会折损许多时间,因此最终显示的结果要比实际一秒内可累加的次数小得多。

为得到真实数据,避免IO和网络的损失,我们可以自定义SIGALRM信号处理函数,时间结束发送SIGALRM信号时在自定义函数中打印即可。

void handle(int signum)
{
    printf("count:%d\n",count);
    exit(1);
}
int main()
{
    signal(14,handle);
    alarm(1);
    while(1)
    {
        count++;
    }
}

在这里插入图片描述

可见IO的速度是相当慢的。

3. 阻塞信号

信号的相关概念

  • 信号递达(Delivery):实际执行信号的处理动作(默认,自定义,忽略)
  • 信号未决(Pending):信号产生之后到递达之前的状态(进程在收到信号后不会处理,而是在合适的时候处理,这个时间窗口是为未决)。(收到了信号还没来得及处理)
  • 阻塞信号(Block):让信号不要递达(即便是在合适的时候也不处理信号)。
  • 被阻塞的信号将一直处于未决状态,直到进程对该信号解除阻塞,才执行抵达动作。
  • 注意:阻塞不等于忽略,阻塞是未决状态没有递达,忽略是递达中处理信号的一种方法。

打个比方:在备忘录上记录回家作业意味着收到信号,等到真正写作业的时候是递达状态,而从记录回家作业到开始写作业的这段时间是未决状态,如果到家后你妈妈叫你先吃完饭再写,那么写作业这件事情就会阻塞,直到吃完饭后阻塞就被解除,可以递达。而忽略意味着,当你开始写作业后发现作业太难不想做了,便不去完成此作业了。

信号在内核中的表示

在这里插入图片描述

我们从横向解读一个信号的状态:

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),他们都以位图作为数据结构,下标表示信号的编号,内容为1表示触发,0为没有触发。最后一列为一个函数指针表示我们递达信号时的处理动作,SIG_DFL为默认处理动作,SIG_IGN为忽略信号,自定义的函数就是signal函数参数的函数指针。

在这里插入图片描述

在上图的例子中:

  1. SIGHUP 信号未阻塞(block为0),且没有收到信号(pending为0),当他递达时执行默认处理动作。
  2. SIGINT 信号产生过(pending为1),但被阻塞(bolck为1)所以暂时不能递达,虽然他的处理动作是忽略(SIG_IGN),但是在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作后再解除阻塞。
  3. SIGQUIT 信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数 sighandler。

⚠注意:如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?

  • POSIX 允许系统递送该信号多次。
  • Linux 常规信号(1-31)在递达之前产生多次只记一次,而实时信号(34-64)在递达之前产生多次可以依次放在一个队列里。本篇不讨论实时信号。

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

因此,未决和阻塞标志可以用相同的数据类型 sigset_t来存储,sigset_t 称为信号集。

sigset_t 信号集

这个类型可以表示每个信号的有效或无效的状态。

  • 阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞;
  • 未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。

阻塞信号集也称为当前进程的信号屏蔽字(Signal Mask),这里的屏蔽不可理解为忽略,因为压根就没递达。

信号集操作函数

sigset_t 使用比特位来表示有效和无效的状态,但是我们不能通过按位操作来设定其值。该类型如何存储bit位依赖于操作系统的实现。

我们应该也只能使用下列函数来操作sigset_t变量,而不应该对它的内部结构进行处理,而且使用 printf 打印 sigset_t 是没有意义的。

  • 信号集sigset_t的操作函数
#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 sigismember(const sigset_t *set, int signo);
  1. sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
  2. sigfillset初始化set所指向的信号集,使其中的所有信号的对应bit置为1,表示系统支持的所有信号在此信号集中全部有效。

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

  1. 初始化sigset_t后,调用 sigaddsetsigdelset函数在该信号集中添加或删除某种有效信号。

🚩注意:上面四个函数都是成功返回0,出错返回-1。

  1. sigismember函数用于判断一个信号集的有效信号中是否包含某种信号,若包含返回1,不包含返回0,出错返回-1。
#include <signal.h>
void PrintSet(const sigset_t* s)
{
    int i;
    for(i=1;i<=31;++i)
    {
       printf("%d ",sigismember(s,i));
    }
    printf("\n");
}
int main()
{

    sigset_t s;
    printf("sigemptyset\n");
    sigemptyset(&s);
    PrintSet(&s);


    printf("sigaddset 1 3 5\n");
    sigaddset(&s,1);
    sigaddset(&s,3);
    sigaddset(&s,5);
    PrintSet(&s);

    printf("sigfillset\n");
    sigfillset(&s);
    PrintSet(&s);

    printf("sigdelset 1 3 5\n");
    sigdelset(&s,1);
    sigdelset(&s,3);
    sigdelset(&s,5);
    PrintSet(&s);
    return 0;
}

在这里插入图片描述

🚩注意:上面的信号集函数只是单纯地针对我们自己定义的sigset_t的变量进行操作,而这个值只是在我们的用户空间的栈上,并没有设置到进程相关的PCB内,所以不会影响到进程的任何行为。所以我们需要通过系统调用函数将设置好的 sigset_t 参数配置进操作系统中。

sigprocmask 设置进程的block位图

调用 sigprocmask 可以读取或者更改进程的信号屏蔽字(阻塞信号集,block)

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

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

  • oset:如果非空指针,则当前的信号屏蔽字可以通过oset参数传出(读取)。
  • set :如果非空指针,则set根据how的指示来更改进程的信号屏蔽字。

oset,set都是非空指针,则可以先通过输出型参数oset来备份原先的信号屏蔽字,然后根据set和how参数更改信号屏蔽字。

  • how 指定更改信号屏蔽字的方式
选项含义
SIG_BLOCKset是我们希望添加到当前信号屏蔽字的信号,相当于mask=mask l set
SIG_UNBLOCKset是我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK把进程当前的信号屏蔽字设为set,相当于mask=set

如果调用了sigprocmask 解除了对当前若未决干信号的阻塞,则在函数返回前,至少将其中一个信号递达。

sigpending 获取进程的pending位图

#include <signal.h>
int sigpending(sigset_t *set);

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

信号集函数操作实验

  1. 先把2号信号阻塞
  2. 键盘发送2号信号
  3. 由于2号信号被阻塞不会递达因此处于未决状态,可以预见在pending位图中的2号位置会被置为1。
  4. 最后使用sigpending函数来获取未决信号集
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void PrintPending(const sigset_t* p)
{
    int i=1;
    for(;i<32;++i)
    {
        if(sigismember(p,i))
        {
           printf("1 "); 
        }
        else
        {
            printf("0 ");
        }
    }
    printf("\n");
}

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

int main()
{
    signal(SIGINT,handler);//捕捉2号信号,执行自定义函数handler
    sigset_t s,oset,p;
    sigemptyset(&s);//初始化s
    sigemptyset(&oset);
    sigaddset(&s,SIGINT);//在s的位图上将SIGINT的bit位置为1
    sigprocmask(SIG_BLOCK,&s,&oset);//让进程的2号信号阻塞
    int count=0;
    while(1)
    {
        sigemptyset(&p);
        sigpending(&p);//获取当前进程的pending位图
        PrintPending(&p);
        sleep(1);
        count++;
        if(count==10)
        {
            //10秒后恢复信号屏蔽字
            sigprocmask(SIG_SETMASK,&oset,NULL);
            //sigprocmask(SIG_UNBLOCK,&s,NULL); //或者取消set的阻塞,也可获得同样效果
        }

    }
    return 0; 
}

在这里插入图片描述

4. 捕捉信号

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。

内核空间与用户空间

每个进程有自己的进程地址空间,其中有内核空间和用户空间。

  • 用户空间存储用户所写的代码及相关数据,通过用户页表与物理内存建立映射关系。
  • 内核空间存储操作系统的代码和数据,通过内核级页表与物理内存建立映射关系。

在这里插入图片描述

内核页表是全局的,每个进程的内核空间中的代码和数据是一样的。

当用户需要执行操作系统的相关代码时(如系统调用),就必须要从用户态进入内核态。

用户态与内核态的切换

  • 用户态:运行用户程序时的状态。只有受限的访问内存,无法访问硬件,其所占有的CPU资源是可以被高优先级抢占的。
  • 内核态:可以执行所有的CPU指令,可以引用任何内存地址,包含外设,例如硬盘,网卡,权限等级最高。

内核态和用户态各有优势:运行在内核态的程序可以访问的资源多,但可靠性、安全性要求高,维护管理都较复杂;用户态程序访问的资源受限,但可靠性、安全性要求低,自然编写维护起来都较简单。一个程序到底应该运行在内核态还是用户态取决于其对资源和效率的需求。

用户态与内核态的切换情况:

  1. 系统调用

所有的用户程序都运行在用户态,但是有时候程序确实需要做一些内核态的事情,比如在屏幕打印文字,读取硬盘中的文件等。于是程序就需要从用户态切换到内核态,再由内核执行相应的请求。这种机制为系统调用

  1. 异常,时间片轮转

在CPU执行用户态程序时,突然发生如硬件异常等突发的异常事件或者进程时间终止,此时会触发从用户态切换为内核态执行相关处理。

  1. 硬件的中断

当硬件完成内核的请求操作后,会向CPU发出中断信号,此时CPU会暂停执行下一条即将执行的指令,转而执行中断信号对应的处理程序。如果先前的程序是在用户态下,就自然发生用户态到内核态的切换。

注意:系统切换的本质也是一种中断,相对于外设的硬中断,这种称为软中断。从本质上看这三种切换方式都相当于执行了一个中断相应的过程,当然系统调用是主动请求切换的,而异常与硬中断是被动的。

内核如何实现信号的捕捉

信号的处理动作可分为三种:默认(SIG_DFL),忽略(SIG_IGN)和自定义函数。

如果信号的处理动作是自定义的,那么在信号递达时就调用这个函数,这称为捕捉信号,由于信号处理函数的代码处于用户空间,处理流程略显复杂,举例如下:

  1. 用户自定义了部分信号的处理函数sighandler。

  2. 当前正在执行用户的main函数,此时发生中断或异常,切换到内核态。

  3. 在中断处理完毕后要返回main函数之前,先检查pending位图和block位图。如果有信号此时没有被阻塞且又处于未决状态,就让信号递达。

  4. 如果处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达且进程没有被终止,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。

  5. 如果信号是自定义的处理函数,内核决定返回用户态去执行sighandler函数,而非恢复main函数的上下文继续执行。

    🚩注意:main函数和sighandler使用不同的堆栈空间,他们之间不存在调用和被调用的关系,是两个独立的进程。

    🚩注意:内核态的权限更高,故无法以内核态的身份执行用户自定义的sighandler函数(可能存在漏洞),内核执行操作系统之外的代码可能会造成系统崩溃,故须先切换为权限更低的用户态去执行自定义函数可保证安全。

  6. sighandler函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态,并清除对应的pending标志位。

  7. 如果没有新的信号要递达,这次返回用户态恢复main函数的上下文继续执行用户程序。

在这里插入图片描述

当信号有自定义处理函数的时候记忆起来有些困难,于是我们针对此将上图简化:

在这里插入图片描述

  • 图形与直线的交点代表着状态切换,箭头表示切换的方向。
  • 图中的原点处于内核态,内核检查进程中信号的pending位和block位来决定下一步是回到用户态还是对信号进行递达处理。

sigaction 函数

  • 函数声明
#include <signal.h>
int sigaction(int signo,const struct sigaction *act,struct sigaction *oldact);
  • 返回值

    成功返回0,出错返回-1

  • 参数

    • signo : 指定信号的编号
    • act : 输入型参数,类型为struct sigaction指针,若非空指针,则根据act结构体来修改信号的处理动作。
    • oldact :输出型参数,类型为struct sigaction指针,若为非空指针,则将信号原先的处理动作备份到oldact中。
  • 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 : 自定义函数(和signal函数的handler一样),也可以定义为SIG_DFL或SIG_IGN 。

  • sa_sigaction :是实时信号的处理函数本篇不做讨论。

  • sa_mask : 设置该信号处理时的信号屏蔽字。

    注意:

    • 🚩当某个信号的处理函数在处理时,内核会自动将当前的信号加入信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字。这样保证了在处理某个信号时如果这种信号再次产生,那么它会被阻塞直到当前处理结束为止。
    • 🚩在调用信号处理函数时,除了当前信号被自动屏蔽外,如果用户还希望屏蔽另外一些信号,则可以使用sa_mask字段来说明需要额外屏蔽的信号,当然信号处理函数返回时会自动恢复原来的信号屏蔽字。
    • 🚩sa_mask的类型为sigset_t,可以使用专属信号集函数(上面介绍过)进行处理。
  • sa_flags :用来设置信号处理的相关操作,平时设为0即可,若有要求可以设置如:

    • SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL。
    • SA_RESTART:如果信号中断了进程的某个系统调用,则中断结束后系统自动启动该系统调用
    • SA_NODEFER :一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置了 SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号
    • SA_NOCLDSTOP :使父进程在他的子进程暂停或继续运行时不会受到SIGCHLD 信号
    • SA_NOCLDWAIT:使父进程在它的子进程退出时不会收到 SIGCHLD 信号,这时子进程如果退出也不会成为僵尸进程。
    • SA_SIGINFO:使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数。
  • sa_restorer :是一个废弃的数据域不要使用。

sigaction 函数实验

要求:我们给SIGINT自定义信号处理函数,在执行SIGINT信号的同时还阻塞住SIGQUIT信号,最后将SIGINT信号还原。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>

struct sigaction newact,oldact; 
void handler_INT(int signo)//SIGINT的自定义函数
{
    printf("catch %d\n",signo);
    printf("SIGQUIT is blocked in 10 sec! try me\n");
    sleep(10);//这10s中,SIGQUIT是阻塞状态。
    sigaction(signo,&oldact,NULL);//10秒后恢复SIGINT的默认信号处理
}

void handler_QUIT(int signo)//SIGQUIT的自定义函数
{
    printf("catch %d\n",signo);
    printf("SIGQUIT is unblocked now!\n"); 
}

int main()
{
    newact.sa_handler=handler_INT;//为SIGINT信号设置自定义函数
    sigemptyset(&newact.sa_mask);
    sigaddset(&newact.sa_mask,SIGQUIT);//添加信号屏蔽字,阻塞SIGQUIT
    sigaction(SIGINT,&newact,&oldact);//newact赋予新行为,oldact备份
    signal(SIGQUIT,handler_QUIT);
    while(1)
    {
        printf("hello\n");
        sleep(1);
    }
    return 0;
}

在这里插入图片描述

pause 函数

#include <unistd.h>
int pause(void);
  • pause 使当前进程挂起,直到有信号递达。
  • 如果信号的处理动作是终止进程,则进程终止,pause没有机会返回。
  • 如果过信号的处理动作是忽略,则进程继续处于挂起状态,pause不返回。
  • 如果信号的处理动作是捕捉自定义,则调用信号处理函数后,pause返回-1,errno设置为 EINTR ,所以pause只有出错的返回值。
  • 错误码 EINTR 表示 “被信号中断” 。

pause 函数实验

使用 alarm 和 pause 实现 sleep 函数,称为 mysleep 。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void sig_alrm(int signo)
{
    printf("bell's ringing\n");
}

unsigned int mysleep(unsigned int nsecs)
{
    struct sigaction newact,oldact;
    unsigned int unslept;
    newact.sa_handler=sig_alrm;
    sigemptyset(&newact.sa_mask);
    newact.sa_flags=0;
    sigaction(SIGALRM,&newact,&oldact);

    alarm(nsecs);
    pause();

    unslept=alarm(0);
    sigaction(SIGALRM,&oldact,NULL);
    return unslept;
}

int main()
{
    while(1)
    {
        mysleep(2);
        printf("Two seconds passed\n");
    }
    return 0;
}
  • 在调用pause等待时,内核可以切换别的进程运行。
  • nsecs后,闹钟超时,内核发送SIGALRM信号给此进程。
  • 从内核态返回该进程的用户态之前,处理未决信号。发现SIGALRM的处理函数为sig_alrm.
  • 切换到用户态执行sig_alrm,同时SIGALRM加入信号屏蔽字。
  • 执行完sig_alrm,SIGALRM信号解除阻塞,执行系统调用sigreturn返回内核态。
  • 回到用户态执行用户主程序。

在这里插入图片描述

可重入函数

当捕捉到信号时,不论主程序进行到哪里,都会跳到信号处理函数执行,从信号处理函数返回后再继续执行主程序。

信号处理函数是一个单独的流程,因为他和主程序时异步的,二者不存在调用和被调用的关系,并且使用的是不同的堆栈空间。引入信号处理函数使得一个进程具有多个控制流程,如果这些控制流程访问相同的全局资源(全局变量、硬件资源等),就可能会出现冲突,如下例子:

在这里插入图片描述

main函数调用insert函数向一个链表head中插入节点node1,刚做完 ① 的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数。

sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步 ② ③ 都做完之后从sighandler返回内核态。

再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第 ① 步之后被打断,现在继续做完第 ④ 步。

结果是,main函数和sighandler先后向链表中插入两个节点,而只有node1真正插入链表中了,node2因为再也找不到从而造成内存泄漏。

🚩注意:

像上例这样, insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入, insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数

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

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

sig_atomic_t类型与volatile限定符

sig_atomic_t

在上面的例子中,main和sighandler都调用了insert函数则有可能出现链表头插错乱的情况,其根本原因在于insert的函数操作要分两步完成,不是一个原子操作

如果这两步操作必定会一起做完,中间不可能被打断,就不会出现错乱了。在之后的线程篇中会讲到如何保证一个代码段以原子操作完成。

如果对全局数据的访问只有一行代码,是不是就是原子操作呢?

如main函数和sighandler函数都对一个全局变量赋值可不可能出现错乱情况?

long long a;
int main(void)
{
    a=5;
    return 0;
}

现调试进入汇编:

                a=5;
8048352: c7 05 50 95 04 08 05 movl $0x5,0x8049550
8048359: 00 00 00
804835c: c7 05 54 95 04 08 00 movl $0x0,0x8049554
8048363: 00 00 00

虽然C代码只有一行,但是在32位机上对一个64位的long long变量赋值需要两条指令完成,因此,它并不是一个原子操作。同样地,读取这个变量到寄存器需要两个32位寄存器才放得下,也需要两条指令,不是原子操作。可以设想一种时序, main和sighandler都对这个变量a赋值,最后变量a的值发生错乱。

如果在64位机上编译,可以用一条指令完成,则是原子操作。同理,如果a是32位的int变量,在32位机上是原子操作,但是16位机上就不是了。

如果程序需要一个变量,保证其读写都是原子操作,为了解决平台的相关问题,C标准定义了一个类型 sig_atomic_t ,在不同的平台下会取不同的类型,32位机上定义sig_atomic_t为int类型。

volatile

🚩注意:

使用sig_atomic_t的类型并不代表着万事大吉,此时还需注意另外一个情况:

#include <signal.h>

sig_atomic_t flag=0;
void handler(int signo)
{
    printf("change flag to 1\n");
    flag=1;
}

int main()
{
    signal(SIGINT,handler);
    while(!flag);//等待信号抵达
    printf("process quit\n");
    return 0;
}

在main函数中首先注册某个信号的处理函数sighandler,然后在一个while死循环中等待信号发生,如果信号递达执行sighandler,从而改变flag,这样再次回到main函数时就可以退出while循环。

在这里插入图片描述

可是当编译器优化的级别比较高时,代码的执行结果可能并不如我们所设想的那样。注意:使用gcc编译时,选项 -O3使得编译器的优化级别最高。

在这里插入图片描述

发现在发送SIGINT信号后,执行了信号处理函数,flag也被我们改变成了1,然而死循环并没有被制止,这是为什么呢??

是编译器优化的失误吗?并非如此,目前用户程序只有单一的执行流程,在我们在主程序中没有改变flag的值,那么flag的值就没有理由会变,没有必要反复去内存中读取,所以编译器就把flag存到了寄存器中。

之所以程序中存在多个执行流程,是因为调用了特定平台的特定库函数,比如signal,sigaction,这些不是C语言本身的规范,编译器自然也无法识别程序中存在多个执行流程。现在内存中的flag被信号处理函数修改为1,但是寄存器里的flag依旧为0。

C语言提供了 volatile 限定符,如果用volatile修饰,可以确保本条指令不会因编译器的优化而省略,且要求每次直接读值。编译器将始终从内存中读取flag的值。

在这里插入图片描述

🚩 需要volatile限定的情况:

  • 变量的内存单元中的数据不需要写操作就可以自己发生变化,每次读上来的值都可能不一样。
  • 即使多次向变量的内存单元中写数据,只写不读,也并不是在做无用功,而是有特殊意义的。

什么样的内存单元会具有这样的特性呢?肯定不是普通的内存,而是映射到内存地址空间的硬件寄存器,例如串口的接收寄存器属于上述第一种情况,而发送寄存器属于上述第二种情况。

sig_atomic_t类型的变量应该总是加上volatile限定符,因为要使用sig_atomic_t类型的理由(原子读取)也正是要加volatile限定符的理由。

SIGCHLD 信号

在进程篇中讲过使用wait和waitpid函数来清理僵尸进程,父进程可以采用阻塞的方式等待子进程,也可以非阻塞的查询子进程是否退出(即轮询方式)。采用前者,父进程阻塞就不能处理自己的工作了,采用后者,父进程在处理自己工作的同时轮询子进程是否退出,降低了父进程的工作效率。

事实上,子进程在终止时会向父进程发送 SIGCHLD 信号,该信号的默认处理动作是忽略,作为父进程可以自定义该信号的处理函数,这样父进程便可以专心处理自己的工作而不必在意子进程,直到收到 SIGCHLD 信号,在信号处理函数中调用wait/waitpid清理子进程即可。

自定义SIGCHLD信号处理函数

我们现在就编写程序实现如下功能,父进程fork子进程,子进程调用exit(2)终止,父进程自定义SIGCHLD信号的处理函数,在其中调用waitpid获得子进程的退出状态并打印。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
void handler(int signo)
{
    printf("catch signal:%d\n",signo);
    int ret=0;
    while((ret=waitpid(-1,NULL,WNOHANG))>0)
    {
        printf("wait child(%d) success\n",ret);
    }
}
int main()
{
    signal(SIGCHLD,handler);
   pid_t id;
   id=fork();
   if(id==0)
   {//child
       printf("child process:%d\n",getpid());
       sleep(3);
       exit(2);
   }
   //father
    while(1);

    return 0;
}

在这里插入图片描述

🚩注意:

  1. 之前谈过,Linux对于block的重复信号只会记录一次,而在我们执行信号处理函数的过程中,SIGCHLD会加入信号屏蔽字,若有多个子进程在此期间终止,我们的handler函数只会清理第一个递达SIGCHLD信号的子进程,所以需要使用while来不断清理在此期间终止的子进程。
  2. 使用waitpid函数清理僵尸进程的好处在于参数:WNOHANG的非阻塞轮询等待。由于此刻使用的是while,使用阻塞等待的话,如果后续等不到子进程,那么就会阻塞在此处。所以轮询在回收完所有可回收的子进程后会离开while循环。

这样父进程就只需关心自己的工作即可,收到信号会自动清理子进程。

⭐tips:

显式的用signal或sigaction函数将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户自定义的忽略通常没有区别,此为特例,且对于Linux有用。


-end-

青山不改 绿水长流

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值