作者: andylin02学习章节: 第 8 章 异常控制流关键词 异常控制流异常中断陷阱系统调用进程上下文切换信号非本地跳转Shell Lab引言当程序“失控”时谁在掌控局面“现代系统通过使控制流发生突变来对系统状态变化做出反应这些突变称为异常控制流Exceptional Control FlowECF。” —— CSAPP 作者在第 1-7 章中我们关注的都是程序的正常运行指令按顺序执行函数调用和返回形成有规律的栈帧。然而现实世界的程序并不总是这样“循规蹈矩”。当你在终端按下CtrlC时程序是如何响应的当程序发生除零错误或访问非法内存时操作系统是如何介入的当你运行多个程序时CPU 是如何在它们之间切换的答案就是异常控制流。ECF 是操作系统为应用提供的一种访问处理器资源之外的能力对应于嵌入式和 CPU 等硬件的中断概念。系统调用、进程管理、并发、I/O 访问都属于异常控制流。本章结构速览8.1 异常异常处理机制、异常类别中断/陷阱/故障/终止8.2 进程逻辑控制流、并发、上下文切换8.3 进程控制fork、exit、wait、execve8.4 信号信号发送与接收、信号处理、阻塞与未决信号8.5 非本地跳转setjmp与longjmp8.6 配套实验Shell Lab—— 实现支持作业控制的 shell一、异常——ECF 的硬件基础1.1 什么是异常异常是异常控制流的一种形式它一部分由硬件实现一部分由操作系统实现。异常就是控制流中的突变用来响应处理器中的一些变化。┌─────────────────────────────────────────────────────────────────────┐ │ 异常处理流程 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 当前指令 Icurr ──→ 处理器检测到事件 ──→ 查找异常表 ──→ 异常处理程序 │ │ │ │ │ │ │ ├──→ 返回 Icurr │ │ │ ├──→ 返回 Inext │ │ │ └──→ 终止程序 │ │ │ │ │ └── 异常处理程序运行在**内核模式**对系统资源有完全访问权限 │ │ │ └─────────────────────────────────────────────────────────────────────┘1.2 异常表与异常号系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。其中一些号码是由处理器的设计者分配的如被零除、缺页、内存访问违例其他号码是由操作系统内核的设计者分配的如系统调用和来自外部 I/O 设备的信号。系统启动时操作系统分配和初始化一张称为异常表的跳转表使得表目 k 包含异常 k 的处理程序的地址。1.3 四类异常异常分为四类中断、陷阱、故障、终止。其中中断属于异步异常其他三种属于同步异常。类别同步/异步返回位置示例中断异步下一条指令I/O 设备信号、定时器陷阱同步下一条指令系统调用syscall故障同步当前指令可修复时缺页错误、保护错误终止同步不返回硬件错误① 中断Interrupt中断是异步发生的是来自处理器外部的 I/O 设备的信号的结果。┌─────────────────────────────────────────────────────────────────────┐ │ 中断处理流程 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 处理器执行 Icurr 中断处理程序 │ │ ┌─────────┐ ┌─────────┐ │ │ │ 取指 │──→ 检测中断引脚 ──→ │ 处理 │ ──→ 返回 Inext │ │ │ 译码 │ 异步 │ 中断 │ │ │ │ 执行 │ └─────────┘ │ │ └─────────┘ │ │ │ │ 特点中断处理程序与进程没有任何关系既没有参数也没有返回值 │ │ │ └─────────────────────────────────────────────────────────────────────┘② 陷阱Trap与系统调用陷阱是有意的异常是执行一条指令的结果。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口叫做系统调用。// 汇编语言中进行系统调用movq $SYS_write,%rax// 系统调用号放在 %raxmovq $1,%rdi// 第一个参数文件描述符 stdoutmovq $msg,%rsi// 第二个参数消息地址movq $len,%rdx// 第三个参数消息长度syscall// 触发陷阱进入内核模式系统调用 vs 普通函数调用普通函数运行在用户模式系统调用运行在内核模式。普通函数只能访问用户空间的数据系统调用可以访问系统所有资源。③ 故障Fault故障由错误情况引起它可能被故障处理程序修正。如果修正成功处理器重新执行当前指令而非下一条。经典案例缺页错误Page Fault┌─────────────────────────────────────────────────────────────────────┐ │ 缺页故障处理流程 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 程序访问虚拟地址 → MMU 查页表 → 页不在内存 → 触发缺页故障 │ │ │ │ │ ↓ │ │ 缺页处理程序将页面从磁盘加载到内存 → 更新页表 │ │ │ │ │ ↓ │ │ 返回并**重新执行**当前指令 → 这次就能命中 │ │ │ └─────────────────────────────────────────────────────────────────────┘段故障Segmentation Fault通常因为程序访问了受限的内存块导致的如读未定义的虚拟内存写只读内存等。这种故障无法修复会导致程序终止。④ 终止Abort终止是发生了不可恢复的致命错误通常是硬件错误异常处理程序直接终止程序。1.4 同步 vs 异步同步异常由执行当前指令直接产生与指令执行有因果关系。程序可以预期并在一定程度上控制这类异常。异步异常由外部事件触发与当前指令无关。程序无法预测其发生时机。Linux/x86-64 系统中的异常示例异常号名称类型0除法错误故障13一般保护故障故障14缺页错误故障18机器检查终止0x80系统调用陷阱/中断二、进程——ECF 的操作系统抽象“进程提供给应用程序两个假象1、独立的逻辑控制流每个程序似乎独占 CPU2、私有的空间地址每个程序似乎独占内存”2.1 逻辑控制流与并发逻辑控制流对于一个进程而言某段时间上执行该进程的整个控制流称为逻辑控制流。并发流一个逻辑流的执行在时间上与另一个流重叠称为并发流。多个流并发的执行的现象被称为并发。并发≠ 并行。并发是逻辑上的同时运行并行是物理上的同时运行。并行流必须是在不同的处理器或计算机上同时运行。并行流一定是并发流反之不一定。多任务multitasking一个进程和其他进程轮流运行每个进程执行它的控制流的一部分的每一段时间叫做时间片。2.2 私有地址空间进程为每个程序提供一种假象它似乎独占地使用系统的内存。每个进程看到的都是虚拟内存拥有独立的地址空间。2.3 用户模式与内核模式处理器通过一个控制寄存器中的一个模式位来区分用户模式和内核模式。模式权限如何进入用户模式不能执行特权指令不能直接访问内核空间初始状态内核模式可以执行任何指令访问任何内存位置通过异常中断、陷阱、故障进程从用户模式变为内核模式的唯一方法是通过异常如中断、故障、系统调用。2.4 上下文切换操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。┌─────────────────────────────────────────────────────────────────────┐ │ 上下文切换 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 进程 A 内核 进程 B │ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │ 运行 │──→ 系统调用 ──→ │保存 │ │ │ │ │ │ │ │上下文│ │ │ │ │ │ │ │ A │ │ │ │ │ │ │ │ │──→ 恢复上下文 B ──→ │ 运行 │ │ │ │ │ │ │ │ │ │ │ │ │←── 定时器中断 ──→ │保存 │ │ │ │ │ │ │ │上下文│ │ │ │ │ │ │ │ B │ │ │ │ │ │ │ │ │──→ 恢复上下文 A ──→ │ │ │ │ └─────┘ └─────┘ └─────┘ │ │ │ │ 上下文包含寄存器、用户栈、内核栈、页表、进程表条目、文件表等 │ │ │ └─────────────────────────────────────────────────────────────────────┘调度时机内核使用调度器对进程做上下文切换切换时机由调度器决定比如时间片用完、sleep、请求磁盘数据后等。三、进程控制——系统调用的实战3.1 获取进程 ID#includesys/types.h#includeunistd.hpid_tgetpid(void);// 返回调用进程的 PIDpid_tgetppid(void);// 返回父进程的 PID3.2 创建进程fork()fork()创建一个与父进程几乎完全相同的子进程。这个几乎完全相同的特性正是 fork 最容易被误解的地方。#includeunistd.h#includestdio.hintmain(){intx1;pid_tpidfork();if(pid0){// 子进程printf(Child: x %d\n,x);}elseif(pid0){// 父进程printf(Parent: x %d\n,--x);}return0;}fork 的关键特性特性说明调用一次返回两次父进程返回子进程 PID子进程返回 0写时复制COW子进程不立即复制父进程内存而是共享物理内存直到任一进程修改数据执行流分离fork 后父子进程独立执行调度顺序由 OS 决定代码位置相同子进程从 fork 调用处继续执行不是从 main 开始使用模式fork()execve()是创建新进程的经典模式——fork()负责创建进程execve()负责替换程序。3.3 进程终止exit()进程终止的原因收到一个信号该信号的默认行为是终止进程从主程序返回main函数返回等价于调用exit调用exit函数#includestdlib.hvoidexit(intstatus);// 终止进程status 为退出状态3.4 回收子进程wait() 与 waitpid()当一个进程终止时它会成为僵尸进程zombie直到被父进程回收。如果父进程在回收它的僵尸子进程前就终止了则由init进程所有进程的祖先回收它们。#includesys/wait.h#includeunistd.hpid_twait(int*status);// 等待任意子进程pid_twaitpid(pid_tpid,int*status,intoptions);// 等待指定子进程waitpid 参数说明pid 0等待特定 PID 的子进程pid -1等待任意子进程与 wait 类似optionsWNOHANG不阻塞、WUNTRACED等待停止的进程等3.5 让进程休眠sleep()#includeunistd.hunsignedintsleep(unsignedintsecs);// 休眠指定秒数intpause(void);// 休眠直到收到信号3.6 加载并运行程序execve()execve是exec家族中最基础的系统调用其他exec函数都是基于它的封装。#includeunistd.hintexecve(constchar*filename,char*constargv[],char*constenvp[]);filename要执行的程序路径argv参数数组以 NULL 结尾envp环境变量数组以 NULL 结尾关键特性execve调用不返回——它用新程序替换当前进程的整个映像。只有在出错时如找不到文件才会返回。// fork execve 经典模式pid_tpidfork();if(pid0){// 子进程char*argv[]{ls,-l,/,NULL};char*envp[]{PATH/usr/bin,NULL};execve(/bin/ls,argv,envp);perror(execve failed);// 只有出错才会执行到这里_exit(1);}else{// 父进程wait(NULL);// 等待子进程结束}四、信号——软件层面的异常“信号允许进程和内核中断其他进程。信号可以理解为一条消息一个事件。”4.1 信号术语术语含义发送信号内核通过更新目标进程上下文中的某个状态发送一个信号给目标进程接收信号目标进程对信号做出反应执行处理程序、忽略或默认动作未决信号pending发出但没有被接收的信号。一个类型最多只能有一个待处理信号阻塞信号进程可以选择性地阻塞特定信号。被阻塞的信号不会接收直到解除阻塞4.2 Linux 中的常见信号信号编号默认动作触发事件SIGINT2终止键盘CtrlCSIGTSTP20停止键盘CtrlZSIGCHLD17忽略子进程停止或终止SIGKILL9终止无法捕获/忽略SIGSTOP19停止无法捕获/忽略SIGSEGV11终止段故障⚠️SIGKILL和SIGSTOP信号无法被捕获和忽略。4.3 发送信号从内核发送硬件异常除零、段故障键盘中断CtrlC→SIGINTCtrlZ→SIGTSTP从一个进程发送到另一个进程#includesignal.hintkill(pid_tpid,intsig);// 发送信号到指定进程intraise(intsig);// 发送信号到自身发送到进程组使用kill(-pid, SIGINT)可以向进程组中的所有进程发送信号。4.4 接收信号#includesignal.h// 旧版接口不推荐行为因平台而异typedefvoid(*sighandler_t)(int);sighandler_tsignal(intsignum,sighandler_thandler);// 推荐使用 sigaction更可靠的信号处理intsigaction(intsignum,conststructsigaction*act,structsigaction*oldact);sigaction允许指定处理函数信号处理期间要阻塞的信号掩码其他标志如SA_RESTART自动重启被中断的系统调用异步信号安全信号处理函数中只能调用异步信号安全async-signal-safe的函数如write、waitpid不能调用printf、malloc、exit等非安全函数。4.5 阻塞和解除阻塞信号#includesignal.hintsigprocmask(inthow,constsigset_t*set,sigset_t*oldset);// 操作 sigset_t 的辅助函数intsigemptyset(sigset_t*set);// 清空信号集intsigfillset(sigset_t*set);// 填满所有信号intsigaddset(sigset_t*set,intsignum);// 添加信号intsigdelset(sigset_t*set,intsignum);// 删除信号intsigismember(constsigset_t*set,intsignum);// 检查成员how参数值效果SIG_BLOCK将set中的信号加入阻塞集合SIG_UNBLOCK从阻塞集合中移除set中的信号SIG_SETMASK将阻塞集合设置为set4.6 等待信号#includesignal.hintsigsuspend(constsigset_t*mask);// 原子操作设置临时阻塞集并等待信号4.7 信号的生命周期┌─────────────────────────────────────────────────────────────────────┐ │ 信号生命周期 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 事件发生 → 内核发送信号 → 信号变为 pending → 进程接收 → 执行处理 │ │ │ │ │ └── 如果被阻塞 → 信号保持 pending解除阻塞后接收 │ │ │ │ 同一类型的 pending 信号最多只有一个后续同类型信号被丢弃 │ │ │ └─────────────────────────────────────────────────────────────────────┘五、非本地跳转——跨越栈帧的跳转5.1 setjmp 与 longjmp非本地跳转允许程序违反调用/返回栈规则直接从当前函数跳转到另一个函数的某个位置。#includesetjmp.hintsetjmp(jmp_buf env);// 保存当前栈环境返回 0voidlongjmp(jmp_buf env,intval);// 跳转到 setjmp 保存的位置setjmp调用一次返回多次初次调用返回 0longjmp跳转返回vallongjmp从不返回#includestdio.h#includesetjmp.hjmp_buf buf;voiderror_handler(){printf(Error occurred!\n);longjmp(buf,1);// 跳回 main 中的 setjmp}intmain(){if(setjmp(buf)0){// 第一次调用正常执行error_handler();}else{// 从 longjmp 返回处理错误printf(Recovered from error\n);}return0;}5.2 应用场景深度嵌套错误处理避免多层函数返回信号处理程序返回在捕获信号后跳回主程序特定位置C/Java 异常机制的底层实现软件异常使用非本地跳转六、配套实验Shell LabShell Lab 要求学生实现一个支持作业控制的简单 Unix shell 程序完美串联了进程控制、信号处理和进程组管理等核心系统编程概念。6.1 实验要求tsh 应支持以下特性前台/后台执行命令行以结尾则后台运行否则前台运行信号处理CtrlCSIGINT终止前台作业CtrlZSIGTSTP停止前台作业作业标识PID 或 JIDJID 前缀%内建命令quit终止 shelljobs列出所有后台作业fg job将作业移至前台发送SIGCONTbg job将作业移至后台发送SIGCONT僵尸进程回收通过SIGCHLD信号处理6.2 核心函数需要在tsh.c中实现以下函数函数作用eval解析和解释命令行的主例程builtin_cmd识别和处理内建命令quit、fg、bg、jobsdo_bgfg实现fg和bg命令waitfg等待前台作业完成sigchld_handler捕获SIGCHLD信号子进程终止或停止sigint_handler捕获SIGINTCtrlCsigtstp_handler捕获SIGTSTPCtrlZ6.3 关键技术点① fork execve 的 fork-and-exec 模型if((pidfork())0){setpgid(0,0);// 创建新的进程组if(execve(argv[0],argv,environ)0){printf(%s: Command not found\n,argv[0]);exit(1);}}② 进程组管理子进程调用setpgid(0, 0)创建新进程组确保前台进程组只有 shell 本身防止被信号错误影响。③ 同步问题处理fork后addjob前阻塞SIGCHLD信号sigset_tmask;sigemptyset(mask);sigaddset(mask,SIGCHLD);sigprocmask(SIG_BLOCK,mask,NULL);// fork 前阻塞 SIGCHLDif((pidfork())0){sigprocmask(SIG_UNBLOCK,mask,NULL);// 子进程解除阻塞setpgid(0,0);execve(...);}else{addjob(...);// 父进程添加作业sigprocmask(SIG_UNBLOCK,mask,NULL);// 解除阻塞}这避免了子进程在父进程调用addjob之前就终止并发送SIGCHLD的情况。④ 信号处理程序规范在sigaction中使用SA_RESTART自动重启被中断的系统调用信号处理函数中只调用异步信号安全函数write、waitpid不要用printfwaitpid使用WNOHANG和WUNTRACED选项6.4 测试使用提供的sdriver.pl和 16 个trace*.txt文件进行回归测试./sdriver.pl-a-p-ttrace01.txt-s./tsh ./sdriver.pl-a-p-ttrace01.txt-s./tshref# 对比参考输出七、本章知识点思维导图第 8 章 异常控制流 │ ├── 1. 异常硬件/OS 层 │ ├── 异常处理异常表、异常号 │ └── 四类异常 │ ├── 中断异步、I/O 设备 │ ├── 陷阱同步、系统调用 │ ├── 故障同步、可能修复 │ └── 终止同步、不可修复 │ ├── 2. 进程 │ ├── 逻辑控制流与并发 │ ├── 私有地址空间 │ ├── 用户模式 vs 内核模式 │ └── 上下文切换 │ ├── 3. 进程控制 │ ├── fork写时复制、调用一次返回两次 │ ├── exit │ ├── wait / waitpid回收僵尸进程 │ ├── sleep │ └── execve替换进程映像 │ ├── 4. 信号 │ ├── 信号概念发送、接收、pending、阻塞 │ ├── 信号类型SIGINT、SIGTSTP、SIGCHLD 等 │ ├── 发送信号kill、raise │ ├── 接收信号signal、sigaction │ ├── 阻塞信号sigprocmask │ └── 异步信号安全 │ ├── 5. 非本地跳转 │ ├── setjmp / longjmp │ └── 应用场景深度错误处理、异常实现 │ └── 6. Shell Lab ├── 功能要求前台/后台、作业控制、信号处理 ├── 核心函数eval、builtin_cmd、do_bgfg、waitfg、三个 handler └── 关键技术forkexecve、setpgid、信号阻塞八、本章小结第 8 章深入计算机系统的底层控制流机制从硬件异常到操作系统进程再到软件信号构建了一个完整的异常控制流体系✅异常机制理解了中断异步、陷阱、故障、终止四类异常的区别以及系统调用作为陷阱异常的本质✅进程抽象掌握了逻辑控制流、并发、上下文切换等核心概念以及用户模式与内核模式的切换机制✅进程控制系统调用深入理解了fork的写时复制机制、execve的进程映像替换、wait/waitpid的僵尸进程回收✅信号机制掌握了信号的发送、接收、阻塞、未决状态以及异步信号安全的注意事项✅非本地跳转理解了setjmp/longjmp的原理及应用场景✅Shell Lab 实验通过实现支持作业控制的 shell将进程控制和信号处理理论转化为实践本章最核心的三个洞察异常是计算机系统各层次的统一机制硬件用异常处理中断和故障操作系统用异常实现系统调用应用层用信号实现进程间通知forkexecve是 Unix 创建新进程的经典模式fork创建进程execve加载程序——两个系统调用的分离提供了极大的灵活性信号处理是并发编程的入门信号处理程序与主程序并发执行需要同步机制避免竞争条件如 Shell Lab 中的addjob/deletejob同步 下一篇预告下一章我们将进入第 9 章虚拟内存。这一章将深入探讨现代操作系统最核心的内存管理机制虚拟内存的概念为什么每个进程都以为自己独占整个内存地址翻译虚拟地址到物理地址的转换过程MMU、页表、TLB分页机制页表结构、多级页表、缺页异常内存映射mmap如何将文件映射到进程地址空间动态内存分配malloc和free的底层实现隐式空闲链表、显式空闲链表、分离适配垃圾收集保守的标记-清扫垃圾收集器配套实验Malloc Lab—— 实现一个动态内存分配器第 9 章将揭开虚拟内存的神秘面纱解释为什么现代计算机可以同时运行几十个程序而不会互相干扰。敬请期待本文为个人学习笔记仅用于知识分享。如有错误欢迎指正。 点赞 收藏 分享让更多开发者看到这篇深度解析❤️ 如果觉得有用请给个赞支持一下作者