文章目录
- 信号捕获
-
- 异常机制与信号机制的类比
- 异常或信号通常意味着程序无法继续正常执行
- 为什么仍然需要区分不同类型的异常或信号
- 信号处理
- 终止信号
-
- [Term与 Core 两种终止方式的区别](#Term与 Core 两种终止方式的区别)
-
- [Term 类型终止](#Term 类型终止)
- [Core 类型终止](#Core 类型终止)
- [Core Dump(核心转储)](#Core Dump(核心转储))
-
- [云服务器默认关闭 Core Dump](#云服务器默认关闭 Core Dump)
- [Core Dump 的作用:事后调试(Post-mortem Debugging)](#Core Dump 的作用:事后调试(Post-mortem Debugging))
- [使用 GDB 分析 Core Dump](#使用 GDB 分析 Core Dump)
- 数组越界为什么不一定崩溃
-
- [C 语言语义角度](#C 语言语义角度)
- 进程虚拟地址空间角度
- 操作系统为什么有时检测不到越界
- 总结
- 信号捕捉机制
- 不可捕获信号
-
- [SIGKILL(信号 9)](#SIGKILL(信号 9))
- [SIGSTOP(信号 19)](#SIGSTOP(信号 19))
- 信号捕捉的本质
- 总结
信号捕获
理论上,进程可以通过信号机制 捕获并处理这些信号,例如:
cpp
signal(SIGSEGV, handler);
但是对于由 硬件异常产生的信号,即使捕获该信号:
- 程序内部状态通常已经处于 不可恢复状态
- 因此继续执行往往没有实际意义
所以实际开发中:
大多数情况下仍然会让程序终止。
可以将整个机制总结为:
程序非法操作 → 硬件检测异常 → 操作系统识别异常 → 转换为信号 → 发送给进程 → 默认终止进程
因此:
C/C++ 程序在发生除零或野指针访问时之所以会崩溃,本质上是因为进程收到了操作系统发送的异常信号。
异常机制与信号机制的类比
在高级语言(如 C++、Java )中,程序通常使用 异常机制(Exception Mechanism) 来处理运行时错误。
异常机制的基本流程包括:
- 抛出异常(Throw)
当程序检测到异常情况时,可以主动抛出异常,例如:
cpp
throw ExceptionType;
- 捕获异常(Catch)
程序可以在适当位置捕获异常并进行处理:
cpp
try {
// code
}
catch(ExceptionType e) {
// handle
}
这一机制与操作系统中的 信号机制(Signal Mechanism) 在概念上具有一定相似性。
| 高级语言 | 操作系统 |
|---|---|
| throw | 发送信号 |
| catch | 捕捉信号 |
| exception | signal |
| 因此可以类比理解为: |
异常机制是语言层面对错误处理的抽象,而信号机制是操作系统层面对异常事件的处理机制。
异常或信号通常意味着程序无法继续正常执行
在实际开发中,无论是:
- 语言层面的异常
- 操作系统层面的信号
大多数情况下都表示:
当前程序已经进入 异常状态(abnormal state)。
因此常见处理方式通常是:
- 记录错误信息(日志)
- 输出提示信息
- 终止程序
例如:
text
记录日志 → 打印错误信息 → 程序退出
对于由 硬件异常产生的信号 (例如 SIGSEGV、SIGFPE),程序通常已经处于不可恢复状态,因此继续执行往往没有实际意义。
为什么仍然需要区分不同类型的异常或信号
虽然许多异常或信号的最终结果都是 终止程序 ,但仍然需要区分不同类型的异常。
原因是:
不同异常或信号反映了不同类型的错误原因。
这对于 问题定位和调试(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 文档中通常有两种标记:
-
Terminate(Term)
终止进程
特点:
-
进程退出
-
不生成 core dump
例如:SIGTERM
SIGINT
SIGALRM
-
Core Dump(Core)
终止进程 + 生成 core 文件
core 文件用于:
程序崩溃调试
例如:
SIGSEGV
SIGABRT
SIGFPE
core 文件中保存:
- 进程内存
- 寄存器状态
- 调用栈
开发者可以使用调试工具分析,例如:
GNU Debugger
信号系统的核心理解可以归纳为:
- 信号产生方式很多,但 最终由操作系统发送
- 信号不会立即处理,而是 记录在 PCB 中等待处理
- 每个信号在发生之前就已经定义了 默认处理行为
- 发送信号的本质是 修改 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]
可能仍在同一页中。
操作系统无法识别。
- 数组越界不一定导致程序崩溃
原因:
- C 语言不进行数组边界检查
- 数组访问在编译后仅表现为指针偏移
- 操作系统只在访问非法虚拟地址时才会触发异常
触发条件:
-
访问未映射页
-
访问权限违规
-
地址空间越界
此时 CPU 触发异常,内核发送:SIGSEGV
- 如果越界仍处于合法虚拟页内
则:
- 操作系统无法检测
- 程序继续运行
- 但可能破坏栈或其他数据
这类错误属于:
未定义行为(Undefined Behavior)
在 C 语言中,数组越界属于未定义行为。
编译器不会进行边界检查,而操作系统只在访问非法虚拟地址时才会触发
SIGSEGV。如果越界访问仍然位于进程合法虚拟页中,程序可能不会崩溃,而是造成内存数据破坏。
总结
可以用以下专业结论总结:
- C 语言数组越界属于 未定义行为,编译器不会自动检测。
- 操作系统只在访问非法虚拟地址时才会触发异常并发送
SIGSEGV。 - Linux 信号终止类型分为 Term 与 Core。
- Core 类型信号会在进程异常终止时生成 **Core Dump 文件
- 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) 中注册信号处理函数。
当信号到达时:
- 内核修改进程的 信号位图
- 进程在合适的执行点检测到信号
- 进入用户态执行注册的 handler
因此:
如果没有信号产生,处理函数不会被执行。
总结
核心结论:
- Linux 信号机制包括 产生、保存和处理 三个阶段。
- 当程序异常终止时,本质上是 进程接收到了某个信号。
- 如果信号属于 Core 类型 ,系统可以生成 Core Dump 文件 用于调试。
- Core Dump 支持 事后调试(Post-mortem Debugging),可以通过 GDB 快速定位错误代码。
- 程序可以通过
signal()注册 自定义信号处理函数,改变信号默认行为 - 为了保证系统可控性,Linux 内核保留了 不可捕获信号 :
SIGKILL (9)SIGSTOP (19)
SIGKILL可以确保系统管理员始终能够 强制终止任意进程。