第八章异常控制流
一、学习目标
1. 了解异常及其种类
2. 理解进程和并发的概念
3. 掌握进程创建和控制的系统调用及函数使用:fork,exec,wait,waitpid,exit,getpid,getppid,sleep,pause,setenv,unsetenv,
4. 理解数组指针、指针数组、函数指针、指针函数的区别
5. 理解信号机制:kill,alarm,signal,sigaction
6. 掌握管道和I/O重定向:pipe, dup, dup2
二、学习资源
1. 教材:第八章《异常控制流》
2. 课程资料:https://www.shiyanlou.com/courses/413 实验九,课程邀请码:W7FQKW4Y
3. 教材中代码和习题中代码运行、思考一下,读代码的学习方法见这。
三、学习任务
1. 阅读教材,完成课后练习(书中有参考答案)
2. 考核:练习题把数据变换一下
3. 加分题:课后作业最多两人一组,互相不能重复,1星题目每人最多加一分,2星题目每人最多加二分,3星题目每人最多加三分,4星题目每人最多加四分。
四、学习过程
8.1 异常
从处理器运行开始到结束,程序计数器假设一个序列的值a0a1......an-1,这个控制转义序列叫做处理器的控制流,异常,就是控制流中的突变,用来响应处理器状态中的某些变化。
状态的变化称为事件,在任何情况下,当处理器检测到有事件发生时,会通过一张叫做异常表的跳转表,进行一个间接过程调用到专门处理程序——异常处理程序。当异常处理程序完成之后,根据引起引起异常的事件类型,会发生以下三种情况之一:
- 处理程序将控制返回给当前指令,即事件发生之时正在执行的指令。
- 处理程序将控制返回给如果没有异常将会执行的下一条指令。
- 处理程序终止被中断的程序。
异常处理
系统中可能的每种异常都被分配了唯一一个非负整数的异常号,异常表中的条目k中包含异常k的处理程序地址。异常表的起始地址存放在一个叫做异常表基址寄存器的特殊寄存器中。
异常类和过程调用的不同之处:
- 返回地址是当前地址或者下一条指令
- 处理器也会把额外的处理器状态压回栈中,在处理程序返回时,重新开始被中断的程序会需要这些状态。
- 如果控制从一个用户程序转移到内核,那么所有项目都会被压到内核栈中而不是用户栈。
- 异常处理程序运行在内核模式下,意味着他们对所有的系统资源拥有完全的访问权限。
异常的类别
1.中断
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。因此是异步的。硬件中断的异常处理程序通常称为中断处理程序。
其余异常类型都是同步发生的,是执行当前指令的结果。这一类指令称为故障指令。
2.陷阱
陷阱是有意的异常,最重要的用途是在用户程序和内核之间提供一个向过程一样的接口,叫做系统调用。
为了允许内核服务的受控访问,使用“syscall n”指令,跳转到一个异常处理程序的陷阱,处理程序对参数解码并调用适当的内核程序。
3.故障
故障由错误情况引起,可能能够被故障处理程序修正。故障发生时,处理器将控制转移给故障处理程序,若能修正,则将控制返回到引起故障的指令,重新执行;若不能修正,处理程序返回abort例程,终止引起故障的应用程序。
4.终止
终止是不可恢复的致命错误造成的结果,通常是硬件错误。终止处理程序将控制直接返回给abort例程,直接终止该应用程序。
Linux/A32系统中的异常
Linux/A32故障和终止
- 除法错误(异常0):应用试图除以0,或者除法指令的结果对于目标操作数过大。
- 一般故障保护(异常13):通常因为一个程序引用一个未定义的虚拟存储区域,或者试图写一个只读文本段。
- 缺页(异常14):处理程序将磁盘上虚拟存储器相应页面映射到物理存储器的一个页面,然后重新开始执行这条指令。
- 机器检查(异常18):在导致故障的指令执行中检测到致命的硬件错误。
Linux/A32系统调用
每个系统调用都对应着唯一的整数号,对应于一个到内核中跳转表的偏移量。
IA32系统调用是通过一条称为 int n 的陷阱指令提供的。
C程序通过syscall函数可以直接调用任何系统调用。
所有Linux系统调用都是通过通用寄存器而不是栈传递的,%eax包含系统调用号,%ebx、%ecx、%edx、esi%、%edi和%ebp包含最多6个参数。栈指针%esp不能使用,因为当进入内核模式时,内核会覆盖它。
8.2 进程
进程的经典定义:一个执行中的程序的实例。
系统中的每个程序都是运行在某个进程的上下文中的。
上下文:由程序正确运行所需的状态组成的。
进程提供给应用程序的关键抽象:
一个独立的逻辑控制流:独占的使用处理器
一个私有的地址空间:独占的使用存储器系统
1.逻辑控制流
(1)含义:
一系列的程序计数器PC的值,分别唯一的对应于包含子啊程序的可执行目标文件中的指令,或者是包含在运行时动态链接到程序的共享对象中的指令,这个PC值的序列就叫做逻辑控制流。
(2)运行:
进程轮流使用处理器,每个进程执行流的一部分,然后被抢占(暂时挂起)。每个进程执行它的流的一部分,然后被抢占,然后轮到其他进程。但是进程可以向每个程序提供一种假象,好像它在独占的使用处理器。
(3)示例:
异常处理程序、进程、信号处理程序、线程、Java进程
2.并发流
(1)含义
一个逻辑流的执行在时间上与另一个流重叠。【与是否在同一处理器无关】
这两个流并发的运行。
(2)几个概念
并发:多个流并发的执行
多任务:一个进程和其他进程轮流运行(也叫时间分片)
时间片:一个进程执行它的控制流的一部分的每一时间段
(3)并行
两个流并发的运行在不同的处理机核或者计算机上。
并行流并行的运行,并行的执行。
3.私有地址空间
进程为程序提供的假象,好像它独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个存储器字节是不能被其他进程读写的。
4.用户模式和内核模式
处理器通常是用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。
当设置了模式位时,进程就运行在内核模式中(超级用户模式)。
没有设置模式位时,进程就运行在用户模式中。
进程从用户模式变为内核模式的唯一方法是通过中断、故障或者陷入系统调用这样的异常实现的。
Linux提供/proc文件系统,允许用户模式进程访问内和数据结构的内容。
5.上下文切换
上下文:内核重新启动一个被抢占的进程所需的状态
操作系统内核使用上下文切换的较高层形式的异常控制流来实现多任务。
内核为每个进程维持一个上下文。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,成为调度。
由内核中成为调度器的代码处理的。
使用上下文切换的机制来控制转移到新的进程
保存当前进程的上下文
恢复某个先前被抢占的进程被保存的上下文
将控制传递给这个新恢复的进程
8.3 系统调用错误处理
- 当Unix系统级函数遇到错误时,它们典型地会返回-1,并设置全局证书变量errno来表示什么出错了。
8.4进程控制
获取进程ID
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void); 返回调用进程的PID
pid_t getppid(void);返回父进程的PID(创建调用进程的进程)
- getpid和getppid函数返回一个类型为pid_t的整数值,在Linux系统上它在types.h中被定义为int
创建和终止进程
- 进程总是处于下面三种状态之一
- 运行
- 停止:被挂起且不会被调度
- 终止:进程永远停止
- 终止原因:
- 收到一个信号,该信号的默认行为是终止进程
- 从主程序返回
- 调用exit函数
- 终止原因:
- 父进程通过调用fork函数创建一个新的运行子进程:
#include <sys/types.h> #include <unistd.h> pid_t fork(void);
- 子进程返回0,父进程返回子进程的PID,如果出错,则为-1.
- 父进程和新创建的子进程之间最大的区别:
- 它们有不同的PID
- fork函数只被调用一次,却返回两次。
- 一次是在父进程中
- 一次是在新创建的子进程中
- 在父进程中,fork返回子进程的PID
- 在子进程中,fork返回0.
- 子进程的PID总是非零的。
- fork函数的并发执行
- 父进程和子进程是并发运行的独立进程
- fork函数的相同的但是独立的地址空间
- fork函数的共享文件
- 调用fork函数n次,产生2的n次方个进程
回收子进程
- 一个终止了但还未被回收的进程称为僵死进程。
- 如果父进程没有回收它的僵死子进程就终止了,那么内核就会安排init进程来回收它们。
- 一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。
- 成功返回子进程PID,如果WNOHANG,返回0,其他错误返回-1.
- 判定等待集合的成员
- 如果pid>0,那么等待集合就是一个单独的子进程,它的进程ID等于pid。
- 如果pid=-1,那么等待集合就是由父进程所有的子进程组成的。
- 修改默认行为
- 可以通过将optioins设置为常量WNOHANG和WUNTRACED的各种组合。
- WNOHANG:如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为0)。默认的行为是挂起调用进程,直到有子进程终止。
- WUNTRACED:挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。返回的PID为导致返回的已终止或被停止子进程的PID。默认的行为是只返回已终止的子进程。
- WNOHANG|WUNTRACED:i级返回如果等待集合中没有任何子进程被停止或已终止的子进程。检查已终止和被停止的子进程时,该选项会有用。
- 可以通过将optioins设置为常量WNOHANG和WUNTRACED的各种组合。
- 检查已回收子进程的退出状态
- wait.h头文件定义了结束status参数的几个宏:
- WIFEXITED:如果子进程通过调用exit或者一个返回正常终止,就返回真。
- WEXXITSTATUS:返回一个正常终止的子进程的退出状态。只有在WIFEXITED返回为真时,才会定义这个状态。
- WIFSIGNALED:如果子进程时因为一个未被捕获的信号终止的,那么就返回真。
- WTERMSIG:返回导致子进程终止的信号的数量。只有在WIFSIGNALED返回为真时,才定义这个状态。
- WIFSTOPPED:如果引起返回的子进程当前是被停止的,那么就返回真。
- WSTOPSIG:返回引起子进程停止的信号的数量。只有在WIFSTOPPED返回为真时,才定义这个状态。
- wait.h头文件定义了结束status参数的几个宏:
- 错误条件
- 如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD。
- 如果waitpid函数被一个信号中断,那么它返回-1,并设置errno为EINTR。
wait函数
- 其是waitpid函数的简单版本
- 调用wait(&status)等价于调用waitpid(-1,&status,0)
让进程休眠
- sleep函数将一个进程挂起一段指定的时间
- 如果请求的时间量已经到了,sleep返回0,否则返回还剩下的要休眠的秒数。
- pause函数让调用函数休眠,直到该进程收到一个信号。
加载并运行程序
- execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。
- 只有出现错误时,execve才会返回到调用程序。
- 调用一次并从不返回。
- getenv函数在环境数据中搜索字符串"name=value",如果找到了,它就返回一个指向value的指针,否则它就返回NULL。
- 如果环境数据包含一个形如“name=oldvalue”的字符串,那么unsetenv会删除它,而setenv会用newvalue代替oldvalue,但是只有在overwrite非零时才会这样。如果name不存在,那么setenv就把“name=newvalue”添加到数组中。
让进程休眠
1.sleep函数
sleep函数使一个进程挂起一段指定的时间。定义如下:
#include <unistd.h>
unsigned int sleep(unsigned int secs);
返回值是剩下还要休眠的秒数,如果到了返回0.
2.pause函数
#include <unistd.h>
int pause(void);
让调用函数休眠,直到该进程收到一个信号。
加载并运行程序——execve函数
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
成功不返回,失败返回-1.
execve函数调用一次,从不返回。
- filename:可执行目标文件
- argv:参数列表
- envp:环境列表
新程序开始时:
getnev函数
#include <stdlib.h>
char *getenv(const char *name);
若存在则为指向name的指针,无匹配是null
在环境数组中搜寻字符串"name=value",如果找到了就返回一个指向value的指针,否则返回null。
setenv和unsetenv函数
#include <stdlib.h>
int setenv(const char *name, const char *newvalue, int overwrite);
若成功返回0,错误返回-1
void unsetenv(const char *name);
无返回值
如果环境数组包含"name=oldvalue"的字符串,unsetenv会删除它,setenv会用newvalue代替oldvalue,只有在overwrite非零时成立。
如果name不存在,setenv会将"name=newvalue"写进数组。
※fork函数和execve函数的区别
- fork函数是创建新的子进程,是父进程的复制体,在新的子进程中运行相同的程序,父进程和子进程有相同的文件表,但是不同的PID
- execve函数在当前进程的上下文中加载并运行一个新的程序,会覆盖当前进程的地址空间,但是没有创建一个新进程,有相同的PID,继承文件描述符。
8.5信号
- Unix信号:更高层的软件形式的异常允许进程中断其他进程。
信号术语
- 传递一个信号到目的进程的两个步骤:
- 发送信号
- 接收信号
- 发送信号的原因:
- 内核检测到一个系统事件
- 一个进程调用了kill函数,显式的要求内核发送一个信号给目的进程。
- 一个进程可以发送信号给它自己。
- 接收信号:
- 1.忽略
- 2.终止
- 3.执行信号处理程序,捕获信号
- 待处理信号:
- 只发出没有被接收的信号
- 任何时刻,一种类型至多只会有一个待处理信号,多的会被直接丢弃
- 一个进程可以选择性的阻塞接受某种信号,被阻塞仍可以被发送,但是不会被接收
- 一个待处理信号最多只能被接收一次。
- pending:待处理信号集合
- blocked:被阻塞信号集合。
发送信号——基于进程组
- 进程组
- 每个进程都只属于一个进程组。
- 进程组ID:正整数
- 一个子进程和他的父进程属于同一进程组。
- 查看进程组id:getpgrp
- 修改进程组:setpgid
- 用/bin/kill程序发送信号
- /bin/kill程序可以向另外的进程发送任意的信号,格式是:
- /bin/kill -n m
- n是信号,m是进程或进程组
- 当n>0时,发送信号n到进程m
- 当n<0时,使信号|n|发送到进程组m中的所有进程。
- 从键盘发送信号
- 在键盘输入ctrl-c会导致一个SIGINT信号被发送到外壳。
- 外壳捕获该信号,然后发送SIGINT信号到这个前台进程组中的每个进程。
- 在默认情况下,结果是终止前台作业。
- 输入ctrl-z会发送一个SIGTSTP信号到外壳,外壳捕获这个信号,并发送SIGTSTP信号给前台进程组中的每个进程。
- 在默认情况下结果时停止前台作业。
- 用kill函数发送信号
- 进程通过调用kill函数发送信号给其他进程(包括它们自己)
- 用alarm函数发送信号
- 进程可以通过调用alarm函数向它自己发送SIGALRM信号。
- 接收信号
- 信号类型的预定义的默认行为:
- 进程终止
- 进程终止并转储存储器
- 进程停止直到被SIGCONT信号重启
- 进程忽略该信号
- signal函数通过下列三种方法来改变和信号signum相关联的行为:
- 如果handler是SIG_IGN,那么忽略类型为signum的信号。
- 如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为。
- 否则,handler就是用户定义的函数的地址,这个函数称为信号处理程序,只要进程接收到一个类型为signum的信号,就会调用这个程序。
- 通过把狐狸程序的抵制传递到signal函数从而改变默认行为,称为设置信号处理程序。
- 调用信号处理程序称为捕获信号
- 执行信号处理程序称为处理信号
- 信号类型的预定义的默认行为:
信号处理问题
* 待处理信号被阻塞
* 带处理信号不会排队等待
* 系统调用可以被中断
* 不可以用信号来对其他进程中发生的事件计数
可移植的信号处理
- Signal包装函数设置了一个信号处理程序,其信号处理语义如下:
- 只有这个处理程序当前正在处理的那种类型的信号被阻塞。
- 和所有信号实现一样,信号不会排队等待。
- 只要可能,被中断的系统调用会自动重启。
- 一旦设置了信号处理程序,它就会移至保持,直到Signal带着handler参数为SIG_IGN或者SIG_DFL被调用。
显式地阻塞和取消阻塞信号 p517
- sigprocmask函数改变当前已阻塞信号的集合,具体行为依赖于how值:
- SIG_BLOCK:添加set中的信号到blocked中(blocked = blocked | set)
- SIG_UNBLOCK:从blocked中删除set的信号(blocked = blocked &~ set)
- SIG_SETMASK:blocked = set
- 如果oldset非空,blocked位向量以前的值会保存在oldset中。
非本地跳转
- 用户级的异常控制流形式,通过setjmp和longjmp函数提供。
- setjump函数在env缓冲区中保存当前调用环境,以供后面longjmp使用,并返回0.
- 调用环境:程序计数器,栈指针,通用目的寄存器
- longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回。然后setjmp返回,并带有非零的返回值retval。
- setjmp函数只被调用一次,但返回多次;
- longjmp函数被调用一次,但从不返回。
操作进程的工具
- STRACE:打印一个正在运行的程序和他的子程序调用的每个系统调用的痕迹
- PS:列出当前系统中的进程,包括僵死进程
- TOP:打印出关于当前进程资源使用的信息
- PMAP:显示进程的存储器映射