Linux系统学习【进程信号详细解析——认识、产生、保存以及捕捉信号】


🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》 《MySQL数据库学习》 《Python知识内容》 《cpolar知识学习》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

在Linux系统中,进程信号(Signal)是一种非常重要的异步事件通知机制.我们在终端中按下 Ctrl+C 终止程序、使用 kill 命令结束进程,甚至程序运行过程中出现非法内存访问、除零异常等情况时,背后都离不开信号机制.看似简单的"通知",实际上涉及操作系统内核、进程控制块以及进程执行流程之间的一整套协作机制.很多人在刚接触信号时,往往只记住了 SIGINT、SIGKILL、SIGTERM 等几个常见信号,却容易忽略更关键的问题:信号究竟是什么?它是如何产生的?进程没有立即处理信号时,信号保存在哪里?所谓阻塞信号又是什么意思?当信号真正递达到进程时,进程又该如何捕捉并执行自定义处理逻辑?这些问题如果只停留在API的使用层面,很容易形成零散的知识点.因此,本篇文章将从 Linux 信号的底层机制出发,按照 "认识信号 → 产生信号 → 保存信号 → 捕捉信号" 的顺序逐步展开,重点分析信号的产生方式、未决信号与阻塞信号的关系、信号递达过程以及信号处理函数的工作机制.通过这一篇内容,我们不仅要学会"怎么发送和捕捉一个信号",更重要的是建立起完整的信号处理模型,真正理解 Linux 内核是如何借助信号完成进程控制、异常处理以及异步事件通知.

目录

1.快速认识信号

1.1⽣活⻆度的信号

你在⽹上买了很多件商品,在等待不同商品快递的到来.但即便快递没有到来,你也知道快递来临时,你该怎么处理快递.也就是你能"识别快递".当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需10min之后才能去取快递.那么在这10min之内,你并没有下去去取快递,但是你是知道有快递到来了.也就是取快递的⾏为并不是⼀定要⽴即执⾏,可以理解成"在合适的时候去取".在收到通知,再到你拿到快递期间,是有⼀个时间窗⼝的,在这段时间,你并没有拿到快递,但是你知道有⼀个快递已经来了.本质上是你"记住了有⼀个快递要去取".当你时间合适,顺利拿到快递之后,就要开始处理快递了.⽽处理快递⼀般⽅式有三种:

1.执⾏默认动作(幸福的打开快递,使⽤商品)

2.执⾏⾃定义动作(快递是零⻝,你要送给你你的⼥朋友)

3.忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏)

快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话.


1.2技术应⽤⻆度的信号

(1)样例

cpp 复制代码
//testsig.cc
#include <iostream>
#include <unistd.h>
int main()
{
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

⽤户输⼊命令,在Shell下启动⼀个前台进程.⽤户按下 Ctrl+C ,这个键盘输⼊产⽣⼀个硬件中断,被操作系统获取,解释成信号,发送给⽬标前台进程前台进程因为收到信号,进⽽引起进程退出.


(2)⼀个系统函数

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

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

其中最容易卡住的是这一句:

cpp 复制代码
typedef void (*sighandler_t)(int);

它定义了一个函数指针类型 sighandler_t,这种函数必须满足:

cpp 复制代码
void 函数名(int);

例如:

cpp 复制代码
void handler(int signo)
{
    cout << "收到信号: " << signo << endl;
}

然后:

cpp 复制代码
signal(SIGINT, handler);

意思就是:

text 复制代码
收到 SIGINT
    ↓
调用 handler(SIGINT)

例如完整代码:

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

using namespace std;

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

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

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

    return 0;
}

运行之后按:

text 复制代码
Ctrl + C

终端会产生 SIGINT,通常编号是 2.由于你已经执行:

cpp 复制代码
signal(SIGINT, handler);

所以进程不会直接按照 SIGINT 的默认行为退出,而会执行:

cpp 复制代码
handler(2);

因此可能看到:

text 复制代码
I am a process, waiting signal!
I am a process, waiting signal!
^C捕获到信号: 2
I am a process, waiting signal!

两个参数

cpp 复制代码
signal(int signum, sighandler_t handler);

signum 是要处理哪个信号,例如:

cpp 复制代码
SIGINT
SIGTERM
SIGALRM
SIGCHLD

handler 是收到信号以后怎么办,通常有三种写法:

cpp 复制代码
signal(SIGINT, handler);   // 自定义处理
signal(SIGINT, SIG_DFL);   // 恢复默认处理
signal(SIGINT, SIG_IGN);   // 忽略信号

所以可以记成:

text 复制代码
signal(哪个信号, 怎么处理);

例如:

cpp 复制代码
signal(SIGINT, SIG_IGN);

表示:

收到 SIGINT 时忽略它.

还有一点很重要:signal() 的返回值也是:

cpp 复制代码
sighandler_t

也就是它会返回这个信号原来的处理方式;失败则返回:

cpp 复制代码
SIG_ERR

所以更完整地理解:

cpp 复制代码
signal(SIGINT, handler);

就是:

把 SIGINT 原来的处理方式替换为 handler,同时把旧的处理方式返回给我.

你现在先把这句话记牢即可:

signal() = 修改某个信号的处理动作.

cpp 复制代码
//testsig.cc
#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;
    signal(SIGINT /*2*/, handler);
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

为什么按了 Ctrl+C,进程却没有退出?

因为你用 signal(SIGINT, handler) 把 SIGINT 的默认"终止进程"动作,改成了执行你自己的 handler().

关键代码是:

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

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

    signal(SIGINT, handler);

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

1.为什么这里进程不退出?

正常情况下,没有这句话:

cpp 复制代码
signal(SIGINT, handler);

你按:

text 复制代码
Ctrl+C

终端会产生 SIGINT 信号,Linux 中通常是:

text 复制代码
SIGINT = 2

而 SIGINT 的默认处理动作是:

text 复制代码
终止进程

所以正常情况是:

text 复制代码
Ctrl+C
   ↓
SIGINT(2)
   ↓
默认处理动作
   ↓
进程退出

但是你的程序执行了:

cpp 复制代码
signal(SIGINT, handler);

相当于告诉操作系统:

"以后我收到 SIGINT 时,不要按照默认方式把我杀掉,请调用我的 handler()."

于是变成:

text 复制代码
Ctrl+C
   ↓
产生 SIGINT(2)
   ↓
操作系统把信号递送给进程
   ↓
发现该进程注册了 handler
   ↓
执行 handler(2)
   ↓
handler 执行结束
   ↓
返回原来的程序继续运行

而你的 handler() 只是:

cpp 复制代码
std::cout << ...

它没有调用 exit(),也没有让 while(true) 结束.

因此 handler 执行完以后:

cpp 复制代码
while (true)

继续运行.

所以看到:

text 复制代码
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C我是:212569,我获得了一个信号:2
I am a process, I am waiting signal!
I am a process, I am waiting signal!

这正是程序应该出现的结果.

2.这个例子到底说明了什么?

它主要说明:

信号的处理动作可以被进程修改

一个信号来了以后,一般有三种处理方法:

text 复制代码
             收到 signal
                  │
        ┌─────────┼─────────┐
        ↓         ↓         ↓
     默认处理     忽略     自定义处理
    SIG_DFL     SIG_IGN     handler

例如:

cpp 复制代码
signal(SIGINT, SIG_DFL);

恢复默认处理:

text 复制代码
Ctrl+C → 退出

也可以:

cpp 复制代码
signal(SIGINT, SIG_IGN);

表示忽略:

text 复制代码
Ctrl+C → 什么都不做

你的代码:

cpp 复制代码
signal(SIGINT, handler);

表示:

text 复制代码
Ctrl+C → 执行 handler()

所以这个实验最重要的结论就是:

信号本身只是通知,收到信号以后到底做什么,可以由进程自己设定.

不过有两个特殊信号例外:

text 复制代码
SIGKILL
SIGSTOP

这两个不能捕获,也不能忽略.

3."信号处理,是自己处理"是什么意思?

这里容易产生误解.

不是说:

进程自己产生信号、自己通知自己.

而是分成两个阶段:

text 复制代码
信号产生、递送
    ↓
主要由操作系统负责

信号对应的处理动作
    ↓
可以由进程自己定义

程序通过:

cpp 复制代码
signal(SIGINT, handler);

提前告诉操作系统:

如果将来收到 SIGINT,我要用 handler() 来处理.

真正收到信号后,操作系统会让当前进程去执行这个函数:

cpp 复制代码
handler(2);

所以可以说:

操作系统负责"送信号",进程负责"怎么处理信号"。

4.用生活例子理解

假如进程就是你,操作系统就是快递员,信号就是快递,发信号的过程类似给你打电话.

假设:

text 复制代码
你 = 进程
操作系统 = 快递/电话系统
Signal = 通知
handler = 你接到通知后的处理方式

你平时正在:

text 复制代码
学习
学习
学习
学习......

对应程序:

cpp 复制代码
while (true)
{
    cout << "I am working..." << endl;
    sleep(1);
}

现在有人给你打电话:

text 复制代码
"有快递到了!"

相当于:

text 复制代码
SIGINT 到达

默认情况

如果你事先没有特殊说明:

text 复制代码
电话来了
   ↓
默认规则
   ↓
停止当前工作

对应:

text 复制代码
Ctrl+C
   ↓
SIGINT
   ↓
SIG_DFL
   ↓
进程退出

你的程序现在的情况

你提前告诉"操作系统":

cpp 复制代码
signal(SIGINT, handler);

相当于说:

"以后电话来了,不要让我直接停止工作.让我接一下电话,处理完继续学习."

于是:

text 复制代码
你正在学习
     ↓
电话响了
     ↓
暂停一下当前工作
     ↓
接电话
     ↓
处理电话
     ↓
挂电话
     ↓
继续学习

对应程序:

text 复制代码
main 中执行 while(true)
        ↓
收到 SIGINT
        ↓
暂时去执行 handler(2)
        ↓
handler 执行结束
        ↓
回到 main
        ↓
继续 while(true)

这就是为什么你的进程不退出。

5.Ctrl+C 的完整信号处理过程

把现在这个实验完整串起来:

text 复制代码
① 程序启动
       ↓
② main() 开始执行
       ↓
③ signal(SIGINT, handler)
       ↓
   注册 SIGINT 的处理方法
       ↓
④ 进入 while(true)
       ↓
⑤ 用户按 Ctrl+C
       ↓
⑥ 终端检测到 Ctrl+C
       ↓
⑦ 内核向前台进程发送 SIGINT
       ↓
⑧ 你的进程收到 SIGINT
       ↓
⑨ 内核发现你设置了 handler
       ↓
⑩ 暂停当前正常执行流程
       ↓
⑪ 执行 handler(2)
       ↓
打印:
"我获得了一个信号:2"
       ↓
⑫ handler 返回
       ↓
⑬ 恢复原来的执行流程
       ↓
⑭ while(true) 继续执行

这张流程非常重要.

可以把核心压缩成:

text 复制代码
正常代码
   ↓
收到信号
   ↓
暂时执行 handler
   ↓
handler 返回
   ↓
继续正常代码

6.如果我就想让它收到 Ctrl+C 后退出呢?

可以自己在 handler 里面退出,例如教学上可以理解为:

cpp 复制代码
void handler(int signumber)
{
    std::cout << "收到信号:" << signumber << std::endl;

    exit(0);
}

那么就是:

text 复制代码
Ctrl+C
   ↓
SIGINT
   ↓
handler(2)
   ↓
exit(0)
   ↓
进程退出

或者不要注册:

cpp 复制代码
signal(SIGINT, handler);

那么 SIGINT 使用默认动作:

text 复制代码
Ctrl+C → SIGINT → 终止

1.3信号概念

在Linux里,信号(Signal)是一种进程之间或内核向进程发送的异步通知机制 .

可以先记成一句话:

信号不是数据,而是"某件事发生了"的通知.

比如:

  • 你按 Ctrl+C → 终端产生 SIGINT
  • 程序访问非法内存 → 内核产生 SIGSEGV
  • 子进程退出 → 父进程收到 SIGCHLD
  • 执行 kill -15 PID → 给目标进程发送 SIGTERM

信号到达进程后,进程通常有三种处理方式:

text 复制代码
默认处理
忽略信号
自定义处理函数 handler

例如:

cpp 复制代码
signal(SIGINT, handler);

表示:

当进程收到 SIGINT 时,不执行默认动作,而是调用 handler().

所以前面的程序按 Ctrl+C 后不退出,是因为:

text 复制代码
Ctrl+C
  ↓
SIGINT
  ↓
进程收到信号
  ↓
执行 handler()
  ↓
handler 返回
  ↓
程序继续运行

信号的几个关键特点

  1. 异步

    程序不知道信号什么时候来,它可能正在执行其他代码时突然收到.

  2. 信号有编号

    例如常见的:

    • SIGINT → 2
    • SIGKILL → 9
    • SIGTERM → 15
  3. 每个信号都有默认动作

    可能是终止、暂停、忽略等.

  4. 大多数信号可以修改处理方式

    可以用 signal() 或更推荐的 sigaction() 注册处理函数.

  5. SIGKILL 和 SIGSTOP 特殊

    它们不能被捕获、忽略或自定义处理.

可以把 Linux 信号理解成:

text 复制代码
进程 = 你
信号 = 电话通知
操作系统内核 = 电话系统

电话来了
   ↓
你决定怎么处理
   ↓
挂断 / 忽略 / 接听处理

因此,信号机制的本质就是 Linux 用来通知进程发生了某种事件的一种软件中断机制.


(1)查看信号

每个信号都有⼀个编号和⼀个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义

#define SIGINT 2

编号34以上的是实时信号,本文只讨论编号34以下的信号,不讨论实时信号.这些信号各⾃在什么条件下产⽣,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal

编号 信号 大概含义 默认行为 / 常见场景
1 SIGHUP 终端挂断 终止;守护进程常用它重新加载配置
2 SIGINT 中断 Ctrl+C,默认终止进程
3 SIGQUIT 退出 **Ctrl+**,默认终止并可能产生 core
4 SIGILL 非法指令 CPU 执行了非法指令,通常 core
5 SIGTRAP 跟踪/断点 调试器常用,如断点
6 SIGABRT 异常终止 abort() 产生,通常 core
7 SIGBUS 总线错误 非法内存访问的一类,通常 core
8 SIGFPE 算术异常 如整数除 0,通常 core
9 SIGKILL 强制杀死 不能捕获、不能忽略、不能阻塞
10 SIGUSR1 用户自定义信号 1 程序自己约定用途
11 SIGSEGV 段错误 非法访问内存,常见 Segmentation fault
12 SIGUSR2 用户自定义信号 2 和 SIGUSR1 类似
13 SIGPIPE 管道破裂 向已经关闭的 pipe/socket 写数据
14 SIGALRM 闹钟信号 alarm() 定时到期
15 SIGTERM 请求终止 kill PID 默认发送,推荐正常结束进程时使用
16 SIGSTKFLT 栈/协处理器错误 Linux 特有,现在很少使用
17 SIGCHLD 子进程状态变化 子进程退出/停止时通知父进程
18 SIGCONT 继续执行 恢复被暂停的进程
19 SIGSTOP 强制暂停 不能捕获、不能忽略、不能阻塞
20 SIGTSTP 终端暂停 Ctrl+Z
21 SIGTTIN 后台进程读终端 后台进程尝试读控制终端时产生
22 SIGTTOU 后台进程写终端 后台进程尝试写控制终端时可能产生
23 SIGURG 紧急数据 socket 收到紧急数据
24 SIGXCPU CPU 时间超限 超过资源限制 RLIMIT_CPU
25 SIGXFSZ 文件大小超限 文件超过 RLIMIT_FSIZE
26 SIGVTALRM 虚拟定时器 进程用户态 CPU 时间计时
27 SIGPROF 性能分析定时器 profiling / setitimer()
28 SIGWINCH 窗口大小改变 调整终端窗口大小时产生
29 SIGIO I/O 事件 异步 I/O 就绪通知;也常叫 SIGPOLL
30 SIGPWR 电源相关事件 电源故障等,较少直接使用
31 SIGSYS 非法系统调用 执行了不允许/非法的 syscall
32 --- glibc/NPTL 内部保留 一般应用程序不要使用
33 --- glibc/NPTL 内部保留 一般应用程序不要使用
34 SIGRTMIN 最小实时信号 实时信号范围的起点

(2)信号处理

Linux 里的信号处理,核心就是:

进程收到一个信号后,决定"怎么处理这个信号".

通常有3种处理方式:

处理方式 写法 含义
默认处理 SIG_DFL 使用系统默认动作
忽略信号 SIG_IGN 收到后不处理
自定义处理 handler 收到后执行自己的函数

默认处理

如果没有写:

cpp 复制代码
signal(SIGINT, handler);

那么 SIGINT 使用默认动作:

text 复制代码
Ctrl+C
  ↓
SIGINT
  ↓
默认动作
  ↓
终止进程

也可以主动恢复默认处理:

cpp 复制代码
signal(SIGINT, SIG_DFL);
cpp 复制代码
#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;
    signal(SIGINT /*2*/, SIG_DFL);
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

输⼊ctrl+c,进程退出,就是默认动作


忽略信号

例如:

cpp 复制代码
signal(SIGINT, SIG_IGN);

此时:

text 复制代码
Ctrl+C
  ↓
SIGINT
  ↓
忽略
  ↓
继续运行
cpp 复制代码
#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;
    signal(SIGINT /*2*/, SIG_IGN); // 设置忽略信号的宏
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

输⼊ctrl+c毫⽆反应


自定义处理

例如:

cpp 复制代码
signal(SIGINT, handler);

收到信号后:

cpp 复制代码
handler(2);

这里的 2 就是 SIGINT 的编号.

所以:

cpp 复制代码
void handler(int signo)

参数 signo 表示:

这次收到的是哪个信号.

一个 handler 甚至可以处理多个信号:

cpp 复制代码
signal(SIGINT, handler);
signal(SIGTERM, handler);

void handler(int signo)
{
    if (signo == SIGINT)
    {
        // 处理 Ctrl+C
    }
    else if (signo == SIGTERM)
    {
        // 处理终止请求
    }
}

2.产生信号

2.1通过终端按键产⽣信号

(1)基本操作

• Ctrl+C(SIGINT)已经验证过,这⾥不再重复.

• Ctrl+(SIGQUIT)可以发送终⽌信号并⽣成core dump⽂件,⽤于事后调试.

cpp 复制代码
#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;
    signal(SIGQUIT /*3*/, handler);
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

我们注释掉11⾏代码再看看!

• Ctrl+Z(SIGTSTP)可以发送停⽌信号,将当前前台进程挂起到后台等.

cpp 复制代码
#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;
    signal(SIGTSTP /*20*/, handler);
    while (true)
    {
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
    }
}

我们注释掉11⾏代码再看看!


(2)理解操作系统如何得知键盘有数据


(3)理解信号起源

在Linux里,"键盘有数据"这个通知最初是从哪里来的?

最关键的一点是:

Linux 内核不是凭空发现键盘有数据,而是底层硬件控制器通过硬件中断通知CPU,然后 Linux 的中断处理链一路把数据送进 input 子系统.

以现代最常见的 USB 键盘为例,可以先记这条链:

text 复制代码
用户按键
   ↓
键盘内部 MCU 生成 HID 数据
   ↓
USB 控制器 xHCI 收到数据
   ↓
xHCI 产生硬件中断(通常 MSI/MSI-X)
   ↓
CPU 进入 Linux 内核中断处理
   ↓
USB / HID 驱动处理
   ↓
Linux input subsystem
   ↓
/dev/input/eventX
   ↓
libinput / X11 / Wayland / 应用程序

真正的"Linux 信号起源"

如果站在 Linux 内核视角看,起点可以认为是:

text 复制代码
硬件
 ↓
IRQ / MSI
 ↓
CPU
 ↓
Linux IRQ subsystem

也就是说,之前这一句:

键盘向 CPU 发送硬件中断

对于现代 USB 键盘来说不够精确.更准确应该是:

text 复制代码
键盘
  │ HID 数据
  ▼
USB Host Controller(xHCI)
  │
  │ IRQ / MSI
  ▼
CPU
  │
  ▼
Linux Kernel

真正向 CPU 发中断的通常是 USB Host Controller,而不是 USB 键盘本身。

USB HID 里所谓的 Interrupt Transfer(中断传输) ,名字很容易误导.它实际上仍然是 Host 主动轮询设备 endpoint:

text 复制代码
Linux/xHCI:
"键盘,有数据吗?"

键盘:
"没有。"

过一小段时间:

Linux/xHCI:
"有数据吗?"

键盘:
"有,A 被按下了。"

xHCI 得到这次传输完成事件以后,再通过 MSI/MSI-X 或传统 IRQ 通知 CPU.

Linux 内核内部继续往下,大致可以理解成:

text 复制代码
xHCI 中断
    ↓
USB Core
    ↓
usbhid
    ↓
HID Core
    ↓
Linux Input Core
    ↓
evdev
    ↓
/dev/input/event3

例如用户程序执行:

c 复制代码
read(fd, &event, sizeof(event));

去读取:

text 复制代码
/dev/input/event3

如果现在没按键,它实际上会睡眠:

text 复制代码
应用程序
   ↓
read()
   ↓
没有数据
   ↓
睡眠 / 等待

然后:

text 复制代码
按 A
 ↓
硬件数据到达
 ↓
xHCI 中断
 ↓
HID 驱动
 ↓
input_event()
 ↓
evdev 缓冲区加入数据
 ↓
wake_up_interruptible()
 ↓
睡眠中的 read() 被唤醒
 ↓
程序拿到 KEY_A

所以,如果是在理解 Linux"为什么知道键盘有数据",建议脑子里建立这一张最小模型:

text 复制代码
             硬件层
键盘 ──USB──> xHCI 控制器
                 │
               IRQ/MSI
                 ↓
                CPU
                 │
─────────────────┼──────────────── Linux 内核
                 ↓
            USB/HID 驱动
                 ↓
           input subsystem
                 ↓
             evdev
                 ↓
        /dev/input/eventX
                 │
─────────────────┼──────────────── 用户态
                 ↓
            应用程序

而如果是老式 PS/2 键盘

text 复制代码
键盘
 ↓
i8042 键盘控制器
 ↓
IRQ 1
 ↓
CPU
 ↓
Linux 中断处理
 ↓
atkbd 驱动
 ↓
input subsystem

所以你可以这样区分:

PS/2:

键盘数据 → IRQ1 → CPU

USB:

键盘数据 → USB/xHCI → IRQ/MSI → CPU


2.2调⽤系统命令向进程产生信号

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
    while (true)
    {
        sleep(1);
    }
}

⾸先在后台执⾏死循环程序,然后⽤kill命令给它发SIGSEGV信号.

639797是sig进程的pid.之所以要再次回⻋才显示Segmentation fault,是因为在639797进程终⽌掉之前已经回到了Shell提示符等待⽤户输⼊下⼀条命令,Shell不希望Segmentation fault 信息和⽤户的输⼊交错在⼀起,所以等⽤户输⼊命令之后才显示.指定发送某种信号的 kill 命令可以有多种写法,上⾯的命令还可以写成 kill -11 639797,11是信号SIGSEGV的编号.以往遇到的段错误都是由⾮法内存访问产⽣的,⽽这个程序本⾝没错,给它发SIGSEGV也能产⽣段错误.


2.3使⽤函数产⽣信号

(1)kill

kill命令是调⽤kill函数实现的.kill函数可以给⼀个指定的进程发送指定的信号.

cpp 复制代码
NAME
kill - send signal to a process
SYNOPSIS
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
RETURN VALUE
On success (at least one signal was sent), zero is returned. On error,
-1 is returned, and errno is set appropriately.

两个参数非常关键:

bash 复制代码
kill(pid, sig)
     │    │
     │    └── 发什么信号
     │
     └────── 发给谁

样例:实现⾃⼰的kill命令

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
// mykill -signumber pid
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_t pid = std::stoi(argv[2]);
    int n = kill(pid, number);
    return n;
}

(2)raise

raise函数可以给当前进程发送指定的信号(⾃⼰给⾃⼰发信号).

cpp 复制代码
NAME
raise - send a signal to the caller
SYNOPSIS
#include <signal.h>
int raise(int sig);
RETURN VALUE
raise() returns 0 on success, and nonzero for failure.

样例

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
    // 整个代码就只有这⼀处打印
    std::cout << "获取了⼀个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
    signal(2, handler); // 先对2号信号进⾏捕捉
    // 每隔1S,⾃⼰给⾃⼰发送2号信号
    while (true)
    {
        sleep(1);
        raise(2);
    }
}

(3)abort

abort函数使当前进程接收到信号⽽异常终⽌.

cpp 复制代码
NAME
abort - cause abnormal process termination
SYNOPSIS
#include <stdlib.h>
void abort(void);
RETURN VALUE
The abort() function never returns.
//就像exit函数⼀样,abort函数总是会成功的,所以没有返回值。
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void handler(int signumber)
{
    // 整个代码就只有这⼀处打印
    std::cout << "获取了⼀个信号: " << signumber << std::endl;
}
// mykill -signumber pid
int main()
{
    signal(SIGABRT, handler);
    while (true)
    {
        sleep(1);
        abort();
    }
}

把这三个接口串起来就是:

bash 复制代码
kill(pid, sig)
    ↓
给指定进程发送信号


raise(sig)
    ↓
当前进程给自己发送信号


abort()
    ↓
当前进程触发 SIGABRT
    ↓
异常终止自己

2.4由软件条件产⽣信号

SIGPIPE是⼀种由软件条件产⽣的信号,在"管道"中已经介绍过了.本文主要介绍alarm函数和SIGALRM信号.

alarm() 和 SIGALRM 要放在一起理解:

alarm() 是"定闹钟",SIGALRM 是"闹钟到点后Linux内核发给当前进程的信号".

函数原型:

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

unsigned int alarm(unsigned int seconds);

例如:

c 复制代码
alarm(5);

意思不是"程序睡眠 5 秒",而是:

text 复制代码
当前进程调用 alarm(5)
        ↓
Linux 内核设置一个 5 秒定时器
        ↓
程序继续正常运行
        ↓
5 秒到期
        ↓
内核向该进程发送 SIGALRM
        ↓
进程处理 SIGALRM

alarm()不会阻塞程序

这一点特别重要.

比如:

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

int main()
{
    alarm(5);

    while (true)
    {
        std::cout << "running..." << std::endl;
        sleep(1);
    }
}

大概会看到:

text 复制代码
running...
running...
running...
running...
running...
Alarm clock

原因是:

text 复制代码
alarm(5)
   ↓
定时器开始计时

与此同时:

while(...)
   ↓
程序继续执行

5 秒后
   ↓
SIGALRM
   ↓
SIGALRM 默认动作:终止进程

所以,如果你没有注册 SIGALRM 的处理函数,5秒到了以后,程序通常直接被终止.

自己处理 SIGALRM

例如:

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

void handler(int sig)
{
    std::cout << "收到 SIGALRM" << std::endl;
}

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

    alarm(3);

    while (true)
    {
        std::cout << "running..." << std::endl;
        sleep(1);
    }
}

逻辑就是:

text 复制代码
signal(SIGALRM, handler)
        ↓
告诉 Linux:
以后收到 SIGALRM 时执行 handler


alarm(3)
        ↓
让内核设置 3 秒定时器


程序继续运行
        ↓
3 秒到
        ↓
内核产生 SIGALRM
        ↓
handler(SIGALRM)
        ↓
handler 返回
        ↓
程序继续运行

大概看到:

text 复制代码
running...
running...
running...
收到 SIGALRM
running...
running...
...

用 signal() 很直观;实际 Linux 编程通常更推荐 sigaction().另外严格来说,signal handler里不推荐使用 std::cout,这里主要用于帮助理解.

alarm() 只触发一次

这一点也很容易误会:

c 复制代码
alarm(3);

不是:

text 复制代码
每隔 3 秒发一次 SIGALRM

而是:

text 复制代码
3 秒以后
   ↓
发一次 SIGALRM
   ↓
结束

如果需要再次触发,可以在 handler 里面重新:

c 复制代码
void handler(int sig)
{
    write(STDOUT_FILENO, "alarm\n", 6);

    alarm(3);
}

于是形成:

text 复制代码
alarm(3)
   ↓
3 秒
   ↓
SIGALRM
   ↓
handler
   ↓
alarm(3)
   ↓
3 秒
   ↓
SIGALRM
   ↓
...

不过真正需要周期定时器时,一般会考虑 setitimer()、POSIX timer 或 timerfd 等机制.

再次调用 alarm() 会覆盖之前的闹钟

例如:

c 复制代码
alarm(10);

sleep(2);

alarm(5);

不是同时存在一个 10 秒和一个 5 秒定时器.

第二次:

c 复制代码
alarm(5);

会取消之前还没到期的 alarm,然后重新设置:

text 复制代码
alarm(10)
   ↓
过去 2 秒
   ↓
理论还剩约 8 秒

alarm(5)
   ↓
旧 alarm 被取消
   ↓
重新设置 5 秒

而且 alarm() 的返回值就是:

上一个 alarm 大约还剩多少秒.

所以:

cpp 复制代码
unsigned int ret = alarm(5);

如果前面没有定时器:

text 复制代码
ret = 0

如果之前设置了:

c 复制代码
alarm(10);
sleep(2);

unsigned int ret = alarm(5);

那么 ret 大约是:

text 复制代码
8

alarm(0) 很有用

c 复制代码
alarm(0);

意思是:

取消当前还没有到期的 alarm.

比如:

text 复制代码
alarm(10)
   ↓
设置 10 秒闹钟

alarm(0)
   ↓
取消闹钟

因此不会再因为这个 alarm 收到 SIGALRM

所以一句话记忆:

alarm() 负责预约时间,Linux 内核负责计时,时间到后由内核产生 SIGALRM.

另外,SIGALRM 如果当前被进程屏蔽(blocked),到点后并不会消失,而是先变成 pending,等解除屏蔽后再递达.


(1)基本alarm验证---体会IO效率问题

程序的作⽤是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终⽌.必要的时候,对SIGALRM信号进⾏捕捉.

结论:闹钟会响⼀次,默认终⽌进程.有IO效率低.


(2)设置重复闹钟

cpp 复制代码
#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); // ⼀次性的闹钟,超时alarm会⾃动被取消
    signal(SIGALRM, hanlder);
    while (true)
    {
        pause();
        std::cout << "我醒来了..." << std::endl;
        gcount++;
    }
}

结论:闹钟设置⼀次,起效⼀次.重复设置的⽅法,如果时间允许,可以测试⼀下alarm(0).


(3)如何理解软件条件

在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产⽣机制.这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产⽣的SIGPIPE信号)等.当这些软件条件满⾜时,操作系统会向相关进程发送相应的信号,以通知进程进⾏相应的处理.简⽽⾔之,软件条件是因操作系统内部或外部软件操作⽽触发的信号产⽣.


(4)如何简单快速理解系统闹钟

系统闹钟,其实本质是操作系统必须⾃⾝具有定时功能,并能让⽤户设置这种定时功能,才可能实现闹钟这样的技术.现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织.内核中的定时器数据结构是:

cpp 复制代码
struct timer_list {
struct list_head entry;
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
struct tvec_t_base_s *base;
};

我们不在这部分进⾏深究,为了理解它,我们可以看到:定时器超时时间expires和处理⽅法

function.操作系统管理定时器,采⽤的是时间轮的做法,但是我们为了简单理解,可以把它在组织成为"堆结构".

可以把 系统闹钟 alarm() 直接理解成:

让 Linux 内核帮你倒计时,时间到了就给当前进程发一个 SIGALRM 信号.

最小模型:

text 复制代码
alarm(5)
   ↓
告诉内核:5 秒后提醒我
   ↓
程序继续运行,不会停下来等
   ↓
5 秒到
   ↓
内核发送 SIGALRM
   ↓
进程处理这个信号

例如:

cpp 复制代码
alarm(3);

while (true)
{
    // 程序继续干自己的事
}

这里 alarm(3) 不是 sleep(3).

区别可以快速记:

text 复制代码
sleep(3)
↓
我自己停 3 秒


alarm(3)
↓
我继续运行
↓
3 秒后内核提醒我

如果你没有处理 SIGALRM:

cpp 复制代码
alarm(3);

3 秒后通常:

text 复制代码
SIGALRM
↓
默认动作
↓
进程终止

如果你自己注册处理函数:

cpp 复制代码
void handler(int sig)
{
    // 闹钟响了
}

signal(SIGALRM, handler);
alarm(3);

就是:

text 复制代码
设置闹钟
  ↓
继续运行
  ↓
3 秒后
  ↓
SIGALRM
  ↓
handler()
  ↓
处理完继续运行

你可以把它类比成手机闹钟:

text 复制代码
alarm(5)       = 设置"5秒后的闹钟"
Linux 内核      = 手机系统负责计时
SIGALRM         = 闹铃响了
handler         = 你听到闹铃后做什么

再记两个常用点:

cpp 复制代码
alarm(0);   // 取消闹钟

而且一次 alarm() 只响一次,不是每隔几秒自动重复.

一句话背下来就够:

alarm() 负责预约,内核负责计时,时间到以后用 SIGALRM 通知进程.


2.5硬件异常产⽣信号

可以把"硬件异常产生信号"快速理解成:程序执行某条指令时,CPU 发现"这条指令没法正常执行",先产生硬件异常进入Linux 内核;内核判断这个异常属于当前进程的问题后,再给进程发送相应信号.硬件异常被硬件以某种⽅式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号.例如当前进程执⾏了除以0的指令,CPU的运算单元会产⽣异常,内核将这个异常解释为SIGFPE信号发送给进程.再⽐如当前进程访问了⾮法内存地址,MMU会产⽣异常,内核将这个异常解释为SIGSEGV信号发送给进程.


(1)模拟除0

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

void handler(int sig)
{
    const char msg[] = "收到 SIGFPE:发生算术异常\n";
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
    _exit(1);
}

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

    volatile int a = 10;
    volatile int b = 0;

    int c = a / b;   // 故意整数除 0

    std::cout << c << std::endl;
    return 0;
}

在常见的Linux/x86环境下,会经历:

bash 复制代码
a / b
 ↓
CPU 执行整数除法指令
 ↓
发现除数为 0
 ↓
CPU 产生 Divide Error 异常
 ↓
进入 Linux 内核
 ↓
Linux 判断这是当前进程造成的
 ↓
产生 SIGFPE
 ↓
handler(SIGFPE)

(2)模拟野指针

cpp 复制代码
//默认⾏为
#include <stdio.h>
#include <signal.h>
void handler(int sig)
{
    printf("catch a sig : %d\n", sig);
}
int main()
{
    // signal(SIGSEGV, handler);
    sleep(1);
    int *p = NULL;
    *p = 100;
    while (1)
        ;
    return 0;
}
cpp 复制代码
//捕捉⾏为
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

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

    // 捕获到 SIGSEGV 后直接结束进程
    _exit(1);
}

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

    sleep(1);

    int *p = NULL;

    // 故意访问非法地址,触发 SIGSEGV
    *p = 100;

    while (1);

    return 0;
}

由此可以确认,我们在C/C++当中除零,内存越界等异常,在系统层⾯上,是被当成信号处理的.

📌 注意

通过上⾯的实验,我们可能发现:发现⼀直有8号信号产⽣被我们捕获,这是为什么呢?上⾯我们只提到CPU运算异常后,如何处理后续的流程,实际上操作系统会检查应⽤程序的异常情况,其实在CPU中有⼀些控制和状态寄存器,主要⽤于控制处理器的操作通常由操作系统代码使⽤.状态寄存器可以简单理解为⼀个位图,对应着⼀些状态标记位、溢出标记位.操作系统会检测是否存在异常状态,有异常存在就会调⽤对应的异常处理⽅法.除零异常后,我们并没有清理内存,关闭进程打开的⽂件,切换进程等操作,所以CPU中还保留上下⽂数据以及寄存器内容,除零异常会⼀直存在,就有了我们看到的⼀直发出异常信号的现象.访问⾮法内存其实也是如此,⼤家可以⾃⾏实验.


(3)⼦进程退出core dump

cpp 复制代码
#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;
        int b = 0;
        a /= b;
        exit(0);
    }
    int status = 0;
    waitpid(-1, &status, 0);
    printf("exit signal: %d, core dump: %d\n", status & 0x7F, (status >> 7) & 1);
    return 0;
}

(4)Core Dump

**Core Dump(核心转储)**可以简单理解成:

程序异常死亡时,Linux把它临死前的运行现场保存下来,方便之后用 GDB 分析为什么崩溃.

例如子进程执行:

cpp 复制代码
int *p = NULL;
*p = 100;

大致流程:

text 复制代码
非法访问内存
    ↓
CPU/MMU 产生异常
    ↓
Linux 产生 SIGSEGV
    ↓
进程因 SIGSEGV 异常终止
    ↓
满足条件时生成 Core Dump
    ↓
保存进程"死亡现场"

Core Dump里有什么?

可以把它想象成程序崩溃瞬间的一张"快照",里面通常包含:

text 复制代码
Core Dump
├── 寄存器状态
├── 调用栈
├── 部分/全部进程内存
├── 线程状态
├── 当前执行位置
└── 其他调试信息

所以程序崩溃后,你可以用:

bash 复制代码
gdb ./testsig core

然后查看:

gdb 复制代码
bt

就能看调用栈,定位程序大概死在哪里.

哪些信号常见会产生 Core Dump?

例如:

text 复制代码
SIGSEGV    非法内存访问
SIGFPE     除0等算术异常
SIGABRT    abort()
SIGILL     非法指令
SIGBUS     总线错误

例如前面写的:

cpp 复制代码
volatile int a = 10;
volatile int b = 0;

a /= b;

可能形成:

text 复制代码
除0
 ↓
SIGFPE
 ↓
子进程异常终止
 ↓
Core Dump

父进程怎么知道子进程发生过 Core Dump?

父进程:

cpp 复制代码
int status;
waitpid(pid, &status, 0);

然后:

cpp 复制代码
if (WIFSIGNALED(status))
{
    printf("signal = %d\n", WTERMSIG(status));

#ifdef WCOREDUMP
    printf("core dump = %d\n",
           WCOREDUMP(status) ? 1 : 0);
#endif
}

比如:

text 复制代码
signal = 8
core dump = 1

意思是:

text 复制代码
8
↓
SIGFPE

core dump = 1
↓
该异常终止带有 Core Dump 标志

你之前写的:

cpp 复制代码
status & 0x7F

是在取终止信号编号.

而:

cpp 复制代码
(status >> 7) & 1

是在观察 core dump 标志位.

但 core dump = 1 不等于一定看到 core 文件

这是特别重要的一点.

首先检查:

bash 复制代码
ulimit -c

如果:

text 复制代码
0

表示禁止生成 core 文件.

可以临时设置:

bash 复制代码
ulimit -c unlimited

然后再让程序崩溃.

另外 Linux 还可能通过:

bash 复制代码
cat /proc/sys/kernel/core_pattern

指定 Core Dump 去哪里.

所以有时看到:

text 复制代码
Floating point exception (core dumped)

但当前目录找不到:

text 复制代码
core

并不奇怪.


一句话记:

Signal 告诉你程序为什么死,Core Dump 保存程序死的时候是什么样.

例如:

text 复制代码
SIGSEGV
   ↓
"死因:非法内存访问"

Core Dump
   ↓
"死亡现场:寄存器、调用栈、内存等"

这两个结合起来,就是 Linux 下分析程序崩溃最基本的机制.


3.保存信号

在Linux进程里,"保存信号"通常是指:信号到达后如果暂时不能处理,内核会把它记录为pending(未决信号),相关信息保存在进程/线程的内核数据结构中.


3.1信号其他相关常⻅概念

核心可以分成三部分:

  • 未决信号 pending:已经产生、但还没递送处理的信号.
  • 阻塞信号集 blocked:当前线程暂时不允许递送哪些信号.
  • 信号处理方式 handler/action:收到某个信号后,是默认处理、忽略,还是调用用户注册的处理函数.

在Linux内核中,线程的 task_struct 里有类似这些字段:

c 复制代码
struct task_struct {
    sigset_t blocked;              // 当前阻塞的信号集
    struct sigpending pending;     // 当前线程私有的未决信号
    struct signal_struct *signal;  // 线程组/进程级信号信息
    struct sighand_struct *sighand;// 信号处理函数表
};

其中 sigpending 大致包含:

c 复制代码
struct sigpending {
    struct list_head list;
    sigset_t signal;
};

可以把流程理解为:

text 复制代码
信号产生
   ↓
内核记录信号
   ↓
pending 未决信号集
   ↓
检查 blocked 信号屏蔽字
   ↓
未被阻塞
   ↓
按照 sighand 中注册的处理方式递送

例如进程阻塞了 SIGINT:

c 复制代码
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);

sigprocmask(SIG_BLOCK, &set, NULL);

这时如果收到 SIGINT,它不会立即执行处理函数,而是先被记录为 pending.以后解除阻塞:

c 复制代码
sigprocmask(SIG_UNBLOCK, &set, NULL);

内核再递送该信号.

还有一个重要区别:

普通信号(1~31)通常不排队. 同一种普通信号在未决期间发生很多次,通常只保存"有这个信号"这一状态.

text 复制代码
SIGINT 到达 5 次
→ pending 中通常只记 1 个 SIGINT

实时信号 SIGRTMIN ~ SIGRTMAX 可以排队,因此多次到达可以分别保存.

可以直接记成:

Linux 进程的信号状态主要保存在 task_struct 及其关联的 signal_struct、sighand_struct 中;未决信号放在 pending,阻塞状态放在 blocked,信号处理函数放在 sighand.


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

• 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有⼀个函数指针表示处理动作.信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志.在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作.

• SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达.虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞.

• SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数

sighandler.如果在进程解除对某信号的阻塞之前这种信号产⽣过多次,将如何处理?POSIX.1允许系统递送该信号⼀次或多次.Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,⽽实时信号在递达之前产⽣多次可以依次放在⼀个队列⾥.

cpp 复制代码
// 内核结构 2.6.18
struct task_struct {
...
/* signal handlers */
struct sighand_struct *sighand;
sigset_t blocked
struct sigpending pending;
...
}
struct sighand_struct {
atomic_t count;
struct k_sigaction action[_NSIG]; // #define _NSIG 64
spinlock_t siglock;
};
struct __new_sigaction {
__sighandler_t sa_handler;
unsigned long sa_flags;
void (*sa_restorer)(void); /* Not used by Linux/SPARC */
__new_sigset_t sa_mask;
};
struct k_sigaction {
struct __new_sigaction sa;
void __user *ka_restorer;
};
/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);
struct sigpending {
struct list_head list;
sigset_t signal;
};

这部分代码描述的是 Linux 2.6.18 中进程/线程如何在内核里保存"信号屏蔽、未决信号、信号处理方式".

可以直接对应成:

text 复制代码
task_struct
│
├── blocked          当前线程屏蔽哪些信号
│
├── pending          当前线程有哪些未决信号
│
└── sighand ───────► 每个信号应该怎么处理
                     └── action[64]

1.task_struct

c 复制代码
struct sighand_struct *sighand;
sigset_t blocked;
struct sigpending pending;
  • blocked:信号屏蔽字.某一位为1,表示对应信号暂时不能递送.
  • pending:未决信号.信号已经到达,但还没被处理.
  • sighand:指向信号处理方式表.

例如:

text 复制代码
SIGINT:
blocked = 1
pending = 1

表示:SIGINT 已经来了,但是目前被阻塞,所以先保持未决状态.

2.sighand_struct

c 复制代码
struct sighand_struct {
    atomic_t count;
    struct k_sigaction action[_NSIG];
    spinlock_t siglock;
};

核心是:

c 复制代码
action[64]

可以理解成一张 64 个信号的处理方式表:

text 复制代码
action[1]  → SIGHUP 怎么处理
action[2]  → SIGINT 怎么处理
action[3]  → SIGQUIT 怎么处理
...

其中:

  • count:引用计数,因为线程之间可能共享 sighand_struct
  • siglock:自旋锁,保护信号相关数据的并发访问
  • action[]:保存每个信号的处理规则

3.k_sigaction

c 复制代码
struct k_sigaction {
    struct __new_sigaction sa;
    void __user *ka_restorer;
};

它保存一个信号具体的处理配置.

其中 sa 最重要:

c 复制代码
struct __new_sigaction {
    __sighandler_t sa_handler;
    unsigned long sa_flags;
    void (*sa_restorer)(void);
    __new_sigset_t sa_mask;
};

分别表示:

  • sa_handler:信号处理函数
  • sa_flags:信号处理选项,如 SA_RESTART
  • sa_mask:执行 handler 时,还要临时屏蔽哪些信号
  • sa_restorer:信号处理完以后恢复执行环境相关的辅助入口

4.sa_handler 本质就是函数指针

c 复制代码
typedef void (*__sighandler_t)(int);

等价于:

c 复制代码
void handler(int signo);

所以:

c 复制代码
sa_handler

可能保存:

text 复制代码
SIG_DFL       默认处理
SIG_IGN       忽略信号
handler地址    用户自定义处理函数

5.sigpending

c 复制代码
struct sigpending {
    struct list_head list;
    sigset_t signal;
};

用于表示已经产生但还没处理的信号.

其中:

text 复制代码
signal → 位图,快速判断哪些信号处于 pending
list   → 保存排队的信号信息

尤其是实时信号可以排队,因此不仅需要一个位图,还需要链表保存多个信号实例.

一句话记忆

text 复制代码
blocked  → 能不能处理
pending  → 信号来没来
sighand  → 来了以后怎么处理

所以整个逻辑就是:

text 复制代码
信号到达
   ↓
记录到 pending
   ↓
检查 blocked
   ↓
没被阻塞
   ↓
查 sighand->action[signo]
   ↓
默认 / 忽略 / 执行用户 handler

这正是我前面给的那张图背后对应的内核数据结构.


3.3sigset_t

从上图来看,每个信号只有⼀个bit的未决标志,⾮0即1,不记录该信号产⽣了多少次,阻塞标志也是这样表示的.因此,未决和阻塞标志可以⽤相同的数据类型sigset_t来存储,这个类型可以表示每个信号的"有效"或"⽆效"状态,在阻塞信号集中"有效"和"⽆效"的含义是该信号是否被阻塞,⽽在未决信号集中"有效"和"⽆效"的含义是该信号是否处于未决状态.阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask)这⾥的"屏蔽"应该理解为阻塞⽽不是忽略.(类似权限那⾥的umask)

sigset_t 本质上就是一个 信号位图(bitmap),用一组二进制位表示哪些信号存在于某个集合中.

在Linux 2.6.18中,可以把它概念化为:

c 复制代码
typedef struct {
    unsigned long sig[_NSIG_WORDS];
} sigset_t;

因为:

c 复制代码
#define _NSIG 64

所以它至少需要 64 个 bit 来表示 64 种信号.

一个位对应一个信号

通常是:

text 复制代码
bit 0  → SIGHUP  (1)
bit 1  → SIGINT  (2)
bit 2  → SIGQUIT (3)
bit 3  → SIGILL  (4)
...
bit 63 → 信号 64

例如:

text 复制代码
sigset_t
┌───────────────────────────────┐
│ ... 0 0 0 0 0 1 0           │
└───────────────────────────────┘
                ↑
              bit 1
             SIGINT(2)

这个 1 的具体含义取决于 sigset_t 用在哪里.

例如在:

c 复制代码
struct task_struct {
    sigset_t blocked;
    struct sigpending pending;
};

blocked

c 复制代码
sigset_t blocked;

某一位为 1:

对应信号被阻塞,暂时不能递送.

例如:

text 复制代码
blocked
SIGHUP   SIGINT   SIGQUIT
  0        1        0
           ↑
       SIGINT 被阻塞

pending.signal

c 复制代码
struct sigpending {
    struct list_head list;
    sigset_t signal;
};

这里的:

c 复制代码
sigset_t signal;

某一位为 1:

对应信号已经产生,目前处于未决状态.

例如:

text 复制代码
pending.signal
SIGHUP   SIGINT   SIGQUIT
  0        1        0
           ↑
       SIGINT 已经到达

如果此时:

text 复制代码
blocked SIGINT = 1
pending SIGINT = 1

就意味着:

text 复制代码
SIGINT 已经来了
      ↓
pending 中记为 1
      ↓
但是 blocked 中也是 1
      ↓
暂时不能处理
      ↓
解除阻塞以后再递送

常见操作也都是在操作这个位图:

c 复制代码
sigset_t set;

sigemptyset(&set);       // 全部清 0
sigfillset(&set);        // 全部置 1
sigaddset(&set, SIGINT); // SIGINT 对应位置 1
sigdelset(&set, SIGINT); // SIGINT 对应位清 0
sigismember(&set, SIGINT); // 判断 SIGINT 是否在集合中

所以可以直接记:

`sigset_t = 信号集合的位图表示,一个 bit 对应一个信号.


3.4信号集操作函数

Linux 中常用的 信号集操作函数 主要有这5个,用来操作 sigset_t:

函数 作用
sigemptyset() 清空信号集,所有位设为 0
sigfillset() 填满信号集,所有信号都加入集合
sigaddset() 向信号集中加入一个信号
sigdelset() 从信号集中删除一个信号
sigismember() 判断某个信号是否在集合中

1.sigemptyset

c 复制代码
int sigemptyset(sigset_t *set);

把信号集清空:

c 复制代码
sigset_t set;
sigemptyset(&set);

可以理解为:

text 复制代码
SIGINT  SIGQUIT  SIGTERM ...
  0       0        0

2.sigfillset

c 复制代码
int sigfillset(sigset_t *set);

把所有信号加入集合:

c 复制代码
sigfillset(&set);

相当于:

text 复制代码
SIGINT  SIGQUIT  SIGTERM ...
  1       1        1

3.sigaddset

c 复制代码
int sigaddset(sigset_t *set, int signo);

把指定信号加入信号集:

c 复制代码
sigemptyset(&set);

sigaddset(&set, SIGINT);
sigaddset(&set, SIGQUIT);

结果类似:

text 复制代码
SIGHUP  SIGINT  SIGQUIT
  0       1        1

4.sigdelset

c 复制代码
int sigdelset(sigset_t *set, int signo);

删除某个信号:

c 复制代码
sigdelset(&set, SIGINT);

就是把对应bit清 0.

5.sigismember

c 复制代码
int sigismember(const sigset_t *set, int signo);

判断信号是否在信号集中:

c 复制代码
if (sigismember(&set, SIGINT) == 1) {
    printf("SIGINT 在信号集中\n");
}

返回值:

text 复制代码
1  → 在集合中
0  → 不在集合中
-1 → 出错

一个完整例子

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

int main(void)
{
    sigset_t set;

    sigemptyset(&set);

    sigaddset(&set, SIGINT);
    sigaddset(&set, SIGQUIT);

    printf("SIGINT: %d\n", sigismember(&set, SIGINT));
    printf("SIGTERM: %d\n", sigismember(&set, SIGTERM));

    sigdelset(&set, SIGINT);

    printf("SIGINT: %d\n", sigismember(&set, SIGINT));

    return 0;
}

可以记成:

text 复制代码
sigemptyset  → 全部清 0
sigfillset   → 全部置 1
sigaddset    → 指定位设 1
sigdelset    → 指定位清 0
sigismember  → 检查某个位

注意,这几个函数只是操作 sigset_t 这个信号集合本身,并不会真正改变进程的信号屏蔽状态.

真正设置进程信号屏蔽字,要配合:

c 复制代码
sigprocmask()

或者在线程程序中使用:

c 复制代码
pthread_sigmask()

(1)sigprocmask

sigprocmask() 用来查看或修改当前线程的信号屏蔽字(blocked signal set).

函数原型:

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

int sigprocmask(int how,
                const sigset_t *set,
                sigset_t *oldset);

如果oldset是⾮空指针,则读取进程的当前信号屏蔽字通过oldset参数传出.如果set是⾮空指针,则 更改进程的信 号屏蔽字,参数how指示如何更改.如果oldset和set都是⾮空指针,则先将原来的信号屏蔽字备份到oldset⾥,然后根据set和how参数更改信号屏蔽字.假设当前的信号屏蔽字为mask,下表说明了how参数的可选值.

参数 含义
SIG_BLOCK set 包含了我们希望添加到当前信号屏蔽字的信号,相当于 `mask = mask
SIG_UNBLOCK set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask = mask & ~set
SIG_SETMASK 设置当前信号屏蔽字为 set 所指向的值,相当于 mask = set

如果调⽤sigprocmask解除了对当前若⼲个未决信号的阻塞,则在sigprocmask返回前,⾄少将其中⼀个信号递达.

其中最关键的是 how:

how 含义
SIG_BLOCK 把 set 中的信号加入屏蔽集
SIG_UNBLOCK 把 set 中的信号从屏蔽集中删除
SIG_SETMASK 直接用 set 替换当前屏蔽集

例如屏蔽 SIGINT:

c 复制代码
sigset_t set;

sigemptyset(&set);
sigaddset(&set, SIGINT);

sigprocmask(SIG_BLOCK, &set, NULL);

此时可以理解为:

text 复制代码
blocked:
SIGINT = 1

这时按 Ctrl+C,SIGINT 已经产生,但因为被阻塞:

text 复制代码
SIGINT 到达
   ↓
pending = 1
   ↓
发现 blocked = 1
   ↓
暂时不递送

解除阻塞:

c 复制代码
sigprocmask(SIG_UNBLOCK, &set, NULL);

如果此时 SIGINT 已经处于pending,解除阻塞后就可以被递送.

保存并恢复原来的屏蔽字

oldset 可以保存修改前的状态:

c 复制代码
sigset_t set, oldset;

sigemptyset(&set);
sigaddset(&set, SIGINT);

/* 屏蔽 SIGINT,同时保存原屏蔽字 */
sigprocmask(SIG_BLOCK, &set, &oldset);

/* 临界区代码 */

/* 恢复原屏蔽字 */
sigprocmask(SIG_SETMASK, &oldset, NULL);

这种方式很常见:

text 复制代码
保存 blocked
    ↓
临时屏蔽某些信号
    ↓
执行关键代码
    ↓
恢复 blocked

如果只是想读取当前屏蔽字:

c 复制代码
sigset_t cur;

sigprocmask(SIG_BLOCK, NULL, &cur);

当 set == NULL 时,how 实际上会被忽略,只获取当前屏蔽集.

返回值

text 复制代码
成功 → 0
失败 → -1,并设置 errno

还有两个特殊信号:

text 复制代码
SIGKILL
SIGSTOP

永远不能被阻塞 ,即使把它们放进 set 也不会生效.

一句话记忆:

sigset_t 是"信号集合",而 sigprocmask() 是把这个集合真正应用到当前执行线程的 blocked 信号屏蔽字上.

多线程程序中一般使用 pthread_sigmask() 来操作线程信号屏蔽字.


(2)sigpending

sigpending() 用来获取当前线程处于未决(pending)状态的信号集合.

函数原型:

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

int sigpending(sigset_t *set);

调用后,set 中保存当前的未决信号:

c 复制代码
sigset_t pending;

sigpending(&pending);

然后可以配合 sigismember() 判断某个信号是否未决:

c 复制代码
if (sigismember(&pending, SIGINT)) {
    printf("SIGINT 当前处于 pending 状态\n");
}

例如先阻塞 SIGINT:

c 复制代码
sigset_t mask, pending;

sigemptyset(&mask);
sigaddset(&mask, SIGINT);

sigprocmask(SIG_BLOCK, &mask, NULL);

此时按下 Ctrl+C:

text 复制代码
SIGINT 产生
    ↓
blocked 中 SIGINT = 1
    ↓
不能立即递送
    ↓
进入 pending 状态

再调用:

c 复制代码
sigpending(&pending);

if (sigismember(&pending, SIGINT))
    printf("SIGINT is pending\n");

会检测到 SIGINT.

text 复制代码
sigprocmask()
     ↓
操作 blocked(信号屏蔽字)

sigpending()
     ↓
读取 pending(未决信号集)

sigismember()
     ↓
检查某个信号是否在信号集中

例如:

text 复制代码
             SIGINT
blocked        1      ← 被屏蔽
pending        1      ← 已经产生但还没处理

解除阻塞:

c 复制代码
sigprocmask(SIG_UNBLOCK, &mask, NULL);

之后 SIGINT 会被递送,处理完成后通常就不再处于pending状态.

一句话记忆:

sigprocmask() 管"哪些信号不能处理",sigpending() 查"哪些信号已经来了但还没处理".

下⾯⽤刚学的⼏个函数做个实验.程序如下:

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

void PrintPending(sigset_t &pending)
{
    std::cout << "curr process[" << getpid() << "] pending: ";

    for (int signo = 31; signo >= 1; signo--)
    {
        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(SIGINT, handler);

    // 1. 屏蔽 2 号信号
    sigset_t block_set, old_set;

    sigemptyset(&block_set);
    sigemptyset(&old_set);

    sigaddset(&block_set, SIGINT);

    // 1.1 真正修改当前进程的 blocked 信号集
    sigprocmask(SIG_BLOCK, &block_set, &old_set);

    int cnt = 15;   // 注意:必须单独写出来,不能放在 // 后面

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

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

        cnt--;

        // 4. 解除对 2 号信号的屏蔽
        if (cnt == 0)
        {
            std::cout << "解除对2号信号的屏蔽!!!" << std::endl;

            // 恢复之前的信号屏蔽字
            sigprocmask(SIG_SETMASK, &old_set, nullptr);
        }

        sleep(1);
    }

    return 0;
}

程序运⾏时,每秒钟把各信号的未决状态打印⼀遍,由于我们阻塞了SIGINT信号,按Ctrl-C将会 使SIGINT信号处于未决状态,按Ctrl-\仍然可以终⽌程序,因为SIGQUIT信号没有阻塞.


4.捕捉信号

"捕捉信号"就是:当某个信号递达进程时,不执行默认动作,而是调用你自己注册的处理函数.

最常见有两种方式:

1.signal():简单写法

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

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

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

    while (true)
    {
        std::cout << "pid: " << getpid() << std::endl;
        sleep(1);
    }

    return 0;
}

运行后按:

text 复制代码
Ctrl + C

终端会产生:

text 复制代码
SIGINT

由于你注册了:

cpp 复制代码
signal(SIGINT, handler);

所以不会直接终止进程,而是执行:

cpp 复制代码
handler(SIGINT);

2.signal() 的第二个参数

函数原型:

cpp 复制代码
__sighandler_t signal(int signum, __sighandler_t handler);

第二个参数有三种典型情况:

cpp 复制代码
signal(SIGINT, handler);   // 自定义捕捉
signal(SIGINT, SIG_IGN);   // 忽略
signal(SIGINT, SIG_DFL);   // 默认处理

可以理解成:

设置 含义
handler 捕捉信号,执行自定义函数
SIG_IGN 忽略信号
SIG_DFL 使用系统默认动作

内核中是怎么实现的

结合前面学的 task_struct:

text 复制代码
task_struct
   |
   └── sighand
          |
          └── action[64]
                |
                ├── SIGINT  → handler
                ├── SIGQUIT → SIG_DFL
                └── ...

例如:

cpp 复制代码
signal(SIGINT, handler);

本质上就是把 SIGINT 对应的处理动作改成:

text 复制代码
SIGINT
  ↓
sighand->action[SIGINT]
  ↓
handler

当信号递达时:

text 复制代码
SIGINT 产生
    ↓
pending 中记录
    ↓
检查 blocked
    ↓
没有被阻塞
    ↓
查看 handler
    ↓
发现是用户自定义函数
    ↓
执行 handler(SIGINT)

更推荐:sigaction()

实际 Linux 编程中,更推荐 sigaction():

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

void handler(int signo)
{
}

int main()
{
    struct sigaction act;

    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    act.sa_flags = 0;

    sigaction(SIGINT, &act, nullptr);

    while (true)
        pause();
}

它比 signal() 更完整,因为可以控制:

text 复制代码
sa_handler   → 信号处理函数
sa_mask      → 执行 handler 时额外屏蔽哪些信号
sa_flags     → 信号处理选项

信号"捕捉"和信号"屏蔽"不是一回事:

text 复制代码
signal / sigaction
        ↓
决定:信号来了以后怎么处理

sigprocmask
        ↓
决定:信号现在能不能递达

例如:

cpp 复制代码
signal(SIGINT, handler);

同时又把 SIGINT 屏蔽:

cpp 复制代码
sigprocmask(SIG_BLOCK, ...);

那么即使已经注册了 handler,SIGINT 也不会立刻执行处理函数,而是先进入 pending.

解除屏蔽后:

text 复制代码
pending SIGINT
    ↓
解除 blocked
    ↓
递达
    ↓
handler(SIGINT)

补充一点:SIGKILL 和 SIGSTOP 不能被捕捉、不能被忽略、也不能被屏蔽.


4.1信号捕捉的流程

如果信号的处理动作是⽤户⾃定义函数,在信号递达时就调⽤这个函数,这称为捕捉信号.

由于信号处理函数的代码是在⽤⼾空间的,处理过程⽐较复杂,举例如下:

• ⽤户程序注册了SIGQUIT 信号的处理函数 sighandler .

• 当前正在执⾏ main 函数,这时发⽣中断或异常切换到内核态.

• 在中断处理完毕后要返回⽤⼾态的main函数之前检查到有信号SIGQUIT递达.

• 内核决定返回⽤⼾态后不是恢复main函数的上下⽂继续执⾏,⽽是执⾏ sighandler函数, sighandler和main函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个独⽴的控制流程.

• sighandler函数返回后⾃动执⾏特殊的系统调⽤sigreturn再次进⼊内核态.

• 如果没有新的信号要递达,这次再返回⽤户态就是恢复main函数的上下⽂继续执⾏了.


4.2sigaction

sigaction() 是 Linux 中更推荐的信号捕捉/设置信号处理方式 的接口,比 signal() 更稳定、功能更完整.

函数原型:

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

int sigaction(int signum,
              const struct sigaction *act,
              struct sigaction *oldact);

• sigaction函数可以读取和修改与指定信号相关联的处理动作.调⽤成功则返回0,出错则返回- 1.signo是指定信号的编号.若act指针⾮空,则根据act修改该信号的处理动作.若oldact指针⾮空, 则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体.

• 将sa_handler赋值为常数SIG_IGN传给sigaction表⽰忽略信号,赋值为常数SIG_DFL表⽰执⾏系统默认动作,赋值为⼀个函数指针表⽰⽤⾃定义函数捕捉信号,或者说向内核注册了⼀个信号处理函数,该函数返回值为void,可以带⼀个int参数,通过参数可以得知当前信号的编号,这样就可以⽤同⼀个函数处理多种信号.显然,这也是⼀个回调函数,不是被main函数调⽤,⽽是被系统所调⽤.

其中:

  • signum:要设置的信号,比如 SIGINT.
  • act:新的处理方式.
  • oldact:保存原来的处理方式,可为 NULL.

最核心的是 struct sigaction:

c 复制代码
struct sigaction {
    void (*sa_handler)(int);
    void (*sa_sigaction)(int, siginfo_t *, void *);
    sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
};

现阶段重点记这 3 个:

text 复制代码
sa_handler → 信号处理函数
sa_mask    → 执行 handler 时额外屏蔽哪些信号
sa_flags   → 控制信号处理行为

例如捕捉 SIGINT:

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

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

int main()
{
    struct sigaction act;

    act.sa_handler = handler;

    // handler 执行期间,不额外屏蔽其他信号
    sigemptyset(&act.sa_mask);

    act.sa_flags = 0;

    // 捕捉 SIGINT
    sigaction(SIGINT, &act, nullptr);

    while (true)
    {
        sleep(1);
    }

    return 0;
}

可以理解为:

text 复制代码
sigaction(SIGINT, &act, NULL)
            ↓
内核中的 sighand
            ↓
action[SIGINT]
            ↓
sa_handler = handler

以后 SIGINT 递达时:

text 复制代码
SIGINT 产生
   ↓
检查 pending / blocked
   ↓
允许递达
   ↓
查看 action[SIGINT]
   ↓
发现 sa_handler = handler
   ↓
执行 handler(SIGINT)

sa_mask 很重要

比如:

c 复制代码
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGQUIT);

表示:

当 handler() 正在执行时,临时把 SIGQUIT 屏蔽掉.

另外,正在处理的那个信号本身默认也会被临时阻塞.

例如处理:

text 复制代码
SIGINT

期间又来一个 SIGINT,默认不会立即嵌套再次执行 handler(),而是先保持pending,等当前handler 返回.

常见 sa_flags

常见的有:

标志 含义
0 默认行为
SA_RESTART 某些被信号中断的系统调用自动重启
SA_SIGINFO 使用 sa_sigaction,获取更多信号信息
SA_NODEFER handler 执行时,不自动屏蔽当前信号
SA_RESETHAND 信号处理一次后恢复默认动作

例如:

c 复制代码
act.sa_flags = SA_RESTART;

保存旧处理方式

c 复制代码
struct sigaction oldact;

sigaction(SIGINT, &act, &oldact);

这样 oldact 就保存修改前的处理方式.

恢复:

c 复制代码
sigaction(SIGINT, &oldact, NULL);

和 signal() 的关系

简单理解:

c 复制代码
signal(SIGINT, handler);

相当于"简单版"的:

c 复制代码
sigaction(SIGINT, ...);

但实际 Linux 编程更推荐:

c 复制代码
sigaction()

因为它可以明确控制:

text 复制代码
处理函数
+
handler 执行期间的屏蔽字
+
各种处理选项

一句话记忆:

signal() 主要解决"谁来处理信号",而 sigaction() 可以完整描述"怎么处理这个信号".


4.3操作系统是怎么运⾏的

可以把操作系统理解成:CPU 一直在执行代码,而操作系统负责在"用户程序"和"内核代码"之间不断切换,并管理 CPU、内存、设备和进程.

一个最核心的运行过程是:

text 复制代码
开机
 ↓
CPU 执行 BIOS / UEFI
 ↓
加载 BootLoader
 ↓
加载操作系统内核
 ↓
内核初始化
 ↓
启动第一个用户进程
 ↓
不断运行用户程序
 ↓
遇到中断 / 异常 / 系统调用
 ↓
进入内核
 ↓
内核处理
 ↓
返回用户态
 ↓
继续执行

(1)硬件中断

硬件中断:外部设备通知 CPU

硬件中断是由 CPU 外部的硬件设备产生的.

例如:

text 复制代码
键盘按键
网卡收到数据
磁盘 I/O 完成
鼠标移动
定时器到期

假设 CPU 正在执行用户程序:

text 复制代码
用户程序正在运行
      ↓
键盘产生中断
      ↓
CPU 暂停当前程序
      ↓
保存当前执行现场
      ↓
进入内核态
      ↓
执行键盘中断处理程序
      ↓
恢复现场
      ↓
继续原来的程序

核心特点:

硬件中断是异步的.

也就是说,程序并不知道中断什么时候到来.

比如:

cpp 复制代码
int a = 10;
int b = 20;   // 可能执行到这里时网卡突然产生中断
int c = a+b;

CPU 可以在正常指令执行过程中被硬件中断打断.

cpp 复制代码
//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); /* int3-5 can be called from all */
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);
// 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱
⻔。
for (i=17;i<48;i++)
set_trap_gate(i,&reserved);
set_trap_gate(45,&irq13);// 设置协处理器的陷阱⻔。
outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。
outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。
set_trap_gate(39,&parallel_interrupt);// 设置并⾏⼝的陷阱⻔。
}
void
rs_init (void)
{
set_intr_gate (0x24, rs1_interrupt); // 设置串⾏⼝1 的中断⻔向量(硬件IRQ4
信号)。
set_intr_gate (0x23, rs2_interrupt); // 设置串⾏⼝2 的中断⻔向量(硬件IRQ3
信号)。
init (tty_table[1].read_q.data); // 初始化串⾏⼝1(.data 是端⼝号)。
init (tty_table[2].read_q.data); // 初始化串⾏⼝2。
outb (inb_p (0x21) & 0xE7, 0x21); // 允许主8259A 芯⽚的IRQ3,IRQ4 中断信号
请求。
}

(2)时钟中断

时钟中断本质上也是一种硬件中断.

它是由硬件定时器周期性产生的,比如:

text 复制代码
程序运行
 ↓
经过一小段时间
 ↓
Timer 产生中断
 ↓
CPU 进入内核

它对操作系统特别重要,因为操作系统要靠它进行:

text 复制代码
进程调度
时间统计
定时器管理
超时处理

例如CPU正在运行进程 A:

text 复制代码
进程 A
   ↓
运行一段时间
   ↓
时钟中断
   ↓
进入内核
   ↓
调度器 scheduler
   ↓
发现 A 时间片用完
   ↓
切换到进程 B

也就是:

text 复制代码
A A A A A
        ↓ 时钟中断
       Kernel
        ↓
B B B B B

所以时钟中断让操作系统实现了:

抢占式多任务.

cpp 复制代码
// Linux 内核0.11
// main.c
sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr) (kernel/sched.c)
// 调度程序的初始化⼦程序。
void sched_init(void)
{
...
set_intr_gate(0x20, &timer_interrupt);
// 修改中断控制器屏蔽码,允许时钟中断。
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调⽤中断⻔。
set_system_gate(0x80, &system_call);
...
}
// system_call.s
_timer_interrupt:
...
;// do_timer(CPL)执⾏任务切换、计时等⼯作,在kernel/shched.c,305 ⾏实现。
call _do_timer ;// 'do_timer(long CPL)' does everything from
// 调度⼊⼝
void do_timer(long cpl)
{
...
schedule();
}
void schedule(void)
{
...
switch_to(next); // 切换到任务号为next 的任务,并运⾏之。
}

(3)死循环

这是理解时钟中断最经典的问题.

假设程序:

cpp 复制代码
int main()
{
    while (1)
    {
    }
}

从程序自身来看:

text 复制代码
while → while → while → while → ...

它:

  • 不调用系统调用
  • 不主动退出
  • 不主动让出 CPU

那操作系统怎么重新获得 CPU?

答案就是:

时钟中断。

即使程序执行:

cpp 复制代码
while (1);

硬件定时器仍然独立工作:

text 复制代码
用户进程:

while
while
while
while
   ↓
=============== 时钟中断 ===============
   ↓
CPU 强制进入内核
   ↓
操作系统调度
   ↓
切换到其他进程

所以死循环并不能阻止操作系统运行.

可以把 CPU 想成一个正在看书的人:

text 复制代码
进程:
"你一直读我的书,永远不要停!"

CPU:
"好的......"

但是旁边有个闹钟:

叮!

CPU:
"我要先去处理操作系统的事情。"

这个"闹钟"就是 时钟中断.

因此:

即使用户进程陷入死循环,操作系统仍然可以通过时钟中断强制拿回 CPU.

不过死循环会消耗大量 CPU 时间:

text 复制代码
while(1)
   ↓
获得时间片
   ↓
一直执行
   ↓
时间片用完
   ↓
被调度出去
   ↓
以后再次被调度
   ↓
继续死循环

所以你看到:

text 复制代码
CPU 使用率很高

但其他进程通常仍然能运行.

cpp 复制代码
void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/
for (;;)
pause();
} // end main

(4)软中断

这里需要区分两个容易混淆的概念.

第一种:广义"软件中断"

操作系统课程里经常把由软件主动触发进入内核的机制称为软件中断.

例如用户程序调用:

cpp 复制代码
write();

程序需要操作系统提供服务:

text 复制代码
用户程序
   ↓
系统调用
   ↓
通过特定 CPU 指令进入内核
   ↓
内核执行系统调用
   ↓
返回用户程序

传统 x86 上曾经常见:

asm 复制代码
int 0x80

例如:

text 复制代码
用户程序
   ↓
int 0x80
   ↓
CPU 切换到内核态
   ↓
执行系统调用

这种是:

软件主动触发的同步事件.

和硬件中断相比:

text 复制代码
硬件中断:外部硬件突然打断 CPU

软件中断:程序执行某条特殊指令主动进入内核

Linux 内核里的 softirq 又是另一回事

Linux 内核里还有一个专门概念叫:

text 复制代码
softirq
软中断

它主要用于:

把硬件中断中不需要立即完成的工作,延迟到稍后执行.

比如网卡收到数据.

如果硬件中断处理函数一次把所有工作做完:

text 复制代码
网卡中断
 ↓
解析数据
 ↓
处理协议
 ↓
处理 TCP
 ↓
处理 IP
 ↓
交给 socket
 ↓
...

中断处理时间会非常长.

因此 Linux 会拆成:

text 复制代码
网卡硬件中断
      ↓
快速处理紧急工作
      ↓
安排 softirq
      ↓
退出硬件中断
      ↓
稍后执行软中断
      ↓
继续处理网络数据

简单理解:

text 复制代码
硬中断:
"有事情来了!赶快先处理最重要的。"

软中断:
"剩下的工作稍后处理。"

所以在 Linux 内核语境下:

text 复制代码
硬件中断 IRQ
     ↓
上半部:快速处理
     ↓
softirq
     ↓
下半部:延迟处理

网络处理中大量使用软中断机制.

四者放在一起

可以形成这样一张关系图:

text 复制代码
                     CPU 正在执行用户程序
                              │
          ┌───────────────────┼───────────────────┐
          │                   │                   │
      外部设备             定时器               程序主动请求
          │                   │                   │
          ▼                   ▼                   ▼
      硬件中断             时钟中断           软件触发
          │                   │                   │
          └──────────────┬────┴───────────────┘
                         ▼
                       内核态
                         │
                 操作系统进行处理
                         │
             ┌───────────┴────────────┐
             │                        │
          返回原程序               进程调度
                                      │
                                      ▼
                                   其他进程

而死循环:

text 复制代码
while(1)
   │
   │ 无法主动进入内核
   │
   ▼
一直占 CPU
   │
   │ 但是
   ▼
时钟中断到来
   ↓
强制进入内核
   ↓
调度其他进程

最后用一句话区分

概念 谁触发 主要作用
硬件中断 外部硬件 通知 CPU 发生硬件事件
时钟中断 硬件定时器 周期性让 OS 获得 CPU,支持调度
死循环 用户程序自身 一直运行,但仍会被时钟中断打断
软中断 软件/内核机制 软件触发进入内核,或 Linux 中用于延迟处理中断工作

最值得记住的是这条:

用户程序即使死循环,也不能真正"控制住"CPU,因为硬件时钟会周期性产生中断,把 CPU 强制交还给操作系统,操作系统再决定下一个运行谁.


(5)缺⻚中断、内存碎⽚处理以及除零野指针错误

缺页、除零、野指针通常属于"异常(Exception)"这一类;内存碎片则不是中断或异常,而是内存管理问题.

情况 本质 是否进入内核 常见结果
缺页 CPU 异常 / Page Fault ✅ 内核补页后继续,或产生 SIGSEGV
内存碎片 内存管理问题 不一定 分配失败、内存整理等
除零 CPU 异常 ✅ 通常向进程发送 SIGFPE
野指针 常导致缺页异常 ✅ 通常 SIGSEGV,但不一定

1.缺页异常 Page Fault

比如程序访问:

c 复制代码
int x = *p;

CPU 根据虚拟地址查页表时发现:

text 复制代码
虚拟地址
   ↓
查页表
   ↓
这个页当前不存在 / 权限不允许
   ↓
CPU 产生 Page Fault
   ↓
进入 Linux 内核

这里有两种完全不同的结果.

如果地址合法,只是页面暂时没装进物理内存:

text 复制代码
缺页
 ↓
内核分配物理页
 ↓
从磁盘读取 / 建立映射
 ↓
更新页表
 ↓
重新执行刚才那条指令

程序甚至不知道发生过缺页.

例如:

c 复制代码
int *p = malloc(4096);
*p = 100;

某些情况下 malloc() 只是建立了虚拟地址空间,第一次真正访问页面时才发生缺页,由内核分配物理页.

如果地址根本不合法:

c 复制代码
int *p = NULL;
*p = 100;

则:

text 复制代码
CPU Page Fault
      ↓
进入内核
      ↓
内核检查地址
      ↓
发现地址非法
      ↓
给进程发送 SIGSEGV
      ↓
进程终止

所以:

Page Fault 本身并不一定是错误.它是虚拟内存正常工作的核心机制之一.

2.内存碎片不是"中断"

内存碎片分两种.

内部碎片:分给你的空间比你实际需要的大.

例如:

text 复制代码
申请 6 KB
实际按 8 KB 分配

用了:6 KB
浪费:2 KB

这 2 KB 就类似内部碎片.

外部碎片:空闲内存很多,但不连续:

text 复制代码
已用 | 空闲 | 已用 | 空闲 | 已用 | 空闲
       2M           3M           1M

总共有:

text 复制代码
6 MB 空闲

但如果需要连续的:

text 复制代码
5 MB

可能找不到.

Linux 会通过很多机制处理,例如:

text 复制代码
伙伴系统 Buddy System
页分配
内存回收
页面换出
内存压缩 compaction
SLAB / SLUB

所以:

内存碎片属于操作系统的内存管理问题,不属于硬件中断、软中断或 CPU 异常.

3.除零错误

例如:

c 复制代码
int a = 10;
int b = 0;

int c = a / b;

CPU 执行除法指令时直接发现:

text 复制代码
除数 = 0

CPU 不能继续正常执行,于是产生异常:

text 复制代码
用户程序
   ↓
执行除法
   ↓
发现除 0
   ↓
CPU 产生异常
   ↓
进入内核
   ↓
Linux 处理异常
   ↓
向当前进程发送 SIGFPE

所以你可能看到:

text 复制代码
Floating point exception

注意,名字虽然叫 SIGFPE(Floating Point Exception),整数除零也通常会产生这个信号.

它属于:

同步异常.

因为就是当前这条指令直接导致的.

4.野指针错误

例如:

c 复制代码
int *p = (int *)0x12345678;
*p = 100;

如果这个地址没有合法映射:

text 复制代码
CPU 访问地址
   ↓
查页表
   ↓
发现地址非法
   ↓
Page Fault
   ↓
进入内核
   ↓
内核判断无法修复
   ↓
SIGSEGV

最终经常看到:

text 复制代码
Segmentation fault

还有一个很重要的细节:

野指针不一定立即报错.

比如:

c 复制代码
int *p;
*p = 100;

如果 p 碰巧指向一个当前进程有权限访问的合法地址,那么CPU不会产生异常.

结果可能是:

text 复制代码
没有崩溃
 ↓
却修改了不该修改的数据
 ↓
程序之后莫名其妙出错

这也是野指针危险的地方.

把它们放到"中断 / 异常"体系里

可以整理成:

text 复制代码
CPU 从正常程序进入内核
          │
    ┌─────┼──────────┐
    │     │          │
硬件中断  异常      系统调用
    │     │          │
键盘     缺页       read()
网卡     除零       write()
时钟     非法访问   fork()
磁盘     野指针     ...

其中:

text 复制代码
硬件中断
→ 外部设备产生
→ 异步

异常
→ 当前 CPU 指令执行导致
→ 同步

系统调用
→ 程序主动请求操作系统服务
→ 同步

所以这几个例子最适合这样记:

text 复制代码
缺页
  → CPU 异常
  → 可能正常修复,也可能 SIGSEGV

除零
  → CPU 异常
  → 通常 SIGFPE

野指针
  → 经常导致缺页异常
  → 通常 SIGSEGV

内存碎片
  → 内存管理问题
  → 不是中断/异常

一句话总结:

异常是 CPU 执行当前指令时"出了特殊情况";缺页、除零、非法地址访问都属于这一范畴,而内存碎片是操作系统管理内存时产生的问题.


4.4理解内核态和⽤户态


5.可重⼊函数

可重入函数(Reentrant Function):一个函数在执行过程中被中断后,尚未执行完,又被再次调用;第二次调用不会破坏第一次调用的执行状态,两个调用都能得到正确结果.

结合前面的信号场景,可以这样理解:

text 复制代码
main()
  ↓
调用 func()
  ↓
func 执行到一半
  ↓
SIGINT 到达
  ↓
进入 handler()
  ↓
handler 又调用 func()
  ↓
第二次 func 执行完成
  ↓
返回第一次 func
  ↓
继续执行

如果最终仍然正确,那么 func() 就具备可重入性.

不可重入的典型原因

比如:

c 复制代码
int count = 0;

void func()
{
    count++;
    // ...
    count--;
}

这里使用了全局变量 count.假设第一次执行:

text 复制代码
count = 0
   ↓
第一次 func
count = 1
   ↓
发生信号
   ↓
handler 再次调用 func
count = 2
   ↓
...

两个调用操作同一个全局变量,容易造成状态混乱,所以这种函数通常不可重入.

之前的链表例子也是一样:

c 复制代码
node_t *head;

void insert(node_t *p)
{
    p->next = head;   // ①
                       // 此时发生中断
    head = p;         // ④
}

如果在①和④之间发生信号,中断处理函数又调用:

c 复制代码
insert(&node2);

可能出现:

text 复制代码
第一次:
node1->next = head

        ↓ 中断

第二次:
node2->next = head
head = node2

        ↓ 返回

第一次继续:
head = node1

最终可能:

text 复制代码
head → node1 → 原链表

node2 → 原链表

node2 从链表入口丢失了.

这就是不可重入导致共享状态被破坏.

可重入函数通常需要满足什么?

核心原则:

一次调用不能依赖或修改其他调用共享的可变状态。

所以通常应做到:

text 复制代码
尽量使用
✅ 局部变量
✅ 函数参数
✅ 调用者提供的内存

尽量避免
❌ 修改全局变量
❌ 修改 static 局部变量
❌ 依赖共享缓冲区
❌ 在没有保护的情况下操作共享数据

例如:

c 复制代码
int add(int a, int b)
{
    int sum = a + b;
    return sum;
}

这个函数只有参数和局部变量:

text 复制代码
第一次调用:自己的栈帧
第二次调用:自己的栈帧

互不影响,因此天然具有很好的可重入性.

一个很形象的理解

不可重入:

text 复制代码
两个人共用一张草稿纸

第一次调用写到一半
        ↓
第二次调用把草稿改了
        ↓
第一次回来继续算
        ↓
数据乱了

可重入:

text 复制代码
第一次调用 → 自己一张草稿纸
第二次调用 → 自己一张草稿纸

互不影响

这里的"草稿纸"就可以理解为:

text 复制代码
函数栈帧 + 局部变量

一句话记忆

可重入函数:执行到一半被打断,再次调用自己,也不会把第一次调用的数据搞乱。

最典型的特征就是:

text 复制代码
少用共享状态
多用参数和局部变量
每次调用拥有独立执行现场

6.volatile

volatile 的核心作用是:

告诉编译器:这个变量的值可能在当前代码看不到的地方被修改,所以每次使用时都要真正去内存读取,不能随意把它长期缓存到寄存器里。

1.为什么信号场景需要 volatile

例如:

c 复制代码
int flag = 0;

void handler(int signo)
{
    flag = 1;
}

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

    while (flag == 0)
    {
    }

    printf("收到信号\n");
}

看起来没问题,但编译器优化后可能认为:

text 复制代码
main() 里面没人修改 flag
        ↓
只读取一次 flag
        ↓
发现 flag == 0
        ↓
一直循环

甚至可能近似优化成:

c 复制代码
if (flag == 0)
{
    while (1)
    {
    }
}

但是实际上:

text 复制代码
main 正在执行
    ↓
SIGINT 到达
    ↓
handler 被异步调用
    ↓
flag = 1

编译器未必能从普通控制流中推断到这一点.

因此:

c 复制代码
volatile int flag = 0;

表示:

flag 随时可能发生变化,每次判断都重新读取.

2.信号处理中更推荐这样写

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

volatile sig_atomic_t flag = 0;

void handler(int signo)
{
    flag = 1;
}

为什么不只是:

c 复制代码
volatile int flag;

而是:

c 复制代码
volatile sig_atomic_t flag;

因为 sig_atomic_t 是专门为这种场景设计的类型:

对它进行简单读写时,不会在中间被信号打断导致只修改了一半.

所以:

text 复制代码
volatile
   ↓
防止编译器把访问优化掉

sig_atomic_t
   ↓
保证信号场景下简单读写具有原子性

通常组合使用:

c 复制代码
volatile sig_atomic_t flag;

3.volatile 本身不保证原子性

这是最重要的误区之一.

例如:

c 复制代码
volatile int count = 0;

count++;

并不是一个不可分割的操作.

实际上大概是:

text 复制代码
读取 count
   ↓
+1
   ↓
写回 count

如果中间发生并发访问:

text 复制代码
线程 A:读 count = 10
线程 B:读 count = 10
线程 A:写 11
线程 B:写 11

本来执行两次 ++:

text 复制代码
期待:12
实际:11

所以:

volatile ≠ 原子操作。

4.volatile 也不保证线程安全

例如:

c 复制代码
volatile int value = 0;

多个线程:

c 复制代码
value++;

依然可能产生数据竞争.

所以多线程同步要使用:

cpp 复制代码
std::atomic<int>

或者:

text 复制代码
mutex
pthread_mutex_t

而不是依赖 volatile.

5.volatile 常见用途

信号处理

c 复制代码
volatile sig_atomic_t quit = 0;

内存映射硬件寄存器

例如嵌入式:

c 复制代码
volatile unsigned int *reg =
    (volatile unsigned int *)0x12340000;

因为硬件可能直接修改这个地址.

某些底层系统代码

值可能被:

text 复制代码
硬件
中断
特殊运行环境

异步改变.

6.普通变量 vs volatile

普通变量:

c 复制代码
int flag;

编译器可以:

text 复制代码
内存 flag
   ↓ 读取一次
CPU 寄存器
   ↓
以后一直用寄存器里的值

volatile:

c 复制代码
volatile int flag;

要求可观察的 volatile 访问不能被随意消掉或合并:

text 复制代码
每次使用
   ↓
重新访问 flag
c 复制代码
volatile sig_atomic_t flag = 0;

这是信号处理函数和正常执行流程之间传递简单状态标志的经典写法.

text 复制代码
handler
   │
   │ flag = 1
   ↓
volatile sig_atomic_t flag
   ↑
   │ 每次重新读取
   │
main

一句话总结:

volatile 防编译器错误优化,sig_atomic_t 保证简单读写的信号安全性;volatile 本身既不保证原子性,也不保证线程安全。


7.SIGCHLD信号

SIGCHLD 是 子进程状态发生变化时,内核发送给父进程的信号 。最常见的用途是:通知父进程"子进程退出了,赶紧来回收"。

1.什么时候产生 SIGCHLD

例如:

cpp 复制代码
pid_t id = fork();

if (id == 0)
{
    // 子进程
    sleep(3);
    exit(0);
}
else
{
    // 父进程
    ...
}

子进程退出时:

text 复制代码
子进程 exit()
      ↓
内核保存子进程退出信息
      ↓
子进程进入 Zombie(僵尸)状态
      ↓
向父进程发送 SIGCHLD
      ↓
父进程 wait / waitpid
      ↓
回收子进程资源

除了终止,子进程停止或恢复执行 等状态变化也可能产生 SIGCHLD.

2.为什么需要 SIGCHLD

如果父进程一直不调用:

cpp 复制代码
wait();

或者:

cpp 复制代码
waitpid();

那么已经退出的子进程会留下少量内核信息:

text 复制代码
PID
退出码
资源使用情况
...

形成:

text 复制代码
Zombie Process
僵尸进程

所以 SIGCHLD 可以理解成子进程对父进程说:

"我退出了,我的退出信息还在内核里,请把我回收掉。"

3.捕捉 SIGCHLD

简单示例:

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

void handler(int signo)
{
    wait(nullptr);
}

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

    pid_t id = fork();

    if (id == 0)
    {
        sleep(3);
        _exit(0);
    }

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

流程:

text 复制代码
父进程
   │
   ├──── fork() ────→ 子进程
   │                    │
   │                    │ exit
   │                    ↓
   │                 SIGCHLD
   │                    │
   ↓                    │
handler(SIGCHLD) ←──────┘
   ↓
wait()
   ↓
回收子进程

4.更正确的处理方式:waitpid()

实际代码通常不能只:

cpp 复制代码
wait(nullptr);

因为多个子进程可能几乎同时退出,而普通信号不会为每个实例都单独排队。

所以推荐:

cpp 复制代码
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <errno.h>

void handler(int signo)
{
    int old_errno = errno;

    while (waitpid(-1, nullptr, WNOHANG) > 0)
    {
        // 一次尽可能回收所有已经退出的子进程
    }

    errno = old_errno;
}

注册:

cpp 复制代码
struct sigaction act = {};

act.sa_handler = handler;
sigemptyset(&act.sa_mask);
act.sa_flags = SA_RESTART;

sigaction(SIGCHLD, &act, nullptr);

这里:

cpp 复制代码
waitpid(-1, nullptr, WNOHANG);

含义是:

text 复制代码
-1
→ 等待任意子进程

WNOHANG
→ 如果当前没有已经退出的子进程,立即返回,不阻塞

所以:

text 复制代码
SIGCHLD 到达
    ↓
handler
    ↓
waitpid(-1, ..., WNOHANG)
    ↓
回收一个
    ↓
继续 waitpid
    ↓
回收第二个
    ↓
继续...
    ↓
没有可回收子进程
    ↓
返回

5.为什么一定要 while

假设有三个子进程:

text 复制代码
child1 ─→ exit
child2 ─→ exit
child3 ─→ exit

它们很快连续退出.

不能简单认为:

text 复制代码
3 个子进程退出
=
一定收到 3 次 SIGCHLD

普通信号可能发生合并。

所以正确思想是:

收到一次 SIGCHLD 后,不是只回收一个,而是把当前所有已经退出的子进程都回收。

因此:

cpp 复制代码
while (waitpid(-1, NULL, WNOHANG) > 0)
{
}

是经典写法.

6.SIGCHLD 和僵尸进程的关系

可以这样记:

text 复制代码
子进程退出
   ↓
变成僵尸
   ↓
产生 SIGCHLD
   ↓
父进程调用 wait/waitpid
   ↓
彻底回收

注意一个很容易混淆的点:

SIGCHLD 的默认动作虽然是"忽略",但默认情况下并不代表自动回收子进程。

也就是说:

cpp 复制代码
// 什么都不设置

父进程仍然通常需要:

cpp 复制代码
wait();
waitpid();

否则可能产生僵尸进程。

但是如果父进程显式设置:

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

在 Linux/POSIX 语义下,子进程退出后通常会被自动回收,不留下僵尸.

还可以通过:

cpp 复制代码
SA_NOCLDWAIT

实现类似效果.

7.SA_NOCLDSTOP

默认情况下,子进程被停止等状态变化也可能通知父进程.

如果父进程只关心子进程退出:

cpp 复制代码
act.sa_flags = SA_RESTART | SA_NOCLDSTOP;

其中:

text 复制代码
SA_NOCLDSTOP
    ↓
子进程 stop / continue 时
不因为停止状态通知父进程

一句话记忆:

SIGCHLD = 子进程状态变化通知;父进程最典型的处理就是使用 wait/waitpid 回收退出的子进程,避免僵尸进程。


8.拓展阅读

8.1用户态和内核态

用户态(User Mode)和内核态(Kernel Mode)本质上是CPU 的两种权限级别.

  • 用户态:普通应用程序运行的状态,权限低,不能直接操作硬件和关键内核资源.
  • 内核态:操作系统内核运行的状态,权限最高,可以访问硬件、内存、设备、进程等系统资源.

CPU指令集 :是CPU实现软件指挥硬件执⾏的媒介,具体来说每⼀条汇编语句都对应了⼀条

CPU 指令,⽽⾮常⾮常多的CPU指令在⼀起,可以组成⼀个、甚⾄多个集合,指令的集合叫CPU 指令集.

CPU指令集有权限分级,⼤家试想,CPU指令集可以直接操作硬件的,要是因为指令操作的不规范,造成的错误会影响整个计算机系统的.好⽐你写程序,因为对硬件操作不熟悉,导致操作系统内核、及其他所有正在运⾏的程序,都可能会因为操作失误⽽受到不可挽回的错误,最后只能重启计算机才⾏.对开发⼈员来说是个艰巨的任务,还会增加负担,同时开发⼈员在这⽅⾯也不被信任,所以操作系统内核直接屏蔽开发⼈员对硬件操作的可能,都不让你碰到这些CPU指令集.

针对上⾯的需求,硬件设备商直接提供硬件级别的⽀持,做法就是对CPU指令集设置了权限,不同级别权限能使⽤的CPU指令集是有限的,以Inter CPU为例,Inter把CPU指令集操作的权限由

⾼到低划为4级:

  • ring 0:权限最⾼,可以使⽤所有 CPU 指令集
  • ring 1
  • ring 2
  • ring 3:权限最低,仅能使⽤常规CPU指令集,不能使⽤操作硬件资源的CPU指令集,⽐如 IO 读写、⽹卡访问、申请内存都不⾏.

要知道的是,Linux系统仅采⽤ring 0 和 ring 3 这2个权限.CPU中有⼀个标志字段,标志着线程的运⾏状态,⽤户态为3,内核态为0.

ring 0被叫做内核态,完全在操作系统内核中运⾏:执⾏内核空间的代码,具有ring 0保护级别,有对硬件的所有操作权限,可以执⾏所有 CPU指令集,访问任意地址的内存,在内核模式下的任何异常都是灾难性的,将会导致整台机器停机.

ring 3被叫做⽤户态,在应⽤程序中运⾏:在⽤户模式下,具有ring 3保护级别,代码没有对硬件的直接控制权限,也不能直接访问地址的内存,程序是通过调⽤系统接⼝(System Call APIs)来达到访问硬件和内存,在这种保护模式下,即时程序发⽣崩溃也是可以恢复的,在电脑上⼤部分程序都是在,⽤户模式下运⾏的.

低权限的资源范围较⼩,⾼权限的资源范围更⼤,所以⽤⼾态与内核态的概念就是CPU 指令集权限的区别.我们通过指令集权限区分⽤户态和内核态,还限制了内存资源的使⽤,操作系统为⽤户态与内核态划分了两块内存空间,给它们对应的指令集使⽤.在内存资源上的使⽤,操作系统对⽤户态与内核态也做了限制,每个进程创建都会分配虚拟空间地址,以Linux32位操作系统为例,它的寻址空间范围是4G(2的32次⽅),⽽操作系统会把虚拟控制地址划分为两部分,⼀部分为内核空间,另⼀部分为⽤户空间,⾼位的1G(从虚拟地址0xC0000000到0xFFFFFFFF)由内核使⽤,⽽低位的3G(从虚拟地址0x00000000到0xBFFFFFFF)由各个进程使⽤.


8.2⽤户态与内核态的切换

什么情况会导致⽤户态到内核态切换?

  • 系统调⽤ :⽤⼾态进程主动切换到内核态的⽅式,⽤户态进程通过系统调⽤向操作系统申请资源完成⼯作,例如fork()就是⼀个创建新进程的系统调⽤.操作系统提供了中断指令int 0x80来主动进⼊内核,这是⽤⼾程序发起的调⽤访问内核代码的唯⼀⽅式.调⽤系统函数时会通过内联汇编代码插⼊int 0x80的中断指令,内核接收到int0x80中断后,查询中断处理函数地址,随后进⼊系统调⽤.
  • 异常 :当 CPU 在执⾏⽤户态的进程时,发⽣了⼀些没有预知的异常,这时当前运⾏进程会切换到处理此异常的内核相关进程中,也就是切换到了内核态,如缺⻚异常.
  • 中断 :当 CPU 在执⾏⽤户态的进程时,外围设备完成⽤户请求的操作后,会向 CPU 发出相应的中断信号,这时CPU会暂停执⾏下⼀条即将要执⾏的指令,转到与中断信号对应的处理程序去执⾏,也就是切换到了内核态.如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执⾏后边的操作等.

切换时CPU需要做什么?

  • 当某个进程中要读写IO,必然会⽤到 ring 0 级别的CPU指令集.⽽此时 CPU 的指令集操作权限只有ring 3,为了可以操作ring 0 级别的CPU指令集,CPU 切换指令集操作权限级别为ring 0(可称之为提权),CPU再执⾏相应的ring 0 级别的 CPU 指令集(内核代码).
  • 代码发⽣提权时,CPU 是需要切换栈的!前⾯提到过,内核有⾃⼰的内核栈.CPU切换栈是需要栈段描述符(ss寄存器)和栈顶指针(esp寄存器),这两个值从哪⾥来?CPU通过⼀个段寄存器(tr)确定TSS(任务状态段,struct TSS)的位置.在TSS结构中存在这么⼀个 SS0 和 ESP0.提权的时候,CPU就从这个TSS⾥把SS0和ESP0取出来,放到ss和esp寄存器中.

切换流程

  • 从⽤户态切换到内核态时,⾸先⽤户态可以直接读写寄存器,⽤户态操作CPU,将寄存器的状态保存到对应的内存中,然后调⽤对应的系统函数,传⼊对应的⽤户栈地址和寄存器信息,⽅便后续内核⽅法调⽤完毕后,恢复⽤户⽅法执⾏的现场.
  • 从⽤户态切换到内核态需要提权,CPU切换指令集操作权限级别为 ring 0.
  • 提权后,切换内核栈.然后开始执⾏内核⽅法,相应的⽅法栈帧时保存在内核栈中.
  • 当内核⽅法执⾏完毕后,CPU切换指令集操作权限级别为ring 3,然后利⽤之前写⼊的信息来恢复⽤户栈的执⾏.

8.3追踪fopen

源码glibc2.9 :

• 中国科学技术⼤学镜像站:https://mirrors.ustc.edu.cn/gnu/libc/

• 清华⼤学镜像站:https://mirrors.tuna.tsinghua.edu.cn/gnu/libc/

cpp 复制代码
# define _IO_new_fopen fopen //宏
// 转到定义
_IO_FILE *
_IO_new_fopen (filename, mode)
const char *filename;
const char *mode;
{
return __fopen_internal (filename, mode, 1);
}
// 转到定义
_IO_FILE *
__fopen_internal (filename, mode, is32)
const char *filename;
const char *mode;
int is32;
{
if (INTUSE(_IO_file_fopen) ((_IO_FILE *) new_f, filename, mode, is32)
}
// 搜索:_IO_file_fopen
INTDEF2(_IO_new_file_fopen, _IO_file_fopen)
// 为了保证软件兼容性,给_IO_file_fopen 起别名:_IO_new_file_fopen,也就是底层调⽤的
就是_IO_new_file_fopen
// INTDEF2:这个宏⽐较难看,忽略
// 搜索:_IO_new_file_fopen
_IO_FILE *
_IO_new_file_fopen (fp, filename, mode, is32not64)
_IO_FILE *fp;
const char *filename;
const char *mode;
int is32not64;
{
result = _IO_file_open (fp, filename, omode|oflags, oprot, read_write,
is32not64);
}
// 转到定义
_IO_FILE *
_IO_file_open (fp, filename, posix_mode, prot, read_write, is32not64)
_IO_FILE *fp;
const char *filename;
int posix_mode;
int prot;
int read_write;
int is32not64;
{
fdesc = open (filename, posix_mode, prot); // 系统调⽤
}
// open,被__open替换
#define open(Name, Flags, Prot) __open (Name, Flags, Prot)
// _open之后,因为glibc做了很多隐藏机制,我们直接看伪代码就可以了
int __open(const char *file, int oflag, mode_t mode)
{
return INLINE_SYSCALL(open, 3, file, oflag, mode);
}
# define INLINE_SYSCALL(name, nr, args...) \
({ \
unsigned long int resultvar = INTERNAL_SYSCALL (name, , nr, args);
\
if (__builtin_expect (INTERNAL_SYSCALL_ERROR_P (resultvar, ), 0)) \
{ \
__set_errno (INTERNAL_SYSCALL_ERRNO (resultvar, )); \
resultvar = (unsigned long int) -1; \
} \
(long int) resultvar; })
# define INTERNAL_SYSCALL(name, err, nr, args...) \
INTERNAL_SYSCALL_NCS (__NR_##name, err, nr, ##args)
// #define __NR_open 2
// 这个前⾯⻅过了
# 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; })
// "0" (__NR_##name):系统调⽤号存⼊ %eax

🚀真正的勇者不是流泪的人,而是含泪奔跑的人!


敬请期待下一篇文章内容


每日心灵鸡汤: 你失去的不是一个脾气好的人,而是一个愿意替你扛事的人!

学会珍惜你身边这种低表达、高责任感的人.他们不一定会说很多好听的话,也不擅长反复表达情绪,但他们有一个很明显的特点:不习惯解释自己,却习惯承担结果;不喜欢制造情绪,却习惯解决问题.他们重承诺、重长期、重边界.真正把一个人放在心上时,不只是陪伴和安慰,而是会把你放进自己的时间、资源、责任和未来规划里.很多时候,你觉得生活很稳定、问题总能解决,并不是因为事情简单,而是有人在替你吸收成本、处理风险、承担压力.但这种人的付出并不是无限的.他们可以包容错误,却不会无限接受失衡;可以原谅一次失望,却会持续判断这段关系还值不值得继续.他们不会因为一次冲突立刻离开,而是在一次次失望里降低信任、减少投入、收回责任,直到某个临界点彻底退出.而这种人真正离开时,往往没有争吵,也没有拉扯.因为在正式离开之前,他早已经完成了内部的判断.最让人后知后觉的,是等他离开以后,那些过去被他承担掉的成本重新落回你身上,你才会发现:你失去的不是一个脾气好的人,而是一个曾经愿意长期把你的问题,当成自己问题的人.

相关推荐
硅基手札3 小时前
【linux内核专栏01】Linux 内核心智模型与设计哲学
linux·运维·驱动开发·开源软件
码完就睡3 小时前
Linux——socket网络编程
linux·运维·网络
道尔柯南3 小时前
【Linux】网络基础概念
linux·运维·网络
个 人 练 习 生3 小时前
C++中的内存管理
开发语言·c++·经验分享·学习
傲世仙尊3 小时前
从抢票事故到锁的原理-互斥条件变量与生产消费模型
linux·驱动开发
AI+程序员在路上3 小时前
AP6275S蓝牙双接口解析:HCI UART与PCM的分工与协同
linux·c语言·物联网
小此方4 小时前
Linux网络(十八):TCP连接管理详解:从三次握手、四次挥手到CLOSE_WAIT与TIME_WAIT,深入理解2MSL与端口复用
linux·网络·网络协议·tcp/ip
进击的荆棘4 小时前
Linux系统——文件(上)
linux·运维·服务器·文件
浩瀚地学4 小时前
deepagents学习打卡day08
经验分享·笔记·python·学习·agent