【Linux第十七章】信号

发布时间:2026/5/17 11:32:47

【Linux第十七章】信号 前言 在 Linux 中signal是最基础也最容易被误解的一套异步通知机制。很多初学者第一次接触它往往只记住了CtrlC、kill -9、alarm()这些零散结论但一旦继续深入到进程控制、异常处理、core dump、信号屏蔽、sigaction、可重入函数这些内容就会发现信号真正难的地方不在于 API 名字多而在于它横跨了用户态、内核态、调度、中断、异常和进程上下文切换。从机制上看信号并不是“直接打断进程然后立刻执行一段代码”这么简单。它更像是内核替进程保存的一条“异步待办通知”先产生、再记录、在合适时机递达最后根据默认动作、忽略或自定义处理函数完成响应。你上传的笔记里已经覆盖了信号的产生、保存、阻塞、递达、捕捉、core dump、SIGCHLD、volatile等关键内容这篇文章会把这些知识点串成一条完整主线并把其中不够严谨的地方修正成更标准的 Linux 语义。一. 什么是信号先把概念立住 信号是内核向进程发送的一种异步通知机制。它的目标不是传输大量数据而是告诉目标进程“发生了一件值得处理的事”。这件事可能来自键盘输入、系统调用、硬件异常、定时器到期或者其他软件条件。你笔记中的“信号会在合适的时候处理”“信号的产生是异步的”本质上都在描述这一点。很多资料会把信号说成“软件模拟中断”这种说法有助于建立直觉但不能完全等同。更准确地说信号和中断都具有异步通知特征但中断是硬件/CPU 层面的事件入口信号是内核暴露给进程的进程级通知语义。中断先让内核拿到控制权内核再决定是否把某类事件转译为某个进程可见的信号。你在第一页、第二页笔记中画出的“外设 → 中断 → 中断向量表 → OS 处理 → 信号映射到目标进程”正好体现了这层关系。1.1 信号最核心的三个特征第一异步。信号不是由当前执行流按顺序“调用”出来的它可能在任何时刻到来。第二轻量通知。信号通常只表达“发生了什么”而不是携带大块业务数据。第三由内核主导递达。应用程序可以触发信号产生但真正把信号送达到目标进程、并决定何时让它可见始终是内核在做。你笔记里“产生信号的方式很多但是发送信号只能由 OS 发送”这个结论表达的就是这个意思。1.2 常见信号不要死记编号要记语义信号常见来源默认语义SIGINTCtrlC终止进程SIGQUITCtrl\终止并通常生成coreSIGTSTPCtrlZ暂停前台作业SIGTERMkill pid默认发送优雅终止请求SIGKILLkill -9 pid立即终止不能捕捉/阻塞/忽略SIGALRMalarm()定时器到期定时通知SIGCHLD子进程状态变化通知父进程回收子进程SIGSEGV非法内存访问段错误常伴随core dumpSIGFPE除零等算术异常算术错误常伴随core dump这里要特别纠正一个常见误区不要在代码里依赖“某个信号一定是某个固定编号”。例如笔记里把SIGCHLD写成 17这在很多 Linux/x86 环境里成立但更可靠的写法始终是使用宏名SIGCHLD而不是手写数字。二. 信号从哪里来四大来源串起来看 ️结合你笔记中的内容Linux 中常见的信号来源可以归纳为四类键盘终端输入、系统调用/软件显式发送、硬件异常转译、定时器等软件条件。这也是理解后续“为什么进程会突然收到一个信号”的基础。2.1 键盘与作业控制信号终端前台作业和键盘之间是绑定的。一个终端在某个时刻通常只有一个前台作业组接收终端输入因此CtrlC通常向前台作业组发送SIGINTCtrl\通常发送SIGQUITCtrlZ通常发送SIGTSTP这也是为什么你在笔记里看到“前台命令只能有一个、后台命令可以有多个、fg可以把后台作业提到前台、bg让停止的后台作业继续执行”。这些现象本质上都和终端作业控制有关。 避坑指南“前台进程无法被暂停”这个说法并不准确。更准确的说法是前台作业可以被CtrlZ触发的SIGTSTP暂停随后由 shell 接管终端控制权。2.2 系统调用或软件显式发送最常见的是kill()、raise()、abort()#includesignal.h#includestdlib.h#includeunistd.hkill(pid,SIGTERM);// 向指定进程发送信号raise(SIGINT);// 给当前进程发送信号abort();// 向当前进程发送 SIGABRT其中kill(pid, sig)不一定是“杀进程”它是“向目标进程发送一个信号”raise(sig)常用于当前进程给自己发信号abort()常用于异常终止当前进程默认产生SIGABRT你笔记中列出的Ctrl\、raise、abort都属于这一类或与这一类紧密相关。2.3 异常硬件/CPU 检测后由内核转成信号程序中的某些错误不会先变成“普通函数返回值”而是会被 CPU 或 MMU 先检测出来然后由内核转译成信号。典型例子包括除零 → 可能触发SIGFPE非法地址访问、野指针、页表转换失败 → 可能触发SIGSEGV或SIGBUS非法指令 → 可能触发SIGILL你笔记里把“CPU 状态位识别异常 → OS 解释 → 向目标进程发送信号”画得非常清楚。这个思路是对的但有一点要更严谨不是所有异常都直接等于某个信号而是内核在异常处理路径上根据异常类型决定是否、以及如何把它呈现为用户态可见的信号。2.4 定时器和软件条件alarm()是信号章节里非常典型的软件条件来源。调用#includesignal.h#includeunistd.hunsignedintalarm(unsignedintseconds);当设定时间到期时内核向当前进程发送SIGALRM。它常被拿来做超时控制、简单定时任务或者教学实验。你笔记里还提到一个非常重要的细节alarm()的返回值通常表示前一个闹钟还剩余的秒数。这也是为什么很多“周期性闹钟”示例会在处理函数里再次调用alarm()。 避坑指南alarm()本身只会安排一次触发。想做周期性定时常见做法是在信号处理函数中再次设置alarm()或者直接使用更完整的定时器接口而不是误以为alarm()天生就是周期性的。三. 信号在进程里是怎么“存起来”的 理解信号机制最关键的一步不是会写signal()而是要弄清楚信号产生之后并不是立刻执行处理函数而是先在进程控制块相关的数据结构中记录下来。你在第三页、第十页的笔记里反复画出的block / pending / handler三列正是 Linux 学习信号时最核心的模型。3.1 三张“表”分别表示什么结构含义典型问题pending未决信号集某个信号是否已经产生、但还未递达“信号来了没有”block信号屏蔽字某个信号当前是否被阻塞“现在允不允许递达”handler/disposition该信号的处理方式默认、忽略、自定义“递达后怎么处理”所以信号的完整生命周期可以概括成产生 - 记录到 pending - 检查是否被 block - 在合适时机递达 - 按 disposition 执行这也解释了你笔记中的一句话“忽略是处理的一种方式阻塞是还没有处理。”这句话非常重要因为很多人会把“阻塞某个信号”和“忽略某个信号”混为一谈。前者只是暂时不让它递达后者则是递达后选择不处理。3.2 未决、阻塞、递达三者关系这三个概念容易混未决信号已经产生但还没有真正送到处理阶段阻塞进程当前不允许某信号递达递达内核真正开始执行该信号对应处理动作因此未决不一定阻塞因为它也可能只是“还没轮到处理”阻塞后若信号产生通常会处于未决状态解除阻塞后未决信号才有机会被递达3.3 普通信号和实时信号的差异你笔记里提到“普通信号用位图维护实时信号用队列维护。” 这个结论非常关键。标准/普通信号通常采用“只记一次”的语义如果某个标准信号已经未决再来一次同种信号通常不会多记一份。而实时信号则不同实时信号通常会排队保存多个实例并且可以按顺序递达。也正因为如此标准信号适合做事件通知实时信号更适合需要保留多次事件发生次数的场景。四. 信号什么时候被处理递达时机与状态切换 “信号会在合适的时候处理”是这套机制最容易被忽视、但又最本质的特征。你在第 11、12 页笔记里已经抓住了要点内核通常会在进程即将从内核态返回用户态时检查是否存在可以递达的未决信号。如果有就在返回前安排相应的处理流程。4.1 为什么不是一产生就立刻处理因为信号的处理必须和当前执行上下文协调。进程可能正处在系统调用中、内核路径中、调度过程中内核不能随意在任意一条指令中间直接跳到用户自定义处理函数里去。更稳妥的做法是先把信号记下来等到安全的检查点再决定是否递达这就是“异步产生、同步到安全点再处理”的核心思想。4.2 自定义捕捉时的大致执行路径如果某个信号的处理方式不是默认动作也不是忽略而是用户自定义处理函数那么流程大致可以理解为用户态执行 - 进入内核态 - 内核发现可递达信号 - 构造用户态处理现场 - 回到用户态执行 handler - 通过 sigreturn 再回内核 - 恢复原执行流你笔记里提到“信号捕捉中一共会设计四次状态切换”本质上就是在描述这种往返原程序执行、内核检查、用户态执行 handler、再返回原程序现场。4.3 为什么 handler 通常在用户态执行这是一个很重要的安全设计。用户自定义的处理函数本质上仍然是用户代码不应该拿着内核权限直接执行。于是内核只负责搭台检查信号、准备现场、切回用户态让用户代码以用户权限运行。运行完毕后再借助sigreturn恢复先前被打断的上下文。你笔记中“自定义方法用用户态执行更安全”这一点是正确的。五. 三种处理方式默认、忽略、捕捉 Linux 信号对每个进程来说最终都要落到三种处理方式之一默认动作SIG_DFL忽略SIG_IGN自定义捕捉即安装 handler你在第一页和第二页的图里已经把这三种处理方式标了出来。实际开发中三者的含义必须分清。5.1 哪些信号不能随便改最重要的两个例外是SIGKILLSIGSTOP它们不能被捕捉、不能被阻塞、也不能被忽略。你笔记里明确写到了“9 号信号无法被捕捉”这是对的但应当再补充完整SIGSTOP也属于这一类。5.2signal()能用但更推荐sigaction()教学阶段常用#includesignal.hvoidhandler(intsigno){// 处理逻辑}intmain(){signal(SIGINT,handler);while(1){}}但工程实践里更推荐sigaction()因为它语义更完整能够更可靠地安装处理函数配置信号屏蔽集sa_mask设置额外行为标志sa_flags#includesignal.h#includestring.hvoidhandler(intsigno){// 只做尽量简单、异步信号安全的事情}intmain(){structsigactionact;memset(act,0,sizeof(act));act.sa_handlerhandler;sigemptyset(act.sa_mask);sigaddset(act.sa_mask,SIGINT);// 处理期间额外屏蔽 SIGINTsigaction(SIGINT,act,NULL);for(;;)pause();}你笔记里后半段已经把重点从signal转到了sigaction和sa_mask这个方向是非常正确的。5.3 处理期间为什么会自动屏蔽当前信号默认情况下某个信号在其 handler 执行期间内核会临时把该信号加入屏蔽集中防止同一信号再次重入打断当前 handler。也就是说信号处理默认不允许同种信号无限嵌套重入。你笔记中的“信号不允许嵌套处理会自动屏蔽正在处理的信号”说的正是这个行为。六. 信号阻塞与未决用sigprocmask真正控制它 理解了pending和block之后下一步就是知道如何显式修改进程的信号屏蔽字。Linux/POSIX 常用接口是#includesignal.hintsigprocmask(inthow,constsigset_t*set,sigset_t*oldset);你在第 11 页截图里已经把SIG_BLOCK、SIG_UNBLOCK、SIG_SETMASK三种操作总结出来了可以直接整理成下面这张表。参数作用SIG_BLOCK把set中的信号加入当前屏蔽字SIG_UNBLOCK把set中的信号从当前屏蔽字移除SIG_SETMASK直接把当前屏蔽字替换为set6.1 一个典型的阻塞流程sigset_tset,oldset;sigemptyset(set);sigaddset(set,SIGINT);sigprocmask(SIG_BLOCK,set,oldset);// 阻塞 SIGINT// 这里即使收到 SIGINT也先变成未决sigprocmask(SIG_SETMASK,oldset,NULL);// 恢复旧屏蔽字这里的oldset正是你笔记里提到的oset/旧参数恢复思路。它的意义非常大先保存现场处理完再恢复。这在写临界区保护或临时屏蔽某些信号时非常常见。6.2 一个容易混淆的结论标准信号在阻塞期间如果反复到来通常只会在未决集中记录一次。因此你看到的“普通信号只记录一次”并不是说信号没来多次而是这类信号的记录语义决定了它不会排队保存所有副本。七.core dump为什么程序崩了还能追现场 你上传的笔记中专门拿出一节讲core dump这很有价值因为它把“信号”从概念层拉到了排障层。对于SIGSEGV、SIGABRT、SIGFPE、SIGQUIT等一类严重错误默认动作可能不仅仅是终止进程还会额外留下一个core文件用于保存崩溃现场。7.1core dump里保存的是什么可以把它理解为程序异常终止时的“内存快照 寄存器现场 调用栈线索”。因此我们可以在事后用调试器回看崩在哪个函数哪一行代码触发异常当时寄存器和栈大概处于什么状态7.2 常见调试方式gdb ./a.out core.12345你第 9 页笔记里的例子就是用gdb打开core文件后定位到a / 0;这一行直接还原出触发SIGFPE的位置。这个过程本质上就是“拿崩溃现场倒推代码路径”。7.3 为什么很多云环境默认关闭core dump因为core文件可能非常大持续崩溃时会迅速写满磁盘。你笔记里提到ulimit -a查看限制ulimit -c调整core文件大小限制这个观察是对的。工程上往往会在“可调试性”和“磁盘安全”之间做权衡。八. 信号处理函数为什么总强调“可重入” ⚠️这是信号章节里最容易从“会写”走向“写对”的分水岭。你笔记后半段专门讲了可重入函数和volatile而且还画了链表插入被打断导致数据错乱的过程这个例子非常典型。8.1 什么叫可重入函数如果一个函数在执行过程中被异步打断后再次进入自身执行仍然不会破坏结果就称为可重入函数。反之就是不可重入函数。信号处理函数之所以危险就在于它可能在原函数执行到一半时突然介入。如果原函数操作的是共享全局状态、链表、堆内存、静态缓冲区handler 再次进入同一逻辑就很可能把原状态打坏。8.2 为什么printf、std::cout这类东西要慎用教学代码里经常在 handler 中直接打印例如你笔记中的若干示例就是std::cout 获取一个14信号。这有助于观察现象但从工程角度说并不安全。因为printf、C 流对象、malloc一类函数通常都不是严格意义上的异步信号安全函数。在真正的信号处理函数中应该尽量只做最小、最确定的动作。 避坑指南handler 中最好只做这几类事设置一个标志位调用极少数异步信号安全函数快速返回。不要在 handler 里写复杂业务逻辑、容器操作、日志框架调用、锁操作。8.3volatile能解决什么不能解决什么你笔记里举了一个经典例子主循环一直检查flag而CtrlC对应的 handler 去修改这个flag。如果编译器开启优化主循环可能把flag缓存在寄存器里导致内存里的修改对循环“不可见”。于是即便 handler 改了值主循环也可能看不到。这时volatile的意义是告诉编译器这个变量可能在当前执行流之外被改变每次都应当重新从内存读取。#includesignal.h#includestdio.h#includeunistd.hvolatilesig_atomic_tflag0;voidhandler(intsigno){flag1;}intmain(){signal(SIGINT,handler);while(!flag){// busy loop}write(1,quit\n,5);return0;}这里再修正一个比笔记更严谨的写法比起单独写volatile int更推荐使用volatile sig_atomic_t作为 handler 和主流程之间共享的简单标志变量。因为sig_atomic_t至少保证这类读写在信号语义下足够简单、可安全表达。同时也要明确volatile解决的是“可见性/优化”问题不解决互斥、不保证复合操作原子性。它不是锁也不是万能同步工具。九.SIGCHLD子进程退出后父进程为什么会被提醒 你在最后一页单独整理了SIGCHLD这是非常实用的一节。因为一旦涉及fork()、并发子进程、僵尸进程SIGCHLD几乎绕不过去。9.1SIGCHLD什么时候产生当子进程退出、停止或继续运行等状态变化发生时内核通常会向父进程发送SIGCHLD。最常见的场景当然还是“子进程退出了”。9.2 它解决了什么问题父进程需要知道哪个子进程结束了是否要调用wait()/waitpid()回收资源是否要避免僵尸进程因此SIGCHLD的本质不是为了“杀掉谁”而是为了通知父进程你的子进程状态变了请决定是否处理。9.3 能不能直接忽略SIGCHLD你笔记里写到“Linux 支持手动忽略SIGCHLD只要忽略所有子进程都不要父进程进行等待”。这个说法在很多 Linux 环境下现象上成立但更严谨的表达应当是Linux 对SIGCHLD的忽略和SA_NOCLDWAIT有特殊处理能帮助避免僵尸进程不过跨平台或工程代码里显式使用waitpid()或配合sigaction配置仍然更清晰可靠。一个更常见的写法如下voidchld_handler(intsigno){while(waitpid(-1,NULL,WNOHANG)0){}}这样即使多个子进程几乎同时退出也能尽量一次性回收干净。十. 面试和实战里最容易被问的几个点 10.1kill -9和kill -15有什么区别kill -15发送的是SIGTERM它给进程留下清理资源、优雅退出的机会kill -9发送的是SIGKILL无法被捕捉和忽略内核会直接终止目标进程。10.2 阻塞和忽略有什么区别阻塞是“不让递达”信号仍可能进入未决集忽略是“已经递达但处理方式是丢弃”。阻塞是时间维度上的延后忽略是语义维度上的处理策略。10.3 为什么标准信号只记录一次因为标准信号通常由未决位图表示同一种信号重复到来时不会像消息队列一样排队保存多个副本。10.4 信号处理函数里最重要的工程原则是什么只做最少的事。最典型做法是设置一个全局标志然后回到主循环里用正常控制流完成后续逻辑。10.5 为什么说信号是异步的但处理常常又发生在“某个固定点”因为“异步”描述的是产生时机不可预测而“固定点”描述的是内核选择在安全路径上递达。两者并不矛盾。总结 把整章内容压缩成一条主线其实就是这几句话信号是内核提供给进程的异步通知机制它可以由键盘、系统调用、异常和软件条件产生产生后不会马上执行而是先记录到进程的未决状态中内核会在合适时机检查屏蔽字和处理方式再决定是否递达递达之后可以执行默认动作、忽略或者进入用户自定义的处理函数。继续往下看core dump让我们知道信号不仅是“通知”还是排障入口sigprocmask让我们知道信号并非不可控sigaction让我们知道工程上要用更完整的接口可重入函数、volatile sig_atomic_t、SIGCHLD则进一步说明信号真正难的不是“收到一个通知”而是在异步打断的语境下如何把程序写得既正确又稳定。所以学习信号不要只停留在“会用CtrlC终止进程”这一级。真正要掌握的是信号从哪里来、存在哪里、什么时候递达、如何处理、处理时会不会把自己的程序打乱。只要这条链路想清楚Linux 信号这一章就真正打通了。

相关新闻