欢迎光临
我们一直在努力

【Linux】二十一.《Linux 信号机制详解:概念、产生方式与核心问题》

在写这篇文章前现给大家明确一个事情:我讲的信号和进程间通信的信号量不是一个东西

先给大家四个基本结论,在后续的讲解过程中就会用到和解析这几个基本点:

  • 信号处理,进程在信号没有产生的时候,早就知道信号该如何处理了

  • 信号的处理,不是立即处理,而是可以等一会在处理,合适的时候,进行信号的处理

  • 人能识别信号,是提前被"教育"过的,进程也是如此,进程早已经内置了对于信号的识别和处理方式

  • 信号源非常多 –> 给进程产生信号的,信号源,也非常多

  • 一.理解认识信号

    1.1生活角度的信号

    •  你在⽹上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
    •  当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的⾏为并不是⼀定要⽴即执⾏可以理解成“在合适的时候去取”。
    •  在收到通知,再到你拿到快递期间,是有⼀个时间窗⼝的,在这段时间,你并没有拿到快递,但是你知道有⼀个快递已经来了。本质上是你“记住了有⼀个快递要去取”
    •  当你时间合适,顺利拿到快递之后,就要开始处理快递了。⽽处理快递⼀般⽅式有三种:1. 执⾏默认动作(幸福的打开快递,使⽤商品)2. 执⾏⾃定义动作(快递是零⻝,你要送给你的⼥朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏)
    • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

    在生活中也存在着很多信号:比如闹钟、电话铃响、红绿灯,狼烟,脸色,肚子疼等等

    两个问题:

    第一个,为什么我们能认出红绿灯或者闹钟呢?

    说白了就是因为有人教过我们,告诉我们那是什么东西,然后我们就记住了。

    第二个,假如身边没有闹钟,那我们知道闹钟响了之后要干嘛吗?

    肯定是知道的,因为以前有人教过我们,不光说了它是什么,还说了为什么要用它,以及听到之后该怎么做。这两个问题其实就对应了 “是什么”和“怎么办”。至于“为什么”,就是说我们之所以需要认识闹钟,是因为我们需要被提醒

    所以我觉得,“是什么”和“怎么办”这俩加起来,就是人能识别信号的关键

    把这个放到操作系统里看,感觉挺像的。操作系统(OS)就像社会,进程就像人。社会里有很多信号围着人转,操作系统里也有很多信号围着进程转。所以进程也得能认得出各种各样的信号。

    其实我想说的就是,进程不光能认识信号,而且不管信号来没来,它都知道该怎么处理。就像我们就算手边没闹钟,也知道闹钟响了要起床一样,提前就知道该干嘛。


    1.2技术应用角度的信号

    先看一个具体的场景:我们在 Shell 里启动了一个前台进程(比如运行一个程序)。

    然后我们按下 Ctrl+C,想把它停下来。这个过程其实是这样的:

    键盘被按下 –> 产生了一个硬件中断

    操作系统捕捉到这个中断 –>把它解释成一个信号

    操作系统把这个信号发给正在运行的那个前台进程

    进程收到信号 –> 执行对应的处理动作(比如退出)


    一个代码样例:

    // sig.cc
    #include <iostream>
    #include <unistd.h>
    int main()
    {
        while(true){
        std::cout << "I am a process, I am waiting signal!" << std::endl;
        sleep(1);
        }
    }

    • ⽤户输⼊命令,在Shell下启动⼀个前台进程
    • 按下 Ctrl+C ,这个键盘输⼊产⽣⼀个硬件中断,被OS获取,解释成信号,发送给⽬标前台进程
    • 前台进程因为收到信号,进⽽引起进程退出

    b.⼀个系统函数

    NAME
    signal – ANSI C signal handling
    SYNOPSIS
    #include <signal.h>
    typedef void (*sighandler_t)(int);
    sighandler_t signal(int signum, sighandler_t handler);
    参数说明:
    signum:信号编号后⾯解释,只需要知道是数字即可
    handler:函数指针,表⽰更改信号的处理动作,当收到对应的信号,就回调执⾏handler⽅法

    ⽽其实, Ctrl+C 的本质是向前台进程发送 SIGINT 即 2 号信号,我们证明⼀下,这⾥需要引⼊⼀个系统调⽤函数

    开始测试:


    问题:为什么 Ctrl+C 不能退出,Ctrl+\\ 却能?

    我代码里的意思是:把 SIGINT 信号的处理方式改成了自己写的函数。

    而 Ctrl+C 产生的正好就是 SIGINT 这个信号。

    所以当你按下 Ctrl+C 的时候,流程是这样的:

    内核收到了键盘中断,把它解释成 SIGINT 信号,然后发给你的进程。进程收到之后,查了一下之前注册的信息,发现 SIGINT 已经被你改成自定义处理了,于是就去执行你写的那个函数,打印了一句话。打印完之后,函数返回,进程回到主循环继续跑,完全没有退出的意思。

    这就是为什么你按 Ctrl+C 没用,它只是在执行你给它安排的任务而已。


    那 Ctrl+\\ 呢?

    Ctrl+\\ 产生的是 SIGQUIT 信号,3号。代码里只改了 SIGINT 的处理方式,根本没有动 SIGQUIT。所以当进程收到 SIGQUIT 的时候,查不到自定义处理,就执行默认动作。而 SIGQUIT 的默认动作就是终止进程并生成 core 文件,所以进程直接退出了。

    补充:进程崩溃的时候,操作系统也会拍一张"快照",把进程死掉那一刻的内存内容、寄存器状态、调用栈等信息全部 dump 到硬盘上,存成一个文件,这个文件就叫 core 文件


    知识补充:

    补充一:进程早就知道该怎么处理信号了

    进程在启动的时候,内核已经把每个信号的默认处理方式告诉它了。比如 SIGINT 默认是终止,SIGQUIT 默认是终止+产生 core 文件。然后用 signal 函数可以提前改掉某个信号的处理方式。

    所以上面代码里,程序一跑起来,signal(SIGINT, handler) 就已经告诉系统"以后收到 SIGINT 就调 handler,别走默认了"。信号来不来的,这个规则已经定好了。


    补充二:信号来了不一定要马上处理

    按 Ctrl+C 的时候,信号确实产生了,但会不会立刻执行 handler?不一定。

    进程可能正在执行系统调用,比如 sleep 或者 read,这时候信号被标记为"待处理",等进程从内核态返回用户态的时候,才会去检查有没有信号 pending,有的话才去执行对应的动作。

    所以信号处理是有延迟的,不是硬中断那种"说停就停"的响应方式,而是等一个合适的时机再去办。


    补充三:进程天生就认识这些信号

    人能识别危险是因为学过,进程能识别信号是因为内核天生就给它装好了这套规则。什么信号是什么意思,默认该干啥,进程一启动就门儿清。signal 只是改了其中某几个的处理方式而已,底层的信号机制一直都在。


    补充四:Ctrl+U 为啥能退出?

    但我试了一下ctrl+u可以修改,说白了就一句话:Ctrl+C 对应的信号被你改了,Ctrl+U 对应的信号没改。

    • Ctrl+C 产生 2 号 SIGINT,代码里把它改成了自定义 handler,所以只会打印一句话,进程不退。

    • Ctrl+U 产生 3 号 SIGQUIT,代码里没改它,还是默认行为,所以进程直接退出。

    • 如果把 signal(SIGINT, handler) 改成 signal(SIGQUIT, handler),那 Ctrl+U 也会退不了,Ctrl+C 反而能杀掉进程。

    你改哪个,哪个就失效;没改的,默认动作照常生效。


    补充五:不要把 Ctrl+U 和 Ctrl+\\ 弄混

    • *Ctrl+* 产生的是 SIGQUIT(3 号信号),默认终止+产生 core 文件。

    • Ctrl+U 是终端驱动的行编辑功能(删除光标前的内容),跟信号没有关系,它是一个独立的终端功能,所以在任何情况下 Ctrl+U 都能正常工作,跟信号捕捉无关。


    注意的点

    第一,Ctrl+C 产生的信号只能发给前台进程。 如果我们在命令后面加个 &这个进程就会被放到后台运行。这时候 Shell 不用等它结束,可以继续接收新命令、启动新进程。后台进程收不到 Ctrl+C 的信号,所以按 Ctrl+C 对它没用。

    第二,Shell 可以同时跑一个前台进程和很多个后台进程。 但只有前台进程能接收到像 Ctrl+C 这种控制键产生的信号。后台进程在偷偷干活,不受这些按键影响。

    第三,前台进程在跑的时候,用户随时可能按下 Ctrl+C。 这意味着进程的代码执行到任何位置,都有可能突然收到信号然后终止。所以说,信号相对于进程的正常执行流程,是异步的——就是说它不按顺序来,不一定等进程执行完某个操作才到,而是随时可能插进来。你没法预测它什么时候来,就像你不知道快递什么时候突然敲门一样。


    同步和异步

    场景是正在上课,老师的快递到了,老师让张三去取,但有两种情况;

    同步:我们自习一会,等张三回来再讲。 –> 说明这件事得等张三回来才能继续顺序是固定的,你等着就行了,知道他回来之后会发生什么。

    异步:我们继续上课,不等张三,让他自己去取快递。 –>说明我们该干嘛干嘛,张三的事是突然插进来的,不按顺序走你没法预料他什么时候回来。


    对应放到信号里:

    信号是异步的,就跟这个例子里的“张三取快递”一样:

    进程正常跑着自己的代码,突然信号来了(就像张三突然要去取快递),进程就得停下来去处理信号,处理完再接着干活。 但进程不知道信号啥时候会来,就像你不知道张三啥时候会突然离开教室一样。


    1.3信号概念

    信号是一种事件的异步通知机制(属于软中断),专门发给进程的。

    拆开来看:

    事件:发生了某件事(比如按了Ctrl+C、定时器到了、别的进程发了消息)

    通知:操作系统告诉进程“有件事发生了”

    异步:进程不知道这件事啥时候会来,随时可能被打断

    机制:就是一种方式、一套规则

    发给进程:信号不是发给别人的,就是发给进程的


    1.4 kill -l查询系统支持的所有信号列表

    man 7 signal指令:查看第 7 章节中关于信号 (signal) 的官方文档

    信号的分类:

    仔细数一下会发现,不是64种,因为中间缺了32和33号,所以实际一共是 62种信号

    它们被分成了两类:

    • 1~31号:叫做普通信号

    • 34~64号:叫做实时信号(名字里都带RT两个字母)

    实时信号响应特别快,优先级很高,像着火了一样必须立刻处理。后面我们主要关注普通信号,实时信号知道有这个东西就行了,类比一下就像每天的闹钟,响了就得处理,但稍微慢一点点也问题不大。


    信号的编号和名字

    每个信号都有一个数字编号和一个宏定义名字,比如:

    • 2号叫 SIGINT(就是我们按Ctrl+C产生的那个信号)

    • 9号叫 SIGKILL(强制终止进程)

    • 14号叫 SIGALRM(闹钟定时信号)

    这些名字其实是在系统头文件 signal.h 里定义的,SIGINT 和写 2 是一个意思,但用名字更直观。

    #define SIGINT 2


    信号的默认处理方式

    每个信号被收到后,进程默认会怎么处理呢?文档里给了五种默认动作(参考下方的说明):

    表格说明:

    动作含义
    Term 终止进程
    Ign 忽略信号,啥也不干
    Core 终止进程并生成核心转储文件(用于调试)
    Stop 暂停(挂起)进程
    Cont 如果进程当前是暂停状态,就继续运行它

    比如我们熟悉的:

    • SIGINT(2号)默认是 Term,终止进程

    • SIGKILL(9号)默认也是 Term,强制终止

    • SIGCHLD(17号)默认是 Ign,忽略它


    1.5信号处理常见方式概览

    除了使用系统默认的处理动作,我们还可以自己定义信号的处理方式,一般有三种可选方案(后面讲 sigaction 函数时会详细介绍):

    第一种:忽略信号

    就是告诉操作系统:这个信号来了我不想管,你直接忽略掉就行。

    代码里这样写:

    signal(SIGINT, SIG_IGN);

    SIG_IGN 是一个宏,意思就是 Ignore,忽略。

    运行效果:按下 Ctrl+C 之后,进程啥反应没有,继续正常运行,因为 SIGINT 信号被忽略了。


    第二种:默认处理

    就是让操作系统按照系统默认的方式去处理。每个信号都有自己的默认动作,比如 SIGINT 的默认动作是终止进程。

    代码里这样写:

    signal(SIGINT, SIG_DFL);

    SIG_DFL 也是一个宏,意思就是 Default,默认。

    运行效果:按下 Ctrl+C 之后,进程直接退出,这就是默认行为。


    第三种:自定义捕捉

    就是我们上一节一直在用的方式,自己写一个函数,告诉操作系统:这个信号来了之后,别按默认的来,去执行我写的这个函数。

    代码里这样写:

    void handler(int signumber)
    {
    // 你想做的事情
    }
    signal(SIGINT, handler);

    运行效果:按下 Ctrl+C,进程不会退出,而是执行你写的 handler 函数,打印一句话或者干点别的。


      (1) 信号的产生

    信号的产生方式主要有两种;

    第一种是用 kill 命令。比如在终端里输入 kill -2 PID,就是给指定进程发送一个 2 号信号,也就是 SIGINT。这跟我们按 Ctrl+C 本质上是一回事。

    第二种就是键盘操作。最常见的莫过于 Ctrl+C 了,按下之后系统就给我们发了一个 SIGINT。

    所以不管你用哪种方式,最终结果都是同一个——信号产生了,准备发给某个进程。

    (2)信号的识别

    进程收到信号后会立刻处理吗?

    答案是不一定。

    进程收到信号之后,往往不会马上就处理,而是先记下来,等到了一个"合适的时机"再去处理。这就好比你正在打游戏,快递员打电话说快递到了,你不会立刻冲下去拿,而是会说"好,等我打完这一局再去"。

    那为什么信号不能立即处理呢?原因其实很简单:信号产生的时刻是无法预料的,它可能在进程执行任何一行代码的时候突然到来。如果进程正在做一些关键的事情,比如写文件、处理重要数据,这时候被中断可能会出问题,所以进程选择先把信号存起来,等手头的事情告一段落再处理。

    这个"合适的时机"后面会细说,这里先记住一个点:信号不是来一个处理一个,而是排队等着被处理。


    (3)信号的处理

    处理方式有三种,前面其实已经介绍过了:

    第一种是默认处理。每个信号都有自己的默认行为,大部分是终止进程,但也有些是别的功能,比如暂停进程或者继续运行。

    第二种是忽略信号。就是信号来了,啥也不干,当没发生过。这其实也算是一种处理方式。

    第三种是自定义捕捉。就是自己写一个函数,告诉系统:这个信号来了,别按默认的来,去执行我写的那个函数。

    用闹钟来类比一下应该更好理解:闹钟响了,你起床了,这是默认行为;闹钟响了,你关掉继续睡,这是忽略;闹钟响了,你起来跳个舞,这就是自定义捕捉。不同的人对同一个信号可以有不同的处理方式,进程也是这个道理。

    1.6信号的本质

    前面我们把信号的产生、识别、处理都过了一遍,现在回过头来问一个核心问题:信号到底是个什么东西?它在计算机里长什么样?存在哪?怎么存的?


    1.信号为什么需要被保存?

    先说一个关键点信号不是立即处理的

    前面举过例子,你在打游戏的时候快递到了,你不会立刻冲下去,而是先记着"有快递这回事",等打完这局再说。

    进程也是一样。信号可能在进程执行任何一行代码的时候突然到来,如果进程正在干要紧的事,比如写文件、拷贝数据,这时候被中断可能会出问题。所以进程的选择是:先记住有信号来了,等合适的时候再处理。

    既然要"先记住",那就必须有个地方来存这个信号。那信号存在哪呢?


    2.信号存在哪?

    信号是发给进程的,不是发给硬件也不是发给网络的,所以信号一定保存在进程自己的地盘里,也就是进程控制块,也就是我们常说的 task_struct

    每个进程都有自己的一份 task_struct,里面记录了进程的各种信息。信号就存在这里面的一个字段里。


    3.信号怎么存的?

    存信号的方式,用的是位图

    什么是位图?简单来说就是用比特位来表示某种状态。比如你有31个信号,就用31个比特位,每个位代表一个信号。

    • 比特位的位置 –> 代表是几号信号

    • 比特位的内容 –> 1 表示这个信号来了,0 表示没来

    所以实际上,task_struct 里只需要一个 unsigned int 类型(00000000 … 00000000)的字段就够了,正好32个比特位,用来表示31个普通信号绰绰有余。

    这就像你有一个通知栏,来了什么消息就亮起对应的图标,一看就知道哪些消息还没处理。


    4.发送信号的本质是什么?

    弄清了信号存在哪、怎么存的,那"发送信号"到底是在干什么?

    发送信号的本质,就是操作系统去修改目标进程 task_struct 里的信号位图,把对应的比特位从 0 改成 1。

    就这么简单,不是什么神秘的操作,也不是传输什么数据包,就是改了一个比特位


    5.为什么只有操作系统能发信号?

    既然发送信号就是改位图,那谁有资格去改呢?只有操作系统。

    因为 task_struct 是属于内核的数据结构,用户进程是没有权限直接访问的。只有操作系统这个"系统资源的管理者"才有资格去读写它。所以不管信号是怎么产生的,最终真正把信号"发出去"的,一定是操作系统。


    6.所有信号最终都是操作系统发的

    我们前面说过信号产生的几种方式:

    用 kill 命令发信号,kill 命令本身是在 bash 里执行的,它的底层会调用操作系统提供的系统调用接口,通过这个接口去修改目标进程的位图

    按 Ctrl+C 发信号,键盘是硬件,操作系统是硬件的管理者,键盘产生的中断一定先被操作系统拿到,然后由操作系统解释成信号,再写进目标进程的位图

    程序里调用 kill 函数发信号,也是一样的道理,最终还是落到系统调用上。

    所以不管信号从哪来、怎么来的,殊途同归:

    所有信号的产生,最终都一定要经过操作系统,由操作系统来把信号写进目标进程的位图里。


    7.信号从产生到处理的完整过程

    第一步:初始状态

    一开始,进程 A 正在正常运行。它的 task_struct 里面有一个信号位图,这个时候所有比特位都是 0,表示没有任何待处理的信号。进程 A 继续干自己的活,什么都不知道。


    第二步:信号产生(按下 Ctrl+C)

    突然,有人按下了 Ctrl+C。这个操作想要终止进程 A。

    键盘被按下之后,首先产生的是一个硬件中断。操作系统作为硬件的管理者,第一时间捕获到了这个中断。操作系统拿到键盘发来的数据之后,解读了一下,发现这是 Ctrl+C,对应的是 SIGINT 信号,也就是 2 号信号。


    第三步:信号发送(操作系统改位图)

    于是操作系统开始找进程 A 的 task_struct,找到了之后,把里面信号位图的第 2 个比特位从 0 改成了 1。

    这一步就是所谓的"发送信号"。说白了就是改了一个比特位。


    第四步:信号未决(Pending 状态)

    这个时候,进程 A 还在正常跑着,它自己并不知道自己的位图已经被改了。信号现在是"待处理"状态,也就是前面说过的 Pending 状态。信号已经来了,但进程还没处理它。


    第五步:信号识别(检查位图)

    过了一段时间,进程 A 运行到了一个"合适的时机"。这个"合适的时机"通常是从内核态返回用户态的时候,比如系统调用结束了、中断处理完了之类的情况。

    这时候进程 A 会检查一下自己的信号位图,发现有比特位是 1,说明有信号在等着。它一看第 2 位是 1,就知道来的是 SIGINT。


    第六步:信号处理

    然后进程 A 就去执行对应的处理动作。至于具体怎么处理,取决于之前注册的是默认、忽略还是自定义函数。


    第七步:处理完毕(清空位图)

    处理完之后,进程 A 把信号位图的第 2 位从 1 改回 0,表示这个信号已经处理完了,可以清掉了。然后进程 A 继续正常执行后面的代码


    简单小结一下

    信号就是一个进程里的比特位。发送信号就是操作系统把这个比特位从 0 改成 1。处理信号就是进程把这个比特位从 1 改成 0。


    二.产生信号

    1.通过终端按键产⽣信号

  • Ctrl+C (SIGINT) 已经验证过,这⾥我就不再重复
  • Ctrl+\\(SIGQUIT)可以发送终⽌信号并⽣成core dump⽂件,⽤于事后调试。
  • Core Dump

    1.Core Dump 是啥?

    Core Dump 可以理解为操作系统给崩溃的进程拍了一张“死亡快照”。进程在崩溃的那一刻,内存里有什么数据,当前执行到哪条指令,函数调用关系是怎样的,CPU 寄存器里存了什么值,通通被 OS 打包成一个文件存到磁盘上,这个文件就叫 core 文件。

    举几个会触发 Core Dump 的常见操作:

    • 野指针解引用:int *p = nullptr; *p = 100;

    • 除零操作:int a = 10 / 0;

    • 栈溢出:递归函数忘记写终止条件

    • 使用 abort() 函数主动触发异常终止

    终端里如果出现了 (core dumped) 这几个字,就代表 core 文件生成成功了。配合 gdb 调试工具,可以直接定位到崩溃的那一行代码。


    2.那平时我们在写代码的时候为啥我从来没见过 core 文件?

    因为系统默认是关着的。Linux 对进程能使用的资源有限制,core 文件大小默认是 0,也就是不让生成。

    用命令看一下:

    ulimit -c

    输出是 0,说明 core 文件被禁用了。需要手动打开:

    ulimit -c unlimited # 不限制大小

    这个设置只对当前终端有效,关了窗口就没了。如果觉得 unlimited 太野了,也可以指定具体大小:

    ulimit -c 1024 # 最大 1M(单位 KB)
    ulimit -c 10240 # 最大 10M

    想看一眼所有资源限制的话:

    ulimit -a

    会输出一堆东西,包括栈大小、最大进程数等等。core 文件限制在“core file size”那一行。有些 Linux 发行版在执行 ulimit -c unlimited 时会报错 unlimited: invalid number,这时候直接改成具体数字就行了:

    ulimit -c 1024


    3.写个程序验证一下

    写一段肯定会崩的代码:

    #include <iostream>

    int main()
    {
    int* p = nullptr;
    *p = 100; // 野指针访问,触发段错误
    return 0;
    }

    先不开 core,会只显示“段错误”,没有 (core dumped),当前目录下也没有 core 文件。符合预期,因为 ulimit -c 是 0,被禁用了。


    如果ulimit -c已经被修改了,那么结果就是:

    然后打开 core:

    ulimit -c unlimited
    ./test
    段错误 (core dumped)

    这次就出现了 (core dumped),然后用 ls 看一眼,多了一个 core 文件:


    刚开始我用ls的时候发现没反应,然后发现这个是根据路径找到错因,我的 core 文件被 Ubuntu 的 Apport 接管了,没有放在当前目录。

    所以我首先确认一下 Apport 服务是不是开着:

    sudo service apport status

    我这个显示没有开,但如果是 running 状态,那就没有关闭会接管。看我刚才那种情况下 core 文件被 Apport 接管,不会直接生成在当前目录。我用这种的方式解决;临时关闭这个Apport服务:

    sudo service apport stop
    ulimit -c unlimited
    ./test

    关掉 Apport 之后,程序崩溃就会在当前目录生成 core 文件。

    不同系统 core 文件命名方式可能不一样,有的是 core,有的是 core.进程PID,可以自己设置命名格式。

    然后用 gdb 加载 core 文件,直接看崩溃位置:

    gdb test core.ID号

    进去之后输入 bt(backtrace 的缩写)查看函数调用栈,就能直接看到崩溃在哪一行:

    一下子就定位到了,原来排查段错误可以这么方便。以前遇到段错误全靠打印日志猜,现在不用了,core 文件一看就知道崩在哪。


    4.Core Dump 标志位

    在 wait/waitpid 的 status 里,bit7 就是 Core Dump 标志位Core Dump 标志位(1 = 生成 core 文件,0 = 没有),记录进程崩溃时有没有生成 core 文件,直接看这张表就清楚了:

    位域含义说明
    bit0 – bit6 终止信号编号 进程是被哪个信号干掉的(比如 11 是 SIGSEGV)
    bit7 Core Dump 标志 1 表示产生了 core 文件,0 表示没有
    bit8 – bit15 退出码 进程正常退出时的返回值(exit(0) 这种)

    WCOREDUMP(status) 就是帮你检查 bit7 是 0 还是 1,不用自己去写位运算。

    实验一:waitpid 解析 status,识别 Core Dump 标志位

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

    using namespace std;

    int main()
    {
    pid_t pid = fork();
    if (pid == 0)
    {
    // 子进程:故意崩溃
    int* p = nullptr;
    *p = 100;
    exit(0);
    }
    else if (pid > 0)
    {
    int status;
    pid_t ret = waitpid(pid, &status, 0);
    if (ret == pid)
    {
    cout << "子进程退出了" << endl;
    if (WIFEXITED(status))
    {
    cout << "正常退出,退出码: " << WEXITSTATUS(status) << endl;
    }
    else if (WIFSIGNALED(status))
    {
    cout << "被信号杀死,信号编号: " << WTERMSIG(status) << endl;
    if (WCOREDUMP(status))
    {
    cout << "发生了 Core Dump,生成了 core 文件" << endl;
    }
    }
    }
    }
    return 0;
    }

    WCOREDUMP(status) 这个宏就是用来检查 bit7 的。如果进程崩溃时生成了 core 文件,这个宏返回真,否则返回假。

    SIGSEGV 编号是 11,段错误,core 文件生成后 WCOREDUMP 返回 1,bit7 被置 1。这些位操作不用自己写,用宏就行了,系统给封装好了直接用。

    函数作用:

    WIFEXITED(status) 判断进程是否正常退出(调用 exit 或者 return)
    WEXITSTATUS(status) 获取正常退出时的退出码(0 表示成功)
    WIFSIGNALED(status) 判断进程是否被信号干掉的
    WTERMSIG(status) 获取干掉进程的信号编号
    WCOREDUMP(status) 判断是否产生了 core 文件

    实验总结

  • 通过waitpid获取子进程退出状态status,status 是一个位图结构: 低 7 位保存杀死进程的终止信号,低 8 位中单独存在 1bit,作为 core dump 标志位,用来标记本次崩溃是否发生核心转储。
  • 和核心转储关联理解 当子进程因未捕获致命信号崩溃,并且ulimit -c > 0满足转储条件时,内核不仅会终止进程、记录终止信号,还会将 status 内的 core dump 标志位置 1; 若资源限制不允许生成 core 文件,标志位则置 0。
  •         由此可见:终止信号 和 core dump 标志是两组独立信息。 无论能不能生成 core 文件,只要进程被信号杀死,低 7 位一定记录对应的信号编号;core 标志仅仅代表是否成功生成快照。


    5.线上环境为什么不开 Core Dump?

    一开始觉得 core 文件这么好用,为啥不一直开着?

    后来想明白了一个场景:假设线上某个服务有 bug,不断崩溃又不断被守护进程重启。每次崩溃都生成一个 core 文件,少则几十 MB,多则几百 MB。没过多久磁盘就满了,服务器连不上,业务直接挂掉。

    所以线上环境一般默认关闭,只在本地调试的时候临时开一下。如果线上实在需要排查问题,用 gdb 直接挂载正在运行的进程进行调试可能更稳妥。

    如果已经产生了 core 文件,用 gdb 加载查看,排查完后记得及时删掉,别占着磁盘空间。


    6.补充:命名方式

    Core 文件的命名格式可以通过系统参数修改:

    cat /proc/sys/kernel/core_pattern

    默认一般是 core 或者 core.%p(%p 是进程 PID)。如果是 core.%p,生成的文件名就是 core.1234 这种格式,方便区分是哪个进程产生的。

    实验 二:持续运行信号测试程序

    文件名:signal.cc

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

    // 信号捕获回调
    void handler(int sig)
    {
    std::cout << "进程捕捉到了一个信号,正在处理中:"
    << sig << " Pid: " << getpid() << std::endl;
    }

    int main()
    {
    // 捕获 Ctrl+C(2:SIGINT) Ctrl+\\(3:SIGQUIT)
    signal(SIGINT, handler);
    signal(SIGQUIT, handler);
    // 不要捕获 SIGFPE(8),捕获后将不会触发core转储

    pid_t pid = getpid();
    while(true)
    {
    std::cout << "我是一个进程,我正在运行…, Pid: " << pid << std::endl;
    sleep(1);
    }
    return 0;
    }

    编译如下:

    g++ -g signal.cc -o mysignal
    ./mysignal

    1.程序持续打印 PID。

    2. 测试 1:按下 Ctrl+C(信号 2 SIGINT) 输出:进程捕捉到了一个信号,正在处理中:2 Pid: xxx 进程不会退出,继续循环运行。

    3. 测试 2:

    按下 Ctrl+\\(信号 3 SIGQUIT) 输出:进程捕捉到了一个信号,正在处理中:3 Pid: xxx 进程不会退出,继续循环运行。

    4. 测试 3:新开终端执行

    开一个新的终端:

    kill -8 你的进程PID


    使用ls查看目录,可以看到自动生成 core.PID 文件,后缀数字对应崩溃进程 PID。多次重复发送信号,会生成多个独立 core 快照文件。

    对进程注册捕获处理的信号(2 号 SIGINT、3 号 SIGQUIT),信号到来时执行我们自定义的回调函数,默认不会杀死进程; 没有设置捕获的 SIGFPE (8 号) 属于具有 Core Dump 属性的致命信号,触发信号默认处理行为:终止进程,条件满足时生成 core 文件

  • 信号捕获 ≠ 永远不会退出 我们可以在 handler 内部手动调用exit()主动退出; 本次实验代码 handler 里没有退出逻辑,所以进程继续运行。
  • 不是所有未捕获信号都会终止进程 Linux 信号默认动作分为:终止、忽略、停止、继续等。 SIGFPE、SIGSEGV 这类默认动作是终止 + 可选 core; 还有部分信号默认动作仅仅是忽略。
  • SIGFPE 想要产生 core,额外前提:ulimit -c > 0,否则即便进程崩溃,也不会输出core dumped。
  • Core was generated by ./signal core 文件与当前加载的可执行程序完全匹配,不存在版本不匹配问题。
  • Program terminated with signal SIGFPE, Arithmetic exception. 验证进程终止信号为 SIGFPE (8 号浮点异常信号),和我们手动执行 kill -8 PID 的操作完全对应,证明 core 快照完整记录了进程死亡原因。
  • #0 … __GI_clock_nanosleep 当前堆栈停留在clock_nanosleep,也就是sleep()底层系统调用。
  • 重点分析: SIGFPE 是外部异步发送的信号,信号可以在程序任意运行时刻递达。本次信号送达时程序正好处于sleep休眠阶段,因此崩溃现场定格在休眠函数内;不代表 sleep 函数出错

    实验清理小命令,批量删除所有 core 文件:

    rm -f core.*

    总给:

  • 信号行为区分:被代码捕获的 2 号 SIGINT、3 号 SIGQUIT 信号,只会执行自定义回调函数,不会终止进程;未捕获的 8 号 SIGFPE 属于致命信号,执行信号默认处理动作,直接终止进程。
  • 核心转储(Core Dump)解释: 当进程收到未捕获、支持生成转储的致命信号发生崩溃时,操作系统会把进程崩溃瞬间的内存、寄存器、函数调用栈等完整运行现场保存到磁盘,生成core.PID快照文件,这个过程就叫做核心转储。 成功生成 core 文件需要同时满足条件:信号具备 core 转储属性、程序未捕获该信号、ulimit -c资源限制大于 0。 core 文件最大价值是支持离线事后调试,即便程序已经退出,我们依旧可以使用 gdb 加载快照还原崩溃现场。
  • ulimit 修改的是当前 Shell 的资源限制,Shell 创建的子进程会自动继承限制,所以我们在终端修改 ulimit 能够控制程序能否产生 core 文件。
  • 结合本次 gdb 调试现象:使用gdb ./signal core.xxx加载快照后,确认进程由 SIGFPE 信号终止。由于信号是通过kill外部异步发送,信号抵达时程序正在执行 sleep 休眠,因此调用栈定格在clock_nanosleep函数内。
  • 生产环境默认关闭核心转储。core 文件占用空间较大,一旦程序持续崩溃重启,会源源不断生成大量 core 文件,最终占满磁盘,造成服务异常。

  • 补充一下:什么是 Term?

    Term 是 Terminate 的缩写,表示终止进程。进程收到 Action 为 Term 的信号后,直接结束,不会生成任何额外文件。

    信号表中的 Term 信号:

    信号编号Action说明
    SIGHUP 1 Term 终端挂起
    SIGINT 2 Term Ctrl+C 中断
    SIGKILL 9 Term 强制杀死
    SIGTERM 15 Term 默认终止信号
    SIGPIPE 13 Term 管道破裂

    Term 信号的共同特点就是:进程死了就死了,什么也没留下。你只知道进程没了,但不知道它死之前发生了什么、执行到哪一行代码出的问题。


    Core 和 Term 对比总结

    对比TermCore
    全称 Terminate Core Dump
    进程是否终止  终止 终止
    是否生成 core 文件  不生成 生成
    能否事后调试  不能  可以用 gdb 分析
    典型信号 SIGINT(2)、SIGTERM(15)、SIGKILL(9) SIGSEGV(11)、SIGABRT(6)、SIGFPE(8)
    使用场景 正常终止、用户主动杀死 程序异常崩溃、需要定位 bug

    两种触发 Core Dump 的硬件异常 + 事后调试

    1.除零异常 SIGFPE (8)

    #include <iostream>
    #include <signal.h>
    #include <unistd.h>
    using namespace std;

    void catchSig(int signum)
    {
    cout << "进程捕捉到了一个信号,正在处理中: " << signum << " Pid: " << getpid() << endl;
    }

    int main()
    {
    signal(SIGINT, catchSig);
    signal(SIGQUIT, catchSig);

    while(true)
    {
    cout << "我是一个进程,我正在运行…, Pid: " << getpid() << endl;
    sleep(1);
    int a = 100;
    a /= 0; // 主动制造除零算术异常
    cout << "run here…" << endl;
    }
    return 0;
    }

    进行编译:

    输出:Floating point exception (core dumped),目录生成 core.PID 文件。

    这里有警告不要担心直接进行下一步操作


    事后调试语法

    #方式一:终端一步直接加载(推荐,简洁)

    在黑色系统终端输入,不要进入 gdb 交互:

    gdb ./mysignal core.ID

    方式二:先进 gdb,再加载 core 文件(截图上方正确示例)

    1. 终端先进入 gd
    gdb ./mysignal

    2. 在 `(gdb)` 提示符内使用专属命令 `core-file`
    (gdb) core-file core.ID

    操作区分总结

    外部 Shell:可用 `gdb 程序 core文件` gdb 交互内部:只能用 `core-file 文件名`,不能再写`gdb`


    1.什么是事后调试

    程序已经崩溃、进程已经彻底退出,无法再复现崩溃现场时,依靠提前生成的core快照文件,重新还原崩溃瞬间全部运行信息,这种程序崩溃后再回头定位 bug的方式,就叫事后调试。

    2.我代码里每一处对应的事后调试体现

  • Core was generated by ./mysignal core 文件是程序崩溃瞬间自动保存的快照,程序此时早已结束运行,现在我们重新加载这份存档,属于事后回溯
  • Program terminated with signal SIGFPE, Arithmetic exception. 不用重新运行程序,直接读出进程当年崩溃的根源:收到 8 号除零异常信号。
  • #0 0x0000561449b6132e in main () at signal.cc:21 直接定位到崩溃代码行 a /= 0;,精准标出源码第 21 行出错。 核心关键点:程序早就崩溃退出了,没有再次运行程序,仅凭 core 文件就还原出崩溃代码,这就是事后调试最直观的体现。
  • 3.补充完整逻辑对比普通调试

    • 普通在线调试:一边运行程序、一边打断点,程序活着才能调试;
    • 事后调试:程序已经死掉,依靠崩溃时保存的 core 快照离线复盘,不需要复现崩溃场景,完全是事后追溯。

    总结:本次通过`gdb ./mysignal core.3029521`加载核心转储文件实现事后调试:程序早已因除零异常崩溃退出,无需重新运行复现故障,仅依靠崩溃时生成的 core 快照,就能读出进程终止信号 SIGFPE,并直接定位到源码`a /= 0;`崩溃行,完整还原崩溃现场,这就是核心转储的事后调试作用。


    2.野指针段错误 SIGSEGV (11)

    #include <iostream>
    #include <signal.h>
    #include <unistd.h>
    using namespace std;

    void catchSig(int signum)
    {
    cout << "进程捕捉到了一个信号,正在处理中: " << signum << " Pid: " << getpid() << endl;
    }

    int main()
    {
    signal(SIGINT, catchSig);
    signal(SIGQUIT, catchSig);

    while(true)
    {
    cout << "我是一个进程,我正在运行…, Pid: " << getpid() << endl;
    sleep(1);
    // 下面这行执行直接崩溃,循环直接终止
    int* p = nullptr; // 空指针
    *p = 100;
    cout << "run here…" << endl;
    }
    return 0;
    }

    编译运行命令

    g++ -o mysignal signal.cc -g
    ulimit -c unlimited
    ./mysignal

    运行现象

    终端输出:Segmentation fault (core dumped),目录生成 core.PID 文件。

    事后调试语法

    gdb ./mysignal core.3039676


    解析如下:

  • Program terminated with signal SIGSEGV, Segmentation fault. 代表程序因为访问非法内存(野指针)收到 11 号段错误信号崩溃
  • #0 0x0000558b41f0032a in main () at signal.cc:22 直接定位到源码signal.cc第 22 行,这一行就是 *p = 100; 野指针赋值代码。 因为是代码运行到野指针语句直接报错,不是 sleep 中途被 kill 打断,所以能精准标出错代码行,这就是加-g编译后的效果。
  • 事后调试体现在这里

    程序早就崩溃退出了,不用重新运行程序复现段错误,只靠崩溃时自动保存的 core 快照文件,执行一行 gdb 加载命令,就能查出当初崩溃的信号类型,并且精准定位野指针出错代码行,不需要重复复现故障,这就是野指针场景下核心转储的事后调试。

    总结一下:

  • 代码里写野指针int* p; *p=100;
  • -g编译,运行程序自动触发段错误,生成 core 文件
  • 终端执行gdb ./mysignal core.3039676离线复盘 bug,精准定位源码行

  • 验证进程等待中的 core dump 标志位: 

    #include <iostream>
    #include <unistd.h>
    #include <sys/wait.h>
    using namespace std;

    int main()
    {
    pid_t id = fork();
    if(id == 0)
    {
    sleep(1);
    int a = 100;
    a /= 0; // 子进程主动制造除零异常,触发SIGFPE
    exit(0);
    }
    int status = 0;
    waitpid(id, &status, 0);
    // status & 0x7F:取出低7位,代表终止信号编号
    // (status >> 7) & 1:取出第7比特,core dump标记位
    cout << "父进程: " << getpid() << " 子进程: " << id
    << " exit sig: " << (status & 0x7F)
    << " is core: " << ((status >> 7) & 1) << endl;
    return 0;
    }

    目录会生成 core 文件,代表允许生成 core 快照,可用于 gdb 事后调试。

    # 关闭core生成
    ulimit -c 0
    ./mysignal


    2.调用系统函数向进程发信号

    (1)kill 命令

    a. 接口介绍

    正确定义:kill 命令是 Linux/Unix 系统下的一个用户态命令行工具,它通过调用系统调用 kill() 来向指定进程发送信号。

    b.代码实现

    1. 发送信号程序(mykill.cc)

    #include <iostream>
    #include <signal.h>
    #include <sys/types.h>
    #include <cstdlib>
    using namespace std;

    int main(int argc, char* argv[])
    {
    // 检查参数个数
    if(argc != 3)
    {
    cout << "用法: ./mykill <信号编号> <进程ID>" << endl;
    cout << "示例: ./mykill 9 1234" << endl;
    return 1;
    }

    // 解析参数
    int signum = atoi(argv[1]);
    pid_t pid = atoi(argv[2]);

    // 调用系统调用 kill 发送信号
    int ret = kill(pid, signum);

    // 判断发送结果
    if(ret == 0)
    cout << "发送信号 " << signum << " 到进程 " << pid << " 成功" << endl;
    else
    cout << "发送失败,请检查进程是否存在" << endl;

    return 0;
    }

    2. 接收信号程序(test.cc)

    #include <iostream>
    #include <signal.h>
    #include <unistd.h>
    using namespace std;

    // 信号处理函数
    void handler(int sig)
    {
    cout << "进程 " << getpid() << " 收到信号: " << sig << endl;
    }

    int main()
    {
    // 注册信号处理函数
    signal(2, handler); // SIGINT
    signal(15, handler); // SIGTERM
    signal(10, handler); // SIGUSR1

    cout << "测试程序启动,PID: " << getpid() << endl;
    cout << "请用 ./mykill 发送信号" << endl;

    // 无限循环,等待信号
    while(true)
    sleep(1);

    return 0;
    }

    3. 编译管理(Makefile)

    all:mykill test

    mykill:mykill.cc
    g++ -o $@ $^ -std=c++11 -g

    test:test.cc
    g++ -o $@ $^ -std=c++11 -g

    .PHONY:clean
    clean:
    rm -f mykill test


    4.编译运行

    (1) 编译程序

    # 方式一:编译全部
    make

    # 方式二:只编译 mykill
    make mykill

    # 方式三:手动编译
    g++ -o mykill mykill.cc -std=c++11 -g
    g++ -o test test.cc -std=c++11 -g

    (2)运行测试

    终端1 – 运行接收程序:

    接下来等待终端发送信号,

    终端2 – 发送信号:

    (3)运行效果

    终端1 输出:

    终端2 输出:

    mykill 通过 kill() 系统调用向进程发送信号。程序先检查参数个数,然后用 atoi() 解析信号编号和 PID,最后调用 kill() 并根据返回值输出结果。命令行程序的基本框架走了一遍:参数校验、类型转换、系统调用、结果反馈。虽然简陋,但足够说明信号发送的本质。



    (2)raise函数

    接口介绍

    raise() 就是自己给自己发信号。

    raise(8); // 给自己发 8 号信号(SIGFPE)

    等价于:

    kill(getpid(), 8); // 给自己发信号

    代码实现:

    #include <iostream>
    #include <signal.h>
    #include <unistd.h>
    using namespace std;

    int main()
    {
    cout << "我开始运行了…" << endl;
    sleep(1);

    raise(8); // 给自己发 SIGFPE 信号

    cout << "这条不会打印" << endl;
    return 0;
    }

    ///////////makefile///////

    mykill:mykill.cc
    g++ -o $@ $^ -std=c++11 -g

    .PHONY:clean
    clean:
    rm -f mykill

    运行结果:


    (3)abort函数

    接口介绍

    abort() 用于程序遇到致命错误时异常终止,发送 SIGABRT(6) 信号并生成 core dump,不可恢复(即使捕获信号最终也会退出)。适合调试和错误处理场景。

    信号 Action 含义

    Action说明
    Term 终止进程(正常退出)
    Core 终止进程 + 生成 core 文件
    Stop 暂停进程(不可捕获)
    Cont 继续运行
    Ign 忽略信号

    关键信号总结

    信号编号Action说明
    SIGABRT 6 Core abort() 触发
    SIGSEGV 11 Core 野指针触发
    SIGKILL 9 Term 强制杀死(不可捕获)
    SIGSTOP 17/19/23 Stop 暂停(不可捕获)

    代码如下:

    #include <iostream>
    #include <cstdlib>
    using namespace std;

    int main()
    {
    cout << "程序开始运行…" << endl;
    abort(); // 异常终止
    cout << "这条不会打印" << endl;
    return 0;
    }

    运行编辑:

    补充:abort() 固定发 6 号信号,raise() 可以指定信号,alarm() 延时发 14 号信号(后面会讲),三者都是给自己发信号


    如何理解系统调用接口?

    系统调用接口是用户程序进入内核的入口:用户调用 kill() 等函数,通过软中断陷入内核,内核验证参数后在目标进程 PCB 中设置信号标记位,进程在合适的时机(从内核态返回用户态)检查并处理信号,执行默认动作、忽略或调用用户注册的处理函数。整个过程是异步的,信号不会立即处理,而是被"挂起"等待进程调度时机。


    3.由软件条件产生信号

    软件条件不是错误,是某种条件被触发时,OS 向进程发信号通知它。说白了就是:程序运行过程中遇到了某种软件层面的情况,操作系统发现了,就发个信号告诉进程"你该处理一下了"。

    举个例子:管道读端关闭了,写端还在写,OS 检测到这个情况就会给写端发 SIGPIPE(13) 信号,把写进程干掉。这就好比你把水倒进一个堵住的管子,系统检测到"没人接收"这个条件,就通知你停止。

    再比如 alarm(),你设置一个定时器说"3秒后提醒我",时间到了 OS 就给你发 SIGALRM(14) 信号。整个过程没有硬件参与,纯粹是软件逻辑触发的。

    SIGPIPE 是典型的软件条件信号。用管道验证一下:创建匿名管道,父进程读、子进程写。父进程把读端关了,子进程还在往管道里写数据。这时候 OS 检测到"管道没有读端了但有人还在写",就给子进程发 SIGPIPE,子进程就被终止了。父进程用 waitpid 拿到退出状态,提取终止信号就能看到是 13。alarm() 也是软件条件产生信号的例子,设置一个闹钟,时间到了 OS 就给你发 SIGALRM。alarm() 还有个特点:再次调用会覆盖前一个,返回剩余秒数。传 0 就是取消闹钟。

    软件条件信号常见的就这几个:

    信号编号什么情况触发
    SIGPIPE 13 管道读端关了还在写
    SIGALRM 14 alarm() 定时到了
    SIGCHLD 17 子进程退出或暂停
    SIGCONT 18 暂停的进程被恢复

    alarm

    a.接口介绍

    这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。

    alarm 函数用于给进程设置定时闹钟,作用是告知内核:等待指定秒数后,向当前进程投递 SIGALRM 信号,该信号默认行为是直接终止进程。

    举个生活化例子辅助理解:假设我设置 30 分钟后的闹钟,睡至第 20 分钟时被中途打断,打算重新设 15 分钟后响铃。此时第一次闹钟原本剩余的 10 分钟,就是本次 alarm 函数的返回值。

    函数有一条特殊规则:若传入参数 seconds 等于 0,代表清除当前已存在的闹钟,函数依旧会返回上一轮闹钟剩余的倒计时秒数。 对应程序场景举例:代码先调用 alarm (30),程序运行 20 秒后再次执行 alarm (15),这次调用的返回结果就是上一个闹钟剩下的 10 秒;如果此时执行 alarm (0),则直接取消 15 秒的新闹钟,同时返回剩余倒计时时长。

    代码如下:

    #include <iostream>
    #include <unistd.h>
    using namespace std;

    int main()
    {
    alarm(1); // 1秒后发信号终止进程

    int count = 0;
    while(true)
    {
    cout << count++ << endl;
    }
    return 0;
    }

    运行结果如下:

    为什么1秒只打印了5万多?

    因为cout是I/O操作,每次打印都要把数据从内存搬到屏幕,中间还要经过终端显示。在云服务器上,还涉及网络传输,速度就慢下来了。

    用云服务器跑,数据要经过本地–>服务器–>网络的链路,1秒能打5万行已经不错了。

    如果去掉I/O,只做纯内存累加,速度就快很多了。

    #include <iostream>
    #include <unistd.h>
    #include <signal.h>
    using namespace std;

    int count = 0;

    // 信号处理函数
    void catchSig(int signum)
    {
    cout << "final count: " << count << endl;
    exit(0);
    }

    int main()
    {
    // 1秒后发送 SIGALRM 信号
    alarm(1);

    // 注册信号处理函数
    signal(SIGALRM, catchSig);

    // 疯狂累加
    while(true)
    {
    count++;
    }

    return 0;
    }


    b.闹钟定时性功能

    每次闹钟响就打印当前累加值,然后重新设置1秒定时器,实现每秒打印一次。

    #include <iostream>
    #include <unistd.h>
    #include <signal.h>
    using namespace std;

    uint64_t count = 0;

    void catchSig(int signum)
    {
    cout << "final count: " << count << endl;
    alarm(1); // 重新设闹钟,继续统计下一秒
    }

    int main()
    {
    signal(SIGALRM, catchSig);
    alarm(1); // 1秒后触发

    while(true)
    {
    count++;
    }

    return 0;
    }

    程序每秒打印一次当前累加值,可以看到1秒大概能累加5亿次。按 Ctrl+C 终止程序,不终止的话会一直打印下去。

    为什么每次都是5亿左右?

    因为1秒内累加次数基本稳定,和机器性能有关。云服务器大概每秒5亿次,本地机器可能更高或更低

    补充:uint64_t 的作用

    用 uint64_t 而不是 int 是因为累加值会很大,每秒5亿,几秒就超过 int 的21亿上限了。uint64_t 能存到1844亿亿,跑多久都不会溢出。


    C. 补充功能

    软件条件控制逻辑

    这段代码展示的是:定时器触发 –>执行一堆回调函数 → 再重新设闹钟,形成一个循环。

    什么是软件条件控制逻辑?

    这里的"软件条件"就是定时器到期这件事。alarm(1)设了一个软件条件:1秒后触发。时间到了,OS发信号,进程响应,执行预设的动作,然后重新设置条件,形成循环。

    本质上就是:软件层面设定条件 –> OS检测条件满足 –>发信号通知 –>进程响应处理 –> 重新设定条件,如此往复。

    #include <iostream>
    #include <string>
    #include <vector>
    #include <functional>
    #include <unistd.h>
    #include <sys/types.h>
    #include <sys/wait.h>
    #include <signal.h>
    #include <stdlib.h>
    using namespace std;

    typedef function<void()> func;
    vector<func> callbacks;
    uint64_t count = 0;

    // 打印累加值
    void showCount()
    {
    cout << "final count: " << count << endl;
    }

    // 打印日志
    void showLog()
    {
    cout << "这个是日志功能" << endl;
    }

    // 执行 who 命令查看登录用户
    void logUser()
    {
    pid_t pid = fork();
    if(pid == 0)
    {
    execl("/usr/bin/who", "who", nullptr);
    exit(1);
    }
    wait(nullptr);
    }

    // 信号处理函数
    void catchSig(int signum)
    {
    for(auto &f : callbacks)
    {
    f();
    }
    alarm(1); // 重新设闹钟
    }

    int main()
    {
    // 注册信号处理函数
    signal(SIGALRM, catchSig);
    alarm(1); // 1秒后触发

    // 添加回调函数
    callbacks.push_back(showCount);
    callbacks.push_back(showLog);
    callbacks.push_back(logUser);

    // 疯狂累加
    while(true)
    {
    count++;
    }

    return 0;
    }


    如何理解软件条件给进程发送信号?

    问:什么是软件条件给进程发送信号?

    答:程序运行过程中,OS 检测到某种软件层面的条件成立了(或不再满足),就主动给相关进程发个信号通知它。这个条件不是硬件错误,是纯软件逻辑层面的。

    问:能举个例子吗?

    答:管道读端关闭了还在写。OS 检测到"管道无读端但有写端在写"这个软件条件,就给写端进程发 SIGPIPE(13) 信号,默认把写进程干掉。

    问:检测到条件后具体怎么发信号的?

    答:两步走。第一步,OS 先识别到某种软件条件触发了(比如定时器到期、管道破裂);第二步,OS 构建信号(确定发给谁、发哪个信号),然后发送给目标进程。

    问:软件条件信号和硬件异常信号有啥区别?

    答:软件条件信号是 OS 检测到的软件状态触发的,比如 SIGPIPE、SIGALRM,本质是逻辑层面的条件满足;硬件异常信号是 CPU 检测到的硬件错误触发的,比如 SIGSEGV(野指针)、SIGFPE(除零),本质是指令执行出错。

    问:常见的软件条件信号有哪些?

    答:SIGPIPE(13) 往没有读端的管道写数据、SIGALRM(14) alarm() 定时器超时、SIGCHLD(17) 子进程退出或暂停。

    总结一下就是:

    答:条件触发 –> OS 识别 –> 构建信号 –> 发送给目标进程 –> 进程执行对应处理动作


    如何简单快速理解系统闹钟

    系统闹钟,其实本质是OS必须⾃⾝具有定时功能,并能让⽤⼾设置这种定时功能,才可能实现闹钟这样的技术。现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。内核中的定时器数据结构是:

    struct timer_list {
    struct list_head entry; // 链表节点,用于挂载到定时器链表中
    unsigned long expires; // 超时时间(jiffies 节拍数)
    void (*function)(unsigned long); // 超时后执行的回调函数
    unsigned long data; // 传给回调函数的参数
    struct tvec_t_base_s *base; // 定时器所属的基数(管理定时器的内核数据结构)
    };

    各字段说明

    字段说明
    entry 内核链表节点,把定时器串到定时器链表里统一管理
    expires 什么时候到期,单位是 jiffies(内核时钟节拍)
    function 定时时间到了要执行的函数指针
    data 传给 function 的参数(比如传个指针或数值)
    base 指向管理这个定时器的基结构,用来控制定时器的生命周期

    我们不在这部分进⾏深究,为了理解它,我们可以看到:定时器超时时间expires和处理⽅法 function。操作系统管理定时器,采⽤的是时间轮的做法,但是我们为了简单理解,可以把它在组织成为"堆结构"

    简单总结:timer_list 就是内核的闹钟结构体,本质和用户态 alarm() 加 signal() 是一回事,只不过在 内核层实现。用户程序设置闹钟,最后就是内核里创建和调度 timer_list 实例来完成定时。


    4.硬件异常产生信号

    硬件异常,说白了就是硬件发现了不对劲,然后告诉 OS,OS 再给进程发信号。

    先说除0:CPU内部有个状态寄存器,每次算完都会记一下结果。除0这事儿在CPU看来就是溢出了,状态寄存器里有个溢出标志位会标记这次计算有问题,这时候CPU就会通知OS。

    再说野指针和越界:程序里用的地址都是虚拟地址,需要MMU(内存管理单元)这个硬件帮忙转成物理地址。转的时候MMU会检查权限,比如你访问的地址根本不存在,或者没有写权限,MMU就报错通知OS。OS一看,这是哪个进程的地址?就是哪个进程干的。

    OS怎么知道是谁干的? 

    如果是除0,当前哪个进程在跑就是谁干的。如果是野指针,当前用的是哪个进程的页表就是谁干的。所以OS能精准找到肇事进程,然后发信号过去。

    硬件异常发生后,进程一定会退出吗?

     默认会退出,但也不一定。比如注册了信号处理函数就能捕获,程序可以继续跑。不过一般不建议这么干,因为出错的进程状态已经不正常了,继续跑可能出更多问题。

    一句话总结:硬件异常被硬件检测到并通知内核,内核向当前进程发送适当的信号,默认动作是终止进程。

    代码如下:

    #include <iostream>
    #include <signal.h>
    #include <unistd.h>
    using namespace std;

    void handler(int signum)
    {
    sleep(1);
    cout << "获得了一个信号:" << signum << endl;
    }

    int main()
    {
    signal(SIGFPE, handler); // 捕获除0异常

    int a = 100;
    a /= 0; // 除0,触发 SIGFPE

    while(true)
    sleep(1);

    return 0;
    }


    为什么会出现死循环?

    因为硬件异常根本没有被解决。

    a /= 0 触发除零异常后,CPU 的状态寄存器里一直记录着"溢出"这个错误状态。handler 执行完返回后,CPU 再次执行 a /= 0 这行代码,又触发同样的异常,再次收到 SIGFPE,再次执行 handler…… 无限循环。

    错误代码

    void handler(int signum)
    {
    sleep(1);
    cout << "获得了一个信号: " << signum << endl;
    // 处理完返回,异常还在,死循环
    }

    int main()
    {
    signal(SIGFPE, handler);
    int a = 100;
    a /= 0; // 每次都在这行触发异常
    while(true) sleep(1);
    }

    修正方法

    void handler(int signum)
    {
    sleep(1);
    cout << "获得了一个信号: " << signum << endl;
    exit(1); // 直接退出,不返回触发异常的代码
    }

    int main()
    {
    signal(SIGFPE, handler);
    int a = 100;
    a /= 0;

    while(true) sleep(1);
    }

    小结一下:

    捕获信号后返回异常点,异常条件没变,再次触发同一硬件异常,再次收到信号,如此反复形成死循环。只有退出进程或在 handler 里修复异常原因,才能打破这个循环。

    5.问题总结

    信号相关核心问题总结

    1. 所有信号最终都要由 OS 执行?

    因为 OS 是进程的管理者,只有 OS 知道所有进程的状态,也只有 OS 有权限修改进程 PCB 中的信息。

    2. 信号是否立即处理?

    不是,信号是在合适的时机处理的。进程可能正在执行更重要的操作(比如内核态系统调用),不会因为收到信号就立刻打断当前工作,而是等回到用户态时再统一处理待处理的信号。

    3. 信号需要记录吗?记录在哪?

    需要。信号是异步事件,可能在任何时刻发生,进程不可能立刻处理。信号需要暂时记录在进程的 PCB 中,具体是用位图(pending 表)来存储,哪个信号来了就把对应比特位设为 1。

    4. 进程如何知道信号该做什么?

    程序员在代码里已经通过 signal() 或 sigaction() 注册好了信号处理函数,处理方式记录在 PCB 的 handler 表里。进程收到信号时查表就知道是该默认终止、忽略还是调用自定义函数。

    5. OS 向进程发送信号的本质?

    OS 找到目标进程的 PCB,把 pending 位图中对应信号编号的比特位从 0 改成 1,这就是发送信号的本质。进程在合适的时机检查 pending 表,发现有信号待处理,再根据 handler 表执行对应的动作。

    小结一下:OS 负责在目标进程 PCB 中设置 pending 位图标记信号,进程在合适的时机检查并执行对应的处理动作。

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux】二十一.《Linux 信号机制详解:概念、产生方式与核心问题》
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址