Linux初阶——信号

时间:2024-10-28 18:27:05

一、预备

1、信号的处理方式

1.1. 默认动作

当收到一个信号时,就执行这个信号的默认动作。

1.2. 忽略

当收到一个信号时,就忽略执行这个信号的默认动作。

1.3. 自定义动作

当收到一个信号时,就执行信号的自定义动作。

2、硬件中断

你有没有想过一个问题:电脑是怎么把键盘上敲的字读进来的呢?

我们知道,我们的电脑是当键盘有数据的时候才会读的,而当键盘没数据时,电脑是不会读的。所以,我们首先需要解决的问题是:电脑怎么知道键盘有数据呢?就像上图一样,其实键盘这种硬件和 CPU 虽然没有直接的导线相连,但会通过其他组件与 CPU 的某个针脚间接通过导线相连。而当键盘有数据时,键盘会通过导线向 CPU 发送高电平,然后 CPU 会用内部的电容来储存这个电平,等到空闲时才会再处理这个信号。然后当 CPU 处理这个信号时,因为这个信号是 5 号针脚传来的,因此 CPU 会去内存的中断向量表调取中断向量表的 5 号方法,即读键盘的方法。然后会把键盘的内容读到键盘的文件缓冲区,最后操作系统会把文件缓冲区的内容刷到内核缓冲区,再到用户缓冲区。

3、常用信号

红框框住的才是常用的信号

4、相关函数

4.1. signal 函数

参数介绍

  • 返回类型:void
  • sig:信号编号。
  • func: 一个返回类型是 void ,参数是 int 类型的函数指针。其中 int 类型的参数是用来传信号编号的。

这个函数就是负责给某个信号定义一个自定义动作。当该进程捕捉到 sig 信号时,进程就会执行 signal 函数里的自定义动作。 

4.2. kill 函数

参数介绍

  • 返回类型:成功发送返回 0,失败返回 -1.
  • pid:进程的 pid。
  • sig:给某个进程发送 sig 号信号。 

4.3. raise 函数

 参数介绍

  • sig:信号编号。

这个函数就是给调用该函数的进程自己发送 sig 号信号。 

4.4. abort 函数 

这个函数就是给调用这个函数的进程自己发送 abort 信号。但值得注意的是,就算我们给 abort 设置了 signal 函数,当进程执行到 abort 函数时,不仅会执行 abort 的自定义动作,还会 abort 掉自己。

5、前台进程 & 后台进程

我们的键盘输入只能输给前台进程,不会输给后台进程。而且在操作系统中只能有一个前台进程,但可以有多个后台进程。

二、信号的产生 

1、键盘组合键(如 control + C / control \)

2、kill 命令(如 kill -signo <pid>)

3、系统函数(kill, raise, abort...)

4、异常

4.1. 硬件产生的异常

什么是硬件产生的异常呢?这里我分别用除零错误和空指针解引用两个例子来说明。

4.1.1. 除零错误

CPU 通过 PC 指针得知正在执行的语句,而当 CPU 执行到上图的语句时,根据冯诺依曼结构, CPU 内部是由计算器和控制器组成的,因此 CPU 在执行此语句时会发现这是除零错误;然后 CPU 会把内部的状态寄存器的某个比特位(用来表示是否除零错误的那一位)从 0 置 1;然后操作系统发现 CPU 的状态寄存器的这个比特位变成 1 后,就会向进程发送除零错误的信号;然后进程就会调用相对应的处理这个信号的方法。


但是,为什么会在这种情况下陷入死循环呢?其实原理很简单。因为 CPU 运算时遇到了除零错误, 而 CPU 认为一旦运算出错,那后面的代码跑了也没意义,因此 CPU 在向操作系统发送信号后,就不会继续向下继续执行代码了。然而,由于我们用 signal 函数重置了对除零错误的处理方法,而这个方法是不会让进程退出的;但是 CPU 因为除零错误,停在了第 17 行,不会执行到第 18 行(return 0)让进程退出;因此进程就一直运行,CPU 的状态寄存器就一直向 OS 报除零错误,然后 OS 就一直向进程发信号,进程就一直调 signal 方法,所以就死循环了。

4.1.2. 空指针解引用

当 CPU 执行到野指针解引用的语句时,由于它是野指针,所以必定会在页表映射时地址转换失败; 当页表地址转换失败时,位于 CPU 内部的 MMU 就会检测到错误,同时 CPU 内部会有一个寄存器专门存报错的虚地址;然后 CPU 就会发信号给操作系统;然后操作系统就会发信号给进程;最后进程就会调用对应的信号处理方法。

4.1.3. OS 如何向进程发送信号
struct task_struct
{
    int signal; // 0000 0000 0000 0000 0000 0000 0000 0010 此时 OS 向进程发送了 1 号信号
};

其实就是 OS 会向进程的 PCB 里的 signal 位图的某一位置 1,表示某号信号。举个例子,当 OS 向进程 PCB 的 signal 的 1 号(最低位是 0 号位)置 1 时,就表示向该进程发送了 1 号信号,然后进程就会执行处理方法了。因此,OS 向进程发送信号的本质其实就是 OS 向进程 PCB 的 signal 位图写东西。所以,我们可以发现,OS 向进程发送信号的过程是不是和硬件中断很像呢?

4.1.4. 进程如何给 OS 发送信号

在这之前,我们得先介绍一下 CPU 内部的状态寄存器。状态寄存器其实是一个有若干位的寄存器,且位于 CPU 内部。而且状态寄存器里的每一位都是有不同的含义的,比如不同位就代表由不同原因产生的报错。

我们再回来谈谈操作系统。操作系统会查看 CPU 的状态寄存器,当看到寄存器的某一位为 1 时,就知道 CPU 报的是什么错了,就相当于 CPU 把报错信号发给操作系统了。

4.2. 软件产生的异常

举个例子,管道关闭读端,写端还在写的话,直接报错。

5、软件产生的信号(以 alarm 为例)

5.1. 关于 alarm 函数

这个函数就是输入一个时间,然后当时间耗尽之后会向进程发送 14 号信号(SIGALRM)。而返回上一个 alarm 调用中剩余的秒数。如果没有活动的定时器,则返回 0。

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

using namespace std;

void handler(int signo)
{
    cout << "...get a sig, number: " << signo <<endl; //我什么都没干,我只是打印了消息
    int n = alarm(5);
    cout << "剩余时间:" << n << endl;
}

int main()
{
    signal(SIGALRM, handler);
    alarm(50);
    while (true)
    {
        cout << "proc is running... pid:" << getpid() << endl;
        sleep(1);
    }
    return 0;
}

5.2. 软件如何产生信号 

三、信号的保存

1、递达(handler 表)

信号的递达其实就是进程在收到信号后,对信号的处理方法。

2、未决(未处理的信号)(pending 表)

信号的未决就是进程在收到信号后,如果该信号并没有被处理,那么 pending 位图的对应位就会置为 1,如果该信号已经被处理了,那 pending 表的对应位就置为 0 了。

3、屏蔽(屏蔽信号)(block 表)

信号的屏蔽就是如果 block 位图里的 2 号位为 1,那么就代表 2 号信号被屏蔽了;即使之后收到了 2 号信号,pending 位图的 2 号位被置为 1,进程也不会调用 2 号信号的处理方法。

4、相关系统函数

4.0. 关于 sigset_t 类型

typedef struct {
    unsigned long __val[_NSIG_WORDS];
} sigset_t;

4.1. sigemptyset

这个函数传的是输出型参数,因此这个函数的作用就是把信号集清空。 

4.2. sigfillset

和上个函数一样,传的也是输出型参数,因此这个函数的作用就是直接把信号集置满,即每一个比特位都为 1. 

4.3. sigaddset

这个函数的作用就是把 signo 号信号加入到 *set 信号集中。 

4.4. sigdelset

这个函数的作用就是从 *set 信号集中删掉 signo 号信号。  

4.5. sigismember

因为这个函数的 set 是输入型参数,因此这个函数的作用就是检查 signo 号信号是不是在该信号集当中。 

4.6. sigprocmask

参数介绍

  • restrict_set:输入型参数,用 restrict_set 修改进程中已有的 block 信号集。
  • restrict_oset:输出型参数,带出原来的 block 信号集。
  • how: 如何用 restrict_set 修改进程中已有的 block 信号集。

how 的宏

  • SIG_BLOCK:屏蔽信号,将新信号添加到当前的 block 信号集中,原来的屏蔽信号保持屏蔽。
  • SIG_SETMASK:直接把 restrict_set 的信号集赋给当前的 block 信号集。
  • SIG_UNBLOCK:对进程的 block 信号集删掉其中的 restrict_set 信号集里的信号。

4.7. sigpending

这个函数的 set 参数是输出型参数,因此该函数的功能是把进程的 pending 位图带出来。如果成功返回 0,失败返回 -1. 

四、信号的处理

1、用户态 & 内核态

简单来说,如果进程要访问库函数或者是我们自己写的函数,那么进程就是处于用户态;而如果进程要访问系统函数,或者是系统的数据结构(比如前面的 block 和 pending 位图,或进程的 task_struct 等),那么进程就是处于内核态的。

但如果想逻辑自洽一点的话,那肯定是没有那么简单的。而要谈用户态和内核态,那么我们就又要谈到进程地址空间了。

1.1. 进程地址空间

我们知道,进程地址空间里的虚拟内存是这样划分的:

但是内核空间里的地址并不会通过普通的页表实现虚地址和物理地址的映射,而是通过内核页表来实现虚地址和物理地址之间的映射的;而且这个内核页表也非常特别,每个操作系统只有 1 个内核页表。换句话说,如果有 500 个进程,那么普通页表就有 500 张,但内核页表就只有 1 张。

1.2. 内核态 & 用户态

从进程的角度来说,当进程要调系统函数时,执行流就会从进程地址空间的代码区跳到内核空间中去调系统函数。然而,操作系统为了防止用户恶意修改其内核数据,于是便给进程设置了权限,当进程符合进入内核的权限时,才能访问内核的数据和代码;如果没有进入内核的权限,那么就无法访问内核的数据和代码。而这个权限就是 ecs 寄存器。当进程要进入内核时,ecs 寄存器的低 2 位为 00,表示进程可以访问内核的代码和数据,但无法访问用户的代码和数据;而当进程想访问用户的代码和数据时,ecs 寄存器的低 2 位为 11,表示进程可以访问用户的代码和数据,但无法访问内核的代码和数据。

用户态与内核态之间的转换

因此,到底什么叫用户态,什么叫内核态呢?当 ecs 寄存器的低 2 位为 00 时,进程就处于内核态,当 ecs 寄存器的低 2 位为 11 时,进程就处于用户态。

2、信号何时被处理

进程从内核态变成用户态的时候,就会做信号的检测和处理。意思就是说,当进程由内核态变成用户态时,就会访问这 3 张表(block、pending、handler),寻找在同一行下,block 和 pending 的值分别为 0,1 的下标(以下图为例的话,下标就是 4,即 4 号信号),然后通过函数数组 handler 调用这个信号的处理方法。

3、信号产生到处理过程(抽象版) 

  • A:在用户态执行用户代码。
  • B:在内核态执行系统函数(如 fork 等)和向进程发信号(如果有信号的话)。
  • C:执行信号检测与处理。如果 handler 表对该信号的处理方法是默认的,那么就调默认的方法(比如退出,就不会去到 D 状态了);而如果处理方法是忽略,那么就直接回到 A 状态,并继续执行下一句代码。
  • D:如果 handler 表对该信号的处理方法被自定义了,即用户用 signal 重置了信号处理方法,就去用户态调自定义的信号处理方法
  • E:调用系统函数 sigreturn,通过 sigreturn 获取原来调系统函数的地方。比如在第 17 行调了 pipe 函数,那么就可以通过 sigreturn 函数让 pc 指针指回第 17 行代码;然后再接着执行第 18 行,第 19 行……

五、信号的捕捉

1、sigaction 函数

1.1. struct sigaction 结构体

因为除了红框框住的,其他都是和实时信号有关的,因此只关注红框的参数就行了。

参数介绍

  • sa_mask:可以理解成这是一个信号集,而里面存的是需要屏蔽的信号。
  • __sa_handler:和 signal 函数的 handler 一样,就是一个处理方法。

 1.2. sigaction 函数

参数介绍

  • sig:要捕捉的 sig 号信号。
  • act:捕捉到 sig 号信号后,对 sig 号信号的处理方法。
  • oact: 该信号原来的处理方法。
  • 返回:如果成功返回 0,失败返回 -1.

1.3. 一些细节 

当 sigaction 或 signal 函数捕捉到信号时,操作系统会自动把这个信号屏蔽掉,等到  sigaction 或 signal 函数运行完后(当 sigaction 或 signal 函数运行完后,就表示已经把 sigreturn 函数也运行完了),才会取消对该信号的屏蔽。否则如果在调用 signal 或 sigaction 函数时又收到了信号,那么进程的状态就会这样变化:E -> C -> D(参考前面的“信号产生到处理”的图),就构成死循环了。

2、可重入函数

什么是重入函数呢?我们以头插链表为例子。假设 insert 函数每调一次就会头插一个节点。

通过上图,我们可以看出,当进程刚执行完 1,但还没执行 4 时,此时进程收到了信号,就会去执行处理方法,然后在处理方法里再做一次头插链表,然后再返回到第一次的 insert 函数。 

 

通过上图,我们可以看出,node2 并没有被指针维护,因此是无法被释放的,就会造成内存泄漏错误。而像这种被重复进入后就会出错的函数,就是不可重入函数;而即使重复进入后也不会出错的函数就叫可重入函数。

3、volatile 关键字

3.1. g++ 优化级别

g++ 有很多优化选项。其中 -O0 是没有优化的,而 -O1,-O2 是优化程度逐步变大。但这都不重要,重要的是我们可以看看下面的代码:

int flag = 0;

void handler(int signo)
{
     cout << "catch a signal: " << signo << endl;
     flag = 1;
}


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

     while(!flag); // 因为后续的代码都不会改变 flag 的值,因此 g++ 会优化 flag 变量,
                   // 把 flag 的值拷到寄存器里,以后要访问 flag 的值的话可以直接访问
                   // 寄存器,这样可以减少访问时间

     cout << "process quit normal" << endl;
     return 0;
}

正常来说 flag 是在栈上的,但是因为编译器对 flag 的优化,在程序运行时 CPU 如果要访问 flag 的值,就直接访问存 flag 的值的寄存器,而不会访问栈上的 flag。因此,如果我们对进程发 control + c 信号,由于 CPU 只访问寄存器上的 flag ,而不访问栈上的 flag,因此只是栈上的 flag 被改成了 1,而寄存器上的 flag 还是 0;所以除了打印 “catch a signal…” 以外,程序还是会死循环的。 

上面代码的运行结果:control + c 失效

3.2. 再谈 volatile

而 volatile 就是用来告诉编译器:不要把 flag 优化掉,就是说不要把 flag 的值拷到寄存器里,以至于不要让 CPU 访问寄存器里的 flag;取而代之的是,CPU 访问栈上的 flag。

加了 volatile 的运行结果

4、SIGCHLD 信号

4.1. 介绍 SIGCHLD 信号

当子进程结束时,会向父进程发送 SIGCHLD (17) 信号。因此其实我们可以利用这个信号来回收子进程。

4.2. 基于信号捕捉的子进程回收

void handler(int signo)
{
    sleep(5);
    pid_t rid;
    while ((rid = waitpid(-1, nullptr, WNOHANG)) > 0)
        cout << "I am process: " << getpid() << " catch a signo: " << signo
        << "child process quit: " << rid << endl;
}

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

    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            while (true)
            {
                cout << "I am child process: " << getpid() << ", ppid: " << getppid() << endl;
                sleep(5);
                break;
            }
            cout << "child quit!!!" << endl;
            exit(0);
        }
        sleep(1);
    }
    // father
    while (true)
    {
        cout << "I am father process: " << getpid() << endl;
        sleep(1);
    }

    return 0;
}