CSAPP 异常控制流
异常控制流
假设处理器工作时,PC指向的指令地址构成的序列为\(a_1,a_2...a_n\),序列\(a\)就称为处理器的控制流,由\(a_i\)向\(a_{i+1}\)的过渡称为控制转移
现代系统通过使控制流发生突变,对系统状态的变化做出反应,这些突变就称为异常控制流(Exceptional Control Flow,ECF)
异常
异常是一种相应处理器变化的机制既有软件实现的部分,也有硬件实现的部分
其机制如下

假设处理器正在执行指令\(I_{cur}\),此时发生一个事件(如除0,数值溢出,直接内存访问结束),处理器会检测到该事件的发生,并通过异常表发生控制转移,跳转到相应的异常处理程序进行处理,然后返回继续执行指令或终止程序
异常的识别与调用
每一种异常都有着一个唯一的无符号数表示的异常号
在系统启动时,系统会生成一张异常表,该表的第\(k\)项存放了对应异常号为\(k\)的异常处理程序指令所在地址
而异常处理程序的调用类似于函数,但是有着一些区别:
- 调用函数后,条件码等信息可能发生变化;调用异常处理程序时这些信息进行了压栈保存,处理结束后弹出,保证了程序的正常运行
- 调用函数会将返回地址压入用户栈中;而调用异常处理程序会将返回地址和相关信息压入内核栈
- 函数运行在用户模式下;而异常处理程序运行在内核模式下,有着对系统资源更完全的访问权

异常的类型
异常有以下四种类型:中断,陷阱,故障,终止
其中中断属于异步异常,而其他三种属于同步异常
中断
外部I/O设备发出信号,导致中断发生。即中断不是由内部指令造成的,所以称为异步异常
I/O设备通过向处理器芯片上的引脚发信号,并将异常号放入系统总线中进而触发中断
其流程如下:

我们以DMA直接内存访问结束为例,当磁盘访问完成时,处理器收到了对应的异常码,在调用异常处理程序后继续处理下一条指令
陷阱
陷阱是有意造成的异常,和中断相似,但区别在于是由内部指令造成的
陷阱最重要的作用是系统调用,这是用户程序和内核间的接口,通过访问内核,得以进行文件读写,创建进程等重要操作
汇编中,使用syscall进行系统调用,触发一个到异常处理程序的陷阱

故障
故障是处理器处理中出现了错误情况,它可能在被异常处理程序修正后成功重新运行(如缺页异常),也可能无法被修复导致程序终止

注意:导致故障时当前指令尚未完成,所以如果成功修复后,需要跳转到当前指令重新执行,而非下一条指令
终止
发生了不可修复的严重错误,不返回控制,直接返回会结束该程序的abort例程
linux系统中的异常
我们以x86-64为例,有256种异常指令,其中0~31由架构定义,对x86-64架构都一样;而32~255由操作系统定义,都是中断和陷阱

其中除法错误和一般保护故障(段错误)虽然在硬件层面上是完全可修复的,但是在软件层面属于逻辑错误,为了便于调试查错,直接会导致程序终止暴露错误而非静默修复
linux为系统调用生成了一张跳转表,每个系统调用都有一个唯一的整数号
汇编语言中,进行系统调用时,将要进行的系统调用的整数号放入\(\%rax\)中,参数最多六个,依次放入\(\%rdi \%rsi \%rdx \%r10 \%r8 \%r9\)中,然后使用指令syscall即可
进行系统调用时,\(\%rcx \%r11\)会被用作临时变量,分别保存下一条指令地址和程序状态标志而被破坏掉
调用结束后的返回值会覆盖\(\%rax\),当其值为负数时,说明系统调用发生了错误
常见系统调用的跳转表如下

hello world 汇编实现

事实上,函数调用在高级语言中被高度封装成了函数(如write封装为printf),但是我们也可以通过相关接口直接进行系统调用

进程
程序是一个静态的文件,而进程则是程序运行起来的动态的实例
上下文是程序运行所需要的各种状态,包括代码,数据,PC,运行栈等,程序在进程的上下文中运行
逻辑控制流
我们以单核处理器为例,假设某一时刻存在多个进程都需要运行,处理器在一个时间点上只能推进一个进程
处理器通过短暂,频繁地中断和切换其处理的进程,创造出一种所有进程都在同时进行的宏观表象

实际上,进程轮流占有处理器,执行一段时间后被抢占,其他进程进行处理。从宏观上来看,就好像每个进程都独占处理器,只是中间发生一些周期性的停顿
并发流
当两个进程运行的时间有交集时,就称这两个进程并发运行
当两个进程并发地运行在不同的处理器1核或者计算机上时,就称这两个进程并行运行
并发和并行最终都达到了多任务的结果
私有地址空间
操作系统通过为每个进程分配虚拟地址空间,使得宏观上就像每个进程占用整个系统地址空间

用户模式和内核模式
用户模式限制了应用可以执行的指令和访问的地址空间
在控制寄存器中,存在一位模式位,当这一位被设置时,进程就处于内核模式,可以执行任何指令集中的指令,访问内存中的任意空间
反之,进程处于用户模式,必须通过异常在异常处理程序中处于内核模式进行操作,然后再回到用户模式中
在linux中,/proc和/sys中存放了内核的信息,大部分只读,小部分可写
上下文切换
内核为每个进程都维护了一个上下文,当需要切换该进程执行时,恢复上下文并控制传递给这个进程即可
在进程执行时,内核中的一段称为调度器的代码进行调度的决策,即决定是否对某个正在执行的进程进行抢占,转而执行其他被挂起的进程
例如,当一个进程需要进行磁盘写的时候,调度器可以先上下文切换执行其他进程,当直接内存访问完成后再转而执行该进程
中断也可能引起上下文切换,如系统都能周期性地发出中断信号,让内核判定当前进程已经进行了较长时间,应该进行上下文切换

进程控制
下文介绍Linux下C程序中的进程控制方法
获取进程id
pid_t getpid();
pid_t getppid();
在Linux中,pid_t 被实现为 int。getpid返回当前进程的pid,getppid返回父进程的pid
创建和终止进程
void exit(int status);
exit函数直接终止当前进程,并将退出状态设置为status
pid_t fork();
fork创建一个子进程,该子进程被创建时几乎是父进程的一份拷贝,享有私有地址空间,与父进程并发地运行。当前进程为父进程的时候,返回值为创建的子进程的pid;当前进程为新的子进程的时候,返回值为0;由此可以判断当前执行的是子进程还是父进程。子进程与父进程交替执行的顺序未知,由调度器决定

同时在shell中,也可以为了对一行命令行求值创建进程,该进程被称为任务,shell为每个任务创建一个新的进程组
通过| (pipe) 将多个进程连接起来,如linux> ls | sort

子进程的回收
当一个进程终止时,其相关的部分状态会仍然保持在内核中,直到被回收。一个终止但未被回收的进程称为僵尸进程(zombie)
父进程可以对终止的子进程进行回收。当一个父进程终止,但是其子进程还没有被回收时,系统会安排pid为1的所有进程的祖先 init进程去回收它们,避免了对于系统资源的占用
pid_t waitpid(pid_t pid, int *statusp, int options);
该函数默认行为是将当前进程挂起,直到指定的子进程终止,然后将子进程回收,最后返回被回收的子进程的pid
若发生错误,则返回-1。错误原因为没有该子进程时,errno被设置为ECHILD;原因为该函数被信号中断时,设置为EINTR
其中,pid表明了需要等待回收的子进程id,若id=-1则为当前进程的全部子进程,否则为pid为该参数的子进程
options能指定该函数的行为,0为默认行为,可以通过以下定义的常量设置其行为
WNOHANG:当前进程不会被挂起,即只会检查pid是否终止,终止则返回pid,否则返回0WUNTRACE:挂起当前进程,采取上文提到的默认行为,同时检查pid是否终止或挂起WCONTINUED:挂起当前进程,采取上文提到的默认行为,同时检查pid是否终止或从挂起变为执行
以上的选项可以进行或运算(如(WNOHANG | WUNTRACE)),函数行为是两种行为逻辑上的或
在该函数调用完毕后,statusp指向的值会被改为导致子进程返回的信息,可以通过以下几个宏进行查看

也可以使用wait(int *statusp)函数,该函数等价于waitpid(-1, int *statusp, 0)
以下是waitpid的一个例子

父进程和子进程执行顺序未知,子进程可能尚未执行完exit函数,需要重复将父进程挂起等待然后回收终止的子进程
进程的休眠
unsigned int sleep(unsigned int secs);
int pause();
sleep将当前进程挂起secs,可能被信号中断,返回值为实际挂起时间与secs的差
pause会将当前进程一直挂起,直到收到一个信号
加载并运行程序
int execve(const char *filename, const char *argv[], const char *envp[])
该函数直接在当前进程中执行可执行文件filename,参数为argv,环境变量为envp,此后除非找不到filename才返回-1,否则不会返回
execve调用启动代码,将控制和相关参数传递给filename的main函数


linux下,C提供了getenv,setenv,unsetenv等函数对当前进程的环境变量进行修改
通过fork+execve,我们得以在创建的子进程中运行一个程序
信号
异常是处理器层面的,而信号可以理解为程序层面的异常。信号提供了一种机制,内核通知用户进程发生了异常,进程做出相应的反应
例如:shell 中 Ctrl+C使内核发送SIGKILL信号将进程终止;Ctrl+Z使内核发送SIGKILL信号将进程挂起

信号处理的流程
当一个系统事件发生时,内核会将对应的信号发送给相应的进程,该进程以某种方式对信号做出回应,称为接收了该信号

每个进程都有两个内核维护的信号集:pending未决信号集 和 blocked 阻塞信号集
当一个信号产生时:
-
首先检查该信号在blocked集中的对应位:
- 如果为1(被阻塞):将pending集中对应位设为1,信号暂时不会被递送
- 如果为0(未被阻塞):立即尝试递送信号
-
接收信号时:
- 如果进程正在执行相同信号的处理程序,则将pending位设为1,等待当前处理完成
- 否则,调用信号处理程序
-
信号处理完成后:
- 内核检查pending集,如果有被阻塞的信号变为未阻塞,则接收它们
- 接收完成后,清除相应的pending位(设为0)
由于pending集采用位图实现,短时间内多次产生的相同信号:
- 在pending集中只会记录一次(位图只能为0或1)
- 即使产生了多次,也只会被接收一次
这就是"标准信号不排队"的现象(注意:实时信号是排队的)
发送信号
进程组
通过将每个进程都分配到某个进程组,得以实现对大量进程发送同一信号
pid_t getpgrp();
int setpgid(pid_t pid, pid_t pgid);
getpgrp 返回当前进程所在进程组编号;setgpid将pid进程所属的组设置为pgid,成功返回0,错误返回-1,pid为0时是当前进程,pgid为0时表示分配到以当前进程pid为组pgid的进程组
信号的发送
在shell中,可以使用linux>/bin/kill -SIG PID 向PID发送SIG对应的信号,其中若PID为负数,则对应的进程为PID绝对值对应的进程组号
在C中,也可以使用kill函数
int kill(pid_t pid, int sig);
用法与/bin/kill类似,只是两个参数位置相反,并且pid=0是是向当前进程所在组的所有进程发送该信号
unsigned int alarm(unsigned int secs);
在secs秒后,内核向当前进程发送一个SIGALRM信号,新的闹钟会取代旧的闹钟,并返回旧闹钟剩余时间
接收信号
C中可以通过signal函数改变除SIGSTOP,SIGKILL以外的信号对应的信号处理程序的行为,原型如下
typedef void (*sighandler_t)(int)
sighandler_t signal(int signum, sighandler_t handler);
该函数将signum对应的信号处理程序行为改为handler函数,若成功则返回之前的行为对应的函数,否则返回SIG_ERR
handler可以取以下参数:
SIG_IGN将行为设置为忽略该信号SIG_DEL将行为设置为默认行为- 传入一个自定义的函数,将行为设置为该函数

事实上,由于较老的Unix系统对信号的处理行为不同,signal函数可移植性比较差,而sigaction函数具有较好的可移植性,但是调用比较复杂。我们可以通过采用sigaction作为实现,定义与signal行为相同的函数

信号的阻塞
隐式阻塞机制:标准信号不排队,会被阻塞
显示阻塞机制:使用 sigprocmask对blocked位进行修改

注意:对blocked的修改,需要自己定义一个sigset_t的变量,通过sigfillset等函数对其进行构造,在将该变量作为参数传入sigprocmask中,实现对blocked的修改

信号处理程序的编写原则
信号处理程序与主程序并发运行,享用同样的全局变量,在编写的时候需要保持谨慎,遵守以下的保守原则
- 信号处理程序要尽量简单,如只是设置标识符,将处理交给主函数
- 采用异步信号安全的函数,这些函数不会被中断,或者是可重入的

3.运行前保存,运行后恢复errno:信号处理程序可能会修改errno的值,主函数中某些函数返回值可能与该值相关
4.对于所有的信号进行阻塞,然后再恢复原阻塞状态
5.使用sig_atomic_t和volatile定义全局变量,volatile告诉编译器该变量不稳定,不要放入寄存器中,而是直接内存访问,避免因为编译器过高的优化导致对该变量的行为改变;sig_atomic_t保证了该变量读写的原子性,即不会被中断
6.注意标准信号不排队机制:当内核检查到pending位为1时,可能已经接收了多个标准信号,需要对这些信号处理不止一次
例如以下的对SIGCHILD子进程终止的信号处理程序就使用while对信号进行多次处理,防止了僵尸进程

7.进行流同步:由于信号处理程序和主函数并发运行,当两者之间存在依赖关系时,如主函数先执行add操作,信号处理程序执行del操作,如果信号处理程序先执行,就可能出错,我们称之为发生了竞争。我们可以通过将对应的信号阻塞,当主函数执行完毕后,再取消阻塞,就保证了流同步
显示地等待信号
在shell执行前台任务时,会一直等待直到该任务结束,即传入SIGCHILD信号。我们假设信号处理函数会将pid设置为wait函数的返回值,可以写出以下代码
while (!pid) {
}
do something...
但是这段代码的缺陷是它大量执行了循环的判断,而不是子进程,这导致了处理器资源的浪费,考虑如下的代码
while (!pid) {
pause();
}
do something...
看起来这段代码将当前进程挂起,直到收到了SIGCHILD信号。但是如果SIGCHILD信号在if和pause之间发生,就永远不会接收到信号,导致一直被挂起,所以我们需要让该操作具有原子性,不会被中断。可以使用sigsuspend函数
int sigsuspend(const sigset_t *mask);
该函数暂时地将blocked设置为mask,并执行pause,结束后恢复原来的blocked,且该函数具有原子性


非本地跳转
C提供了一种用户级的异常控制流方式,称为非本地跳转,可以直接从一个正在执行的函数转移到另一个函数,使用setjmp,longjmp实现
#include <setjmp.h>
int setjmp(jmp_buf env);
int longjmp(jmp_buf env, int retval);
通过setjmp,将当前的调用环境放入env中并且返回0;调用longjmp,将控制转移到env被setjmp,调用环境也设置为与env中的相同,并使得转移位置的setjmp返回值为retval
注意setjmp不会保存pending和blocked
通过非本地跳转,我们得以实现嵌套函数的退出


通过sigsetjmp,我们得以保存pending和blocked位,使得跳转后这两个位向量等于设置时的位向量


浙公网安备 33010602011771号