目标1.了解什么是用户态什么是内核态2.可重入函数的认识3.volatile的认识4.知道SIGCHLD信号的作用一.用户态和内核态通过对信号的多方面认识我们已经了解了什么是信号信号保存和信号捕获那么本篇就要解决一些细节问题首先看图解释1.用户空间与内核空间虚拟地址空间中0-3GB是属于用户空间的里面包含代码区 全局数据区 堆区 共享区 栈区 命令行参数和环境变量然后3GB-4GB是属于内核空间的里面包含内核代码 系统调用表 各种异常处理方法 中断处理 调度器 文件系统 内核页表 进程控制块 PCB / task_struct IDT 中断描述符表示意图2.内核区我们知道每个进程都有自己的虚拟地址空间例如有两个A进程 和 B进程进程 A 的虚拟地址空间 ┌────────────────────┐ │ 用户区 A │ ← A 自己的代码、堆、栈 ├────────────────────┤ │ 内核区 │ ← 映射操作系统内核 └────────────────────┘ 进程 B 的虚拟地址空间 ┌────────────────────┐ │ 用户区 B │ ← B 自己的代码、堆、栈 ├────────────────────┤ │ 内核区 │ ← 映射操作系统内核 └────────────────────┘A 和 B 的用户区空间是互相隔离但它们的内核区通常映射到同一份物理内存也就是同一套操作系统内核代码和核心数据结构所以进程 A 的内核区 ─┐ ├── 映射到同一个操作系统内核 进程 B 的内核区 ─┘结论操作系统无论怎么切换进程都能找到同⼀个操作系统换句话说操作系统系统调用方法的执行 是在进程的地址空间中执行的示意图3.为什么切换进程后还能找到同一个操作系统进程切换时CPU 会切换页表。比如从进程 A 切到进程 B进程 A 页表 → 进程 B 页表切换页表后用户区映射变了A 的用户区消失换成 B 的用户区但是内核区映射通常保持一致A 的内核区地址 0xFFFF... → 内核物理内存 B 的内核区地址 0xFFFF... → 同一份内核物理内存所以虽然换了进程内核在虚拟地址空间中的位置仍然一样。可以理解成每个进程都有一本自己的地图。 地图下半部分每个人自己的家不一样。 地图上半部分国家机关的位置一样。 无论你拿的是谁的地图都能找到同一个政府大楼。这里用户区 每个进程自己的家 内核区 操作系统内核 页表 地址地图 系统调用 去政府办事注意用户页表在一个进程里面可以存在多份但是内核页表系统提供一份由所有的进程共享。4.身份切换我们已经知道了用户空间和内核空间都在同一个虚拟地址空间上如果用户随便拿一个虚拟地址在[3GB4GB]的范围内的那用户不就可以随便访问内核的代码和数据了吗答案这个不被允许的操作系统为了保护自己不相信任何人必须采用系统调用的方式访问用户态以用户身份访问[03GB]内核态以内核身份通过系统调用的方式访OS[3GB,4GB]也就是说这个地址是可以看到的但是内容是没有权限访问的。但是在操作系统中用户/OS是如何知道当前是处于用户身份还是内核态、身份的呢CPU 里有一个寄存器叫CS表示CPU 当前正在执行哪一段代码CS 里面不仅有代码段信息还带着权限级别。这个权限级别就是CPLCPL CS 的低 2 位通常CPL 0 → 内核态 CPL 3 → 用户态所以可以这样理解CPU 看当前 CS 的权限级别 ↓ 如果 CPL 3就认为当前在用户态 ↓ 如果 CPL 0就认为当前在内核态所以当用户程序执行系统调用比如syscall用户态程序 ↓ 执行 syscall ↓ CPU 自动切换到内核入口 ↓ CS 被切换成内核代码段 ↓ CPL 从 3 变成 0于是 CPU进入内核态。流程图二.可重入函数1.概念先看一张图片解释main函数调用insert函数向⼀个链表head中插⼊节点node1插⼊操作分为两步刚做完第⼀步的 时候因为硬件中断使进程切换到内核再次回用户态之前检查到有信号待处理于是切换到 sighandler函数但是sighandler也调用insert函数向同⼀个链表head中插入节点node2插入操作的两步都做完之后从sighandler返回内核态再次回到用户态就从main函数调用的insert函数中继续 往下执行先前做第⼀步之后被打断现在继续做完第⼆步那么结果是main函数和sighandler先后向链表中插⼊两个节点而最后只有⼀个节点真正插⼊链表中了但是这也导致了一个问题当我们销毁链表时不就造成内存泄漏了吗像上例这样insert函数被不同的控制流程调用有可能在第⼀次调⽤还没返回时就再次进入该函 数这称为重入insert函数访问⼀个全局链表有可能因为重入而造成错乱像这样的函数称为不可重⼊函数反之如果⼀个函数只访问自己的局部变量或参数则称为可重⼊函数。注意大部分的函数都是不可重入的也不建议信号处理函数里不要调用这种非可重入函数。信号处理函数应该尽量简单些。2.例子这里举个更详细的例子帮助理解假设insert是头插法代码类似void insert(Node **head, Node *node) { node-next *head; // 第一步 *head node; // 第二步 }初始链表head → A → B → Cmain准备插入node1。执行第一步后node1-next head;此时状态是node1 → A → B → C head → A → B → C注意node1 还没有真正挂到 head 上。然后发生信号进入sighandler它也调用insert(head, node2);信号处理函数完整执行完两步node2-next head; head node2;链表变成head → node2 → A → B → C node1 → A → B → C然后信号处理函数返回main继续执行它刚才没做完的第二步head node1;于是链表变成head → node1 → A → B → C这时node2怎么样了node2 → A → B → C但是已经没有任何指针从head指向node2了。所以结果是node2 曾经插入成功但后来被 main 中断前未完成的插入操作覆盖掉了。node2 从链表中丢失如果程序也没有别的指针保存它那它就泄漏了。也就是说销毁链表时head → node1 → A → B → C只能释放node1、A、B、C。但是node2已经不在链表里了node2 变成孤儿节点如果没有其他地方记录node2就释放不到它造成内存泄漏。流程图三.volatile关键字该 关键字在C当中我们可能有所涉猎今天我们站在信信角度重新理解⼀下首先写个代码#include stdio.h #include signal.h int flag 0; void handler(int sig) { printf(chage flag 0 to 1\n); flag 1; } int main() { signal(2, handler); while(!flag) { sleep(1); printf(process quit normal\n); } return 0; }makefile中test:test.cpp g -o $ $^ -stdc11 -g .PHONY:clean clean: rm -rf test标准情况下按下 ctrlc 2号信号被捕捉执⾏自定义动作修改 flag 1 while条件不满⾜退出循环进程退出。但是在优化情况下键入 CTRL-C 2号信号被捕捉执行⾃定义动作修改 flag 1 但是while条 件依旧满足进程继续运行但是很明显flag肯定已经被修改了但是为何循环依旧执行这是因为优化后编译器可能把flag的值放到某个寄存器里反复使用。比如原来逻辑是while (!flag) { }未优化时可能类似每次循环 去内存读取 flag 判断 flag 是否为 0优化后编译器可能认为循环体里没有修改flag所以flag的值不会变。于是可能变成一开始 从内存读取 flag 到寄存器 后面循环 一直判断寄存器里的值 不再反复读取内存中的 flag也就是类似内存中的 flag已经被信号处理函数改成 1 寄存器中的 flag 副本还是 0 while 判断用的是寄存器里的 0 所以循环继续执行所以问题不是handler没有修改成功而是main 循环没有重新从内存里取最新的flag。所以加上volatile后volatile int flag 0;意思就是告诉编译器这个变量可能会被当前代码看不见的地方修改比如信号处理函数、中断、硬件等所以每次使用它都要从内存重新读取不要只用寄存器缓存值。示意图四.SIGCHLD信号1.基本认识在进程一章中我们介绍了使用wait和waitpid函数处理僵尸进程的方法。父进程可以选择两种方式一种是阻塞等待子进程结束另一种是非阻塞地轮询检查是否有子进程需要清理。第一种方式会导致父进程无法执行自身任务而第二种方式虽然不会阻塞父进程但需要定期轮询检查增加了程序实现的复杂度。其实当子进程终止时它是会向父进程发送SIGCHLD信号。该信号的默认处理方式是忽略但父进程可以自定义其处理函数。这样父进程就能专注于自身任务无需主动关注子进程。子进程终止时会主动通知父进程父进程只需在信号处理函数中调用wait清理子进程即可。例如我们写个父进程通过fork()创建子进程后子进程调用exit(2)终止运行。此时父进程会收到SIGCHLD信号并在其自定义的信号处理函数中调用wait()获取子进程的退出状态最终打印该状态信息的代码#include stdio.h #include stdlib.h #include signal.h #include sys/types.h #include sys/wait.h #include unistd.h void handler(int sig) { pid_t id; while ((id waitpid(-1, NULL, WNOHANG)) 0) { printf(wait child success: %d\n, id); } printf(child is quit! %d\n, getpid()); } int main() { signal(SIGCHLD, handler); pid_t cid fork(); if (cid 0) { // child printf(child: %d\n, getpid()); sleep(3); exit(1); } while (1) { printf(father proc is doing some thing!\n); sleep(1); } return 0; }感兴趣的可以自己验证一下。2.细节处理1.那么为什么SIGCHLD信号默认处理动作是忽略呢因为很多父进程并不需要立刻处理子进程退出事件。如果每个子进程退出都强制打断父进程父进程的逻辑会很混乱。所以系统设计成SIGCHLD 默认不打扰父进程 父进程如果关心子进程退出就自己注册 handler 或调用 wait/waitpid也就是说默认忽略是为了不让子进程退出事件默认干扰父进程2.SIGCHLD信号默认处理方式是忽略和利用signal将这个 SIGCHLD的处理动作置为SIG_IGN的区别1.默认处理方式是忽略也就是你什么都不写// 没有 signal(SIGCHLD, ...)子进程退出时子进程退出 ↓ 内核给父进程发送 SIGCHLD ↓ 父进程默认忽略这个信号 ↓ 但是子进程仍然会变成僵尸进程 ↓ 需要父进程 wait / waitpid 回收所以默认忽略 SIGCHLD 只是父进程不会被这个信号打断或终止 不代表子进程自动回收。2.显式设置为 SIG_IGN也就是你写signal(SIGCHLD, SIG_IGN);这表示你明确告诉操作系统我不关心子进程退出状态不需要 wait 获取退出码。在 Linux 中通常效果是子进程退出 ↓ 内核不给它保留僵尸状态 ↓ 子进程自动被回收 ↓ 父进程之后 wait / waitpid 可能会失败也就是说显式设置 SIGCHLD 为 SIG_IGN 不仅忽略 SIGCHLD 还可能让子进程退出后自动回收不产生僵尸进程。注意系统默认的忽略动作和用户用signal函数自定义的忽略通常是没有区别的但这是⼀个特例但是此方法对于Linux可用但不保证在其它UNIX系统上都可用。示意图总结用户态和内核态体现的是 CPU 权限身份的不同信号会打断正常执行流因此信号处理函数中要注意可重入问题而volatile解决的是编译器优化导致变量不被重新读取的问题SIGCHLD则用于通知父进程子进程状态变化帮助父进程回收子进程避免僵尸进程。