Linux 进程信号(一)信号的产生,signal,kill,raise,abort,alarm,core dump功能
目录
需要知道的是,本篇我们要学习的信号和进程间通信的信号量没有任何关系,信号和信号量完全是两套不同的东西,底层用途、原理、代码都不搭边。
一、信号
引入信号概念
在学习信号之前,我们先可以通过生活中信号的例子来帮助我们进行类比理解,其实信号在我们的生活中就有很多场景,我们可以举例出来很多,例如:红绿灯,闹钟,发令枪,古代的狼烟,外卖电话,快递取件码的信息,信号枪等等。但是为什么我们会认识这些信号?因为有人教我们或者伴随着我们平时的日常学习,使我们记住了这些常见的信号。
如何理解和认识信号? 认识即首先我们要认识/辨别信号,其次要知道信号的处理方法。

信号分为三个流程,首先是信号的产生,信号的保存和信号的处理,这三个流程是随着时间而进行,这里我们举一个生活中的例子帮助我们理解 :
1. 生活中的信号
假设你在网上买了很多商品,等待不同快递的到来。即便快递还没到,你也清楚:一旦快递通知来了,要该怎么处理这件事—— 这就是你对快递信号的识别与预设处理方式。
信号产生 : 快递员到了楼下,给你发了取件通知(电话 / 短信),这就是快递信号的产生。这个通知是异步的,你无法预知它会在什么时候到来。异步的意思就是多个事情同时发生并且彼此之间互相不影响。
信号保存 : 有假设此时你正忙着打游戏,需要 5 分钟后才能去取件。这 5 分钟里,你没有立刻去取快递,但你记住了有快递要取—— 这个"记住"的过程,就是信号的保存。它让你不会因为暂时忙碌而丢失这个通知。
信号处理 : 等你打完游戏,时间合适了,就去处理快递。但是处理方式通常有三种:
- 默认动作:开心地拆开快递,使用商品;
- 忽略动作:拿到快递后扔到床头,继续开一把游戏。
- 自定义动作:如果是零食,你会送给女朋友;
2. 过渡到计算机世界:进程的信号机制
现在我们把生活里的你替换成计算机里的进程,把快递通知替换成信号,我们就能得到进程信号的完整逻辑:
1. 进程如何认识信号?
进程能识别信号,是内核程序员写好的内置特性—— 就像你从小被教会认识红绿灯、闹钟一样,进程从诞生起就知道不同编号信号的含义。更重要的是:即便信号还没产生,进程也提前准备好了处理方法。就像你在快递没来之前,就已经想好了取件后的三种处理方式。
2. 信号的完整流程:产生 → 保存 → 处理
信号产生:内核或其他进程给目标进程发送一个信号,这就是信号的产生。它是异步的,进程无法预知信号何时到来。
信号保存:进程可能正在执行优先级更高的任务,不会立刻处理信号。内核会帮进程把这个信号记下来(保存在进程的信号位图或信号队列中),直到进程准备好处理它 —— 这就是信号的保存阶段。
信号处理(也叫信号捕捉) :当进程进入合适时机(比如从内核态返回用户态时),会检查是否有未处理的信号,并执行预设的三种信号处理方式:
- 默认处理:执行内核定义的默认行为;
- 忽略信号:直接丢弃这个信号,当作没发生;
- 自定义处理:执行程序员通过 signal 注册的回调函数,处理完后回到原来的执行流程。
3. 核心结论总结
- 信号是进程间的异步通知,就像生活里的快递电话,随时可能到来。
- 进程对信号的识别是内核内置的,处理方法在信号产生前就已准备好。
- 信号不会被立即处理,而是先被保存,在合适时机由进程执行「默认 / 忽略 / 自定义」三种处理方式。
二、信号的产生
下面我们来学习信号的产生,在学习信号的产生之前,我们可以先看一下进程中的信号是什么样子的 :

![]()

信号其实是宏,比如说 2 号信号 SIGINT ,我们可以用数字 2 来表示,也可以用 SIGINT 信号名字来表示这个信号。同时还要知道的是没有 0 号信号。
下面我们再来写一段代码看一下信号:


运行现象 :


当我们开始运行时会每隔1秒打印内容和这个进程的 PID,然后接着我们在复制SSH渠道重新启动一个中断,执行 kill -2 3361566 这个信号命令后,原先的进程就会被这个信号给终止掉, 所以我们也就能推断出这个 2 号信号 SIGINT 的功能是终止进程。
其实在Linux中,大部分信号的默认行为都是终止进程。Linux 中普通信号共 31 个(1~31 号),其中默认终止进程的信号约 20+ 个,占大多数,还有少数的默认忽略信号,以及剩下的默认暂停/继续信号。
下面进入重点,我们知道信号的处理方式有三种 : 默认处理,默认忽略,以及由我们自定义进行处理,下面我们就通过自定义的方式来处理一下,在自定义处理信号之前,我们要先学习一个函数,这个函数叫 signal,这个函数就是自定义处理信号的核心函数 :
signal 函数
signal 函数的作用是自定义信号的处理方式,通俗点来说就是给一个已有的信号,绑定你自己写的处理函数,从而当这个信号发生时,不让它执行默认行为(比如终止进程),而是去执行你写的回调函数的功能。我们也把这种方式叫做信号自定义捕捉。
自定义捕捉 = 用 signal 把信号的默认行为替换成你写的回调函数,让信号按你的逻辑来处理。

第一个参数 signum 即要处理的目标信号编号(如SIGINT=2、SIGQUIT=3)。
第二个参数 typedef void (*sighandler_t)(int) 是一个函数指针类型的别名,定义了信号处理函数的统一格式,返回值是 void,参数 是 int (表示收到的信号编号),我们写的 Handler 函数只要符合这个类型时,就可以作为回调函数回调传给 signal 函数,从而执行我们写的这个回调函数的功能。
回调函数就是我们写好的一个函数,但我们自己从不主动调用,而是把它的地址交给别人(系统 / 库函数 / 第三方代码),由对方在合适时机 “回头调用”,这个函数就是回调函数。本质上就是我们只定义处理逻辑,触发时机由对方控制。执行完回调后,程序会回到原来的执行流程继续运行。

返回值成功时返回之前绑定的信号处理函数指针,失败则返回 SIG_ERR。
下面我们就将这个函数应用到代码中 :

需要注意的是当我们自定义捕捉信号时,signal 函数只需执行一次即可,后面会一直生效。
运行:

通过 signal 注册自定义 handler 后,SIGINT(2 号)和 SIGQUIT(3 号)不再执行默认的终止进程行为,转而执行我们定义的打印逻辑。这也就是为什么我们在右侧分别发送2号和3号信号,进程不但没有终止,反而继续执行打印 handler 函数的原因,因为此时2号3号信号已经被我们自定义了。
内核在调用 handler 时,会自动将触发的信号编号传入 signo 参数,让同一个处理函数可以区分不同信号。信号到来时会打断主循环执行 handler,执行完毕后进程回到原循环继续运行,不会终止,体现了信号的异步通知特性。并且 signal 只需在进程启动时注册一次,后续所有同类型信号都会触发该处理逻辑,直至进程结束。

此时我们在键盘上按下 Ctrl + C 键,回惊奇的发现Ctrl+C同样也会出发 2 号信号并执行和上面自定义2号信号一样的功能。
此时我们就可以得出第一个结论 : Ctrl+C 的功能就是终止进程,相当于通过键盘给目标进程发送2号信号,处理动作就是终止掉该进程。

如果我们要终止掉这个进程,可以再使用 kill -9,9号信号彻底杀死这个进程。
下面为了解决进程在我们执行自定义捕捉信号后还在运行的现象,我们可以在 handler 函数中加上exit(10) 使其退出这个进程:

运行结果:

此时我们使用2号进程或者Ctrl+C时,就会执行 handler 函数,并且之后就会退出进程,退出码是10。
补充细节 :
这里我们再补充几个细节:
细节1 :
那如果我们自定义捕捉所有的普通信号呢 ?
答案是我们可以自定义捕捉绝大部分普通信号,理论上我们写个循环,把所有信号都绑上 handler,几乎所有信号都杀不死这个进程。但是有 2 个信号永远不能捕捉、不能忽略!
一个是 9 号信号 SIGKILL (直接杀死进程),另一个是 19 号信号 SIGSTOP (直接暂停进程),这两个信号不能自定义捕捉,无论怎么写 signal,这两个永远管不住!所以这个信号也被成为管理员信号,这是为了保护系统的安全。如果一个进程能捕捉所有信号,那它就可以变成杀不死的病毒。所以 Linux 强制保留 2 个终极信号。
细节2 :
在进行信号捕捉的过程中,是进程本身在进行信号捕捉吗?
答案是是的,这个过程是进程本身执行内核跳转过来的自定义 handler 函数。但是信号捕捉的发起和调度仍然是内核自己完成的。
细节3 :
信号处理的前两个方式 : 1.默认处理 2.忽略处理
信号处理的前两个方式和下面的两个宏有关,和信号无关,为什么?
首先要分清核心边界:SIG_DFL、SIG_IGN 不是信号,是<signal.h>里定义的两个特殊宏,用来标记两种基础处理规则,再加上自定义函数,就构成信号的三大处理方式;对于一个信号来说(比如 2 号信号 SIGINT),默认情况下进程没做任何手动配置时,就遵循 SIG_DFL 这个宏对应的规则,执行系统预设动作,也就是按下 Ctrl+C 触发 2 号信号后直接终止进程,这就是平时按 Ctrl+C 程序退出的根本原因;如果手动把 2 号信号的处理设置成 SIG_IGN 这个宏,就代表告诉内核直接忽略这个 2 号信号的所有作用,哪怕按下 Ctrl+C、内核投递了 SIGINT 信号,也不会执行终止进程的操作,信号会被直接丢弃,程序完全不受影响、继续正常运行;最后第三种方式是不使用这两个宏,而是填入自己写的自定义处理函数,让内核触发 2 号信号时切换回用户态执行我们编写的专属逻辑。
举个例子比如上面这个 signal 函数,就是让进程忽略 2 号信号和 Ctrl+C的,当进程收到 2 号信号 SIGINT 时,直接当作没发生,既不终止也不执行任何回调。但是这对 9 号和 19 信号仍然不起作用,因为上面说过这两个信号是管理员信号,也被叫做不可忽略信号。
SIG_DFL 和 SIG_IGN 都是系统统一的处理方式标识,都能在 signal 和 sigaction 里用,但为什么按 Ctrl+C 默认终止进程时用的是 SIG_DFL,而不是 SIG_IGN?
原因其实非常简单:SIG_DFL 代表 “系统预设的默认动作”,而 SIG_IGN 代表 “明确告诉系统忽略这个信号”,这两个宏分别对应两种完全不同的用户意图,所以操作系统在判断 “默认情况下应该怎么做” 时,自然选择 SIG_DFL,而不是 SIG_IGN。 具体来说,当进程没有通过任何 signal 或 sigaction 注册过处理方式时,内核内部为每个信号预先设定的默认处理动作就是 SIG_DFL,比如 SIGINT 的默认动作就是终止进程,这是系统级的兜底规则,无论进程有没有写代码,内核都按这个来;而 SIG_IGN 是你显式告诉内核 “我要忽略这个信号”,只有在你手动调用 signal (SIGINT, SIG_IGN) 时,内核才会用这个规则,否则它不会自动触发。换句话说,SIG_DFL 是 “系统自带的默认方案”,而 SIG_IGN 是 “你手动选择的忽略方案”,前者是进程未配置时的自然结果,后者是进程主动配置后的行为,所以 Ctrl+C 触发 SIGINT 时,默认必然走 SIG_DFL,而不是 SIG_IGN,这完全符合两种宏的定义和操作系统的统一逻辑。
所以到目前为止我们知道信号的产生方式有两种 :
1. 用 kill -N 命令产生信号
2. 通过键盘发送信号
- Ctrl + C 是向目标进程发送信号 SIGINT(2 号信号)默认终止当前进程,让进程立即退出。
- Ctrl + \ 是向目标进程发送信号 SIGQUIT(3 号信号)默认终止进程。
- Ctrl + Z 是向目标进程发送信号发送信号 SIGTSTP(19 号信号),默认暂停当前进程。
我们也可以使用 man 7 signal 查看各个信号更详细的信息,比如 :
- SIGINT 的作用是 Term 即终止进程
- SIGQUIT 的作用是 Core 即终止进程并生成核心转储(后面讲)
- SIGSTOP 的作用是 Stop 即暂停进程
再补充两个细节 :
细节1 : 键盘快捷键(如 Ctrl+C 、 Ctrl+\ 、 Ctrl+Z )只能控制前台进程,对后台进程无效。因为键盘输入属于终端设备的输入,只有前台进程才拥有对当前终端的控制权,能接收键盘输入;后台进程被系统剥夺了终端输入权限,所以收不到这些键盘信号。如果想给后台进程发信号,需要用 kill 命令手动指定进程 PID 发送,比如:
kill -2 <后台进程PID> #给后台进程发SIGINT
细节2 : 为什么 bash 进程自己不对信号做响应?(bash忽略了所有的信号)
我们知道 bash 就是命令行的本体进程,你在终端里的所有交互,本质上都是和这个 bash 进程在对话。那为什么按 Ctrl+C 时,bash 进程不退出呢?原因就是 Ctrl+C 发送的 SIGINT 信号,被 bash 自己 “忽略” 了,所以不会让 bash 进程终止。但是如果你真的想让 bash 进程终止,那么此时仍要用到 SIGKILL(9 号信号)这种无法被捕捉的信号。
除0和野指针引发的异常:
如果我们的程序div 0,野指针等,程序就会崩溃 --- 为什么它会崩溃?




进程因除 0 / 野指针退出,本质是因为出现错误异常被内核捕获后,发送了 SIGFPE/SIGSEGV 信号。
1. 那怎么证明是因为异常而退出?
我们可以通过捕获信号并打印捕捉到的信号,即在代码中注册信号处理函数和异常错误,当信号触发时打印信号编号,直接证明是信号导致的退出:


![]()
![]()


![]()
![]()
运行后会打印出 8 或 11,直接证明是信号导致的退出。
2. 为什么会收到异常信号,OS怎么知道收到了异常信号?
两种异常的本质 :
- 除 0(div 0) :本质是 CPU 硬件层面报错。CPU 执行 a /= 0 时,发现除数为 0,触发浮点异常硬件中断,告诉系统 “我算不下去了”。
- 野指针(*p = XX):本质是 MMU(内存管理单元)+ 页表报错。进程访问非法地址时,MMU查页表发现地址不合法,触发缺页 / 保护错误硬件中断,告诉系统 “有人乱摸内存”。
所以不论是除0错误还是野指针出错,本质上都是硬件出现了错误,硬件是"出事的人”,CPU/MMU 发现异常后,立刻向CPU发送硬件中断(相当于喊 “救命”)。而操作系统 OS 作为“软硬件资源的管理者”,它必须知道硬件出了什么错(是除 0 还是非法内存),谁搞的事?→ 当前正在 CPU 上运行的进程(就是你的程序),所以 OS 会根据硬件错误类型,向肇事进程发送对应信号:除 0 → 发送 SIGFPE(浮点异常信号),野指针 → 发送 SIGSEGV(段错误信号),进程收到信号后,默认行为是:终止自己这个进程,并且生成 core dump 文件(保存崩溃现场)。
所以程序异常不是 “自己崩的”,是硬件先报错 → OS 捕获 → OS 给进程发信号 → 进程被信号杀死,这就是你看到 Floating point exception 或 Segmentation fault 的底层原因。
3. 为什么除0和野指针会触发硬件报错?
除0错误 :

在用户代码层面上 : 当我们执行 int a = 10; a /= 0; 时编译器会将其翻译为x86除法指令(如 idiv),并把字面量 10 和 0 分别加载到CPU通用寄存器(比如 eax=10,ebx=0)。然后CPU算术逻辑单元执行,CPU执行除法指令时,会检测到除数为0的非法操作,这是硬件层面的运算异常。此时CPU会将标志寄存器(EFlags)中的溢出标志位(OF)置为 1,表示当前运算结果不可信、发生了错误。这个标志位就是检测运算是否正确的核心,如果计算正确标志位就为 0,错误则为 1,与此同时,CPU会触发一个硬件中断/异常( #DE Divide Error 异常),暂停当前指令流,将控制权交给操作系统的异常处理程序。操作系统如何识别“谁触发了异常” ? 操作系统内核会用一个专用寄存器来保存一个全局指针 current(下图),这个指针永远指向当前正在CPU上运行的进程描述符(task_struct)。

只要CPU在执行某个进程的代码,这个寄存器就不会变,内核随时能通过它找到对应的进程。当除0异常发生时,CPU会自动保存当前进程的上下文(寄存器值、指令指针等)到内核栈,然后跳转到内核的异常处理函数。内核异常处理函数通过 current 指针,立刻就能定位到是哪个进程在执行除法时触发了硬件错误。同时内核会检查EFlags的OF位,确认是运算类异常(除0)。之后内核会向该进程发送 SIGFPE (浮点异常信号,实际也包含整数除0),默认行为是终止进程并生成核心转储(core dump)。如果进程注册了 SIGFPE 的信号处理函数,内核会跳转到该函数执行,进程可以选择优雅退出或尝试恢复。如果没有处理函数,进程会被操作系统强制终止,资源被回收,CPU调度器切换到下一个可运行进程。这整个过程就是除0导致硬件报错的原因。
野指针错误 :
野指针本质是访问了非法的虚拟地址,这个错误是在硬件级地址翻译过程中被CPU发现的,和 CR3 寄存器、MMU、页表有关,进程运行在虚拟地址空间,所有内存访问都要经过MMU(内存管理单元),将虚拟地址翻译成物理地址。CR3 寄存器是CPU的页表基址寄存器,它永远指向当前进程的页全局目录(PGD),是地址翻译的起点。当野指针指向一个不存在/未映射/无权限的虚拟地址时,MMU在查页表的过程中发现该虚拟地址没有对应的页表项,或页表项标记为无效,CPU会立刻触发#PF(Page Fault,页故障)硬件异常,暂停当前指令流,将控制权交给内核的页故障处理程序。当MMU发现地址完全无效(比如访问内核地址空间、未映射的空洞地址),内核判定这是进程的非法内存访问,即野指针错误。此时内核会通过上面那个全局指针向触发异常的进程发送SIGSEGV(11号信号,段错误),默认行为是终止进程并生成core dump。
两者的共同点是:都由硬件直接检测并触发异常,内核通过异常类型识别错误,再向进程发送对应信号;如果注册了自定义信号处理函数且不主动退出,都会因为上下文恢复而陷入死循环。
4. 为什么会死循环的打印消息?


我们并没有在 handler 函数中用 exit() 函数使进程退出,我们原本以为像除0和野指针这种错误会使进程出现异常而自动退出,但是并没有,并且收到异常信号后仍死循环的打印消息,为什么呢?这是因为当进程触发除 0 硬件异常后,内核会向进程发送 8 号SIGFPE信号,若进程注册了自定义信号处理函数且未在函数中主动终止进程,那么处理函数执行完毕后,操作系统会继续调度该进程,此时内核会完整恢复触发异常前保存的进程硬件上下文(包括指令指针、寄存器值等全部状态),由于 EIP 程序计数器仍指向那条引发除 0 错误的指令,CPU 会重新执行该指令并再次触发异常,进而形成死循环打印消息;这也印证了进程上下文的核心特性 —— 它是进程运行状态的完整快照,无论因异常、中断还是时间片耗尽被切走,只要进程未终止或未主动修改上下文,重新被调度时都会恢复到之前的状态,让进程仿佛从未被打断过,正是这种上下文的完整性保留,才导致了未处理的异常会反复触发。
野指针触发的 #PF 页故障异常,和除0错误的 #DE 异常在底层处理逻辑上完全一致,当CPU执行野指针指令(如访问非法虚拟地址)时,MMU地址翻译失败,触发 #PF 硬件异常。此时CPU会自动保存当前进程的硬件上下文,其中 EIP 程序计数器仍然指向那条引发野指针的指令。内核识别到这是非法内存访问后,向进程发送 SIGSEGV (11号信号)。如果进程注册了自定义信号处理函数,内核会跳转到该函数执行,在函数里打印消息,但我们也没有调用 exit() 终止进程。信号处理函数执行完毕返回后,内核会完整恢复之前保存的硬件上下文——这意味着 EIP/RIP 依然指向那条野指针指令。操作系统继续调度该进程时,CPU会重新执行这条指令,再次触发 #PF 异常,内核再次发送 SIGSEGV 信号,信号处理函数再次打印消息,如此循环往复,就形成了持续打印消息的死循环。
core dump 核心转储
5. core dump 是啥?
![]()
![]()
观察上面两个图,为什么报错后面都有 (core dumped),它是什么 ?
core dump 也就做核心转储,是进程意外终止时,操作系统将其内存状态、寄存器值、调用栈等运行时信息完整保存到磁盘的文件,本质是进程崩溃时的 “现场快照”。我们图中的 (core dumped) 提示就表示:进程因非法操作(除 0 / 野指针)被内核终止,并且操作系统已经把崩溃现场写入了core 文件,方便开发者调试。所以核心转储的目的就是和后续 debug 的调试有关。

在绝大多数 Linux 发行版中,比如小编使用的 Ubuntu 版本中,核心转储(Core Dump)默认是关闭的。我们可以输入命令 ulimit -c。如果输出是 0,那就说明已关闭。如果是具体数字,说明已开启。并且在 Ubuntu 下即使我们在本地终端执行了 ulimit -c 开启了核心转储,如果不满足特定环境条件,极大概率还是用不了,系统依然无法生成 core 文件。
但是在Centos 下可以看到这个core dump 文件,所以我们在 Centos 下进行后续操作 :



通过上面三幅图我们可以清楚地看出在 Centos 下运行除0报错的程序就会生成 core 文件,core.后面的数字是当前进程的 pid,需要注意的是 Ubuntu 下 core 文件没有 pid,Centos 下有。并且我们每运行一次程序就会生成一份 core 文件,当我们 vim 打开 core 文件时,出现的是乱码。
那我们应通过 core dump 文件进行 debug 调试功能?
![]()

答案是我们可以在 gdb/cdgb 下,使用 core file 命令进行调试,就会自动定位错误位置,如上图所示,这种调试方式也被叫做事后调试,但是前提是要有明确的 core dump 错误。
回答一下上面的问题,为什么云服务器下一般核心转储是关闭的?
云服务器(尤其是大厂生产环境)默认关闭核心转储,本质是为了保障系统稳定性和磁盘资源安全,防止磁盘耗尽,因为大厂的生产环境通常会配置进程自动重启机制,当进程因崩溃退出后,会被立即拉起。如果开启 core dump,极端情况下每次崩溃都会生成一个 core 文件(体积往往很大)。进程反复崩溃重启会产生无数个 core 文件,快速占满磁盘分区,导致系统无法写入新数据,最终引发操作系统宕机。
那我怎么知道当前进程发生了核心转储?
其实是父进程bash知道我们子进程发生了核心转储,因为我们之前在学习进程等待时见过下面一幅图 :

这张图是 Linux 中 waitpid()/wait() 系统调用返回的进程终止状态码的 16 位格式,它和核心转储(core dump)是直接相关的,首先当正常终止时,高 8 位是进程的 exit() 退出状态,低 8 位为 0。
当被信号所杀时,高 8 位就不会被使用,只使用低 8 位,低 8 位里第 0~6 位是终止信号编号(比如 SIGSEGV=11、SIGFPE=8)。第 7 位就是 core dump 标志位(值为 1 表示进程终止时生成了 core dump 文件)。
下面我们可以通过代码来证明是否发生了核心转储 :

- (status >> 8) & 0xFF:提取高 8 位,即退出状态码(因子进程被信号杀死,此值无实际意义,输出为 0)。
- status & 0x7F:提取低 7 位,即终止信号编号(此处为 8,对应 SIGFPE)。
- (status >> 7) & 0x1:提取第 7 位,即core dump 标志位(输出为 1,说明发生了核心转储)。 即 bash 通过waitpid等待才发现了当前我们的子进程发生了核心转储。
重谈 term 和 core :
![]()
![]()
所以 :
1. Term 只表示是单纯终止,内核收到这类信号后,会直接强势终止进程,不会生成 core dump 文件。在进程终止状态码中,core dump 标志位(第 7 位)为 0,父进程通过 waitpid() 解析时,WCOREDUMP(status) 会返回假(0)。这是 “干净” 的进程终止,只结束运行,不保留崩溃现场。
2. Core 则是核心转储终止,内核收到这类信号后,会先生成 core dump 文件(保存进程内存、寄存器等运行时快照),再终止进程。在进程终止状态码中,core dump 标志位(第 7 位)为 1,父进程通过 waitpid() 解析时,WCOREDUMP(status) 会返回真 (1)。这是 “带现场” 的进程终止,既结束运行,又留下调试用的核心转储文件。
信号的保存
进程是如何保存信号的?信号存在哪里?OS用什么数据结构来管理信号?
信号保存在进程的task_struct结构体中。使用信号位图(bitmask,本质是unsigned int类型的sigs成员)来保存和管理[1,31]号普通信号:
- 比特位的位置对应信号编号(比如第8位对应 SIGFPE ,第11位对应 SIGSEGV );
- 比特位的值(1/0)表示进程是否收到了该信号(1=已收到,0=未收到)。
谁来修改位图? 由操作系统内核来修改这个位图——当内核要给进程发送信号时,就会修改task_struct里的信号位图,把对应信号的比特位置为1,这就是“发送信号”的本质:修改进程的信号位图。
综上,从本质上来说,所有的信号发送操作,最终都必须由操作系统内核来完成对目标进程信号位图的修改。不管是哪种发送信号的方式:1. 用户态调用 kill() 系统调用,2. 键盘输入 Ctrl+C 产生 SIGINT,3. 子进程退出让父进程收到 SIGCHLD,4. 定时器超时产生 SIGALRM,5. 程序出现除零/野指针触发硬件异常。这些操作都只是向操作系统内核“提交”发送信号的请求,用户态进程没有权限直接修改另一个进程的 task_struct 结构体(这是内核态的核心数据结构,用户态无法直接访问)。内核收到请求后,会1. 验证发送者的权限(比如是否有权限向目标进程发信号),2. 找到目标进程的 task_struct, 3. 修改其内部的信号位图(将对应信号的比特位置为1),4. 完成真正的“写信号”动作。
所以最后结论是:我们可以使用 kill 命令等多种方式向进程发送信号,但是所有信号的最终写入操作,都只能由操作系统内核完成,用户态进程无法直接向目标进程写信号。
所以操作系统会提供专门的系统调用,让用户态程序通过内核来向目标进程写信号。
三、通过系统调用向进程来写信号
下面我们就来学习通过系统调用向进程来写信号 :
kill
kill不仅有命令行的指令,而且还有对应的系统调用kill。
kill 的调用很简单,第一个参数 pid 就是你想要发送的进程的pid,如果发送给自身调用的 pid,那么可以使用 getpid 获取自身的 pid,第二个则是你想要发送的信号 sig。

成功时返回 0(至少发送了一个信号),失败时返回 -1 并设置 errno。
下面我们可以通过底层封装 kil l系统调用的方式模拟命令行的 kill 指令,实现一个自己的kill指令 :

mykill.cc 是信号发送端,接收命令行参数 信号编号 和 目标进程PID,调用 kill() 接口发送信号。首先检查参数个数,若不为 3 则提示用法。将字符串参数转换为整数:signumber(信号号)、pid(目标进程 ID)。调用 kill(pid, signumber) 底层触发系统调用,向目标进程发送信号。其实底层还是kill系统调用,说明用户态函数本质是封装内核系统调用。
loop.cc 是目标进程,循环打印自身 PID 并每秒休眠,作为被信号终止的测试进程。运行后会持续输出 我是一个进程: <PID>,直到收到终止信号。

从终端操作可以看到直接运行 ./mykill 时参数不足,触发用法提示。正确格式 ./mykill 2 3624982:
2 对应 SIGINT(键盘中断信号,Term 类型,单纯终止进程)。3624982 是 loop_process 的 PID。发送后,loop_process 进程被终止,停止打印输出。这验证了 kill() 接口能通过信号编号精准控制目标进程的行为。
raise

raide 的功能是向调用者自身发送信号。相当于自己给自己发信号,只需要传入信号编号 sig,无需指定 PID。raise(sig) 等价于 kill(getpid(), sig),本质是自己给自己发信号的语法糖。
证明自己给自己发信号 :

运行结果 :

进程先打印 进程正在运行: 36466294,休眠 2 秒后调用 raise(2) 向自己发送 2 号信号(SIGINT)。
信号触发自定义 handler 函数,打印 进程捕捉到信号: 2 pid: 36466294。处理函数返回后,循环继续执行,因此输出交替出现 “运行” 和 “捕捉信号” 的日志。从而 验证了 raise(2) 确实实现了进程自我发送信号,等价于 kill(getpid(), 2)。自定义 handler 成功捕获了 SIGINT,覆盖了默认的 “终止进程” 行为,所以进程不会退出,而是持续循环打印。
abort

abort 的作用就是引发进程异常终止,它没有参数,包含头文件 <stdlib.h>。本质行为是向调用进程发送 6 号信号 SIGABRT,等价于 kill(getpid(), SIGABRT)。
![]()
![]()
SIGABRT 是 6 号信号,属于 Core 类型,终止进程时会生成核心转储文件。
abort() 其实类似于 exit(),但是在大多数项目里我们更倾向于使用 abort,因为 abort 带核心转储,方便事后调试
证明 :

注册信号处理函数 handler 捕捉 6 号信号(SIGABRT)。进程循环打印运行状态,休眠 2 秒后调用 abort()。abort() 本质是向自身发送 SIGABRT,触发 handler 执行。

运行结果 : 进程启动,打印 进程正在运行: <pid>。休眠 2 秒后,abort() 发送 SIGABRT。handler 被调用,打印 进程捕捉到信号: 6 pid: <pid>。handler 返回后,abort() 内部会恢复 SIGABRT 的默认行为,再次发送信号,进程最终终止。
特殊点 : 和普通 signal 捕捉不同,SIGABRT 有一个关键特性:即使你注册了自定义 handler 去捕捉它,abort() 最终还是会让进程终止,不会像 SIGINT 那样只通过 handler 继续运行。原因是 abort() 内部会先重置 SIGABRT 的处理行为为默认,再重新发送一次信号,确保进程一定会终止并生成 core dump,保证崩溃现场被保留。
五、软件条件产生信号

目前为止,我们已经知道了 Linux 中信号的 4 种核心来源,分别对应不同的触发主体:
1. 键盘(IO 硬件)
- 典型信号:SIGINT(Ctrl+C)、SIGTSTP(Ctrl+Z)、SIGQUIT(Ctrl+\)。
- 触发:用户通过键盘输入特殊组合键,终端驱动向前台进程组发送信号,属于硬件 IO 层面的信号源。
2. 异常(硬件 CPU/MMU)
- 典型信号:SIGSEGV(段错误 / 野指针)、SIGFPE(除 0 错误)、SIGBUS(总线错误)。
- 触发:CPU/MMU 在执行指令时检测到硬件级异常(如非法内存访问、非法运算),内核将异常转化为对应信号发送给进程,属于硬件异常层面的信号源。
3. 系统命令(用户)
- 典型信号:通过 kill 命令发送的任意信号(如 kill -9 PID)。
- 触发:用户在 shell 中执行 kill 等命令,底层调用 kill() 接口向目标进程发送信号,属于用户主动操作层面的信号源。
4. 系统调用(开发者)
- 典型信号:kill()、raise()、abort() 等函数发送的信号。
- 触发:开发者在代码中调用系统调用 / 库函数,主动向进程(自身或其他进程)发送信号,属于程序开发层面的信号源。
理解软件
可是异常只能由硬件产生吗?难道软件不可以产生异常吗?其实软件也是可以的产生异常的,例如下面的场景。
场景1. 我们使用 read 系统调用接口打开4号文件描述符,我们知道当进程启动的时候,默认会打开3个文件描述符,即0,1,2,此时我们没有主动打开4号文件描述符,那么 read 还向4号文件描述符中进行读取数据,毫无疑问是会读取失败的,那么我们打印一下 read 的返回值n,并且如果 n 打开文件描述符失败,则会返回-1。


此时毫无疑问,read 打开4号文件描述符失败,这属于软件层面上引起的异常,因为此时 read 想要从4号文件描述符中读取数据,但是此时4号文件描述符并没有打开,所以在软件层面上资源未就绪就去访问,所以这属于软件层面的异常。
场景2. 软件层面发送信号还有管道的情况,例如管道写端正常,但是管道读端退出读了几次之后,管道读端退出的情况,那么此时写端继续去写就没有了意义,因为管道中已经没有读端继续去读取数据了,都没有人读取你写端的数据了,那么你写端还写什么写,我操作系统还要费心费力的帮你做数据拷贝,调度,你进程的运行还要占用内存空间,我操作系统不会做任何一件浪费时间和空间的事情,那么写端就会被操作系统通过发送13号信号SIGPIPE关闭。
所以软件只是单纯的和软件有关,和其他的都没有关系
下面我们介绍一个与软件有关的一个系统调用:
alarm
alarm() 本质是软件层面利用内核定时器实现异步事件驱动的机制的一个系统调用
alarm 的作用是为调用进程设置一个闹钟时钟,到期后自动发送14号信号。参数 seconds 是倒计时秒数。
![]()
![]()
本质是在 seconds 秒后,内核向调用进程发送 14 号信号 SIGALRM。14 号 SIGALRM 信号属于 Term 类型,默认行为是终止进程(不生成 core dump)。
关键特性与返回值 :
- 单次触发:alarm 是一次性闹钟,触发后不会自动重复,需要重新调用才能再次计时。
- 覆盖旧闹钟:如果调用 alarm 时已经存在未触发的闹钟,新的倒计时会覆盖旧的,并返回旧闹钟剩余的秒数。
- 取消闹钟:传入 seconds = 0 可以取消之前设置的闹钟,返回旧闹钟剩余的秒数。
现象 :


我们首先调用 alarm(1) 设置 1 秒后发送 14 号 SIGALRM 信号。进入死循环,持续打印进程 PID 并递增计数器 cnt。我们可以观察到进程先快速循环打印了365万次的 “进程正在运行”,1 秒后收到 SIGALRM 信号。因为没有注册自定义信号处理函数,SIGALRM 触发默认行为:终止进程,并输出 Alarm clock 提示。最终进程被杀死,循环停止。
怎么证明这个闹钟起了作用?
我们继续通过捕捉这个闹钟来证明:

运行后,进程会持续打印 “进程正在运行: X”,1 秒后触发 SIGALRM,执行 handler 打印信号信息并以状态码 4 退出。

为什么只打印了19811次? 其实是比我们预想的少
改一下代码:

运行结果 :

4亿次!!! 所以打印次数从 1 万次 → 4 亿次,暴涨 4 万倍!为什么
答案是在冯・诺依曼体系结构中,CPU 与 I/O 硬件设备存在天然的速度鸿沟:CPU 运算速度达纳秒级,而 I/O 设备(如终端打印)速度慢至毫秒 / 微秒级,两者相差数十万到上亿倍。带 std::cout 打印时,循环中 99% 的时间都消耗在字符串格式化、系统调用、内核态切换等低效 I/O 操作上,CPU 长期处于等待状态,1 秒内仅能执行约 1 万次;去掉 I/O 后,cnt++ 被优化为纯寄存器级运算,完全规避了 I/O 等待与内核切换开销,CPU 得以全速释放算力,1 秒内可完成近 4 亿次循环。这一性能暴涨的本质,正是冯・诺依曼体系下 I/O 是性能瓶颈、CPU 是核心算力 的直观体现,也印证了 “CPU 极快、I/O 极慢” 这一计算机底层铁律。
拓展 : 我们可以根据闹钟实现一个周期性定时器 :


从输出可以看到,程序形成了稳定的周期性行为:每 2 秒触发一次 SIGALRM(14 号信号),打印 进程捕捉到信号: 14 pid: xxx cnt: 1。信号处理函数中调用 alarm(2),重新设置下一次闹钟,实现循环定时。主循环里 sleep(1) 配合打印,使得每 2 秒信号周期内,会输出 2 次 进程正在运行: 1。每次在 handler 里调用 alarm(2),都会覆盖之前的闹钟,并重新开始 2 秒倒计时。这让原本一次性的 alarm(),变成了周期性定时器,实现了 “每 2 秒触发一次信号” 的循环效果。

返回值 : 如果调用 alarm 时已经存在未触发的闹钟,新的倒计时会覆盖旧的,并返回旧闹钟剩余的秒数。
还要注意的是所以千万不要在循环里设置闹钟,比如while循环里,如果做了,那每次循环,都会重新设置一个 n 秒闹钟,后面设置的闹钟,会自动覆盖前面的闹钟闹钟永远在被 “刷新”,永远不会真正触发,结果导致信号永远不会来!所以信号永远触发不了。
闹钟的管理

当我们理解了闹钟的使用以及返回值,那么我们思考一下,我们这一个进程可以设置闹钟,那么其它进程也可以设定闹钟,操作系统中有很多的进程,所以操作系统中必然会存在很多的闹钟,所以操作系统就要把闹钟管理起来,如何管理?
先描述,再组织:
描述闹钟:用结构体(如 struct timer)封装闹钟的核心信息:
- expired:闹钟到期的时间戳(当前时间戳 + 设定秒数)
- 所属进程的 PID、task_struct 地址(关联到目标进程)
- 触发次数 cnt、回调函数 callback(如发送 SIGALRM 的逻辑)
组织闹钟:用 最小堆(小根堆) 数据结构来组织所有闹钟:按 expired 时间戳排序,堆顶永远是最早到期的闹钟。如果堆顶的最小闹钟都没到期,那后面的闹钟肯定也没到期,所以后面所的闹钟都未到期,我们也就无需处理了。操作系统只需定期检查堆顶闹钟的时间戳,无需遍历所有闹钟:
- 若当前时间 < 堆顶 expired:所有闹钟都未到期,无需处理。
- 若当前时间 ≥ 堆顶 expired:弹出堆顶,执行回调(如发信号),再调整堆结构。
为什么选择最小堆?
- 高效获取最早到期闹钟:堆的 O(1) 时间复杂度就能拿到堆顶(最早到期的闹钟)。
- 插入 / 删除效率高:新闹钟插入或旧闹钟删除时,堆调整的时间复杂度是 O(log n),远优于遍历链表 / 数组的 O(n)。
- 避免无效遍历:不用逐个检查所有闹钟的时间戳,只需要盯着堆顶,大幅提升内核效率。
操作系统管理闹钟的本质,是用结构化数据(struct timer)描述定时事件,用最小堆高效组织这些事件,通过 “只检查最早到期的堆顶” 来最小化时间开销,最终实现对大量进程闹钟的高效、低延迟管理。
其实真实 OS 内核里,定时器管理方式并不是简单的 “小堆” 早期 Linux 确实用过最小堆,但现代主流内核(Linux ≥ 2.6)都采用了更高效的时间轮(Timer Wheel) 结构,而不是单纯的小根堆。小根堆的插入/删除开销虽然是 O(log n),但是在高并发、大量定时器场景下,log n 依然会成为性能瓶颈。而现代 Linux 用的是时间轮(Timer Wheel),这里我们不过多讲解,总之讲述小根堆这种数据结构让我们又重新认识了一种新的组织方式,对我们以后的学习会很有帮助。
五、五种信号产生方式总结
截至目前为止,信号产生的五种方法我们都已经学习了
- 键盘硬件触发:用户通过终端快捷键(如 Ctrl+C 发送 SIGINT、Ctrl+Z 发送 SIGTSTP),由终端驱动向前台进程组发送信号,属于**硬件IO层**的信号来源。
- 硬件异常触发:CPU/MMU 检测到非法指令或内存访问(如除0错误触发 SIGFPE、野指针访问触发 SIGSEGV),内核将硬件异常转化为信号发送给进程,属于硬件异常层的信号来源。
- 系统命令(用户操作):用户执行 kill 等命令,底层调用 kill() 系统调用向目标进程发送信号,属于用户交互层的信号来源。
- 系统调用(开发者编程):开发者在代码中调用 kill()、raise()、alarm()、abort() 等接口,主动向进程(自身或其他进程)发送信号,属于**软件开发层**的信号来源。
- 软件定时器/异步事件:内核定时器(如 alarm())到期发送 SIGALRM,或子进程退出时内核向父进程发送 SIGCHLD,属于软件异步事件层的信号来源。

无论是上面哪种信号产生方式,总之信号的产生,本质都是借助操作系统(OS)内核之手,向目标进程“写入信号:无论来源是用户键盘、硬件异常、系统命令还是软件定时器,最终都由内核完成“信号投递”的动作,进程无法主动感知,只能被动接收并处理。这些信号围绕用户交互、硬件异常、软件定时/进程管理等核心场景展开,是 Linux 中进程间异步通信的基础机制,体现了冯·诺依曼体系下“CPU 与外设/事件异步协作”的设计思想。
六、总结
本文深入讲解了Linux系统中的信号机制。信号是进程间异步通信的一种方式,分为产生、保存和处理三个阶段。信号来源包括键盘输入、硬件异常、系统命令、系统调用和软件定时器等。文章详细介绍了信号的默认处理、忽略和自定义捕捉三种方式,并通过代码示例展示了signal()、kill()、raise()、abort()和alarm()等系统调用的使用。重点分析了除0和野指针等异常导致进程崩溃的底层原理,即硬件报错触发内核发送信号。同时讲解了核心转储(coredump)的作用和调试方法,以及信号在进程控制块(task_struct)中的保存方式。最后总结了五种信号产生方式,强调所有信号最终都由操作系统内核完成投递。
谢谢大家的观看!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐





所有评论(0)