【Linux】信号到底什么时候被处理?sigaction、中断、用户态内核态与 SIGCHLD

【Linux】信号到底什么时候被处理?sigaction、中断、用户态内核态与 SIGCHLD 个人主页爱和冰阔乐专栏传送门《数据结构与算法》 、C学习方向C方向学习爱好者⭐人生格言得知坦然 失之淡然博主简介文章目录前言一、信号处理的时机内核态返回用户态1.1 默认和忽略呢1.2 执行自定义 handler 时是以什么身份执行的1.3 纯死循环是怎么进入内核的二、sigaction比 signal 更完整的捕捉接口2.1 处理期间当前信号自动被屏蔽三、操作系统是怎么运行的3.1 OS 怎么知道键盘上有数据了3.2 中断向量表硬件事件的函数指针数组3.3 没有中断时OS 在干嘛3.4 软中断软件也能触发这套逻辑3.5 系统调用的完整链路四、用户态和内核态五、可重入函数六、volatile信号引出的 C 语言关键字七、SIGCHLD子进程退出的通知机制7.1 先验证7.2 用信号回收所有子进程7.3 SIG_IGN 的历史特例总结前言前两篇留了一个坑信号产生了、保存了但合适的时候到底是什么时候这一篇填坑而且这个坑挖得很深——往下挖会挖到中断向量表、时钟中断、系统调用表、用户态内核态切换最后用可重入函数、volatile 和 SIGCHLD 收尾。看完这篇你能回答纯while(1)死循环为什么也能被 CtrlC 杀掉自定义 handler 到底以什么身份在执行int 0x80是怎么把你带进内核的一、信号处理的时机内核态返回用户态先说结论进程从内核态返回用户态的时候进行信号检查与处理。内核态执行 OS 的代码和数据时计算机所处的模式。调用系统调用实际是 OS 在执行对应方法用户态执行用户自己写的代码比如 while 死循环时的模式。进程在 OS 的驱使之下进行信号检查OS 在返回用户态之前检查是否有信号到来。收到了且没被 block就转而进入信号处理流程。如果是自定义动作OS 会先回到用户层执行 handler执行完再返回内核最后由内核返回用户空间接着执行主流程代码。这就是那张著名的 4 次切换图用户主流程 → 内核信号检查→ 用户handler→ 内核sigreturn→ 用户主流程。1.1 默认和忽略呢忽略pending 表 1→0直接返回用户层继续跑默认动作进程当前在内核态OS 可以直接杀掉进程。1.2 执行自定义 handler 时是以什么身份执行的以用户身份执行不能以内核身份。想想 handler 是用户写的万一里面有非法操作——删配置文件这种用户本来没权限干的事——要是拿内核身份跑权限直接被滥用。所以内核态切到用户态执行 handler必须做身份切换。那执行完 handler怎么知道要回到内核这和函数调用的底层机制有关。函数调用的本质是压栈。调用 func地址假设是 100要先压入返回地址、形参、临时变量。调用完把返回地址 pop 给 pc 指针CPU 就接着 func 后面的代码跑。有个经典的攻击手法正好利用这一点假设有个 mybug 函数把 mybug 的地址强行写到 func 的返回地址里func 一返回直接跳去执行 mybug——在 main 和 func 之间硬插了一个函数调用。信号的捕捉流程用的是同一招OS 把 sigreturn 这个系统调用的地址埋在 handler 执行完的返回处handler 一结束自然触发 sigreturn重新陷入内核再由内核返回主流程。这就解释了为什么执行完自定义动作能自动回到内核。1.3 纯死循环是怎么进入内核的写个while(1)不调任何系统调用CtrlC 照样杀得掉。它没调系统调用怎么进的内核只要是进程就会被调度调度就有时间片。时间片到了OS 把进程从 CPU 上强制剥离——这本身就是切入内核的过程。进程在调度中周期性、高频地进出内核不是一直执行用户代码也不是一直执行内核代码而是执行一段用户代码、执行一段内核代码交替进行这是 OS 决定的。所以 CtrlC 杀掉 while(1)信号先记在 pending 里时间片一到进程切入内核切回来之前检查信号递达终止。二、sigaction比 signal 更完整的捕捉接口intsigaction(intsignum,conststructsigaction*act,structsigaction*oldact);核心作用同 signal但功能更多还能处理实时信号。act是用户填充的内核数据结构oldact是输出型参数把历史上对特定信号的处理动作带回来方便将来恢复。structsigaction{void(*sa_handler)(int);// 自定义捕捉方法void(*sa_sigaction)(int,siginfo_t*,void*);// 实时信号的处理函数sigset_t sa_mask;// 信号集额外要屏蔽的信号intsa_flags;// 选项本文都设 0void(*sa_restorer)(void);};sa_handler 赋SIG_IGN表示忽略、赋SIG_DFL表示默认、赋函数指针就是自定义——这也是个回调函数不被 main 调用而被系统调用参数就是信号编号所以一个函数能处理多种信号。2.1 处理期间当前信号自动被屏蔽当某个信号的处理函数被调用时内核自动将当前信号加入进程的信号屏蔽字block 表置 1处理函数返回时自动恢复——防止 handler 还没执行完同类信号又进来把 handler 递归调用一遍。如果还希望处理期间额外屏蔽别的信号用 sa_mask 字段说明返回时同样自动恢复。demo 验证voidhandler(intsignum){std::couthello signal:signumstd::endl;while(true){// 不断打印 pending 表sigset_t pending;sigpending(pending);for(inti31;i1;i--){if(sigismember(pending,i))std::cout1;elsestd::cout0;}std::coutstd::endl;sleep(1);}exit(0);}intmain(){structsigactionact,oact;act.sa_handlerhandler;sigemptyset(act.sa_mask);act.sa_flags0;sigaction(SIGINT,act,oact);// 捕捉 2 号信号while(true){std::couthello worldgetpid()std::endl;sleep(1);}return0;}进了 handler 后再狂按 CtrlC第二次及以后的 2 号信号全部 pending——当前正在处理的信号被自动屏蔽了Ubuntu20.04、CentOS7 上均验证。sa_mask 的用法——处理 2 号期间把 3、4 号也屏蔽sigemptyset(act.sa_mask);sigaddset(act.sa_mask,3);sigaddset(act.sa_mask,4);三、操作系统是怎么运行的这部分是信号的地基。信号处理挂在内核态→用户态切换上那切换本身从哪来答案中断。3.1 OS 怎么知道键盘上有数据了外部设备在硬件上直接和CPU 的针脚连接设备一旦就绪按下、回车就向特定针脚发硬件中断。CPU 针脚可以和外设做信号沟通外设通过触发高低电平通知 CPU。但外设不可能每个都直接连 CPU——两者速度差几何倍而且 CPU 引脚数量有限。所以中间有个中断控制器全部外设接入中断控制器控制器只用少数引脚连 CPU。把中断控制器想成一个多插槽设备外设接在插槽上某外设就绪就给对应插槽发高电平控制器识别出是哪个针脚被点亮把针脚编号写进自己的寄存器再主动给 CPU 特定针脚发高电平。CPU 知道有设备准备好了但不知道是谁于是读中断控制器的寄存器拿到中断号——由此确定是哪个设备。顺便说一句外设里也有寄存器CPU 向磁盘写数据指令in发给磁盘的控制器控制寄存器存指令、地址寄存器存要访问的位置、数据寄存器存内容CPU 访问内存也一样先把物理地址写进内存的地址寄存器内存据此定位。3.2 中断向量表硬件事件的函数指针数组CPU 知道哪个设备就绪还没完——CPU 不能执行代码只有软件知道怎么处理数据。所以 OS 在自己内部维护一张中断向量表一个函数指针数组数组下标就是中断号指向各类中断处理方法。CPU 拿着中断号查表找到方法入口执行中断处理比如读键盘数据。OS 不需要轮询外设外设准备好了会叫我。中断向量表本身就是 OS 的一部分开机就加载进内存了。看到这里是不是觉得眼熟发中断 —— 发信号 保存中断号 —— 记录信号pending 中断号 —— 信号编号 处理中断 —— 处理信号handler 外部设备 —— 中断源/信号源 中断被屏蔽 —— block 表信号是纯软件的但本质是用软件模拟硬件中断——信号的思想来源就是硬件中断。3.3 没有中断时OS 在干嘛什么都不做OS 是暂停的。一直闲着太尴尬所以在 OS 的中断向量表里注册一个中断服务——进程调度在硬件上引入时钟源以固定频率向 CPU 发中断。操作系统就在硬件时钟中断的驱动下进行调度。OS 就是一个基于中断工作的软件本质是一个死循环——需要什么功能就往中断向量表里加方法剩下的躺平等中断。后来发现时钟源放外面会和外设抢中断控制器、硬件触发效率也低干脆把时钟源集成进 CPU 内部没有外设中断时CPU 自己按固定间隔触发时钟中断。CPU 由此有了主频这个概念——频率就是中断触发的频率。主频为什么越快 CPU 越快主频可以作为 OS 调度执行速度的参考之一这就是答案。时间片是什么时钟中断驱动的调度节拍。3.4 软中断软件也能触发这套逻辑外部硬件中断要硬件触发。有没有可能因为软件原因也触发有。CPU 把除 0 这类错误规定成由 CPU 内部触发的中断一旦检测到除 0 错误CPU 自己生成中断号OS 停下来走中断处理——比如给目标进程发信号。野指针也在 CPU 内部转成硬件中断。所有异常都会被转成中断OS 由此知道硬件出异常了系统自动注册了对应的异常处理方法。缺页中断也在中断向量表里虚拟地址合法、页表却没建映射物理页不存在触发缺页中断处理方法重新申请物理空间、构建映射。所以在虚拟地址空间申请空间不一定立刻在物理内存开辟。注意区分除 0 是我们写的代码导致硬件先出错硬件触发的中断而 CPU 还提供了主动陷入内核的指令——x86 的int、x86_64 的syscall执行int 0x80就主动触发一次中断流程。3.5 系统调用的完整链路CPU 是硬件但有自己的指令集C/C 编译成二进制本质就是指令集 数据。Linux 把所有系统调用写进一张系统调用表sys_call_table内核里的函数指针数组每个系统调用有唯一下标——系统调用号。在中断表对应下标处注册方法1. 获取系统调用号 2. 调用系统调用方法。用户层以 open 为例move eax, 5 # 把系统调用号 5 放进寄存器 eax int 0x80 # 触发软中断 → CPU 查中断向量表执行 int 80 的方法 → 从约定寄存器 eax 拿到系统调用号 → 索引 sys_call_table调用对应方法 → 返回值经寄存器带回用户层那我们平时调 open 怎么没写过 int 0x80因为 OS 根本不提供系统调用接口OS 只提供系统调用号。我们用的 open、fork 全是 glibc 封装的。glibc 里#define SYS_ify(syscall_name) __NR_##syscall_name这个宏把系统调用名转成调用号SYS_ify(open)展开为__NR_open。系统调用号不是 glibc 提供的是内核提供的内核提供入口man 2 syscall、汇编级软中断命令和头文件让上层语言的设计者完成封装。命名习惯故意的int 0x80/syscall叫陷阱Trap——没有异常是故意陷入内核除 0、野指针这种叫异常Exception。缺页异常这个名字的由来也在这。四、用户态和内核态系统调用的过程也是在进程地址空间上进行的所有函数调用都是地址空间之间的跳转。32 位下地址空间分两半0-3G 用户空间3-4G 内核空间。OS 也是软件也在内存里1G 的内核空间同样要映射到物理内存——内核页表。内核页表和用户页表的关键区别每个进程都有自己 0-3G 的数据用户页表有多份而 OS 的代码数据只有一份每个进程都把同一份 OS 映射到自己 3-4G 的内核空间——内核页表全系统只有一份所有进程共享。两条结论无论进程如何调度我们总能找到 OS——每个进程的内核空间都指向同一份 OS那用户随便拿个 3-4G 的虚拟地址不就能访问内核数据了不行——OS 不相信任何人。为了保护自己引入用户态和内核态用户态以用户身份只能访问自己的 [0-3GB] 内核态以内核身份允许通过系统调用访问 OS 的 [3-4GB]硬件上怎么区分CPU 内部的代码段寄存器cs低 2 个比特位记录当前模式0 是内核态3 是用户态称作CPL当前权限级别。用户态下访问 3-4G 的地址CPU 直接终止进程。执行int 0x80时cs 指向 OS 代码段、权限位 3→0这就是陷入内核——但不能直接拿地址访问任意内核代码必须带系统调用号否则视为非法操作。五、可重入函数经典事故现场main 调用 insert 向链表 head 插节点 node1插入分两步第一步p-next head刚做完第一步硬件中断使进程切到内核回用户态前检查到有信号待处理于是进 sighandler——handler 里也调用 insert 插 node2两步都做完了。回到 main 的 insert 继续第二步。结果插了两个节点链表上只剩一个——另一个节点丢了内存泄漏。像这样函数被不同的控制流程调用main 执行流、handler 执行流第一次调用还没返回就再次进入称为重入。insert 访问全局链表可能因重入造成错乱——不可重入函数。反之只访问自己局部变量或参数的函数是可重入Reentrant函数。为什么访问局部变量没事局部变量在各自栈帧里两个执行流各用各的。大部分函数都是不可重入的符合以下条件之一就是调用了 malloc 或 free——malloc 用全局链表管理堆调用了标准 I/O 库函数——标准库的很多实现以不可重入方式使用全局数据结构。可重入和不可重入描述的是函数的特点不是优缺点。六、volatile信号引出的 C 语言关键字看个 demointflag0;voidhandler(intsignu){std::cout更改全局变量flag- 1std::endl;flag1;}intmain(){signal(2,handler);while(!flag);// main 里并不修改 flagstd::coutprocess quit normalstd::endl;return0;}预期CtrlC 后 handler 把 flag 改成 1循环退出。但在高优化级别下main 循环可能永远退不出去。为什么先补一个背景所有变量都在物理内存上CPU 计算分三步——把数据从内存加载到 CPU、在寄存器里运算、需要时写回内存。编译器优化时如果看到 flag 在 main 里只读不写就自作主张把 flag 缓存进寄存器之后循环只查寄存器、不再访存。而编译器无法识别执行流的概念——它看不到 flag 和 handler 的关系不知道 handler 会异步修改 flag。gcc 的优化级别-O0不优化默认、-O1常规、-O2/-O3更高。O1 下的现象循环卡死再按一次 CtrlC 打印1-1。解释handler 里读的是内存里真实的 flag第一次 CtrlC 把内存改成 1第二次再按内存已经是 1所以打印 1-1而 main 循环读的是寄存器里的旧副本 0——handler 看内存、main 看缓存副本两者不同步寄存器把变量的真实情况挡住了内存不可见了。O2 下的现象程序直接输出process quit normal跑完退出连 CtrlC 都不用按。因为新版本 GCC 能识别 signal 这个库函数知道注册信号处理器会异步修改全局变量于是放弃激进优化。老版本 gcc 才会直接优化成 while(1) 死循环。解法volatile。volatileintflag0;volatile 的作用保持内存的可见性。告知编译器被它修饰的变量不允许被优化到寄存器对该变量的任何操作都必须在真实内存中进行。七、SIGCHLD子进程退出的通知机制之前讲进程时说过用 wait/waitpid 清理僵尸进程阻塞等待——父进程被卡住干不了自己的活非阻塞轮询——父进程要一边干活一边惦记着问。两种都不优雅。其实子进程终止时会给父进程发 SIGCHLD 信号默认处理是忽略。父进程自定义这个信号的处理函数在 handler 里调 wait 清理——子进程终止会通知父进程父进程只管专心干自己的事。7.1 先验证voidSay(intnum){std::coutfather get a signal:numstd::endl;}intmain(){signal(SIGCHLD,Say);// 父进程捕捉pid_t idfork();if(id0){std::coutI am child,exitstd::endl;sleep(3);exit(3);}waitpid(id,nullptr,0);std::coutI am father,exitstd::endl;return0;}子进程退出父进程确实收到了 SIGCHLD17 号。7.2 用信号回收所有子进程handler 里循环 waitpid注意三个返回值的分支voidWaitall(intnum){while(true){// -1 表示任意一个子进程pid_t nwaitpid(-1,nullptr,WNOHANG);// waitpid 默认阻塞没有子进程死亡父进程就挂在这什么都干不了// 有 10 个子进程 6 个退出 4 个没退阻塞版会一直卡住// 所以用 WNOHANG非阻塞// n 0回收成功继续循环把退出的都收掉// n 0没有已退出的子进程还活着收工if(n0)break;elseif(n0){std::coutwaitpid errorstd::endl;break;}}std::coutfather get a signal:numstd::endl;}7.3 SIG_IGN 的历史特例由于 UNIX 的历史原因还有另一招父进程调用 sigaction 把 SIGCHLD 置为 SIG_IGNfork 出的子进程终止时会被内核自动清理不产生僵尸进程也不通知父进程。这是特例对 Linux 可用但不保证其他 UNIX 系统通用。疑点来了SIGCHLD 默认处理本来就是忽略SIG_IGN为什么自己再设一次 SIG_IGN 表现就完全不同因为父进程对 SIGCHLD 的默认处理是 SIG_DFL只是这个默认动作的具体内容恰好是忽略。默认忽略和显式 SIG_IGN 走的是两套内核逻辑处理方式行为子进程退出结果能否 wait 到SIG_DFL默认收到信号直接丢弃产生僵尸进程✅ 可 wait 到 pid 和退出码signal(SIGCHLD, SIG_IGN)显式忽略内核特殊处理不产生僵尸❌ waitpid 返回 -1自定义 handlerWaitall收到信号进回调handler 里 waitpid 回收✅ 拿到退出状态关键同样是忽略信号一个是出厂默认 SIG_DFL一个是手动 SIG_IGN内核两套逻辑。信号都被丢弃但子进程资源的处理完全不同。SIG_DFL 不等于 SIG_IGN。总结这一篇把信号的地基打通了信号处理的时机 内核态返回用户态时的检查点 ↓ 为什么会回用户态调度、时间片、系统调用 ↓ 什么驱动调度时钟中断 ↓ 中断谁来管中断控制器 中断向量表 ↓ 软件怎么触发int 0x80 / syscall陷阱 异常除0/缺页 ↓ 用户态内核态怎么隔离cs 寄存器 CPL 0/3 3G/1G 地址空间自定义 handler以用户身份执行靠埋 sigreturn 返回内核sigaction 处理期间自动屏蔽当前信号sa_mask 额外加屏蔽信号的本质是用软件模拟硬件中断可重入handler 里别碰 malloc/标准 IOvolatile别让编译器把变量缓存进寄存器SIGCHLDSIG_DFL 和显式 SIG_IGN 是两套内核逻辑显式忽略不产生僵尸。三篇到这里齐了产生 → 保存 → 捕捉。把三张表pending/block/handler和一条主线OS 何时检查信号记住Linux 信号这块就通了。有收获点个赞评论区聊聊你在 handler 里踩过的坑——比如在 handler 里 printf 结果丢日志的都来说是为什么。资源分享【Linux】CtrlC 到底做了什么从键盘、kill 到 alarm一次讲清信号的产生【Linux】共享内存为什么快System V 的 key、shmget、shmat 与进程通信实战【Linux】两个毫无关系的进程怎么通信命名管道 FIFO 从原理到 Server/Client 实战