目录
🌇前言
在 Linux 中,进程具有独立性,进程在运行后可能“放飞自我”,这会给进程管理带来困扰。因此,为了更好地控制进程的运行,操作系统引入了一种机制,那就是 进程信号。本文将从 什么是进程信号 开始,讲述各种进程信号的产生方式及其作用。
不同的信号就像是交通指示灯,它们代表着不同的执行动作。
🏙️正文
1、进程信号基本概念
1.1、什么是信号?
信号是信息传递的承载方式,是操作系统用于通知进程发生某些事件或要求进程执行特定动作的机制。信号通常代表某种特定的执行动作,类似于生活中的各种信号:
鸡叫 => 天快亮了
闹钟 => 起床、完成任务
红绿灯 => 红灯停,绿灯行
这些信号通常是我们日常生活中习惯的现象,遇到这些信号时,我们会立刻做出相应的反应。例如,看到红绿灯变绿,我们就会开车继续前行;闹钟响了,我们就会起床。
然而,计算机中的进程并没有类似的“常识”,它们不能像我们一样根据外界的信号自动作出响应。为了使进程能在合适的时机执行特定的操作,程序员们为操作系统设计了一个机制,允许操作系统给进程发送“信号”,这些信号会触发相应的动作。
这些信号就像一组特定的指令,每个信号表示一个特殊的动作。进程接收到这些信号后,就会做出相应的反应。
通过 kill -l
查看当前系统中的信号集合表
kill -l
在 Linux 系统中,信号用于进程间的通信和控制,按其用途和响应方式,信号分为 普通信号 和 实时信号 两大类。
1. 信号分类:
普通信号(1~31 号信号):
主要用于 分时操作系统,适合个人电脑等环境。信号只是简单地表示某个动作发生,进程响应时并不需要保存信号的详细内容或持续时间。它们用于一般的进程管理和任务调度。实时信号(34~64 号信号):
主要用于 实时操作系统(RTOS),适用于对响应时间要求极高的场景,如车载系统、火箭发射控制台等。实时信号能够保存信号的内容和持续时间,提供更精确的任务控制,适合需要快速响应的任务。
2. 为何关注 1~31 号信号:
由于大多数用户操作系统(如个人电脑、服务器)使用 分时操作系统,这些系统的进程管理通常依赖于 普通信号。因此,我们只需关注 1~31 号信号,而不需要深入了解 实时信号。普通信号涵盖了如进程终止、暂停、继续等常见的操作。
1.2、信号的作用
这么多信号,其对应功能是什么呢?
- 可以通过
man 7 signal
进行查询
Linux 系统中有 31 个常用的 普通信号,它们的编号为 1~31。每个信号对应一种特定的操作或事件。以下是这些信号及其默认功能的简单总结:
信号编号 | 信号名 | 功能 |
---|---|---|
1 | SIGHUP | 终端连接断开时发送,通常用于通知进程终止。 |
2 | SIGINT | 用户按下 Ctrl + C 时发送,默认终止进程。 |
3 | SIGQUIT | 用户按下 Ctrl + \ 时发送,终止进程并生成 core 文件。 |
4 | SIGILL | 非法指令执行时发送,终止进程并生成 core 文件。 |
5 | SIGTRAP | 由断点或 trap 指令产生,默认终止进程并生成 core 文件。 |
6 | SIGABRT | 调用 abort 函数时发送,进程异常终止并生成 core 文件。 |
7 | SIGBUS | 内存故障时产生,终止进程并生成 core 文件。 |
8 | SIGFPE | 算术错误(如除零、浮点溢出)时产生,终止进程并生成 core 文件。 |
9 | SIGKILL | 无法捕捉或忽略,强制终止进程。 |
10 | SIGUSR1 | 用户定义信号,默认终止进程。 |
11 | SIGSEGV | 访问无效内存时产生,默认终止进程并生成 core 文件。 |
12 | SIGUSR2 | 用户定义信号,默认终止进程。 |
13 | SIGPIPE | 向已关闭管道写入数据时产生,默认终止进程。 |
14 | SIGALRM | 定时器超时或 setitimer 设置的时间超时时产生。 |
15 | SIGTERM | 请求进程正常退出,程序可以捕捉并做清理工作。 |
16 | SIGSTKFLT | 堆栈故障,默认终止进程。 |
17 | SIGCHLD | 子进程状态变化时发送给父进程,默认忽略。 |
18 | SIGCONT | 继续暂停的进程。 |
19 | SIGSTOP | 强制停止进程,无法捕捉或忽略。 |
20 | SIGTSTP | 用户按 Ctrl + Z 时发送,暂停进程。 |
21 | SIGTTIN | 后台进程读取终端时发送,暂停进程。 |
22 | SIGTTOU | 后台进程向终端输出数据时发送,暂停进程。 |
23 | SIGURG | 套接字有紧急数据时发送,默认忽略。 |
24 | SIGXCPU | 进程超时分配的 CPU 时间时发送,终止进程并生成 core 文件。 |
25 | SIGXFSZ | 进程写文件时超出最大文件大小时发送,终止进程并生成 core 文件。 |
26 | SIGVTALRM | 虚拟时钟超时产生,默认终止进程。 |
27 | SIGPROF | 进程占用 CPU 和系统调用时间超时时发送,默认终止进程。 |
28 | SIGWINCH | 窗口大小变化时发送,默认忽略。 |
29 | SIGIO | 异步 I/O 事件时产生,默认终止进程。 |
30 | SIGPWR | 电源故障时产生,默认终止进程。 |
31 | SIGSYS | 无效系统调用时发送,终止进程并生成 core 文件。 |
注意: 其中的 9
号 和 19
号信号是非常特殊的,不能修改其默认动作。
1.3 信号的基本认知
进程信号由 信号编号 和 执行动作 组成,每个信号对应一个特定的动作。对于进程来说,这些动作通常包括:终止进程、暂停进程、恢复进程。虽然这几个动作已经足够简单,然而,为什么操作系统要定义这么多不同的信号呢?
信号的细化并非仅仅是为了控制进程的基本行为,更重要的是 便于管理进程。进程终止的原因可能有很多种,如果我们将所有的终止原因归为一种信号,问题的分析和定位将变得非常困难。因此,将信号细分为不同的类型,能够帮助操作系统 精准定位 进程状态以及引发问题的根源。
而且,普通信号 就有 31 种,这意味着我们可以用一个 int
类型来存储 1~31 号信号的状态信息,方便检查信号是否已经被触发(即信号的保存)。
因此,信号被细化了,不同的信号对应不同的执行动作。虽然大多数信号最终都以 终止进程 为结果,但它们的细化有助于对进程状态和行为进行更精确的控制与管理。
进程的执行动作是可以修改的,操作系统预设了默认动作,但程序员可以通过自定义信号的响应行为来调整默认处理方式。
默认动作:
忽略:进程可以选择忽略某些信号。
自定义动作:可以自定义特定信号的处理方式,以便在接收到信号时执行自定义操作。
因此,我们不仅可以 更改信号的执行动作,而且操作系统还允许我们通过专门的机制来处理和响应信号。
信号的存储:
为了有效管理进程信号,操作系统在 进程控制块(PCB) 中增加了信号相关的数据结构:signal_struct
。在该结构体中,信号的状态 被保存在一个 位图结构 uint32_t signals
中,用来标记 1~31 号信号的有无信息。
struct signal_struct {
atomic_t sigcnt; // 信号计数
atomic_t live; // 活跃信号
int nr_threads; // 线程数
wait_queue_head_t wait_chldexit; // 处理子进程退出
struct task_struct *curr_target; // 当前线程组的目标进程
struct sigpending shared_pending; // 共享信号队列
int group_exit_code; // 进程组退出码
int notify_count; // 通知计数
struct task_struct *group_exit_task; // 进程组退出任务
int group_stop_count; // 进程组停止计数
unsigned int flags; // 信号相关标志位
unsigned int is_child_subreaper:1; // 子重生进程标记
unsigned int has_child_subreaper:1; // 子进程是否有子重生进程
//...
};
下面对 进程信号 做一波概念性的总结
1.信号是执行的动作的信息载体,程序员在设计进程的时候,早就已经设计了其对信号的识别能力
2.信号对于进程来说是异步的,随时可能产生,如果信号产生时,进程在处理优先级更高的事情,那么信号就不能被立即处理,此时进程需要保存信号,后续再处理
3.进程可以将 多个信号 或 还未处理 的信号存储在 signal_struct 这个结构体中,具体信号编号,存储在 uint32_t signals 这个位图结构中
4.所谓的 “发送” 信号,其实就是写入信号,修改进程中位图结构中对应的比特位,由 0 置为 1,表示该信号产生了
5.signal_struct 属于内核数据结构,只能由 操作系统 进行同一修改,无论信号是如何产生的,最终都需要借助 操作系统 进行发送
6.信号并不是立即处理的,它会在合适的时间段进行统一处理
===== 信号产生的方式 =====
2、键盘键入
信号产生(发送)的第一种方式:键盘键入
通俗来说,键盘键入就是通过命令行来操作,进而触发进程信号。
2.1、Ctrl+C 终止前台进程
你是否遇到过系统卡死的情况?是否遇到过程序因死循环而无法停止的情况?这些都是常见的问题,当它们发生时,我们可以通过 键盘键入 Ctrl + C
来发出 2 号信号(即 SIGINT
)来终止前台进程的运行。
下面是一段 死循环 的代码示例:
#include <iostream>
#include <unistd.h>
using namespace std;
int main() {
while(true) {
cout << "我是一个进程,我正在运行…… PID: " << getpid() << endl;
sleep(1);
}
return 0;
}
运行该程序时,它会一直打印输出。当程序进入死循环时,按下 Ctrl + C
会发出 2 号信号 SIGINT,并终止进程。
ctrl + c
终止的是当前正在运行的前台进程,如果在程序运行时加上&
表示让其后台运行,此时会发现无法终止进程
如何证明按 Ctrl + C
发出的信号是 2 号信号?
可以通过 signal
函数注册一个自定义的执行动作来验证。
signal
函数的作用是注册一个信号处理函数,使得我们能够修改某个信号的默认处理方式。
示例:用 signal
注册自定义处理方法
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo) {
cout << "当前 " << signo << " 号信号正在尝试执行相应的动作" << endl;
}
int main() {
// 给 2 号信号注册新方法
signal(2, handler);
while(true) {
cout << "我是一个进程,我正在运行…… PID: " << getpid() << endl;
sleep(1);
}
return 0;
}
在上面的代码中,我们使用 signal(2, handler)
来注册对 2 号信号(SIGINT
) 的处理函数。当按下 Ctrl + C
终止进程时,程序会执行我们注册的 handler
函数,而不是默认的终止操作。输出将显示:
当前 2 号信号正在尝试执行相应的动作
这证明了 Ctrl + C 发出的信号就是 2 号信号(SIGINT
)。
signal 注册执行动作
signal
函数 的作用是注册信号的处理函数。我们可以通过它修改信号的默认处理行为,也可以自定义新的信号响应方式。
signal
函数的调用格式如下:
int signal(int signo, void (*handler)(int));
- signo:待注册信号的编号(如
2
对应SIGINT
)。 handler:信号处理函数的地址。该函数接收一个
int
类型参数(信号编号),并且没有返回值。
返回值表示前一个信号处理方法的地址,若失败,则返回 SIG_ERR
。
修改所有信号的执行动作
如果我们注册所有普通信号的处理方法,将会发生什么呢?
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo) {
cout << "当前 " << signo << " 号信号正在尝试执行相应的动作" << endl;
}
int main() {
// 给所有普通信号注册新方法
for (int i = 1; i < 32; i++)
signal(i, handler);
while (true) {
cout << "我是一个进程,我正在运行…… PID: " << getpid() << endl;
sleep(1);
}
return 0;
}
在此代码中,我们使用 for
循环为所有 1~31 号信号注册了相同的信号处理函数 handler
。此时,信号的默认动作已经被我们自定义。
但有两个信号 无法 被修改其默认行为:
9 号信号 (
SIGKILL
):这个信号是用来强制终止进程的,无法被捕捉或忽略。19 号信号 (
SIGSTOP
):这是用来暂停进程的信号,也无法被捕捉或忽略。
所以,尽管我们修改了大多数信号的执行动作,但 SIGKILL
和 SIGSTOP
信号依然会按照默认行为执行,无法被更改。
2.2、硬件中断
当我们按下 Ctrl + C 键时,系统会产生一个硬件中断信号,以下是该过程的详细说明:
硬件中断过程:
按下
Ctrl + C
后,键盘会向 CPU 发送一个信号,这个信号通过硬件中断的机制进行处理。中断控制器(如 8259)首先接收来自键盘的中断信号,并通过指定的针脚号将信息发送到 CPU。
CPU 接收到该信息后,将 针脚号(即中断号)写入到 寄存器 中,表示一个中断事件发生。
接下来,CPU 根据寄存器中的中断号查询 中断向量表,该表映射了所有硬件中断对应的处理方法。
最后,CPU 根据查询的结果执行相应的 读取方法,完成对按键信号的解析。
信号的解析与转换:
当键盘被按下时,CPU 会首先确定哪个按键被按下。这是通过硬件中断机制完成的。
键盘的驱动程序将读取按下的键,进而解析出按下的是
Ctrl + C
。解析结果被转化为 2 号信号(
SIGINT
),这个信号会触发操作系统终止当前前台进程的操作。
键盘按下与位置的区别:
键盘的 按下 和 具体按键的位置 是不同的。首先,CPU 需要识别按下的按键,并找到相应的处理方法。
之后,CPU 通过调用键盘的 读取方法 获取按键信息,并通过驱动程序进行数据的解析与转换。
硬件中断与进程信号的相似性:
硬件中断 与 进程信号 的处理流程非常相似,二者都先检测信号,然后执行相应的动作。
不同之处在于,硬件中断是由硬件设备(如键盘、鼠标)发出的,而进程信号是由操作系统或进程本身触发的。
操作系统与硬件的解耦:
信号和动作的设计使得操作系统能够高效处理外部事件。操作系统只需要关注是否有信号发出,然后去 中断向量表 查找并执行相应的处理方法。
操作系统不需要关心硬件的具体实现,这样实现了 操作系统与硬件的解耦,使得系统具有更好的灵活性和可扩展性。
3、系统调用
除了通过键盘键入发送信号外,还可以通过直接调用 系统接口 来发送信号。毕竟,bash 本质上也是一个进程,它进行的操作其实就是程序替换的一部分。
3.1、kill
函数
信号的发送主要是通过 kill
函数来实现的,它是操作系统提供的一个标准系统调用。
kill
函数的原型:
int kill(pid_t pid, int signo);
返回值:成功返回
0
,失败返回-1
并设置错误码。参数1:待操作的进程的 PID(进程 ID)。
参数2:待发送的信号。
下面我们来看一个简单示例:程序运行 5 秒后,它会自行终止:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main() {
int n = 1;
while (true) {
cout << "我是一个进程,已经运行了 " << n << " 秒 PID: " << getpid() << endl;
sleep(1);
n++;
if (n > 5)
kill(getpid(), SIGKILL); // 发送 SIGKILL 信号,终止进程
}
return 0;
}
解析:
运行此程序后,它会每秒打印一次进程信息。
当
n > 5
时,程序会调用kill(getpid(), SIGKILL)
来终止自己。当然,
kill
函数可以发送其他类型的信号,这里我们演示的是SIGKILL
信号,它用于强制终止进程。
实际上,命令行中的 kill
命令就是对 kill
函数的封装。比如,kill -9 PID
就是向 PID 为 PID
的进程发送 SIGKILL
信号。
3.2、模拟实现 myKill
我们可以模仿 kill
命令实现一个自定义的命令,比如 myKill
。
#include <iostream>
#include <string>
#include <signal.h>
using namespace std;
void Usage(string proc) {
// 打印使用信息
cout << "\tUsage: \n\t";
cout << proc << " 信号编号 目标进程" << endl;
exit(2);
}
int main(int argc, char *argv[]) {
// 参数个数要严格限制
if (argc != 3) {
Usage(argv[0]);
}
// 获取两个参数
int signo = atoi(argv[1]);
int pid = atoi(argv[2]);
// 执行信号发送
kill(pid, signo);
return 0;
}
解析:
这个程序模拟了
kill
命令。它接受两个命令行参数:信号编号和进程 ID。通过调用kill
函数,它会发送指定的信号给目标进程。比如,执行
./myKill 9 1234
就会向进程 ID 为1234
的进程发送SIGKILL
信号。
3.3、raise
函数
raise
是另一个发送信号的函数,它与 kill
的区别是:raise
只能 向自己 发送信号。
raise
函数原型:
int raise(int signo);
返回值:成功返回
0
,失败返回非0
。参数:要发送的信号(如
SIGKILL
、SIGTERM
等)。
示例:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main() {
int n = 1;
while (true) {
cout << "我是一个进程,已经运行了 " << n << " 秒 PID: " << getpid() << endl;
sleep(1);
n++;
if (n > 5)
raise(SIGKILL); // 向自己发送 SIGKILL 信号
}
return 0;
}
解析:
该程序每秒打印一次信息,当运行超过 5 秒时,调用
raise(SIGKILL)
发送SIGKILL
信号终止进程。raise
是对kill
的封装,参数是信号编号,而 PID 会自动传递为当前进程的 ID。
3.4、abort
函数
abort
是 C 语言标准库中的一个函数,它会向当前进程发送 6 号信号(SIGABRT
),用于强制终止进程并生成 core 文件。
abort
函数原型:
void abort(void);
没有返回值,也没有参数。
示例:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo) {
cout << "收到了 " << signo << " 号信号,已执行新动作" << endl;
}
int main() {
signal(6, handler); // 注册 SIGABRT 信号的处理函数
int n = 1;
while (true) {
cout << "我是一个进程,已经运行了 " << n << " 秒 PID: " << getpid() << endl;
sleep(1);
n++;
if (n > 5)
abort(); // 发送 SIGABRT 信号,终止进程
}
return 0;
}
解析:
abort
会立即终止进程,并且即使我们修改了信号的处理动作,它也会按照默认行为发送SIGABRT
信号终止进程。与
exit
不同,abort
会触发一个 core dump,用于调试。
4、软件条件
信号产生(发送)的第三种方式:软件条件
软件条件是指在程序运行过程中,由系统或程序内部的某些特定条件触发信号。例如,管道读写时如果读端关闭,操作系统会发送一个信号(如 SIGPIPE
信号,编号为 13)终止写端进程。这种方式使得程序能够根据运行状态动态地触发信号。
4.1、alarm
设置闹钟
系统为我们提供了 闹钟(报警),使用 alarm
函数来实现定时功能。它不是用来起床的,而是用来定时触发事件的。
alarm
函数原型:
unsigned int alarm(unsigned int seconds);
返回值:如果上一个闹钟还有剩余时间,则返回剩余时间;否则返回 0。
参数:设定的时间(单位是秒),表示多少秒后发出信号。
当设定的时间到达时,闹钟会响,并向进程发送 14 号信号(SIGALRM
)。
示例:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main() {
alarm(5); // 设置一个五秒后的闹钟
int n = 1;
while (true) {
cout << "我是一个进程,已经运行了 " << n << " 秒 PID: " << getpid() << endl;
sleep(1);
n++;
}
return 0;
}
在上面的代码中,
alarm(5)
会设置一个五秒后的闹钟,闹钟响起时会发送 SIGALRM
信号,通常默认的行为是终止进程。
更改 SIGALRM
信号的执行动作
可以通过注册信号处理函数来改变 SIGALRM
信号的默认行为。例如,我们可以设置一个新的处理动作,使得当闹钟响起时,程序重新设置一个新的闹钟。
示例:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo) {
cout << "收到了 " << signo << " 号信号,已执行新动作" << endl;
int n = alarm(10); // 设置下一个 10 秒后的闹钟
cout << "上一个闹钟剩余时间: " << n << endl;
}
int main() {
signal(SIGALRM, handler); // 注册信号处理函数
alarm(10); // 设置一个十秒后的闹钟
while(true) {
cout << "我是一个进程,我正在运行…… PID: " << getpid() << endl;
sleep(1);
}
return 0;
}
解析:
在程序运行时,设置了 10 秒后的闹钟。当闹钟响起时,我们的信号处理函数
handler
会执行,并重新设置一个 10 秒后的新闹钟,导致程序不断循环。
4.2、测试算力
通过使用闹钟,我们可以简单地测试当前服务器的算力。我们可以设定一个 1 秒后的闹钟,看看程序能在 1 秒内进行多少次运算。
示例:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int main() {
alarm(1); // 设置一个 1 秒后的闹钟
int n = 0;
while(true) {
cout << n++ << endl;
}
return 0;
}
解析:
在这段代码中,
alarm(1)
设置了一个 1 秒后的闹钟。然而,由于程序没有执行 IO 操作,它会在 1 秒内进行大量的累加。可以通过输出的结果判断程序能在 1 秒内执行多少次运算。
优化测试:取消 IO 操作
程序中涉及 IO 操作时,通常会使得计算速度变慢。为了去除 IO 操作的干扰,我们可以取消 IO 操作,专注于计算。
优化示例:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
int n = 0;
void handler(int signo) {
cout << n << endl;
exit(1); // 程序结束
}
int main() {
signal(SIGALRM, handler); // 注册信号处理函数
alarm(1); // 设置 1 秒后的闹钟
while(true) {
n++; // 仅进行累加操作
}
return 0;
}
解析:
在此优化版本中,我们取消了 IO 操作,只进行整数累加。
运行结果显示,在 1 秒内程序能够进行 5 亿多次累加操作,验证了 IO 操作的影响。
总结:
alarm
函数可以设定一个定时器,并在设定的时间到达时发送SIGALRM
信号,通常用于定时任务。通过 信号处理函数,可以修改
SIGALRM
信号的默认行为,重新设定闹钟。测试算力:通过简单的程序,我们可以用
alarm
函数测试当前服务器的算力,去除 IO 干扰后,能够进行大规模的计算。
5、硬件异常
信号产生(发送)的最后一种方式是:硬件异常
硬件异常通常指的是我们在编程中最常遇到的错误,例如除以零、访问野指针等。
5.1、除 0 导致异常
首先,来看一个简单的错误代码:
#include <iostream>
using namespace std;
int main() {
int n = 10;
n /= 0; // 除 0 错误
return 0;
}
解析:
运行时会出现一个错误,因为除数为零是不允许的。根据错误信息,可以推测出这是 8 号信号(
SIGFPE
,浮点异常)引发的错误。
我们可以通过 signal
函数来更改 8 号信号 的执行动作,尝试“逆天改命”让除 0 成为合法操作:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "虽然除 0 了,但我不终止进程" << endl;
}
int main()
{
signal(SIGFPE, handler);
int n = 10;
n /= 0;
return 0;
}
问题:
运行后,虽然我们更改了执行动作,程序进入了死循环地不断发送
SIGFPE
信号,原因是 状态寄存器(后面会讲)在异常状态下不会自动复位,因此系统持续发送信号。
5.2、状态寄存器
在 CPU 中,存在很多 寄存器,其中大部分主要用来存储数据信息,用于运算,除此之外,还存在一种特殊的 寄存器 =》 状态寄存器,这个 寄存器 专门用来检测当前进程是否出现错误行为,如果有,就会把 状态寄存器(位图结构)中对应的比特位置 1,意味着出现了 异常
当操作系统检测到 状态寄存器 出现异常时,会根据其中的值,向出现异常的进程 轮询式 的发送信号,目的就是让进程退出
比如上面的 除 0 代码,发生异常后,CPU 将 状态寄存器 修改,变成 异常状态,操作系统检测到 异常 后会向进程发送 8 号信号,即使我们修改了 8 号信号的执行动作,但 因为状态寄存器仍然处于异常状态,所以操作系统才会不断发送 8 号信号,所以才会死循环式的打印
能让 状态寄存器 变为 异常 的都不是小问题,需要立即终止进程,然后寻找、解决问题
毕竟如果让 除 0 变为合法,那最终的结果是多少呢?所以操作系统才会不断发送信号,目的就是 终止进程的运行
5.3、野指针导致异常
除了除 0 异常,另一个常见的异常是 野指针问题,例如:
#include <iostream>
using namespace std;
int main()
{
int* ptr = nullptr;
*ptr = 10;
return 0;
}
解析:
程序试图访问一个空指针,导致 段错误(Segmentation fault),这会触发 11 号信号(
SIGSEGV
)。野指针 问题通常分为两类:
指向不该指向的空间:例如,试图访问没有映射的内存区域(如空指针或已释放的内存)。
权限不匹配:例如,程序尝试写入只读内存。
那么 野指针 问题是如何引发的呢?
借用一下 共享内存 中的图~
野指针问题主要分为两类:
- 指向不该指向的空间
- 权限不匹配,比如只读的区域,偏要去写
共识:在执行 *ptr = 10
这句代码时,首先会进行 虚拟地址 -> 真实(物理)地址 之间的转换
指向不该指向的空间:这很好理解,就是页表没有将 这块虚拟地址空间 与 真实(物理)地址空间 建立映射关系,此时进行访问时 MMU 识别到异常,于是 MMU 直接报错,操作系统识别到 MMU 异常后,向对应的进程发出终止信号
C语言中对于越界 读 的检查不够严格,属于抽查行为,因此野指针越界读还不一定报错,但越界写是一定会报错的
权限不匹配:页表中除了保存映射关系外,还会保存该区域的权限情况,比如 是否命中 / RW
等权限,当发生操作与权限不匹配时,比如 nullptr
只允许读取,并不允许其他行为,此时解引用就会触发 MMU
异常,操作系统识别到后,同样会对对应的进程发出终止信号
页表中的属性
- 是否命中
RW
权限UK
权限(不必关心)
注:MMU 是内存管理单元,主要负责 虚拟地址 与 物理地址 间的转换工作,同时还会识别各种异常行为
一旦引发硬件层面的问题,操作系统会直接发信号,立即终止进程
到目前为止,我们学习了很多信号,分别对应着不同的情况,其中有些信号还反映了异常信息,所以将信号进行细分,还是很有必要的
6、核心转储
Linux 提供了一项重要的系统功能:核心转储(Core Dump)。当一个进程因异常终止时,操作系统可以将该进程在异常时的内存数据保存下来,并生成一个核心转储文件。这个文件包含了进程的内存内容(包括程序计数器、堆栈信息、寄存器等),能够帮助开发人员进行调试。
6.1、核心转储的概念
在 Linux 系统中,核心转储 是指操作系统将进程的内存数据保存到磁盘中,通常是以 core.pid
这样的格式生成一个二进制文件。这个文件能够帮助开发人员在进程崩溃后进行调试。
产生核心转储文件的信号:
一些信号(如 SIGQUIT
、SIGILL
等)会在终止进程时生成核心转储文件。以下是能够触发核心转储的信号列表:
3 号
SIGQUIT
4 号
SIGILL
5 号
SIGTRAP
6 号
SIGABRT
7 号
SIGBUS
8 号
SIGFPE
11 号
SIGSEGV
24 号
SIGXCPU
25 号
SIGXFSZ
31 号
SIGSYS
对于这些信号,操作系统会先生成核心转储文件,再终止进程。
为什么我们没有看到核心转储文件?
在默认的环境下,云服务器通常会关闭核心转储功能。所以,即使程序触发了会生成核心转储的信号(如 SIGSEGV
,即段错误),也不会产生核心转储文件。
6.2、打开与关闭核心转储
我们可以通过命令来查看和配置核心转储文件的行为。
查看当前的资源限制:
ulimit -a
输出结果中可以看到核心转储文件的大小。如果大小为
0
,表示禁用了核心转储。
启用核心转储:
使用以下命令来设置允许生成核心转储文件,并指定其大小:
ulimit -c 1024
这样可以设置核心转储文件的最大大小为 1024KB。
测试:
可以使用之前的 野指针 代码来验证核心转储的生成:
#include <iostream>
using namespace std;
int main() {
int* ptr = nullptr;
*ptr = 10; // 引发段错误(野指针)
return 0;
}
当这个程序发生段错误时,如果核心转储功能启用,会生成 core.pid
文件,其中 pid
是进程的 ID。
关闭核心转储:
为了避免生成大量的核心转储文件,可以关闭核心转储功能:
ulimit -c 0
这将禁用核心转储文件的生成。
云服务器上是可以部署服务的,一般程序发生错误后,会立即重启
如果打开了核心转储,一旦程序 不断挂掉、又不断重启,那么必然会产生大量的核心转储文件,当文件足够多时,磁盘被挤满,导致系统 IO 异常,最终会导致整个服务器挂掉的
还有一个重要问题是 core 文件中可能包含用户密码等敏感信息,不安全
6.3、核心转储的作用
核心转储文件的主要作用是调试。当程序发生异常退出时,生成的核心转储文件包含了进程崩溃时的内存状态,可以帮助开发人员调试程序,查找错误的根源。
如何使用核心转储文件进行调试:
编译程序时添加调试信息:
使用-g
选项编译程序以生成包含调试信息的可执行文件:g++ -g -o myprogram myprogram.cpp
生成核心转储文件:
程序运行时发生错误后,会生成核心转储文件(如core.1234
,其中1234
是进程 ID)。使用 GDB 进行调试:
使用 GDB(GNU 调试器)来加载核心转储文件并调试程序:gdb ./myprogram core.1234
然后可以使用 GDB 提供的命令来查看程序崩溃时的堆栈跟踪、变量值等信息,帮助快速定位问题。
总结:
核心转储文件 是调试进程崩溃的有力工具,能够记录进程发生异常时的内存状态。
ulimit -c
命令可以控制核心转储的生成,ulimit -c 0
用于禁用核心转储功能。调试方法:通过
gdb
加载核心转储文件进行事后调试,能够迅速定位进程崩溃的原因。
之前在 进程创建、控制、等待 中,我们谈到了 当进程异常退出时(被信号终止),不再设置退出码,而是设置 core dump
位 及 终止信号
也就是说,父进程可以借此判断子进程是否产生了 核心转储 文件