CSAPP Learning#
This document is specially for Chapter 8 of book CSAPP.
异常 Exception#
CPU读取一串线性指令的过程是控制流(Control Flow)作用;每个编号ak对应Ik指令的执行。
Ik地址的不平滑突变可能是正常的jmp跳转,call指令等造成;
但是除此之外就是异常情况的发生,比如系统调用,IO输入输出流,StackOverflow栈溢出,它们会中断当前程序,把程序引向其他位置进行。
C++/Java可以使用 try-catch 语句进行捕获异常处理,这里的Exception和本章重点的操作系统/硬件的Exception有所不同;
在用户层底下操作系统以及硬件方面,有固定的一套异常处理程序,这套指令是只要异常触发就会发生的。
操作系统定义了一张异常跳转表 (Exception Table),在系统启动时分配和初始化:
| Index | Pointer to |
|---|---|
| 0 | Code for Exception Handler 0 |
| 1 | Code for Exception Handler 1 |
| 2 | Code for Exception Handler 2 |
| … | … |
| n-1 | Code for Exception Handler n-1 |
CPU中会存放一个特殊的寄存器:异常表基址寄存器 (Exception Table Base Register),指向这个跳转表初始位置。
从而我们通过register+编号的方式定位到相应的处理程序。
异常处理相当于特殊的Procedure调用。
- 处理器把当前或下一条指令压入栈中
- 处理器还会把一些特殊状态值压入栈中
- 如果是转入系统内核,那么异常处理(包括压栈)不发生在用户层,其指令调用发生在内核栈中。
- 运行在内核态,有对所有系统资源的访问权限。
因为处理异常相当于中断了原本程序的执行。
异常有4种类型 (class):#
中断 Interrupt#
这是唯一一种异步情况,即与当前指令无关。
例如键盘控制器引发的硬件中断,是检测键盘输入状态然后执行相应的处理程序,处理完成后回到原程序指令的下一步。
陷阱 Trap#
它为用户与系统交互提供接口,例如往磁盘读写文件,进行相应的处理程序,处理完成后回到原程序指令的下一步。
故障 Fault#
发生故障时候系统会先尝试修复故障,修复成功回到故障指令地址重新运行这一条故障指令;失败则结束程序。
致命 Abort#
这一般是引发了硬件层面的故障,会直接结束程序。
具体案例#

Divide Error是除法错误,就是使除法器出现问题,例如除数为0。这种时候的处理方法一般是终止程序。General Protection Fault是一般保护错误,一般是程序接触到了不存在或无权访问的内存区域。这种时候的处理方法一般是终止程序。Page Fault是缺页故障,一般系统会先尝试去磁盘中读取缺失的部分进行修复。Machine Check是机器检查,一般反映硬件层面的故障。
这几个都是芯片架构师定义的。
OS-Defined Exceptions#
注意这张表格和上面所说的跳转表**不一样**,这些Number是**系统函数的调用编号**。
汇编中通过 `syscall` 指令来实现系统层面调用。
注意在C语言中`write()` 和 `exit()` 实际上是为 `syscall` 提供**包装**。进程 Process#
区分于程序,进程是一个被创建的实例。
作为一个单个的非实例的程序,我们会假设它
- 自由地占用虚拟内存地址
- CPU自始至终都只在处理它
但对于一个单核处理器而言,常常是多个进程并发执行。
并发不等于并行。
- 并发是指处理器的同一个核一直在ABC等持续的进程中不断切换跳转工作,这个过程中进程ABC并发。
- 并行是指CPU的不同内核同时处理不同进程。
进程有三种状态:
- Running
- Stopped(通过信号(Signal)控制)
- Terminated(终止)引发包括
exit()- main函数返回
- 终止信号
而进程依赖逻辑控制流工作,每个进程都有一个。
上下文 Context#
内核为每一个进程维持一个上下文,以便于进程的挂起和恢复。
内部包括了
- general-purpose registers
- floating-point registers
- program counter
- user’s stack
- status register
- kernel’s stack
- various kernel data structures (页表,进程表,已打开文件的信息表)
内核可以调度进程,即抢占与恢复进程,通过上下文切换实现。
- 保存原来上下文
- 切换新的上下文
- 重定位控制流
这个过程发生在很多个进程并发,或者有需要调用系统函数的情况
但上下文切换花费的成本代价显然非常大。
用户模式和内核模式#
通过Control Register这个寄存器维护模式位,标示当前程序权限。
内核模式可以调用任何内核指令,访问任何内存。
用户权限不能执行特权指令,如调用I/O,停止处理器,改变模式位等。
用户权限只能通过系统调用间接地访问内核的内存代码数据,比如处理异常,用户模式调用内核中的代码,这些内核中的代码在内核模式执行,最后回到用户模式。
子进程与fork()函数#
在父进程中执行 fork() 函数会拉起一个和父进程相同的子进程,所有信息一致,但是存在内存的不同实际位置。
我们该如何区分父进程与子进程同一位置的这个函数呢?
- 在父进程中,
fork()函数会返回一个子进程的标识码pid(>0) - 在子进程中,
fork()函数会返回0,并且是从这一句fork开始起执行。 - 这就是调用一次,返回2次的特殊性。
如果我们执行
/*in function main()*/
printf("This is the father process.\n");
fork(); // #1
fork(); // #2
printf("Hello, World!\n");
它实际上执行方法:
这四个hello的输出,由于并发执行的原因,实际顺序具有任意性。
execve()函数#
输入一次不返回,包括3个参数:
const char* filename; // 启动文件名
const char* argv[]; // 命令行启动参数
const char* envp[]; // 环境参数
它负责调取加载器,覆盖当前进程,比如把shell用新的某个进程覆盖掉。
比如从Shell中运行可执行文件。
waitpid()函数#
进程结束后还是会赖在内存里占用空间,称为僵死进程。
父进程中的 waitpid() 函数可以回收清理这些僵尸进程。
它有三个参数
pid_t pid;
int *statusp;
int options;
返回值是被回收子进程的pid。
- pid = -1: 监控所有子进程
- pid > 0: 监控特定pid子进程
指针*statusp指向对子进程监控状态,即退出情况,具体可以通过wait.h中宏查询。
pid = -1时,子进程的回收也具有顺序任意性,需要代码人为约束。
此外注意,调用waitpid()时候,父进程暂时被挂起,需要等待对应子进程结束。
进程组 process group#
一般子进程与父进程隶属于同一个进程族;也可以使用 setpgid(pid_t pid, pid_t pgid) 人为设置。
这里当
- pid = 0: 不改pid
- pgid = 0: 使用进程的pid值作为pgid值
使用 类似 ls | sort 创建作业会给作业分配不同的进程组。
(这句指令的意思是把 ls 的输出喂给 sort 进行输入的管道)
信号 Signal#
是用户层的软件形式的特殊异常。
发送信号#
- 键盘:Ctrl+C终止前台进程;Ctrl+Z挂起前台进程
- Shell:
/bin/kill -9 11111 #杀死进程号11111那个
/bin/kill -9 -11111 #杀死进程组11111里面所有进程 - 进程内部调用函数kill()
- 进程内部调用函数alarm()
接受信号#
进程从 kernel mode 变成 user mode 的时候会触发检查,如果待处理集合非空,会挑选信号编号最小的一个施加给进程,在系统层面执行默认行为,或者如果被程序捕获,会转到用户层的信号处理程序。
有四种预设的默认行为:
- The process terminates 进程终止
- The process terminates and dumps core 进程终止并转储内存(到磁盘上)
- The process suspends until restarted by a SIGCONT signal 进程挂起直到被唤醒
- The process ignores the signal 进程忽略该信号
信号处理过程类似于异常处理。
注意:
- 一种类型的待处理信号只会存在一个,剩下的会被丢弃
- 信号处理程序可以被其他信号处理程序中断
如果有好多个信号怎么办?
在某个信号处理程序执行完之后,会先切到kernel mode再切回user mode以刷新状态。
缺点:
- 信号处理不是时间先后顺序
- 对于Unix而言不同版本系统处理信号方式也不同
细节与补充#
Signal, Exception, try - catch 这三者是什么关系?#
Signal 是内核层面向用户层面提供的信号,类似于交通信号灯,除了类似于 SIGKILL 这类的强制性执行默认动作,其他都可以在编程语言中捕获并自行处理。
CSAPP书中有一个案例,其中Signal在指挥交通,保证父进程在子进程结束后继续执行下一步。再比如 sigsuspend() 方法也行之有效。
Signal和它的捕获处理方法具有异步性。
Exception是硬件层面异常。其中的Abort类型和Fault类型在出错时会最高权限地打断进程执行,而不是像Signal可以被捕捉重新处理具有妥协性。
但是我们调用系统函数 trap 类型如果遇到了错误情况,并不会强制像fault这样直接爆掉,而是会提供错误码和错误信息返回给用户层。
这些信息经过(JVM等)进一步处理,是可以被try-catch捕捉到的。
try-catch 语句具有同步性,它必须需要主函数执行到了某一个特定区块才能触发。并且 try-catch 这些异常都是在C++/Java语境下定义的高级层面上的异常情况,即便它们本质上可能是一个trap意外的包装。
案例:java.io.IOException 的工作原理就是系统发出来的错误情况提供到了JVM上。
为什么我shell里面运行完一个文件最后还会回到shell进程里面呢,execve()不是覆盖了原进程吗?#
Shell后台使用了指令组合:
fork();
if pid == 0 then execve(...);
waitpid(...);
Shell它fork出一个子进程,再把这个子进程用execve覆盖掉,并且用waitpid暂时挂起父进程,最后清理。
Signal的异步性让我想到了javascript里面的addEventListener()的监听函数,这个两者间有什么关系?#
浏览器底层有一个大的while True循环叫做Event Loop,它负责接受各种信号并转化成对应的Event,将它们按时间先后存放到序列中,不会被覆盖,并且这样处理之后这些Events它们无权中断运行中的任何进程。然后Event Loop 通过调用回调函数来唤醒对应的监听函数进行工作。
怎么区分前台进程和后台进程?#
在同一Shell下,前台进程一般只有一个进程或者(不一定是整个)进程组。ls | sort 案例中,ls和sort是同一个进程组,但是和Shell不是同一个进程组。运行的时候ls和sort都活跃在前台。
By Tab_1bit0
