【Linux】进程信号

🔥铅笔小新z:个人主页

🎬博客专栏:Linux学习

💫滴水不绝,可穿石;步履不休,能至渊。


一、信号基础概念与认识

1. 什么是信号

信号(Signal) 是进程之间事件异步通知的一种方式,属于软中断

  • 信号是从纯软件角度模拟硬件中断的行为
  • 硬件中断是发给 CPU 的,而信号是发给进程的
  • 信号相对于进程的控制流程来说是异步(Asynchronous)

生活角度理解信号

生活场景 信号机制
你识别快递 进程识别信号(内核内置特性)
收到快递通知但暂时不取 信号产生但暂不处理(在合适的时候处理)
记住有快递要取 信号被保存(未决状态)
取快递并处理 信号递达并执行处理动作

关于信号处理的核心结论

  1. 识别信号是内置的 --- 进程识别信号的能力由内核程序员编写在内核中
  2. 处理方法预先知道 --- 即使信号没有产生,进程也知道该如何处理该信号
  3. 处理时机 --- 信号不会立即被处理,而是在"合适的时候"处理
  4. 处理方式有三种
    • 默认动作(SIG_DFL) --- 执行系统默认处理
    • 忽略(SIG_IGN) --- 忽略该信号
    • 自定义捕捉 --- 用户注册信号处理函数,信号到来时回调执行

2. 信号的基本概念

查看信号

每个信号都有一个编号 和一个宏定义名称 ,这些宏定义可以在 <signal.h> 中找到:

c 复制代码
#define SIGINT  2   // 终端中断信号
#define SIGQUIT 3   // 终端退出信号
#define SIGKILL 9   // 杀死进程信号
#define SIGSEGV 11  // 段错误信号
#define SIGALRM 14  // 闹钟信号
#define SIGTERM 15  // 终止信号
// ... 等等

编号 34 以上的是实时信号,常规信号(1~31)在递达之前产生多次只计一次,实时信号可以排队。

查看所有信号的详细信息:man 7 signal

信号处理动作的三种方式

c 复制代码
// 源码中的定义
#define SIG_DFL ((__sighandler_t) 0)   /* 默认处理动作 */
#define SIG_IGN ((__sighandler_t) 1)   /* 忽略信号 */
/* 信号处理函数类型 */
typedef void (*__sighandler_t)(int);

实际上 SIG_DFLSIG_IGN 就是把 0 和 1 强制转换为函数指针类型。


3. signal 函数 --- 注册信号处理动作

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

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

参数说明

参数 说明
signum 信号编号(如 SIGINT=2)
handler 函数指针,表示更改信号的处理动作。收到对应信号时,回调执行 handler 方法

返回值

返回之前的信号处理函数指针,出错返回 SIG_ERR

重要注意

signal 函数仅仅是设置了特定信号的捕捉行为处理方式 ,并不是直接调用处理动作。如果后续特定信号没有产生,设置的捕捉函数永远也不会被调用


4. 示例代码

4.1 基础示例 --- 捕捉 SIGINT 信号

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

// 自定义信号处理函数
void handler(int signumber)
{
    // signumber 参数为收到的信号编号
    std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber << std::endl;
}

int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;

    // 注册 SIGINT (2号信号) 的自定义处理函数
    signal(SIGINT, handler);

    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

运行结果 :按下 Ctrl+C 后,进程不再退出,而是打印信号编号后继续运行。

4.2 忽略信号

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

int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;

    // SIG_IGN 表示忽略该信号
    signal(SIGINT, SIG_IGN);

    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

运行结果 :按下 Ctrl+C 没有任何反应,信号被忽略。

4.3 恢复默认处理

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

int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;

    // SIG_DFL 表示恢复默认处理动作(终止进程)
    signal(SIGINT, SIG_DFL);

    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

运行结果 :按下 Ctrl+C 后进程终止,即默认动作。


5. 关于前台进程与后台进程

  • Ctrl+C 产生的信号只能发给前台进程
  • 一个命令后面加 & 可以放到后台运行
  • Shell 可以同时运行一个前台进程和任意多个后台进程
  • 只有前台进程 才能接到像 Ctrl+C 这种控制键产生的信号
  • 可以通过 &nohup 命令控制进程的前后台运行

6. 核心要点总结

  1. 信号是软中断 --- 模拟硬件中断机制,但层级不同
  2. 异步通知 --- 信号相对于进程控制流程是异步的
  3. 三种处理方式 --- 默认、忽略、自定义捕捉
  4. signal 只注册不调用 --- 只是设置处理方式,信号没来时不会触发
  5. 前台进程才能接收终端信号 --- 务必注意前台/后台的区别

知识拓展:深入理解信号

1. SIGKILL 和 SIGSTOP 的特殊性

在所有 Linux 信号中,有两个"不可捕捉、不可阻塞、不可忽略"的特权信号:

  • SIGKILL (9号) :必须终止进程,是系统管理员杀"失控进程"的最后手段。kill -9 是最常用的强制终止命令。
  • SIGSTOP (19号):必须暂停进程,与 SIGTSTP(20) 不同------SIGTSTP 可以被捕捉(比如 Shell 的后台进程组可以通过捕捉 SIGTSTP 避免被暂停)。

只有这两个信号具有最高优先级的强制力,其余所有信号都可以被进程选择忽略或自定义处理。

2. signal 函数的可移植性问题

历史上 signal 在不同 UNIX 标准中有两种语义:

  • SysV 语义:信号处理函数执行完毕后,自动重置为 SIG_DFL,需再次调用 signal 重新注册,存在信号丢失风险
  • BSD 语义:处理函数注册后持续有效,不会自动重置

Linux 遵循 BSD 语义,但 POSIX 标准明确推荐使用 sigaction 替代 signal,因为 sigaction 在所有类 UNIX 系统上行为一致,且功能更强大。

3. 信号处理函数的异步安全限制

信号处理函数是异步执行的,可能在主程序的任意指令处被中断。因此信号处理函数中只能调用 Async-Signal-Safe 函数(如 write、_exit),不能调用 printf、malloc、free 等非可重入函数。这是很多信号处理 bug 的来源。


总结:什么是信号?

问题:请谈谈你对 Linux 信号的理解。

信号是操作系统提供的一种进程间异步通知机制,本质上是一种软中断------从纯软件角度模拟硬件中断的行为。与硬件中断的关键区别在于:硬件中断是发给 CPU 的,信号是发给进程的。

信号的核心特征包括:

  1. 异步性:信号相对于进程的控制流是完全异步的,进程无法预知信号何时到来
  2. 内置识别:进程识别信号的能力由内核提供,每个进程天生就知道如何处理各种信号(信号产生前处理方式就已确定)
  3. 延迟处理:信号不会立即被处理,而是在"合适的时候"------即进程从内核态返回用户态之前------统一检查并递达
  4. 三种处理方式:默认动作(SIG_DFL,如 Term/Core/Stop/Cont/Ign)、忽略(SIG_IGN)、自定义捕捉(通过 signal/sigaction 注册回调函数)

特殊限制:SIGKILL(9) 和 SIGSTOP(19) 不能被捕捉、阻塞或忽略,这是系统提供的最强控制手段。signal 函数仅仅是注册处理方式,信号没来时处理函数永远不会被调用。信号处理函数设计时必须考虑异步安全问题,避免调用非可重入函数。


二、信号的产生方式

概述

信号产生的五种方式:

  1. 通过终端按键产生信号
  2. 调用系统命令向进程发信号
  3. 使用函数产生信号(kill, raise, abort)
  4. 由软件条件产生信号(alarm, SIGPIPE)
  5. 由硬件异常产生信号(除零、野指针)

所有信号的产生,最终都要由操作系统来执行,因为操作系统是进程的管理者。


1. 通过终端按键产生信号

按键 信号 编号 默认动作 说明
Ctrl + C SIGINT 2 Term(终止进程) 中断信号
Ctrl + \ SIGQUIT 3 Core(终止+生成core文件) 退出信号,用于事后调试
Ctrl + Z SIGTSTP 20 Stop(停止进程) 将前台进程挂起到后台

1.1 Ctrl+\ --- SIGQUIT 示例

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

// 自定义信号处理函数
void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber << std::endl;
}

int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;

    // 捕捉 SIGQUIT(3号信号)
    signal(SIGQUIT, handler);

    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}
  • 捕捉时:按 Ctrl+\ 会打印信号编号,进程继续运行
  • 不捕捉时(注释掉 signal 行):按 Ctrl+\ 进程终止并提示 Quit (core dumped)

1.2 Ctrl+Z --- SIGTSTP 示例

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

void handler(int signumber)
{
    std::cout << "我是: " << getpid() << ", 我获得了一个信号: " << signumber << std::endl;
}

int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;

    // 捕捉 SIGTSTP(20号信号,暂停信号)
    signal(SIGTSTP, handler);

    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}
  • 捕捉时:按 Ctrl+Z 打印信号编号
  • 不捕捉时:按 Ctrl+Z 进程被挂起(Stopped),可通过 fg 命令恢复前台运行

1.3 OS如何得知键盘有数据

当用户在键盘上按下按键时,键盘硬件产生硬件中断,操作系统获取到该输入后,将其解释为对应的信号,发送给目标前台进程。


2. 调用系统命令向进程发信号

kill 命令

bash 复制代码
# 语法
kill -<信号名/信号编号> <进程PID>

# 示例:向进程发送 SIGSEGV(段错误信号)
kill -SIGSEGV 213784
# 等价于
kill -11 213784

注意:通过 kill 发送 SIGSEGV 信号也能使正常进程产生段错误,段错误不一定只由非法内存访问产生。


3. 使用函数产生信号

3.1 kill 函数

c 复制代码
#include <sys/types.h>
#include <signal.h>

/*
 * 功能:向指定进程发送指定信号
 * 参数:
 *   pid  - 目标进程PID
 *   sig  - 要发送的信号编号
 * 返回值:
 *   成功返回0,失败返回-1并设置errno
 */
int kill(pid_t pid, int sig);
示例:实现自己的 mykill 命令
c 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>

/*
 * mykill 命令用法:./mykill -<信号编号> <进程PID>
 * 例如:./mykill -11 12345  # 向PID为12345的进程发送11号信号(SIGSEGV)
 */
int main(int argc, char *argv[])
{
    // 检查命令行参数个数是否正确
    if (argc != 3)
    {
        std::cerr << "Usage: " << argv[0] << " -signumber pid" << std::endl;
        return 1;
    }

    // 解析信号编号(去掉 "-" 前缀)
    int number = std::stoi(argv[1] + 1);
    // 解析目标进程 PID
    pid_t pid = std::stoi(argv[2]);

    // 向指定进程发送信号
    int n = kill(pid, number);
    return n;
}

3.2 raise 函数

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

/*
 * 功能:给当前进程发送指定的信号(自己给自己发信号)
 * 参数:sig - 要发送的信号编号
 * 返回值:成功返回0,失败返回非零
 */
int raise(int sig);
示例:每隔1秒自己给自己发信号
c 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

// 信号处理函数
void handler(int signumber)
{
    // 整个代码只有这一处打印
    std::cout << "获取了一个信号: " << signumber << std::endl;
}

int main()
{
    // 先对2号信号(SIGINT)进行捕捉
    signal(2, handler);

    // 每隔1秒,自己给自己发送2号信号
    while (true)
    {
        sleep(1);
        raise(2);
    }
}

3.3 abort 函数

c 复制代码
#include <stdlib.h>

/*
 * 功能:使当前进程接收到 SIGABRT 信号而异常终止
 * 说明:和 exit 一样,abort 总是会成功,没有返回值
 */
void abort(void);
示例
c 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>

void handler(int signumber)
{
    std::cout << "获取了一个信号: " << signumber << std::endl;
}

int main()
{
    // 捕捉 SIGABRT(6号信号)
    signal(SIGABRT, handler);

    while (true)
    {
        sleep(1);
        abort();  // 发送 SIGABRT 信号
    }
}

重要特性 :即使捕捉了 SIGABRT 信号,abort 函数仍然会终止进程(打印 Aborted)。这是因为 abort 的机制是在信号处理后仍然终止进程。


4. 由软件条件产生信号

4.1 alarm 函数

c 复制代码
#include <unistd.h>

/*
 * 功能:设定一个闹钟,告诉内核在 seconds 秒之后给当前进程发送 SIGALRM 信号
 * 参数:seconds - 定时秒数(0 表示取消以前的闹钟)
 * 返回值:以前设定的闹钟时间还余下的秒数,如果没有则返回0
 */
unsigned int alarm(unsigned int seconds);
  • SIGALRM 信号的默认处理动作是终止当前进程
  • alarm 是一次性的:闹钟响一次后自动取消
示例1:测试 IO 效率问题
c 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

/*
 * 带 IO 操作的情况:1秒内计数
 * 因为 cout 有大量 IO 操作,CPU 被 IO 拖慢
 */
int main()
{
    int count = 0;
    alarm(1);  // 1秒后发送 SIGALRM

    while (true)
    {
        // IO 操作非常耗时,会拖慢计数速度
        std::cout << "count : " << count << std::endl;
        count++;
    }
    return 0;
}
示例2:去掉 IO 操作,CPU 纯计算
c 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>

int count = 0;

void handler(int signumber)
{
    // 到达1秒时打印最终的计数值(可观的数量级差异)
    std::cout << "count : " << count << std::endl;
    exit(0);
}

int main()
{
    // 捕捉 SIGALRM 信号
    signal(SIGALRM, handler);
    alarm(1);  // 1秒后触发 SIGALRM

    while (true)
    {
        count++;  // 纯 CPU 运算,速度极快
    }
    return 0;
}

对比结论

  • 带 IO 的版本:1秒计数约10万级别
  • 纯 CPU 的版本:1秒计数可达数亿级别
  • 可见 IO 操作极大地降低了程序效率
示例3:设置重复闹钟(模拟硬件定时中断)
c 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#include <functional>

// 函数包装器类型,用于模拟硬件中断处理
using func_t = std::function<void()>;

int gcount = 0;               // 全局计数器
std::vector<func_t> gfuncs;   // 存储需要周期性执行的任务

// 信号处理函数:相当于时钟中断处理程序
void hanlder(int signo)
{
    // 执行所有注册的任务
    for (auto& f : gfuncs)
    {
        f();
    }
    std::cout << "gcount : " << gcount << std::endl;

    // 重设闹钟,实现周期性触发
    int n = alarm(1);
    std::cout << "剩余时间 : " << n << std::endl;
}

int main()
{
    // 可以注册各种周期性任务,类似于内核的中断处理
    // gfuncs.push_back([](){ std::cout << "我是一个内核刷新操作" << std::endl; });
    // gfuncs.push_back([](){ std::cout << "我是一个检测进程时间片的操作" << std::endl; });
    // gfuncs.push_back([](){ std::cout << "我是一个内存管理操作" << std::endl; });

    alarm(1);                     // 启动1秒闹钟
    signal(SIGALRM, hanlder);     // 注册信号处理函数

    while (true)
    {
        pause();  // 等待信号到来
        std::cout << "我醒来了..." << std::endl;
        gcount++;
    }
}
c 复制代码
#include <unistd.h>

/*
 * 功能:等待信号,使调用进程挂起直到收到信号
 * 返回值:仅当收到信号且信号处理函数返回后,pause 返回 -1,errno 设为 EINTR
 */
int pause(void);

4.2 SIGPIPE 信号

由软件条件产生的信号,在"管道"中介绍过:向一个已关闭读端的管道写入数据时产生。

4.3 如何理解软件条件

操作系统内部存在很多软件状态和条件,当这些条件满足时,OS 会向进程发送相应信号。例如:

  • 定时器超时 --- alarm 设定的时间到达 → SIGALRM
  • 管道异常 --- 向已关闭的管道写数据 → SIGPIPE

4.4 系统闹钟的本质

系统闹钟的本质是操作系统必须自身具备定时功能。内核中的定时器数据结构:

c 复制代码
// Linux 内核中的定时器结构
struct timer_list {
    struct list_head entry;         // 链表节点,用于组织定时器
    unsigned long expires;          // 超时时间(jiffies)
    void (*function)(unsigned long); // 超时处理函数
    unsigned long data;             // 传递给处理函数的参数
    struct tvec_t_base_s *base;     // 所属的定时器基础结构
};

操作系统管理定时器通常采用时间轮的方式组织,可简单理解为"堆结构"来管理所有定时器。


5. 由硬件异常产生信号

硬件异常被硬件检测到并通知内核,内核向当前进程发送适当的信号。

硬件异常 信号 编号 名称
除0(CPU运算单元检测) SIGFPE 8 浮点异常
非法内存访问(MMU检测) SIGSEGV 11 段错误

5.1 模拟除0

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

void handler(int sig)
{
    printf("catch a sig : %d\n", sig);
}

int main()
{
    // 不捕捉时:默认动作产生 Core Dump,进程终止
    // signal(SIGFPE, handler);  // 8) SIGFPE

    sleep(1);
    int a = 10;
    a /= 0;                 // 除零操作,CPU运算单元产生异常
    while (1);
    return 0;
}

5.2 模拟野指针(非法内存访问)

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

void handler(int sig)
{
    printf("catch a sig : %d\n", sig);
}

int main()
{
    // 捕捉 SIGSEGV 信号后,进程不会退出,而是不断收到信号
    // signal(SIGSEGV, handler);

    sleep(1);
    int* p = NULL;
    *p = 100;   // 对空指针解引用,MMU检测到非法访问
    while (1);
    return 0;
}

5.3 为什么捕捉异常信号后会一直收到信号

当发生除零或野指针等异常时:

  1. CPU 的控制和状态寄存器会记下异常状态(即位图中的状态标记位)
  2. 如果捕捉了信号但没有清理异常状态(如没有关闭文件、切换进程等)
  3. CPU 中保留的上下文数据和寄存器内容仍标记着异常状态
  4. 因此每次检查都会发现异常未处理,从而不断发出信号

6. Core Dump(核心转储)

6.1 什么是 Core Dump

当一个进程异常终止 时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是 core,这叫做 Core Dump

  • 用于事后调试(Post-mortem Debug) --- 通过调试器检查 core 文件以查清错误原因
  • core 文件中可能包含用户密码等敏感信息,默认不允许产生

6.2 查看与设置 Core Dump

bash 复制代码
# 查看当前 core 文件限制
ulimit -a

# 允许 core 文件最大为1024KB
ulimit -c 1024

# 查看结果
core file size          (blocks, -c) 1024

6.3 子进程退出与 Core Dump

c 复制代码
#include <iostream>
#include <string>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>

int main()
{
    if (fork() == 0)  // 子进程
    {
        sleep(1);
        int a = 10;
        a /= 0;       // 除零异常,子进程异常终止
        exit(0);
    }

    // 父进程等待子进程退出
    int status = 0;
    waitpid(-1, &status, 0);

    // 解析退出状态
    // status 低7位:终止信号编号
    // status 第8位:是否为 core dump
    printf("exit signal: %d, core dump: %d\n",
           status & 0x7F,           // 终止该进程的信号编号
           (status >> 7) & 1);      // 是否产生了 core dump
    return 0;
}

6.4 信号的默认处理动作分类

通过 man 7 signal 查看信号的默认动作:

动作 说明
Term 终止进程
Core 终止进程并产生 Core Dump
Ign 忽略该信号
Stop 停止进程(挂起)
Cont 如果进程已停止则继续运行

常见信号动作示例:

  • SIGINT → Term
  • SIGQUIT → Core
  • SIGKILL → Term
  • SIGSEGV → Core
  • SIGPIPE → Term
  • SIGCHLD → Ign

7. 核心要点总结

  1. 信号产生五途径:终端按键、系统命令、库函数、软件条件、硬件异常
  2. 信号本质是软件中断,由 OS 统一管理和分发
  3. alarm 是一次性的,要实现重复闹钟需在信号处理函数中重设
  4. 硬件异常(除零、野指针)在系统层面被当作信号处理
  5. Core Dump 用于事后调试,默认关闭,需 ulimit -c 开启
  6. 信号不立即处理,而是在"合适的时候"(从内核返回用户态前)处理

知识拓展:深入理解信号产生

1. 标准信号 vs 实时信号

Linux 信号分为两大类:

  • 标准信号 (1~31):不可靠信号,pending 位图只有 0/1 两种状态,递达前多次产生只计一次,不排队
  • 实时信号 (34~64, SIGRTMIN~SIGRTMAX) :可靠信号,支持排队(由 struct list_head list 管理),保证递达顺序

信号 32(SIGCANCEL)、33(SIGSETXID) 被 glibc 内部使用,不暴露给用户。

2. 硬件异常信号的"死循环"问题

捕捉 SIGFPE 或 SIGSEGV 后,如果信号处理函数中没有修复异常原因(修正除零、建立合法内存映射等),返回后 CPU 会再次检测到同样的异常,导致信号被不断发送。这就是为什么代码中捕捉 SIGFPE 后会持续收到信号------CPU 的状态寄存器标记未被清除。

3. Core Dump 的实战用法
bash 复制代码
ulimit -c unlimited          # 开启 core dump,不限制大小
./a.out                      # 运行程序,崩溃后产生 core 文件
gdb ./a.out core             # GDB 加载 core 文件调试
(gdb) bt                     # 查看崩溃时的完整调用栈
(gdb) info locals            # 查看局部变量
(gdb) frame 3                # 切换到对应栈帧

Core Dump 是定位程序崩溃原因的利器,默认关闭的原因是 core 文件可能包含用户密码等敏感信息。

4. alarm 函数的使用细节
  • alarm(0) 取消之前设置的闹钟,返回剩余秒数
  • 一个进程同时只能有一个闹钟,新 alarm 会覆盖旧 alarm
  • alarm 默认动作是终止进程(需捕捉 SIGALRM 才能自定义处理)
  • 返回值是上一次闹钟剩余的秒数,如果没有则为 0

总结:信号是如何产生的?

问题:请列举 Linux 中信号有哪几种产生方式?背后原理是什么?

Linux 中信号有五种产生方式,所有信号的产生最终都由操作系统统一执行:

  1. 终端按键:用户按 Ctrl+C(→SIGINT)、Ctrl+\(→SIGQUIT)、Ctrl+Z(→SIGTSTP)。按下按键时键盘产生硬件中断 → OS 捕获中断 → 解释为信号 → 发送给前台进程组。

  2. 系统命令kill -<信号> <PID> 命令通过 kill() 系统调用向任意进程发送信号。注意 kill 不仅可以发送终止信号,可以发送任意信号(如 kill -SIGSEGV 让正常进程段错误)。

  3. 库函数kill(pid, sig) 向指定进程发信号;raise(sig) 给自己发信号;abort() 发送 SIGABRT 并强制终止------即使捕捉了 SIGABRT,abort 仍会终止进程。

  4. 软件条件alarm(seconds) 设定定时器,到期发送 SIGALRM(默认终止进程);向已关闭读端的管道写数据触发 SIGPIPE;资源限制超时也以信号方式通知。

  5. 硬件异常:除零(CPU 运算单元检测)→ SIGFPE;非法内存访问(MMU 检测)→ SIGSEGV。硬件异常被 CPU 检测后通知内核,内核转发为信号给进程。

信号最终以位图形式记录在进程 PCB(task_struct)的 sigpending 结构中,在适当时机递达。


三、信号的保存与阻塞

1. 信号相关概念

概念 英文 说明
信号递达 Delivery 实际执行信号的处理动作
信号未决 Pending 信号从产生到递达之间的状态
信号阻塞 Block 进程选择阻塞某个信号,被阻塞的信号产生后将保持在未决状态
信号屏蔽字 Signal Mask 即阻塞信号集

重要区分:阻塞 vs 忽略

  • 阻塞:信号被阻塞就不会递达(根本不会去处理)
  • 忽略:是信号递达之后的一种可选处理动作

被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。


2. 信号在内核中的表示

信号在内核中的表示示意图:

2.1 内核数据结构

在 Linux 内核中,信号相关的 PCB 结构如下:

c 复制代码
// 进程控制块(PCB),定义在 include/linux/sched.h
struct task_struct {
    // ...
    /* 信号处理函数表 */
    struct sighand_struct *sighand;
    /* 阻塞信号集(信号屏蔽字) */
    sigset_t blocked;
    /* 未决信号集 */
    struct sigpending pending;
    // ...
};

// 信号处理函数描述结构
struct sighand_struct {
    atomic_t count;                          // 引用计数
    struct k_sigaction action[_NSIG];        // 信号处理动作数组,_NSIG=64
    spinlock_t siglock;                      // 自旋锁
};

// 新版信号处理动作结构
struct __new_sigaction {
    __sighandler_t sa_handler;   // 信号处理函数指针
    unsigned long   sa_flags;    // 标志位
    void (*sa_restorer)(void);   // 恢复函数(暂未使用)
    __new_sigset_t  sa_mask;     // 处理函数执行期间需要额外屏蔽的信号集
};

// 内核信号动作封装
struct k_sigaction {
    struct __new_sigaction sa;
    void __user *ka_restorer;
};

// 未决信号集结构
struct sigpending {
    struct list_head list;    // 信号链表(用于实时信号排队)
    sigset_t signal;          // 信号集位图(标记哪些信号处于未决状态)
};

/* 信号处理函数类型 */
typedef void (*__sighandler_t)(int);

2.2 信号在内核中的存储方式

每个信号在内核中对应两个标志位和一个函数指针:

复制代码
                        block(阻塞位图)    pending(未决位图)
信号 1 (SIGHUP)    →       0                   0
信号 2 (SIGINT)    →       1                   1      ← 被阻塞,暂时不能递达
信号 3 (SIGQUIT)   →       0                   0
...                         ...                 ...

关键规则

  1. 信号产生时,内核在 PCB 中设置该信号的 pending 标志(置1)
  2. 直到信号递达才清除 pending 标志
  3. 被阻塞的信号即使产生了 pending 标志,也不会递达
  4. 常规信号在递达之前产生多次只计一次(pending 只有 0/1 两种状态)
  5. 实时信号在递达之前可以依次放在队列中(由 struct list_head list 管理)

2.3 示例图解说明

假设当前进程的状态:

  • SIGHUP(1号信号):未阻塞、未产生、默认处理动作
  • SIGINT(2号信号):已阻塞、已产生处于未决状态、处理动作是忽略(但必须解除阻塞后才能忽略)
  • SIGQUIT(3号信号):未阻塞、未产生、自定义处理函数

注意:虽然 SIGINT 的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。


3. sigset_t --- 信号集类型

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

sigset_t 是一个不透明数据类型,对于每种信号用一个 bit 表示"有效"或"无效"状态。

  • 阻塞信号集中"有效"的含义:该信号被阻塞
  • 未决信号集中"有效"的含义:该信号处于未决状态

使用者只能调用标准函数来操作 sigset_t,不应该对它的内部数据做任何解释(比如用 printf 直接打印是没有意义的)。

信号集操作函数

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

/*
 * 清空信号集:将所有信号的对应 bit 清零
 * 返回值:成功0,失败-1
 */
int sigemptyset(sigset_t *set);

/*
 * 填满信号集:将所有信号的对应 bit 置位
 * 返回值:成功0,失败-1
 */
int sigfillset(sigset_t *set);

/*
 * 添加信号:将指定信号的对应 bit 置位
 * 返回值:成功0,失败-1
 */
int sigaddset(sigset_t *set, int signo);

/*
 * 删除信号:将指定信号的对应 bit 清零
 * 返回值:成功0,失败-1
 */
int sigdelset(sigset_t *set, int signo);

/*
 * 检查信号:判断指定信号是否在信号集中
 * 返回值:包含返回1,不包含返回0,出错返回-1
 */
int sigismember(const sigset_t *set, int signo);

注意 :在使用 sigset_t 类型的变量之前,一定要调用 sigemptysetsigfillset 做初始化,使信号集处于确定的状态。


4. sigprocmask --- 读取/更改信号屏蔽字

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

/*
 * 功能:读取或更改进程的信号屏蔽字(阻塞信号集)
 * 参数:
 *   how  - 如何修改屏蔽字
 *   set  - 新的屏蔽字(非空时表示要修改)
 *   oset - 输出旧的屏蔽字(非空时表示要读取)
 * 返回值:成功0,失败-1
 */
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

how 参数的三种取值

含义
SIG_BLOCK 0 将 set 中的信号添加到当前屏蔽字中(mask = mask | set)
SIG_UNBLOCK 1 从当前屏蔽字中移除 set 中的信号(mask = mask & ~set)
SIG_SETMASK 2 将当前屏蔽字设置为 set(mask = set)

重要特性

如果调用 sigprocmask 解除 了对当前若干个未决信号的阻塞,则在 sigprocmask 返回前,至少将其中一个信号递达


5. sigpending --- 读取未决信号集

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

/*
 * 功能:读取当前进程的未决信号集
 * 参数:set - 输出未决信号集
 * 返回值:成功0,失败-1
 */
int sigpending(sigset_t *set);

6. 综合示例:阻塞信号、查看未决信号集

c 复制代码
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>

/*
 * 打印当前进程的未决信号集(从31号到1号)
 * 每位数字表示对应信号是否处于未决状态
 */
void PrintPending(sigset_t &pending)
{
    std::cout << "curr process[" << getpid() << "]pending: ";

    // 从31到1遍历所有常规信号
    for (int signo = 31; signo >= 1; signo--)
    {
        // sigismember 判断该信号是否在未决信号集中
        if (sigismember(&pending, signo))
        {
            std::cout << 1;  // 该信号处于未决状态
        }
        else
        {
            std::cout << 0;  // 该信号未产生或已递达
        }
    }
    std::cout << "\n";
}

// 信号处理函数
void handler(int signo)
{
    std::cout << signo << " 号信号被递达!!!" << std::endl;
    std::cout << "-------------------------------" << std::endl;

    // 查看递达时的未决信号集(此时该信号应该已被清除)
    sigset_t pending;
    sigpending(&pending);
    PrintPending(pending);

    std::cout << "-------------------------------" << std::endl;
}

int main()
{
    // 0. 捕捉2号信号(SIGINT),使用自定义处理函数
    signal(2, handler);     // 自定义捕捉
    // signal(2, SIG_IGN);  // 忽略一个信号
    // signal(2, SIG_DFL);  // 信号的默认处理动作

    // 1. 屏蔽2号信号
    sigset_t block_set, old_set;
    sigemptyset(&block_set);   // 清空 block_set
    sigemptyset(&old_set);     // 清空 old_set
    sigaddset(&block_set, SIGINT);  // 将 SIGINT 添加到 block_set 中

    // 1.1 将 block_set 设置到进程的 Block 表中
    // SIG_BLOCK 表示将 block_set 中的信号添加到当前屏蔽字
    sigprocmask(SIG_BLOCK, &block_set, &old_set);
    // 此时2号信号已被屏蔽!

    int cnt = 15;
    while (true)
    {
        // 2. 获取当前进程的 pending 信号集
        sigset_t pending;
        sigpending(&pending);

        // 3. 打印 pending 信号集
        PrintPending(pending);

        cnt--;

        // 4. 计数到0时,解除对2号信号的屏蔽
        if (cnt == 0)
        {
            std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
            // SIG_SETMASK:直接用 old_set 替换当前屏蔽字
            sigprocmask(SIG_SETMASK, &old_set, &block_set);
        }

        sleep(1);
    }
}

运行结果分析

复制代码
curr process[448336]pending: 0000000000000000000000000000000
curr process[448336]pending: 0000000000000000000000000000000
^C curr process[448336]pending: 0000000000000000000000000000010   ← 按了Ctrl+C,2号信号pending置1
curr process[448336]pending: 0000000000000000000000000000010   ← 因为被阻塞,一直未决
curr process[448336]pending: 0000000000000000000000000000010   ← ...
curr process[448336]pending: 0000000000000000000000000000010
...                                                          ← 每1秒打印一次
curr process[448336]pending: 0000000000000000000000000000010
  • 程序运行时,每秒钟把各信号的未决状态打印一遍
  • 阻塞了 SIGINT(2号信号),按 Ctrl+C 会使 SIGINT 处于未决状态
  • Ctrl+\ (SIGQUIT)仍然可以终止程序,因为 SIGQUIT 未被阻塞
  • 解除屏蔽后,SIGINT 信号被递达(执行 handler 函数)

7. 核心要点总结

  1. 三个核心概念:递达(Delivery)、未决(Pending)、阻塞(Block)
  2. 阻塞 ≠ 忽略:阻塞阻止信号递达,忽略是递达后的处理方式
  3. 每个信号两个 bit:block 位 + pending 位,存放在 PCB 中
  4. 常规信号不排队:产生多次只计一次
  5. 操作信号集的步骤 :初始化(sigemptyset/sigfillset)→ 增删(sigaddset/sigdelset)→ 应用到进程(sigprocmask
  6. 解除阻塞时立即递达:解除对未决信号的阻塞后,至少一个信号会立即被递达

知识拓展:深入理解信号阻塞与未决

1. sigsuspend --- 原子地等待信号
c 复制代码
int sigsuspend(const sigset_t *sigmask);

sigsuspend 的功能:暂时用 sigmask 替换进程屏蔽字 → 挂起进程等待信号 → 信号处理函数返回后自动恢复原屏蔽字。

sigprocmask(SIG_SETMASK, &new, &old) + pause() 组合相比,sigsuspend 的原子性可以避免竞态条件------如果在 sigprocmask 解除阻塞后 pause() 之前信号到达,则 pause() 会永久阻塞。sigsuspend 将这两步合并为原子操作,杜绝了这个问题。

2. 信号阻塞的典型应用场景
  • 临界区保护:在操作关键全局数据结构时阻塞可能产生影响的信号,防止信号处理函数并发访问
  • 初始化阶段:子进程在 fork 后 exec 前阻塞所有信号,确保初始化过程不被中断
  • 父子进程同步:fork 前阻塞 SIGCHLD,完成子进程设置后再解除阻塞,避免信号处理逻辑混乱
3. 多线程中的信号处理
  • 每个线程有独立的信号屏蔽字(sigprocmask 仅影响调用线程)
  • 所有线程共享信号处理动作(signal/sigaction 对整个进程生效)
  • 未决信号是进程级的:信号产生时写入进程的 pending 位图
  • 信号递达时内核选择一个不阻塞该信号的线程来处理
4. sigset_t 操作的内核实现

内核中使用位图(bitmap)存储信号集,每个信号对应一个 bit。例如 sigset_t 在 64 位系统上占 8 字节(64 bits),足以覆盖 64 个信号。sigaddset 就是 OR 操作,sigdelset 是 AND NOT 操作。


总结:阻塞、未决与递达三者的关系

问题:请解释信号的阻塞(Block)、未决(Pending)、递达(Delivery)三个概念,以及阻塞与忽略的区别。

这是信号处理最核心的三个概念,理解它们的区别是理解整个信号机制的基础。

概念定义

  • 递达(Delivery):实际执行信号处理动作的时刻
  • 未决(Pending):信号从产生到递达之间的中间状态------信号已产生但尚未被处理
  • 阻塞(Block):进程主动设置信号屏蔽字,阻止指定信号被递达,被阻塞的信号一直保持在未决状态

阻塞 vs 忽略的本质区别

  • 阻塞是阻止信号递达的"过程控制"------信号根本不会被处理,保持在未决状态,直到解除阻塞
  • 忽略是递达之后的"结果处理"------信号仍然完成了递达,只不过递达时执行的动作是忽略(SIG_IGN)

内核实现(PCB 中的三个字段):

  1. block 位图(阻塞信号集):标记哪些信号被阻塞(1=阻塞)
  2. pending 位图(未决信号集):标记哪些信号已产生但未递达(1=待处理)
  3. handler 数组:每个信号对应的处理函数指针(SIG_DFL/SIG_IGN/用户自定义)

处理流程:信号产生 → 内核设置 pending 位 → 递达前检查 block 位 → block=1 保持 pending,block=0 执行 handler。常规信号在 pending 期间多次产生只计一次(位图只有 0/1),实时信号支持排队。

关键特性:解除对未决信号的阻塞时,至少有一个信号会在 sigprocmask 返回前立即被递达。


四、信号的捕捉与处理

1. 信号捕捉的完整流程

当信号的处理动作是用户自定义函数 时,在信号递达时调用这个函数,这称为捕捉信号(Catch)

信号处理函数的代码在用户空间,处理过程较为复杂:

复制代码
┌─────────────────────────────────────────┐
│ ① 用户程序 main 函数正在执行             │
│    (当前位于用户态)                     │
└──────────────┬──────────────────────────┘
               │ 发生中断/异常/系统调用
               ▼
┌─────────────────────────────────────────┐
│ ② 进程切换到内核态                       │
│    内核处理完中断/异常/系统调用后          │
│    准备返回用户态                         │
└──────────────┬──────────────────────────┘
               │ 检查 signal pending 位图
               ▼
┌─────────────────────────────────────────┐
│ ③ 发现有信号递达                         │
│    且 handler 不是 SIG_DFL/SIG_IGN      │
└──────────────┬──────────────────────────┘
               │ 不恢复 main 上下文
               ▼
┌─────────────────────────────────────────┐
│ ④ 返回用户态,执行 sighandler 函数       │
│    使用独立堆栈(非 main 函数栈)          │
└──────────────┬──────────────────────────┘
               │ sighandler 执行完毕
               ▼
┌─────────────────────────────────────────┐
│ ⑤ 自动调用 sigreturn 系统调用再次入内核   │
└──────────────┬──────────────────────────┘
               │ 检查是否还有信号待处理
               ▼
┌─────────────────────────────────────────┐
│ ⑥ 没有新信号,恢复 main 函数上下文继续执行 │
└─────────────────────────────────────────┘

关键理解点

  1. sighandlermain 函数使用不同的堆栈空间
  2. 它们之间不存在调用和被调用的关系,是两个独立的控制流程
  3. 回调机制:信号处理函数不是被 main 函数调用,而是被系统所调用

2. sigaction 函数

sigaction 是比 signal 更强大、更可靠的信号处理函数接口。

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

/*
 * 功能:读取和修改与指定信号相关联的处理动作
 * 参数:
 *   signo - 指定信号的编号
 *   act   - 非空时,根据 act 修改该信号的处理动作
 *   oact  - 非空时,通过 oact 传出该信号原来的处理动作
 * 返回值:成功0,出错-1
 */
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

sigaction 结构体

c 复制代码
struct sigaction {
    void     (*sa_handler)(int);      // 信号处理函数(或 SIG_DFL / SIG_IGN)
    void     (*sa_sigaction)(int, siginfo_t *, void *);  // 实时信号处理函数
    sigset_t   sa_mask;               // 处理函数执行期间额外屏蔽的信号集
    int        sa_flags;              // 标志位
    void     (*sa_restorer)(void);    // 恢复函数(已废弃,不应使用)
};

sa_handler 的三种赋值

赋值 含义
SIG_IGN 忽略信号
SIG_DFL 执行系统默认动作
函数指针 用自定义函数捕捉信号(回调函数)

sa_mask 的自动屏蔽特性

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

这保证了:在处理某个信号时,如果这种信号再次产生,它会被阻塞到当前处理结束为止

如果除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明。


3. sigaction 使用示例

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

void handler(int sig)
{
    std::cout << "捕获到信号: " << sig << std::endl;
}

int main()
{
    struct sigaction act, oact;

    // 清空 act 结构体
    memset(&act, 0, sizeof(act));

    // 设置信号处理函数
    act.sa_handler = handler;

    // 清空 sa_mask,表示处理 SIGINT 时只自动屏蔽 SIGINT 本身
    sigemptyset(&act.sa_mask);

    // 设置标志位(0 表示使用 sa_handler)
    act.sa_flags = 0;

    // 注册 SIGINT 的处理动作,并保存旧的动作到 oact
    sigaction(SIGINT, &act, &oact);

    while (true)
    {
        pause();
    }

    return 0;
}

常用的 sa_flags 选项

标志 说明
SA_NOCLDSTOP 子进程停止时不产生 SIGCHLD 信号
SA_NOCLDWAIT 子进程终止时自动清理,不产生僵尸进程
SA_SIGINFO 使用 sa_sigaction 而非 sa_handler
SA_RESTART 被信号中断的系统调用自动重启

4. 核心要点总结

  1. 信号捕捉的六步流程:用户态运行 → 中断/异常进内核 → 检查信号 → 返回用户态执行 sighandler → sigreturn 回内核 → 恢复 main 上下文
  2. sighandlermain 使用独立堆栈 ,是两个独立的控制流程
  3. sigaction 比 signal 更强大:支持额外屏蔽信号、多种标志位
  4. 自动屏蔽当前信号:处理信号期间,同种信号再次产生会被阻塞
  5. sigreturn 系统调用:信号处理函数返回后通过它重新进入内核

知识拓展:深入理解信号捕捉

1. 慢速系统调用与 EINTR 错误

慢速系统调用(如 read 从终端/管道/网络读取、pausewait)可能使进程永久阻塞。当进程阻塞其间收到信号时:

  • 系统调用被中断,返回 -1
  • errno 设为 EINTR
  • 调用 sigaction 时设置 SA_RESTART 标志,内核会自动重启被中断的系统调用,程序员无需手动处理 EINTR

典型场景:网络服务器中 read 被信号中断后返回 EINTR,未处理该错误会导致服务器异常关闭。SA_RESTART 可以透明地解决这个问题。

2. 异步信号安全函数列表

信号处理函数中可以安全调用的函数(Async-Signal-Safe):

  • writereadopenclose(不是 printf/fprintf!)
  • waitwaitpid
  • sigactionsigprocmasksigpendingsigsuspend
  • exit_exit_Exit
  • getpidgetuidgetgid
  • abortraise

禁止在信号处理函数中调用的函数:

  • printf / fprintf / sprintf(使用标准 I/O 全局缓冲区)
  • malloc / free(使用全局链表管理堆)
  • pthread_* 系列函数
  • 任何可能持有锁的函数(可能死锁)
3. SA_SIGINFO 标志与 siginfo_t

设置 sa_flags |= SA_SIGINFO 后使用 sa_sigaction 而非 sa_handler

c 复制代码
void handler(int sig, siginfo_t *info, void *context);

siginfo_t 包含详细信息:发送者 PID(si_pid)、发送者 UID(si_uid)、信号原因(si_code,如 SI_USER/SI_KERNEL/SI_QUEUE),甚至硬件异常地址(si_addr,对 SIGSEGV 可定位到具体哪个地址访问非法)。


总结:信号捕捉的完整流程

问题:当信号被捕捉时,从产生到处理完成经历哪些步骤?请详细描述。

信号捕捉(用户自定义处理函数)的完整流程分为六步:

  1. 用户态正常运行:main 函数的控制流程在用户态正常执行
  2. 进入内核态:发生中断/异常/系统调用,进程从用户态切换到内核态
  3. 检查信号:内核处理完毕后准备返回用户态前,检查进程的 pending 信号集,发现有信号需要递达且处理动作为用户自定义函数
  4. 执行 sighandler:内核不直接恢复 main 的上下文,而是先返回用户态,在独立堆栈空间中执行用户注册的信号处理函数
  5. sigreturn 回内核 :sighandler 执行完毕后自动调用 sigreturn 系统调用重新进入内核态
  6. 恢复 main 上下文:内核再次检查信号,若没有新信号则恢复 main 函数的上下文,继续执行

关键理解

  • sighandlermain 使用不同的堆栈空间 ,是两个独立的控制流程
  • 它们之间不存在调用与被调用关系------sighandler 是被内核回调的
  • sigactionsignal 更强大的原因:支持 SA_RESTART(自动重启中断的系统调用)、sa_mask(处理期间额外屏蔽其他信号)、SA_SIGINFO(获取信号的详细来源信息)
  • 自动屏蔽当前信号:处理信号期间内核自动将该信号加入屏蔽字,同种信号再次产生会被阻塞,处理完成后自动恢复

五、操作系统运行机制(中断、系统调用、内核态/用户态)

1. 概述

要深入理解信号,必须理解操作系统是如何运行的。操作系统的运行依赖中断机制,可以分为三类:

中断类型 触发方式 示例 类比
硬件中断 外部硬件设备触发 键盘输入、硬盘读写完成 快递员按门铃
时钟中断 定时器周期性触发 进程调度的时间片轮转 闹钟定时响起
软中断(陷阱) CPU 内部软件触发 int 0x80 系统调用 主动打电话给物业
异常 CPU 内部错误触发 除零、缺页、野指针 家里水管爆了

一句话总结:操作系统就是躺在中断处理例程上的代码块!


2. 硬件中断

外部设备触发中断时,CPU 暂停当前工作,执行对应的中断处理程序。

c 复制代码
// Linux 内核 0.11 --- 中断向量表初始化
void trap_init(void)
{
    int i;

    // 设置各种异常的中断向量处理函数
    set_trap_gate(0, &divide_error);          // 除零错误
    set_trap_gate(1, &debug);                 // 调试异常
    set_trap_gate(2, &nmi);                   // 非屏蔽中断
    set_system_gate(3, &int3);                // 断点指令
    set_system_gate(4, &overflow);            // 溢出
    set_system_gate(5, &bounds);              // 边界检查
    set_trap_gate(6, &invalid_op);            // 无效操作码
    set_trap_gate(7, &device_not_available);  // 设备不可用
    set_trap_gate(8, &double_fault);          // 双重错误
    set_trap_gate(9, &coprocessor_segment_overrun);
    set_trap_gate(10, &invalid_TSS);
    set_trap_gate(11, &segment_not_present);
    set_trap_gate(12, &stack_segment);
    set_trap_gate(13, &general_protection);    // 通用保护
    set_trap_gate(14, &page_fault);            // 缺页异常
    set_trap_gate(15, &reserved);
    set_trap_gate(16, &coprocessor_error);

    // int 17~48 暂时设为 reserved
    for (i = 17; i < 48; i++)
        set_trap_gate(i, &reserved);

    set_trap_gate(45, &irq13);                // 协处理器
    set_trap_gate(39, &parallel_interrupt);   // 并行口
}

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

通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询。


3. 时钟中断 --- 操作系统的"心跳"

问题:操作系统自己被谁推动执行?

答案:时钟中断 --- 硬件定时器(如 Intel 8253/8254 PIT)会每隔固定时间产生一次中断,这个中断会触发操作系统的调度程序。

c 复制代码
// Linux 内核 0.11 --- main.c
void main(void)
{
    // ...
    sched_init();   // 调度程序初始化
    // ...
}

// 调度程序的初始化
void sched_init(void)
{
    // ...
    // 设置时钟中断门(IRQ0,中断向量0x20)
    set_intr_gate(0x20, &timer_interrupt);

    // 修改中断控制器屏蔽码,允许时钟中断
    outb(inb_p(0x21) & ~0x01, 0x21);

    // 设置系统调用中断门
    set_system_gate(0x80, &system_call);
    // ...
}

时钟中断的处理流程

assembly 复制代码
; system_call.s 中的时钟中断处理
_timer_interrupt:
        ; ... 保存寄存器状态
        call _do_timer      ; 调用 do_timer 函数
        ; ... 恢复寄存器
c 复制代码
// 时钟中断处理函数
void do_timer(long cpl)
{
    // ...
    schedule();     // 进程调度入口
}

// 进程调度函数
void schedule(void)
{
    // ...
    switch_to(next);  // 切换到下一个任务运行
}

核心结论

  • 操作系统在硬件时钟的推动下自动调度
  • 时钟中断是操作系统的"心跳"
  • 时间片就是在这个机制下实现的------每个进程运行一段时间后,时钟中断触发,切换到下一个进程

4. 操作系统的本质 --- 死循环

c 复制代码
// Linux 内核 0.11 --- main.c
void main(void)
{
    // ... 各种初始化操作 ...

    /*
     * 注意!! 对于任何其它的任务,'pause()'意味着我们必须等待收到一个信号才会返
     * 回就绪运行态。任务0 在任何空闲时间里都会被激活,当没有其它任务在运行时,
     * 我们回到这里,一直循环执行'pause()'。
     */
    for (;;)
        pause();    // 永远循环等待中断
}
// end main

操作系统的本质:一个死循环 + 中断处理

  • 操作系统自己不做任何事情
  • 需要什么功能,就向中断向量表中添加方法即可
  • 平时处于 pause() 睡眠状态,中断到来时才被唤醒执行对应的处理

与 CPU 主频的关系

  • CPU 主频决定了时钟中断的频率
  • 主频越快,单位时间内时钟中断次数越多
  • OS 在每个时钟中断中有机会检查和调度进程
  • 所以 CPU 主频是 OS 调度执行速度的参考之一

5. 软中断(陷阱)--- 系统调用的实现

5.1 什么是软中断

不是由外部硬件触发,而是由软件(CPU 内部)主动触发的中断逻辑。

CPU 为此设计了专门的汇编指令:

  • int 0x80(x86 传统方式)
  • syscall(x86-64 现代方式)

5.2 系统调用的完整过程

复制代码
用户程序调用库函数(如 fopen)
        │
        ▼
glibc 库函数封装
        │
        ▼
      __open()
        │
        ▼
      INLINE_SYSCALL(open, 3, ...)
        │
        ▼
      执行 syscall 或 int 0x80 指令(软中断)
        │
        ▼
CPU 触发软中断 → 进入内核态
        │
        ▼
根据系统调用号在 sys_call_table 中查表
        │
        ▼
执行对应的内核函数(如 sys_open)
        │
        ▼
返回结果给用户态

5.3 系统调用号与跳转表

c 复制代码
// include/linux/sys.h --- 系统调用函数指针表
// 用于系统调用中断处理程序(int 0x80),作为跳转表
extern int sys_setup();
extern int sys_exit();
extern int sys_fork();
extern int sys_read();
extern int sys_write();
extern int sys_open();
extern int sys_close();
// ... (约70个系统调用)

// 系统调用函数指针数组
fn_ptr sys_call_table[] = {
    sys_setup, sys_exit, sys_fork, sys_read,
    sys_write, sys_open, sys_close, sys_waitpid,
    sys_creat, sys_link, sys_unlink, sys_execve,
    // ... 更多系统调用
};

系统调用号的本质就是数组下标!

assembly 复制代码
; 系统调用入口汇编代码 _system_call
_system_call:
        ; 检查调用号是否超出范围
        cmp eax, nr_system_calls - 1
        ja bad_sys_call

        ; 保存原段寄存器值
        push ds
        push es
        push fs

        ; ebx, ecx, edx 中存放系统调用的参数
        push edx
        push ecx
        push ebx

        ; 设置内核数据段
        mov edx, 10h
        mov ds, dx
        mov es, dx

        ; 根据系统调用号调用对应的处理函数
        ; sys_call_table + eax * 4 = 对应处理函数的地址
        call [_sys_call_table + eax * 4]

        ; ... 检查进程状态,必要时重新调度 ...

5.4 系统调用编号定义

c 复制代码
// linux-2.6.18/include/asm-x86_64/unistd.h
/* 至少每个 cache line 8 个系统调用 */
#define __NR_read               0
__SYSCALL(__NR_read, sys_read)
#define __NR_write              1
__SYSCALL(__NR_write, sys_write)
#define __NR_open               2
__SYSCALL(__NR_open, sys_open)
#define __NR_close              3
__SYSCALL(__NR_close, sys_close)
// ...
#define __NR_rt_sigaction      13
__SYSCALL(__NR_rt_sigaction, sys_rt_sigaction)
#define __NR_rt_sigprocmask    14
__SYSCALL(__NR_rt_sigprocmask, sys_rt_sigprocmask)
// ...

5.5 glibc 如何封装系统调用

用户程序从不需要直接写 int 0x80syscall,因为 glibc 将几乎所有系统调用都封装好了

c 复制代码
// glibc 中系统调用的底层封装
#define INTERNAL_SYSCALL_NCS(name, err, nr, args...) \
({                                                    \
    unsigned long int resultvar;                      \
    LOAD_ARGS_##nr(args)                               \
    LOAD_REGS_##nr                                     \
    asm volatile (                                     \
        "syscall\n\t"                                  \
        : "=a" (resultvar)                             \
        : "0" (name) ASM_ARGS_##nr                     \
        : "memory", "cc", "r11", "cx");                \
    (long int) resultvar;                              \
})

系统调用号由 Linux 内核提供(__NR_xxx),不是 glibc 定义的。glibc 通过 SYS_ify(syscall_name) 宏将系统调用名称转换为对应的系统调用号。


6. 缺页中断与异常处理

c 复制代码
void trap_init(void)
{
    // ...
    set_trap_gate(14, &page_fault);    // 缺页异常处理
    // ...
}

操作系统中的各种问题最终都会被转换成 CPU 内部的软中断/异常,走中断处理例程:

  • 缺页中断 → CPU 触发 page fault → 内核申请内存、填充页表、建立映射
  • 内存碎片处理 → 内核在适当时机进行内存整理
  • 除零/野指针 → CPU 触发异常 → 内核向进程发送 SIGFPE/SIGSEGV 信号

当程序出现除零或者野指针错误时,虽然我们平时在 C/C++ 层面看到的是"异常"或"信号",但系统底层的实现机制就是 CPU 的异常处理机制。

CPU 内部的软中断(如 int 0x80syscall)叫做陷阱(Trap)

CPU 内部的软中断(如除零、野指针等)叫做异常(Exception)

现在可以理解为什么叫"缺页异常"了------它本质上就是一种 CPU 内部异常。


7. 内核态与用户态

7.1 特权级别

CPU 指令集有权限分级,以 Intel CPU 为例,划分为 4 个级别(Ring 0 ~ Ring 3):

级别 名称 权限 说明
Ring 0 内核态 最高 可使用所有 CPU 指令集,直接操作硬件
Ring 1 --- 较高 一般不使用
Ring 2 --- 较低 一般不使用
Ring 3 用户态 最低 仅能使用常规指令,不能直接操作硬件

Linux 系统仅使用 Ring 0(内核态)和 Ring 3(用户态)。

CPU 中有一个标志字段(CPL --- Current Privilege Level)标志着线程的当前特权级别:

  • CPL = 0:内核态
  • CPL = 3:用户态

7.2 内存空间划分

以 32 位 Linux 为例,每个进程的 4GB 虚拟地址空间划分为:

复制代码
0xFFFFFFFF  ┌─────────────────────┐
            │                     │
            │   内核空间 (1GB)     │  ← 所有进程共享,仅内核态可访问
            │  0xC0000000~        │
0xC0000000  ├─────────────────────┤
            │                     │
            │   用户空间 (3GB)     │  ← 每个进程独立,用户态可访问
            │  0x00000000~        │
0x00000000  └─────────────────────┘
状态 可访问地址范围 说明
用户态 0x00000000 ~ 0xBFFFFFFF(低 3GB) 每个进程独有
内核态 0x00000000 ~ 0xFFFFFFFF(全部 4GB) 0xC0000000 以上只能内核态访问

7.3 三种切换到内核态的场景

1. 系统调用(主动切换)

用户态进程主动通过系统调用向 OS 申请资源:

  • 执行 int 0x80syscall 软中断指令
  • 这是用户程序主动进入内核的唯一方式
2. 异常(被动切换)

CPU 在执行用户态进程时发生未预知的错误:

  • 缺页异常 → 内核分配物理内存
  • 除零异常 → 内核发 SIGFPE 信号
  • 段错误 → 内核发 SIGSEGV 信号
3. 中断(被动切换)

外围设备完成操作后向 CPU 发出中断信号:

  • 硬盘读写完成
  • 网络数据到达
  • 键盘输入

7.4 切换时 CPU 的工作

  1. 提权:从 Ring 3 切换到 Ring 0,CPU 指令集权限提升
  2. 切换栈 :从用户栈切换到内核栈
    • CPU 从 TSS(任务状态段)中取出 SS0 和 ESP0
    • 将这些值放入 ss 和 esp 寄存器
  3. 保存现场:保存用户态的寄存器状态到内核栈
  4. 执行内核代码:在内核栈上执行对应的内核方法
  5. 恢复现场:内核方法执行完毕后,降权回 Ring 3,恢复用户态上下文

用户态切换到内核态时涉及现场保存与恢复,还要进行安全检查,是比较耗费资源的。

7.5 追踪 fopen 到系统调用的完整路径

复制代码
fopen()                                ← 用户程序调用
    ↓
__fopen_internal()                     ← glibc 内部
    ↓
_IO_new_file_fopen()                   ← glibc 文件操作
    ↓
_IO_file_open()                        ← glibc 文件操作
    ↓
open(Name, Flags, Prot)                ← 宏展开为 __open
    ↓
__open()                               ← glibc 封装
    ↓
INLINE_SYSCALL(open, 3, file, oflag, mode)  ← 内联系统调用
    ↓
INTERNAL_SYSCALL_NCS(__NR_open, ...)   ← 使用系统调用号
    ↓
syscall                                ← CPU 执行 syscall 指令
    ↓                                   (软中断,进入内核态)
sys_open()                             ← 内核中的实际实现

8. 核心要点总结

  1. 操作系统的本质:一个死循环,在硬件时钟的推动下运行
  2. 中断是操作系统的驱动力:硬件中断、时钟中断、软中断、异常
  3. 系统调用通过软中断实现int 0x80 / syscall → 查表跳转 → 内核函数
  4. 系统调用号 = 数组下标sys_call_table[系统调用号]
  5. glibc 封装了系统调用:用户程序不直接接触软中断指令
  6. 内核态 vs 用户态:Ring 0 vs Ring 3,本质是指令集权限的区分
  7. 三种进入内核态的方式:系统调用(主动)、异常(被动)、中断(被动)
  8. 用户态/内核态切换代价大:涉及提权、栈切换、现场保存与恢复

知识拓展:深入理解操作系统运行机制

1. x86-64 的 syscall/sysret 指令

在 x86-64 架构中,syscall 取代了传统 int 0x80,性能更高:

  • syscall 通过 MSR(Model-Specific Register)存储内核入口地址,比查中断向量表更快
  • 系统调用号通过 RAX 传递,参数通过 RDI、RSI、RDX、R10、R8、R9 传递(6 个参数)
  • syscall 自动将 RIP 切换到内核入口,RSP 切换到内核栈
  • sysret 返回用户态,零 Rings 切换的 CPL 恢复
2. 上下文切换的完整代价

用户态 ↔ 内核态切换的成本构成:

  1. 模式切换:CPL 从 3 切换为 0(提权),涉及 CS 段寄存器更新
  2. 栈切换:CPU 从 TSS 中读取 SS0/ESP0,切换到内核栈
  3. 现场保存:通用寄存器、段寄存器、标志寄存器压入内核栈
  4. 安全检查:内核验证用户态传入的指针(必须在用户空间地址范围)
  5. TLB/缓存影响:切换可能影响 TLB 和 L1 缓存命中率
  6. 返回恢复:恢复所有寄存器、切换回用户栈、降权

这也是为什么频繁系统调用会显著降低性能------每次调用都有数百纳秒到微秒级的开销。

3. 中断上下文 vs 进程上下文
  • 中断上下文:运行硬件中断处理程序(ISR),不可睡眠,不可被调度,不能访问用户空间
  • 进程上下文:运行系统调用,可睡眠,可被调度,可访问用户空间

这就是为什么 printk(内核打印)可以在任何上下文使用,而 copy_from_user 必须在进程上下文使用的原因。

4. Linux 0.11 与现代内核的对比

虽然学习 Linux 0.11 有助于理解核心原理,但现代内核(5.x/6.x)有显著变化:

  • 使用 C 语言重写了大部分汇编代码
  • 支持 SMP 多核,使用 RCU 锁等复杂并发机制
  • sys_call_table 被更复杂的 dispatch 机制取代
  • 进程调度从 O(n) 变为 O(1),再变为 CFS(完全公平调度)

总结:操作系统的运行机制

问题:请描述操作系统是如何运行的?用户态和内核态之间如何切换?

操作系统的本质可以用一句话概括:"一个死循环 + 中断处理" 。内核初始化完成后进入无限循环,平时处于 pause() 等待状态,只有中断到来时才被唤醒执行对应的处理函数。

操作系统的运行完全依赖中断机制,分为四类:

  1. 硬件中断:外部设备(键盘、硬盘)触发,CPU 被通知,是"由外向内"的被动响应
  2. 时钟中断:硬件定时器周期性触发,是 OS 的"心跳",推动进程调度和时间片轮转
  3. 软中断/陷阱(Trap) :CPU 执行 int 0x80/syscall 指令主动触发,用于系统调用,是"由内自发"的主动请求
  4. 异常(Exception):CPU 执行指令时检测到错误(除零、缺页),被动触发

用户态到内核态的切换(完整流程)

  1. 提权:CPL(Current Privilege Level)从 Ring 3 切换为 Ring 0,指令集权限提升
  2. 栈切换:CPU 从 TSS 中读取 SS0 和 ESP0 值,切换到内核栈
  3. 保存现场:用户态的 CS、EIP、EFLAGS 等寄存器压入内核栈
  4. 执行内核代码 :查系统调用表(sys_call_table)或中断向量表,执行对应函数
  5. 恢复与返回:恢复保存的寄存器,降权回 Ring 3,返回用户态继续执行

三种进入内核态的场景:系统调用(主动)、异常(被动)、外部中断(被动)。切换开销较大(涉及模式切换、栈切换、现场保存与恢复),因此高频系统调用应尽可能合并。


六、可重入函数与 volatile 关键字

1. 可重入函数(Reentrant Function)

1.1 什么是重入

当一个函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入(Reentrancy)

1.2 不可重入的例子

复制代码
main 函数                         sighandler 函数
    │                                  │
    ▼                                  ▼
 调用 insert(&head, node1)          调用 insert(&head, node2)
    │                                  │
    ├─ 步骤1: node1->next = head       ├─ 步骤1: node2->next = head
    ├─ 步骤2: head = node1     ← 中断  ├─ 步骤2: head = node2
    │                                  │
    (此时 head = node1)              (此时 head = node2)
        │
        ▼
    (从中断处恢复,执行步骤2: head = node1)

结果:main 函数和 sighandler 先后向链表中插入两个节点,最后只有一个节点真正插入链表中(node1 覆盖了 node2)。

1.3 可重入函数 vs 不可重入函数

类型 定义 特点
可重入函数 只访问自己的局部变量或参数 无论被谁调用、何时调用都不会出错
不可重入函数 访问全局数据结构 重入时可能造成数据错乱

1.4 不可重入函数的条件

如果一个函数符合以下条件之一,则是不可重入的:

  1. 调用了 mallocfree --- 因为 malloc 使用全局链表来管理堆
  2. 调用了标准 I/O 库函数 --- 标准 I/O 库的很多实现以不可重入的方式使用全局数据结构
  3. 使用了全局或静态变量 --- 多个控制流程同时访问同一个全局变量可能导致数据不一致

1.5 可重入函数的编写原则

  • 只使用局部变量
  • 不调用不可重入的函数
  • 不访问全局数据结构(或者用互斥机制保护,但互斥本身在信号处理中也要谨慎使用)

2. volatile 关键字

2.1 问题场景

考虑下面的代码,在信号处理函数中修改全局变量 flag,但 main 中的循环却检测不到变化:

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

int flag = 0;   // 全局标志位

void handler(int sig)
{
    printf("chage flag 0 to 1\n");
    flag = 1;   // 在信号处理函数中修改 flag
}

int main()
{
    signal(2, handler);     // 注册 SIGINT 的处理函数

    while (!flag)           // 主循环等待 flag 变为 1
    {
        // 空转等待
    }

    printf("process quit normal\n");
    return 0;
}

正常情况(无优化编译)

bash 复制代码
$ gcc -o sig sig.c         # 默认编译(无优化)
$ ./sig
^Cchage flag 0 to 1
process quit normal         # 正常退出

开启优化后

bash 复制代码
$ gcc -o sig sig.c -O2     # 开启 O2 优化
$ ./sig
^Cchage flag 0 to 1
^Cchage flag 0 to 1
^Cchage flag 0 to 1        # 永远不会退出!

2.2 原因分析

编译器在开启 -O2 优化时:

  1. 看到 while(!flag) 循环中没有对 flag 的修改
  2. flag 的值加载到 CPU 寄存器
  3. 后续每次循环判断都直接从寄存器读取,不再访问内存
  4. 信号处理函数修改的是内存中的 flag,寄存器中的值没有更新
  5. 导致 while 循环永远检测不到变化

这就是数据二异性问题:while 检测的 flag 并不是内存中最新的 flag。

2.3 volatile 的作用

volatile 关键字的作用:保持内存的可见性

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

volatile int flag = 0;  // ← 使用 volatile 修饰

void handler(int sig)
{
    printf("chage flag 0 to 1\n");
    flag = 1;
}

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

    while (!flag)       // 现在每次都会从内存读取 flag
    {
    }

    printf("process quit normal\n");
    return 0;
}
bash 复制代码
$ gcc -o sig sig.c -O2   # 即使开启 O2 优化
$ ./sig
^Cchage flag 0 to 1
process quit normal       # 正常退出

volatile 告知编译器

  • 被该关键字修饰的变量不允许被优化
  • 对该变量的任何操作,都必须在真实的内存中进行
  • 每次访问都必须从内存读取,不能使用寄存器中的缓存值

2.4 volatile 的适用场景

  1. 信号处理函数中修改的全局标志位
  2. 多线程间共享的全局变量(不过多线程场景更推荐使用原子操作)
  3. 硬件寄存器的映射地址(MMIO --- 内存映射 IO)
  4. 与异步事件相关的全局变量

3. 核心要点总结

  1. 可重入函数只使用局部变量,不依赖全局数据结构
  2. 不可重入的原因:调用 malloc/free、标准 I/O、使用全局/静态变量
  3. 重入的风险:不同控制流程交错执行,破坏共享数据的一致性
  4. volatile 保证内存可见性:禁止编译器优化,强制从内存读取
  5. 信号处理函数中修改全局标志位,必须用 volatile 修饰
  6. 使用 volatile 时配合 -O2 及以上优化级别才能体现出作用

知识拓展:深入理解可重入与 volatile

1. sig_atomic_t --- 信号安全的原子类型

C 标准提供 sig_atomic_t 类型,保证对该类型变量的读写是原子的(单条指令完成,不可被中断拆分):

c 复制代码
#include <signal.h>
volatile sig_atomic_t flag = 0;

sig_atomic_t 通常是 int 类型,跨平台保证信号处理函数中安全读写。超过该类型大小的变量(如 long long、结构体)在多条指令中完成,不是原子的。

2. 编译器屏障 vs CPU 内存屏障
  • 编译器屏障(Compiler Barrier)volatile 的本质是一种编译器屏障,阻止编译器将变量优化到寄存器。更通用的编译器屏障:asm volatile("" ::: "memory")
  • CPU 内存屏障(Memory Barrier) :CPU 可能乱序执行指令,volatile 无法阻止 CPU 级别的乱序。多核场景下需要使用 CPU 内存屏障指令(如 mfence__sync_synchronize)。

结论:volatile 只解决单线程异步重入(信号处理场景)的编译器优化问题,不解决多核场景的 CPU 乱序问题。

3. 可重入 vs 线程安全
  • 可重入(Reentrant) :函数执行中被中断后再次进入,结果仍正确。关注单线程内异步重入(信号处理函数)。必须只使用局部变量。
  • 线程安全(Thread-Safe) :多个线程同时调用,结果仍正确。关注多线程并发。可以通过互斥锁实现。

一个函数可以既线程安全又可重入(只操作局部变量),也可以线程安全但不可重入(持有锁,但锁本身在中断中不可用)。

4. GCC attribute((noinline)) 与优化观察

在测试 volatile 效果时,可通过以下手段观察编译器是否将变量优化到寄存器:

bash 复制代码
gcc -O2 -S test.c          # 生成汇编代码,检查是否存在 load 指令
objdump -d a.out | less     # 反汇编,观察变量访问指令

总结:可重入函数和 volatile

问题:什么是可重入函数?信号处理函数中修改全局变量为什么要用 volatile 修饰?

可重入函数:一个函数在被不同的控制流程调用时,如果第一次调用还没返回就再次进入该函数,执行结果仍然正确,则称该函数是可重入的。

不可重入的条件(满足任一即可):

  1. 调用了 malloc/free------因为 malloc 使用全局链表管理堆内存
  2. 调用了标准 I/O 库函数------标准 I/O 的全局缓冲区不是重入安全的
  3. 使用了全局或静态变量------多个控制流程同时访问导致数据不一致

典型例子:main 和 sighandler 同时调用链表插入函数,sighandler 在 main 执行到一半时插入节点,返回后 main 继续执行导致 node2 被覆盖。

volatile 关键字 :告知编译器该变量不允许被优化到寄存器中,每次访问都必须从真实内存中读取。

为什么需要 volatile :编译器开启 -O2 优化时,发现 while(!flag) 循环中未修改 flag,会将 flag 加载到 CPU 寄存器中,后续循环判断直接从寄存器读取。而信号处理函数修改的是内存中的 flag,导致"数据二异性"------while 检查的 flag 不是内存中最新的值,造成死循环。

volatile 修饰后,编译器强制每次循环都从内存读取 flag,即使开启 -O2 优化也能正确检测到信号处理函数的修改。

注意volatile 只解决编译器优化问题,不解决 CPU 乱序执行问题。多核环境还需原子操作或内存屏障。在信号处理场景中,更推荐使用 volatile sig_atomic_t 类型。


七、SIGCHLD 信号

1. 背景问题

在进程控制中,父进程需要清理子进程退出后的资源(避免僵尸进程)。传统方式有两种:

方式 优点 缺点
阻塞等待(wait/waitpid 阻塞调用) 逻辑简单 父进程被阻塞,无法处理自己的工作
非阻塞轮询(WNOHANG 循环检查) 父进程可以工作 需要定时轮询,实现复杂

SIGCHLD 信号的出现完美解决了这个问题。


2. SIGCHLD 信号

基本特性

特性 说明
触发条件 子进程终止时,自动向父进程发送
默认动作 忽略(Ign)
信号编号 17(Linux 上)
作用 通知父进程有子进程结束,需要处理

工作原理

  1. 父进程自定义 SIGCHLD 信号的处理函数
  2. 父进程专心处理自己的工作,不必关心子进程
  3. 子进程终止时,内核自动向父进程发送 SIGCHLD 信号
  4. 父进程收到信号后,在信号处理函数中调用 wait 清理子进程

3. 示例代码

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

// SIGCHLD 信号处理函数
void handler(int sig)
{
    pid_t id;

    /*
     * 使用 WNOHANG 非阻塞方式回收所有已退出的子进程
     * waitpid 返回 > 0 表示成功回收一个子进程
     * 循环回收是为了防止多个子进程同时退出时信号丢失
     */
    while ((id = waitpid(-1, NULL, WNOHANG)) > 0)
    {
        printf("wait child success: %d\n", id);
    }

    printf("child is quit! %d\n", getpid());
}

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

    pid_t cid;
    if ((cid = fork()) == 0)  // 子进程
    {
        printf("child : %d\n", getpid());
        sleep(3);       // 子进程睡眠3秒
        exit(1);        // 子进程退出,会发送 SIGCHLD 给父进程
    }

    // 父进程继续执行自己的工作
    while (1)
    {
        printf("father proc is doing some thing!\n");
        sleep(1);
    }

    return 0;
}

关键设计要点

  1. 使用 WNOHANG:确保 waitpid 不会阻塞信号处理函数
  2. 循环回收 :使用 while 循环而非 if,因为多个子进程同时退出时,SIGCHLD 信号可能合并(常规信号不排队),一次处理函数调用要回收所有已退出的子进程
  3. 信号处理函数要简短:信号处理函数中应尽快完成工作返回

4. 避免僵尸进程的另一种方法

除了自定义 SIGCHLD 处理函数外,还有一种更简单的做法:

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

int main()
{
    // 将 SIGCHLD 的处理动作置为 SIG_IGN
    // 这样 fork 出来的子进程在终止时会自动清理
    signal(SIGCHLD, SIG_IGN);

    pid_t cid;
    if ((cid = fork()) == 0)  // 子进程
    {
        printf("child : %d\n", getpid());
        sleep(3);
        exit(1);
    }

    // 父进程不需要 wait 也不会产生僵尸进程
    while (1)
    {
        printf("father proc is doing some thing!\n");
        sleep(1);
    }

    return 0;
}

重要说明

  • 将 SIGCHLD 的处理动作置为 SIG_IGN,子进程终止时会自动清理,不会产生僵尸进程
  • 此方法通过系统默认的忽略动作实现,是 /signal.h 中的一个特例
  • 通常情况下,用户自定义的忽略和系统的默认忽略没有区别
  • 此方法在 Linux 上可用,但不保证在其他 UNIX 系统上都可用

5. 两种方式对比

方式 优点 缺点
自定义处理函数 + wait 可以获取子进程退出状态,更灵活 代码稍多
SIG_IGN 代码最简单 无法获取子进程退出状态;非 POSIX 标准,可移植性差

6. 核心要点总结

  1. 子进程终止时自动给父进程发 SIGCHLD 信号
  2. SIGCHLD 的默认处理动作是忽略
  3. 自定义 SIGCHLD 处理函数可以让父进程专心工作,子进程结束时自动通知
  4. 使用循环 + WNOHANG 回收子进程,避免信号丢失
  5. 设置 SIGCHLD 为 SIG_IGN 可以自动清理子进程,不产生僵尸进程(Linux 特例)
  6. 父进程应使用 while 循环调用 waitpid(-1, NULL, WNOHANG),确保一次处理所有已退出的子进程

知识拓展:深入理解 SIGCHLD

1. SIG_IGN 与 wait 的互斥性

如果进程将 SIGCHLD 设置为 SIG_IGN,则后续调用 wait/waitpid 会阻塞直到所有子进程退出,然后返回 -1 并设置 errno 为 ECHILD(没有子进程可等待)。这是因为内核在 SIG_IGN 时已经自动清理了子进程资源,父进程已无子进程可以回收。

2. SA_NOCLDSTOP 与 SA_NOCLDWAIT 标志

使用 sigaction 注册 SIGCHLD 时可附加高级标志:

c 复制代码
struct sigaction act;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = SA_NOCLDSTOP | SA_NOCLDWAIT;
sigaction(SIGCHLD, &act, NULL);
  • SA_NOCLDSTOP:子进程暂停(SIGSTOP/SIGTSTP)时不产生 SIGCHLD,仅在终止时通知
  • SA_NOCLDWAIT:类似 SIG_IGN 的效果,子进程终止时自动清理,不产生僵尸进程
3. wait 的竞争条件与信号丢失

常规信号不排队,多个子进程同时退出时 SIGCHLD 可能合并为一次递达。这就是为什么处理函数中必须用 while 循环 waitpid(-1, NULL, WNOHANG) ------ 确保在一次信号处理中回收所有已退出的子进程。如果用 if 而非 while,会造成部分子进程未被回收,形成僵尸进程。

4. SIGCHLD 的跨平台差异
  • Linux:默认动作是 Ign(忽略)
  • 某些 UNIX 系统:默认动作可能是 SIG_DFL(丢弃信号,不产生僵尸进程)
  • POSIX 标准对 SIGCHLD 的默认动作没有强制规定
  • 将 SIGCHLD 设为 SIG_IGN 并自动清理子进程是 Linux 特有的行为,非 POSIX 标准,跨平台代码中推荐使用 waitpid + WNOHANG 方式

总结:SIGCHLD 信号

问题:什么是 SIGCHLD 信号?如何用它避免僵尸进程?

SIGCHLD(编号 17)是子进程状态变化(终止、暂停、继续)时由内核自动发送给父进程的信号,默认处理动作是忽略(Ign)。

SIGCHLD 解决了父进程既要处理自己的工作又要回收子进程资源的矛盾。传统方式有两种缺陷:阻塞等待让父进程无法工作,非阻塞轮询又增加复杂度。SIGCHLD 提供了优雅的异步通知方案。

SIGCHLD 处理的最佳实践

c 复制代码
void handler(int sig) {
    pid_t pid;
    while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
        // 成功回收一个子进程
    }
}

三个关键设计要点:

  1. WNOHANG:确保 waitpid 不会阻塞信号处理函数
  2. while 循环:常规信号不排队,一次处理函数调用要回收所有已退出的子进程,避免僵尸进程残留
  3. waitpid(-1, ...):回收任意子进程

另一种方式:将 SIGCHLD 设为 SIG_IGN,内核自动清理子进程,不会产生僵尸进程,也无需 wait 调用:

c 复制代码
signal(SIGCHLD, SIG_IGN);

这是 Linux 特例,简单直接,但非 POSIX 标准,且无法获取子进程退出状态。跨平台代码应使用 waitpid + WNOHANG 方式。


相关推荐
晓蓝WQuiet11 小时前
vim/linux使用笔记
linux·笔记·vim
STDD11 小时前
Abiotic Factor多人生存建筑游戏《非生物因素》 专用服务器搭建教程
服务器·数据库·游戏
相思难忘成疾11 小时前
【Linux网络服务】基于Euler系统的主从DNS服务器深度配置
linux·运维·服务器
光电笑映11 小时前
Linux 文件 IO:缓冲区、重定向与一切皆文件
linux·运维·服务器
淼淼爱喝水11 小时前
【Ansible 入门实战】三种变量详解
java·linux·数据库·ansible·playbook
Languorous.11 小时前
Linux mkdir、rmdir 命令详解——目录的创建与删除(新手零踩坑)
linux·运维·服务器
樱桃花下的小猫11 小时前
腐蚀Rust-EAC 及官方验证关闭教程
服务器·rust·云鸢互联·零门槛一键开服·腐蚀rust服务器
酷道11 小时前
CentOS 7 安装 Docker
linux·docker·centos
Python-AI Xenon11 小时前
双网卡双网关服务器策略路由配置与持久化完全指南
linux·运维·计算机网络·网络故障排查