Linux进程信号

1. 信号的概念

1.1 查看信号

使用kill -l可以查看所有的信号。

在这里插入图片描述

其中,131号信号时普通信号,32、33信号不存在,3464号信号是实时信号,我们这里的学习主要是普通信号。

1.2 概念

信号:Linux系统提供的一种,向指定进程发送特定事件的方式,收到信号的进程要做识别和处理

信号的产生是异步的:一个进程在执行的过程中,不知道什么时候能收到信号,信号的发送和进程的执行是两条线,这就是异步。

1.3 信号处理的常见方式

  1. 默认动作:可以通过man 7 signal查看信号的默认动作

    在这里插入图片描述

  2. 忽略动作:忽略信号,不做处理

  3. 自定义处理—信号的捕捉:不想收到信号后执行默认的动作,而是执行自定义的处理,这就是对信号的捕捉

    #include <signal.h>
    
    typedef void (*sighandler_t)(int);
    
    sighandler_t signal(int signum, sighandler_t handler);
    

    作用:捕捉信号,只需要执行一次,从此以后,只要接收到这个信号,就会一直被捕捉。

    参数:

    • signum:指定的信号
    • sighandler_t:函数指针,指向捕捉到信号以后,需要执行的动作,其中int类型的参数为收到的信号,也可以使用宏SIG_IGN和SIG_DFL,分别代表忽略信号和信号的默认处理动作。

    返回值:

    • 成功返回信号处理程序的先前值
    • 失败返回SIG_ERR,错误码被设置

    测试代码:

    #include <iostream>
    #include <signal.h>
    #include <unistd.h>
    
    void hander (int sig)
    {
        std::cout << "get a sig: " << sig << std::endl;
    }
    
    int main()
    {
        signal(1, hander);
        signal(2, hander);
        signal(3, hander);
        while (true)
        {
            std::cout << "hello world, pid: " << getpid() << std::endl;
            sleep(1);
        }
    
        return 0;
    }
    

    在这里插入图片描述

    在上面的代码中,无法在终端中使用ctrl+c将这个进程杀掉,因为ctrl+c就是向终端中正在运行的进程发送2号信号,此时如果使用ctrl+c,发出的2号信号会被捕捉。

    补充:

    • ctrl+\的作用是向进程发送3号信号,作用也是杀死进程

    • 1~31号信号中,6号信号虽然可以被捕捉,但是执行完后,进程还是会被杀死

    • 1~31号信号中,9号信号不允许被捕捉,即使执行了signal,9号信号依旧不会被捕捉

1.4 信号的发送和保存

在进程的task_struct中,存在一个uint32_t类型的整数(无符号32位整数),这个整数是一张位图,初始时位0,每当系统向这个进程发送信号,就是将对应位图的位置从0修改成1。

task_struct是在内核中的,只有OS才能对内核中的数据进行修改,所以无论以什么方式发送信号,本质上都是通知OS,然后让OS发送信号。

2. 信号的产生

2.1 发送信号的系统调用

2.1.1 kill
#include <signal.h>

int kill(pid_t pid, int sig);

功能:向指定进程发送指定信号

参数:

  • pid:指定进程id
  • sig:指定信号

返回值:

  • 成功返回0
  • 失败返回-1
2.1.2 raise
#include <signal.h>

int raise(int sig);

功能:给当前进程发送指定信号

参数:

  • sig:指定信号

返回值:

  • 成功返回0
  • 失败返回非0
2.1.3 abort
#include <stdlib.h>

void abort(void);

功能:给当前进程发送6号信号,作用是终止当前进程

注意:6号信号可以被捕捉,但是执行完自定义的行为后,该进程还是会被终止。

2.2 由软件条件产生信号

#include <unistd.h>

unsigned int alarm(unsigned int seconds);

功能:未来的seconds秒后,对当前进程发送14号信号。

参数:

  • seconds:指定的秒数,如果设置0,代表取消警报

返回值:

  • 返回先前设定的警报时间到期的时间剩余的秒数
  • 如果之前没有设定警报,返回0

2.3 硬件异常产生信号

比如除0错误/解引用空指针,因为当这个数据传到硬件中时,被硬件判断出执行的话会出现异常,把异常反馈给OS后,OS发送信号给进程,进程被杀死,除0错误将会发送8号信号,空指针解引用将会发送11号信号。

2.4 核心转储

对于man 7 signal手册中action栏的内容中Term和Core虽然都代表终止进程,但是是有区别的,Term终止异常进程,Core不仅终止进程,还会形成一个debug文件(进程退出的时候的镜像数据),方便我们调试,但是这个功能默认是被关闭的,可以通过ulimit -a查看:

在这里插入图片描述

其中core file size这一行为0,代表这个功能默认被关闭了,可以使用ulimit -c unlimited打开:

在这里插入图片描述

这里我们可以使用除零错误的代码来测试一下,除零以后,该进程会收到SIGFPE信号,也就是8号信号:

int main ()
{
    int a = 10 / 0;

    return 0;
}

在这里插入图片描述

此时当前目录还是没有生成core文件,可以通过sudo bash -c "echo core > /proc/sys/kernel/core_pattern "来让core文件生成到当前目录:

在这里插入图片描述

此时生成了对应的文件。

而之前在进程控制中,waitpid得到的status中,有一个core dump标志,就是这个地方使用的,如果core dump标志被置为1,就说明生成了core文件。

在这里插入图片描述

有了这个code文件以后,我们可以使用gdb调试工具打开可执行程序,然后直接输入core-file core,就可以直接跳转到程序出问题的地方:

在这里插入图片描述

这种方式就可以比我们自己一行一行去debug方便一些。

3. 信号的保存(信号阻塞)

3.1 相关概念

  • 信号递达:实际执行信号的处理动作
  • 信号未决:信号从产生到递达之间的过程
  • 阻塞信号:进程可以选择阻塞一些信号,当这个信号产生后,永不递达,一直处在未决状态,直到进程解除对该信号的阻塞

3.2 信号在内核中的表示

在task_struct中,维护了三张表,分别是block、pending、handler,其中block和pending表本质上是两张位图,block维护有哪些信号被阻塞,pending表维护有哪些信号未决,handler表是一张函数指针表,指向相应信号的处理函数。

3.3 sigset_t类型

这个类型用于存储block、pending这两张表,本质上是一个信号集。

block的这张表,也就是阻塞信号集,也被称为信号屏蔽字。

3.4 信号集操作函数

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);

int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
int sigpending(sigset_t *set);
  • sigemptyset:初始化set指向的信号集,将全部信号置0,代表目前不含任何信号
  • sigfillset:初始化set指向的信号集,将全部信号置1,代表目前包含所有信号
  • sigaddset:添加指定信号
  • sigdelset:删除指定信号
  • sigismember:判断是否包含某种信号

  • sigprocmask:读取或更改信号屏蔽字

    • 参数:

      • how:有三个选项:

        在这里插入图片描述

      • set:输入型参数,一个信号集,根据how和set来修改当前的屏蔽字

      • oset:输出型参数,返回修改前的屏蔽字

  • sigpending:获取当前进程的pending位图。

4. 信号的处理

4.1 捕捉信号

在这里插入图片描述

上面这张图是信号捕捉的过程,上面的框是用户态、下面的框是内核态,进程接收到信号后可能不会立即处理,而是等到合适的时候处理,这个合适的时候就是在内核态返回用户态的时候。

4.2 内核态/用户态

4.2.1 再谈地址空间

在这里插入图片描述

对于进程的地址空间,之前学习的都是上图中下半部分的用户空间,而内核空间是干什么用的?

回顾用户空间,用户空间会通过一张页表来映射到自己的代码和数据以及第三方库,这里的页表我们称之为用户级页表。

在我们计算机启动的时候,第一个被加载到内存中的软件就是OS,随之产生的还有另一张页表,我们称之为内核级页表。

对于整个系统中,每个进程都有自己的用户级页表,但是内核级页表只有一张,而进程地址空间中,内核空间,就是通过内核级页表映射到OS,而具体而言,内核空间中存放的就是一个一个的系统调用的虚拟地址,这些虚拟地址可以通过内核级页表映射到OS中来执行相应的系统调用。

OS不相信任何人,进程要访问内核空间时要收到一定约束,进程只可以通过系统调用来访问OS,而不能直接接触到OS的数据。

4.2.2 键盘的输入过程

我们通过键盘输入的时候,时间是随机的,OS是怎么知道键盘什么时候被按下?

键盘是一个外设,对于所有外设都可以给CPU发送一个消息,我们称之为硬件中断,而每个外设都有自己的中断号,键盘也一样,当键盘输入的时候,键盘就会给CPU发送一个硬件中断,把自己的终端号放到CPU中,这样CPU就能够读到这个中断号。

在OS中,维护着一个函数指针数组(中断向量表),这个函数指针数组分别指向执行不同硬件的函数,而所谓的中断号,是被精心设计过的,这个中断号就对应着要执行的函数的下标,也就是说,假设键盘的中断号是3,键盘输入的时候,将3放在CPU的一个寄存器中,CPU读到了这个信号,马上停止手中的其他工作,然后在操作系统维护的这个中断向量表中找到对应的函数,然后执行。

而这个过程似曾相识,和上面说到的信号的机制类似,不过信号是纯软件,而中断是硬件+软件,信号就是模仿中断的过程来设计的。

4.2.3 OS如何正常运行
4.2.3.1 理解系统调用

在OS中有一个函数指针数组,里面存着所有的系统调用,我们只要找到这个数组中对应的数组下标,就能执行系统调用,而这个下标,就是系统调用号。

执行任何系统调用都需要:

  1. 系统调用号
  2. 系统调用函数指针表

而CPU是如何执行系统调用的,首先执行系统调用之前,会将对应的系统调用好放在CPU的指定寄存器中,然后CPU形成中断,此时CPU就知道需要执行系统调用了,此时CPU拿到寄存器里的系统调用号,直接就去函数指针表中找到相应的系统调用,然后执行。

这里的中断是CPU内部自己形成的中断,也被称为陷阱/缺陷。

4.2.3.2 OS是如何运行的

OS在我们开机以后就一直在运行,直到电脑关机,这意味着OS本质上就是一个死循环,一直在运行。

而在我们的计算机中,有一个叫做时钟的硬件,一直以很短的时间间隔在给CPU发送中断,而CPU接收到这个中断以后,就意味着当前调度的工作时间片已经结束了,然后去调度下一个任务,而CPU执行的任务就是OS在开机时初始化好的一个个方法,比如系统调用等,而OS在初始化工作完成后,就一直循环,等待CPU的调度。

4.2.4 内核态和用户态

由于OS不相信用户,在用户跳到内核空间执行系统调用的时候,是无法直接跳转过去的。如何做到不让用户直接跳转过去的?

用户必须在特定的条件下才可以跳转到内核空间。如何做到在特定的条件下才能跳转的?

要实现上面两种效果,需要硬件(CPU)的配合。

在CPU中有一个寄存器,其中有两个比特位,这两个比特位如果是0,说明CPU可以执行内核的代码,如果是3,说明CPU只能执行用户的代码。

而用户态和内核态之间的切换就是这两个值的转变。

4.3 信号捕捉的方法

信号捕捉除了使用signal系统调用,还可以使用sigaction。

#include <signal.h>

int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

参数:

  • signo:信号编号
  • act:输入型参数,输入一个结构体,里面记录着我们的自定义函数
  • oact:输出型参数,输出改变前的信息,方便后续恢复

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);
};

对于捕捉信号,此时如果正在对一个信号进行处理,那么此时会对这个信号进行屏蔽,当执行完这个信号才会解除屏蔽,这意味着不允许同一个信号进行递归式的处理。

但是假设此时捕捉的是2号信号,虽然2号信号会被屏蔽,但是其他信号还是可以被继续捕捉,如果不想其他信号在执行当前信号的时候被捕捉,就可以通过sa_mask传入指定的信号集,让信号集中的信号也被屏蔽。

此时,同样的,也有部分信号不能被屏蔽,比如9号信号。

5. 可重入函数

现在来看一种场景:

在这里插入图片描述

这段代码是链表的一个头插,就上面的代码而言,头插的过程是需要执行两行代码的,此时如果在进行头插的时候,收到了一个信号,然后当它执行完第一行代码的时候,恰好时间片到了,此时由用户态切换为内核态,下次被调度的时候,需要将内核态切换成用户态,此时首先判断是否有未阻塞并且未决的信号,然后发现存在一个信号,然后执行这个信号指定的函数,如果这个信号自定义的函数中,又调用了一个头插,那么就会造成一种情况,如上图,会导致一系列的问题,导致系统的错乱。

像上面这种,一个函数还没有返回的时候,又被进入,这个行为被称为重入。

当一个函数被重入后,会导致一些错误的,这种函数被称为不可重入函数。

相反,如果一个函数被重入后,不会导致问题的发生,这种函数就被称为可重入函数。

简单判断:如果一个函数只使用了自己函数内部的局部变量和资源,一般而言,这种函数大都是可重入的,反之,如果一个函数使用了函数外部的变量和资源,那么这个函数大都是不可重入的。

6. volatile

先看一段代码:

#include <iostream>
#include <signal.h>

bool gflag = false;

void handler(int sig)
{
    std::cout << "get a signo: " << sig << std::endl;

    gflag = true;
}

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

    while (!gflag);

    std::cout << "process quit normal" << std::endl;

    return 0;
}

在这里插入图片描述

在这段代码中,就是让main函数一直死循环,直到接收到2号信号后,再退出,执行的结果也和我们预测的结果一样。

但是在现在的编译器中,很多编译器会对我们的代码进行一些优化,对于上面的代码,编译器发现,gflag的值在main函数中不会发生任何改变,那么此时编译器就会让刚开始读取到的gflag一直放在寄存器中,不去内存中读取更新新的gflag,我们以优化的方式来编译执行就可以看到:

在这里插入图片描述

当我们使用O1优化选项进行编译的时候,就发现,此时无论发送多少个2号信号都无法让进程停止,但是从打印出来的信息来看,我们的handler函数确实也执行了,这也就应证了我们上面的说法,这也就是编译器过度优化导致的问题。

而volatile关键字修饰的作用就是,不允许编译器对这个变量做优化,每次使用的使用都必须重新从内存中读取(保持内存的可见性),修改一下代码:

#include <iostream>
#include <signal.h>

volatile bool gflag = false;

void handler(int sig)
{
    std::cout << "get a signo: " << sig << std::endl;

    gflag = true;
}

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

    while (!gflag);

    std::cout << "process quit normal" << std::endl;

    return 0;
}

在这里插入图片描述

此时,就不会出现上面的问题了。

7. SIGCHLD信号 - 重谈进程等待

子进程退出的时候,其实并不是静悄悄的退出的,在子进程退出的时候会给父进程发送SIGCHLD信号(17号信号)。

在之前,我们的父进程等待子进程都只能主动的去调用waitpid去等待子进程,而现在我们就可以做一件事:我们对于17号信号进行自定义,在自定义的handler函数中去等待子进程的退出,此时父进程就不必主动的去调用waitpid,可以干自己的事情,每当子进程退出,收到信号以后,它就会自动的去调用这个handler函数,去等待子进程。

但是这里还是会存在一些问题,如果有多个子进程同时退出,这些子进程同时向父进程发送信号,那么父进程可能只收到一次信号,就只等待一次,为了解决这个问题,我们可以在等待的时候,循环的进行等待,直到等待完所有的子进程(等待失败),再跳出循环。

实际上,如果父进程不在乎子进程的退出信息,还有一种方式,就是使用sigaction手动的将17号信号设置为SIG_IGN,这样fork出来的子进程会在终止时自动清理掉,不会产生僵尸进程,也不会通知父进程,此方法对于Linux可用,其它系统不一定可用。

Logo

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

更多推荐