信号捕捉与不可捕捉机制(进阶篇)

文章目录

信号捕获

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

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 可以确保系统管理员始终能够 强制终止任意进程
相关推荐
小则又沐风a2 小时前
Linux使用指南和基础指令(1)
java·linux·运维
ALINX技术博客2 小时前
【黑金云课堂】FPGA技术教程Linux开发:Petalinux安装
linux·运维·fpga开发
橙子也要努力变强2 小时前
信号的处理方式与生命周期(核心机制篇)
linux·网络·c++
特种加菲猫2 小时前
C++ 容器适配器揭秘:stack, queue 和 priority_queue 的模拟实现
开发语言·c++
小此方2 小时前
Re:Linux系统篇(二)指令篇 · 一:基础六大指令精讲+Linux操作技巧——让你从小白到入门
linux·服务器
Shingmc32 小时前
【Linux】Socket编程TCP
服务器·网络·tcp/ip
SilentSamsara2 小时前
ConfigMap 与 Secret:配置注入的四种姿势与安全边界
linux·运维·服务器·安全·微服务·kubernetes·k8s
飘忽不定的bug2 小时前
记录:RK3576 适配开源GPU驱动(panfrost)
linux·gpu·rk3576·panfrost
Lentou2 小时前
部署项目之systemd部署
linux·运维·服务器