【进程信号】:溯源硬件起中断,掌舵内核控信号

前言:

为什么 ctrl+c 能终结任务?为什么除 0 会导致程序崩溃?这是我们前边看似常见却不懂原理的事情,下面就让我们看看它是怎么回事!在看这篇之前,务必要学习过文章:【进程】:时间片上的舞者,状态机里的棋子

目录

一、信号的快速认识

[1. 信号概念的引入](#1. 信号概念的引入)

[2. 信号的查看](#2. 信号的查看)

[3. 信号的处理](#3. 信号的处理)

(1)signal函数详解

(2)进程的三种处理方式

二、信号的产生

[1. 通过终端按键产生](#1. 通过终端按键产生)

(1)操作方法

(2)理解OS如何知道键盘上有数据产生

(3)前台进程与后台进程

[① 前台进程 (Foreground Process)](#① 前台进程 (Foreground Process))

[② 后台进程 (Background Process)](#② 后台进程 (Background Process))

[③ 相关命令](#③ 相关命令)

[④ 具体现象解释](#④ 具体现象解释)

[2. 通过系统命令发信号](#2. 通过系统命令发信号)

[3. 在代码中通过系统调用函数产生信号](#3. 在代码中通过系统调用函数产生信号)

(1)kill

(2)raise

(3)abort

(4)demo实验

[4. 由软件条件产生信号](#4. 由软件条件产生信号)

(1)alarm函数详解

[① 函数原型与信号类型](#① 函数原型与信号类型)

[② 返回值逻辑](#② 返回值逻辑)

[③ 核心特性](#③ 核心特性)

(2)初步认识OS的工作模式

[① 什么是 pause?](#① 什么是 pause?)

[② OS 的工作模式](#② OS 的工作模式)

(3)alarm计算时间的方法

[① OS 自身具有定时功能](#① OS 自身具有定时功能)

[② alarm计时](#② alarm计时)

[③ 闹钟的异步反馈](#③ 闹钟的异步反馈)

(4)简单了解alarm在内核中的实现

[① 内核管理alarm的方法](#① 内核管理alarm的方法)

[② 内核源码的分析](#② 内核源码的分析)

[③ 软实时](#③ 软实时)

(5)SIGPIPE

(6)理解软件条件

[5. 由硬件异常产生信号](#5. 由硬件异常产生信号)

(1)模拟除0

(2)模拟野指针

[(3)Core Dump](#(3)Core Dump)

[① 概念](#① 概念)

[② 作用](#② 作用)

[③ 用waitpid验证](#③ 用waitpid验证)

[④ 具体设置](#④ 具体设置)

三、信号的保存

[1. 有关信号概念的补充](#1. 有关信号概念的补充)

[2. block,pending,handle](#2. block,pending,handle)

(1)三张表的"三位一体"结构

(2)信号产生后内核的处理流程

(3)发送多个相同的信号的处理办法

[3. sigset_t](#3. sigset_t)

[4. 信号集操作函数 与 demo测试](#4. 信号集操作函数 与 demo测试)

[(1)sigprocmask:操控 Block 表](#(1)sigprocmask:操控 Block 表)

[(2)sigpending:查看 Pending 表](#(2)sigpending:查看 Pending 表)

(3)demo测试

四、信号的捕捉

[1. 硬件中断](#1. 硬件中断)

(1)CPU针脚

(2)中断控制器与中断号

(3)硬件的寄存器

(4)中断向量表

(5)全流程阐述

[2. 时钟中断](#2. 时钟中断)

(1)硬件源头

(2)该中断程序的作用

[① 更新系统时间](#① 更新系统时间)

[② 维护定时器](#② 维护定时器)

[③ 检查进程的时间片(核心功能)](#③ 检查进程的时间片(核心功能))

[3. 软中断](#3. 软中断)

(1)概念

[(2)int 0x80 / syscall 指令](#(2)int 0x80 / syscall 指令)

(3)陷阱的处理流程

(4)检测信号的时机

[4. 用户态与内核态](#4. 用户态与内核态)

(1)概念及原因

(2)转换机制

(3)内存布局

(4)扩展

[5. 信号的捕捉流程(重点)](#5. 信号的捕捉流程(重点))

第一阶段:信号的"起源、涂色与实时发现"(内核态/硬中断)

第二阶段:信号的"出鞘与跳转"(跨越态切换)

第三阶段:信号的"处理与回执"(用户态)

[第四阶段:信号的"归位与重生"(内核态 → 用户态)](#第四阶段:信号的“归位与重生”(内核态 → 用户态))

[6. 中断与信号的关系(关键理解)](#6. 中断与信号的关系(关键理解))

[7. sigaction函数](#7. sigaction函数)

[(1)struct sigaction](#(1)struct sigaction)

(2)sigaction函数

(3)demo测试

五、SIGCHLD信号

[1. 作用解析](#1. 作用解析)

[2. 回收进程改进](#2. 回收进程改进)

六、可重入函数

[1. 概念](#1. 概念)

[2. 判定标准](#2. 判定标准)

[3. 信号在这里的用途](#3. 信号在这里的用途)

七、volatile



一、信号的快速认识

1. 信号概念的引入

你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递,也就是你能 "识别快递"。当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递,那么在这5min之内,你并没有下去取快递,但是你是知道有快递到来了,也就是取快递的行为并不是一定要立即执行 ,可以理解成**"在合适的时候去取"。在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了** ,本质上是你**"记住了有一个快递要去取"。当你时间合适,顺利拿到快递之后,就要开始处理快递** 了,而处理快递一般方式有三种 :①执行默认动作(幸福的打开快递,使用商品)② 执行自定义动作(快递是零食,你要送给你你的女朋友)③ 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)。快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。

这就是信号的大体过程,我们在前面也提到过,比如什么kill -9,ctrl+c,这都是信号。所以,我要说出信号的概念了,但你一定看不懂,不过等你看完这篇文章之后,就是小菜一蝶了。

信号是 Linux/Unix 系统中一种软件中断机制 ,用于异步通知进程 发生了某个事件。它也是进程间通信的一种简单方式,但主要传递的是"事件"而非"数据"。在 Linux 中,信号是软件层面的中断 。 当硬件发生异常(如除以零)或系统发生特定事件(如按下 Ctrl+C)时,内核会向进程发送一个通知。进程在接收到信号后,会暂时中断当前的执行流,转而去处理这个信号,处理完毕后再返回原处继续执行。

怎么理解同步通知与异步通知?

信号被视为异步通知的典型代表,因为它具有以下特性:

进程无法预知信号何时到来

当你的程序正在执行一个复杂的数学运算(如循环累加)时,用户突然在终端按下了 Ctrl+C。此时,内核会立刻向你的进程发送 SIGINT 信号。如果这是同步 的,程序必须运行到类似于 check_for_signals() (检查是否有信号)这样的代码行才会处理;但因为它是异步的,无论程序当前运行到哪一行代码,内核都会强行切断当前的执行路径,转去执行信号处理函数。

2. 信号的查看

  • 用kill -l 查看所有信号
  • 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义 #define SIGINT 2。
  • 编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明:man 7 signal。

3. 信号的处理

(1)signal函数详解

cpp 复制代码
#include <csignal>

#define SIG_DFL ((__sighandler_t) 0) /* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */
// 其实SIG_DFL和SIG_IGN就是把 0,1 强转为函数指针类型
typedef void (*sighandler_t)(int);
//sighandler_t是一个函数指针

sighandler_t signal(int signum, sighandler_t handler);

参数解析:

  • signum : 想要处理的信号编号(如 SIGINT, SIGTERM)。
  • handler : 处理动作,它可以是以下三种值之一:SIG_IGN ,忽略该信号;SIG_DFL ,恢复该信号的系统默认处理动作;自定义函数指针 : 指向一个格式为 void func(int) 的函数。当信号发生时,内核会自动调用它,并将信号编号作为参数传入。

返回值(了解):

  • 成功 :返回该信号上一次的处理程序指针。

  • 失败 :返回 SIG_ERR,并设置 errno

(2)进程的三种处理方式

  • 执行默认动作 (Default): 每个信号都有系统预设的行为(如 SIGINT 终止进程,SIGCHLD 被忽略)。

  • 忽略信号 (Ignore): 进程接收到信号后直接丢弃,不执行任何动作。但SIGKILL (9) 和 SIGSTOP (19) 无法被忽略(也不能被自定义),这是为了保证管理员始终能控制进程。

  • 自定义信号 (Catch): 程序员提供一个自定义函数(信号处理程序)。当信号发生时,内核暂停当前程序,调用该函数

cpp 复制代码
#include <iostream>
#include <csignal>  // signal 头文件
#include <unistd.h> // getpid, sleep 头文件

// 1. 自定义处理函数 (Catching)
void myhandler(int sig)
{
    std::cout << "\n[自定义捕获] 接收到信号 " << sig << " (SIGINT),但我选择不退出!" << std::endl;
}

int main()
{
    std::cout << "当前进程 PID: " << getpid() << std::endl;

    //  A. 自定义捕获 (SIGINT: Ctrl+C) 
    // 当按下 Ctrl+C 时,不再终止进程,而是执行 my_handler
    signal(SIGINT, myhandler);

    //  B. 忽略信号 (SIGQUIT: Ctrl+\) 
    // 当按下 Ctrl+\ 时,系统原本会终止进程,现在会完全没反应
    signal(SIGQUIT, SIG_IGN);

    //  C. 恢复默认动作 (SIGTERM: 默认终止) 
    // 假设我们之前修改过 SIGTERM,现在将其改回系统默认动作
    // 注意点:后执行的 signal 调用会覆盖先前的设置。信号的处理动作始终以最后一次成功注册的结果为准。
    signal(SIGTERM, SIG_DFL);


    while (true)
    {
        sleep(1);
    }

    return 0;
}

二、信号的产生

1. 通过终端按键产生

(1)操作方法

这是开发调试中最常用的方式。当你在终端按下特定组合键时,终端驱动程序会捕捉到这些按键,并将其解释为信号发送给前台进程组。

  • Ctrl + C : 产生 SIGINT (2)。默认动作为终止进程

  • Ctrl + \ : 产生 SIGQUIT (3)。默认动作为终止并产生 Core Dump(之后讲)。

  • Ctrl + Z : 产生 SIGSTOP (19)。默认动作为暂停进程(将其挂起至后台)。

(2)理解OS如何知道键盘上有数据产生

  • 当你按下键盘上的某个键时,键盘就像是一个"报信员",它意识到有任务来了,必须立刻通知最高统帅部(CPU)。
  • 报信员(键盘)并不能直接跟操作系统对话,它通过物理线路向 CPU 发送了一个"硬件中断"**。**你可以理解为报信员按响了 CPU 办公室门外的紧急门铃。
  • CPU 的反应: 它不需要去看屏幕,只要针脚上有电压变化,它就知道:"有人按门铃了,而且是键盘那个房间的门铃。"
  • CPU 接收到信号后,会立刻放下手里正在干的活(比如正在计算的逻辑),去内存里的"急诊手册"(中断向量表)中查找:如果键盘门铃响了,我该运行哪段代码?这段代码就是操作系统(OS)里的驱动程序。CPU 开始跑这段代码,去处理这个突发的键盘事件。
  • 在 CPU 的指挥下,操作系统停下原本的任务,伸出"手"去键盘的寄存器里把这个按键的具体信息(比如你按的是 'A' 还是 'Ctrl+C')抓取出来,放进内存的缓冲区 里。 如果是普通字符,就等你的程序来读;如果是 Ctrl+C,操作系统就会立刻给你的进程发一个"红牌"(信号),让进程退出。

++我知道你有点懵,没关系,我们在硬件中断那里着重讲++

(3)前台进程与后台进程

① 前台进程 (Foreground Process)

前台进程是当前直接与用户交互的进程。

  • 交互权: 它独占终端的输入(标准输入 stdin)。你在键盘上输入的任何字符都会直接发给它。

  • 信号敏感度: 它是终端信号的第一接收者 。当你按下 Ctrl+CCtrl+\ 时,内核会精准地将信号发送给当前的前台进程组。

  • 数量限制: 在同一个终端中,同一时刻只能有一个前台进程(组)

  • 启动方式: 直接输入命令运行,如 ./myshell。(平时我们运行起来后它们就是前台)

② 后台进程 (Background Process)

后台进程是在"幕后"运行的进程。

  • 交互权:不占用终端输入。即便它正在运行,你依然可以在终端输入其他命令。

  • 输出表现: 默认情况下,后台进程依然会将结果打印到屏幕(标准输出 stdout),这可能会干扰你当前的屏幕显示。通常建议将后台进程的输出重定向到文件。

  • 信号屏蔽: 后台进程免疫 键盘产生的信号。你按 Ctrl+C 无法杀掉后台进程,必须使用 kill 命令通过 PID 来手动发送信号。

  • 启动方式: 在命令末尾加上 & 符号,如 ./myshell &

③ 相关命令
  • jobs: 查看当前终端下所有的后台任务及其状态。

  • Ctrl + Z : 将一个正在运行的前台进程暂停并送入后台。

  • fg [job_id]: 将后台进程调回前台。

  • bg [job_id]: 让处于暂停状态的后台进程在后台继续运行。

bash 复制代码
yhz@VM-0-5-ubuntu:~/sign-study$ ./demo
进程 [3546817] 正在运行...
进程 [3546817] 正在运行...
^Z
[1]+  Stopped                 ./demo

yhz@VM-0-5-ubuntu:~/sign-study$ jobs
[1]+  Stopped                 ./demo

yhz@VM-0-5-ubuntu:~/sign-study$ bg 1
[1]+ ./demo &
进程 [3546817] 正在运行...
yhz@VM-0-5-ubuntu:~/sign-study$ 进程 [3546817] 正在运行...
进程 [3546817] 正在运行...                                                                                                                        
进程 [3546817] 正在运行...

fg 1  #切换至前台
./demo
进程 [3546817] 正在运行...
进程 [3546817] 正在运行...
^C
yhz@VM-0-5-ubuntu:~/sign-study$ 
④ 具体现象解释
  • 显示器为什么会出现数据混乱?

所谓的"混乱",本质是输出数据的原子性 被破坏了,即未对资源进行保护;无论你有多少个进程,无论是前台还是后台,它们最终输出的目的地都是同一个终端设备文件,即显示器是共享资源,内核虽然负责调度进程,但通常不会对多个进程同时向终端写入的数据进行顺序编排。谁拿到CPU时间片,谁就往缓冲区里写。

  • bash是什么进程?

▶ 当你输入命令并运行时: 比如你执行 ./task,此时 bash 会调用 waitpid 阻塞住自己,并将 ./task 提升为前台进程 。这时,bash 变成了后台进程(或者说是不活跃的前台)。

▶ 当没有程序运行时: 此时 bash 就是当前的前台进程,它正在等待你的输入。

  • 孤儿进程为什么用ctrl+c杀不掉?

Ctrl+c产生的信号由内核精准投递至当前终端的前台进程组,而孤儿进程在失去父进程后通常会转入后台或脱离终端关联 ,脱离了信号投递的"准星"范围。因此,要清理孤儿进程,必须通过 ps 获取其 PID 后,使用 kill 命令进行手动清除。

  • 为什么僵尸进程kill -9 都无能为力?

你不能杀死一个已经死掉的东西。 信号的作用是通知一个"活着的"进程去做某事。僵尸进程已经不是一个活着的代码执行流了,它只是一份留在内核里的结构体数据(墓碑)。它不处理信号,也没有 CPU 执行权。

2. 通过系统命令发信号

这个我们只要学会kill即可

bash 复制代码
kill [参数] [进程PID]
  • 不带参数: 默认发送 SIGTERM (15)。

  • 带参数: 可以通过编号或名称指定信号,如 kill -9 1234kill -SIGKILL 1234

其他用法:

  • killall : 根据进程名发送信号。示例:killall myshell 会杀掉所有名为 myshell 的进程。

  • pkill : 更加灵活,支持正则表达式或匹配特定用户。示例:pkill -u yhz 会杀掉用户 yhz 的所有进程\

  • kill -l :它会列出系统中所有的信号名称和对应的编号。

3. 在代码中通过系统调用函数产生信号

(1)kill

kill 是最通用的函数,可以向系统中的任何进程或进程组发送信号。

  • 原型: int kill(pid_t pid, int sig);

  • 逻辑:

    • pid > 0:发送给指定 PID 的进程。

    • pid == 0:发送给与调用者同进程组的所有进程。

    • pid == -1:发送给所有有权限发送信号的进程(慎用)。

    • pid < -1:发送给进程组 ID 为 |pid| 的所有进程。

(2)raise

raise 用于向当前调用进程发送信号。

  • 原型: int raise(int sig);

  • 逻辑: 它的底层等价于 kill(getpid(), sig)。通常用于程序在检测到某种特定状态时,主动触发自己的信号处理逻辑。返回0表示成功,非0不成功。

(3)abort

abort 是一个标准库函数,用于异常终止当前进程。

  • 原型: void abort(void);

  • 逻辑: 它向进程发送 SIGABRT (6) 信号。即使该信号被捕获并在 handler 中返回,abort 依然会强制终止进程。 它的目的是确保程序在发生严重错误时产生 Core Dump 并退出。

(4)demo实验

cpp 复制代码
void handler(int sig)
{
    std::cout << "捕获到信号: " << sig << std::endl;
    fflush(stdout);
}

int main(int argc, char *argv[])
{
    std::string mode = argv[1];
    signal(SIGINT, handler);  // 为 kill/raise 测试准备
    signal(SIGABRT, handler); // 为 abort 测试准备

    if (mode == "kill")
    {
        pid_t id = fork();
        if (id == 0)
        {
            std::cout << "子进程运行中..." << std::endl;
            while (true)
                sleep(1);
        }
        else
        {
            sleep(2);
            std::cout << "父进程通过 kill 发送 SIGINT 给子进程 " << id << std::endl;
            kill(id, SIGINT);
            sleep(1);
            kill(id, SIGKILL); // 彻底杀掉子进程
            wait(nullptr);
        }
    }

    else if (mode == "raise")
    {
        std::cout << "通过 raise 触发 SIGINT..." << std::endl;
        sleep(1);
        raise(SIGINT);
        std::cout << "raise 后的代码继续执行" << std::endl;
    }

    else if (mode == "abort")
    {
        std::cout << "准备调用 abort..." << std::endl;
        sleep(1);
        abort();
        std::cout << "这一行永远不会被打印" << std::endl;
    }

    return 0;
}

4. 由软件条件产生信号

(1)alarm函数详解

① 函数原型与信号类型
  • 头文件: <unistd.h>

  • 原型: unsigned int alarm(unsigned int seconds);

  • 发送信号: SIGALRM (14)

  • 默认动作: 如果进程没有捕获(Catch)这个信号,进程会直接终止
② 返回值逻辑

alarm 的返回值并不是成功或失败,而是上一个闹钟剩余的秒数

  • 场景 A: 之前没设过闹钟。调用 alarm(10),返回 0

  • 场景 B: 设了 alarm(10),跑了 3 秒后,你又调用了 alarm(5)。此时,旧闹钟被取消,新闹钟(5秒)开始计时。返回 7(即旧闹钟剩下的时间)。

  • 场景 C: 想取消闹钟。调用 alarm(0)。内核会取消当前的闹钟,并返回剩余秒数。

③ 核心特性
  • 一次性: alarm 注册的闹钟响过一次后就失效了。如果你想实现"每隔 5 秒响一次",必须在信号处理函数 handler 里重新调用一次 alarm(5)
  • 异步触发: 闹钟是在内核中计时的,与你程序里是否在忙碌(比如在算 1+1 或在执行 sleep)无关。哪怕程序阻塞了,时间一到,信号照样送达。
  • 单闹钟机制: 一个进程在同一时刻只能拥有一个闹钟。新闹钟会无情地覆盖掉旧闹钟。

④ 两个demo小测试

  • 测IO对速度的影响
cpp 复制代码
void testhaveIO()
{
    int count = 0;
    alarm(1);
    while (true)
    {
        std::cout << "count : " << count << std::endl;
        count++;
    }
}

int count = 0;
void handler1(int signumber)
{
    std::cout << "count : " << count << std::endl;
    exit(0);
}

void testnoIO()
{
    signal(SIGALRM, handler1);
    alarm(1);
    while (true)
    {
        count++;
    }
}
bash 复制代码
#有IO
yhz@VM-0-5-ubuntu:~/sign-study$ g++ alarm_test.cpp -o test
yhz@VM-0-5-ubuntu:~/sign-study$ ./test 
count : 0
#...
count : 62659
Alarm clock

#无IO
yhz@VM-0-5-ubuntu:~/sign-study$ g++ alarm_test.cpp -o test
yhz@VM-0-5-ubuntu:~/sign-study$ ./test                    
count : 406462684
  • alarm覆盖性测试
cpp 复制代码
void handler(int sig)
{
    std::cout << "闹钟响了!收到信号: " << sig << std::endl;
}

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

    std::cout << "设定 5 秒闹钟" << std::endl;
    alarm(5);
    sleep(2);
    std::cout << "改设 4 秒闹钟。" << std::endl;
    unsigned int remaining = alarm(4);
    std::cout << "旧闹钟剩下的时间是: " << remaining << " 秒" << std::endl;

    // 此时程序会等待 4 秒后触发 handler
    while (true)
        pause(); // 让进程挂起等待信号

    return 0;
}

(2)初步认识OS的工作模式

其实上面的第二个代码就可以简单看成OS工作的模式。

① 什么是 pause

pause 是一个系统调用,它的唯一作用是:让进程进入睡眠状态,直到接收到一个信号。

  • 在没有信号到达时,进程不占用 CPU 资源(不处于死循环状态)。

  • 一旦有信号递达并执行完处理函数,pause 才会返回。

② OS 的工作模式

操作系统的核心运行逻辑是:"平时睡眠,由中断唤醒,处理任务,继续睡眠。"

cpp 复制代码
while (true) {
    alarm(5);   // 1. 预设一个"未来中断"(模拟时钟中断)
    pause();    // 2. 进程交出 CPU,进入睡眠(模拟 OS 的等待状态)
    
    // 3. 当 5 秒到,SIGALRM 到达,唤醒进程
    // 4. 执行信号处理函数 handler (模拟 OS 执行中断处理程序)
    
    // 5. 信号处理完,从 pause 返回,回到这里
    do_something(); // 模拟 OS 执行调度或管理任务
}

OS 并不是一个时刻在空转的死循环。如果没有任何任务,OS 实际上处于一种"低功耗挂起"状态。它完全依赖中断来驱动。硬件中断, 你动了一下鼠标、敲了一下键盘、网卡收到了一个数据包。时钟中断, 硬件计时器每隔几毫秒敲一下门,提醒 OS 该起来看看有没有进程跑超时了。软件中断(系统调用), 你的程序想打开一个文件(调用 open),通过特殊的指令"敲门"进入内核态。

简单来说,OS 是计算机的厂长,它不拧螺丝(不跑业务),只负责排班(调度)、发钥匙(分内存)和维持秩序(安全),通过控制硬件和管理进程来确保工厂的运转。在宏观上看,OS 是一个"极其被动"的管理者。 如果没有外界的"催促"(中断),操作系统就像一台关掉了电源的发动机,或者是一个陷入永恒沉睡的管家。如果没有各种 "中断",OS什么都不干。

(3)alarm计算时间的方法

① OS 自身具有定时功能

操作系统自己是不会计时的,它必须依赖硬件。主板上有一个物理硬件(比如高精度定时器 HPET 或 APIC)。这个硬件就像一个节拍器,每隔一段极其微小的时间(比如 1/1000 秒)就给 CPU 发一个电信号(时钟中断)。每次电信号一来,内核代码就会被强行唤醒。OS 靠着数这些"节拍"的次数,才知道现在是几点几分几秒。

在 Linux 内核里,有一个非常著名的全局变量,叫作 jiffies。它是一个极其简单的整数(无符号长整型)。通常在系统启动时被初始化为 0。硬件每产生一次时钟中断,它的值就精确地自增 1。

  • OS 在启动时会读取一次主板电池供电的实时时钟,获取一个初始的现实时间。之后,OS 就不再去读那个慢吞吞的 RTC 硬件了,而是直接用算出来的秒数来推算出当前的精确时间。
② alarm计时

当你调用 alarm(5) 时,内核其实是在做加法:

  • 内核读取现在的 jiffies 是 10000。

  • 内核计算出:10000 + (5 * 1000HZ) = 15000。

  • 内核把 15000 这个数字填进你的进程管理卡片里。

  • 每一毫秒中断来时,内核在 jiffies++ 之后,都会顺便比对一下:"现在的 jiffies 达到 15000 了吗?" 到了,就发信号。

③ 闹钟的异步反馈

"闹钟"这个接口实现了权力的传递 ,它精髓不在于计时,而在于"异步通知"。

进程设置完闹钟后,可以去干任何事。当 OS 的"节拍器"数到了那个约定的数字,OS 会主动停下当前的工作,跳到该进程的地址空间里去执行那段信号处理代码。这就是你之前说的"被动管理"。用户设置(主动),OS 计时并踢醒用户(被动响应)。

(4)简单了解alarm在内核中的实现

① 内核管理alarm的方法

Linux 内核在实现中经历了几个阶段:

  • 早期(简单的有序链表): 插入慢(O(N)),查找快(O(1))。进程多了之后,每次 alarm 都要遍历整个链表找插入位置,效率太低。

  • 中期 : 使用了**时间轮,**类似于哈希表,把时间分成不同的"桶"。这是 Linux 长期使用的高效算法,适合处理海量的、精度要求不高的定时任务。

  • 现代: 使用了红黑树 。红黑树是一种平衡二叉搜索树,它的最左侧节点就是最小值。它在插入、删除、查找最小值上都能稳定在 O(log N)。(和小根堆一个意思,但堆删除节点时比较麻烦)

内核通过一个全局的定时器列表(如时间轮或红黑树)管理所有闹钟。当你调用 alarm(),内核按到期时间排好序(这就是数据结构的意义)。每次时钟中断响起,内核只看列表最前面的任务:如果时间到了就发信号,否则直接返回,从不遍历全部。

② 内核源码的分析
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记录的是该定时器应该在什么时候到期。 expires = jiffies + (5 * HZ),内核每次时钟中断 jiffies++ 后,就会拿当前的 jiffies 和这个 expires 比较。如果 jiffies >= expires,说明时间到了!
  • *function函数指针,时间一到,内核该做的事情
  • entry内核通过这个把成千上万个 timer_list 串在一起(按照 expires 的先后顺序排队)
  • data是function 的参数,如发信号时,得知道发给哪个进程(PID),或者发哪个信号,这些信息可以存在这里。
  • *base标记这个定时器属于哪个 CPU 核心(不做了解)
③ 软实时

由于操作系统采用离散的时钟节拍(jiffies)计时,且信号的递送依赖于进程调度,alarm(5)的实际触发时间往往会略微超过 5 秒 。这种误差由计时颗粒度、系统调用开销以及进程排队等待 CPU 的调度延迟共同构成。在 Linux 等分时操作系统中,这种"尽力而为"的定时机制被称为软实时 ,它保证了任务不会早于预定时间执行,但无法规避因系统负载而产生的滞后。所以,软件实现与物理真实之间存在偏差!

了解 timer_create

它支持纳秒级精度,并且允许每个进程创建多个定时器实例,还能通过 sigevent 结构体自定义通知方式,比如触发线程或发送特定的实时信号。就是原理和alarm大差不差,但比alarm牛b。

(5)SIGPIPE

这是一种典型的通信异常产生的软件条件,我们学通信时已经说过,这里简单提及。

触发条件

  • 进程 A 向进程 B 建立了一个管道(Pipe)。
  • 进程 B(读端)已经关闭,但进程 A(写端)仍在尝试写入。
  • 后果: 这种行为在逻辑上是无意义的,内核会立即产生 SIGPIPE (13) 信号发给进程 A。

为什么这叫软件条件?

因为这不涉及硬件损坏,纯粹是内核在管理文件描述符和管道缓冲区时,发现"读端不存在"这一逻辑条件成立,从而通过信号通知写端进程。

(6)理解软件条件

  • 定义: 信号的产生不源于外部的物理撞击或内部的电路报错,而是因为某种预设的软件逻辑、状态或规则达到了触发点

  • 本质: 它是内核在检查自身维护的各种数据结构时,发现某个逻辑条件成立而主动发起的异步通知。

系统调用vs软件条件

系统调用(即时指令),当你调用 killraiseabort 时,你是在下达一个明确的命令

  • 因果关系: 调用函数就是产生信号的直接原因

  • 逻辑: 只要你执行了这一行代码,内核进入系统调用流程后,二话不说,立刻去修改目标进程的 PCB(进程控制块)里的信号位图。

软件条件(逻辑达成),当你调用 alarm(5) 时,该函数本身并不产生信号

  • 因果关系: 调用函数只是设定了一个条件,产生信号的原因是"时间流逝"。

  • 逻辑: alarm 执行完就结束了,信号可能在 5 秒后才产生。在这 5 秒间,内核并没有在执行 alarm 函数,而是时钟中断在不断地"数节拍"。直到逻辑条件(jiffies >= expires)满足,信号才被"顺便"触发。

5. 由硬件异常产生信号

(1)模拟除0

cpp 复制代码
void handler(int sig)
{
    printf("catch a sig : %d\n", sig);
}
int main()
{
    for (int i = 0; i <= 31; i++)
    {
        signal(i, handler);
    }
    sleep(1);
    int a = 10;
    a /= 0;
    while (1)
        ;
    return 0;
}

(2)模拟野指针

cpp 复制代码
void handler(int sig)
{
    printf("catch a sig : %d\n", sig);
    exit(1);
}

int main()
{
    for (int i = 0; i <= 31; i++)
    {
        signal(i, handler);
    }
    sleep(1);
    int *p = NULL;
    *p = 100;
    while (1)
        ;
    return 0;
}

如果没有exit(1),一直有8号/11号信号产生被捕获,这是为什么呢?

上面我们只提到CPU运算异常后,如何处理后续的流程,实际上 OS 会检查应用程序的异常情况,其实在CPU中有一些控制和状态寄存器,主要用于控制处理器的操作,通常由操作系统代码使用。状态寄存器可以简单理解为一个位图,对应着一些状态标记位、溢出标记位。OS 会检测是否存在异常状态,有异常存在就会调用对应的异常处理方法。除零异常后,我们并没有清理内存,关闭进程打开的文件,切换进程等操作,所以CPU中还保留上下文数据以及寄存器内容,除零异常会一直存在,就有了我们看到的一直发出异常信号的现象。访问非法内存其实也是如此。

++至于硬件中断的详细信息,我们在信号处理那块讲解。++

(3)Core Dump

① 概念

当一个进程因为某些严重错误(通常是硬件异常引发的信号)而崩溃时,操作系统会把该进程此时此刻在内存中的状态 (包括变量的值、函数的调用栈、寄存器的数值等)完整地"倾倒"出来,保存为一个磁盘文件。这个文件就叫 Core 文件。(中文名叫核心转储)

② 作用

程序崩溃往往是一瞬间的事,你来不及观察它的内部状态。Core dump 记录了这个瞬间的完整"案发现场",包括:程序计数器 :崩溃时执行到哪一行代码?栈信息 :函数调用关系是怎样的(调用栈)?各函数的局部变量是什么?内存数据 :关键的全局变量、静态变量的值是什么?寄存器状态:CPU 当时各个寄存器的值。有了这些信息,开发者可以在程序运行结束后,像"回放录像"一样还原崩溃时的场景。

③ 用waitpid验证
cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    pid_t id = fork();
    if (id == 0)
    {
        sleep(1);
        int *p = nullptr;
        *p = 1234; // 野指针
        exit(0);
    }
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0)
    {
        if (WIFSIGNALED(status))
        {
            int sig = WTERMSIG(status); // 获取信号编号
            // 通过位运算提取第 7 位 (core dump 标志位)
            int core_dump_flag = (status >> 7) & 0x1;

            std::cout << "子进程被信号 " << sig << " 终止。" << std::endl;
            std::cout << "Core Dump: " << core_dump_flag << std::endl;
        }
    }
    return 0;
}
④ 具体设置

现在的云服务器一般不会开启Core dump,Core 文件的大小等于进程运行时的内存大小。如果一个占用了 8GB 内存的程序崩了,瞬间生成一个 8GB 的文件,硬盘可能会被塞爆。

开启方法(短期有效):

bash 复制代码
yhz@VM-0-5-ubuntu:~/sign-study$ ulimit -a                                                 
real-time non-blocking time  (microseconds, -R) unlimited
core file size              (blocks, -c) 0  #表示允许的大小
data seg size               (kbytes, -d) unlimited
scheduling priority                 (-e) 0
file size                   (blocks, -f) unlimited
pending signals                     (-i) 7378
max locked memory           (kbytes, -l) 251336
max memory size             (kbytes, -m) unlimited
open files                          (-n) 1048576
pipe size                (512 bytes, -p) 8
POSIX message queues         (bytes, -q) 819200
real-time priority                  (-r) 0
stack size                  (kbytes, -s) 8192
cpu time                   (seconds, -t) unlimited
max user processes                  (-u) 7378
virtual memory              (kbytes, -v) unlimited
file locks                          (-x) unlimited
yhz@VM-0-5-ubuntu:~/sign-study$ ulimit -c 1024  #修改大小                                           
yhz@VM-0-5-ubuntu:~/sign-study$ echo "core.%e.%p" | sudo tee /proc/sys/kernel/core_pattern 
core.%e.%p
#修改路径到当下

三、信号的保存

看这个之前一定要了解位图,我在这里讲过:【哈希hash】:程序的"魔法索引",实现数据瞬移

1. 有关信号概念的补充

  • 实际执行信号的处理动作称为信号递达(所以说信号有三种递达方式)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block)某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  • 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

2. block,pending,handle

(1)三张表的"三位一体"结构

① Block 表(屏蔽字/阻塞位图)

  • 本质: 一个 sigset_t 类型的位图。

  • 逻辑: 这里的位如果是 1,表示对应的信号被屏蔽(阻塞)了。

  • 含义: "如果这个信号来了,先给我拦住,不许处理,直到我解除阻塞为止。"

② Pending 表(未决位图)

  • 本质: 是一个结构体,但我们可以认为它就是一个位图。

  • 逻辑: 这里的位如果是 1,表示信号已经产生 ,但还没被处理。(未决状态)

  • 含义: "收件箱"里有一封还没拆的信。

③ Handler 表(处理方法表)

  • 本质: 一个函数指针数组

  • 逻辑: void (*handler[32])(int);

  • **含义:**和我们前面signal函数的第二个参数意思一样。

(2)信号产生后内核的处理流程

我们向OS发送2号信号:

  • 产生阶段: 信号产生后,内核直接把进程 Pending 表 的第 2 位改成 1

  • 检查阶段: 内核会先看 Pending ,发现第 2 位是 1(有信号来了)再看 Block,检查第 2 位是不是 1。

  • 判定逻辑: ①如果 Block[2] == 1:对不起,信号被阻塞了,虽然它在收件箱里(Pending 为 1),但进程绝对不去处理 ,直到用户手动把 Block 设为 0。②如果 Block[2] == 0:绿灯通行!内核去 Handler 数组 找对应的下标 2,执行里面的函数。

  • 处理阶段: 在执行 Handler 之前,内核通常会Pending 表 的第 2 位清零(0),表示"这封信我拆开处理了"。

易错点:

  • Pending 决定了"有没有"(状态),Block 决定了"准不准"(权限)。
  • 即使一个信号被 Block(阻塞)了,它产生时依然会把 Pending 置为 1。只不过它会一直卡在 Pending 状态,等着被释放。
  • 信号被"忽略"(Handler = SIG_IGN)和被"阻塞"(Block = 1)是两码事。忽略 是已经处理了,处理动作就是"无视"。阻塞是还没处理,堵在门口不让进。

(3)发送多个相同的信号的处理办法

假设多次发送信号 SIGINT (2号),且进程目前正处于某种状态

  • 第一个 SIGINT 到达,Pending 位图置 1。

  • 内核准备处理,Pending 位图清 0,转而执行 Handler 函数。

  • 如果在 Handler 函数执行期间,第二个 SIGINT 又来了,内核会自动将当前的 SIGINT 加入 Block 集(防止同一个信号处理函数被嵌套触发导致栈溢出)。

  • 第三个、第四个 SIGINT 接着来,此时由于 SIGINT 已被阻塞,它们只能把 Pending 位图置为 1。

  • 结果: 无论后面补了多少个 SIGINT,Pending 位图只能记录"有",不能记录"次数"。当 Handler 执行完,内核解除阻塞,刚才憋着的多次信号最终只会触发一次 。这就是普通信号的丢失(不可靠性)

上面我说的都是自定义捕捉的处理办法,信号产生时,内核直接检查 Handler 表。如果发现是 SIG_IGN,内核会直接把这个信号丢弃 ,甚至都不会去修改 Pending 位图。当第一个默认信号(如 SIGINT)产生并递达时,内核直接接管。由于默认动作是"杀死进程",内核会立刻执行清理工作并关闭进程,它一般不会动Block。本质原因是默认和忽略是内核自己处理,内核代码是受控的,不需要这种"护盾"。

如何处理普通信号的丢失?

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

struct sigpending pending;

要想彻底解决必须要用实时信号,这也是为什么pending要用结构体!链表(struct list_head list)的作用是,① 信息载体 :信号不仅仅是一个编号。有时候信号会携带额外信息(比如谁发的、发送时的具体数据、错误原因等)。这些信息存储在 struct sigqueue 节点里,挂在这个链表上。② 支持排队(针对实时信号) :对于 34-64 号实时信号,如果有 10 个信号发过来,内核会分配 10 个 sigqueue 节点挂在链表上。这样解除阻塞后,就能按顺序处理 10 次。稍微了解即可

3. sigset_t

每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储 ,这个类型sigset_t称为**信号集,**可以表示每个信号的"有效"或"无效"状态,在阻塞信号集中"有效"和"无效"的含义是该信号是否被阻塞,而在未决信号集中"有效"和"无效"的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。(类似权限那里的umask)

sigset_t类型对于每种信号用一个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。

函数 作用 本质逻辑
sigemptyset(sigset_t *set) 清空 将位图所有位设为 0
sigfillset(sigset_t *set) 全填 将位图所有位设为 1
sigaddset(sigset_t *set, int signum) int signum 就是信号编号 添加 `set
sigdelset(sigset_t *set, int signum) 删除 set &= ~(1 << (signum-1))
sigismember(const sigset_t *set, int signum) 查询 判断某一位是否为 1

在使用 sigset_t 变量之前,必须先调用 sigemptysetsigfillset初始化!

4. 信号集操作函数 与 demo测试

(1)sigprocmask:操控 Block 表

cpp 复制代码
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

① how (怎么改):这是一个动作指示,有三个选项:

  • SIG_BLOCK :相当于 mask |= set(把 set 里的信号加到黑名单)。

  • SIG_UNBLOCK :相当于 mask &= ~set(把 set 里的信号从黑名单移除)。

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

② set:准备好的新位图。

③ oldset:内核会把修改之前的 Block 位图存进去。如果你不关心原来的状态,传 NULL

(2)sigpending:查看 Pending 表

cpp 复制代码
int sigpending(sigset_t *set);

set 是一个输出型参数。内核会把当前的 Pending 位图拷贝一份,填到你提供的这个变量里。

(3)demo测试

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

// 打印位图
void printSigSet(sigset_t *set)
{
    for (int i = 31; i >= 1; i--)
    {
        if (sigismember(set, i))
            std::cout << "1";
        else
            std::cout << "0";
    }
}

// 同时读取并打印 Block 和 Pending
void showTables()
{
    sigset_t b, p;
    sigprocmask(SIG_BLOCK, NULL, &b); // 获取当前 Block
    sigpending(&p);                   // 获取当前 Pending

    std::cout << "  Block:";
    printSigSet(&b);
    std::cout << "  Pending:";
    printSigSet(&p);
    std::cout << std::endl;
}

// 自定义信号处理函数
void handler(int signo)
{
    std::cout << "\n>>> 捕捉到信号: " << signo << std::endl;
    // 在 Handler 执行期间,观察 Block 位图的变化
    for (int i = 0; i < 3; i++)
    {
        std::cout << "信号" << signo;
        showTables();
        sleep(1);
    }
    std::cout << ">>> 信号 " << signo << " 处理完毕,退出 Handler" << std::endl;
}

int main()
{
    signal(SIGINT, handler);  // 2号信号
    signal(SIGQUIT, handler); // 3号信号

    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGINT); // 初始时我们先手动屏蔽 SIGINT
    sigprocmask(SIG_BLOCK, &set, NULL);

    std::cout << "PID: " << getpid() << "\n";
    std::cout << "初始情况";
    showTables();
    int count = 0;
    while (true)
    {
        std::cout << "进入循环 ";
        showTables();
        sleep(1);
        count++;

        // 3. 10秒后解除屏蔽
        if (count == 5)
        {
            std::cout << ">>> 倒计时结束:解除 SIGINT 屏蔽!" << std::endl;
            sigemptyset(&set);
            sigprocmask(SIG_SETMASK, &set, NULL);
        }
    }

    return 0;
}

四、信号的捕捉

1. 硬件中断

(1)CPU针脚

CPU 的背面那一排排细密的金针或触点就是针脚 。从逻辑上讲,它们是 CPU 的物理接口, 比如地址总线/数据总线针脚 ,就是负责传地址和传数据。其中最关键的一根叫 INTR(Interrupt Request) 。你可以把 INTR 针脚想象成一根**电线,**当外设想发起中断时,就往这根线上通个电(高电平)。CPU 每执行完一条指令,都会去"看一眼"这根针脚上有没有电压。

(2)中断控制器与中断号

CPU 只有一两个 INTR(中断请求) 引脚,但电脑里有几十个硬件设备:键盘、鼠标、网卡、显卡、磁盘、定时器......如果每个设备都直接把电线连到 CPU 的引脚上,不仅引脚不够用,而且当两个设备同时发信号时,CPU 会直接"疯掉",它不知道该先理谁。这时就要有**中断控制器(APIC)**了。

  • 多路复用:把几十路设备的信号汇聚到一两根线上,再传给 CPU。

  • 优先级仲裁:如果键盘和网卡同时来了信号,它根据预设的优先级(比如磁盘优先级高于键盘),决定先放谁进去。

  • 排队机制:没排上的信号先在控制器里挂起,等 CPU 忙完了再送下一个。

中断号是一个简单的整数(0~255),用来区分是哪个设备在闹事。APIC会计算出该硬件中断的中断号n,然后在CPU询问时把中断号发给它。

(3)硬件的寄存器

每个硬件(键盘、网卡、显卡)内部都有自己的小存储单元,叫寄存器 。在中断流程中,它们扮演两个角色:① 状态寄存器 :记录"发生了什么"。比如网卡寄存器里某一位置 1,表示"数据包已到齐";键盘寄存器里置 1,表示"有按键按下"。② 数据寄存器:存放具体内容。比如你按下的到底是 'A' 还是 'B',就存在键盘的数据寄存器里。

在中断流程中的作用 : 当 CPU 被中断引脚"叫醒"后,它并不知道具体发生了什么。它必须通过 in / out 指令去读硬件的寄存器,才能把具体的按键值或数据包拿出来。

(4)中断向量表

它在内存中就是一个数组 。数组的下标就是中断号 ,数组的内容是一个结构体。 结构体的核心内容就是中断对应的处理函数在内核中的起始地址。这是从硬件跳转到软件代码的核心桥梁。

(5)全流程阐述

  • 物理触发:你按下了键盘(比如 Ctrl+C)。

  • 针脚通电:键盘控制器将电信号发给 中断控制器 (APIC) 。APIC 经过优先级仲裁,通过 CPU 针脚 (INTR) 向 CPU 发起高电平信号。

  • 中断号确定:CPU 执行完当前指令,感知到针脚电位变化,向 APIC 回复确认。APIC 通过数据总线送回一个 中断号 (例如 0x21)。

  • 硬件动作:CPU 立即停止当前的 main 任务,自动将当前的 EIP/RIP (指令指针)CS (代码段寄存器)SS/SP (栈指针) 压入该进程的内核栈(现场保护)。

  • 查表 (IDT):CPU 根据 IDTR 寄存器 找到内存中的 中断向量表 (IDT) ,以中断号 0x21 为索引,找到键盘驱动程序的入口地址,这时他就会陷入内核,内核驱动程序访问键盘控制器的 数据寄存器,读取数据,并执行方法

  • 执行完毕,恢复现场

硬件中断的源头是外部设备,它是异步的;

硬件异常的源头是内部硬件,它是同步的。

(这是信号的产生层面,和信号本身无关)

2. 时钟中断

时钟中断也属于硬件中断的一种,它的优先级是极高的。

(1)硬件源头

在主板上,有一个专门的硬件芯片,它是一个晶体振荡器 。它以极高的频率产生脉冲,内核在启动时会编程设定它的频率(比如每秒跳 100 次或 1000 次)。每当计数值减到 0,它就会通过电线(针脚)给 CPU 发送一个硬件中断信号。这个信号对应的中断号通常是 0x20

(2)该中断程序的作用

① 更新系统时间

内核维护着自 1970 年 1 月 1 日以来的秒数。每跳一次,内核就累加这个计数器。具体方法在alarm中已经讲过。

② 维护定时器

alarm函数等,上面讲过

③ 检查进程的时间片(核心功能)

时钟中断在不断地强行切任务是你的电脑能同时跑很多程序的原因!

CPU的主频

它是 CPU 内部执行指令的**最小节拍,**决定了指令跑得有多快。主频非常快(纳秒级),而时钟中断的频率相对慢得多(毫秒级),主频负责"让指令跑起来",时钟中断负责"让内核醒过来"。时钟中断提供了固定的调度周期,而 CPU 主频决定了该周期内指令执行的密度;因此,它们虽然是两个独立的时钟源,却是 OS 评估进程任务进度和进行多核负载均衡的关键参考。

3. 软中断

我们这里谈的全部为广义软中断。

(1)概念

有人一定会问,那只有硬件有中断吗,那肯定不是,毕竟OS要通过中断"前进"。广义软中断 是一个操作系统教材中常用的分类概念,指所有由软件(而非外部硬件)触发的中断类事件。它是相对于"硬中断"(外部设备触发)的一个相对的概念。它主要包括:

概念 触发方式 运行位置 主要用途
Trap (陷阱) int 0x80 / syscall 指令 内核态 系统调用,主动进入内核。
Exception (异常) 除 0、非法内存访问 内核态 硬件报错,被动进入内核。
Softirq (内核软中断) 内核代码调用 raise_softirq 内核态 中断下半部,内核内部异步任务。

为什么硬件异常也属于软中断

在广义的分类中,我们将"中断"分为两类,硬中断 :来自 CPU 外部 (通过电平跳变捅针脚),软中断(同步异常) :来自 CPU 内部 (在执行指令时由于逻辑或指令要求触发)。因为它不是外部设备触发的,而是 CPU 在执行你那行 *ptr = 10; 指令时,内部逻辑走不通了,被迫停下来跳进内核,但发现它的人是硬件。

(2)int 0x80 / syscall 指令

① int 0x80(经典陷阱指令)

  • 本质 :这是一条汇编指令。intInterrupt 的缩写,0x80 是一个十六进制的中断号(128号)。

  • 原理 :在早期的 x86 架构中,Linux 选择使用 128 号中断作为系统调用的入口。当 CPU 执行到 int 0x80 时,它会像处理硬件中断一样,去 IDT里查 128 号对应的函数地址。

  • 缺点:太慢了。因为它要像硬件中断一样,经历复杂的查表、权限检查、压栈过程。

syscall(现代指令)

  • 本质 :这是 AMD/Intel 后来专门为系统调用设计的 CPU 指令

  • 原理:它不再去查 IDT 表。CPU 内部有一个特殊的寄存器(MSR 寄存器),里面直接存着内核系统调用的入口地址。

  • 优点:极快。它精简了大量的保护性检查和压栈动作,直接"一键切入"内核,是现在 64 位 Linux 的默认选择。

(3)陷阱的处理流程

  • 在 C++ 调用 read() 时,标准库会把系统调用号 (比如 read 是 0)放进 eax 寄存器,把参数放进 ebx, ecx 等寄存器。
  • 程序执行 int 0x80。CPU 硬件感知到这是一个"陷阱(Trap)"指令。
  • CPU 权限从 Ring 3(用户态)切换到 Ring 0(内核态),并保护现场。
  • CPU 查 IDT 表中的第 0x80 项,跳到内核定义的通用入口函数
  • 内核根据系统调用号 ,去查一张叫 sys_call_table 的函数指针数组,数组下标就是系统调用号, 最终调用真正的内核函数 sys_read()
  • 内核跑完 sys_read,把结果放回 rax,然后回到用户态。

(4)检测信号的时机

从内核态返回用户态前是检测信号的绝对核心,无论是系统调用结束、硬件中断处理完毕,还是异常恢复,只要CPU准备从内核回到用户空间,都必须经过这个"安检口"检查pending位图,如果没有其他干扰,检查到就处理。

各种中断冲突了怎么办?(了解)

各种中断不会真的"撞车",因为硬件有优先级裁决,软件有屏蔽和分阶段处理机制。高优先级的可以打断低优先级的(嵌套),内核可以在关键时刻关门谢客(屏蔽),并通过"上半部"快速登记、"下半部"(狭义软中断)慢慢处理的方式,确保系统既不会丢中断,也不会卡死。

4. 用户态与内核态

(1)概念及原因

想象一下,如果任何一个普通的 C++ 程序都能直接操作硬盘、修改内存分配、或者关闭 CPU 的中断针脚,那么一个写错的死循环或者一个恶意木马就能让整个系统瞬间崩溃。

为了防止这种情况,CPU 硬件设计了不同的特权级别

  • Ring 0 = 内核态:拥有至高无上的权力,可以执行任何 CPU 指令,访问任何硬件。

  • Ring 3 = 用户态:受限的权力,只能访问属于自己的内存空间,严禁直接操作硬件。

当前运行在哪个 Ring,由 CS 寄存器的最后2个比特位(RPL)决定:00 是 Ring 0(内核态),11 是 Ring 3(用户态),具体不深究。

维度 用户态 (User Mode) 内核态 (Kernel Mode)
指令权限 只能执行普通运算指令(加减乘除)。 可以执行特权指令(如关中断、操作 MMU、停机)。
内存访问 只能访问进程自己的虚拟内存。 可以访问所有物理内存和内核空间。
崩溃后果 顶多是该进程"段错误"被杀掉。 整个操作系统崩溃。

(2)转换机制

  • 系统调用(主动申请) :通过 syscallint 0x80 指令,像客户向柜员申请取钱一样,主动进入内核。

  • 异常(被动陷入):比如程序除零、野指针。CPU 硬件强制把权限提升到内核态,让内核来处理这个"肇事者"。

  • 硬件中断(外力介入):时钟中断、键盘中断。不管你愿不愿意,CPU 强行切入内核态去处理硬件请求。

(3)内存布局

用户态 只能看到用户空间 ,而内核态 能看到整个地址空间。 每个进程有自己的用户页表,但所有进程共享同一份内核页表, 都指向相同的物理地址。还有一个概念是内核栈, 每个进程都有两个栈,用户栈和内核栈,一旦切换到内核态,CPU 会立刻自动换用内核栈,把用户态的"书签"(寄存器、返回地址)压进去,这就是保护现场。

(4)扩展

5. 信号的捕捉流程(重点)

第一阶段:信号的"起源、涂色与实时发现"(内核态/硬中断)

  • 物理触发:键盘产生电信号,APIC 捅 CPU 针脚。
  • 硬中断入核 :CPU 查 IDT 表,通过 0x21 号找到键盘驱动入口。
  • 身份转换 :CPU 从 Ring 3 切换到 Ring 0,自动换上当前进程的内核栈,压入用户态现场。
  • 涂黑位图 :驱动程序识别出 Ctrl+C。内核调用 send_signal,在当前进程 task_structPending 位图 里把 SIGINT 涂成 1
  • 回程例行检查 :驱动逻辑跑完,进入中断返回路径(如 ret_from_intr)。内核执行 test_thread_flag,发现位图刚才被涂黑了。
  • 感知与分流 :内核意识到"有信号要处理",不再执行直接返回用户态的原计划 ,而是原地转向执行 do_signal()

第二阶段:信号的"出鞘与跳转"(跨越态切换)

  • 入核判定(第 1 次切换):其实就是刚才那个键盘中断引发的入核。它既是"产生信号"的入口,也成了"检测信号"的契机
  • 构造用户栈帧 :内核不能在内核态跑你的 handler(权限太高,不安全)。内核会在你的用户栈 上,强行压入一个伪造的现场(包括信号处理完后要去的地址 sigreturn,这是函数栈帧的知识)。
  • 修改返回地址 :内核把保存好的、原本指向你自己代码的 RIP 临时改成你的 handler 函数地址
  • 返回用户态(第 2 次切换) :执行 IRET。CPU 以为自己回到了断点,结果一睁眼,发现自己在跑你的 handler

第三阶段:信号的"处理与回执"(用户态)

  • 执行业务 :你的 handler 打印了一句 "捕捉到信号"
  • 收尾工作handler 执行完毕,它会跳向之前内核在用户栈里埋伏好的 sigreturn(这又是一个系统调用/软中断)

第四阶段:信号的"归位与重生"(内核态 → 用户态)

  • 最后入核(第 3 次切换) :通过 sigreturn 再次进入内核。
  • 现场还原 :内核把之前保存在内核栈里的、真正的 while(1) 断点数据(原始的 RIPEFLAGS)拿出来,覆盖掉那个临时的 handler 现场。
  • 最终返回(第 4 次切换) :执行 IRET。CPU 这次真的回到了你的代码上。

默认和忽略的流程是什么?

当信号产生中断进入内核,准备"涂色"时,内核会先看一眼该进程的 sighand_struct(信号行为表),如果是 SIG_IGN或者是默认动作且默认即为忽略 (比如 SIGCHLD),它直接拒收信号,位图(Pending)连 1 都不会被涂上,更不会有后续的 4 次切换。如果是终止类信号(比如 SIGKILL 9号), 内核根本不写位图等待,直接在此时此刻就开始执行销毁进程的逻辑。所以,这种情况在内核态直接"闭环"处理了,用户态进程完全感知不到。

而其他的大多数信号如果是SIG_DFL(默认终止/暂停),它和自定义捕捉的处理方式一样,只不过在执行函数时如果是**默认终止,**内核在安检口直接把进程送进"坟墓",不再返回用户态。

6. 中断与信号的关系(关键理解)

同学们,大家有没有发现信号的捕捉流程与中断的处理机制非常相似,它们都先是打断当前任务,保存上下文, 一个查中断向量表 ,另一个查**handler表,**然后去各自的地方去处理,最后来恢复现场。这印证了

++所以,信号一定是由硬中断或者软中断引起的,但是,不是所有中断都一定会产生信号,注意我说的是信号的产生,信号本身和中断几乎没有关系,我们之所以说它是软件层面的中断,是因为它和中断的处理机制极其相似,而且只发生在软件层上,它的处理和中断的唯一关系就是,中断会给信号处理带来契机。另外,信号的产生与递达是两个执行流程,它们是完全解耦的,你别看上面所说的四次切换好像信号在产生完就被立马处理了,那是因为产生信号本身就是一个契机,如果产生的那个信号现在被阻塞了,那本次中断返回时也不会去递达信号,或者A进程给B进程发信号,可B进程因为时间片耗尽压根没在跑,那也不会递达。++

7. sigaction函数

(1)struct sigaction

cpp 复制代码
struct sigaction 
{
    void     (*sa_handler)(int);          // 方式 A:传统的处理函数
    void     (*sa_sigaction)(int, siginfo_t *, void *); // 方式 B:携带详细信息的处理函数
    sigset_t   sa_mask;                   // 关键!信号屏蔽字(排队机制)
    int        sa_flags;                  // 行为标志位,了解即可
    void     (*sa_restorer)(void);        // 不用管
};
  • sa_handler:传统的信号处理函数,只接收信号编号
  • sa_sigaction:增强版处理函数,接收更多信息(需设置sa_flags |= SA_SIGINFO,尽了解,上面和这个二选一)
cpp 复制代码
//sa_sigaction
void handler(int sig, siginfo_t *info, void *context) 
{
    // sig: 信号编号
    // info: 信号详细信息,siginfo_t 结构体里包含了 si_pid(发送者进程号)、si_addr(触发异常的内存地址)等宝贵数据。
    // context: 用户上下文(很少用)
}

sa_mask:防止"信号套娃",这是 sigaction 最强大的地方。

  • 默认行为 :当你在执行 SIGINT 的处理函数时,内核会自动阻塞 SIGINT,防止它递归嵌套。

  • 扩展行为 :如果你希望在处理 SIGINT(Ctrl+C)时,连 SIGQUIT(Ctrl+\)也暂时不要打断我,就把 SIGQUIT 加入到 sa_mask 中。

  • 物理本质 :当 CPU 准备切回用户态跑 handler 前,内核会把 sa_mask 叠加到该进程的 Block 位图里;等 handler 跑完,再恢复。

(2)sigaction函数

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

int sigaction(int signum, 
              const struct sigaction *act,
              struct sigaction *oldact);
参数 含义
signum 要操作的信号编号(SIGINT、SIGTERM 等,除 SIGKILL/SIGSTOP)
act 新的处理方式(若 NULL,则不改变)
oldact 旧的的处理方式(若 NULL,则不获取),输出型参数
返回值 成功返回 0,失败返回 -1 并设置 errno

(3)demo测试

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

// 打印位图
void printSigSet(sigset_t *set)
{
    for (int i = 31; i >= 1; i--)
    {
        if (sigismember(set, i))
            std::cout << "1";
        else
            std::cout << "0";
    }
}

// 同时读取并打印 Block 和 Pending
void showTables()
{
    sigset_t b, p;
    sigprocmask(SIG_BLOCK, NULL, &b); // 获取当前 Block
    sigpending(&p);                   // 获取当前 Pending

    std::cout << "Block:";
    printSigSet(&b);
    std::cout << "  Pending:";
    printSigSet(&p);
    std::cout << std::endl;
}

void my_handler(int signo)
{
    std::cout << "捕捉到信号: " << signo << std::endl;

    // SIGINT (2) 和我们在 sa_mask 里设定的信号都会在 Block 位图中显示为 1
    for (int i = 0; i < 3; ++i)
    {
        showTables();
        sleep(2);
    }

    std::cout << "捕捉" << signo << "号信号完成" << std::endl;
}

int main()
{
    struct sigaction act;

    act.sa_handler = my_handler;
    act.sa_flags = 0; // 默认

    // 在处理 SIGINT 时,强制屏蔽 SIGQUIT (3号信号)
    sigemptyset(&act.sa_mask); // 先初始化
    sigaddset(&act.sa_mask, SIGQUIT);

    // 注册两个信号
    sigaction(SIGINT, &act, nullptr);
    sigaction(SIGQUIT, &act, nullptr);

    std::cout << "PID: " << getpid() << std::endl;

    while (true)
    {
        pause();
    }

    return 0;
}

五、SIGCHLD信号

1. 作用解析

子进程正常退出或被信号干掉时,他会给父进程发送SIGCHLD(17号信号),他的默认动作是忽略。我们先来验证一下:

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

// 1. 定义监控函数
void handler(int signo)
{
    std::cout << " 父进程捕捉到了信号: " << signo << std::endl;
    // 清理尸体
    while (waitpid(-1, nullptr, WNOHANG) > 0)
    {
        std::cout << " 已通过 waitpid 回收子进程资源。" << std::endl;
    }
}

int main()
{
    signal(17, handler);
    // 创建子进程
    int count = 0;
    while (count < 10)
    {
        pid_t pid = fork();
        if (pid == 0 && count != 5)
        {
            sleep(2);
            _exit(0);
        }
        std::cout << "父进程正在忙碌中... " << count++ << std::endl;
        sleep(1);
    }

    return 0;
}

为什么 waitpid 要加循环和阻塞?

由于信号位图无法记录相同信号产生的次数(多次触发仅记录一次),父进程可能仅因一次信号检测而进入 Handler。如果父进程正在处理第一个儿子的 SIGCHLD,此时第二个、第三个儿子也死了。Pending 位图已经被第一个信号涂成 1 了。后续的信号再来,发现位图已经是 1,就直接丢弃了。所以当你的 handler 跑完回到用户态,内核只带你进了一次 handler。如果你只调一次 wait,剩下的两个儿子就成了永久的僵尸,所以在 handler 里必须用 while 循环把所有"已死"的儿子全部捞出来。

如果不加WNOHANG,waitpid 会死等。 万一你还有 2 个儿子活得好好的,while 循环转到第 3 次时,waitpid 就会卡死handler 里,直到那两个活着的儿子死掉。

2. 回收进程改进

cpp 复制代码
int main()
{
    signal(SIGCHLD, SIG_DFL);

    int count = 0;
    while (count < 10)
    {
        pid_t pid = fork();
        if (pid == 0 && count != 5)
        {
            sleep(5);
            _exit(0);
        }
        std::cout << "父进程正在忙碌中... " << count++ << std::endl;
        sleep(1);
    }
}

在 Linux 中,如果你显式将 SIGCHLD 设为 SIG_IGN,内核会触发特殊逻辑:**子进程退出时直接释放资源,不再转为僵尸进程,也不通知父进程。**注意:在 Linux 内核里,SIG_DFL(默认忽略)和 SIG_IGN(显式忽略)虽然"动作"一样(我们之前说过),但触发的"资源回收策略"完全不同,它默认忽略时会产生僵尸,但显示设置却不会,你可以理解为特殊处理。

六、可重入函数

1. 概念

main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。一般来说每个控制流程(进程/线程)都有自己的私有栈,但对信号处理函数来说,虽然main 和 handler 是两个不同的控制流程,但它们共享同一个栈空间!但不可重入的根本原因还是"多个执行流访问同一份可变数据"(通常是全局/静态变量)。

2. 判定标准

  • 使用了全局变量或静态变量 (如上面的链表、static int count)。

  • 调用了标准 I/O 库函数 (如 printf, malloc, free)。

  • 调用了不可重入的系统调用(如获取全局状态的函数)。

3. 信号在这里的用途

这时的信号屏蔽字有了大用

  • 保护临界区 :在操作链表前,调用 sigprocmask 把可能干扰你的信号全部封锁(Block)。
  • 执行操作:安全地修改链表。
  • 解除封锁:操作完后再把信号放出来。

通过修改 task_struct 里的 Block 位图,让内核在"回程检查"时,即使 Pending 位图是 1,也因为 Block 位图的拦截而暂时不执行捕捉动作。

七、volatile

先上代码看现象:

cpp 复制代码
#include <cstdio>
#include <csignal>
// int quit = 0;
volatile int quit = 0;
void handler(int signo)
{
    printf("捕捉到信号\n");
    quit = 1; // 信号捕捉函数修改全局变量
}

int main()
{
    signal(SIGINT, handler);
    while (!quit)
    {
        // 执行一些任务
    }
    printf("进程安全退出\n");
    return 0;
}
bash 复制代码
# int quit=0;
yhz@VM-0-5-ubuntu:~/sign-study$ g++ volatile.cpp -o test
yhz@VM-0-5-ubuntu:~/sign-study$ ./test                  
^C捕捉到信号
进程安全退出
yhz@VM-0-5-ubuntu:~/sign-study$ g++ volatile.cpp -o test -O1 #一级优化
yhz@VM-0-5-ubuntu:~/sign-study$ ./test                      
^C捕捉到信号
^C捕捉到信号
^\Quit

# volatile int quit = 0;
yhz@VM-0-5-ubuntu:~/sign-study$ g++ volatile.cpp -o test    
yhz@VM-0-5-ubuntu:~/sign-study$ ./test                  
^C捕捉到信号
进程安全退出
yhz@VM-0-5-ubuntu:~/sign-study$ g++ volatile.cpp -o test -O3 #三级优化
yhz@VM-0-5-ubuntu:~/sign-study$ ./test                      
^C捕捉到信号
进程安全退出

当你开启编译器优化(如 g++ -O1)时,编译器会盯着 main 函数看:它发现 while 循环里根本没有修改 quit 的代码。它心想:"既然循环里没改 quit,那 !quit 永远是真的,我为了提速,直接把 quit 的值一次性装载到 CPU 寄存器里,以后每次判断直接看寄存器,不用再去内存找了。"所以你第二次怎么发都不改。

当你把声明改为 volatile int quit = 0; 时,你给编译器下达了死命令:

  • 禁止寄存器缓存 :每次用到这个变量,必须去内存地址里读取。

  • 禁止指令重排:编译器不能为了优化性能而随意挪动涉及这个变量的代码顺序。

所以,C语言关键字的最后一个现在也学完了:volatile,保证内存可见性!



后记:

从内核态的"出鞘",到用户态 Handler 的"回执",再到最后 sigreturn 的"重生",每一个步骤都严丝合缝。学习信号处理,本质是在学习权力的边界, 进程何时必须放下手头的工作去响应系统的号召。而文章所讲的中断是你提升水平,拔高思维的重要一环,希望大家可以认真研究,如果该文章对大家有帮助,麻烦点个小心心支持一下吧!

相关推荐
能喵烧香1 小时前
跨越系统的开源尝试:KDE Windows版本全解析
linux·windows·开源
智算菩萨2 小时前
OpenAI Codex 国内使用完全指南:Windows/macOS/Linux 三平台详细安装配置教程(现在最新的有gpt-5.3-codex和gpt-5.4)
linux·windows·gpt·macos·ai·ai编程·codex
Yupureki2 小时前
《Linux网络编程》4.应用层HTTP协议
linux·服务器·c语言·网络·c++·http
孙同学_2 小时前
【Linux篇】网络层与数据链路层详解
linux·网络·智能路由器
拾光Ծ2 小时前
【Linux系统】进程信号(上)
linux·运维·服务器·面试·信号处理
咖喱o2 小时前
网络-堆叠
linux·运维·服务器·网络
Java面试题总结2 小时前
一文搞定 Linux Nginx 从安装、启动到 nginx.conf 全配置详解(新手也能看懂)
linux·运维·nginx
齐齐大魔王9 小时前
linux-僵死进程处理
linux·运维·服务器
wuminyu12 小时前
专家视角看Java字节码加载与存储指令机制
java·linux·c语言·jvm·c++