Linux进程信号

在Linux系统编程中,信号(Signal) 是进程间通信(IPC)中一种非常古老且重要的机制。它本质上是一种异步的事件通知机制。很多时候,我们看到程序崩溃报错"Segmentation fault"或者按 Ctrl+C 终止程序,背后其实都是信号在起作用。

一、 什么是信号?

1.1 基本概念

信号是Linux系统提供的一种向指定进程发送特定事件的方式。

  • 本质 :一种异步的事件通知机制。

    • 异步:进程不知道信号什么时候会来,进程正在执行自己的代码,信号随时可能到达。
  • 作用 :告诉进程发生了什么事,而不是告诉进程现在立刻去做什么。

  • 特点

    • 多种事件可以同时发生,彼此互不影响。

    • 进程收到信号后的常见结果:终止、暂停、忽略等。

1.2 信号的生命周期

信号不是产生后立刻就被处理的,它有一个完整的过程:

  1. 信号产生 (Generation):由用户、OS或硬件产生。

  2. 信号保存 (Preservation):信号产生后,如果进程正在处理更重要的事情(如处于内核态),信号需要被暂时"记下来"。

  3. 信号处理 (Delivery):在合适的时间,进程处理该信号。

思考 :为什么要保存?
答案:因为信号产生是异步的,进程收到信号时可能正忙,无法立即处理,所以必须先在内核中将其保存下来,等到合适的时候再处理。

二、 信号的产生方式

信号的来源非常广泛,主要可以归纳为以下几种:

  • 键盘 -- IO硬件
  • 异常 --- 硬件 cpu,mmu
  • 系统命令 --- 用户
  • 系统调用 --- 开发者
  • 软件条件 --- 系统(定时器设计)

2.1 通过终端按键产生

这是我们最熟悉的方式,通常用于控制前台进程

  • Ctrl + C :发送 2 号信号 (SIGINT),默认动作是终止进程

  • Ctrl + \:发送 3 号信号 (SIGQUIT),默认动作是终止进程并Core Dump。

  • Ctrl + Z:发送 19 号信号 (SIGSTOP),暂停进程。

细节 :键盘产生的信号只能发给前台进程。后台进程无法获取键盘输入,因此无法响应这些快捷键。

2.2 通过系统命令产生

  • kill -l:查看系统支持的所有信号。

    • 1-31:普通信号(我们主要研究的对象)。

    • 34-64:实时信号。

    • 注意:没有0号信号。

  • kill -signum pid:向指定进程发送指定信号。

2.3 通过硬件异常产生

这是程序崩溃的主要原因。当硬件检测到错误时,会通知内核,内核再向当前进程发送信号。

  • 除0错误:CPU运算单元检测到异常 -> 通知OS -> OS发送 8 号信号 (SIGFPE, Floating Point Exception)。

  • 野指针/越界:MMU(内存管理单元)检测到非法访问 -> 通知OS -> OS发送 11 号信号 (SIGSEGV, Segmentation Fault)。

核心原理

  1. CPU或MMU出现硬件错误。

  2. OS作为硬件管理者,必须知道硬件报错。

  3. OS定位到当前是谁(哪个进程)在使用硬件。

  4. OS向该进程写入特定的信号,进程随之崩溃退出。

2.4 通过软件条件产生

发送信号的方式很多,围绕者用户,硬件,软件各种场景展开,借助OS之手向目标进程"写"信号。软件条件 不满足时也会产生信号。最典型的例子就是管道 (Pipe)

管道读写异常 (SIGPIPE)
  • 场景

    有一个匿名管道,读端(Reader)把文件描述符关闭了,但是写端(Writer)还在一直尝试往管道里写数据。

  • 结果

    这是一个非法的软件行为(没有人读,写就没有意义)。操作系统会识别到这个软件异常,并向写端进程发送 13号信号 (SIGPIPE)

  • 动作

    SIGPIPE 的默认动作是终止进程。这也就是为什么在命令行管道中,如果后面的 grep 挂了,前面的 cat 也会跟着挂掉的原因

三、 信号的保存:内核中的位图

问题:信号保存在哪里?怎么保存?

3.1 保存位置

信号是保存在进程的 PCB (Process Control Block) 中的,即Linux下的 task_struct 结构体。

3.2 保存结构:位图 (Bitmap)

在 task_struct 中,有一个类似于 unsigned int sigs 的字段(实际结构更复杂,但逻辑一致)。

  • 比特位的位置 :代表信号的编号(如第2位代表2号信号)。

  • 比特位的内容 (0或1):代表是否收到该信号。

3.3 谁来修改这个位图?

只有操作系统 (OS)

无论信号是由键盘、命令还是硬件产生的,最终都必须由OS将对应的比特位由 0 置为 1。

  • 结论 :发送信号的本质,就是OS向目标进程的 task_struct 中写入位图的过程

四、 信号的处理

当信号被保存后,进程会在合适的时候(通常是内核态返回用户态时)检测并处理信号。处理方式有三种:

4.1 默认动作 (Default)

大多数信号的默认动作是终止进程(Terminate)。例如 SIGINT、SIGKILL。

4.2 忽略动作 (Ignore)

进程收到信号后,什么也不做。

  • 设置方式:signal(SIGINT, SIG_IGN);

4.3 自定义捕捉 (Catch / Custom Handler)

程序员可以提供一个函数,当信号发生时,让OS调用这个函数。

API 接口:signal

cpp 复制代码
#include <signal.h>

// sighandler_t 是一个函数指针: void (*)(int)
typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);
  • signum:要捕捉的目标信号(如 SIGINT)。

  • handler:回调函数,当信号产生时由OS调用。

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signo) {
    std::cout << "收到一个信号: " << signo << ",但我就是不退出!" << std::endl;
}

int main() {
    // 注册信号捕捉,只需要调用一次
    signal(SIGINT, handler); 

    while(true) {
        std::cout << "Test signal..." << std::endl;
        sleep(1);
    }
    return 0;
}
  • 现象:运行该程序后,按下 Ctrl+C,进程不会退出,而是打印 handler 函数中的内容。

4.4 无法被捕捉的信号

9号信号 (SIGKILL) 是管理员信号,具有至高无上的权力。

  • 不可被自定义捕捉

  • 不可被忽略

  • 原因:如果所有信号都能被捕捉或忽略,这就可能产生一个永远无法杀死的恶意进程,OS将失去对系统的控制权。

五、 信号分类

1. 信号的分类:普通信号 vs 实时信号

图片中展示了两大类信号,虽然看起来只是一堆数字,但底层实现机制完全不同:

  • 1 ~ 31号信号(普通信号)

    • 特点:非可靠信号。

    • 存储结构 :使用 位图 (Bitmap) 保存。在进程的 task_struct 中,用一个 unsigned int(32位)的比特位来标记是否收到了该信号。

    • 缺陷 :因为位图只能标记"有"或"无"(0或1),所以如果同一个信号在短时间内被发送多次,进程还没来得及处理,那么后续的信号就会丢失

    • 用途:分时操作系统中的常规事件通知(如键盘中断、程序崩溃、终止进程)。

  • 34 ~ 64号信号(实时信号)

    • 特点:可靠信号。

    • 存储结构 :使用 队列 (Queue) 保存。

    • 优势:信号不会丢失。如果发送5次,队列里就挂5个节点,进程会处理5次。

    • 用途:主要用于对实时性要求高的工业控制、底层驱动开发等领域。

注意 :Linux中没有 0号信号,也没有 32、33号信号(这两个通常被NPTL线程库内部保留使用了)。

2. 重点标注信号的"前世今生"(硬件与内核的联动)

图片中用绿色方框和箭头重点指出了 8号11号 信号,这两个信号是程序崩溃(Core Dump)最常见的原因,它们体现了硬件异常如何转化为软件信号的过程。

8) SIGFPE (Floating Point Exception)
  • 字面意思:浮点数异常。

  • 实际触发 :不仅仅是浮点数,整数除以0 也是发这个信号。

  • 底层硬件关系

    1. CPU内部的 ALU (算术逻辑单元) 执行除法指令时,发现除数为0。

    2. ALU 触发硬件中断(Trap)。

    3. CPU 状态切换到内核态,OS 捕获到这个硬件异常。

    4. OS 识别出是哪个进程正在使用CPU。

    5. OS 向该进程发送 SIGFPE 信号(修改该进程PCB中的位图)。

    6. 进程收到信号,默认动作是终止并报错 Floating point exception。

11) SIGSEGV (Segmentation Violation)
  • 字面意思:段违例(即段错误)。

  • 实际触发:野指针、空指针解引用、数组越界、试图修改只读内存(比如修改代码段)。

  • 底层硬件关系

    1. 进程试图通过虚拟地址访问内存。

    2. MMU (内存管理单元) 在进行"虚拟地址 -> 物理地址"转换时,发现映射失败(地址不存在)或者权限检查失败(试图写只读页)。

    3. MMU 触发硬件异常。

    4. OS 捕获异常,确认是哪个进程非法访问。

    5. OS 向该进程发送 SIGSEGV 信号。

    6. 进程收到信号,默认动作是终止并报错 Segmentation fault。

3. 其他关键信号的映射关系

除了框出来的两个,图片中还有几个必须掌握的"明星信号":

  • 2) SIGINT

    • 硬件来源:键盘。

    • 触发:用户按下 Ctrl + C。

    • 作用:中断前台进程。

  • 3) SIGQUIT

    • 硬件来源:键盘。

    • 触发:用户按下 Ctrl + \。

    • 作用:退出进程并生成 Core Dump 文件(用于事后调试)。

  • 9) SIGKILL

    • 特殊地位管理员信号

    • 权限不可被捕捉,不可被忽略,不可被阻塞

    • 作用:一击必杀。当进程卡死或中毒,无法响应普通信号时,用它强制杀死。

  • 19) SIGSTOP

    • 特殊地位:用于暂停进程(挂起)。

    • 权限 :同样不可被捕捉,不可被忽略

    • 触发:用户按下 Ctrl + Z。

  • 17) SIGCHLD

    • 关系:父子进程机制。

    • 触发:当子进程结束(exit)或暂停时,会自动给父进程发这个信号。

    • 作用:父进程收到后,负责回收子进程的僵尸状态(wait/waitpid)。

六、深度问答与细节

Q1: 为什么除0会死循环?

如果在代码中写了 int a = 10/0; 并捕捉了 SIGFPE 信号,且在 handler 中没有退出进程,你会发现 handler 被疯狂调用。
原因

  1. CPU执行除0指令出错。

  2. OS发送 SIGFPE。

  3. 进程处理信号,执行 handler。

  4. handler返回,OS将控制权交还给进程,通过保存的寄存器(EIP/RIP)跳回刚才出错的那行指令继续执行

  5. CPU再次执行除0,再次报错......形成死循环。

Q2: 进程如何知道自己收到了信号?

进程不需要一直盯着看。OS会在进程从内核态切换回用户态的时候(例如系统调用返回时、时钟中断返回时),顺便检查 task_struct 中的信号位图。如果发现有位为1,就去处理。

Q3: 为什么说信号处理是进程自己做的?

虽然信号是OS写入的,但处理动作(比如执行自定义的 handler 函数)是由进程的主控制流(或者说在该进程的上下文中)执行的。

七、 核心转储 (Core Dump)

7.1 Term 与 Core 的区别

通过 man 7 signal 查看信号手册时,我们会发现信号的处理动作(Action)栏中,有的写的是 Term,有的写的是 Core。

  • Term (Termination):单纯的终止进程。操作系统直接回收资源,进程消失。

  • Core (Core Dump)终止进程 + 核心转储 。进程不仅退出了,OS还会把进程当前内存中的数据(包括堆栈信息、全局变量等)原样"转储"到磁盘上,生成一个调试文件(通常叫 core 或 core.pid)。

为什么要 Core Dump?

核心转储是为了事后调试 (Post-mortem Debugging)。当服务器程序在半夜运行奔溃时,开发者不在场。有了 core file,我们就可以用 GDB 还原案发现场,定位到程序是在哪一行代码、因为什么原因挂掉的。

7.2 如何开启 Core Dump

在很多生产环境或云服务器中,Core Dump 功能默认是关闭 的。

我们可以使用 ulimit -a 命令查看当前资源限制:

开启方式:使用 ulimit -c 设置允许生成的 core 文件大小。

开启后,当程序触发 SIGFPE(浮点异常)、SIGSEGV(段错误)等信号时,就会在当前目录下生成 core 文件。

7.3 如何验证与调试

  1. 验证 :编写一个除0错误或野指针的程序,运行后会看到提示:

    cpp 复制代码
    #include<stdio.h>
    #include<signal.h>
    
    void handler(int sig)
    {
        printf("catch a sig : %d\n", sig);
    } 
    
    int main()
    {
        //signal(SIGFPE, handler); // 8) SIGFPE
        int a = 10;
        a/=0;
        return 0;
    }
  2. 调试:使用 GDB 加载 core 文件(core文件如果未生成,可能是Ubuntu 系统中core 文件被 apport 服务接管):

    bash 复制代码
    gdb 可执行程序 core-file-name

    GDB 会直接跳到导致崩溃的那一行代码。

7.4 父进程如何知道子进程发生了 Core Dump?

我们在学习 waitpid 时知道,status 参数是一个输出型参数,它实际上是一个位图。

  • 低7位:表示终止信号。

  • 第8位 (Bit 7)Core Dump 标志位。如果该位为1,说明子进程发生了核心转储。

八、 发送信号的系统调用

除了键盘和硬件异常,我们在代码中也可以通过系统调用主动给进程发送信号。发送信号的本质,依然是向目OS标进程的PCB位图中写入信号

8.1 kill 函数

不仅仅是命令行命令,kill 也是一个系统调用,可以给任意进程发送任意信号。

cpp 复制代码
#include <signal.h>
int kill(pid_t pid, int sig);
  • 功能:向进程 pid 发送信号 sig。

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

8.2 raise 函数

cpp 复制代码
#include <signal.h>
int raise(int sig);
  • 功能 :给当前进程自己(Caller)发送信号 sig。

  • 本质:等价于 kill(getpid(), sig);

8.3 abort 函数

cpp 复制代码
#include <stdlib.h>
void abort(void);
  • 功能:引起程序的异常终止。

  • 本质 :给当前进程发送 6号信号 (SIGABRT)

  • 特点:abort() 类似于 exit(),但它是一种更激烈的退出方式,通常会触发 Core Dump(如果开启的话)。

九、alarm 函数与定时器

9.1 alarm 接口

cpp 复制代码
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
  • 功能 :设置一个"闹钟"。告诉内核在 seconds 秒后,向当前进程发送 14号信号 (SIGALRM)

  • 默认动作 :SIGALRM 的默认动作是终止进程

  • 返回值

    • 如果之前有没响的闹钟,返回上一个闹钟的剩余时间

    • 如果之前没有闹钟,返回 0。

  • 一次性:alarm 设置的闹钟是一次性的,响一次就没了。

  • 取消闹钟:调用 alarm(0) 可以取消当前未响的闹钟。

9.2 理解闹钟

这就好比你对操作系统说:"5秒后提醒我。" OS 记下了这个事,5秒一到,OS 就像扔砖头一样扔过来一个 SIGALRM 信号,把你(进程)砸死(终止)。当然,你可以通过 signal(SIGALRM, handler) 自定义捕捉这个信号,实现定时任务。

十、 内核如何管理定时器? (数据结构揭秘)

问题:操作系统中可能运行着成千上万个进程,每个进程都可能调用 alarm 设置闹钟。OS 怎么知道哪个闹钟先响?怎么高效管理这些闹钟?

10.1 先描述,再组织

在内核中,每个闹钟本质上是一个结构体对象(例如 struct timer):

cpp 复制代码
struct timer {
    uint64_t expired_time; // 绝对过期时间 = 当前时间 + seconds
    void (*callback)();    // 回调函数/处理动作
    // ... 其他属性
};

10.2 数据结构:最小堆 (Min-Heap)

操作系统不需要检查所有的闹钟,它只需要检查那个最快要过期的闹钟。如果最快要过期的都没过期,其他的肯定也没过期。

  • 组织方式 :OS 通常使用 最小堆 (Min-Heap) 来组织这些定时器结构体。

    • 堆顶元素:永远是过期时间最小(最早触发)的那个闹钟。
  • 检查逻辑:OS 只需要周期性地(比如每次时钟中断)检查堆顶元素:

    • if (current_time >= heap[0].expired_time) -> 触发信号,弹出堆顶,继续检查新的堆顶。

    • else -> 没到时间,不用管后面的,直接返回。

总结

  • alarm 本质是让OS创建一个定时器节点。

  • OS 使用最小堆高效管理这些节点,保证查找最近超时任务的时间复杂度为

    O(1),插入和删除为 )O(logN)。

  • 时间一到,OS 发送 SIGALRM 信号。

十一、 信号的保存:三张表 (Block, Pending, Handler)

11.1 核心概念:阻塞、未决、递达

在深入数据结构之前,我们必须先理清三个非常容易混淆的概念:

  1. 信号递达 (Handler):实际执行信号处理动作的过程(例如执行默认动作、忽略或调用自定义Handler)。

  2. 信号未决 (Pending):信号从产生到递达之间的状态。简单来说,就是信号已经产生了,但进程还没来得及(或暂时不愿)处理它。

  3. 信号阻塞 (Block):进程可以选择阻塞(屏蔽)某个信号。

    • 关键点 :被阻塞的信号产生时,会一直保持在未决状态 (Pending),直到进程解除对此信号的阻塞,才执行递达动作。

    • 区别阻塞忽略是完全不同的。

      • 阻塞:信号根本没被处理,还在排队(Pending位为1)。

      • 忽略:信号已经被处理了(递达了),只是处理的动作是"什么都不做"。

11.2 内核中的三张表

在进程的 PCB (task_struct) 中,维护了三张核心的表来管理信号:

  1. Block 表(信号屏蔽字)

    • 本质:位图 (sigset_t-- unsigned long)。

    • 含义 :比特位的位置代表信号编号,内容(0或1)代表该信号是否被阻塞

    • 图解:如果第2位是1,说明2号信号被屏蔽了。

  2. Pending 表(未决信号集)

    • 本质:位图 (sigset_t-- unsigned long)。

    • 含义 :比特位的位置代表信号编号,内容(0或1)代表该信号是否已产生且未被处理

    • 图解:如果第2位是1,说明收到了一个2号信号,正在等待处理。

  3. Handler 表(处理方法表)

    • 本质:函数指针数组 (sighandler_t handler[32])。

    • 含义:数组下标对应信号编号(下标 = 信号 - 1),数组内容是处理该信号的函数地址。

    • 类型:SIG_DFL (默认), SIG_IGN (忽略), 或用户自定义函数地址。

11.3 信号处理的逻辑流

当一个信号产生时:

  1. OS修改 Pending表,将对应位置1。

  2. 在合适的时候,OS检查 Block表

  3. 如果 Block对应位为1:虽然Pending是1,但被挡住了,无法递达,保持Pending状态。

  4. 如果 Block对应位为0 :信号可以通过,OS根据 Handler表 找到处理函数,执行递达动作,并将 Pending位清0。

注意:常规信号(1-31)在递达之前产生多次只计一次(因为位图只能存0或1);而实时信号可以排队。


十二、 信号集操作函数

由于 Block 和 Pending 表都是位图结构,依赖于具体的系统实现,OS 不希望用户直接通过位运算操作这些数据。因此,OS 提供了一套专门的数据类型 sigset_t 和对应的操作函数。

12.1 sigset_t 类型

这是一个系统定义的数据类型,用于表示信号集。在用户层,我们可以把它看作是一个黑盒,只能用API去操作它。

12.2 信号集操作 API

cpp 复制代码
#include <signal.h>

int sigemptyset(sigset_t *set);                  // 清空(全0)
int sigfillset(sigset_t *set);                   // 填满(全1,包含所有信号)
int sigaddset(sigset_t *set, int signum);        // 添加一个信号(置1)
int sigdelset(sigset_t *set, int signum);        // 删除一个信号(置0)
int sigismember(const sigset_t *set, int signum);// 判断是否存在(返回1真,0假)

注意:在使用 sigset_t 变量之前,必须调·

12.3 核心系统调用:sigprocmask

用于读取更改进程的 Block 表(信号屏蔽字)。

cpp 复制代码
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  • how:操作模式。

    • SIG_BLOCK:mask = mask | set (添加屏蔽)

    • SIG_UNBLOCK:mask = mask & ~set (解除屏蔽)

    • SIG_SETMASK:mask = set (直接覆盖,最常用)

  • set:我们设定的新集合。

  • oldset:输出型参数,保存旧的屏蔽字(方便恢复)。

12.4 核心系统调用:sigpending

用于获取当前进程的 Pending 表。

cpp 复制代码
int sigpending(sigset_t *set);
  • set:输出型参数,内核将当前的 pending 表拷贝到这里。

十三、 实验:验证 Block 与 Pending 的关系

我们通过代码来验证以下现象:

  1. 屏蔽2号信号(Ctrl+C)。

  2. 不断打印 Pending 表。

  3. 发送2号信号,观察 Pending 表对应位变为1(但进程不退出)。

  4. 解除屏蔽,观察信号被递达,Pending 表对应位清0。

13.1 实验代码

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

void PrintPending(sigset_t &pending)
{
    for (int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&pending, signo)) std::cout << "1";
        else std::cout << "0";
    }
    std::cout << std::endl;
}

void handler(int signo)
{
    std::cout << "------------------------enter handler" << std::endl;
    // 验证:在handler执行期间,Pending位是否已经清0?
    sigset_t pending;
    sigemptyset(&pending);
    sigpending(&pending);
    PrintPending(pending); 
    
    std::cout << "处理完成信号: " << signo << std::endl;
    std::cout << "------------------------leave handler" << std::endl;
}

int main()
{
    std::cout << "我的pid: " << getpid() << std::endl;

    // 0. 捕捉2号信号,方便观察现象
    signal(2, handler);

    // 1. 屏蔽2号信号
    sigset_t block_set, old_set;
    sigemptyset(&block_set);
    sigemptyset(&old_set);
    sigaddset(&block_set, 2); // 添加2号信号到集合中

    // 应用屏蔽字,从现在开始,2号信号被阻塞
    sigprocmask(SIG_SETMASK, &block_set, &old_set);

    int cnt = 1;
    while (true)
    {
        // 2. 获取并打印当前进程的 pending 表
        sigset_t pending;
        sigemptyset(&pending);
        sigpending(&pending);
        PrintPending(pending);

        // 3. 20秒后,解除对2号信号的屏蔽
        if (cnt == 20)
        {
            std::cout << "解除对2号信号屏蔽" << std::endl;
            // 恢复旧的屏蔽字(old_set中2号没被屏蔽)
            // 一旦解除屏蔽,积压的2号信号会立即被递达!
            sigprocmask(SIG_SETMASK, &old_set, nullptr);
        }
        cnt++;
        sleep(1);
    }
}

13.2 实验现象与结论

  1. 现象

    • 程序运行,打印全0的位图。

    • 按下 Ctrl+C,位图变为 0...010 (第2位为1),但程序没停止(信号被阻塞,处于未决)。

    • 20秒后,打印"解除屏蔽"。

    • 立即执行 handler 函数。

    • handler 执行完毕后,位图恢复全0。

  2. 关键结论

    • 解除屏蔽的瞬间,积压的信号会被立即递达

    • Pending位是在执行 Handler 之前 就已经被清0了(注意看代码中 Handler 内部打印的 Pending 表,2号位已经是0了)。

      • 注:如果在 Handler 内部再次发送2号信号,Pending位会再次变1,但这属于下一次处理流程了。

十四、 信号捕捉的完整流程:用户态与内核态

问题:信号到底是什么时候被处理的?

答案:从内核态返回用户态的时候,进行信号的检测和处理。

14.1 用户态 vs 内核态

  • 用户态 (User Mode):执行用户自己的代码,受限访问(只能访问 [0, 3GB] 地址空间)。

  • 内核态 (Kernel Mode):执行操作系统代码(系统调用、异常处理),拥有最高权限(访问 [3GB, 4GB] 以及所有硬件)。

什么情况会进入内核态?

  1. 系统调用(如 read, write, fork)。

  2. 异常(如缺页异常、除0错误)。

  3. 外设中断(如时钟中断、键盘中断)。

14.2 信号处理流程图解

这是一个非常经典的"∞"字形流程:

  1. 进入内核:用户程序运行中,因为系统调用或中断,陷入内核态。

  2. 执行内核任务:内核处理完异常或系统调用(比如读完了磁盘数据)。

  3. 信号检测 :在准备返回用户态之前,内核会检查当前进程的 Pending 表和 Block 表。

    • 如果没有待处理信号 -> 直接返回用户态,恢复上下文继续运行。

    • 如果有待处理信号 -> 进入步骤4。

  4. 执行 Handler

    • 如果是默认动作(如终止)-> 直接杀掉进程。

    • 如果是自定义捕捉 -> 切回用户态 执行 sighandler 函数。

    • 为什么要切回用户态? 操作系统不信任用户代码。如果用内核态权限执行用户写的 handler,万一代码里有恶意操作,系统就挂了。

  5. 返回内核:sighandler 执行完毕后,通过特殊的系统调用 sigreturn 再次进入内核。

  6. 恢复上下文:内核清理现场,最终返回用户态,从主控制流被打断的地方继续执行。

14.3 记忆图解

简记:4个交点对应4次切换

  1. 用户 -> 内核 (中断/系统调用)

  2. 内核 -> 用户 (执行Handler)

  3. 用户 -> 内核 (Handler返回)

  4. 内核 -> 用户 (恢复主流程)


十五、 操作系统是怎么运行的?------ 中断驱动

15.1 冯·诺依曼体系与CPU的视角

我们要先回到计算机的本源------冯·诺依曼体系结构

操作系统本质上是一个软件,是一堆存储在磁盘上、加载到内存里的二进制代码。CPU 是一个只会执行指令的硬件。

  • 问题:CPU 怎么知道什么时候该去执行操作系统的代码(比如读磁盘、发网络包),什么时候执行用户的代码?

  • 答案中断 (Interrupt)

15.2 硬件中断

请看下图的硬件中断流程:

  1. 外设就绪:当键盘被按下、网卡收到了数据、或者磁盘读写完毕,这些外部设备(外设)需要 CPU 的关注。

  2. 发起中断 :外设通过硬件线路向 中断控制器 (如 8259A/APIC) 发送电信号。

  3. 通知 CPU:中断控制器检查优先级后,向 CPU 的引脚发送一个电信号,告诉 CPU:"别睡了/别算数了,有急事!"。

  4. CPU 保护现场 :CPU 收到中断后,会暂停当前正在执行的程序(比如你的 while(1) 循环),将当前的寄存器状态(PC指针、状态寄存器等)压入内核栈中保存,这叫保护现场

  5. 查表执行 :CPU 获取中断号,去内存中查一张特殊的表------中断向量表 (IDT, Interrupt Descriptor Table)

    • 这张表里存放的是中断处理程序 (ISR) 的入口地址。

    • 重点中断向量表是操作系统的一部分,OS启动时就加载进内存了。

  6. 执行中断服务:CPU 跳转到对应的中断服务程序(如键盘驱动、网卡驱动)去执行代码。

  7. 恢复现场:处理完后,CPU 从栈中恢复之前的寄存器状态,继续执行被打断的程序。

结论通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。 操作系统是"躺"在中断处理程序上的代码,由外设推着走。

15.3 深度思考:信号与中断的关系(康师傅 vs 康帅傅)

我们在学习"进程信号"时,一直说信号是异步 的,现在的"硬件中断"也是异步的。它们之间有什么关系呢?

  1. 计算机发展的历史逻辑

    • 计算机世界先有了硬件中断,用来处理键盘、网卡等突发硬件事件。

    • 后来的OS设计者发现,软件进程也需要一种类似的机制,来处理软件层面的突发事件(如进程被杀、除0错误)。

    • 于是,设计者模仿 硬件中断,发明了信号机制

  2. 本质区别

    • 信号 (Signal) :是纯软件的方式,模拟中断完成特定的任务处理。

    • 硬件中断 (Interrupt) :是硬件+软件配合的产物,由电流触发。

  3. 形象比喻:康师傅 vs 康帅傅

    • 这两者原理极其相似(包装像、名字像、功能都是填饱肚子/处理异步事件)。

    • 但是本质完全不同(一个是正版大厂方便面,一个是模仿的山寨品)。

    • 信号就是操作系统里的"康帅傅",它借鉴了硬件中断的理念,但在实现上完全依赖于软件(位图、PCB)。


十六、 谁在推着操作系统走?------ 时钟中断

如果没有任何外设操作(没按键盘、没联网),操作系统是不是就不工作了?当然不是。因为有一个特殊的硬件------时钟源

16.1 系统的脉搏:时钟中断

在主板上有一个晶振 或定时器硬件(如 8253 PIT),它会以固定的频率(比如每 1ms 或 10ms)向 CPU 发送中断。这被称为时钟中断

流程

  1. 时钟震荡,产生中断信号。

  2. CPU 收到中断,暂停当前进程。

  3. 查 IDT 表,找到时钟中断处理程序(Linux 0.11 中是 timer_interrupt)。

  4. 执行 do_timer() 函数。

16.2 调度器的动力

在 do_timer 中,操作系统会做一件极其重要的事情:扣除当前进程的时间片

cpp 复制代码
// 伪代码逻辑
void do_timer(long cpl) {
    // ... 增加系统滴答数 ticks ...
    if ((--current->counter) > 0) return; // 时间片没用完,继续跑
    current->counter = 0;
    schedule(); // 时间片用完了,触发进程调度!切换到下一个进程
}
  • 为什么 CPU 有主频? 主频越高,单位时间内处理的时钟中断和指令越多,系统响应越快。

  • 并发的本质 :正是因为有了时钟中断,操作系统才能强制把 CPU 从一个死循环的进程手里抢回来,分给别的进程。这就是时间片轮转的基础。

💡 现实中的样例:CPU 2.5GHz 意味着什么?

我们买电脑时常看参数:Intel i7 2.5GHz

  • 2.5GHz:代表 CPU 内部的时钟频率高达 25亿次/秒。CPU 运转极快,执行指令也是纳秒级的。

  • OS的节拍 (Tick) :操作系统并不会每秒中断 25亿次(那样CPU光处理中断了,干不了活)。使用分频方式,OS 通常将时钟中断设置为 100Hz ~ 1000Hz(即每 10ms 或 1ms 触发一次)。

16.3 操作系统的本质:死循环

如果当前没有任务需要执行,操作系统在干嘛?

它在跑一个特殊的进程------0号进程 (Idle Task)

cpp 复制代码
void main(void) {
    // ... 初始化 ...
    for(;;) pause(); // 这是一个死循环!
}

操作系统本质就是一个死循环。它在 pause() 中挂起 CPU(低功耗模式),等待被下一个中断(键盘、时钟等)唤醒。


十七、 软件触发的中断------软中断与系统调用

我们在前文中提到,外设(如键盘)可以通过硬件线路触发"硬中断"。那么,正在运行的软件代码能不能也触发中断呢?

答案是肯定的。这正是系统调用(System Call)实现的基石。在操作系统中,这被称为软中断陷阱(Trap)

17.1 标准库的封装:程序员眼中的 syscall vs 真实的 syscall

我们平时写代码调用的是 printf,或者 write,这些其实是 C 标准库(glibc)封装好的库函数 ,而不是真正的系统调用

1. 库函数的封装工作

glibc 是用户程序和 Linux 内核之间的"中间人"。当我们调用 write() 时,glibc 实际上做了以下动作:

  1. 参数准备:将用户传入的参数(文件描述符、缓冲区、长度)放到特定的 CPU 寄存器中。

  2. 设置系统调用号:OS 提供了几百个系统调用,每个都有唯一的编号(如 __NR_write 是 4)。glibc 会把这个编号放到 EAX 寄存器中。

  3. 触发中断 :执行特殊的汇编指令(int 0x80 或 syscall),主动触发软中断,陷入内核。

2. 汇编层面的调用链(伪代码演示)

假设我们在 C 代码中写了:write(1, "hello", 5);

Step 1: 用户态(User Mode) - Glibc 的封装

bash 复制代码
; C语言调用: write(1, "hello", 5)
; 这里的代码是运行在 Ring 3 的

mov edx, 5          ; 参数3: 长度 len 放入 EDX 寄存器
mov ecx, "hello"    ; 参数2: 缓冲区地址 buffer 放入 ECX 寄存器
mov ebx, 1          ; 参数1: 文件描述符 fd 放入 EBX 寄存器

mov eax, 4          ; 【关键】: 将系统调用号(sys_write) 放入 EAX 寄存器
                    ; OS 根据这个 EAX 知道你想干什么

int 0x80            ; 【核心指令】: 触发 0x80 号中断!
                    ; 此时 CPU 动作:
                    ; 1. 检查权限,发现是陷阱门,允许通过
                    ; 2. 切换堆栈(用户栈 -> 内核栈)
                    ; 3. 修改 CS:IP,跳转到内核的中断处理程序
                    ; 4. 特权级 CPL 从 3 变为 0

Step 2: 内核态(Kernel Mode) - 统一入口 system_call

CPU 跳转到了 IDT 中 0x80 号位置登记的地址,通常是 system_call 函数:

bash 复制代码
; 这里的代码是运行在 Ring 0 (内核态)的

system_call:
    SAVE_ALL        ; 1. 保存现场(把用户态的寄存器压入内核栈)
    
    cmpl $NR_syscalls, %eax  ; 2. 检查 EAX 里的调用号是否合法
    jae bad_sys_call         ; 如果非法,报错返回
    
    call *sys_call_table(,%eax,4) 
    ; 3. 【查表执行】
    ; sys_call_table 是一个函数指针数组
    ; 相当于执行: sys_call_table[EAX](EBX, ECX, EDX)
    ; 此时,真正的 sys_write 被执行
    
    movl %eax, PT_EAX(%esp)  ; 4. 将函数返回值写回栈中保存的 EAX 位置
    
    RESTORE_ALL     ; 5. 恢复现场
    iret            ; 6. 中断返回(从 Ring 0 切回 Ring 3)

17.3 系统调用表 (sys_call_table)

在内核中,有一个非常重要的数组 sys_call_table。它就像一个函数指针数组,下标就是系统调用号,内容就是对应的内核函数地址。

cpp 复制代码
// 内核源码示意 (伪代码)
typedef void (*syscall_ptr_t)(void);

const syscall_ptr_t sys_call_table[] = {
    [0] = sys_restart_syscall,
    [1] = sys_exit,
    [2] = sys_fork,
    [3] = sys_read,
    [4] = sys_write,  // 对应 EAX = 4
    // ...
};
  • 系统调用号的本质 :就是这个数组的下标

  • 宏定义:我们在头文件 <unistd.h> 或 <sys/syscall.h> 中看到的 #define __NR_write 4,就是为了配合这个表。

17.4 为什么这叫"软中断"?

  • 硬中断 :由硬件(键盘、网卡)通过电流触发,是异步的(随时可能发生)。

  • 软中断(陷阱) :由软件执行指令(int 0x80)触发,是同步的(只有执行到这行代码才会发生)。

虽然触发源不同,但它们在 CPU 处理的流程上是殊途同归的:都通过 IDT(中断向量表)进行跳转,都涉及现场保护与恢复,都涉及特权级的切换。

总结:

  1. OS 提供 :系统调用处理函数(如 sys_write)和 系统调用表(sys_call_table)。

  2. 标准库 提供:方便的 C 接口(如 write()),负责把参数和调用号搬运到寄存器,并执行 int 0x80 陷入内核。

  3. 核心流程:用户调用库函数 -> 库函数填寄存器 -> 触发软中断 -> CPU切入内核 -> 内核查表执行 -> 返回结果。

17.5 异常 (Exception)

除了主动的 int 0x80,还有被动的软中断:

  • 除0错误:CPU ALU 单元运算出错 -> 触发 0号中断(异常)。

  • 缺页异常:MMU 转换地址失败 -> 触发 14号中断(异常)。

  • 野指针:访问越界 -> 触发异常。

本质:这些异常都会被 CPU 捕获,转而执行 OS 预设的中断处理程序。OS 在处理程序中发现是用户进程搞的鬼,就会向该进程发送信号(如 SIGFPE, SIGSEGV),导致进程终止。

总结操作系统就是躺在中断处理例程上的代码块! 无论是硬件推着走(硬中断),还是软件主动请求(软中断/系统调用),亦或是程序出错(异常),最终都是中断机制在起作用。


十八、 深入理解用户态与内核态

我们常说"切换到内核态",这到底是什么意思?

18.1 虚拟地址空间的共用

看看这张内存图:

  • [0, 3GB] 用户空间:每个进程独有,存放代码、数据、堆、栈。

  • [3GB, 4GB] 内核空间所有进程共享

    • 无论怎么切换进程,地址空间的高 1GB 映射的物理内存都是同一块(操作系统的代码和数据)。

    • 结论进程在进行系统调用时,并没有切换"操作系统",而是在自己的地址空间的高处,执行操作系统的代码。

18.2 谁来保护内核?------ CPU 特权级 (CPL/DPL)

既然内核代码就在进程的 3G-4G 空间里,为什么我在用户态写个指针 char *p = 0xc0000000; *p = 10; 会报错(Segfault)?

这是 硬件(CPU) 的保护机制。

  • CS 寄存器 :CPU 的代码段寄存器中,最后 2 位表示当前特权级 (CPL, Current Privilege Level)。

    • 0:二进制00,内核态(Ring 0),最高权限,可以执行所有指令,访问所有内存。

    • 3:二进制11,用户态(Ring 3),最低权限,受限访问。

  • 内存页表/段描述符 :每一页内存都有一个属性 (DPL, Descriptor Privilege Level)。内核页面的 DPL 是 0。

访问检查

当 CPU 处于 CPL=3 (用户态) 时,试图访问 DPL=0 (内核态) 的地址空间,硬件电路会直接拦截,并触发异常中断。

18.3 状态切换的本质

所谓"从用户态切换到内核态",本质上是:

  1. 触发中断(硬中断或 int 0x80)。

  2. CPU 硬件自动校验:允许通过特定的"中断门"进入 Ring 0。

  3. 修改 CS 寄存器 :将 CPL 从 3 变为 0。
    注意,仅通过一条指令修改CS寄存器,无法将当前特权级从3(用户态)变为0(内核态),也无法因此获得访问DPL=0的内核地址空间的权限。

  4. 跳转代码:CPU 指令指针 (EIP) 跳到 3G 以上的内核代码处执行。


十九、 更高级的信号捕捉:sigaction

之前我们使用 signal 函数来捕捉信号,它简单易用,但功能相对单一。在POSIX标准中,sigaction 是一个功能更强大、移植性更好的信号捕捉接口。

19.1 函数原型

cpp 复制代码
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  • signum: 指定要捕捉的信号编号。

  • act: 输入参数,包含了详细的信号处理动作信息。

  • oldact: 输出参数,用于备份旧的处理动作。

19.2 struct sigaction 结构体

这个结构体是核心,我们看下它的定义:

cpp 复制代码
struct sigaction {
    void     (*sa_handler)(int); // 1. 简单的信号处理函数指针
    void     (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理(暂不讨论)
    sigset_t   sa_mask;          // 2. 处理该信号期间,需要额外屏蔽的信号集
    int        sa_flags;         // 3. 选项标志,通常设为0
    void     (*sa_restorer)(void); // 旧版本使用,现已废弃
};

19.3 核心机制:自动屏蔽 (Auto-Masking)

我们在学习信号保存时知道,Block表(屏蔽字)决定了信号是否能递达。sigaction 有一个非常重要的特性

当某个信号的处理函数(Handler)被调用时,内核会自动将当前该信号加入进程的信号屏蔽字(Block表)。当信号处理函数返回时,自动恢复原来的信号屏蔽字。

为什么?

为了防止信号递归 。如果处理2号信号的过程中,又来了个2号信号,如果允许嵌套调用,可能会导致栈溢出或逻辑混乱。OS通过自动屏蔽,保证了同一时刻,同一个信号的处理函数不会被重复调用(串行化处理)

此外,sa_mask 字段 的作用是:如果在调用 Handler 期间,你还想顺便屏蔽掉其他信号(比如处理2号时,不想被3号打断),就可以把3号加到 sa_mask 集中。

19.4 流程验证图解

请看下图,描述了 sigaction 介入后的信号处理流程:

  1. Entry: 收到信号,进入 Handler。此时 OS 自动将当前信号(如2号)的 Block 位置 1。同时将 sa_mask 指定的信号也 Block 置 1。

  2. Execution: 执行 Handler 代码。此时如果再次收到2号信号,它会变成 Pending(未决)状态,不会立即打断当前执行。

  3. Leave: Handler 执行完毕,调用 sys_sigreturn 返回内核。

  4. Restore: 内核自动将 Block 表恢复到 Entry 之前的状态。之前 Pending 的信号此刻被解除屏蔽,进行下一次递达。

19.5 实验验证:自动屏蔽机制

我们可以通过以下代码来验证这个特性。我们在 handler 函数中打印当前的 Pending 表。如果 sigaction 的自动屏蔽机制生效,那么当我们正在处理 2 号信号时,如果再次发送 2 号信号,它应该会出现在 Pending 表中(位图为 1),而不会立即打断当前的 handler。

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

void handler(int signo)
{
    for (int signo = 31; signo > 0; signo--)
    {
        if (sigismember(&pending, signo))
        {
            std::cout << "1";
        }
        else
        {
            std::cout << "0";
        }
    }
    std::cout << std::endl;
}

int main()
{
    struct sigaction act, oact;
    
    // 设置处理函数
    act.sa_handler = handler;
    
    // 初始化 sa_mask,这表示在处理信号期间,除了当前信号外,不额外屏蔽其他信号
    // 如果想额外屏蔽比如3号信号,可以在这里 sigaddset(&act.sa_mask, 3);
    // 要有很关键
    sigemptyset(&(act.sa_mask)); 
    
    act.sa_flags = 0;

    // 注册 2号信号 (SIGINT) 的处理动作
    sigaction(SIGINT, &act, &oact);

    std::cout << "进程在运行: " << getpid() << std::endl;

    while(true)
    {
        sleep(1);
    }
    return 0;
}

二十、 可重入函数

信号捕捉最大的特点是异步。主程序执行到一半,可能随时被切走去执行 Handler,Handler 执行完再切回来。这引发了一个严重的数据安全问题。

20.1 链表插入的灾难

假设我们有一个全局链表,主函数 main 和信号处理函数 handler 都要向这个链表中插入节点 insert()。

  • 场景还原

    1. main 函数正在调用 insert(node1)。插入操作分两步:(1) p->next = head; (2) head = p;。

    2. 刚执行完第(1)步,发生了中断/时钟切片,进程暂停。

    3. 进程恢复运行时,发现有一个待处理信号,于是进入 handler。

    4. handler 中也调用了 insert(node2),并且完整执行了(1)(2)步,将 node2 挂到了链表头。

    5. handler 返回,回到 main 的第(2)步继续执行。此时 main 将 head 指向了 node1。

  • 结果

    head 指向了 node1,而 node1->next 指向了旧的 head。node2 就这样凭空消失了(内存泄漏)!

20.2 什么是可重入函数?

  • 不可重入函数:像上面的 insert 函数,被不同的执行流(main 和 handler)重复调用(重入)时,会导致数据错乱或逻辑错误。

    • 特征

      1. 使用了全局变量或静态变量。

      2. 调用了 malloc 或 free(它们内部维护了全局链表)。

      3. 调用了标准 I/O 库函数(如 printf,内部有全局缓冲区)。

  • 可重入函数:如果一个函数只访问自己的局部变量或参数,不依赖任何全局数据,在多执行流环境下安全运行,则称为可重入函数。

注意 :绝大多数系统库函数和自己写的函数都是不可重入的。在信号处理函数中,应尽量避免调用不可重入函数(如 printf, malloc),虽然我们演示时常用 printf,但在工程实践中这是禁忌。


二十一、 消失的内存可见性:volatile 关键字

在编写信号处理程序时,我们常遇到一个诡异的现象:Handler明明修改了变量,Main函数却"视而不见"。

21.1 实验现象

cpp 复制代码
#include <stdio.h>
#include <signal.h>

int flag = 0; // 全局变量

void handler(int sig) {
    printf("change flag 0 -> 1\n");
    flag = 1; // 修改全局变量
}

int main() {
    signal(2, handler);
    while(!flag); // 等待 flag 变 1
    printf("process quit normal\n");
    return 0;
}
  • 正常编译 (gcc -o sig sig.c):运行程序,按 Ctrl+C,Handler 执行,flag 变 1,while 循环结束,进程正常退出。

  • 优化编译 (gcc -o sig sig.c -O2) :运行程序,按 Ctrl+C,Handler 执行打印了日志,但是 while 循环一直在空转,进程不退出!

21.2 原因分析:寄存器 vs 内存

这是编译器优化(Optimization)带来的副作用。

  1. 编译器的视角:编译器在分析 main 函数时,发现 while(!flag) 里面没有任何代码修改 flag。编译器认为 flag 在 main 的上下文中是一个"常量"。

  2. 优化动作 :为了提高速度,编译器决定把 flag 的值从内存读到 CPU 寄存器 (Register) 中,以后每次 while 判断都直接读寄存器,不再访问内存(因为读寄存器比读内存快得多)。

  3. 运行时 :handler 执行时,确实把内存 中的 flag 改成了 1。但是,main 函数的 while 循环还在傻傻地看寄存器里的旧值(0)。

  4. 结果 :内存改了,寄存器没改,造成了内存不可见

21.3 解决方案:volatile

C语言提供了 volatile 关键字,意为"易变的"。

cpp 复制代码
volatile int flag = 0;
  • 作用:告诉编译器,这个变量可能会被意想不到的执行流(如信号、硬件中断)修改。

  • 强制约束禁止编译器将该变量缓存到寄存器中。每次访问该变量,必须直接从内存中读取。

加上 volatile 后,无论开多高的优化等级,程序都能正常退出了。


二十二、 SIGCHLD:优雅地回收子进程

在之前的进程控制章节中,我们知道子进程退出时,如果父进程不管不顾,子进程就会变成僵尸进程(Zombies),造成内存泄漏。为了回收子进程,父进程通常有两种做法:

  1. 阻塞等待 (wait):父进程必须暂停手中的工作,专心等子进程死,效率极低。

  2. 非阻塞轮询 (waitpid + WNOHANG):父进程虽然可以做其他事,但需要不断分心去 check 子进程的状态,代码逻辑复杂耦合。

有没有一种办法,能让子进程退出了主动通知父进程,父进程再去回收呢?

22.1 SIGCHLD 机制

其实,Linux 早就设计了这种机制。子进程退出时,会向父进程发送 17) SIGCHLD 信号。

只不过,该信号的默认处理动作是 Ign (忽略),所以我们平时写代码时没感觉到它的存在。

22.2 方法一:自定义捕捉回收 (Handler)

我们可以注册 SIGCHLD 的处理函数,在 Handler 里面调用 wait/waitpid。这样父进程只需专心做自己的事,只有收到信号时才被打断去回收子进程

⚠️ 核心痛点:多子进程并发退出问题

这里有一个非常经典的面试题/坑点,请看如下场景:

问题:如果父进程 Fork 了 10 个子进程,它们几乎在同一时刻退出,会发生什么?

  1. 信号丢失问题 :10 个子进程退出,会发送 10 次 SIGCHLD 信号。但是,普通信号(1-31)使用 位图 (Bitmap) 记录,不支持排队

    • 当父进程正在处理第 1 个信号时,剩下的 9 个信号可能几乎同时到达。

    • Pending 位图的第 17 位只是被反复置为 1。

    • 结果:父进程可能只收到了 1 次或 2 次信号,如果 Handler 里只 wait 一次,就会导致剩下 8 个子进程变成僵尸

  2. 阻塞死锁问题:为了解决信号丢失,我们必须在一个 Handler 里把所有退出的子进程都收完(循环 wait)。

    • 但如果 10 个子进程中,只有 5 个退出了,剩下 5 个还在跑?

    • 如果我们用阻塞式的 waitpid(-1, 0, 0),收完 5 个僵尸后,第 6 次 wait 就会阻塞住,导致 Handler 卡死,父进程的主逻辑也随之卡死。

✅ 正确的解决方案:while 循环 + WNOHANG

为了解决上述两个问题,Handler 必须这样写:

  1. 使用 while 循环:只要还有僵尸,就一直收,一次 Handler 调用清理所有积压的僵尸。

  2. 使用 WNOHANG:非阻塞等待。如果发现子进程还在运行,不要卡住,直接返回,结束 Handler。

python 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>

void handler(int signo)
{
    printf("父进程捕捉到信号: %d, 开始回收...\n", signo);
    
    // 必须使用 while 循环 + WNOHANG!
    pid_t id;
    int status = 0;

    // waitpid 返回值含义:
    // > 0 : 成功回收了一个子进程,返回其pid
    // = 0 : 子进程还在运行,且设置了WNOHANG,不等待,直接返回
    // < 0 : 所有子进程都回收完了(没有子进程了),出错返回
    while( (id = waitpid(-1, &status, WNOHANG)) > 0) 
    {
        printf("成功回收子进程: %d, 退出码: %d\n", id, WEXITSTATUS(status));
    }
    
    printf("本次信号处理结束,已无僵尸进程。\n");
}

int main()
{
    // 注册信号处理函数
    signal(SIGCHLD, handler);

    // 创建 10 个子进程
    for(int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if(id == 0)
        {
            // 子进程
            int n = rand() % 5 + 1; // 随机休眠 1-5秒
            printf("我是子进程 %d, 我将运行 %d 秒\n", getpid(), n);
            sleep(n);
            exit(i); // 退出
        }
    }

    // 父进程主循环
    while(1)
    {
        printf("父进程(PID:%d) 正在安心做自己的事情...\n", getpid());
        sleep(1);
    }

    return 0;
}

22.3 方法二:显式忽略 (Linux 特性)

如果觉得写 Handler 太麻烦,而且父进程其实完全不关心子进程的退出状态(比如不需要知道它是正常退出还是报错),在 Linux 下有一种"偷懒"的奇技淫巧。

代码

python 复制代码
// 显式地将 SIGCHLD 的处理动作设置为"忽略"
signal(SIGCHLD, SIG_IGN);

原理与细节(核心差异)

这里有一个非常反直觉的现象:SIGCHLD 的默认动作(SIG_DFL)本身就是忽略,为什么还需要手动设置 SIG_IGN?两者有何不同?

  1. 默认的忽略 (SIG_DFL -> Action: Ign)

    • 这是操作系统默认的行为。虽然对信号的处理动作是"忽略"(即不终止父进程),但内核依然会保留子进程的 PCB(即变成僵尸进程),等待父进程来 wait。

    • 含义:"父进程没说不要,我先帮他留着尸体,万一他以后要查呢。"

  2. 显式的忽略 (SIG_IGN)

    • 这是用户通过代码明确告知内核的行为。

    • 在 Linux 中,内核对 SIGCHLD 做了特殊处理。如果发现应用层将其 handler 设置为了 SIG_IGN,内核在子进程退出时,会自动清理回收资源,不再产生僵尸进程

    • 含义:"父进程明确说了他不要了,直接处理掉吧。"

⚠️ 注意事项

  1. 可移植性:这是一个 Linux 系统特有的行为(虽然现在大部分 UNIX 变种都支持,但不保证完全的可移植性)。

  2. wait 失效 :使用此方法后,系统会自动回收子进程。如果父进程随后调用 wait 或 waitpid,会因为找不到任何子进程而立即出错返回 -1(错误码 ECHILD),无法捕获子进程的退出信息。

相关推荐
Zeku1 小时前
20251125 - 韦东山Linux第三篇笔记【下】
linux·驱动开发·嵌入式硬件
XH-hui1 小时前
【打靶日记】VluNyx 之 Hat
linux·网络安全·vulnyx
RisunJan1 小时前
Linux命令-fping命令(网络诊断工具)
linux·网络
BD_Marathon1 小时前
【Zookeeper】Zookeeper内部的数据模型
linux·分布式·zookeeper
繁华似锦respect2 小时前
C++ 无锁队列(Lock-Free Queue)详细介绍
linux·开发语言·c++·windows·visual studio
qq_433192182 小时前
Linux ISCSI服务器配置
linux·服务器·数据库
python百炼成钢2 小时前
47.Linux UART 驱动
linux·运维·服务器·驱动开发
汽车仪器仪表相关领域2 小时前
PSN-1:氮气加速 + 空燃比双控仪 ——NOS 系统的 “安全性能双管家”
大数据·linux·服务器·人工智能·功能测试·汽车·可用性测试