Exceptional Control Flow: Exceptions and Processes
在之前的学习主要关注静态代码,直到加载程序。而本章开始关注动态的过程,讨论程序执行的具体动态过程。
这章对应的 Lab 是 Shell Lab.
Exceptional Control Flow#
正常情况下,程序的控制流是线性的,顺序执行。CPU 朴素地从指令序列上读取并依次执行。
而我们知道,在实际的程序中,控制流并不是线性的,称为异常控制流,分为两个层面的状态:
- 程序状态 Program state
- 条件跳转 Jumps and branches
- 函数调用 Call and return
- 系统状态 System state
- 比如键盘按
Ctrl-C
,除零等等。
- 比如键盘按
在实际的程序中,控制流的改变是非常复杂的,统称为 Exceptional Control Flow 异常控制流。
不同层级上的机制#
- Low level mechanisms:
- 「Exceptions 异常」 系统层面,需要硬件和操作系统一起解决。
- Higher level machinisms:
- 「Process context switch 进程切换」 需要操作系统 + 硬件计时器;
- 「Signals 信号」 需要操作系统;
- 「Nonlocal jumps 非本地跳转」 需要 C runtime library 实现,如
setjmp
和longjmp
函数配合使用,实现局部函数调用栈以外的异常跳转,属于用户级控制流转变机制。
Exceptions#
这是异常控制流的最底层机制,异常是由 CPU 触发的,通常是由于硬件错误或者系统调用引起的。
对于不同的异常,有一个独立的异常号,与对应的异常处理程序的地址一起,对应存储在 「Exception Tables」 中,非常类似于跳转表。这个表的起始地址会存储在一个 CPU 的特殊寄存器中。但是好像在不同的系统中有不同实现。
系统在发现异常后,会根据 Exception Tables,转到操作系统中对应的异常处理程序,并执行不同操作。
异常有很多种类型,下面介绍一些类型的异常及处理程序。
Interrupts#
中断,Asynchronous Exceptions.
由处理器连接的外部设备引起的异常,比如键盘、鼠标、网络等。
CPU 会在当前指令完成后,再处理外部异常,之后回到原来的指令的下一条继续执行。
Synchronous Exceptions#
简称异常,是 CPU 内部的异常,细分为以下类别:
- Traps
- Intentional,由程序员主动引起的异常;
- 比如 system calls,breakpoint 等等。
- 可以在处理结束后,从原来的下一条指令继续执行。
- Faults
- Unintentional,部分 recoverable ;
- 这里 recoverable 的含义是:内核 能否让同一指令重新尝试,也就是有可能可以通过重复程序恢复的。
- 比如 page faults (recoverable),protection faults (unrecoverable),除零错误 (unrecoverable);
- 对于 recoverable 的错误,回到原指令重试;对于 unrecoverable 的,中断。
- Aborts
- Unintentional and unrecoverable;
- 致命错误,不返回程序,直接中断。
Processes#
进程是一个正在执行的程序的实例(Instance),也是 The most profound ideas in computer science.
这里,我们不关注操作系统如何实现进程,而是关注进程对于程序执行的影响,它提供独立的:
- 「Logical control flow」 独立的控制流。
- 「Private address space」 隔离开的内存空间。
让程序有假象,好像其正在独占 CPU 和内存。
下面具体展开上述的两个方面。
Logical Control Flow#
先考虑最简单的情况,在只有一个核心的 CPU 上,只能物理上同时运行一个进程。然而系统肯定要同时运行多个进程,这就需要进行并发执行。
- 「并发流 Concurrent flow」:
- 多个进程/线程在时间上交替执行,CPU 在它们之间快速切换,形成错觉;
- 每个进程分配的 time slice 很短。
- 物理上某时刻只有一个进程在运行;
- Concurrent 的定义: 称两个任务并发,当且仅当他们的执行时间有重叠;
- 「并行流 Parallel flow」:在多核CPU上,不同核上的进程/线程可以真正同时运行。
Context Switching#
操作系统利用上下文切换来实现多任务。
上下文会存储一个进程的状态,用来恢复它,一般包括:寄存器、程序寄存器、用户栈等各种各样信息。
上下文切换时,CPU需要在内核模式下执行调度代码,恢复目标进程上下文后,切换到该进程的用户模式运行。
- User mode:受限状态,不能直接访问硬件或其它进程的内存;
- Kernel Mode:最高特权,可以访问任何资源,执行任何指令。
System Calls#
这是调用异常处理程序的具体过程实现,当程序需要在进程外执行操作时,需要内核帮助,如:读写文件、开辟内存、网络通信等。
这个过程很像是函数调用,核心区别在于:
- 调用的指令不同,
call
和syscall
。 - System Call 用 内核 执行,而Function Call 用 应用程序 执行。
- Different set of privileges,系统调用执行在内核态(Kernel mode),而普通函数调用在用户态(User mode)。系统调用通过硬件特权级切换允许程序访问受保护资源,确保系统安全性和稳定性。
- 有变量
errno
表示系统调用的错误类型。
具体的流程是:
- 把 System Call 的编号存到
%rax
中 - 执行
syscall
指令,操作系统就会自动切换到相应处理程序,用内核执行。 - 注意这里
syscall
是一条汇编层面的机器指令,后面也不用跟任何参数。但是不代表不需要参数,如果这个系统调用需要传参,也需要放到%rdi,%rsi
等寄存器中。
Process Control#
进程控制是操作系统提供的 API,允许用户程序创建、终止和管理进程。
每个进程都有非零 PID:
getpid()
:获取当前进程的 ID;getppid()
:获取当前进程的父进程 ID;
一个 process 可以分为以下四种状态:
- 「Running」 正在运行,或者正在等待 CPU;
- 「Blocked / Sleeping」 等待某个外部事件的发生;
- 「Stopped」:被用户暂停
Ctrl+Z
; - 「Terminated / Zombie」:进程已经结束,但是还没有被父进程回收。
Terminate#
进程的终止:
让进程以指定的状态码退出。
这个函数 调用一次,不返回。
Fork#
父进程创建子进程,子进程是父进程的副本,拥有独立的地址空间。
子进程与父进程的比较:
- 运行相关的信息(如寄存器、栈)相同;
- PID 不同;
是 C 语言中的一个系统调用,创建一个新的进程,返回值为:
- 给父进程返回子进程的 PID;
- 给子进程返回 0;
这个函数调用一次,返回两次。
事实上,系统会采用 Copy-on-write 的方式来实现 fork,父进程和子进程共享同一块内存区域,直到其中一个进程修改该内存区域时,操作系统才会为其分配新的内存空间,以避免不必要的内存复制和开销。
Reaping Child Processes#
一个进程终止后,会变成**「Zombie」**状态的进程,这是一种半死不活的状态,虽然运行结束了,但是占有了资源,没有被回收。因此,子进程在终止后,父进程需要负责回收。
如果父进程比子进程更早终止了,这时子进程称为**「Orphaned child」**,会被操作系统收养,成为 init
进程的子进程。这是一个 的特殊进程,负责收养所有的孤儿进程。
需要注意的是:
- 只有已经结束了的子进程才会被回收,Running 的子进程不会被 Terminate。
- 回收子进程没有特定顺序。
Reaping 回收 zombie 进程,依靠的是调用 wait()
或 waitpit()
来等待子进程结束:
pid
是进程 ID,也表示等待的子进程集合,比如当pid
为 -1 时,表示等待所有的子进程结束。statusp
是指向子进程的退出状态status
的指针,可以查表得知退出的具体状态。options
是一些其他选项。- 如果等待集合中,有一个子进程结束了,函数就会返回。
函数是 waitpid
的简化版本,等价于 .
Loading and Running Programs#
进程的创建和运行是通过 exec
系列函数实现的:
-
filename
是可执行目标文件的路径。 -
argv
是参数列表- 一般满足:。
- 必须以 NULL 结尾。
-
envp
是环境变量列表-
每个条目式形如 的字符串。
-
对环境变量的操作有:
getenv()
,setenv()
,unsetenv()
等等。 -
必须以 NULL 结尾。
-
与 exit()
一样,这个函数 调用一次,不返回。
调用 execve 成功后,当前进程的 用户空间代码和数据 会被指定的新程序所替换,但进程ID不会改变。
- 当前进程的整个地址空间被清空,加载新程序的代码段、数据段、堆和栈。
- 而如进程号(PID)、父进程号(PPID)、当前工作目录、用户ID、环境变量等大部分属性保持不变。
- execve 成功调用后不会返回;如果返回了,则说明出错了。