Linux系统篇33——信号(五):信号从不“立刻处理“,内核只在回用户态的路上顺路办了它

发布时间:2026/9/15 7:09:58
Linux系统篇33——信号(五):信号从不“立刻处理“,内核只在回用户态的路上顺路办了它 本文收录于「流浪」的系列专栏 Linux系统⚙️ C 数据结构与算法 Python LangChain LangGraph️ MySQL 数据库 Git 工具 计算机网络 AI 大厂面试、八股 学习筑基专栏 博客主页:流浪 原创首发于 CSDN前情:信号(一)讲了信号是什么、从哪来;信号(二)讲了 task_struct 三张表怎么收;信号(三)讲了阻塞:屏蔽不等于丢弃;信号(四)讲了 kill -9 与 core。本篇:悬了四篇的问题——“信号到底什么时候被处理”——在本篇落地。主线:捕捉信号 → 用户态与内核态 → 硬件中断与时钟 → 软件中断与系统调用,层层递进,一气呵成。一、捕捉信号:信号的处理,从不立即先纠正一个直觉:信号的处理,不是立即处理,而是等一会儿再处理——在合适的时候,才进行信号处理。你早就见过证据:一个死循环进程收到 SIGINT,并不是当场死亡;你注册的 handler 打印,也总是晚半拍才出现。信号从产生到被处理,中间隔着一段挂号等待期(pending),本篇就讲清楚这期间发生了什么。1.1 信号的处理方式有三种:忽略 / 默认 / 自定义信号真正被处理(递达)的那一刻,动作只有三种,没有第四种:方式含义谁执行忽略收到当没收到,销账了事内核置位即可,最省事默认按 OS 为该信号内置的约定执行内核照章办事自定义执行你注册的信号处理函数用户,内核只负责修路1 方式一:忽略把信号设置为忽略(SIG_IGN),递达时内核直接把它当空气。注意一个细节:默认动作就是忽略和你主动设置忽略是两回事——比如 SIGCHLD 默认忽略,和你显式SIG_IGN,在某些语义(如僵尸进程回收)上并不等价,这个坑后面系列单独聊。2 方式二:默认OS 给每个信号都内置了出厂约定。默认动作拢共四大类:默认动作含义典型信号Term终止SIGTERM、SIGINTTermCore终止并落盘 core 文件(呼应信号(四))SIGQUIT、SIGSEGVStop停止进程SIGSTOP、SIGTSTP(CtrlZ)Cont继续进程SIGCONT3 方式三:自定义用signal()或sigaction()注册一个函数,信号递达时执行它:voidhandler(intsigno);// 你写的函数signal(SIGINT,handler);// 注册:SIGINT 递达时,执行 handler自定义是三种方式里唯一需要折腾 CPU 身份的,复杂到要穿越四次身份墙——这是本篇主角。1.2 合适的时候,是什么时候?1 先想清楚:进程凭什么能看到信号?反问一句:pending 位图躺在哪里?——内核的task_struct里。用户态代码根本摸不到这本账。所以结论只有一个:查账这件事,必须有人在内核里才能干。信号处理的时机 进程从内核态返回用户态的时候。2 do_signal():内核的查账员每次从内核态回用户态,内核都会路过一个检查点,由do_signal()出场查账(课程口径;新内核里这套逻辑演化为exit_to_user_mode_loop,思想不变):// 伪代码,基于 arch/x86/kernel/signal.c 思路简化do_signal(structpt_regs*regs){while((signrget_signal())){// 1. 查 pending:取 pending ~block 中最小的信号if(sig_ignored(signr)){// 2. 忽略:账本上直接销账清除 pending 对应位;// 不打扰任何人,继续返回用户态continue;}if(handler 表[signr]SIG_DFL)// 3. 默认:按约定办事执行默认动作;// 终止/停止/core……多半不再返回else// 4. 自定义:最麻烦的一种偷改返回用户态的上下文;// 把回去后的第一条指令换成 handler 入口}// 没有信号要处理 → 原路返回用户态,继续 main 主流程}查账顺序严格是三步:先查 pending 表——为 0,直接返回;不为 0——再看 block 表,判断该信号有没有被阻塞(被阻塞?当没看见,信号继续挂着,呼应信号(三));最后查 handler 表,决定动作。3 如果收到的是默认、忽略呢?忽略:do_signal检查时直接将 pending 表置 0,直接返回用户态,完事;默认:OS 根据特定信号的既定约定,执行特定动作,也简单。4 因此:OS 执行默认、忽略,比自定义简单得多一句话对比:忽略是销账,默认是照章办事,都一步到位;唯独自定义,内核要做局——先偷改上下文、放用户回去执行函数、再等用户自己回来销假。这个局怎么做,见 1.4。1.3 重谈捕捉过程:自定义 handler,由谁执行?1 灵魂拷问:用户身份,还是 OS 身份?我们在执行自定义方法时,是以用户身份执行,还是以 OS 身份执行?2 答案:用户3 为什么?——一笔安全账反过来想就明白了:如果内核亲自执行用户写的函数,等于用户代码借到了内核的权力——万一这个函数里在做非法操作呢?比如删文件、改文件权限、绕过权限检查?内核的铁面无私就被用户代码污染了。所以原则是:内核只负责把路修到 handler 门口,绝不替用户跑腿。执行 handler 前后,内核只做两件事:保存现场、恢复现场。1.4 因此,一次捕捉 四次身份切换1 一张 ∞ 形图这张 ∞ 形图是信号捕捉的灵魂:两条曲线的交点,就是信号检测的时间点和横线和曲线的交点是现场恢复点。建议自己动手画一遍,比背十遍都有用。2 第①次切换:用户态 → 内核态主线进程因为系统调用、中断或异常,陷入内核。(为什么会恰好在内核?——1.5 节回答。)3 第②次切换:内核态 → 用户态返回前do_signal()查账,发现是自定义 handler,于是修改返回用户态的上下文——把回去后该执行的下一条指令偷换成 handler 的入口地址。CPU 回到用户态,顺理成章地先执行了 handler。4 第③次切换:用户态 → 内核态(sys_sigreturn)handler 执行完,函数return并不会直接回main——而是通过一个专门的系统调用sys_sigreturn再次进入内核,把第 3 步里内核保存的原始现场取回来。5 第④次切换:内核态 → 用户态带着原上下文,回到 main 的断点,继续跑。主线全程无感,仿佛什么都没发生过。6 结论,和一个伏笔从内核态返回用户态时做信号处理;捕捉一个自定义信号,进程一共要完成四次身份切换。伏笔:handler 是插在 main 任意两条指令之间执行的,它和 main 共享同一个地址空间——所以才有可重入函数async-signal-safe这些讲究(下一篇细聊)。1.5 进程为什么要进入内核?1 因为要被调度,而调度由 OS 完成进程没主动干啥,为什么老在内核里?——因为进程要被调度,而调度这件事只能由 OS 完成。一个正常运行着的进程,注定周期性地被拉进内核、再放回用户态。2 返回用户态是信号检查的天然卡点信号检查点选在返回用户态这里,不是巧合,是顺路:反正每次回用户态都要过内核的岗亭,顺便把信号账本查了,零成本。那问题继续往下钻:是什么力量,把进程周期性地拽进内核的?答案是两个大字:中断。但在讲中断之前,先把用户态/内核态本身说清楚。二、用户态与内核态地址空间怎么分内核怎么进讲清这个问题,绕不开五样东西:地址空间、如何进入、硬件中断、时钟中断、缺页。第一样是地基,本章讲;后四样对应进内核的四条路,散在第三、四、五章——这里先把框架立起来。2.1 从地址空间看:一个进程的两个世界1 用户空间与内核空间以 32 位经典划分为例:每个进程的虚拟地址空间是 4G,其中0~3G 是用户空间,归进程自己的代码和数据;3~4G 是内核空间,映射的是操作系统(64 位的划分方式不同,思想一致)。内核空间是所有进程共享的同一份映射——因为 OS 只有一个。2 用户态/内核态的本质:CPU 的两档权限“态描述的是CPU 当前正在执行的代码的权限等级:跑用户代码,处于用户态,只能摸用户空间;跑内核代码,进入内核态,才有资格摸内核空间。所谓切换身份”,本质是 CPU 换挡。2.2 什么时候换挡?进内核的四条路1 路一:系统调用进程主动要服务(读写文件、创建进程)——第五章细讲。2 路二:硬件中断外设来敲门——第三章的主角。3 路三:时钟中断OS 的心跳,驱动的正是 1.5 说的调度——第四章细讲。4 路四:缺页与异常除 0、野指针、缺页,进程自己撞上的——5.4 节细讲。三、硬件中断:OS 是怎么运行的3.1 起点:OS/进程怎么知道磁盘上有数据了?1 答案:硬件中断!磁盘数据到了,OS 不可能一直盯着——是硬件主动通知它。2 为什么不轮询?让 CPU 每隔一段时间挨个问外设好了没,查询本身烧掉大量 CPU 时间,还引入无谓延迟。中断的本质是事件驱动:平时不打扰,事了自然知。3.2 冯诺依曼体系:CPU 只和内存直接打交道冯诺依曼结构体系决定了CPU 要从内存中读数据。外设(如键盘)连接内存,间接和 CPU 相连——间接二字,正是下面针脚与中断控制器的意义。3.3 针脚:中断的物理通道1 什么是针脚针脚就是 CPU 芯片边缘那圈金属引脚,是 CPU 与外界唯一的物理接口。其中有些针脚专门用于接收中断信号:外部电平一变,就等于硬件对 CPU说了句话。2 中断控制器(8259):外设与 CPU 的中间人问题来了:外设成百上千,CPU 的中断针脚只有几个,怎么接?——需要一个中间人:中断控制器(经典型号8259)。所有外设的中断线汇到它这里,由它统一向 CPU 的中断针脚发信号。(现代机器已用 APIC 取代 8259,且时钟源也集成进了 CPU——思想不变,教材口径照旧。)3 两级发信号的完整硬件链路外部设备准备好数据 ↓ ①向中断控制器(8259)的特定针脚发送信号 中断控制器 ↓ ②再由中断控制器向 CPU 的特定针脚发送中断信号 CPU 被硬件戳了一下,放下手头工作,处理中断3.4 先硬件,再软件1 CPU 只知道有设备准备好了中断信号到了,故事只讲了一半:CPU 只知道外部有设备准备好了,但到底是可以被读、还是可以被写,以及对数据的处理——它并不知道。2 这些由软件来完成:中断向量表怎么办?得有一张中断号 → 处理代码的对照表。于是就有了中断向量表——它本质上是一个函数指针数组。3 下标就是 CPU 的中断号数组的下标就是 CPU 的中断号,数组的内容,是对应中断服务程序(ISR)的入口地址。和数组 下标打了这么多年交道,这是它最硬核的一次出场。4 完整链路总结外设就绪 → 中断控制器(8259) → CPU 针脚收到中断 → 以中断号为下标,查中断向量表 → 找到对应中断服务程序的入口地址 → 去执行处理代码5 中断向量表本身就是 OS 的一部分最后一块拼图:中断向量表是 OS 的一部分。所以 OS 从不主动追问外部设备你好了没——它准备好,会通知 OS。这就是事件驱动。四、有没有一种熟悉感:信号就是用户态的中断4.1 硬件中断 vs 进程信号:同一套设计思想看到这里,你应该有一种莫名的熟悉感——没错,硬件中断和 Linux 进程信号,在设计思想上一模一样:硬件中断Linux 进程信号中断号信号编号(1~64)设备就绪,控制器挂号pending 位图置 1中断屏蔽字block 屏蔽位图中断向量表 → ISRhandler 表(默认/忽略/自定义)ISR 执行完,恢复现场返回handler 执行完,sigreturn恢复现场先挂号,择机处理,处理完恢复现场——一个模子刻出来的。4.2 没有中断到来时,OS 在做什么?1 答案:什么都不做,它是暂停的可能颠覆直觉:OS 不是时刻管理者,没有中断,它就在原地停着。啊我没听错吧 他啥都不做进程是咋调度的2 内核源码:for(; pause();内核的主循环朴素到令人发笑(经典示意):for(;;)pause();// 睡过去,等中断来叫醒4.3 时钟:OS 的心跳1 时钟源:以固定频率向 CPU 发中断中断控制器里有一个时钟源,以固定的频率向 CPU 发送特定的中断信号(时钟中断)——不管有没有外设干活,它雷打不动。2 在中断向量表里,注册时钟中断的服务 进程调度时钟中断的向量表表项,注册的就是进程调度函数。每次时钟中断到达,CPU 都会进内核跑一次调度逻辑。3 时间片的真正来历于是,OS 在硬件时钟中断的驱动下,进行调度——这就是时间片的真正来历:所谓时间片,就是用固定频率的中断数出来的。4 进程调度的大致过程set_intr_gate(0x20, timer_interrupt); // 开机初始化时执行一次 _timer_interrupt: // 时钟中断服务程序(汇编)入口 ... call _do_timer; // do_timer(long CPL) does everything ... ... do_timer: // C 函数:每次时钟节拍都会走到 ... if ((--current-counter) 0) return; // 如果进程运行时间还没完,则退出 schedule(); // ← 图中标注进行调度! ... switch_to(next);5 为了追求效率:时钟源集成进 CPU 内部外置时钟源走针脚,又慢又占资源;后来干脆将时钟源集成在 CPU 内部(local APIC timer 的思想),发中断更准时、开销更低。6 频率 → 时间戳:离线也知道几点有了固定频率,就能数中断算出时间戳,再与历史时间对照校准——这样计算机哪怕离线,也知道现在几点了(实时时钟 RTC 的思路)。4.4 结论:OS 就是基于中断进行工作的软件OS 就是基于中断进行工作的软件。它平时暂停在死循环里,所有的主动性——调度、外设、异常——全部来自中断。4.5 延伸一问:有没有软件触发的中断?有。比如除 0、野指针、缺页中断——这些由软件撞出来的中断,让OS 得以基于中断知道硬件异常。它们和系统调用同属软件中断家族,下一章细讲。五、软件中断:一条指令把 CPU骗进内核5.1 CPU 内部的触发指令1 x86(32 位):int 指令经典如int 0x80。2 x86_64:syscall 指令这两条指令的共同点:自动让 CPU 触发一次中断,陷入内核——不需要任何外设参与,软件自己就能敲门。5.2 系统调用表与系统调用号1 问题:CPU 只有一个,怎么知道你要调哪个服务?当我们进行系统调用时,具体是如何进入 OS、完成系统调用的?毕竟 CPU 只有一个,内核怎么区分你要的是 open 还是 write?2 答案:系统调用表,又一个函数指针数组系统里有一张系统调用表——每一个系统调用都有一个唯一的下标,这个下标就是系统调用号。你把调用号放进寄存器,内核按下标查表,跳到对应服务入口。(和中断向量表如出一辙——第三处熟悉感。)5.3 open 函数不就是系统调用吗?1 不是:OS 不提供任何系统调用,只提供调用号你天天调的open(),是glibc 封装的一层壳。OS 这边,只认调用号。2 拆开 glibc 的 open// glibc 的 open(32 位示意)intopen(constchar*path,intflags,...){mov eax,5;// 5 __NR_open,系统调用号放进 eaxsyscall// 32 位写法:int 0x80 —— 触发软中断,陷入内核}// 内核按 eax 里的 5 查系统调用表 → 找到 sys_open 入口执行5.4 软件中断的两兄弟:陷阱与异常1 陷阱(trap):主动索取服务int 0x80/syscall——主动、有意为之,为了获取内核服务。2 异常(exception):被动撞上除 0、野指针、缺页——进程自己撞上的错误或事件。注意:缺页不一定是错误,请求分页(懒加载)就是靠它实现的,这个坑系列后面填。OS 正是基于中断,才知道硬件出了异常。六、收束:CtrlC 的一生6.1 完整链路现在,可以把第一篇埋下的线全部缝上了:敲下 CtrlC → 键盘控制器向中断控制器发信号(硬件中断) → CPU 收到中断,执行键盘中断服务程序 → 驱动把 SIGINT 挂进前台进程的 pending 位图(挂号) → 该进程某次从内核态返回用户态时 → do_signal() 查账:pending1,block0 → 默认动作是终止 → 进程消亡而如果你用sigaction注册了 handler:同一条路走到查账那一步,变成四次身份切换——内核改上下文,回用户态执行你的函数,sys_sigreturn回内核,再回 main。一切的中介,都是中断。理解了中断,你才真正理解了信号;理解了信号,你才真正看到了 OS 运行的骨架。6.2 最简实验(建议自己跑一遍)#includestdio.h#includesignal.h#includeunistd.hvoidhandler(intsigno){printf(get a signal: %d\n,signo);}intmain(void){signal(2,handler);// 给 SIGINT 注册自定义动作(sigaction 是更现代的写法)while(1){printf(I am a process, pid: %d\n,getpid());sleep(1);}return0;}运行后kill -2 pid,观察打印——再想想:这条打印出现在 main 循环的哪两条指令之间?它背后,是刚刚那四次身份切换。七、文末面试题7.1 推导题(按本讲知识点,附答案)1.【推导】信号在什么时候被处理?为什么必须是这个时刻?答:从内核态返回用户态时。pending / block / handler 三张表都在内核task_struct里,用户态摸不到,只有回用户态路上的检查点(do_signal)能查账。2.【推导】一次自定义信号的捕捉,一共发生几次用户态/内核态切换?答:四次。陷入内核 → 回用户态执行 handler → handler 完经sys_sigreturn回内核 → 恢复现场回用户态。3.【推导】自定义 handler 为什么由用户身份执行,内核不代劳?答:安全。内核若亲自执行用户函数,等于用户代码借到内核权力,可借机删文件、改权限;内核只保存/恢复现场。4.【推导】handler 执行期间,又收到同一个信号,会立刻嵌套执行吗?答:默认不会。执行 handler 前,内核自动把该信号加入进程屏蔽字;相同信号此时只会挂号(pending 置 1),等 handler 返回、屏蔽字恢复后再递达。5.【推导】没有任何中断到来时,CPU 在干什么?答:暂停。内核停在for(;;) pause();上;一切主动性(调度、外设、异常)都由中断驱动。6.【推导】中断向量表、系统调用表、信号的 handler 表,共同点是什么?答:都是函数指针数组 唯一下标——下标分别是中断号、系统调用号、信号编号;查表跳转,一套思想三处复用。7.【推导】open() 是系统调用吗?描述一次系统调用的完整过程。答:不是,open 是 glibc 封装的壳。过程:调用号放 eax →int 0x80/syscall触发软中断陷入内核 → 内核按调用号查系统调用表 → 执行对应服务程序 → 返回。7.2 真题(搜断面经,均已转述并核对原文主题)1. 从用户态切换到内核态,有哪几种方式?答:系统调用、程序异常(缺页/除零等)、外设中断——正是 2.2 节那四条路的归纳。【真题 · 转述自 小林coding《操作系统面试题》 与 GitHub: 0voice/linux_kernel_wiki 面试题(题库型,无单一年份)】2. 信号是怎样被捕捉的?处理过程中用户态/内核态如何切换?答:即 1.4 节四次身份切换。【真题 · 转述自 CSDN 博文《面试常问:Linux信号之信号的产生、信号的捕捉》 · 2018 年】小结:本篇把信号从挂号讲到递达,又顺手掀了 OS 的老底——它自己就是一个被中断驱动的软件。跑 6.2 实验时踩到的任何坑,评论区见。系列回顾:信号(一) · 信号(二) · 信号(三) · 信号(四)下一篇预告:信号(六)——可重入函数与 volatile:handler 插队执行的副作用,比你想的大。