第八章 异常控制流
学习目标:
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
一、知识点梳理
(一)异常控制流概述
-
控制转移:从一条指令到下一条指令的过渡。
-
控制流:控制转移序列。
- 最简单的控制流:平滑的序列,每条指令在存储器中都是相邻的。 - 平滑流的突变:由于跳转、调用和返回等指令造成两条指令不相邻。
-
异常控制流(Exceptional Control Flow, ECF):现代操作系统通过使控制流发生突变来对系统状态做出反应,这些突变称为异常控制流。
-
异常控制流发生在计算机系统的各个层次
- 硬件层:硬件检测到的事件会触发控制突然装移到异常处理程序 - 操作系统层:内核通过上下文转换将控制从一个用户进程转移到另一个用户进程。 - 应用层:一个进程可以发送信号到到另一个进程,而接收者将会控制突然转移到它的一个信号处理程序。 - 一个程序可以通过回避通常的栈规则,并执行到其它函数中任意位置的非本地跳转来对错误做出反应。
-
ECF是操作系统用来实现I/O、进程和虚拟存器的基本机制。
-
应用程序通过使用一个叫做陷阱或者系统调用的ECF形式,向操作系统请求服务。
-
操作系统为应用程序提供了强大的ECF机制,用来创建新进程、等待进程终止、通知其他进程中系统的异常事件、检测和相应这些事件。
-
ECF是计算机系统中实现并发的基本机制。
-
软件异常允许程序进行非本地跳转来响应错误情况。非本地跳转是一种应用层ECF,在C中通过setjump和longjmp提供。
(二)异常
1.异常的剖析
- 异常是异常控制流的一种形式,是控制流中的突变,用来响应处理器状态中的某些变化,由硬件和操作系统实现。
- 事件:状态变化,可能和当前指令的执行有关。
异常处理程序完成后
- 1)处理程序将控制返回给事件发生时正在执行的当前指令
- 2)处理程序将控制返回给没有发生异常将会执行的下一条指令
- 3)处理程序终止被中断的程序
2.异常处理
(1)异常表与异常号
- 异常表:当处理器检测到有事件发生时,它会通过跳转表,进行一个间接过程调用(异常),到异常处理程序。系统启动时操作系统分配和初始化一张异常表。
- 异常号:系统中可能的某种类型的异常都分配了一个唯一的非负整数的异常号。异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器里。
(2)异常与过程调用
异常类似于过程调用,但是有一些重要的不同:
- 处理器压入栈的返回地址,是当前指令地址或者下一条指令地址。
- 处理器也把一些额外的处理器状态压到栈里
- 如果控制一个用户程序到内核,所有项目都压到内核栈里。
- 异常处理程序运行在内核模式下,对所有的系统资源都有完全的访问权限。
3.异常的类别
- 故障指令:同步发生的,执行当前指令的结果。
(1)中断
- 来自处理器外部I/O设备的信号的结果
- 异步发生
- 总是返回到下一条指令
(2)陷阱
- 有意的异常
- 同步发生
- 最重要的用途是提供系统调用。系统调用运行在内核模式中,并且可以访问内核中的栈
- 总是返回到下一条指令
(3)故障
- 由错误状况引起
- 可能能够被故障处理程序修正
- 要么重新执行引起故障的指令,要么终止
- 经典的故障:缺页异常。
(4)终止
- 不可恢复的致命错误造成的结果,通常是硬件错误。
- 不会将控制返回给应用程序。
4.Linux/IA32系统中的异常
- 一共有256种不同的异常类型。
(1)故障和终止
- 除法错误(浮点异常)
- 异常0
- 原因:除以零,或结果太大
- 终止程序
- 一般保护故障(段故障)
- 异常13
- 原因:程序引用了一个未定义的虚拟存储器区域,或因为程序试图写一个只读的文本段
- 终止程序
- 缺页
- 异常14
- 重新执行产生故障的指令
- 返回当前地址
- 机器检查
- 异常18
- 在导致故障的指令执行中检测到致命的硬件错误
- 终止程序
(2)系统调用
-
每一个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量
-
在IA32中,系统调用通过一条陷阱指令提供:
int n
(n为异常号) -
所有的到Linux系统调用的参数都是通过寄存器传递的
- %eax:包含系统调用号 - %ebx,%ecx,%edx,%esi,%edi,%ebp:包含最多六个任意参数 - %esp:栈指针,不能使用,因为当进入内核模式时,内核会覆盖它。
(三)进程
进程的经典定义:一个执行中的程序的实例
- 系统中每个程序都是运行在某个进程上下文中的。
- 上下文是由程序正确运行所需的状态组成的。状态包括存放在存储器中的程序代码和数据,栈、通用目的寄存器内容、程序计数器、环境变量和打开文件描述符的集合。
进程提供给应用程序的关键抽象
- 一个独立的逻辑控制流,提供一个假象:程序独占地使用处理器
- 一个私有的地址空间,提供一个假象:程序独占地使用存储器系统
1.逻辑控制流
程序计数器(PC)值的序列叫做逻辑控制流,简称逻辑流。
- 进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占,然后轮到其他进程。
2.并发流
一个逻辑流的执行在时间上与另一个流重叠(与是否在同一处理器无关),这两个流并发的运行。
-
并发:多个流并发的执行
-
多任务:一个进程和其他进程轮流运行(也叫时间分片)
-
时间片:一个进程执行它的控制流的一部分的每一时间段
-
并行:两个流并发的运行在不同的处理机核或者计算机上。
并行流并行的运行,并行的执行。
3.私有地址空间
- 进程为程序提供的假象,好像它独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个存储器字节是不能被其他进程读写的。
4.用户模式和内核模式
用户模式和内核模式的区别就在于用户的权限上,权限指的是对系统资源使用的权限。
(1)模式位
- 处理器通常用某个控制寄存器中的一个模式位来描述进程当前享有的特权。
- 设置模式位:内核模式(超级用户)
- 没有设置模式位:用户模式
(2)转换模式
- 运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过如中断、故障或陷入系统调用之类的异常。
(3)/proc文件系统
- 将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。它允许用户模式进程访问内核数据结构的内容。
5.上下文切换
- 操作系统内核使用上下文切换这种较高层形式的异常控制流来实现多任务。上下文切换机制建立在较底层异常机制之上。
(1)上下文
内核重新启动一个被抢占的进程所需的状态。
- 由一些对象的值组成:通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈、内核数据结构(页表、进程表、文件表)。
(2)上下文切换机制
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程。
(3)上下文切换原因
- 内核代表用户执行系统调用时(进程休眠)
- 中断
(四)系统调用错误处理
1.错误处理
包装函数被封装在一个源文件(csapp.c)中,这个文件被编译和链接到每个程序中。一个独立的头文件(csapp.h)中包含这些包装函数的函数原型。
(1)Unix系统中的错误处理
-
Unix风格的错误处理
-
通常形式:
if ((pid = wait(NULL))<0){ fprintf(stderr,"wait error: %s\n",strerror(errno)); exit(0); }
-
strerror函数返回某个errno值的文本描述
-
-
Posix风格的错误处理
-
任何有用的结果都返回在通过引用传递进来的函数参数中
-
通常形式:
if ((retcode = pthread_create(&tid,NULL,threadd,NULL))!=0){ fprintf(stderr,"pthread_create error: %s\n",strerror(retcode)); exit(0); }
-
-
DNS风格的错误处理
-
gethostbyname和gethostbyaddr函数失败时返回NULL指针,并设置全局变量h_errno。
-
通常形式:
if ((p = gethostbynae(name)) ==NULL)} fprintf(stderrr,"gethostbyname error: %s\n:",hstrerror(h_errno)); exit(0); }
-
-
错误报告函数
(2)错误处理包装函数
- 系统使用错误处理包装函数,系统级函数是小写,包装函数名大写。
- 包装函数调用基本函数,有问题终止,如果没有问题和基本函数一致。
-
Unix风格的错误处理包装函数
void Kill(pid_t pid,int signum) { int rc; if ((rc = kill(pid,signum))<0) unix_error("Kill error"); }
- 成功返回void,如果错误,打印一条消息然后退出。
-
Posix风格的错误处理包装函数
void Pthread_detach(pthread_t tid){ int rc; if ((rc = pthread_detach(tid)) != 0) posix_error(rc,"Pthread_detach error"); }
- 成功时返回void。
-
DNS风格的错误处理包装函数
struct hostent *Gethostbyname(const char *name) { struct hostent *p; if ((p = gethostbyname(name)) == NULL) dns_error("Gethostbyname error"); return p; }
(五)进程控制
1.每个进程都有一个唯一的正数进程ID(PID)。
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void); 返回调用进程的PID
pid_t getppid(void); 返回父进程的PID(创建调用进程的进程)
2.创建和终止进程
(1)进程的三种状态
- 运行
- 停止:被挂起且不会被调度
- 终止:永远停止。
- 收到信号,默认行为为终止进程
- 从主程序返回
- 调用exit函数
(2)创建进程
-
父进程通过调用fork函数来创建一个新的运行子进程。fork函数定义如下:
#include <sys/types.h> #include <unistd.h> pid_t fork(void);
-
fork函数只被调用一次,但是会返回两次:父进程返回子进程的PID,子进程返回0.如果失败返回-1。
(3)终止进程
-
exit函数
#include <stdlib.h> void exit(int status);
-
exit函数以status退出状态来终止进程。
使用fork创建一个进程
- 调用一次,返回两次
- 并发执行,内核能够以任何方式交替执行它们的逻辑控制流中的指令
- 相同但是独立的地址空间:每个进程有相同的用户栈、本地变量值、堆、全局变量值和代码。但是父进程和子进程都有自己的私有地址空间。
- 共享文件:子进程继承了父进程所有的打开文件。
调用fork函数n次,产生2^n个进程。
3.回收子进程
-
进程终止后还要被父进程回收,否则处于僵死状态。
-
如果父进程没有来得及回收,内核会安排init进程来回收他们。init进程的PID为1.
-
一个进程可以通过调用waitpid函数来等待它的子进程终止或停止。
#include <sys/types.h> #include <sys/wait.h> pid_t waitpid(pid_t pid, int *status, int options); - 成功返回子进程PID,如果WNOHANG,返回0,其他错误返回-1。
(1)判定等待集合的成员
pid>0 等待集合是一个单独子进程,进程ID等于pid
pid=-1 等待集合是由父进程所有的子进程组成
(2)修改默认行为
-
设置常量WNOHANG和WUNTRACED
- WHOHANG - 默认:挂起调用进程 - 修改:如果等待集合中的任何子程序都没有终止,立即返回 - 返回值:0 - WUNTRACED - 默认:返回已终止的子进程 - 修改:挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止 - 返回值:导致返回的已终止或被终止子进程的PID - WNOHANG | WUNTRACED - 修改:立即返回 - 返回值:0,或者返回值等于被停止或者已停止的子进程PID
(3)检查已回收子进程的退出状态
-
status参数:在wait.h头文件中定义了解释status参数的几个宏
WIFEXITED:如果子进程通过调用exit或一个返回正常终止,就返回真 WEXITSTATUS:返回一个正常终止的子进程的退出状态。只有在WIFEXITED返回为真时,才会定义这个状态 WIFSIGNALED:如果子进程是因为一个未被捕获的信号终止的,那么返回真 WTERMSIG:返回导致子进程终止的信号的编号。只有在WIFSIGNALED返回为真时才定义这个状态 WIFSTOPPED:如果引起返回的子进程当前是被停止的,那么返回真 WSTOPSIG:返回引起子进程停止的信号的数量。只有在WIFSTOPPED返回为真时才定义这个状态
(4)错误条件
- 若调用进程没有子进程,waitpid返回-1,设置errno为ECHILD。
- 若waitpid被一个信号中断,返回-1,设置errno为EINTR。
(5)wait函数
-
wait函数是waitpid函数的简单版本,wait(&status)等价于waitpid(-1,&status,0).
#include <sys/types.h> #include <sys/wait.h> pid_t wait(int *status); - 成功返回子进程pid,出错返回-1
4.让进程休眠
(1)sleep函数
#include <unistd.h>
unsigned int sleep(unsigned int secs);
- sleep函数使一个进程挂起一段指定的时间。
- 返回值是剩下还要休眠的秒数,时间结束返回0.
(2)pause函数
#include <unistd.h>
int pause(void);
- 让调用函数休眠,直到该进程收到一个信号。
5.加载并运行程序
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
- 成功不返回,失败返回-1。
(1)参数列表argv
(2)环境变量envp
(3)主函数
int main(int argc, char **argv, char **envp)
- 当main开始执行时,用户栈组织结构:
(4)操作环境数组的函数
1)getnev函数
#include <stdlib.h>
char *getenv(const char *name);
- 若存在则为指向name的指针,无匹配是null
- 在环境数组中搜寻字符串"name=value",如果找到了就返回一个指向value的指针,否则返回null。
2)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"写进数组。
(六)信号
- unix信号是一种更高层次的软件形式的异常,它允许进程中断其他进程。
- 一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。
- 每种信号类型都对应某种系统事件。底层的硬件异常是由内核异常处理程序处理的。
1.信号术语
(1)传送一个信号到目的进程
- 两个步骤:
1)发送信号。
内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。
- 发送信号的两种原因:
- 内核检测到一个系统事件。
- 一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程,一个进程可以发送信号给它自己。
2)接收信号。
当目的进程被内核强迫以某种方式的发送做出反应时,目的进程就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数不活这个信号。
(2)待处理信号
一个只发出而没有被接收的信号叫做待处理信号。
- 在任何时刻,一种类型至多只会有一个待处理信号。
- 一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,他仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
- 一个待处理信号最多只能被接收一次。
2.发送信号
(1)进程组
-
每个进程都只属于一个进程组,进程组是由一个进程组ID来标识的。
-
返回调用进程的进程组ID:getpgrp函数
#include <unistd.h> pid_t getpgrp(void);
-
默认的,一个子进程和它的父进程同属于一个进程组。
-
改变自己或者其他进程的进程组:setpgid函数
#include <unsitd.h> int setpgid(pid_t pid,pid_t pgid); - 成功返回0,错误返回-1
(2)用/bin/kill程序发送信号
可以向另外的进程发送任意的信号
/bin/kill -发送的信号值 进程号
(3)从键盘发送信号
- 在任何时刻,至多只有一个前台作业和0个或多个后台作业。外壳为每个作业创建一个独立的进程组,一个作业对应一个进程组。
(4)用kill函数发送信号
- 发送SIGKILL信号给它的子进程
(5)用alarm函数发送信号
- 发送SOGALARM信号给它的子进程
3.接收信号
-
当内核从一个异常处理程序返回,准备将控制传递给进程P时,他会检查进程P的未被阻塞的处理信号的集合。如果这个集合为空,那么内核将控制传递到P的逻辑控制流中的下一条指令;如果集合是非空的,那么内核选择集合中的某个信号K(通常是最小的K0,并且强制P接收信号K。收到这个信号会触发进程的某种行为。一旦进程完成了这个行为,那么控制就传递回P的逻辑控制流中的下一条指令。
- 每个信号类型都有一个预定的默认行为: - 进程终止 - 进程终止并转储存储器 - 进程停止直到被SIGCONT型号重启 - 进程忽略该信号
(2)signal函数
-
可以通过下列三种方法之一来改变和信号signum相关联的行为:
- 如果handler是SIG_IGN,那么忽略类型为signum的信号 - 如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为 - 否则,handler就是用户定义的函数的地址,这个函数成为信号处理程序,只要进程接收到一个类型为signum的信号,就会调用这个程序,通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序。
-
进程可以通过使用signal函数来修改和信号相关的默认行为。唯一的例外是SIGSTOP和SIGKILL,它们的默认行为不能被修改。
4.信号处理问题
(1)一个程序要捕获多个信号
- 待处理信号被阻塞。Unix信号处理程序通常会阻塞当前处理程序正在处理的类型的待处理信号。
- 待处理信号不会排队等待。任意类型至多只有一个待处理信号。因此,如果有两个类型为K的信号传送到一个目的进程,而由于目的进程当前正在执行信号K的处理程序,所以信号K时阻塞的,那么第二和信号就简单地被简单的丢弃,不会排队等待。
- 系统调用可以被中断。像read、wait和accept这样的系统调用潜在地会阻塞进程一段较长的时间,称为慢速系统调用。在某些系统中,当处理程序捕获到一个信号时,被中断的慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误的条件,并将errno设置为EINTR。
(2)重要的教训:不可以用信号来对其他进程中发生的事件计数。
5.可移植的信号处理
- 使用sigaction的一个包装函数,提供POSIX兼容系统上的可移植的信号处理。
6.显式地阻塞和取消阻塞信号
7.同步流避免并发错误
(七)非本地跳转
1.概念
c语言提供的用户级异常控制流形式,它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。
2.实现函数:setjmp和longjmp
#include <setjmp.h>
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigns);
int longjmp(jmp_buf env,int retval);
int siglongjmp(sigjmp_buf env, int retval);
- setjmp函数在env缓冲区中保存调用环境,以供longjmp使用,返回0。
- 调用环境:程序计数器,栈指针,通用目的寄存器
- longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回,返回非0
(八)操作进程的工具
-
STRACE:打印一个正在运行的程序和他的子程序调用的每个系统调用的痕迹
-
PS:列出当前系统中的进程(包括僵死进程)。
-
TOP:打印出关于当前进程资源使用的信息。
-
PMAP:显示进程的存储器映射。
-
/proc:一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取。例:
-
查看当前平均负载
-
查看cpu信息
-
二、其它
- 这章的书理解起来感觉很晦涩,前面感觉和操作系统是重叠的,但是后面信号的部分不是很好理解。
- 读的不细致,这两天需要重新再理解一下书上的代码。
三、参考资料
参考资料1:深入理解计算机系统(第二版)