Linux进程信号信号处理

发布于:2025-07-31 ⋅ 阅读:(20) ⋅ 点赞:(0)

目录

1.信号的处理时机

1.1、信号处理情况

1.2、“合适” 的时机

2、用户态与内核态

2.1、用户态与内核态的概念

2.2、重谈进程地址空间

2.3、信号的处理过程

3. 信号的捕捉

3.1 内核如何实现信号的捕捉

3.2 sigaction

4.信号部分小结

5. 补充知识

5.1 可重入函数

5.2 volatile 关键字

5.3 SIGCHLD 信号

5.4 SIGCHLD 的忽略方式

1.信号的处理时机

在学习了信号的产生、阻塞与递达的机制后,我们接下来要讨论的是 信号的处理时机。信号的处理时机对于信号的响应和进程的稳定性有着直接影响。让我们详细解析这一部分内容。

1.1、信号处理情况

普通情况

普通情况 下,当信号没有被阻塞时,信号产生后会记录未决信息,但不会被立即递达或处理。进程会在 合适的时机 对信号进行处理。

特殊情况

特殊情况 下,信号被阻塞。当信号产生时,会记录未决信息,但信号不会立即递达。只有当信号的阻塞被解除时,信号才会递达并进行处理。就像气球被堵住空气无法释放,只有气球爆裂后,空气才会释放一样。

1.2、“合适” 的时机

信号的产生是 异步 的,即信号可能随时产生,而进程可能正在处理其他更重要的任务。为了避免信号立即中断进程正在做的工作(如 I/O 操作),信号需要在一个合适的时机进行处理。

合适的时机 发生在进程从 内核态 返回 用户态 时。这是信号的检测和处理时机。

2、用户态与内核态

信号的处理时机和 用户态内核态 之间的转换密切相关,理解这两者的概念对于深入理解信号的处理至关重要。

2.1、用户态与内核态的概念
  • 用户态:执行用户编写的代码时,CPU 处于用户态。

  • 内核态:执行操作系统代码时,CPU 处于内核态。操作系统的内核代码负责进程调度、系统调用、异常处理中断、文件管理等任务。

用户态和内核态是两种不同的状态,它们之间会发生相互切换。当进程需要执行系统调用、发生异常或处理中断时,它会从用户态切换到内核态。

用户态切换为内核态:

  • 进程时间片到期,需要进行进程切换。

  • 调用系统调用(如 openreadwrite)。

  • 发生异常、中断或陷阱。

内核态切换为用户态:

  • 进程切换完毕后,操作系统返回到用户态,继续执行用户进程。

  • 系统调用处理完成后,返回用户态。

  • 异常、中断等处理完毕后,恢复用户态。

2.2、重谈进程地址空间

首先简单回顾下 进程地址空间 的相关知识:

  • 进程地址空间 是虚拟的,依靠 页表+MMU机制 与真实的地址空间建立映射关系
  • 每个进程都有自己的 进程地址空间,不同 进程地址空间 中地址可能冲突,但实际上地址是独立的
  • 进程地址空间 可以让进程以统一的视角看待自己的代码和数据

 不难发现,在 进程地址空间 中,存在 1 GB内核空间,每个进程都有,而这 1 GB 的空间中存储的就是 操作系统 相关 代码 和 数据,并且这块区域采用 内核级页表 与 真实地址空间 进行映射

为什么要区分 用户态 与 内核态 ?

  • 内核空间中存储的可是操作系统的代码和数据,权限非常高,绝不允许随便一个进程对其造成影响
  • 区域的合理划分也是为了更好的进行管理

 所谓的 执行操作系统的代码及系统调用,就是在使用这 1 GB 的内核空间

进程间具有独立性,比如存在用户空间中的代码和数据是不同的,难道多个进程需要存储多份 操作系统的代码和数据 吗?

  • 当然不用,内核空间比较特殊,所有进程最终映射的都是同一块区域,也就是说,进程只是将 操作系统代码和数据 映射入自己的 进程地址空间 而已
  • 而 内核级页表 不同于 用户级页表,专注于对 操作系统代码和数据 进行映射,是很特殊的

当我们执行诸如 open 这类的 系统调用 时,会跑到 内核空间 中调用对应的函数

跑到内核空间 就是 用户态 切换为 内核态 了(用户空间切换至内核空间)

 这个 跑到 是如何实现的呢?

CPU 中,存在一个 CR3 寄存器,这个 寄存器 的作用就是用来表征当前处于 用户态 还是 内核态

  • 当寄存器中的值为 3 时:表示正在执行用户的代码,也就是处于 用户态
  • 当寄存器中的值为 0 时:表示正在执行操作系统的代码,也就是处于 内核态

通过一个 寄存器,表征当前所处的 状态,修改其中的 值,就可以表示不同的 状态,这是很聪明的做法

重谈 进程地址空间 后,得到以下结论

  1. 所有进程的用户空间 [0, 3] GB 是不一样的,并且每个进程都要有自己的 用户级页表 进行不同的映射
  2. 所有进程的内核空间 [3, 4] GB 是一样的,每个进程都可以看到同一张内核级页表,从而进行统一的映射,看到同一个 操作系统
  3. 操作系统运行 的本质其实就是在该进程的 内核空间内运行的(最终映射的都是同一块区域)
  4. 系统调用 的本质其实就是在调用库中对应的方法后,通过内核空间中的地址进行跳转调用

那么进程又是如何被调度的呢?

1.操作系统的本质
- 操作系统也是软件啊,并且是一个死循环式等待指令的软件
- 存在一个硬件:操作系统时钟硬件,每隔一段时间向操作系统发送时钟中断

2.进程被调度,就意味着它的时间片到了,操作系统会通过时钟中断,检测到是哪一个进程的时间片到了,然后通过系统调用函数 schedule() 保存进程的上下文数据,然后选择合适的进程去运行

2.3、信号的处理过程

当在 内核态 完成某种任务后,需要切回 用户态,此时就可以对信号进行 检测 并 处理

情况1:信号被阻塞,信号产生/未产生

信号都被阻塞了,也就不需要处理信号,此时不用管,直接切回 用户态 就行了

下面的情况都是基于 信号未被阻塞信号已产生 的前提

情况2:当前信号的执行动作为 默认

大多数信号的默认执行动作都是 终止 进程,此时只需要把对应的进程干掉,然后切回 用户态 就行了

情况3:当前信号的执行动作为 忽略

当信号执行动作为 忽略 时,不做出任何动作,直接返回 用户态

情况4:当前信号的执行动作为 用户自定义

这种情况就比较麻烦了,用户自定义的动作位于 用户态 中,也就是说,需要先切回 用户态,把动作完成了,重新坠入 内核态,最后才能带着进程的上下文相关数据,返回 用户态

内核态 中,也可以直接执行 自定义动作,为什么还要切回 用户态 执行自定义动作?

  • 因为在 内核态 可以访问操作系统的代码和数据,自定义动作 可能干出危害操作系统的事
  • 在 用户态 中可以减少影响,并且可以做到溯源

为什么不在执行完 自定义动作 直接后返回进程?

  • 因为 自定义动作 和 待返回的进程 属于不同的堆栈,是无法返回的
  • 且进程的上下文数据还在内核态中,所以需要先坠入内核态,才能正确返回用户态

注意: 用户自定义的动作,需要先切换至 用户态 中执行,执行结束后,还需要坠入 内核态

通过一张图快速记录信号的 处理 过程

3. 信号的捕捉

3.1 内核如何实现信号的捕捉

当信号到达进程时,如果信号的执行动作是用户自定义的(即信号捕捉),内核会在适当时机进入用户态执行自定义动作。由于信号和待返回的函数属于不同的堆栈空间,它们之间是独立的执行流,所以在执行用户自定义动作时,内核需要先进入内核态(通过 sigreturn(),然后再返回用户态(通过 sys_sigreturn()以完成信号处理。

3.2 sigaction

sigaction 是处理信号的更强大函数,相比 signal 函数,它提供了更多的功能,允许用户设置信号的处理方式。

  • sigaction 结构体包含几个字段,其中最重要的是 sa_handler,它指向一个用户定义的信号处理函数。

  • sa_mask 是一个信号集,它指定了在执行信号处理函数时需要屏蔽的信号。也就是说,信号处理函数在执行过程中,可以选择阻止某些信号的到达,直到处理完成。

下面是 sigaction 的定义:

struct sigaction {
    void (*sa_handler)(int);           // 自定义的信号处理函数
    void (*sa_sigaction)(int, siginfo_t *, void *);  // 用于实时信号
    sigset_t sa_mask;                  // 屏蔽信号集
    int sa_flags;                      // 一些选项,通常设为0
    void (*sa_restorer)(void);         // 用于实时信号,不用管
};

返回值:成功返回 0,失败返回 -1 并将错误码设置

参数1:待操作的信号

参数2:sigaction 结构体,具体成员如上所示

参数3:保存修改前进程的 sigaction 结构体信息

这个函数的主要看点是 sigaction 结构体

struct sigaction 
{
	void     (*sa_handler)(int);	//自定义动作
	void     (*sa_sigaction)(int, siginfo_t *, void *);	//实时信号相关,不用管
	sigset_t   sa_mask;	//待屏蔽的信号集
	int        sa_flags;	//一些选项,一般设为 0
	void     (*sa_restorer)(void);	//实时信号相关,不用管
};

sigaction 主要通过设置 sa_mask 来阻塞某些信号的递达,直到当前信号的处理完成。这样可以避免信号之间的干扰。

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

using namespace std;

static void DisplayPending(const sigset_t pending) {
    cout << "当前进程的 pending 表为: ";
    int i = 1;
    while (i < 32) {
        if (sigismember(&pending, i))
            cout << "1";
        else
            cout << "0";
        i++;
    }
    cout << endl;
}

static void handler(int signo) {
    cout << signo << "号信号确实递达了" << endl;
    int n = 10;
    while (n--) {
        sigset_t pending;
        sigemptyset(&pending);
        int ret = sigpending(&pending);
        assert(ret == 0);
        DisplayPending(pending);
        sleep(1);
    }
}

int main() {
    cout << "当前进程: " << getpid() << endl;

    struct sigaction act, oldact;

    memset(&act, 0, sizeof(act));
    memset(&oldact, 0, sizeof(oldact));

    act.sa_handler = handler;
    sigaddset(&act.sa_mask, 3);
    sigaddset(&act.sa_mask, 4);
    sigaddset(&act.sa_mask, 5);

    sigaction(2, &act, &oldact);

    while (true); // 死循环
    return 0;
}

运行结果

在程序中注册了一个自定义的信号处理函数 handler,当信号 2 到达时,它会触发这个函数。在函数执行过程中,信号集 sa_mask 会阻塞信号 3、4、5,直到 handler 函数执行完毕。

重点注意:

  • 信号屏蔽集 sa_mask 中设置的信号,会在信号处理函数执行过程中被阻塞

  • 信号处理完成后,阻塞的信号会被解除屏蔽并立即递达。

4.信号部分小结

信号处理的过程可以总结为以下几个阶段:

  1. 信号产生阶段:信号可以通过四种方式产生:

    • 键盘键入(如 Ctrl+C)

    • 系统调用(例如调用 kill()

    • 软件条件(如通过 raise() 发送信号)

    • 硬件异常(如除以零、访问非法内存等)

  2. 信号保存阶段:当信号产生后,内核会将其保存在一个叫做 pending 的信号表中,待后续处理。同时,还有一个 block 表和 handler 表来管理信号的屏蔽和处理动作。

  3. 信号处理阶段:信号的处理在内核态切换回用户态时进行。进程在接收到信号时会根据设定的信号处理程序进行相应的处理。

5. 补充知识

5.1 可重入函数

可以被重复进入的函数称为 可重入函数

比如单链表头插的场景中,节点 node1 还未完成插入时,node2 也进行了头插,最终导致 节点 node2 丢失,造成 内存泄漏

可重入函数指的是能够在中断或递归调用的情况下继续执行的函数。一般而言,如果函数调用了与内存管理或标准 I/O 相关的函数,它就变得不可重入。比如在多线程环境下,如果多个线程同时访问同一个资源而没有加锁,就会导致资源竞态和内存泄漏等问题。

  • 不可重入函数的条件

    • 调用了内存管理函数(例如 mallocfree

    • 调用了标准 I/O 函数(例如 printfscanf,因为它们内部会使用静态数据结构)

5.2 volatile 关键字

volatile 关键字用于告诉编译器不要对变量进行优化,确保每次访问该变量时,都会直接从内存中读取它的值。通常在处理硬件寄存器、信号处理等情况时,使用 volatile 以确保变量的值能实时反映外部环境的变化。

下面是一个使用 volatile 的例子:

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

int flag = 0;   // 一开始为假

void handler(int signo)
{
    printf("%d号信号已经成功发出了\n", signo);
    flag = 1;
}

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

    while(!flag);   // 故意不写 while 的代码块 { }

    printf("进程已退出\n");

    return 0;
}

初步结果符合预期,2 号信号发出后,循环结束,程序正常退出

这段代码能符合我们预期般的正确运行是因为 当前编译器默认的优化级别很低,没有出现意外情况

通过指令查询 gcc 优化级别的相关信息

man gcc
: /O1

 其中数字越大,优化级别越高,理论上编译出来的程序性能会更好

事实真的如此吗?

让我们重新编译上面的程序,并指定优化级别为 O1

gcc mySignal mySignal.c -O1

 编译成功后,再次运行程序

此时得到了不一样的结果:2 号信号发出后,对于 falg 变量的修改似乎失效了

将优化级别设为更高是一样的结果,如果设为 O0 则会符合预期般的运行,说明我们当前的编译器默认的优化级别是 O0

查看编译器的版本

gcc --version

 当前版本为 gcc(GCC) 4.8.5 (不同版本编译器的默认优化级别可能略有不同)

那么我们这段代码哪个地方被优化了呢?

  • 答案是 while 循环判断

 首先要明白:

  1. 对于程序中的数据,需要先被 load 到 CPU 中的 寄存器 中
  2. 判断语句所需要的数据(比如 flag),在进行判断时,是从 寄存器 中拿取并判断
  3. 根据判断的结果,判断代码的下一步该如何执行(通过 PC 指针指向具体的代码执行语句)

所以程序在优化级别为 O0 或更低时,是这样执行的: 

5.3 SIGCHLD 信号

SIGCHLD 信号用于通知父进程其子进程已经结束。当子进程结束时,会向父进程发送 SIGCHLD 信号,父进程可以捕获该信号并通过调用 wait()waitpid() 来回收子进程的资源,从而避免产生“僵尸进程”。

  • 示例:
    下面的程序演示了父进程如何捕捉 SIGCHLD 信号并回收子进程资源:

    #include <stdio.h>
    #include <stdlib.h>
    #include <signal.h>
    #include <unistd.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    
    void handler(int signo) {
        printf("进程 %d 捕捉到了 %d 号信号\n", getpid(), signo);
    }
    
    int main() {
        signal(SIGCHLD, handler);
    
        pid_t id = fork();
        if (id == 0) {
            int n = 5;
            while (n)
                printf("子进程剩余生存时间: %d秒 [pid: %d  ppid: %d]\n", n--, getpid(), getppid());
    
            exit(-1);  // 子进程退出
        }
    
        waitpid(id, NULL, 0);  // 父进程等待子进程结束
        return 0;
    }
    

多子进程回收:
如果父进程有多个子进程需要回收,可以通过 waitpid() 使用 WNOHANG 选项进行非阻塞式回收,确保不会因为一个子进程的退出阻塞了对其他子进程的回收。 

while (1) {
    pid_t ret = waitpid(-1, NULL, WNOHANG);
    if (ret > 0) {
        printf("父进程: %d 已经成功回收了 %d 号进程\n", getpid(), ret);
    } else {
        break;
    }
}
5.4 SIGCHLD 的忽略方式

另一种简洁的方法是将 SIGCHLD 信号的处理方式设置为忽略(SIG_IGN)。这样,操作系统会自动处理子进程的回收,父进程不需要主动进行回收操作:

signal(SIGCHLD, SIG_IGN);

这使得子进程在退出后不会成为僵尸进程,操作系统会自动进行清理。


其实还有一种更加优雅的子进程回收方案

由于 UNIX 历史原因,要想子进程不变成 僵尸进程,可以把 SIGCHLD 的处理动作设为 SIG_IGN 忽略,这里的忽略是个特例,只是父进程不对其进行处理,但只要设置之后,子进程在退出时,由 操作系统 对其负责,自动清理资源并进行回收,不会产生 僵尸进程

也就是说,直接在父进程中使用 signal(SIGCHLD, SIG_IGN) 就可以优雅的解决 子进程回收问题,父进程既不用等待,也不需要对信号做出处理

原理:在设置 SIGCHLD 信号的处理动作为忽略后,父进程的 PCB 中有关僵尸进程处理的标记位会被修改,子进程继承父进程的特性,子进程在退出时,操作系统检测到此标记位发生了改变,会直接把该子进程进行释放

SIGCHLD 的默认处理动作是忽略(什么都不做),而忽略动作是让操作系统帮忙回收,父进程不必关心


网站公告

今日签到

点亮在社区的每一天
去签到