CSAPP 第八章 异常控制流
系统的异常处理机制
从给处理器加电开始,到断电位置,程序计数器假设一个值的序列 $$a_0,a_1,a_2,···,a_{n-1}$$,其中 $$a_i$$ 是某个指令 $$I_k$$ 的地址,每次从 $$a_i$$ 到 $$a_{i+1}$$ 的过渡叫做控制转移。这样的控制转移序列叫做处理器的控制流。
突变是指在平滑流(相邻指令的地址也是相邻的)中,调用诸如跳转、调用和返回这样的程序指令造成的相邻指令地址不相邻的情况。
同时系统必须要对系统状态的变化做出反应,比图硬件定时器定时产生信号,而这个系统状态并不是被内部的程序变量捕获的,也不和程序执行相关。想带系统通过使控制流发生来对这种情况做出反应,我们把这些突变称为异常控制流(Exceptional Control Flow,ECF)。
异常
异常就是控制流中的突变,用来相应处理器状态中的某些变化。状态变化称为事件,事件可能与当前指令直接相关。在处理器中,状态被编码为不同的位和信号。
如下图展示了异常处理的基本思想。
当处理器状态发生一个重要的变化时,处理器正在执行某个当前指令 $$I_{curr}$$ 。在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转表,进行一个间接过程调用到异常处理程序(exception handler)。
当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3 种情况中的一种:
- 处理程序将控制返回给当前指令 $$I_{curr}$$ ,即当事件发生时正在执行的指令
- 处理程序将控制返回给 $$I_{next}$$ ,如果没有发生异常将会执行的下一条指令
- 处理程序终止被中断的程序。
异常处理
系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)。其中一些号码是由处理器的设计者分配的,包括被零除、缺页、内存访问违例、断点以及算术运算溢出;其他号码是由操作系统内核的设计者分配的,包括系统调用和来自外部 I/0 设备的信号等。
在系统启动时,操作系统分配和初始化跳转表,使得表目 k 包含异常 k 的处理程序的地址。如下图:
异常号是异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊 CPU 寄存器里。
一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。异常类似于过程调用,但是有一些重要的不同之处:
- 过程调用时,在跳转到处理程序之前处理器将返回地址压入栈中。在异常处理中,根据异常的类型,返回地址要么是当前指令,要么是下一条指令。
- 异常处理器也把一些额外的处理器状态压到栈里,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。
- 如果控制从用户程序转移到内核,所有这些项目都被压到内核栈中,而不是压到用户栈中。
- 异常处理程序运行在内核模式下,这意味着它们对所有的系统资源都有完全的访问权限。
异常的类型
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort)。
除了中断之外,其余的异常类型都是同步发生的,是执行当前指令的结果,叫做故障指令。
中断
中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。
硬件中断的异常处理程序常常称为中断处理程序。
中断处理程序返回后,会将控制返回给下一条指令。
陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。
陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。每一个指令对应一个系统调用号 n ,处理器为我们提供了一条特殊的 syscall
指令,当用户想要请求服务 n 时,会把 n 保存给 %rax
寄存器,再执行 syscall
。此时会进入一个陷阱处理函数,并根据传进来的参数调用合理的处理程序。
从程序员的角度来看,系统调用和普通的函数调用是一样的。但是系统调用运行在内核模式中,允许系统调用执行特权指令,并访问定义在内核中的栈。
故障
故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。
如果处理程序能够修正这个错误情况,它就将控制返同到引起障的指令,从而重新执行它。否则,处理程序返同到内核中的 abort 例程,abort 程会终止引起故障的应用程序。
终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如 DRAM 或者SRAM 位被损坏时发生的奇偶错误。
终止处理程序从不将控制返回给应用程序,而是直接返回给 abort 例程,这个例程会终止这个应用程序。
Linux/x86-64 系统中的异常
x86-64 系统定义了多达 256 种不同的异常。其中 0~31 号对应的是 Interl 架构师定义的异常,在所有的 x86-64 系统中都是一样的,剩下的的就是操作系统定义的中断和陷阱。
故障和终止
系统调用
进程
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。
上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
逻辑控制流
一个进程的程序计数器(PC)序列叫逻辑控制流,或者简称逻辑流。
进程不是独占处理器的,如下图:
每个竖直的条表示一个进程的逻辑流的一部分,三个逻辑流的执行是交错的。每个进程执行它的流的一部分,然后被抢占(暂时挂起),然后轮到其他进程。
并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行。
多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段做时间片。因此,多任务也叫做时间分片。
需要注意,并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流,它们并行地运行,且并行地执行。
私有地址空间
进程之间是独立运行的,为每个程序提供它自己的私有空间。进程的地址空间不能被其他进程读写,从这个意义上来说,这个地址空间是私有的。
每个程序的私有地址空间结构都是相似的,如下:
用户模式和内核模式
处理器通常是用某个控制寄存器中的一个模式位(mode bit)来限制一个应用可以执行的指令以及它可以访问的地址空间范围,该寄存器描述了进程当前享有的特权。
- 当设置了模式位时,进程就运行在内核模式中(超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
- 没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个 I/O 操作,也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷人系统调用这样的异常。
特别的是,Linux 提供了 /proc
文件系统,允许我们用户模式进程访问内核数据结构的内容。在 2.6 版本的 Linux 中引入了 /sys
文件系统他输出关于系统总线和设备的额外的底层信息。
比如 /proc/cpuinfo
里面包含了 CPU 的类型,/proc/<process id>/maps
包含了进程使用的内存段。
上下文切换
内核为每个进程维持一个上下文,上下文就是重新启动一个被抢占的进程所需要的状态,包括寄存器、程序计数器、用户栈、内核栈和各种内核数据结构。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。
当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。
上下文切换的流程如下:
- 保存当前进程 A 的上下文
- 恢复某个先前被抢占的进程 B 被保存的上下文
- 将控制传递给这个新恢复的进程 B
当内核模式代表用户执行系统调用时,可能会发生上下文切换;中断也会引发上下文切换。
系统调用错误处理
我们需要在代码中检查错误,例如调用 Unix fork 函数时我们会如下检查错误:
1 |
|
错误检查会不可避免地让代码变得臃肿难懂,我们可以通过定义错误报告函数来一定程度上简化代码:
1 |
|
这样以后我们的错误检查就可以写作:
1 |
|
错误处理包装函数可以进一步简化代码:
。对于一个给定的基本函数 foo,我们定义一个具有相同参数的包装函数Foo,但是第一个字母大写了。包装函数调用基本函数,检查错误,如果有任何问题就终止。比如,下面是 fork 函数的错误处理包装函数:
1 |
|
给定这个包装函数,我们对 fork 的调用就缩减为 1 行 pid = Fork();
进程控制
获取进程ID
每个进程都有一个唯一的正数进程 ID(PID)。
getpid()
函数返回调用进程的 PID;getppid()
函数返回它的父进程的 PID(创建调用进程的进程)。
创建和终止进程
进程总是处于下面三种状态之一:
- 运行:进程要么在 CPU 上执行,要么在等待被执行且最终会被内核调度。
- 停止:进程的执行被挂起,且不会被调度。当收到
SIGSTOP
、SIGTSTP
、SIGTTIN
或者SIGTTOU
信号时,进程就停止,并且保持停止直到它收到一个SIGCONT
信号,在这个时刻,进程再次开始运行。 - 终止:进程永远地停止了。进程会因为三种原因终止:收到终止进程的一个信号、从主程序返回、调用 exit 函数
exit 函数以 status 退出状态来终止进程。
父进程使用 fork 系统调用可以创建一个新的,与父进程上下文几乎一致的子进程。
虽然父进程和子进程的地址空间值一模一样,但是他们之间是独立的。
fork 被调用一次,返回两次。一次是在父进程中,一次是新建立的子进程中。父进程返回子进程的 pid,子进程返回 0,我们也可以使用返回值来区分是父进程还是子进程。
回收子进程
当进程由于某种原因终止时,内核并不是立即把他从内存中清除。相反,进程被保持在一种已经终止的状态,直到被他的父进程回收。
终止了但是没有被回收的进程叫做僵死进程。
如果僵死进程的父进程还没有将僵死进程回收就终止了,那么僵死进程会变成孤儿进程,由 init
进程成为它的养父,也就是说会由 init
回收。
init
进程 pid 为 1,是在系统启动的时候由内核创建的,是所有进程的父进程或祖先进程。因为僵死进程即使没有运行,依然要占用内存资源。
一个进程可以调用 waitpid(pid_t pid,int *statusp,int options)
函数等待它的子进程终止或者停止。
默认情况下(options=0
)waitpid
一旦接收到等待集合中的其中一个子进程的信号,就会立刻返回。
判定等待集合的成员
等待集合的成员是由参数 PID 来确定的:
pid>0
:等待进程是一个单独的子进程,它的进程 ID 等于 pidpid>-1
:等待进程是有父进程所有的子进程组成
修改默认行为
可以通过将 options 设置为常量 WNOHANG
、WUNTRACED
和 WCONTINUED
的各种组合来修改默认行为,也可以用或运算把这些选项组合起来:
WNOHANG:
它会使调用变为非阻塞。如果存在可以获取状态的子进程,返回该子进程的进程ID;如果没有子进程退出,返回 0 而不是阻塞等待。
WUNTRACED:
它允许等待获取已经停止(但未终止)的子进程的状态信息。通过这个标志,可以检测到由于收到
SIGSTOP
信号而暂停的子进程的状态变化。WCONTINUED:
用于获取子进程被继续执行的状态信息。当一个进程收到
SIGCONT
信号并恢复执行时,可以使用WCONTINUED
标志来检测到这种状态变化。
检查已回收子进程的退出状态
如果 statusp
参数是非空的,那么 waitpid
就会在 status
中放上关于导致返回的子进程的状态信息。
wait.h
头文件定义了解释 status
参数的几个宏:
- WIFEXITED:如果了进程通过调用 exit 或者一个返回(return)正常终止,就返回真
- WEXITSTATUS:返回一个正常终止的子进程的退出状态。只有在
WIFEXITED()
返回为真时,才会定义这个状态。 - WIFSIGNALED:如果子进程是因为一个未被捕获的信号终止的,那么就返回真。
- WTERMSIG:返回导致子进程终止的信号的编号。只有在
WIFSIGNALED()
返回为真时,才定义这个状态。 - WIFSTOPPED:如果引起返回的子进程当前是停止的,那么就返回真。
- WSTOPSIG:返回引起子进程停止的信号的编号,只有在
WIFSTOPPED()
返回为真时,才定义这个状态。 - WIFCONTINUED:如果子进程收到
SIGCONT
信号重新启动,则返回真
错误条件
如果调用进程没有子进程,那么 waitpid 返回 -1,并且设置 errno 为 ECHILD
。如果 waitpid 函数被一个信号中断,那么它返回 -1,并设置 errno 为 EINTR
。
wait函数
wait 函数时 waitpid 函数的简单版本:
1 |
|
调用 wait(&status)
等同于 waitpid(-1,&status,0)
。
使用 waitpid 的实示例
给了一个例子:
1 |
|
可以发现,程序不会按照顺序回收子进程,这取决于运行的机器的调度。这是非确定性行为的一个实例。
我们也可以强制用代码来控制子进程的回收顺序。就是创建一个数组保存 pid,每次循环只回收指定进程,但是这依然不能保证子进程会按顺序结束,但是一定会按顺序回收。
1 |
|
让程序进入休眠
sleep函数将一个程序挂起一段指定的时间,如果请求的时间到了,sleep返回0,否则返回剩下的需要休眠的秒数。
1 |
|
pause函数也是让程序休眠,直到该进程收到信号:
1 |
|
加载并运行程序
execve(const char *filename,const char *argv[,const char *env[])
函数用于在当前进程中加载并运行一个新的程序可执行目标文件 filename 。execve
调用一次就无法返回了,除非加载失败(例如找不到 filename 这个文件)并返回错误。
一般来说,加载的程序会以这样的入口去加载 int main(int argc,char *argv[],char *env[])
,这里 execve
的后面两个参数就是这里的参数。
对于环境变量,Linux 提供了几个函数来操作环境变量:
char *getenv(char *name)
用于获取一个环境变量,成功就返回value
的指针,这里是value
的指针,不是一整个条目的指针,失败就返回NULL
。int setenv(const char *name,const char *newvalue,int overwrite)
用于给环境变量name
设置一个新的值newvalue
,如果已经存在,那么判断overwrite
是否非 0,非 0 则直接替换。void unsetenv(char *name)
用于取消一个环境变量。
据此我们可以写出一个简易的 SHELL 程序,利用 fork
和 execve
的搭配就可以解决,主要工作量还是在解析命令上面。
利用 fork 和 execve 运行程序
我们只要 fork
出一个子进程,然后子进程用于 execve
装载父进程读入的一个命令,父进程有两种选择,一个是等待执行完毕(一般shell都支持的),这个可以直接使用 wait 实现,或者是加一个 &
挂在后台运行,我们只要根据加没加 &
判断 wait 的参数即可。
此时我们还有选择处理信号,因为收到了 Ctrl+C
的终止信号之后,我们不应当终止父进程,而应该把它转发给子进程,让子进程去处理。
信号
Linux 信号允许进程和内核中断其他进程。一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。
每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,这些硬件异常对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
信号术语
传送一个信号到目的进程是由两个不同的步骤组成的:发送信号和接收信号。
一个发出而没有被接收的信号叫做待处理信号。
在任何时刻,一种类型至多只会有一个待处理信号。
如果一个进程有一个类型为的待处理信号,那么任何接下来发送到这个进程的信号都不会排队等待,它们只是被简单地丢弃。一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
一个待处理信号最多只能被接收一次。
发送信号
发送信号可以有如下两种原因:
- 内核检测到一个系统事件,比如零除错误或者子进程中终止
- 一个进程调用了 kill 函数,显示要求内核发送信号给目的进程
进程组
所有发送信号的机制都是基于进程组(processgroup)这个概念的。每个进程都只属于一个进程组,进程组是由一个正整数进程组 ID来标识的。
setpgid(pid_t pid,pid_t pgid)
可以为指定进程设置进程组 id
,如果 pid
参数为 0
,那么表示对自身设置,如果 pgid
为 0
,表示创建一个 gid
等于 pid
的一个组并加入其中。
使用 /bin/kill 发送信号
/bin/kill -9 12345
可以给 pid
为 12345
的进程发送信号9(SIGKILL)。如果 pid 为负,则表示给对应的组中所有的进程发送这个信号。
从键盘发送信号
shell 使用作业(job)来表示一条命令所创建的进程。在任何时刻,最多有一个前台作业和 0 个或多个后台作业。
在键盘中输入 Ctrl+C
会导致内核发送一个 SIGINT
信号到前台进程组中的每一个进程,最终结果就是终止前台作业。类似的,使用 Ctrl+Z
会发送一个 SIGSTOP
信号给所有前台作业,默认情况下,结果是挂起前台作业。
kill函数发送信号
进程通过调用 kill(pid_t pid,int sig)
发送一个 sig
信号给进程 id 为 pid 的进程。
- pid = 0:给自己所在的进程组中的每一个进程发送信号
- pid < 0:给组 id 为
|pid|
的所有进程发送信号
用 alarm 函数发送信号
unsigned alarm(unsigned secs)
函数可以给自身发送 SIGALRM
信号,内核会创建一个定时器,到指定 secs
秒数之后,内核会发送一个 SIGALRM
信号,返回值为上一次闹钟所剩余的秒数,如果是第一次调用,则返回 0。
接收信号
当内核把进程 p 从内核模式切换到用户模式时(例如从系统调用返回,或是完成了一次上下文切换),它会检查进程的未被阻塞的待处理信号的集合(pending&~blocked)。
如果集合是非空的,那么内核选择集合中的某个信号 k (通常是最小的)并且强制进程接收信号 k 。收到这个信号会触发进程 p 采取某种行为。一旦进程完成了这个行为,那么控制就传递回 p 的逻辑控制流中的下一条指令。
每个信号会有一个默认的行为:
- 终止进程
- 终止进程并转储内存
- 挂起进程
- 忽略信号
使用 signal(int signum,sighandler_t handler)
函数可以修改 signum 信号的行为,不同的 handler 对应不同的结果:
- SIG_IGN :表示忽略该信号
- SIG_DFL :表示恢复该信号的默认处理
两种信号不能修改默认行为,也不能忽略:
SIGKILL
和SIGSTOP
这个函数被称为信号处理程序,只要进程接收到一个类型为 signum 的信号,就会调用这个程序。通过把处理程序的地址传递到 signal 函数从而改变默认行为,这叫做设置信号处理程序(installing the handler)。调用信号处理程序被称为捕获信号。执行信号处理程序被称为处理信号。
通常情况下,在信号处理程序执行 return
时,会把控制流交还给被之前被中断那条指令的后面一条指令,例外的情况就是某些系统在执行系统调用的时候收到信号,系统调用会直接返回一个错误。
一个信号处理程序可能会被另一个信号处理程序打断,但是不会被自己打断。
阻塞和解除阻塞信号
Linux提供阻塞信号的隐式和显式的机制:
- 隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理的信号类型的待处理信号。
- 显式阻塞机制。应用程序可以使用
sigprocmask
函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。
使用函数 sigprocmask(int how,const sigset_t *set,sigset_t *oldset)
可以改变当前的 block
集合值。具体的行为依赖于 how的值:
- SIG_BLOCK:把 set 中的信号添加到 blocked 中(blocked=blocked I set)
- SIG_UNBLOCK:从blocked 中删除 set 中的信号(blocked=blocked &~set)
- SIG_SETMASK:block=set
如果 oldset 非空,那么 blocked 位向量之前的值保存在 oldset 中。
我们使用下面的函数对 set 信号进行操作:
int sigemptyset(sigset* set)
初始化 set 为空集int sigfillset(sigset* set)
将 set 填满信号int sigaddset(sigset* set, int signum)
将 signum 信号添加到集合中int sigdelset(sigset* set, int signum)
将 signum 信号从集合中删除
编写信号处理程序
安全的信号处理
G0 处理程序要尽可能简单
避免麻烦的最好方法是保持处理程序尽可能的小和简单。我们尽量将复杂的逻辑放在主函数中实现,尽量简单的设置全局标志。
G1 在处理理程序中只调用异步信号安全的函数
所谓异步信号安全的函数能够被信号处理程序安全地调用,原因如下:
- 要么它是可重入的,意味着它只会调用局部变量而不访问任何全局变量
- 它不能被信号处理程序中断,意味着这个函数要么不执行,要么完全执行。
所有的 IO 函数都是不安全的,因为它们可被打断,且在调用 IO 函数的时候都会访问一个
_IO_2_1_stdout
的全局结构。
G2 恢复和保存 errno
许多 Linux 异步信号安全的函数都会在出错返回时设置 errno。
但是处理程序中调用这样的函数可能会干扰主程序中的其他部分,我们可以在进入处理程序时把 errno 保存在一个局部变量中,在处理程序返回前恢复它。
G3 阻塞所有信号,保护对共享全局数据结构的访问
如果处理程序和主程序或其他处理程序共享一个全局数据结构,那么在访问(读或者写)该数据结构时,处理程序和主程序应该暂时阻塞所有的信号。
G4 用 volatile 声明全局变量
可以用 volatile 类型限定符来定义一个变量,让编译器不对其中的代码做出优化,每次引用都会从内存中读出值。例如 volatile int g;
G5 用 sig atomict 声明标志
整型数据型 sig atomic_t
,对它的读和写保证会是原子的(不可中断的)。
正确的信号处理
从这里开始到这一节结束,这部分的东西多多少少都讲过了,遂不记🤪
非本地跳转
C语言提供了一种用户级异常控制流形式,称为非本地跳转(nonlocaljump),它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。
非本地跳转是通过 setjmp 和 longjmp 函数来提供的。
1 |
|
setjmp 函数在 env 缓冲区中保存当前调用环境,以供后面的 longjmp 使用,并返回 0。
longjmp 函数从 env 缓冲区种回复调用环境,然后触发一个从最近一次初始化 env 的 setjmp 的调用返回,返回值非零。
1 |
|
setjmp
函数调用一次返回多次,调用会把当前状态保存在 buf
中并返回 0。直到后面遇到 longjmp
时,会根据保存的位置恢复寄存器状态,并将返回值置为第二个参数。
非本地跳转的另一个妙用是可以在接收信号的时候不返回被中断的位置,也不直接退出,而是可以重新指定跳转位置。
如下,当用户在键盘上键入 Ctrl+C 时,这个程序用信号和非本地跳转来实现软重启:
1 |
|
在 while 1
中当我们按下 Ctrl+C
,指令在循环中的一个地方被中断。如果没有 setjmp
和 longjmp
的处理,那么我们收到这个信号之后,要么接着回去(return),要么退出(exit),但是有了这两个函数,我们就可以设置一个点位,让它信号处理完毕之后都回到那个点位去。
操作进程的工具
Linux 为我们提供了大量操作进程的工具
- STRACE:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。
- PS:列出当前系统中的进程(包括僵死进程)。
- TOP:打印出关于当前进程资源使用的信息。
- PMAP:显示进程的内存映射。
- /proc:一个虚拟文件系统,以 ASCII 文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。比如,输入 “cat/proc/loadavg”,可以看到 Linux 系统上当前的平均负载。
讲的很细很细,有一点难懂,还是需要沉淀一下🫠
为什么最近总是困困的