038 进程信号 —— 信号的处理

65 阅读15分钟

进程信号 —— 信号的处理

Linux 进程信号【信号处理】 | CSDN

1. 捕捉/处理信号(进程地址空间)

1. 内核空间与用户空间

每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:

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

内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。

image-20250717230508790转存失败,建议直接上传图片文件

  • 操作系统本质上就是一个“基于时钟中断的死循环” ,它通过中断机制不断调度任务,实现多任务并发的“假象”。 OS 本身并没有“结束”的时候,它一直在“看有没有事要做”。 它开机后就进入一个大循环:看有没有进程要运行(调度)、看有没有中断发生、处理完后继续循环,这个循环不会退出,除非关机。
  • 时钟中断就像是操作系统的心跳,每隔一段时间就“敲一下操作系统”:“该换人干活了!” 计算机硬件中有一个 时钟芯片,它每隔一定时间(微秒级)发出一个 时钟中断,OS 利用这个中断来做 时间片调度

此外我们也会发现一个很有意思的事情:无论是台式机/笔记本,在开机后,无论联网与否,时间总是准的,原因是计算机主板中存在很小的纽扣电池,它负责在电脑断电时维持实时时钟(RTC)的运行,从而保证时间不会丢失,当机器断电数月后才可能开机后时间不准。


举个生活例子:餐厅的叫号系统

你去餐厅吃饭,点完菜后坐等。

  • 时钟中断 :就像服务员每隔一段时间查看叫号系统。
  • 进程调度 :服务员看到你号码到了,叫你去取餐。
  • 死循环 :服务员一直在柜台后面转悠,不停地看有没有新号码要处理。

操作系统就像这个服务员,一直在“看有没有事做”。

2. 什么是用户态和内核态?

状态特征权限等级能做什么
用户态执行用户代码,如普通应用程序只能访问用户空间,不能访问硬件
内核态执行操作系统核心代码可以访问内核空间、控制硬件、管理内存、调度任务等

用户态不能直接访问内核态的数据,否则整个系统会变得非常不安全!

用户态和内核态的切换是由 CPU 的特权级机制控制的,CS 寄存器的 RPL 字段决定当前执行代码的权限级别,int 0x80 是一种触发系统调用、切换到内核态的方式,CR3 寄存器用于管理进程的虚拟地址空间,通常在进程切换时变化,但在用户态 ↔ 内核态切换时不变化。

int 0x80 定义 int 0x80 探索 Linux 系统调用机制的演变:int 0x80syscall

特权级、ecs、CR3 寄存器这些内容不做重点,点到为止了,个人能力有限,讲不太明白 🥹。

3. 什么是进程切换?

【操作系统】进程切换到底是怎么个过程?| CSDN 进程切换原理 | 博客园 深入理解进程切换 | CSDN

“进程切换”是指 CPU 当前执行的进程被挂起,另一个进程被调度到 CPU 上运行,这是一种 上下文切换所以:进程切换 = 保存旧进程的状态 + 加载新进程的状态 + CPU 执行新进程的代码。 进程切换不仅是操作系统内部实现并发的核心机制,也是作为系统程序员写代码时需要“尽量避免过度调度”的重要优化点。

  1. 保存当前进程的状态(上下文)

操作系统会把当前正在运行的进程的“执行状态”保存下来,比如:

  • 程序计数器(PC):下一条要执行哪条指令
  • 寄存器的值:临时数据
  • 栈指针、堆栈信息:函数调用现场

这些信息保存在 进程控制块(PCB) 中。

  1. 选择下一个要运行的进程(调度): 操作系统通过调度器(scheduler)决定下一个该谁运行。

  2. 加载下一个进程的状态

操作系统从下一个进程的 PCB 中加载它的上下文,包括:

  • 程序计数器(PC)

  • 寄存器内容

  • 栈信息

  1. 继续执行下一个进程: CPU 开始执行下一个进程的代码。

注意:用户态 ↔ 内核态切换 ≠ 进程切换

类型是否等同切换是否改变进程是否保存上下文触发示例
用户态 ↔ 内核态❌ 不是进程切换❌ 不换进程❌ 不换上下文系统调用、中断、异常
进程 A ↔ 进程 B✅ 是进程切换✅ 换进程✅ 保存/恢复上下文时间片用完、I/O 阻塞、调度触发
  • 用户态 ↔ 内核态: 你是一个顾客,在银行大厅(用户态)填单子,然后去柜台找工作人员帮你处理转账(进入内核态),处理完你回到大厅继续自己的事。你没换人,只是权限变了。
  • 进程 A ↔ 进程 B 切换: 你正在银行柜台办事,还没办完,系统叫下一个客户了。工作人员保存你当前的状态(办到哪一步),切换到下一个客户。你和别人换了,这才是真正的“切换”
  • 用户态 ↔ 内核态”切换只是权限切换,不换进程;而“进程切换”才是真正换人干活,需要保存和恢复上下文。

按 Ctrl+C 会不会进程切换?

不一定!

  • 按 Ctrl+C 会触发 中断(来自键盘设备)→ 导致进程进入内核态。
  • 由内核触发信号发送,进而 递达信号,调用 handler(仍在当前进程内)。

所以:不会进程切换,只是状态切换为内核态,然后再返回用户态。 但如果按 Ctrl+C 后进程终止,那么调度器会调度其他进程,才会产生真正的进程切换

4. 内核如何捕捉信号?

当进程从内核态准备返回用户态时,内核会检查是否有未决(pending)且未被阻塞的信号。如果信号的处理动作是默认或忽略,内核直接执行对应动作并清除 pending 标志,然后返回用户态继续执行主流程;如果处理动作是用户自定义函数,内核会伪造一次函数调用,让用户态执行该 handler。执行完毕后,通过 sigreturn 系统调用重新进入内核态,清理 pending 标志并恢复进程上下文,最后再次返回用户态,继续执行主控制流程。信号的捕捉不是直接调用函数,而是由内核精心安排的一次“用户态函数调用 + 内核态恢复”的过程,确保安全、稳定、可控。

image-20250717233930425

image-20250717234912707转存失败,建议直接上传图片文件

当识别到信号的处理动作是自定义时,能直接在内核态执行用户空间的代码吗? 虽然内核有权限直接执行用户代码,但为了安全和稳定,操作系统绝不允许在内核态直接执行用户定义的代码。用户代码必须在用户态下执行,权限受限,防止破坏系统。


2. sigaction

1. sigaction 函数原型

1. 功能

用于设置某个信号的处理方式(三种)。sigaction()signal() 的“升级版”,功能更强大、更安全、更可移植,推荐在实际开发中使用它来注册信号处理函数。

2. 函数原型
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
3. 参数详解
参数名类型含义
signumint要设置的信号编号,如 SIGINT(2 号信号)、SIGTERM(15 号信号)等
actconst struct sigaction *新的信号处理方式(结构体指针),可设置 handler、flags、mask 等
oldactstruct sigaction *可选,用于保存旧的处理方式(可用于恢复、可为 NULL

其中 struct sigaction 结构体定义如下:

struct sigaction
{
    void     (*sa_handler)(int);         // 简单的信号处理函数(类似 signal 的用法)
    void     (*sa_sigaction)(int, siginfo_t *, void *);  // 带详细信息的信号处理函数(需要 SA_SIGINFO 标志)
    sigset_t   sa_mask;                  // 在信号处理函数执行期间额外屏蔽的信号集合
    int        sa_flags;                 // 标志位,控制信号行为(如 SA_RESTART、SA_SIGINFO 等)
    void     (*sa_restorer)(void);       // 已废弃,忽略
};
1. sa_handler:最简单的处理函数

类似 signal() 的用法,接收一个整数参数(信号编号)。

void handler(int signo)
{
    printf("收到信号:%d\n", signo);
}

struct sigaction act;
act.sa_handler = handler;
2. sa_sigaction:带详细信息的处理函数(需要配合 SA_SIGINFO 标志使用,了解)

可以获取信号的详细信息(发送者、原因等),接收三个参数:

  • 信号编号。
  • siginfo_t *:包含信号来源等信息。
  • void *:指向 ucontext_t 的指针(可获取寄存器等上下文信息)。
void sigaction_handler(int signo, siginfo_t *info, void *context)
{
    printf("收到信号:%d,发送者PID:%d\n", signo, info->si_pid);
}

struct sigaction act;
act.sa_sigaction = sigaction_handler;
act.sa_flags = SA_SIGINFO;
3. sa_mask:信号处理期间屏蔽的其他信号集合

在执行信号处理函数时,可以屏蔽其他信号,防止多个信号处理函数嵌套执行。

sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGUSR1);  // 在处理当前信号时,暂时屏蔽 SIGUSR1
4. sa_flags:控制信号行为的标志位
标志含义
SA_RESTART自动重启被中断的系统调用(如 read/write)
SA_SIGINFO使用 sa_sigaction 而不是 sa_handler
SA_NODEFER不自动屏蔽当前信号(默认会屏蔽)
SA_RESETHAND处理完信号后重置为默认行为
4. 返回值
  • 成功返回 0
  • 失败返回 -1,并设置 errno
5. 代码示例
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstring>
using namespace std;

void handler(int signum)
{
    cout << "收到信号: " << signum << endl;
}

int main()
{
    struct sigaction act;                       // 用于设置新行为
    memset(&act, 0, sizeof(act));               // 初始化为 0,避免随机值干扰
    act.sa_handler = handler;                   // 设置信号处理函数
    sigemptyset(&act.sa_mask);                  // 清空屏蔽字
    act.sa_flags = 0;                           // 无特殊行为

    if (sigaction(SIGINT, &act, nullptr) == -1) // 设置 SIGINT(如按 Ctrl+C) 的处理方式
    {
        perror("sigaction error");
        return 1;
    }

    while (true)                                // 模拟服务一直运行
    {
        cout << "程序运行中...(按 Ctrl+C 测试信号)" << endl;
        sleep(2);
    }

    return 0;
}

2. pending 什么时候从 1 -> 0?

pending 位在信号递达时被清 0,早于 handler 的执行,确保信号不会重复处理。 pending 位图就像一个“未读消息列表”。

  • 信号产生时,该信号在 pending 位图中被标记为 1(未读)。
  • 当信号递达时,内核会把该位清 0,表示“这个信号我已经处理了”。

实验证明:

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

void handler(int signo)
{
    printf("进入 handler\n");
    sleep(3);                                   // 模拟处理过程
    printf("退出 handler\n");
}

int main()
{
    struct sigaction act;                       // 定义信号处理结构体
    act.sa_handler = handler;                   // 设置信号处理函数
    sigemptyset(&act.sa_mask);                  // 阻塞信号集为空
    act.sa_flags = 0;                           // 信号处理标志位

    sigaction(SIGINT, &act, NULL);              // 注册信号处理函数

    sigset_t set;                               // 定义信号集
    sigemptyset(&set);                          // 信号集为空
    sigaddset(&set, SIGINT);                    // 加入 SIGINT 信号
    sigprocmask(SIG_BLOCK, &set, NULL);         // 阻塞 SIGINT 信号

    printf("请在 5 秒内按 Ctrl+C(SIGINT 会被 pending)...\n");
    sleep(5);                                   // 等待 5 秒

    sigset_t pending;                           // 定义 pending 信号集  
    sigpending(&pending);                       // 获取 pending 信号集
    if (sigismember(&pending, SIGINT))
    {
        printf("SIGINT 当前处于 pending 状态(位为 1)\n");
    }

    sigprocmask(SIG_UNBLOCK, &set, NULL);       // 解除 SIGINT 信号的阻塞
    pause();                                    // 等待信号处理完成

    sigpending(&pending);                       // 获取 pending 信号集
    if (!sigismember(&pending, SIGINT))
    {
        printf("SIGINT 的 pending 位已被清 0\n");
    }

    return 0;
}

运行结果示例:

[hcc@hcss-ecs-be68 Signal Processing]$ ./test1 
请在 5 秒内按 Ctrl+C(SIGINT 会被 pending)...
^C
SIGINT 当前处于 pending 状态(位为 1)
进入 handler
退出 handler
^C
进入 handler
退出 handler
SIGINT 的 pending 位已被清 0

信号的 pending 位在信号递达前被清 0,handler 执行期间该信号会被自动屏蔽,handler 执行完毕后恢复。如果在 handler 执行期间发送该信号,它会再次进入 pending,handler 会再次被调用。

3. 信号处理过程中,是否自动将该信号加入 blocked

在执行信号处理函数时,内核会自动将该信号加入 block 集合,防止递归调用;handler 执行完毕后,block 集合恢复原样。

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

void handler(int signo)
{
    printf("进入 handler,此时 SIGINT 被屏蔽\n");

    sigset_t blocked;                                    // 保存当前信号集
    sigprocmask(0, NULL, &blocked);                      // 获取当前信号集

    if (sigismember(&blocked, SIGINT))                   // 判断 SIGINT 是否被屏蔽
    {
        printf("SIGINT 当前被阻塞(屏蔽中)\n");
    }
    else
    {
        printf("SIGINT 未被阻塞\n");
    }

    sleep(5); // 模拟处理过程
    printf("退出 handler\n");
}

int main()
{
    struct sigaction act;                                // 信号处理结构体
    act.sa_handler = handler;                            // 设置信号处理函数
    sigemptyset(&act.sa_mask);                           // 初始化信号集
    act.sa_flags = 0;                                    // 信号处理标志

    sigaction(SIGINT, &act, NULL);                       // 注册信号处理函数

    printf("发送 SIGINT 将触发 handler\n");
    while (1)
    {
        pause();                                         // 阻塞进程,等待信号
    }

    return 0;
}

运行结果示例:

[hcc@hcss-ecs-be68 Signal Processing]$ ./test2
发送 SIGINT 将触发 handler
^C进入 handler,此时 SIGINT 被屏蔽
SIGINT 当前被阻塞(屏蔽中)
退出 handler
^C进入 handler,此时 SIGINT 被屏蔽
SIGINT 当前被阻塞(屏蔽中)
退出 handler
……

在 handler 执行期间,SIGINT 被自动加入 block 集合(屏蔽),即使多次按 Ctrl+C,也不会再次进入 handler。


3. 可重入函数

可重入函数到底是什么? | stack overflow c 语言学习 432 可重入函数 | B 站

可重入函数 是指:函数在被中断后,其上下文环境不被破坏,并且可以 安全地重新调用,不影响原始执行。我们可以把一个函数想象成一个 银行柜台

  • 不可重入函数 :只有一个柜员,只能服务一个客户。如果另一个客户来了,就会出错(比如数据混乱、死锁)。
  • 可重入函数 :每个客户都有自己的工位,互不干扰,可以同时处理。

信号处理函数中 只能用可重入函数,否则可能引发程序崩溃或行为不确定。非可重入函数,不可在 handler 中使用。 好在目前我们所学习到的函数都是可重入函数,简单注意一下即可。

4. volatile

volatile 是 C 语言的一个关键字,该关键字的作用是保持内存的可见性。作用是告诉编译器:“这个变量的值可能随时被修改,不要做优化!” 当变量可能被信号处理函数或中断修改时,必须使用 volatile 修饰,否则优化器可能让主线程永远看不到值的变化,导致逻辑失效。

#include <stdio.h>
#include <signal.h>
using namespace std;

int flag = 0;

void handler(int signo)
{
	printf("收到信号:%d\n", signo);
	flag = 1;                           // 设置flag为1,表示收到信号(// 信号处理函数中修改 flag 的值)
}
int main()
{
	signal(SIGINT, handler);            // 注册信号处理函数
	while (!flag);                      // 等待信号处理函数执行完毕
	printf("进程正常退出!\n");

	return 0;
}

-O0, -O1, -O2, -O3 是控制 编译器优化强度 的开关,等级越高程序执行速度越快,但调试越困难、对写法越敏感(如 volatile 就必须加)。默认建议开发调试用 -O0,上线前用 -O2。在 man 手册中查看:man gcc → 按下 / 进入搜索模式,输入:-O → 然后按 n(next)多次查找。(优化选项(使用 GNU 编译器集合(GCC))

选项含义(优化级别)特点 / 行为
-O0不做优化编译快、便于调试、代码按字面意思执行
-O1基本优化小幅优化、不会改变代码结构
-O2常规优化(推荐上线用)去除冗余,常量折叠,循环展开,较稳定
-O3激进优化(最大速度)包括内联函数、多重循环展开,性能极高但可能更难调试
-Os优化空间(减小生成文件大小)类似 -O2,但更注重体积
-Ofast忽略一些标准规范,疯狂优化(不推荐)会跳过 IEEE/C99 规范,速度最快但可能不准确

运行结果示例:

image-20250719164021603

当我们将 int flag = 0; 改变成 volatile int flag = 0; 就会发现运行结果一致了:

image-20250719165429223转存失败,建议直接上传图片文件

5. SIGCHLD 信号

SIGCHLD 是子进程退出时,自动发送给其父进程的信号(通知父进程:子进程挂了”的信号)。 也叫:子进程状态改变信号,通常用于通知父进程去回收子进程,防止僵尸进程出现!

1. 什么时候会触发 SIGCHLD

当子进程发生以下情况,内核就会给它的父进程发送 SIGCHLD 信号:

子进程行为会触发 SIGCHLD 吗?说明
正常退出(return/exit)最常见用途
异常终止(段错误、除 0)会触发
被信号杀死(如 SIGKILL)依然会通知
被暂停/恢复执行可配合 WUNTRACED 监控状态变化

2. 函数原型:signal / sigaction 监听 SIGCHLD

#include <signal.h>
#include <sys/wait.h>

// 可以用如下方式监听 SIGCHLD 信号:
signal(SIGCHLD, handler);        // 简单方式
sigaction(SIGCHLD, &act, NULL);  // 更稳定推荐方式

3. 示例代码:监听 SIGCHLD + 回收子进程

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
using namespace std;

void handler(int signo)
{
    // 回收所有退出的子进程,防止僵尸进程
    while (waitpid(-1, NULL, WNOHANG) > 0)
    {
        printf("子进程已退出,回收完成\n");
    }
}

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

    pid_t pid = fork();
    if (pid == 0)
    {
        printf("子进程运行中,PID = %d\n", getpid());
        sleep(2);
        exit(0);                // 正常退出,触发 SIGCHLD
    }
    else
    {
        while (1)
        {
            printf("父进程在运行中,PID = %d\n", getpid());
            sleep(1);
        }
    }

    return 0;
}

运行结果示例:

[hcc@hcss-ecs-be68 Signal Processing]$ ./SIGCHLD 
父进程在运行中,PID = 27055
子进程运行中,PID = 27056
父进程在运行中,PID = 27055
父进程在运行中,PID = 27055
子进程已退出,回收完成
父进程在运行中,PID = 27055
父进程在运行中,PID = 27055
^C

如果不处理 SIGCHLD 会怎样?

子进程退出,父进程没有调用 waitwaitpid,就会产生 僵尸进程,虽然不会占用内存,但会占用 PID 表项,多了会导致系统资源耗尽。

重点说明
SIGCHLD 什么时候触发子进程退出、异常终止、被信号杀死、状态改变
为啥要处理它?防止僵尸进程! 并控制子进程生命周期
怎么处理?signalsigaction 监听 SIGCHLD,调用 waitpid
实战用途写守护进程、多进程服务程序、提升系统稳定性