在这里插入图片描述

大家好,欢迎来到 huangjin007_ 的博客
⭐ 个人主页:huangjin007_
🔥 文章收录专栏:Linux 内功修炼手册(系统篇)
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇

Linux 系统篇(三十六) —— 信号是如何产生的?


一、前言

  在上一篇博客中,我们初步认识了 Linux 信号,了解了信号的基本概念、生命周期,以及信号的三种处理方式:

  • 默认处理(SIG_DFL)
  • 忽略处理(SIG_IGN)
  • 自定义捕捉(Catch)

  我们知道,信号的生命周期可以简单概括为:

  信号产生 → 信号保存 → 信号处理

  其中,信号产生是整个生命周期的起点。

  那么,一个问题就来了:

  信号究竟是怎么产生的?是谁产生的?

  例如:

  • 按下 Ctrl+C,为什么程序会终止?
  • 执行 kill -9 pid,为什么能够杀死指定进程?
  • 程序发生除零错误,操作系统为什么会知道?
  • 调用 alarm(),为什么过一段时间进程就会收到信号?

  这些看似毫不相关的现象,其实都与 Linux 信号的产生机制有关。

  本篇就从这些实际问题出发,一步步理解 Linux 信号的五种产生方式。


二、Linux 信号产生的五种方式

  从产生来源来看,Linux 信号主要有以下五种产生方式:

产生方式典型操作对应信号
键盘产生Ctrl+C、Ctrl+\、Ctrl+ZSIGINT、SIGQUIT、SIGTSTP
系统调用产生kill()、raise()、abort()根据调用参数决定
系统命令产生kill -9 pidSIGKILL
硬件异常产生除零、非法内存访问SIGFPE、SIGSEGV
软件条件产生alarm() 超时、管道读端关闭SIGALRM、SIGPIPE

  虽然这些信号的产生方式不同,但它们最终都需要操作系统参与。

  信号不是硬件或者用户直接修改进程的数据结构,而是由操作系统负责产生、记录和递达的。


三、通过终端按键产生信号

3.1 Ctrl+C:为什么能够终止进程?

  在 Linux 中,我们经常使用 Ctrl+C 终止正在运行的程序。

例如:

while :; do
    echo "I am running..."
    sleep 1
done

  运行之后,程序会不断输出内容。

  此时按下:

Ctrl + C

  程序就会终止。

  那么问题来了:

  我们明明只是按下了一个键,为什么进程就终止了?

  实际上,Ctrl+C 并不是直接杀死进程,而是通过终端产生一个信号:

  SIGINT(信号编号为 2)

  其默认处理动作是 Term,也就是终止进程。

  我们可以编写一个简单的程序验证:

#include <iostream>
#include <unistd.h>
#include <signal.h>

void handler(int signumber)
{
    std::cout << "我是进程: " << getpid()
              << ", 我获得了一个信号: "
              << signumber << std::endl;
}

int main()
{
    std::cout << "我是进程: " << getpid() << std::endl;

    // 捕捉 SIGINT 信号
    signal(SIGINT, handler);

    while (true)
    {
        std::cout << "I am a process, I am waiting signal!"
                  << std::endl;
        sleep(1);
    }

    return 0;
}

  编译运行:

g++ sig.cc -o sig
./sig

  运行结果:
在这里插入图片描述

  可以看到,按下 Ctrl+C 后,程序并没有终止,而是执行了我们自定义的处理函数。

  这是因为我们通过 signal() 修改了 SIGINT 的处理动作。

  如果注释掉:

signal(SIGINT, handler);

  再次运行程序,按下 Ctrl+C,进程就会按照默认动作终止。

这里需要记住:

  Ctrl+C 产生的是 SIGINT 信号,而进程是否终止,取决于它对这个信号设置的处理动作。

3.2 Ctrl+\:为什么能够产生 Core Dump?

  除了 Ctrl+C,还有一个比较特殊的组合键:

Ctrl + \

  它会产生:

  SIGQUIT(信号编号为 3)

  SIGQUIT 默认的处理动作是:

  终止进程,并产生 Core Dump。

  我们只需要把上面程序中的信号修改一下:

signal(SIGQUIT, handler);

  运行程序后按下 Ctrl+\:

在这里插入图片描述

  程序没有终止,是因为我们捕捉了 SIGQUIT。

  如果注释掉捕捉代码:

// signal(SIGQUIT, handler);

  再次运行并按下 Ctrl+\:

在这里插入图片描述

  为什么会出现 Core Dump?

  简单来说,当进程因为某些信号异常终止,并且该信号的默认动作支持 Core Dump 时,操作系统可以将进程的部分内存状态保存到磁盘,供后续调试使用。

  关于 Core Dump,后面会专门介绍。

3.3 Ctrl+Z:为什么程序没有结束?

  还有一个常见的组合键:

Ctrl + Z

  它产生的是:

SIGTSTP(信号编号为 20)

  与前面两个信号不同,SIGTSTP 的默认动作不是终止进程,而是停止进程。

  我们修改一下程序:

signal(SIGTSTP, handler);

  运行程序并按下 Ctrl+Z:

在这里插入图片描述

  由于我们捕捉了 SIGTSTP,所以程序并没有停止。

  注释掉捕捉代码,再次运行:

在这里插入图片描述

  此时进程已经被暂停了,但并没有退出。

  可以使用:

jobs

  查看当前 Shell 管理的作业:

在这里插入图片描述

  如果想让它继续运行,可以使用:

bg %1

  让它在后台继续运行。

  或者:

fg %1

  将它重新放到前台运行。

  这里需要注意:

  Ctrl+Z 只是让进程停止,并不是结束进程。

3.4 理解 OS 如何知道键盘有数据?

  前面我们知道:

  • Ctrl+C 产生 SIGINT。
  • Ctrl+\ 产生 SIGQUIT。
  • Ctrl+Z 产生 SIGTSTP。

  那么问题来了:

  操作系统是怎么知道我们按下了这些组合键的?

  这就涉及键盘输入的处理过程。

  可以简单理解为(具体的过程我们之后会讲):

  1. 用户按下键盘按键。
  2. 键盘控制器产生硬件中断,通知 CPU 有键盘输入。
  3. 操作系统的中断处理机制获取键盘输入。
  4. 终端驱动程序识别对应的特殊控制字符。
  5. 如果识别到 Ctrl+C、Ctrl+\ 或 Ctrl+Z,就向终端的前台进程组发送对应信号。

例如:

在这里插入图片描述

  这里需要特别注意:

  键盘产生的信号,通常发送给当前终端的前台进程组,而不是任意一个进程。

3.5 前台进程与后台进程

  我们平时执行程序,通常有两种方式:

./test

以及:

./test &

它们有什么区别?

启动方式进程状态
./test前台运行
./test &后台运行

  前台进程和后台进程最大的区别之一,就是它们对终端输入的使用方式不同。

前台进程:

  • 可以从终端读取标准输入。
  • 可以向终端输出内容。
  • 接收终端发送的键盘信号。

后台进程:

  • 可以向终端输出内容。
  • 默认不能直接读取终端标准输入。
  • 不会因为终端上的 Ctrl+C 而直接收到对应信号。

  为什么后台进程不能直接读取终端输入?

  因为一个终端在同一时刻只能有一个前台进程组。

  如果后台进程尝试读取控制终端,通常会收到 SIGTTIN 信号,并按照默认动作停止。

  所以,前台与后台的区别并不是能不能输出,而主要涉及终端输入的控制权。

3.6 孤儿进程为什么不能直接通过 Ctrl+C 终止?

  假设存在父子进程关系:

父进程
   |
  fork()
   |
   +---- 子进程

  如果父进程先退出,子进程就会成为孤儿进程。

  孤儿进程会被系统中的其他进程(通常是 PID 1 对应的 init 或 systemd)收养。

  但是这里有一个容易混淆的地方:

  孤儿进程并不等于后台进程。

  父进程退出后,子进程是否还能接收 Ctrl+C,取决于它是否仍然属于终端的前台进程组。

  如果它已经不属于当前终端的前台进程组,那么终端按键产生的 SIGINT 就不会发送给它。

  因此,不能简单地认为所有孤儿进程都无法通过 Ctrl+C 终止。


四、通过系统命令产生信号

  前面我们通过键盘产生了信号。

  那么,如果不想通过键盘操作,能不能主动给指定进程发送信号?

  当然可以。

  Linux 提供了 kill 命令。

4.1 kill 命令的基本使用

  首先编写一个简单的死循环程序:

#include <iostream>
#include <unistd.h>

int main()
{
    while (true)
    {
        sleep(1);
    }

    return 0;
}

  编译运行:

g++ sig.cc -o sig

  让程序在后台运行:

./sig &

  通过下面的命令查看进程 PID:

ps ajx | grep sig

  假设得到:

PPID    PID      PGID     SID      TTY
211805  213784   213784   211805   pts/0

  其中 PID 为:

213784

  现在,我们向这个进程发送 SIGSEGV 信号:

kill -SIGSEGV 213784

  也可以使用信号编号:

kill -11 213784

  因为 SIGSEGV 对应的编号就是 11。

  此时进程会按照 SIGSEGV 的默认处理动作终止。


五、通过系统调用产生信号

  前面使用的 kill 命令,本质上也是通过系统调用实现的。

  Linux 为我们提供了几个常用接口:

  • kill()
  • raise()
  • abort()

  下面逐个认识。

5.1 kill():向指定进程发送信号

函数原型:

#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);

参数说明:

  • pid:指定目标进程。
  • sig:需要发送的信号编号。

返回值:

  • 成功:返回 0。
  • 失败:返回 -1,并设置 errno。

  需要注意,kill() 并不一定意味着杀死进程。

  它真正的作用是发送指定信号,最终如何处理,取决于目标进程对该信号的处理动作。

例如:

kill(pid, SIGINT);

  表示向指定进程发送 SIGINT。

  另外,kill() 的第一个参数还有不同含义:

pid 的取值含义
pid > 0向 PID 对应的进程发送信号
pid == 0向调用进程所在的进程组发送信号
pid == -1向调用者有权限发送信号的进程发送信号
pid < -1向进程组 ID 为 pid 的进程组发送信号

  这里的 kill() 需要具备相应权限,否则发送会失败。

实现自己的 mykill 命令

  理解了函数原型之后,我们可以尝试自己实现一个简单的 kill 命令。

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>

// mykill -signumber pid

int main(int argc, char* argv[])
{
    if (argc != 3)
    {
        std::cerr << "Usage: "
                  << argv[0]
                  << " -signumber pid"
                  << std::endl;
        return 1;
    }

    // 去掉信号编号前面的 -
    int number = std::stoi(argv[1] + 1);

    pid_t pid = std::stoi(argv[2]);

    // 发送信号
    int ret = kill(pid, number);

    if (ret == -1)
    {
        perror("kill");
        return 1;
    }

    return 0;
}

编译:

g++ mykill.cc -o mykill

使用:

./mykill -9 213784

  这样,我们就实现了一个非常简单的信号发送工具。

5.2 raise():给自己发送信号

  如果想让一个进程给自己发送信号,应该怎么办?

  可以使用 raise()。

  函数原型:

#include <signal.h>

int raise(int sig);

  作用:

  向调用 raise() 的进程发送指定信号。

  可以近似理解为:

kill(getpid(), sig);

其中:

getpid()

  用于获取当前进程的 PID。

  来看一个例子:

#include <iostream>
#include <unistd.h>
#include <signal.h>

void handler(int signumber)
{
    std::cout << "获取了一个信号: "
              << signumber << std::endl;
}

int main()
{
    // 捕捉 2 号信号
    signal(SIGINT, handler);

    while (true)
    {
        sleep(1);

        // 每隔一秒给自己发送一次 SIGINT
        raise(SIGINT);
    }

    return 0;
}

运行结果:
在这里插入图片描述

  可以看到,进程不需要依赖键盘,也不需要其他进程,就能够通过 raise() 给自己发送信号。

5.3 abort():让进程异常终止

  前面两个接口都可以发送指定信号。

  那么,如果我们想让当前进程异常终止呢?

  可以使用 abort()。

  函数原型:

#include <stdlib.h>

void abort(void);

  abort() 的作用是:

  使当前进程异常终止,并产生 SIGABRT 信号(编号 6)。

  来看一个例子:

#include <iostream>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>

void handler(int signumber)
{
    std::cout << "获取了一个信号: "
              << signumber << std::endl;
}

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

    while (true)
    {
        sleep(1);
        abort();
    }

    return 0;
}

运行结果:

获取了一个信号: 6
Aborted

  这里有一个比较特殊的地方:

  即使我们已经捕捉了 SIGABRT,进程最终还是会终止。

  因为 abort() 的设计目的就是使当前进程异常终止,捕捉处理函数并不能阻止它最终终止进程。


六、由软件条件产生信号

  前面介绍的信号,大多是通过键盘或者主动调用函数产生的。

  但实际上,还有一类信号,它们并不是由用户主动发送的,而是由某些软件条件触发的。

例如:

  • 管道的读端关闭后,写端继续写入。
  • 定时器到达指定时间。

  这就是软件条件产生的信号。

6.1 SIGPIPE:管道读端关闭后继续写入

  还记得之前学习的匿名管道吗?

  管道通常用于具有亲缘关系的进程之间进行通信。

例如:

在这里插入图片描述

  如果进程 B 关闭了读端,而进程 A 仍然尝试向管道写入数据,会发生什么?

操作系统会产生:

  SIGPIPE(信号编号 13)

  其默认处理动作是终止进程。

注意:

  SIGPIPE 的产生条件是管道没有读者时仍然写入,而不是管道缓冲区已满。

  这是两个完全不同的概念。

6.2 alarm():设置系统闹钟

  接下来介绍一个非常重要的函数:alarm()。

  它可以让操作系统在指定时间后向当前进程发送 SIGALRM 信号。

  函数原型:

#include <unistd.h>

unsigned int alarm(unsigned int seconds);

  它的作用是:

  调用 alarm(seconds),告诉操作系统:

  在 seconds 秒之后,向当前进程发送 SIGALRM 信号。

  SIGALRM 的编号为 14,默认处理动作是终止进程。

例如:

alarm(5);

  表示设置一个 5 秒后的闹钟。

  5 秒后,当前进程就会收到 SIGALRM。

  如果:

alarm(0);

  则表示取消之前设置的闹钟。

alarm() 的返回值

  alarm() 的返回值比较容易考察。

  它返回:

  之前设置的闹钟还剩余的秒数。

  如果之前没有设置闹钟,则返回 0。

例如:

alarm(5);
sleep(2);

unsigned int ret = alarm(10);

std::cout << ret << std::endl;

  大致执行过程:

t = 0:

alarm(5)
设置一个 5 秒后的闹钟
返回 0,因为之前没有闹钟

t = 2:

已经过去 2 秒
原来的闹钟还剩 3 秒

再次调用 alarm(10)
返回 3
同时将原来的闹钟替换为 10 秒后的新闹钟

  所以可以总结为:

  • 第一次设置闹钟,通常返回 0。
  • 再次设置闹钟,返回旧闹钟剩余时间。
  • 新闹钟会替换旧闹钟。
  • alarm(0) 可以取消闹钟。

6.3 alarm() 的基本验证:体会 IO 效率问题

  我们先来看一个实验。

需求:

  让程序在 1 秒钟内不断计数,1 秒后通过 SIGALRM 终止进程。

  第一种方式:大量 IO 操作。

#include <iostream>
#include <unistd.h>
#include <signal.h>

int main()
{
    int count = 0;

    alarm(1);

    while (true)
    {
        std::cout << "count : "
                  << count << std::endl;

        count++;
    }

    return 0;
}

运行结果:

在这里插入图片描述

  第二种方式:减少 IO 操作。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>

int count = 0;

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

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

    alarm(1);

    while (true)
    {
        count++;
    }

    return 0;
}

运行结果:

在这里插入图片描述

  可以发现,第二种方式在相同时间内完成的计数明显更多。

为什么?

  因为第一种方式每次循环都要进行输出操作,而 IO 操作通常涉及缓冲区管理,甚至可能发生用户态与内核态之间的切换。

  第二种方式只进行计数,减少了大量 IO 开销。

  因此,我们可以得出一个结论:

  IO 操作通常比简单的内存计算开销更大,频繁 IO 会明显影响程序运行效率。

6.4 如何设置重复闹钟?

  前面我们发现,alarm() 设置的闹钟是一次性的。

  当时间到达后,内核会发送 SIGALRM,原来的闹钟就结束了。

  那么,如果我们想实现每隔 1 秒执行一次任务,应该怎么办?

  可以在信号处理函数中重新设置闹钟。

  这里还需要认识一个函数:pause()。

  函数原型:

#include <unistd.h>

int pause(void);

  pause() 的作用:

  让当前进程挂起,直到捕捉到一个信号,并且对应的信号处理函数返回。

  如果信号处理函数正常返回,pause() 会返回 -1,并将 errno 设置为 EINTR。

来看一个例子:

#include <iostream>
#include <unistd.h>
#include <signal.h>

int gcount = 0;

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

    // 重新设置 1 秒后的闹钟
    alarm(1);
}

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

    // 设置第一次闹钟
    alarm(1);

    while (true)
    {
        // 等待信号
        pause();

        std::cout << "我醒来了..." << std::endl;

        gcount++;

        std::cout << "gcount: "
                  << gcount << std::endl;
    }

    return 0;
}

  运行之后,我们会发现程序每隔一段时间就会被唤醒一次。

  整个过程可以理解为:

在这里插入图片描述


七、如何理解软件条件?

  前面我们介绍了 SIGPIPE 和 SIGALRM。

  它们虽然产生原因不同,但有一个共同特点:

  信号不是由 CPU 执行错误指令直接产生的,而是由软件状态或软件设置的条件触发的。

例如:

在这里插入图片描述

再例如:

在这里插入图片描述

  这就是软件条件产生信号的基本含义。


八、系统闹钟的底层原理

  前面我们通过 alarm() 实现了定时功能。

  但还有一个值得思考的问题:

  操作系统是怎么记住每个进程设置的闹钟,并且在时间到达时准确发送信号的?

  这就涉及操作系统对定时器的管理。

8.1 操作系统为什么需要管理定时器?

  假设系统中有三个进程:

进程 A:设置 5 秒后发送信号
进程 B:设置 10 秒后发送信号
进程 C:设置 3 秒后发送信号

  操作系统必须知道:

  • 哪个进程设置了闹钟?
  • 闹钟什么时候到期?
  • 到期之后应该执行什么操作?

  如果只记录时间,却不知道对应的进程,就无法准确地发送信号。

  因此,操作系统需要对定时器进行统一管理。

  这也符合操作系统中一个非常重要的思想:

  先描述,再组织。

8.2 定时器的数据结构

  Linux 内核中曾使用过类似下面的定时器结构:

struct timer_list
{
    struct list_head entry;

    unsigned long expires;

    void (*function)(unsigned long);

    unsigned long data;

    struct tvec_t_base_s *base;
};

  这是较早期 Linux 内核版本中的结构示意,现代内核的具体实现已经有所变化。

  其中比较重要的是两个成员:

  expires:表示定时器的到期时间。

例如:

expires = 100

  可以理解为定时器在指定的时间点到期。

  function:表示定时器到期后需要执行的回调函数。

  例如,闹钟到期后,相关处理逻辑就会向对应进程发送 SIGALRM。

8.3 多个定时器如何组织?

  假设操作系统中同时存在很多定时器:

定时器 A:5 秒
定时器 B:10 秒
定时器 C:3 秒
定时器 D:1 秒

  操作系统需要快速找到最先到期的定时器。

  从数据结构的角度,可以使用最小堆来帮助理解:

             1
           /   \
          3     5
         /
        10

  最小堆有一个特点:

  堆顶元素永远是当前最小的元素。

  因此,想知道哪个定时器最先到期,只需要查看堆顶即可。

  • 获取最小值:O(1)
  • 插入元素:O(log n)
  • 删除堆顶:O(log n)

九、硬件异常产生信号

  前面介绍的几种信号,大多来自用户操作或者软件条件。

  接下来,我们看看另一种比较特殊的产生方式:

  硬件异常。

例如:

  • 整数除零。
  • 访问非法内存地址。
  • 向只读内存写入数据。

  这些操作都可能导致程序出现异常。

  那么,操作系统是怎么知道程序发生了错误的?

9.1 除零错误:SIGFPE

先来看一个最简单的例子:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

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

int main()
{
    sleep(1);

    int a = 10;

    a /= 0;

    while (1);

    return 0;
}

运行程序:

Floating point exception

  这里产生的是:

  SIGFPE(信号编号 8)

  SIGFPE 的全称是 Floating Point Exception,表示算术运算异常。

  虽然名字中有 Floating Point,但它并不只针对浮点运算,整数除零也可能产生 SIGFPE。

  我们可以尝试捕捉它:

signal(SIGFPE, handler);

  发生异常时,处理函数就会收到对应的信号编号。

9.2 野指针:SIGSEGV

  再来看另一个非常常见的错误:

  非法内存访问。

例如:

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

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

int main()
{
    sleep(1);

    int* p = NULL;

    *p = 100;

    while (1);

    return 0;
}

运行:

Segmentation fault (core dumped)

  这是因为我们尝试通过空指针访问内存。

  系统产生的信号是:

  SIGSEGV(信号编号 11)

  它表示非法内存访问。

  常见情况包括:

  • 解引用空指针。
  • 访问没有权限的内存。
  • 向只读内存区域写入数据。
  • 访问已经失效的内存地址。

9.3 为什么操作系统能够发现这些错误?

  这是一个非常重要的问题。

  我们先回忆一下之前学习的虚拟内存和地址转换。

  在现代操作系统中,进程使用的是虚拟地址。

  当程序访问一个地址时,CPU 中的 MMU 会负责将虚拟地址转换为物理地址。

  整个过程可以简单理解为:

在这里插入图片描述

  这里涉及两个重要的硬件:

CPU:

  负责执行指令,当执行到非法操作时,可能触发处理器异常。

  例如整数除零,就可能触发处理器的除法异常。

MMU:

  负责内存地址转换和访问权限检查。

  当程序访问非法地址或者违反内存访问权限时,MMU 可以触发页故障等异常。

  随后,CPU 将控制权交给操作系统的异常处理程序。

  操作系统根据异常类型和发生位置,判断应该采取什么处理方式,并向对应进程产生相应信号。

因此,需要记住:

  硬件负责检测异常并通知内核,真正向进程发送信号的是操作系统。

9.4 为什么捕捉 SIGSEGV 后,程序可能一直打印?

  这里有一个比较容易让人困惑的现象。

  假设我们捕捉了 SIGSEGV:

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

然后执行:

int* p = NULL;
*p = 100;

可能出现:

catch a sig : 11
catch a sig : 11
catch a sig : 11
...

为什么同一个信号会不断产生?

原因在于:

  程序发生非法内存访问后,处理函数虽然执行了,但原来的错误指令并没有得到修复。

  当处理函数返回后,程序可能重新回到发生异常的指令位置继续执行。

  再次执行时,又会发生相同的非法访问,于是再次产生 SIGSEGV。

  如此反复,就可能形成连续的异常处理。

  因此,捕捉信号并不意味着修复了程序错误。

  对于 SIGSEGV、SIGFPE 这类硬件异常,通常不能简单地捕捉后继续执行原程序。


十、Core Dump:进程异常终止后的现场保存

  前面在学习 Ctrl+\ 时,我们见过:

Quit (core dumped)

  这里就正式认识一下 Core Dump。

10.1 什么是 Core Dump?

  当进程因为某些信号异常终止时,操作系统可以将进程当时的部分内存状态保存到磁盘文件中。

  这个文件通常称为 Core 文件。

它的作用是:

  保存进程异常终止时的现场,方便开发人员事后分析程序为什么崩溃。

例如:

在这里插入图片描述

这种调试方式叫作:

  Post-mortem Debug,也就是事后调试。

10.2 为什么有时候没有 Core 文件?

  因为 Core 文件可能比较大,而且可能包含进程中的敏感数据。

  所以 Linux 通常会通过资源限制控制 Core 文件的生成。

  我们可以使用:

ulimit -a

  查看当前 Shell 的资源限制。

  其中:

core file size (blocks, -c) 0

  表示当前 Core 文件大小限制为 0,也就是不允许生成 Core 文件。

  如果希望在开发调试阶段生成 Core 文件,可以执行:

ulimit -c unlimited

  表示取消当前 Shell 的 Core 文件大小限制。

  然后再运行程序:

./test

  当程序因为支持 Core Dump 的信号异常终止,并且其他生成条件满足时,就有机会生成 Core 文件。

需要注意:

  ulimit 修改的是当前 Shell 及其后续创建的子进程所继承的资源限制,不会自动影响已经运行的其他进程。

  另外,Core 文件的实际生成位置和名称还可能受到系统配置影响。

10.3 如何查看子进程是否产生 Core Dump?

  我们可以通过 waitpid() 获取子进程的退出状态。

例如:

#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>

int main()
{
    pid_t id = fork();

    if (id == 0)
    {
        sleep(1);

        int a = 10;
        a /= 0;

        return 0;
    }

    int status = 0;

    waitpid(id, &status, 0);

    if (WIFSIGNALED(status))
    {
        std::cout << "exit signal: "
                  << WTERMSIG(status) << std::endl;

        std::cout << "core dump: "
                  << WCOREDUMP(status) << std::endl;
    }

    return 0;
}

  这里有几个值得认识的宏:

宏作用
WIFEXITED(status)判断子进程是否正常退出
WEXITSTATUS(status)获取正常退出时的退出码
WIFSIGNALED(status)判断子进程是否被信号终止
WTERMSIG(status)获取终止子进程的信号编号
WCOREDUMP(status)判断是否产生了 Core Dump

例如:

exit signal: 8
core dump: 1

  表示子进程因为 SIGFPE(8)异常终止,并且产生了 Core Dump。


十一、总结

  到这里,我们已经学习了 Linux 信号的五种产生方式。

  可以将它们统一整理成下面这张图:

信号产生

键盘输入

系统调用

系统命令

硬件异常

软件条件

Ctrl+C / Ctrl+\ / Ctrl+Z

kill() / raise() / abort()

kill -9 等

除零 / 野指针

alarm() / SIGPIPE

操作系统处理

保存信号 Pending

选择合适的时机

执行信号处理


结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐