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),无法捕获子进程的退出信息。

相关推荐
jimy14 小时前
安卓里运行Linux
linux·运维·服务器
爱凤的小光5 小时前
Linux清理磁盘技巧---个人笔记
linux·运维
耗同学一米八6 小时前
2026年河北省职业院校技能大赛中职组“网络建设与运维”赛项答案解析 1.系统安装
linux·服务器·centos
知星小度S7 小时前
系统核心解析:深入文件系统底层机制——Ext系列探秘:从磁盘结构到挂载链接的全链路解析
linux
2401_890443027 小时前
Linux 基础IO
linux·c语言
智慧地球(AI·Earth)8 小时前
在Linux上使用Claude Code 并使用本地VS Code SSH远程访问的完整指南
linux·ssh·ai编程
老王熬夜敲代码9 小时前
解决IP不够用的问题
linux·网络·笔记
zly35009 小时前
linux查看正在运行的nginx的当前工作目录(webroot)
linux·运维·nginx
QT 小鲜肉9 小时前
【Linux命令大全】001.文件管理之file命令(实操篇)
linux·运维·前端·网络·chrome·笔记
问道飞鱼10 小时前
【Linux知识】Linux 虚拟机磁盘扩缩容操作指南(按文件系统分类)
linux·运维·服务器·磁盘扩缩容