Linux信号机制

文章目录

信号基本框架

信号通常分为几个阶段:

  1. 信号产生(Signal Generation)
  2. 信号保存(Signal Pending)
  3. 信号递达(Signal Delivery)
  4. 信号处理(Signal Handling)

信号的基本概念

信号(Signal)是操作系统向进程发送的一种异步事件通知机制,用于通知进程某种事件已经发生。

例如:

  • 用户按下 Ctrl+C
  • 定时器到期
  • 非法内存访问
    操作系统会向进程发送相应的信号。
    Linux 信号是一种异步事件通知机制,用于向进程通知某种事件已经发生。信号产生后会被记录为未决信号,并在适当时机由进程进行处理,其处理方式包括默认处理、自定义处理函数或忽略信号。

信号产生的异步性

信号具有 异步(Asynchronous)特性

专业表述:

信号的产生与进程当前执行的控制流程是相互独立的,因此信号的产生与进程执行之间是异步关系。

也就是说:

  • 信号 可以在任何时刻产生
  • 与进程正在执行的代码 没有时间顺序保证
    例如:
text 复制代码
进程正在执行代码
        ↓
信号随时可能到达

因此:
信号属于异步事件通知机制。


信号不一定立即处理

由于信号是异步产生的,因此:

信号到达进程时,不一定会被立即处理。

原因:

  • 进程可能正在执行关键代码
  • 信号可能被暂时阻塞(blocked)
  • 内核可能延迟调度信号处理
    因此信号处理通常发生在:
    进程返回用户态或安全点时。

信号的"未决状态"(Pending Signal)

由于信号 不会立即处理,因此必然存在一个时间窗口:

text 复制代码
信号产生  →  信号被处理

在这个时间窗口中:

操作系统必须记录该信号已经到达。

这在操作系统中称为:
未决信号(Pending Signal)

专业描述:

当信号产生但尚未被进程处理时,该信号处于未决状态,内核会在进程的信号集合中记录该信号。

Linux 内核中通常用 位图(bitmask) 来保存未决信号。


信号处理方式

当进程处理信号时,通常有三种处理方式:

默认处理(Default Action)

操作系统为每个信号定义了 默认行为

例如:

信号 默认行为
SIGINT 终止进程
SIGTERM 终止进程
SIGSTOP 暂停进程
SIGCHLD 忽略

自定义处理(Signal Handler)

进程可以通过系统调用注册信号处理函数:

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

或者:

c 复制代码
sigaction()

当信号到达时:

text 复制代码
执行用户自定义的信号处理函数

忽略信号(Ignore)

进程也可以选择忽略某些信号:

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

表示:

text 复制代码
信号到达后不执行任何操作

注意:

有些信号 不能被忽略

  • SIGKILL
  • SIGSTOP

信号处理的一般流程

完整流程如下:

text 复制代码
1 信号产生
      ↓
2 内核记录为 pending signal
      ↓
3 进程在合适时机检测信号
      ↓
4 根据信号处理策略执行动作

处理策略包括:

  • 默认动作
  • 自定义处理函数
  • 忽略信号

信号机制的关键特性

从操作系统角度总结,信号机制具有以下特性:
事件通知机制

信号用于通知进程某个事件已经发生。
异步性Asynchronous)

信号可以在任意时刻产生,与进程执行流程无关。 延迟处理

信号到达时不一定立即处理。
未决信号机制

信号产生后,如果未立即处理,会被记录为 pending signal
多种处理策略

信号处理方式包括:

  • 默认动作
  • 用户自定义处理函数
  • 忽略信号

在操作系统中,信号是一种用于通知进程异步事件的机制。信号的产生具有异步性,因此进程在接收到信号时不一定立即处理。操作系统会在进程的未决信号集合中记录该信号,并在合适的时机进行处理。信号的处理方式包括默认处理、用户自定义信号处理函数以及忽略信号三种策略。


信号的基本共识

首先需要明确一个基本共识:信号是发送给进程的事件通知机制

在 Linux/Unix 系统中,可以通过命令:

  • kill -9 PID
  • kill -SIGINT PID
    向指定 PID(Process ID) 的进程发送信号。
    因此可以确认:

信号的接收对象是进程。


进程如何识别信号

类比人识别红绿灯需要:

  1. 能够识别信号
  2. 知道对应行为
    进程识别信号同样需要两个要素:

识别信号

进程必须能够识别某种信号类型,例如:

  • SIGINT
  • SIGTERM
  • SIGKILL
    由于进程本质是程序代码的执行实例,因此:

进程对信号的识别能力是由程序员通过程序代码实现的。

换句话说:
信号识别逻辑由程序设计实现。


对信号产生行为

当进程识别到信号后,需要执行相应行为,例如:

  • 终止进程
  • 忽略信号
  • 执行信号处理函数
    这意味着:

每种信号必须定义对应的处理策略。

信号不一定被立即处理

当进程收到信号时,进程可能正在执行其他代码,因此:

信号不一定会被立即处理。

原因:

  • 进程可能正在执行关键代码
  • 当前代码优先级更高
  • 系统调度策略影响
    因此:
    信号处理具有延迟性。

信号必须被暂存

由于信号可能无法立即处理,因此系统必须解决一个问题:

信号到达后,在被处理之前如何保存?

因此进程必须具备:
信号暂存能力

也就是说:
信号需要被记录下来,等待后续处理。


信号在进程中的存储位置

进程在操作系统中由 PCB(Process Control Block) 描述。

在 Linux 中,PCB 对应的数据结构是:

复制代码
task_struct

因此:

信号状态会被保存在进程的 task_struct 中。


信号的存储方式(位图)

Linux 使用 位图(bitmap) 记录信号状态。

普通信号编号:

复制代码
1 ~ 31

因此可以使用一个 32位整数 来表示:

c 复制代码
struct task_struct {
    ...
    unsigned int signal;
    ...
};

位图规则:

位位置 表示信号
bit1 signal1
bit2 signal2
bit3 signal3
... ...
bit31 signal31
位值含义:
位值 含义
0 未收到信号
1 已收到信号
例如:
复制代码
00000000000000000000000000001000

表示:

复制代码
收到了 signal4

信号发送的本质

教材中通常称为:

发送信号 (send signal)

但从系统实现角度来看:
信号发送的本质是修改进程PCB中的信号位图。

例如:

发送 signal9

复制代码
bit9 = 1

即:

复制代码
signal_bitmap |= (1 << 9)

因此:

信号发送 ≈ 修改 PCB 中的信号标志位。

并不是真正意义上的"传输数据"。


谁有权限修改信号位图

需要注意:

  • PCB 是 内核数据结构
  • task_struct操作系统维护
    因此:

用户程序没有权限直接修改 PCB。

只有:
操作系统内核

才能修改进程的信号位图。


发送信号的最终结论

因此可以得到一个重要结论:

所有信号发送行为,本质上都是操作系统内核修改目标进程PCB中的信号状态。

即:

复制代码
用户程序
    ↓
系统调用
    ↓
操作系统内核
    ↓
修改目标进程 PCB 信号位图

进程处理信号的三种方式

当进程处理信号时,可以采取三种策略:

默认处理(Default Action)

操作系统定义默认行为,例如:

  • 终止进程
  • 产生 core dump
  • 忽略

捕捉信号(Signal Catch)

进程可以注册:

复制代码
signal handler

即:
自定义信号处理函数


忽略信号(Ignore)

进程可以选择:

复制代码
忽略该信号

例如:

复制代码
SIG_IGN

信号发送

在 Linux / Unix 系统中,无论信号通过何种方式产生,其本质都是:

由操作系统内核向目标进程设置相应的信号状态。

原因如下:

  1. 进程的控制信息存储在 PCB(Process Control Block)
  2. Linux 中 PCB 对应的数据结构是:
c 复制代码
task_struct
  1. PCB 属于 内核数据结构
    因此:

用户程序没有权限直接修改 PCB。

只有:
操作系统内核(Kernel)

才能修改进程的信号状态。


用户发送信号必须依赖系统调用

由于用户程序不能直接访问内核数据结构,因此如果希望用户程序能够发送信号,就必须通过:

系统调用(System Call)

来完成。

因此操作系统必须提供一组 信号相关系统调用接口,用于:

  1. 发送信号
  2. 设置信号处理方式
  3. 处理信号
    例如常见接口:
系统调用 作用
kill() 向进程发送信号
signal() 设置信号处理方式
sigaction() 更规范的信号处理接口
raise() 向当前进程发送信号

kill 命令的底层实现

在 Linux 中常用命令:

bash 复制代码
kill -9 PID

虽然这是一个 shell 命令,但其本质是:

调用系统调用 kill()

执行流程如下:

复制代码
用户命令 kill
        ↓
shell 调用 kill() 系统调用
        ↓
内核修改目标进程 PCB 信号位图
        ↓
目标进程收到信号

因此可以得出结论:

所有信号发送最终都由操作系统内核完成。


信号产生

键盘组合键

在终端环境中,某些 键盘组合键 会触发信号。

例如:

组合键 信号
Ctrl + C SIGINT
Ctrl + \ SIGQUIT
Ctrl + Z SIGTSTP
这些信号通常用于控制前台进程。

Ctrl+C 的工作机制

当用户在终端按下:

复制代码
Ctrl + C

执行流程如下:

复制代码
键盘输入
      ↓
终端驱动识别按键
      ↓
操作系统内核解释为 SIGINT
      ↓
内核向前台进程发送 SIGINT
      ↓
进程处理该信号

其中:

复制代码
SIGINT = 信号编号 2

SIGINT 的默认行为

在 Linux 中,可以通过手册查看信号说明:

bash 复制代码
man 7 signal

SIGINT 的默认行为是:

终止进程(Terminate Process)

因此:

当程序运行时按 Ctrl+C,进程会直接结束。


信号处理方式

进程收到信号后,可以采用三种处理策略:

默认处理(Default Action)

使用系统定义行为,例如:

  • 终止进程
  • 停止进程
  • 忽略信号

忽略信号(Ignore)

进程可以选择忽略信号,例如:

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

捕捉信号(Signal Catch)

进程可以定义 信号处理函数(Signal Handler)

例如:

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

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

注册信号处理函数:

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

signal 函数原型

Linux 提供的信号注册接口:

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

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

参数说明:

参数 含义
signum 信号编号
handler 信号处理函数
其中:
c 复制代码
void handler(int signo)

是一个 函数指针类型,表示信号处理回调函数。


信号处理函数的执行机制

需要注意:

调用 signal() 时:

只是注册信号处理函数,并不会立即执行。

执行流程如下:

复制代码
程序启动
    ↓
注册 signal handler
    ↓
程序继续执行
    ↓
收到对应信号
    ↓
内核触发信号处理函数

因此:

信号处理函数本质上是一种 回调函数(callback)

示例:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.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;
}

运行程序后:

复制代码
Ctrl+C

输出:

复制代码
进程捕捉到信号: 2

说明:

SIGINT 已被程序捕获。


为什么 Ctrl+C 不再终止程序

因为:

我们通过

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

修改了 SIGINT 的默认行为。

默认行为:

复制代码
terminate

现在变为:

复制代码
执行 handler()

因此进程不会退出。

如果希望处理后退出,可以:

cpp 复制代码
exit(0);

SIGKILL 无法被捕捉

即使程序捕获了大量信号,仍然存在一个例外:

复制代码
SIGKILL (9)

特点:

特性 说明
不可捕捉 Cannot be caught
不可忽略 Cannot be ignored
不可阻塞 Cannot be blocked
因此:
bash 复制代码
kill -9 PID

一定可以终止进程。

这是操作系统提供的 强制终止机制,用于防止恶意程序无法被杀死。


键盘信号的产生机制

键盘本质上是一个硬件输入设备。当用户按下特定组合键时,系统执行如下流程:

复制代码
键盘输入
    ↓
终端驱动程序检测按键
    ↓
操作系统解释为特定信号
    ↓
向当前前台进程发送信号
    ↓
目标进程在适当时机处理该信号

因此:

键盘组合键实际上是 触发操作系统发送信号的一种方式


前台进程(Foreground Process)

在 Linux 终端环境中,同一时刻只能有一个前台进程与终端交互

默认情况下:

复制代码
bash shell

是前台进程。

当用户执行某个程序,例如:

bash 复制代码
./my_signal

执行流程如下:

复制代码
bash shell
      ↓
启动新进程 my_signal
      ↓
my_signal 成为前台进程
      ↓
bash 退到后台等待

此时:

  • 键盘输入信号会发送给 my_signal
  • 而不是 bash

可以通过命令查看进程关系:

bash 复制代码
ps ax | grep my_signal

例如:

复制代码
PID    PPID
1391   3815

其中:

  • 1391 为程序进程 PID
  • 3815 为父进程 bash

系统调用

除了键盘输入外,还可以通过 系统调用(System Call) 发送信号。

这是程序向进程发送信号的主要方式。

常见系统调用包括:

系统调用 作用
kill() 向指定进程发送信号
raise() 向当前进程发送信号
alarm() 定时产生信号
本节重点介绍:
复制代码
kill()

kill 系统调用

kill() 系统调用用于向指定进程发送信号。

函数原型:

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

int kill(pid_t pid, int sig);

参数说明:

参数 含义
pid 目标进程 PID
sig 要发送的信号编号
返回值:
返回值 含义
0 成功
-1 失败,并设置 errno

信号发送的权限模型

需要明确一个关键原则:

信号发送的能力属于操作系统内核。

用户程序并不能直接修改进程 PCB。

因此:

复制代码
用户程序
    ↓
系统调用
    ↓
操作系统内核
    ↓
修改目标进程 PCB 信号位图

也就是说:
真正发送信号的是内核,而不是用户程序。

用户程序只是通过系统调用请求内核执行该操作。


系统调用的设计目的

操作系统通常遵循以下原则:

内核功能必须通过 系统调用接口 向用户空间提供服务。

原因包括:

  1. 安全性(Security)
  2. 权限控制(Permission Control)
  3. 系统稳定性(System Stability)
    因此:
    即使操作系统具备发送信号的能力,也必须通过系统调用向用户开放。
    示例程序:通过 kill() 发送信号
    可以实现一个简单程序,通过命令行向目标进程发送信号。
    示例:
cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

using namespace std;

void usage(const char* proc)
{
    cout << "Usage: " << proc << " <PID> <SIGNAL>" << endl;
}

int main(int argc, char* argv[])
{
    if(argc != 3)
    {
        usage(argv[0]);
        exit(1);
    }

    pid_t pid = atoi(argv[1]);
    int signo = atoi(argv[2]);

    int ret = kill(pid, signo);

    if(ret == 0)
        cout << "Signal sent successfully" << endl;
    else
        perror("kill");

    return 0;
}

运行示例:

bash 复制代码
./my_process 1391 2

表示:

复制代码
向 PID=1391 的进程发送 SIGINT 信号

信号产生方式总结

Linux 中常见的信号产生方式包括:

方式 示例
键盘输入 Ctrl+C
系统调用 kill()
进程自身产生 raise()
定时器 alarm()
硬件异常 除零错误
软件异常 非法内存访问

小结

Linux 信号机制的关键结论:

  1. 信号的接收对象是进程

  2. 信号状态存储在 PCB(task_struct)

  3. 信号发送本质是 修改 PCB 中的信号位图

  4. 用户程序必须通过 系统调用 发送信号

  5. 键盘组合键(如 Ctrl+C)也是信号产生方式

  6. Ctrl+C 对应信号:

    SIGINT (2)

  7. 信号处理方式包括:

  • 默认处理
  • 自定义捕捉
  • 忽略信号
  1. signal() 用于注册信号处理函数
  2. 信号处理函数是 回调函数
  3. SIGKILL 无法被捕获或忽略,是系统提供的强制终止信号。

信号默认处理行为

在 Linux/Unix 系统中,当 进程收到信号(signal)时 ,系统会按照该信号的 默认处理动作(default action) 进行处理。

从整体上看:

大多数信号的默认处理行为是终止进程(Terminate)

但不同信号虽然默认行为可能相同,其 语义(meaning)是不同的

换句话说:

  • 信号的意义并不是由处理动作决定的
  • 信号的意义由它所表示的事件决定
    例如:
信号 含义 默认动作
SIGINT 用户按 Ctrl+C 终止进程
SIGQUIT Ctrl+|终止并生成 core
SIGFPE 算术异常(如除零) 终止进程
SIGSEGV 非法内存访问 终止进程
虽然这些信号 默认处理结果都是终止进程,但:

不同信号表示不同类型的异常事件或系统状态。

因此:
信号的价值在于描述不同的系统事件,而不是区分不同的处理动作。


触发条件

硬件条件触发

信号可以通过多种方式产生,例如:

用户产生

用户在终端输入:

复制代码
Ctrl + C
Ctrl + \

终端驱动程序会向前台进程发送信号:

  • Ctrl+C → SIGINT
  • Ctrl+\ → SIGQUIT

软件方式产生

进程可以通过系统调用主动发送信号,例如:

复制代码
kill(pid, signo)
raise(signo)
  • kill() 可以向任意进程发送信号
  • raise() 给当前进程发送信号

硬件异常产生

信号 不一定需要用户显式发送 ,有些信号会 由操作系统自动产生

例如:

复制代码
int a = 10;
a = a / 0;

执行该代码时会产生 除零异常

程序运行结果通常是:

复制代码
Floating point exception (core dumped)

其本质原因是:

CPU 在执行除零运算时产生了 硬件异常(Hardware Exception)

操作系统随后会将该异常 转换为信号 并发送给当前进程。

在除零情况下:

复制代码
SIGFPE (signal 8)

除零异常产生信号的底层机制

整个过程可以从 硬件 → 操作系统 → 进程 三个层次理解。

CPU 执行算术运算

程序代码:

复制代码
a = 10 / 0

CPU 在执行该指令时:

  • 从寄存器读取操作数
  • 执行除法运算

CPU 检测到异常

CPU 内部有一种特殊寄存器:
状态寄存器(Status Register / Flag Register)

其中包含各种 标志位(Flag)

例如:

标志位 含义
ZF 零标志
CF 进位标志
OF 溢出标志
当执行 10 / 0 时:
CPU 会检测到 算术异常,并设置相应的异常标志。
这就属于:

CPU 硬件级异常(Hardware Exception)


操作系统检测到异常

CPU 产生异常后会触发 异常中断(Exception Trap)

此时:

  • CPU 从用户态进入内核态
  • 操作系统获得控制权
    操作系统会判断:
  • 当前异常类型
  • 当前执行的进程

操作系统转换为信号

操作系统会将硬件异常 映射为信号

例如:

复制代码
除零异常 → SIGFPE (8)

随后:

操作系统向当前进程发送该信号。


为什么捕捉 SIGFPE 后信号会不断出现

如果你捕捉了 SIGFPE,程序会出现一个现象:

复制代码
信号处理函数不断被调用

原因是:

  • CPU 指令仍然停留在 除零指令

  • 处理函数返回后

  • 程序继续执行该非法指令
    于是:

    再次触发异常
    → 再次发送 SIGFPE
    → 再次调用 handler

因此就形成 无限触发信号的循环


CPU 寄存器属于进程上下文

在计算机系统中:

  • CPU 寄存器在硬件上 只有一份
  • 但寄存器中的数据 属于当前运行进程的执行上下文
    所谓 进程上下文(Process Context) 包括:
  • 通用寄存器
  • 程序计数器(PC)
  • 栈指针(SP)
  • 状态寄存器(Flags Register)
    当操作系统发生 进程切换(Context Switch) 时:
  1. 保存当前进程寄存器内容
  2. 恢复下一个进程寄存器内容
    因此:

CPU 寄存器的值实际上是进程上下文的一部分。


除零异常的状态不会被用户进程修复

当 CPU 执行除零运算时:

复制代码
10 / 0

CPU 会产生 算术异常(Arithmetic Exception) ,并在 状态寄存器(Status Register) 中设置异常标志位。

例如:

  • Overflow Flag
  • Divide Error Flag
    这些标志位:
  • CPU 硬件维护
  • 用户程序 无法直接修改
    因此:

用户进程无法修复 CPU 的异常状态。

信号不会必然导致进程终止

在 Linux/Unix 系统中:

进程收到信号并不一定会终止。

原因是:

每个信号都有三种处理方式:

  1. 默认处理(Default Action)
  2. 忽略信号(Ignore)
  3. 自定义处理函数(User-defined handler)
    例如:
cpp 复制代码
signal(SIGFPE, handler);

当进程收到 SIGFPE 时:

  • 系统不会执行默认终止动作
  • 而是调用用户注册的 handler 函数
    因此:

通过自定义信号处理函数,可以改变信号的默认处理行为,从而避免进程终止。


进程未退出仍然可能继续执行

如果信号处理函数执行完毕后:

  • 进程 没有退出
  • 没有修复异常状态
    那么该进程仍然是可调度的。
    换句话说:

只要进程没有终止,它仍然会被操作系统调度继续运行。

信号机制是 Linux 中用于 通知进程发生特定事件的一种异步机制

其特点包括:

  1. 信号表示系统中的某种 事件或异常

  2. 信号可以由 用户、进程或操作系统自动产生

  3. 硬件异常(如除零)会被操作系统转换为信号

  4. 进程收到信号后可以

    • 执行默认动作
    • 忽略信号
    • 自定义处理函数
      例如:

    除零异常
    → CPU 检测异常
    → 操作系统捕获异常
    → 转换为 SIGFPE
    → 发送给当前进程
    → 进程执行默认终止动作


为什么 SIGFPE 会不断触发

当发生除零异常时,系统执行流程如下:

  1. CPU 执行非法算术运算
  2. CPU 触发硬件异常
  3. 操作系统检测异常
  4. 操作系统向进程发送 SIGFPE
    如果进程:
  • 捕获了 SIGFPE
  • 但没有终止
  • 也没有修复异常
    那么程序状态仍然存在问题。
    当进程再次被调度时:
  1. 操作系统恢复该进程的寄存器上下文

  2. 状态寄存器中的异常标志仍然存在

  3. 操作系统再次检测到异常状态

  4. 再次向进程发送 SIGFPE
    于是形成循环:

    异常状态存在

    操作系统发送 SIGFPE

    信号处理函数执行

    进程继续运行

    再次调度

    异常状态仍然存在

    再次发送 SIGFPE

因此就会出现:

信号处理函数被反复调用的现象。

可以总结为以下几个关键点:

  1. 信号不会必然导致进程终止
    如果进程注册了信号处理函数,默认终止行为会被替换。
  2. CPU 寄存器属于进程上下文
    在进程切换时会被保存和恢复。
  3. 硬件异常状态由 CPU 维护
    用户进程无法直接修改状态寄存器。
  4. 异常状态未被修复时
    每次进程恢复执行时操作系统都会重新检测到异常。
    因此:

如果异常状态未被修复,而进程又没有退出,操作系统可能会反复向该进程发送相同的信号。


语言层面的异常本质是系统层面的硬件异常

在 C/C++ 程序中,一些常见的运行时错误,例如:

  • 除零运算
  • 空指针解引用
  • 数组越界
  • 野指针访问
    在语言层面通常被认为是 程序错误
    但从操作系统角度来看:

这些错误本质上都是 硬件异常(Hardware Exception)

当 CPU 或相关硬件检测到异常时:

  1. 硬件触发异常
  2. 操作系统捕获异常
  3. 操作系统将异常转换为 信号(signal)
  4. 将信号发送给当前进程
  5. 进程按照信号的默认动作或自定义动作处理
    因此:
    很多程序崩溃的根本原因是进程收到了某种信号。

野指针导致程序崩溃的原因

考虑如下代码:

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

分析:

cpp 复制代码
int *p = NULL;

该语句只是定义了一个指针变量 p,并将其值设为 0,不会产生错误。

但:

cpp 复制代码
*p = 100;

表示对地址 0 进行写操作。

也就是:

复制代码
访问地址 0

空指针访问为什么会触发异常

在 Linux 中,进程访问内存的过程如下:

复制代码
程序
 ↓
虚拟地址
 ↓
页表
 ↓
MMU
 ↓
物理内存

每个进程都有:

  • 虚拟地址空间
  • 页表(Page Table)
    虚拟地址必须通过 页表映射 才能访问物理内存。
    指针本质是虚拟地址
    例如:
cpp 复制代码
int *p

指针变量 p 中保存的值,本质上是一个 虚拟地址

当执行:

cpp 复制代码
*p = 100;

系统执行过程为:

  1. 取出 p 中的值(0)
  2. 将 0 当作虚拟地址
  3. 通过 MMU(Memory Management Unit) 进行地址转换
  4. 查页表

为什么访问 0 地址会出错

在现代操作系统中:

虚拟地址 0 一般不会映射到任何物理内存。

这是为了防止:

  • 空指针访问

  • 程序错误
    因此:
    当 MMU 尝试进行地址转换时:

    虚拟地址 0

    查页表

    没有合法映射

MMU 会触发:

内存访问异常(Memory Fault)


MMU 异常如何变成信号

当 MMU 检测到非法内存访问时:

  1. MMU 产生 硬件异常

  2. CPU 进入 异常处理流程

  3. 操作系统内核获得控制权

  4. 操作系统判断异常类型
    如果是非法内存访问:
    操作系统会向进程发送信号:

    SIGSEGV

信号编号:

复制代码
11

含义:

复制代码
Segmentation Fault
段错误

SIGSEGV 的默认处理行为

查询:

复制代码
man 7 signal

可以看到:

信号 名称 默认动作 含义
11 SIGSEGV Terminate + core 非法内存访问
因此:
当进程收到 SIGSEGV 时:

默认行为是 终止进程并生成 core dump

于是程序就会崩溃。

野指针访问的完整过程如下:

复制代码
程序执行 *p = 100
        ↓
p = 0(空指针)
        ↓
访问虚拟地址 0
        ↓
MMU 查页表
        ↓
发现地址非法
        ↓
MMU 产生硬件异常
        ↓
CPU 进入异常处理
        ↓
操作系统捕获异常
        ↓
操作系统向进程发送 SIGSEGV (11)
        ↓
进程执行默认动作
        ↓
进程终止

从系统层面来看:

  1. C/C++ 程序中的很多运行时错误本质上是硬件异常。
  2. 硬件异常会被操作系统捕获,并转换为 信号(Signal)
  3. 常见对应关系:
程序错误 信号
除零 SIGFPE (8)
非法内存访问 SIGSEGV (11)
非法指令 SIGILL
  1. 进程收到信号后:
  • 默认行为通常是 终止进程
    因此程序表现为 崩溃(crash)

所有程序崩溃,本质上都是进程收到了某个信号。

最常见三个:

复制代码
SIGSEGV  段错误
SIGABRT  abort()
SIGFPE   算术异常
程序异常与信号的关系

在 C/C++ 程序运行过程中,如果发生某些 非法操作,例如:

  • 除零运算
  • 空指针或野指针访问
  • 非法内存访问
    程序通常会直接 崩溃终止
    从操作系统的角度来看,其本质原因是:

程序的非法操作会触发 硬件异常(Hardware Exception) ,操作系统检测到该异常后,会将其转换为 信号(Signal) 并发送给对应进程。


硬件异常触发信号的基本过程

当程序执行非法操作时,系统内部会经历如下过程:

  1. 程序执行异常指令
    例如:
  • 10 / 0(除零运算)
  • *p = 100p == NULL(空指针访问)
    这些操作属于 非法或未定义行为
  1. 硬件检测异常
    CPU 或相关硬件组件会检测到异常,例如:
  • CPU 运算单元检测到算术异常(除零)
  • **MMU(Memory Management Unit)**检测到非法内存访问
    此时硬件会触发 异常(Exception)
  1. 操作系统接管异常
    当硬件产生异常后:
  • CPU 会进入 内核态
  • 操作系统内核获得控制权
  • 操作系统分析异常类型
  1. 异常转换为信号
    操作系统会将硬件异常 转换为对应的信号,并发送给当前进程,例如:
异常类型 信号
除零运算 SIGFPE
非法内存访问 SIGSEGV
非法指令 SIGILL
  1. 进程处理信号
    进程收到信号后,可以:
  • 执行 默认处理动作
  • 忽略信号
  • 自定义信号处理函数
    但对于大多数硬件异常信号而言:

默认处理动作通常是 终止进程(Terminate)

因此程序会表现为 崩溃(crash)

信号产生的第四种方式:软件条件触发

在 Linux/Unix 系统中,信号不仅可以通过:

  1. 用户操作产生 (如 Ctrl+C
  2. 系统调用产生 (如 kill()raise()
  3. 硬件异常产生 (如除零、非法内存访问)
    还可以由 软件条件(Software Condition) 触发。
    所谓 软件条件触发信号,是指:

操作系统在检测到某种特定的软件运行状态或系统条件时,自动向相关进程发送信号。

这种信号的产生 并不依赖用户操作,也不由硬件异常直接触发,而是由操作系统根据系统运行状态主动产生。


管道写入错误(SIGPIPE)

在 Linux 中,管道(Pipe)是一种常见的 进程间通信(IPC)机制

假设存在两个进程:

  • 写进程(writer):向管道写入数据

  • 读进程(reader) :从管道读取数据
    结构如下:

    进程A (writer) → pipe → 进程B (reader)

如果发生以下情况:

  1. 读端关闭(reader 关闭文件描述符)
  2. 写端仍然继续向管道写数据
    此时会出现一个问题:

写入的数据将没有任何进程读取。

为了避免这种 无意义的资源消耗 ,操作系统会采取措施。

当写进程继续写入时:

  • 操作系统检测到 管道读端已经关闭

  • 内核会向写进程发送一个信号:

    SIGPIPE

信号编号:

复制代码
13

其默认处理行为为:

终止写进程(Terminate)

因此写进程通常会被系统终止。


SIGPIPE 的产生原因

SIGPIPE 的产生并不是由于硬件异常,而是由于一种 软件运行条件

复制代码
管道读端关闭 + 写端继续写入

因此:

SIGPIPE 是由 操作系统根据软件运行状态自动产生的信号

这种情况被称为:
Software Condition Generated Signal


定时器信号

在 Linux 系统中,信号不仅可以由用户操作、系统调用或硬件异常产生,还可以由 软件条件(software condition) 触发。其中一个典型例子是 进程定时器信号

Linux 提供了 alarm() 系统接口,用于为当前进程设置一个定时器。

该接口定义如下:

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

unsigned int alarm(unsigned int seconds);

其功能是:

为当前进程设置一个定时器,当指定的时间间隔(以秒为单位)到达后,内核会向该进程发送 SIGALRM 信号

SIGALRM 的信号编号为 14,其默认处理行为是:

终止进程(Terminate Process)

其行为如下:

  1. 进程调用 alarm(n)

  2. 内核启动一个定时器

  3. n 秒时间到达

  4. 操作系统向该进程发送信号
    发送的信号为:

    SIGALRM

信号编号:

复制代码
14

默认处理行为:

终止进程


alarm 的基本作用

alarm() 的作用可以理解为:

为当前进程设置一个 闹钟(Timer)

例如:

c 复制代码
alarm(5);

含义是:

复制代码
5 秒之后
操作系统向该进程发送 SIGALRM 信号

如果进程没有捕获该信号,则默认行为是:

复制代码
终止进程

Linux 中信号的产生方式可以总结为四类:

产生方式 示例
用户操作 Ctrl+C → SIGINT
系统调用 kill(), raise()
硬件异常 除零 → SIGFPE
软件条件 SIGPIPE、SIGALRM

其中:
软件条件产生信号指的是:

操作系统根据程序运行时的某种逻辑条件或系统状态,自动向进程发送信号。

典型例子包括:

  • SIGPIPE:管道读端关闭但写端继续写
  • SIGALRM:定时器时间到达

alarm() 的工作机制

alarm() 的语义类似于为进程设置一个"闹钟"。

当进程调用:

c 复制代码
alarm(1);

其含义是:

  • 当前时刻 不会立即触发信号
  • 内核启动一个 1 秒的定时器
  • 1 秒时间到达
  • 内核向该进程发送 SIGALRM 信号
    如果进程未对该信号进行捕获或处理,则会执行默认行为:
text 复制代码
进程被终止

因此在终端中通常会看到类似信息:

text 复制代码
Alarm clock

这表示进程因为接收到 SIGALRM 信号而退出。

alarm() 的返回值

alarm() 具有返回值:

复制代码
unsigned int

返回值含义如下:

返回值 含义
0 之前没有设置定时器
非0 返回 之前定时器剩余的秒数
这意味着:

如果在旧定时器尚未触发时再次调用 alarm(),旧定时器会被取消,并返回其剩余时间。


取消闹钟

如果调用:

c 复制代码
alarm(0);

其语义是:

取消当前进程已经设置的定时器

同时函数返回:

复制代码
原定时器剩余的秒数

因此 alarm(0) 的作用等价于:

复制代码
取消定时器 + 查询剩余时间

为什么 alarm 属于"软件条件触发信号"

在信号分类中,SIGALRM 被归类为 软件条件产生的信号

其原因在于:

  • 该信号并非由 用户操作 触发

  • 也不是由 硬件异常 产生

  • 而是由 操作系统内部的软件机制 在满足某种条件时触发
    具体条件是:

    系统时间 >= 设定的超时时间

因此它属于 软件条件触发信号(Software-generated signal)


I/O 操作对性能的影响

循环中包含输出操作:

cpp 复制代码
while(true)
{
    cout << cnt++ << endl;
}

运行结果大约为:

复制代码
4 万次左右

原因是:

每次循环都执行一次 I/O 输出操作

I/O 操作属于 外设访问 ,其速度远慢于 CPU 运算。

因此程序的大部分时间都消耗在:

  • 控制台输出
  • 缓冲区刷新
  • 系统调用
  • 终端或网络传输
    从而严重降低了循环执行次数。

将输出移出循环:

cpp 复制代码
while(true)
{
    cnt++;
}

并在收到信号后打印结果:

cpp 复制代码
void handler(int signo)
{
    printf("%d\n", cnt);
}

此时 1 秒内的统计结果可以达到:

复制代码
3 亿次左右

相比之前约 提高了 10⁴(10000)倍

性能差异的根本原因在于:

I/O 操作远慢于 CPU 计算

CPU 执行一次自增操作只需要极少的指令周期,而一次输出操作通常涉及:

  1. 用户态到内核态切换

  2. 写入内核缓冲区

  3. 终端设备处理

  4. 可能的网络传输(远程终端)
    在远程服务器环境中,输出数据还需要:

    服务器 → 网络 → 本地终端

因此 I/O 延迟会进一步增加。

I/O 是程序性能的重要瓶颈

在高性能程序设计中,应尽量避免:

text 复制代码
在高频循环中执行 I/O 操作

因为 I/O 的性能开销远高于普通计算操作。


SIGALRM 信号只触发一次

在实验代码中,我们为进程设置了一个定时器:

c 复制代码
alarm(1);

该调用会在 1 秒后向进程发送一个 SIGALRM 信号

如果程序捕获该信号,例如:

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

则当信号到达时会执行对应的 信号处理函数(signal handler)

在实验中可以观察到一个重要现象:

  • 信号处理函数只被调用 一次
  • 程序只接收到 一个 SIGALRM 信号
    原因在于:

alarm() 设置的定时器是 一次性定时器(one-shot timer)

即:

  • 定时器触发一次后
  • 就会自动失效
  • 不会再次触发信号

不终止进程时的行为

如果在信号处理函数中 不调用 exit() 终止进程,程序的执行流程如下:

  1. 程序运行并设置定时器
  2. 1 秒后收到 SIGALRM
  3. 内核调用信号处理函数
  4. 处理函数执行完毕
  5. 程序继续执行原来的代码
    因此我们会看到:
  • 处理函数只执行 一次

  • 程序不会退出
    这是因为我们 覆盖了 SIGALRM 的默认行为
    默认情况下:

    SIGALRM → 终止进程

但如果注册了信号处理函数,则由用户代码决定如何处理。


如果希望 每隔一秒执行一次任务,可以在信号处理函数中重新设置定时器,例如:

c 复制代码
void handler(int signo)
{
    printf("%d\n", cnt);
    alarm(1);  // 重新设置定时器
}

程序流程变为:

  1. 设置 alarm(1)

  2. 1 秒后收到 SIGALRM

  3. 执行 handler

  4. 在 handler 中再次调用 alarm(1)

  5. 再过 1 秒再次触发信号
    这样就形成了一个 周期性定时器(periodic timer)
    其逻辑可以表示为:

    SIGALRM → handler() → alarm(1) → SIGALRM → handler() ...


与 sleep 的逻辑类比

这种机制在逻辑上类似于:

c 复制代码
while (true)
{
    sleep(1);
    printf("%d\n", cnt);
}

区别在于:

方法 特点
sleep 主线程阻塞
alarm + signal 通过信号异步触发
alarm() 的方式属于:
复制代码
异步事件驱动机制

sleep() 属于:

复制代码
同步阻塞机制

通过该实验可以得到几个关键结论:

  1. I/O 操作远慢于 CPU 运算,频繁输出会严重降低程序执行效率。
  2. alarm() 设置的定时器是 一次性定时器
  3. 若需要周期性触发信号,需要在信号处理函数中 重新调用 alarm()
  4. 使用 alarm() 与信号机制可以实现 基于时间的事件触发模型

操作系统如何管理多个闹钟

任何进程都可以调用 alarm() 设置定时器,因此系统中可能同时存在大量定时器。

操作系统必须对这些定时器进行统一管理。

在操作系统设计中通常遵循一个基本原则:

复制代码
先描述(Describe),再组织(Organize)

即:

  1. 使用数据结构描述对象

  2. 再通过某种结构组织这些对象
    在操作系统中,多个进程都可以调用 alarm() 设置定时器,因此内核中可能同时存在 大量定时器对象 。为了高效管理这些定时器,操作系统需要使用合适的数据结构进行组织。
    常见的定时器管理策略包括:
    时间轮(Time Wheel)
    时间轮是一种高效的定时器管理结构,通过将时间划分为多个槽(slot)来组织定时任务。
    其核心思想是:

    时间 → 离散化 → 映射到时间槽

当时间推进时,只需要检查当前时间槽中的定时器即可。

这种结构具有:

  • 插入复杂度:O(1)
  • 删除复杂度:O(1)
  • 检查复杂度:O(1)
    因此常用于 高并发服务器定时器系统
    例如:
  • Nginx
  • Nett
  • Redis
    都使用类似的思想实现定时任务管理。

最小堆(Min Heap)

另一种经典实现方式是使用 最小堆(优先级队列)

假设系统中存在 100 个定时器,每个定时器都有一个 超时时间

复制代码
5s
10s
20s
55s
...

可以按 超时时间排序 构建最小堆:

复制代码
            5
         /     \
       10       20
      /  \
    55   ...

最小堆的特点是:

复制代码
堆顶元素 = 最早到期的定时器

操作流程如下:

  1. 定时器加入系统
    → 插入最小堆

  2. 操作系统检查定时器

    检查堆顶节点

  3. 如果:

    current_time >= heap.top().expire_time

说明定时器到期。

此时:

复制代码
1. 取出堆顶
2. 向目标进程发送 SIGALRM
3. 调整堆结构
4. 再次检查新的堆顶

重复该过程直到:

复制代码
堆顶未超时

这种设计的时间复杂度:

操作 复杂度
插入定时器 O(log n)
删除定时器 O(log n)
查询最近超时 O(1)
因此非常适合 中等规模定时器管理

内核中定时器对象的抽象

在概念上,操作系统可以为每一个定时器维护一个结构体,例如:

c 复制代码
struct alarm
{
    uint64_t when;        // 超时时间(时间戳)
    int type;             // 定时器类型(一次性 / 周期性)
    task_struct *task;    // 所属进程
    struct alarm *next;   // 指向下一个定时器
};

各字段含义如下:

字段 作用
when 定时器触发的时间点
type 定时器类型(one-shot / periodic)
task 关联的进程
next 用于组织定时器结构
例如:
如果当前时间为:
复制代码
1000

进程设置:

复制代码
alarm(100)

则:

复制代码
when = 1100

表示未来触发时间。


定时器数据结构组织

操作系统需要对所有定时器进行组织,例如:

复制代码
alarm_list_head → alarm1 → alarm2 → alarm3 → ...

当某个进程调用 alarm() 时:

  1. 内核创建一个 alarm 对象
  2. 填充定时信息
  3. 将其加入 定时器队列

定时器检查机制

操作系统会周期性检查这些定时器,例如:

复制代码
当前时间 = now

对于每个定时器:

复制代码
if (now >= alarm.when)

说明:

复制代码
定时器已经到期

此时内核会执行:

复制代码
向对应进程发送 SIGALRM

伪代码示意:

c 复制代码
for each alarm in alarm_list
{
    if (current_time >= alarm.when)
    {
        send_signal(SIGALRM, alarm.task);
    }
}

因此,从操作系统内部实现角度来看:

alarm() 本质上是 向内核注册一个定时器对象

操作系统负责:

  1. 管理所有定时器

  2. 维护触发时间

  3. 在条件满足时发送信号
    换句话说:

    alarm() ≈ 在内核中创建一个定时器任务

Linux 中 alarm() 的工作机制可以概括为:

  1. 进程调用 alarm(seconds)
  2. 内核创建一个定时器对象
  3. 将其加入定时器管理结构
  4. 内核周期性检查定时器
  5. 当时间到达时发送 SIGALRM
  6. 进程执行默认或自定义信号处理
    因此:

SIGALRM 属于 由软件条件(时间到达)触发的信号


软件条件触发信号的本质

通过上述机制可以看到:

操作系统会 周期性检查定时器是否超时

判断条件为:

复制代码
current_time >= timer.expire_time

当条件成立时:

复制代码
内核 → 向目标进程发送 SIGALRM

整个过程包括:

  1. 定时器创建

  2. 数据结构管理

  3. 时间检查

  4. 信号发送
    这些行为全部由 操作系统软件逻辑完成
    因此:

    SIGALRM 属于 软件条件触发信号

条件就是:

复制代码
定时器超时

信号产生方式总结

在 Linux 系统中,常见的信号产生方式包括以下几类:

产生方式 示例
用户输入 Ctrl+C → SIGINT
命令/工具 kill 命令
系统调用 kill(), raise()
硬件异常 除零 → SIGFPE
软件条件 定时器超时 → SIGALRM
无论信号来源如何:
复制代码
最终发送信号的主体都是操作系统

因为只有操作系统才具有 修改进程状态和向进程发送信号的权限


信号捕获

理论上,进程可以通过信号机制 捕获并处理这些信号,例如:

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

但是对于由 硬件异常产生的信号,即使捕获该信号:

  • 程序内部状态通常已经处于 不可恢复状态
  • 因此继续执行往往没有实际意义
    所以实际开发中:

大多数情况下仍然会让程序终止。


可以将整个机制总结为:

程序非法操作 → 硬件检测异常 → 操作系统识别异常 → 转换为信号 → 发送给进程 → 默认终止进程

因此:
C/C++ 程序在发生除零或野指针访问时之所以会崩溃,本质上是因为进程收到了操作系统发送的异常信号。


异常机制与信号机制的类比

在高级语言(如 C++、Java )中,程序通常使用 异常机制(Exception Mechanism) 来处理运行时错误。

异常机制的基本流程包括:

  1. 抛出异常(Throw)
    当程序检测到异常情况时,可以主动抛出异常,例如:
cpp 复制代码
throw ExceptionType;
  1. 捕获异常(Catch)
    程序可以在适当位置捕获异常并进行处理:
cpp 复制代码
try {
    // code
}
catch(ExceptionType e) {
    // handle
}

这一机制与操作系统中的 信号机制(Signal Mechanism) 在概念上具有一定相似性。

高级语言 操作系统
throw 发送信号
catch 捕捉信号
exception signal
因此可以类比理解为:

异常机制是语言层面对错误处理的抽象,而信号机制是操作系统层面对异常事件的处理机制。


异常或信号通常意味着程序无法继续正常执行

在实际开发中,无论是:

  • 语言层面的异常
  • 操作系统层面的信号
    大多数情况下都表示:

当前程序已经进入 异常状态(abnormal state)

因此常见处理方式通常是:

  1. 记录错误信息(日志)
  2. 输出提示信息
  3. 终止程序
    例如:
text 复制代码
记录日志 → 打印错误信息 → 程序退出

对于由 硬件异常产生的信号 (例如 SIGSEGVSIGFPE),程序通常已经处于不可恢复状态,因此继续执行往往没有实际意义。


为什么仍然需要区分不同类型的异常或信号

虽然许多异常或信号的最终结果都是 终止程序 ,但仍然需要区分不同类型的异常。

原因是:

不同异常或信号反映了不同类型的错误原因。

这对于 问题定位和调试(Debugging) 非常重要。

例如:

信号 含义 可能原因
SIGSEGV Segmentation Fault 野指针、非法内存访问
SIGFPE Floating Point Exception 除零、算术溢出
SIGILL Illegal Instruction 非法指令
SIGABRT Abort 程序主动终止
当程序崩溃时,通过 错误信息或信号类型,开发者可以快速判断问题来源。
例如:
  • 如果出现 Segmentation Fault
    → 通常说明存在 非法内存访问或野指针问题
  • 如果出现 Floating Point Exception
    → 通常说明存在 除零或算术运算异常
    因此:

不同的异常或信号可以帮助开发者快速定位问题原因。


可以从两个层面理解这一机制:

语言层面

高级语言通过 异常机制(Exception) 来处理运行时错误:

  • throw 抛出异常
  • catch 捕获异常

操作系统层面

操作系统通过 信号机制(Signal) 来处理系统级异常:

  • 硬件异常或系统事件产生信号
  • 进程接收并处理信号

核心结论

即使大多数异常或信号最终都会导致程序终止,不同类型的异常或信号仍然具有重要意义,因为它们能够反映 程序发生错误的具体原因,从而帮助开发者定位和修复问题


信号处理

信号是否会立即处理

信号产生之后,并 不会立即执行处理逻辑

实际流程是:

复制代码
信号产生
↓
内核记录信号
↓
等待合适时机处理

信号通常会被记录在 进程控制块(PCB) 中。

PCB 内部会维护:

复制代码
pending signal bitmap

用于表示:

复制代码
当前进程有哪些信号正在等待处理

信号处理行为是预先定义的

在信号真正发生之前,系统已经定义好了处理方式。

每个信号都有 默认行为(Default Action),例如:

行为 含义
Terminate 终止进程
Ignore 忽略信号
Stop 暂停进程
Continue 继续执行
程序员也可以通过接口修改:
复制代码
signal()
sigaction()

来自定义信号处理逻辑。

因此:

即使信号尚未发生,进程也已经知道该如何处理该信号。

操作系统发送信号的本质

在操作系统内部,发送信号并不是直接"调用函数",而是:

复制代码
修改目标进程 PCB 中的信号状态

具体来说就是:

复制代码
设置 pending signal bitmap 中的某一位

例如:

复制代码
SIGALRM → 设置第14位

随后在合适时机:

复制代码
内核触发信号处理流程

终止信号

很多信号的默认行为都是 终止进程,但在 Linux 文档中通常有两种标记:

  1. Terminate(Term)

    终止进程

特点:

  • 进程退出

  • 不生成 core dump
    例如:

    SIGTERM
    SIGINT
    SIGALRM


  1. Core Dump(Core)

    终止进程 + 生成 core 文件

core 文件用于:

复制代码
程序崩溃调试

例如:

复制代码
SIGSEGV
SIGABRT
SIGFPE

core 文件中保存:

  • 进程内存
  • 寄存器状态
  • 调用栈
    开发者可以使用调试工具分析,例如:
    GNU Debugger

信号系统的核心理解可以归纳为:

  1. 信号产生方式很多,但 最终由操作系统发送
  2. 信号不会立即处理,而是 记录在 PCB 中等待处理
  3. 每个信号在发生之前就已经定义了 默认处理行为
  4. 发送信号的本质是 修改 PCB 的信号状态位

Term与 Core 两种终止方式的区别

在 Linux 信号机制中,不同信号具有不同的默认处理动作(default action)

其中最常见的终止类型有两种:

类型 含义 行为
Term(Terminate) 普通终止 进程直接被终止,不生成调试信息
Core(Terminate + Core Dump) 异常终止 终止进程,同时生成 core dump 文件
Term 类型终止

Term 类型信号的默认行为:

  • 终止目标进程

  • 不保存进程运行时状态
    典型信号:

  • SIGTERM

  • SIGINT

  • SIGKILL
    终止流程:

    信号产生

    内核修改PCB中的信号位图

    进程被调度时检测到信号

    执行默认处理动作

    进程终止

特点:

  • 不保留调试信息
  • 不生成 core 文件
  • 通常用于正常终止进程

Core 类型终止

Core 类型信号的默认行为:

  • 终止进程
  • 生成 Core Dump 文件
    Core Dump 文件包含:
  • 进程虚拟地址空间
  • 寄存器状态
  • 调用栈
  • 内存数据
    主要用于:

程序崩溃后的调试分析

常见 Core 信号:

信号 含义
SIGSEGV 段错误
SIGABRT 程序异常终止
SIGFPE 浮点异常
SIGBUS 总线错误
例如:
复制代码
Segmentation fault (core dumped)

表示:

  • 发生了 SIGSEGV

  • 同时生成 core 文件
    开发者可以使用

    gdb program core

分析崩溃原因。

Core Dump(核心转储)

Core Dump 是操作系统在进程异常终止时执行的一种调试机制。

定义:

当进程发生异常终止时,操作系统会将该进程在崩溃时刻的内存状态转储到磁盘文件中,用于后续调试分析。

Core 文件通常包含:

  • 进程虚拟地址空间

  • 寄存器状态

  • 调用栈信息

  • 线程状态

  • 全局变量与局部变量数据
    生成文件形式:

    core.<PID>

例如:

复制代码
core.8961

其中:

复制代码
PID = 发生异常的进程 ID

云服务器默认关闭 Core Dump

在很多 云服务器环境 中,系统默认关闭 Core Dump。

原因:

  • 防止生成大文件占用磁盘

  • 提高系统安全性
    可以使用命令查看资源限制:

    ulimit -a

其中:

复制代码
core file size (blocks, -c) 0

表示:

复制代码
Core Dump 被禁用

开启 Core Dump

可以通过以下命令开启:

复制代码
ulimit -c 1024

含义:

复制代码
允许生成最大 1024 blocks 的 core 文件

再次查看:

复制代码
ulimit -a

会看到:

复制代码
core file size (blocks, -c) 1024

当程序崩溃时:

复制代码
Segmentation fault (core dumped)

并且当前目录会生成:

复制代码
core.<PID>

Core Dump 的作用:事后调试(Post-mortem Debugging)

Core Dump 的主要作用是:

支持 程序崩溃后的离线调试

这种调试方式称为:

复制代码
Post-mortem Debugging

即:
事后调试


使用 GDB 分析 Core Dump

为了使 Core 文件包含完整调试信息,程序编译时需要加入:

复制代码
-g

示例:

复制代码
gcc -g main.c -o program

程序崩溃后:

复制代码
gdb program core.<pid>

例如:

复制代码
gdb mysignal core.8961

GDB 会自动加载:

  • 程序

  • Core 文件

  • 崩溃现场数据
    随后可以直接定位崩溃位置,例如:

    Program terminated with signal SIGSEGV

并显示:

复制代码
mysignal.c:31

即:

程序在 第 31 行代码发生崩溃


数组越界为什么不一定崩溃

在学习 C 语言时,调试阶段(如 Debug / Release 模式分析 )经常会遇到一种现象:

例如:

c 复制代码
int arr[10];

for(int i = 0; i < 13; i++)
{
    arr[i] = i;
}

数组实际大小为 10 个元素 ,但程序访问到了 arr[10]、arr[11]、arr[12]

在实际运行时可能出现以下情况:

  • 程序 没有崩溃
  • 编译 没有报错
  • 程序 仍然正常运行

这种现象的原因涉及多个层次:

语言层原因

C 语言属于 不进行边界检查的语言(No Bounds Checking)

数组访问:

c 复制代码
arr[i]

在编译后会转换为:

c 复制代码
*(arr + i)

本质上只是 指针偏移访问

因此:

  • 编译器不会检测数组越界
  • 运行时也不会自动检查
    这种行为属于:

Undefined Behavior(未定义行为)


操作系统层原因

即使发生数组越界,程序也 不一定崩溃 ,原因是:

操作系统的内存保护机制是 以页(Page)为单位 进行管理,而不是变量级别。

典型页大小:

复制代码
4KB

假设:

复制代码
int arr[10] = 40 Bytes

即使访问:

复制代码
arr[100]

仍可能位于 同一虚拟页内

只要访问地址:

  • 属于当前进程的合法虚拟地址空间

  • 且权限允许访问
    CPU 和操作系统都不会触发异常。
    只有当访问:

  • 未映射地址

  • 受保护区域

  • 内核空间
    CPU 才会产生 Page Fault,随后内核向进程发送:

    SIGSEGV

即:

复制代码
Segmentation Fault

C 语言语义角度

C 语言:

不进行数组越界检查(No Bounds Checking)

因此:

  • 编译器不会报错

  • 运行时也不会自动检测
    所以:

    a[100]

编译后只是:

复制代码
*(a + 100)

即:
一个普通指针访问操作


进程虚拟地址空间角度

数组 a 通常位于:

复制代码
栈区(Stack)

例如函数栈布局:

复制代码
高地址
│
│  局部变量
│  a[10]
│
│  其他栈数据
│
低地址

当访问:

复制代码
a[100]

可能出现三种情况:

情况1:仍然在合法栈空间

复制代码
a[100]

仍然落在:

复制代码
当前进程栈页(stack page)

结果:

  • 操作系统不会检测到异常
  • 程序继续运行
  • 但可能破坏其他变量
    这种情况叫:

Silent Memory Corruption(静默内存破坏)


情况2:访问未映射页

如果越界访问:

复制代码
访问未映射虚拟页

CPU 会触发:
Page Fault

内核检查后发现:

  • 该地址没有映射
    于是发送信号:

    SIGSEGV

结果:

复制代码
Segmentation fault

情况3:访问受保护区域

例如:

  • 内核空间

  • 只读页
    CPU 会产生:
    Protection Fault
    同样导致:

    SIGSEGV


操作系统为什么有时检测不到越界

关键原因:

操作系统只检查页级别(Page Level Protection),不会检查变量级别边界

内存保护粒度:

复制代码
4KB Page

而数组:

复制代码
int a[10] = 40B

远小于一页。

因此:

复制代码
a[10] → a[100]

可能仍在同一页中。

操作系统无法识别。


  1. 数组越界不一定导致程序崩溃
    原因:
  • C 语言不进行数组边界检查
  • 数组访问在编译后仅表现为指针偏移

  1. 操作系统只在访问非法虚拟地址时才会触发异常
    触发条件:
  • 访问未映射页

  • 访问权限违规

  • 地址空间越界
    此时 CPU 触发异常,内核发送:

    SIGSEGV


  1. 如果越界仍处于合法虚拟页内
    则:
  • 操作系统无法检测
  • 程序继续运行
  • 但可能破坏栈或其他数据
    这类错误属于:

未定义行为(Undefined Behavior)


在 C 语言中,数组越界属于未定义行为。

编译器不会进行边界检查,而操作系统只在访问非法虚拟地址时才会触发 SIGSEGV

如果越界访问仍然位于进程合法虚拟页中,程序可能不会崩溃,而是造成内存数据破坏。


总结

可以用以下专业结论总结:

  1. C 语言数组越界属于 未定义行为,编译器不会自动检测。
  2. 操作系统只在访问非法虚拟地址时才会触发异常并发送 SIGSEGV
  3. Linux 信号终止类型分为 TermCore
  4. Core 类型信号会在进程异常终止时生成 **Core Dump 文件
  5. Core Dump 用于 事后调试(Post-mortem Debugging),可以借助 GDB 快速定位程序崩溃位置。

信号捕捉机制

在 Linux 中,可以通过 signal()sigaction() 为信号注册 自定义处理函数

示例:

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

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

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

此时:

  • 当进程接收到 SIGINT
  • 将执行 自定义处理函数
    而不是执行默认动作。
    需要注意:

调用 signal() 只是 注册信号处理函数,并不会立即执行该函数。

处理函数只有在 信号实际到达时 才会被调用。


捕捉所有信号的实验

可以通过循环为多个信号注册同一个处理函数:

c 复制代码
for(int sig = 1; sig <= 31; sig++)
{
    signal(sig, handler);
}

该程序的行为是:

  • 为所有普通信号注册相同的捕捉函数
  • 当信号到达时执行 handler
    同时程序通过循环保持运行:
c 复制代码
while(1)
{
    sleep(1);
}

在此情况下:

  • 进程收到信号时不会执行默认终止动作
  • 仅执行自定义处理函数
    因此许多信号将 无法终止该进程

为什么进程仍然可以被终止

如果允许进程捕获 所有信号 ,将导致严重的系统安全问题:

例如:

  • 恶意程序可以屏蔽所有终止信号
  • 系统管理员无法结束该进程
    为了解决这一问题,Linux 内核设计了 不可捕获信号(Uncatchable Signals)

不可捕获信号

Linux 中有两个特殊信号:

信号 编号 特性
SIGKILL 9 不可捕获、不可忽略
SIGSTOP 19 不可捕获、不可忽略

SIGKILL(信号 9)

特点:

  • 无法被程序捕获
  • 无法被忽略
  • 无法被屏蔽
    因此系统管理员可以通过:
bash 复制代码
kill -9 <pid>

强制终止进程

该操作由 内核直接执行,不会进入用户态信号处理函数。


SIGSTOP(信号 19)

作用:

  • 强制暂停进程
    同样:
  • 不可捕获
  • 不可忽略
    可以通过以下信号恢复运行:
bash 复制代码
kill -18 <pid>

对应信号:

复制代码
SIGCONT

信号捕捉的本质

调用:

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

本质上只是:

在进程的 PCB(Process Control Block) 中注册信号处理函数。

当信号到达时:

  1. 内核修改进程的 信号位图
  2. 进程在合适的执行点检测到信号
  3. 进入用户态执行注册的 handler
    因此:

如果没有信号产生,处理函数不会被执行。


总结

核心结论:

  1. Linux 信号机制包括 产生、保存和处理 三个阶段。
  2. 当程序异常终止时,本质上是 进程接收到了某个信号
  3. 如果信号属于 Core 类型 ,系统可以生成 Core Dump 文件 用于调试。
  4. Core Dump 支持 事后调试(Post-mortem Debugging),可以通过 GDB 快速定位错误代码。
  5. 程序可以通过 signal() 注册 自定义信号处理函数,改变信号默认行为
  6. 为了保证系统可控性,Linux 内核保留了 不可捕获信号
    • SIGKILL (9)
    • SIGSTOP (19)
  7. SIGKILL 可以确保系统管理员始终能够 强制终止任意进程

信号的保存

在 Linux 信号机制中,完整的生命周期通常包括三个阶段:

  1. 信号产生(Signal Generation)
  2. 信号保存(Signal Pending / Signal Masking)
  3. 信号处理(Signal Handling)

此前已经介绍了信号产生的多种方式,例如:

  • 硬件异常
  • 软件条件
  • 键盘输入
  • 系统调用(如 kill
  • 进程间通信
    需要注意的是:

无论信号由哪种方式触发,最终都必须由 操作系统内核 负责向目标进程发送信号。

用户程序并不能直接修改进程控制块(PCB),所有信号操作都必须通过系统调用进入内核完成。


信号递达(Signal Delivery)

定义

当进程实际执行某个信号对应的处理动作时,该过程称为:

信号递达(Signal Delivery)

信号递达可能对应以下几种行为:

  • 执行默认处理动作(如终止进程)
  • 调用用户自定义信号处理函数
  • 执行忽略操作
    例如:
text 复制代码
SIGINT → 执行用户自定义 handler
SIGTERM → 终止进程

上述实际执行处理逻辑的过程,即为 信号递达


信号未决(Pending Signal)

在 Linux 中,信号并不是在产生后立即被处理。
定义

从信号产生到信号递达之间的状态,称为 未决状态(Pending)

即:

复制代码
Signal Generated
        ↓
Pending (未决)
        ↓
Signal Delivered

因此,一个信号在系统中可能处于:

  • 已产生
  • 尚未处理
    的状态。
    该机制的原因在于:
  • 进程可能正在执行关键代码
  • 内核需要在合适的时机进行信号处理
    因此信号会被 暂时保存

信号阻塞(Signal Blocking)

除了"未决状态"外,Linux 还允许进程主动 阻塞某些信号
定义

进程可以通过设置 信号屏蔽字(Signal Mask),使某些信号暂时无法被递达。

如果某个信号被阻塞:

  • 当该信号产生时
  • 不会立即被处理
  • 会一直保持在 Pending 状态

只有在 解除阻塞(Unblock) 后,该信号才可能被递达。

因此信号处理流程可以表示为:

复制代码
Signal Generated
        ↓
Pending
        ↓
(若被阻塞 → 保持 Pending)
        ↓
解除阻塞
        ↓
Signal Delivery

阻塞与忽略的区别

在信号处理机制中,阻塞(Block)忽略(Ignore) 是两个完全不同的概念。

阻塞(Block)

特点:

  • 信号不会被递达

  • 信号会被保存在 Pending 状态

  • 解除阻塞后仍然可能被处理
    即:

    Signal Generated

    Pending

    Blocked

    Unblock

    Delivered

无信号时仍可设置阻塞

信号阻塞机制 并不依赖于信号是否已经产生

也就是说:

  • 即使当前没有任何信号
  • 进程仍然可以提前设置 阻塞某些信号
    当未来这些信号产生时:
  • 它们将直接进入 Pending 状态
  • 不会立即递达。

信号阻塞与进程阻塞的区别

需要区分两个容易混淆的概念:

概念 含义
进程阻塞(Process Blocking) 进程因等待资源进入阻塞状态
信号阻塞(Signal Blocking) 进程屏蔽某些信号

两者完全不同:
进程阻塞

复制代码
Running → Waiting → Ready

原因:

  • 等待 I/O

  • 等待锁

  • 等待资源
    信号阻塞

    Signal Masking

作用:

  • 控制哪些信号可以被递达
    因此:

信号阻塞与进程状态无关。


忽略(Ignore)

忽略是一种 信号处理方式

当信号递达时:

  • 系统执行的处理动作是 Ignore
    因此流程是:

    Signal Generated

    Pending

    Delivered

    Ignore

也就是说:

忽略是 一种处理结果 ,而阻塞是 一种递达控制机制


Pending 与 Block 的关系

需要注意:
Pending(未决)Block(阻塞) 是两种完全不同的概念。

概念 含义
Pending 信号已经产生但尚未递达
Block 进程主动禁止该信号递达

它们之间可能存在组合关系:

  • 未阻塞信号 → 可以递达
  • 阻塞信号 → 会保持 Pending

相关推荐
shughui2 小时前
FinalShell / Xshell 完整教程(下载+安装+使用,2026最新版)
linux·fiddler·xshell·xftp·finalshell·远程连接工具
程序猿编码2 小时前
给你的网络流量穿件“隐形衣“:手把手教你用对称加密打造透明安全隧道
linux·开发语言·网络·安全·linux内核
skilllite作者2 小时前
AI agent 的 Assistant Auto LLM Routing 规划的思考
网络·人工智能·算法·rust·openclaw·agentskills
pengyi8710152 小时前
私网IP映射公网基础原理,搭配代理IP远程访问入门
linux·服务器·网络
AILabNotes3 小时前
014、隐私增强技术:零知识证明与混合网络在网关中的应用
网络·区块链·零知识证明
深圳市九鼎创展科技3 小时前
MT8883 vs RK3588 开发板全面对比:选型与场景落地指南
大数据·linux·人工智能·嵌入式硬件·ubuntu
RisunJan4 小时前
Linux命令-ngrep(方便的数据包匹配和显示工具)
linux·运维·服务器
.千余4 小时前
【Linux】基本指令3
linux·服务器·开发语言·学习
热爱Liunx的丘丘人5 小时前
Ansible-doc及常用模块
linux·运维·服务器·ansible