云计算百科
云计算领域专业知识百科平台

【Linux 系统篇(三十六)】信号(二):信号是如何产生的?——从键盘输入到硬件异常

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

在这里插入图片描述

大家好,欢迎来到 huangjin007_ 的博客
⭐
个人主页:huangjin007_
🔥
文章收录专栏:Linux 内功修炼手册(系统篇)

总会有一些坚持能从冰封的土地里培育出十万朵怒放的蔷薇


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

文章目录

  • Linux 系统篇(三十六) —— 信号是如何产生的?
    • 一、前言
    • 二、Linux 信号产生的五种方式
    • 三、通过终端按键产生信号
      • 3.1 Ctrl+C:为什么能够终止进程?
      • 3.2 Ctrl+\\:为什么能够产生 Core Dump?
      • 3.3 Ctrl+Z:为什么程序没有结束?
      • 3.4 理解 OS 如何知道键盘有数据?
      • 3.5 前台进程与后台进程
      • 3.6 孤儿进程为什么不能直接通过 Ctrl+C 终止?
    • 四、通过系统命令产生信号
      • 4.1 kill 命令的基本使用
    • 五、通过系统调用产生信号
      • 5.1 kill():向指定进程发送信号
        • 实现自己的 mykill 命令
      • 5.2 raise():给自己发送信号
      • 5.3 abort():让进程异常终止
    • 六、由软件条件产生信号
      • 6.1 SIGPIPE:管道读端关闭后继续写入
      • 6.2 alarm():设置系统闹钟
        • alarm() 的返回值
      • 6.3 alarm() 的基本验证:体会 IO 效率问题
      • 6.4 如何设置重复闹钟?
    • 七、如何理解软件条件?
    • 八、系统闹钟的底层原理
      • 8.1 操作系统为什么需要管理定时器?
      • 8.2 定时器的数据结构
      • 8.3 多个定时器如何组织?
    • 九、硬件异常产生信号
      • 9.1 除零错误:SIGFPE
      • 9.2 野指针:SIGSEGV
      • 9.3 为什么操作系统能够发现这些错误?
      • 9.4 为什么捕捉 SIGSEGV 后,程序可能一直打印?
    • 十、Core Dump:进程异常终止后的现场保存
      • 10.1 什么是 Core Dump?
      • 10.2 为什么有时候没有 Core 文件?
      • 10.3 如何查看子进程是否产生 Core Dump?
    • 十一、总结
    • 结语:

一、前言

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

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

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

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

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

  那么,一个问题就来了:

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

  例如:

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

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

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


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

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

产生方式典型操作对应信号
键盘产生 Ctrl+C、Ctrl+\\、Ctrl+Z SIGINT、SIGQUIT、SIGTSTP
系统调用产生 kill()、raise()、abort() 根据调用参数决定
系统命令产生 kill -9 pid SIGKILL
硬件异常产生 除零、非法内存访问 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。

  那么问题来了:

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

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

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

  • 用户按下键盘按键。
  • 键盘控制器产生硬件中断,通知 CPU 有键盘输入。
  • 操作系统的中断处理机制获取键盘输入。
  • 终端驱动程序识别对应的特殊控制字符。
  • 如果识别到 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 信号的五种产生方式。

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

    #mermaid-svg-VkostN5TsB5jPUPw{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VkostN5TsB5jPUPw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VkostN5TsB5jPUPw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VkostN5TsB5jPUPw .error-icon{fill:#552222;}#mermaid-svg-VkostN5TsB5jPUPw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VkostN5TsB5jPUPw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VkostN5TsB5jPUPw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VkostN5TsB5jPUPw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VkostN5TsB5jPUPw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VkostN5TsB5jPUPw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VkostN5TsB5jPUPw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VkostN5TsB5jPUPw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VkostN5TsB5jPUPw .marker.cross{stroke:#333333;}#mermaid-svg-VkostN5TsB5jPUPw svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VkostN5TsB5jPUPw p{margin:0;}#mermaid-svg-VkostN5TsB5jPUPw .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-VkostN5TsB5jPUPw .cluster-label text{fill:#333;}#mermaid-svg-VkostN5TsB5jPUPw .cluster-label span{color:#333;}#mermaid-svg-VkostN5TsB5jPUPw .cluster-label span p{background-color:transparent;}#mermaid-svg-VkostN5TsB5jPUPw .label text,#mermaid-svg-VkostN5TsB5jPUPw span{fill:#333;color:#333;}#mermaid-svg-VkostN5TsB5jPUPw .node rect,#mermaid-svg-VkostN5TsB5jPUPw .node circle,#mermaid-svg-VkostN5TsB5jPUPw .node ellipse,#mermaid-svg-VkostN5TsB5jPUPw .node polygon,#mermaid-svg-VkostN5TsB5jPUPw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VkostN5TsB5jPUPw .rough-node .label text,#mermaid-svg-VkostN5TsB5jPUPw .node .label text,#mermaid-svg-VkostN5TsB5jPUPw .image-shape .label,#mermaid-svg-VkostN5TsB5jPUPw .icon-shape .label{text-anchor:middle;}#mermaid-svg-VkostN5TsB5jPUPw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VkostN5TsB5jPUPw .rough-node .label,#mermaid-svg-VkostN5TsB5jPUPw .node .label,#mermaid-svg-VkostN5TsB5jPUPw .image-shape .label,#mermaid-svg-VkostN5TsB5jPUPw .icon-shape .label{text-align:center;}#mermaid-svg-VkostN5TsB5jPUPw .node.clickable{cursor:pointer;}#mermaid-svg-VkostN5TsB5jPUPw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VkostN5TsB5jPUPw .arrowheadPath{fill:#333333;}#mermaid-svg-VkostN5TsB5jPUPw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VkostN5TsB5jPUPw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VkostN5TsB5jPUPw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VkostN5TsB5jPUPw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VkostN5TsB5jPUPw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VkostN5TsB5jPUPw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VkostN5TsB5jPUPw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VkostN5TsB5jPUPw .cluster text{fill:#333;}#mermaid-svg-VkostN5TsB5jPUPw .cluster span{color:#333;}#mermaid-svg-VkostN5TsB5jPUPw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-VkostN5TsB5jPUPw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VkostN5TsB5jPUPw rect.text{fill:none;stroke-width:0;}#mermaid-svg-VkostN5TsB5jPUPw .icon-shape,#mermaid-svg-VkostN5TsB5jPUPw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VkostN5TsB5jPUPw .icon-shape p,#mermaid-svg-VkostN5TsB5jPUPw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VkostN5TsB5jPUPw .icon-shape .label rect,#mermaid-svg-VkostN5TsB5jPUPw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VkostN5TsB5jPUPw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VkostN5TsB5jPUPw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VkostN5TsB5jPUPw :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}#mermaid-svg-VkostN5TsB5jPUPw .startNode>*{fill:#F2F4F7!important;stroke:#9CA3AF!important;stroke-width:2px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .startNode span{fill:#F2F4F7!important;stroke:#9CA3AF!important;stroke-width:2px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .startNode tspan{fill:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .sourceNode>*{fill:#E0F2FE!important;stroke:#7DD3FC!important;stroke-width:1px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .sourceNode span{fill:#E0F2FE!important;stroke:#7DD3FC!important;stroke-width:1px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .sourceNode tspan{fill:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .detailNode>*{fill:#DCFCE7!important;stroke:#86EFAC!important;stroke-width:1px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .detailNode span{fill:#DCFCE7!important;stroke:#86EFAC!important;stroke-width:1px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .detailNode tspan{fill:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .osNode>*{fill:#F3E8FF!important;stroke:#D8B4FE!important;stroke-width:2px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .osNode span{fill:#F3E8FF!important;stroke:#D8B4FE!important;stroke-width:2px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .osNode tspan{fill:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .pendingNode>*{fill:#FFE4E6!important;stroke:#FDA4AF!important;stroke-width:1px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .pendingNode span{fill:#FFE4E6!important;stroke:#FDA4AF!important;stroke-width:1px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .pendingNode tspan{fill:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .handleNode>*{fill:#ECFCCB!important;stroke:#BEF264!important;stroke-width:2px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .handleNode span{fill:#ECFCCB!important;stroke:#BEF264!important;stroke-width:2px!important;color:#333!important;}#mermaid-svg-VkostN5TsB5jPUPw .handleNode tspan{fill:#333!important;}

    信号产生

    键盘输入

    系统调用

    系统命令

    硬件异常

    软件条件

    Ctrl+C / Ctrl+\\ / Ctrl+Z

    kill() / raise() / abort()

    kill -9 等

    除零 / 野指针

    alarm() / SIGPIPE

    操作系统处理

    保存信号 Pending

    选择合适的时机

    执行信号处理


    结语:

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

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

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【Linux 系统篇(三十六)】信号(二):信号是如何产生的?——从键盘输入到硬件异常
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!