8.1 异常
- 所谓异常就是控制流过程中的突变,用来响应处理器状态中的某些变化;
- 处理器的状态的变化称为事件(event),事件可能和当前指令的执行相关(虚拟存储器缺页,算术溢出,除数为零),也可能没有关系(定时器信号,I/O请求完成);
- 当有异常发生时,处理器会通过异常表(exception table),进入异常处理程序(exception handler)。
- 通常异常处理程序处理完成之后会有三种情况:
- 处理程序将控制权返回给当前指令
- 处理程序将控制权返回给下一条指令
- 处理程序终止被中断的程序
8.1.1 异常处理
- 系统中可能的每种异常都分配了一个唯一的非负整数的异常号(exception number),一部分由处理器设计这分配(被零除、缺页、存储器访问违例、断点、算术溢出),一部分由操作系统内核设计者分配(系统调用和I/O设备的信号);
- 在启动系统是,操作系统维护了一个异常表,此异常表为一个跳转表;异常表的起始地址存储在异常表基址寄存器(exception table base register);
- 异常处理程序调用和一般的过程调用的不同之处
- 一般过程调用时会将返回地址压入栈中,而异常返回时要么返回当前指令,要么是下一条指令
- 处理会把一些处理器的状态压入栈中,返回时重新恢复在这些状态;
- 当控制从用户程序转移到内核时,所有这些项目都会被压入到内核的栈中,而不是用户的栈;
- 异常处理程序运行在内核的模式下,这意味着对所有的系统资源有完全的访问权限;
8.1.2 异常的类别
异常可以为四种:中断(interrupt)、陷阱(trap)、故障(fault)、终止(abort)
- 中断(中断是异常的一种,异常不等于中断)
- 中断是异步发生的,是来自处理器外部I/O设备信号的结果,而不是由任何一条指令造成的(异步);
- 中断的过程:
- I/O设备通过向处理的引脚发送一个发送信号,并将异常号放到系统总线上,以触发中断,异常号标识了触发中断的设备;
- 处理器注意到引脚的电压发生变化,便从系统总线上读取异常号,然后调用相应的中断处理程序;
- 当处理程序结束时,返回到下一条指令;
剩下的异常类型(陷阱、故障和终止)是同步发生的,是执行当前指令的结果。此类指令叫做故障指令(faulting instruction)。
- 陷阱和系统调用
- 陷阱是有意的异常;其用途是在用户程序和内核之间提供一个像过程一样的接口,叫系统调用;
- 当用户程序向内核请求服务时(read、fork、execve、 exit), 为了允许对这些内核 服务的受控访问,处理器提供了“syscall n”指令;当用户想调用服务n时,调用此指令,会导致到一个异常处理程序的陷阱,此异常处理程序对参数解码(应该是n),并调用相应的内核处理程序;
- 一般的过程调用运行在过程模式(限制了指令的类型、并且在相同的函数栈中),而系统调用运行在内核模式(允许系统调用执行指令(syscall),并且在内核栈中);
- 故障
- 由错误引起,可能被修正
- 当故障发生时,它就将控制转移到故障处理程序;如果程序能够修正这个错误的情况,就返回到引起故障的指令,否则返回到内核的abort例程;
- 终止
- 终止是不可恢复的致命的错误造成的,通常为硬件错误;终止程序不应将控制返回给应用程序;
8.2 进程
异常是允许操作系统提供进程(process)的概念所需要的基本构造块;
进程经典的定义是一个执行中的程序实例;系统中每个程序都是运行在某个进程上下文中的;上下文包括:存储器中的代码和数据、通用寄存器中的内容、程序计数器、栈、环境变量以及打开文件描述符的集合。
进程提供两个关键的抽象:
一个独立的逻辑控制流:它提供一个假象,好像程序独占的使用处理器;
一个私有的地址空间:它提供一个假象,好像程序独占的使用存储器系统;
8.2.1 逻辑控制流
逻辑控制流就是PC值对应的指令序列;
一个处理器的物理流可以分成多个逻辑流;
8.2.2 并发流
计算机中流有不同的形式:异常处理程序、进程、线程、信号处理程序;
并发流:一个逻辑流在执行时间上与另一个流重叠;
并发:多个流并发的执行的现象;
多任务:一个进程和其他进程轮流执行的概念称为多任务;
时间片:一个进程执行它的控制流的一部分的每一时间段;多任务也叫时间分片
并行流:并发的真子集,如果两个流并发的运行在不同的处理器核或者计算机上,称为并行流;
8.2.3 私有地址空间
- 一个进程为每个程序提供提供他自己的私有地址空间
- 和此空间中某个地址相关联的存储器字节时不能被其他进程读写的;
- 每个私有地址空间的内容不同,但有相同的通用结构;
- 32位进程代码段从0x08048000开始,64位进程从0x00400000开始;
- 地址空间顶部保留给内核,包含内核的代码、数据和栈;(例如执行一个系统调用);
8.2.4 用户模式和内核模式
- 处理器通常使用某个控制寄存器中的一个模式位(mode bit)来切换用户模式或者内核模式
- 内核模式可以执行所有的指令和访问任何存储器位置
- 用户模式则没有上述功能;任何尝试都会导致一般保护故障;必须通过系统调用接口访问内核代码和数据;
8.2.5 上下文切换
操作系统使用上下文切换这种较高形式的异常控制流来实现多任务;上下文切换机制是建立在前面所述的较低层异常机制之上的;
内核为每个进程维护一个上下文(内核重新启动一个被抢占的进程所需要的状态),上下文由:通用目的寄存器、浮点寄存器、程序计数器、用户栈、处理器状态寄存器、内核栈和内核数据结构(描绘地址空间的页表、有关当前进程信息的进程表、包含进程已打开文件的信息的文件表)
内核重新启动一个之前被抢占的进程叫调度,内核中称为调度器;
上下文切换:
- 保存当前进程的上下文;
- 恢复某个先前被抢占的进程的上下文;
- 将控制权交给新恢复的进程
发生上下文切换的情况:
当内核代表用户执行系统调用时,可能发生上下文切换;比如系统调用因为某个事件而阻塞,内核可以让该进程休眠,切换到另一个进程(比如read访问磁盘、sleep);在此种情况下,在内核中的工作一部分代表进程A,一部分代表进程B。
一般而言,即使系统调用没有发生阻塞,内核也可以决定执行上下文切换;
中断也可以发生上下文切换;比如所有系统都有产生某种周期性定时器中断的机制(1ms或者10ms),每当定时器中断时,切换新的进程;
8.3 系统调用错误处理
- 当Unix系统级函数遇到错误时,典型的会返回-1,并设置全局变量errno;
if((pid = fork()) < 0){
fprintf(stderr,"fork error: %s\n",strerror(errno)); //strerror()函数返回错误关联的字符串;
exit(0);
}
但这样使得代码繁琐,简单一层包装:
if((pid = fork()) < 0)
unix_error("fork error");
void unix_error(char * msg){
fprintf(stderr,"%s,%s\n",msg,strerror(errno));
exit(0);
}
使用错误包装处理函数:
pid_t Fork(void){
pid_t pid;
if((pid = fork()) < 0){
unix_error("fork error");
}
return pid;
}
8.4 进程控制
8.4.1 获取进程状态
pid_t getpid(void); //获取当前进程
pid_t getppid(void); //获取父进程
8.4.2 创建和终止进程
进程的三种主要状态:
运行 要么再CPU上运行,要么等待且最终被CPU执行;
停止 进程的执行被挂起,且不会被调度;
终止 进程永久停止
收到一个信号,改信号默认终止进程
从主程序返回 返回值为程序的最终返回状态
调用exit()函数 参数值为最终的返回状态
创建进程:
pid_t fork(void);
子与父进程的同与不同:
子进程得到父进程虚拟地址空间的一份拷贝,但是是独立的,包括代码段、数据段、推以及用户栈,此外还有文件描述符表(意味着子进程可以操作父进程打开的文件);
子进程与父进程最大的区别在于有不同的pid;
fork()函数的特点:
- 调用一次返回两次,一次在父进程中,返回子进程pid;一次在子进程中,返回0;
8.4.3 回收子进程
当进程终止时,还是保存在内核中的,等待被其父进程回收;
当父进程回收子进程时,内核将子进程的退出状态传递给父进程,然后抛弃子进程;(要求父进程回收为了获取子进程退出状态,内核会抛弃子进程,维护系统资源);终止却未被回收的子进程是僵尸进程;
如果父进程没有回收其子进程就终止,那么由init进程回收;
waitpid()函数:
pid_t waitpid(pid_t pid,int *status,int options);
参数pid:
- pid > 0 : 等待集合就一个单独的子进程,就是此pid;
- pid = -1:所有子进程;
- Unix进程组
参数options
- WNOHANG 立即返回,不等待;
- WUNTRACED 挂起调用进程,直到等待集合中一个进程被终止或者停止,返回相关pid;
检查子进程退出状态
status参数非空,则此参数保存函数退出状态;
WIFEXITED(status) 子进程通过exit或者return返回正常终止时,返回真。
- WEXITSTATUS(status)返回一个正常终止的子进程的状态,依赖于上一个函数返回真;
WIFSIGNALED(status) 如果子进程因为一个未捕获的信号终止,返回真;
- WTERMSIG (status),返回导致子进程终止的信号的数量,之上上一个函数返回真时;
WIFSTOPPED(status)如果返回的子进程当前是被停止的,返回真
- WSTOPSIG(status)返回引起子进程停止的信号的数量;依赖上一个函数返回真;
调用错误
当调用进程没有子进程时,返回-1,并且设置errno为ECHILD;
函数被一个信号中断,返回-1,并设置errno为EINTR;
wait函数
pid_t wait(int* status); //等价调用waitpid(-1,&status,0);
8.4.4 让进程休眠
unsigned int sleep(unsigned int secs);
- 如果请求时间到,则返回0;否则(函数被信号中断而过早的返回),返回剩下要休眠的秒数;
int pause(void);
- 让调用进程休眠,直到该进程收到信号;
8.4.5 加载并运行程序
int execve(const char* filename,const char argv[],const char *envp[]); //调用一次,成功不返回
参数 argv指向一个指针数组,每个指针指向一个参数串;
参数envp指向一个指针数组,每个指针指向一个环境变量串;
在加载了filename之后,execve调用启动代码,启动代码设置栈,并将控制传递给新程序的主函数;
8.5 信号
每种信号类型都对应某种系统事件;底层的硬件异常,是由内核异常处理程序处理的;信号提供了一种机制,通知用户进程发生了这些异常;
8.5.1 信号术语
发送信号
- 内核监测到一个系统事件;
- 一个进程调用kill函数,显式要求内核发送一个信号给目的进程;一个进程可以给自己发送信号。
接收信号
- 当目的进程被内核强制以某种方式对内核做出反应时,目的进程就接收了信号;
- 进程可以忽略这个信号,终止或者通过执行用户层函数信号处理程序;
一个只要发出而没有被接收的信号,叫待处理信号;
- 在任何时刻,一种类型,至多只有一个待处理信号,多余的同类型信号会被直接抛弃;
- 一个进程可以有选择的阻塞接收某种信号,此信号仍可被发送,但待处理信号不会被接收,直到进程取消对这种信号的阻塞;
- 带处理信号最多只能被处理一次,内核为每个进程维护着待处理信号的集合和被阻塞信号的集合;当接收到一个信号时,在集合中标记,当接收时,消除;
8.5.2 发送信号
Unix提供了多种向进程发送进程的机制。这些机制是基于进程组的;
进程组
- 每个进程只属于一个进程组,进程组ID号由正整数表示;
pid_t getpgrp(void);//获得当前进程的进程组id
int setpgid(pid_t pid,pid_t pgid);
- 当pid为0时,改变调用进程,非零时,对应进程
- 当pgid为0时,调用进程pid作为进程组号,非零时对应号;
- 用/bin/kill程序发送信号
/bin/kill -9 15213 当-15213时,结束进程组号为15213的每一个进程
- 从键盘发送信号
- 作业: shell对一个命令行求职而创建的进程;
- 任何时刻,只有一个前台作业,0个或者多个后台作业
- 外壳为每个作业创建独立的进程组
- ctrl+c 导致SIGINT被发送到外壳,外壳捕获信号,然后发送到前台进程中的每个进程
用kill函数发送信号
int kill(pid_t pid,int sig);
- pid > 0,发送给指定进程, pid < 0,给进程组每个进程
- 用alarm函数发送信号
unsigned int alarm(unsigned int secs);
- alarm函数和sleep函数由相似之处,被打断时都会返回剩余秒数
- 对alarm的调用都会取消正在等待的alarm,并返回秒数;没有真在等待的就返回0;
8.5.3 接收信号
当内核从异常程序返回时,会检查进程的未被阻塞的待处理信号的集合(pending&~block);原因:被阻塞说明条件还不满足,遂不予处理;
- 当集合为空时,说明没有待处理的消息,直接将逻辑控制流传递给下一条指令;
- 当集合不为空时,内核会选择集合中的某个信号(通常为值最小的那个),并强制进程接收信号,触发对应此信号的处理程序;处理完成后,控制流给下一指令;
- 信号的默认行为:
- 进程终止 (SIGKILL)
- 进程终止并转储存储器()
- 进程停止,直到被SIGCONT信号重启
- 进程忽略该信号(SIGCHLD)
进程可以使用signal函数修改信号的默认处理行为;
注意: SIGSTOP和SIGKILL是不可忽略的的;
此函数有三种修改关联方法:- handler为SIG_IGN,忽略该信号;
- handler为SIG_DEF,默认行为
- handler为用户定义的函数地址,则调用此信号处理程序;
当处理程序处理完时,控制通常会返回给下一个指令;但在某些系统中,被中断的系统掉用,会立即返回一个错误;(下面将讲述);
- 信号处理程序的逻辑控制流与主函数的逻辑控制流重叠;
8.5.4 信号处理问题
- 待处理信号被阻塞: 当当前的正在处理的信号处理程序是刚到达的信号类型时,待处理信号会被阻塞;
- 待处理信号不会排队等待:当有两个同类型的信号到达,且此类型的处理程序正在运行,信号被阻塞,且第二个信号直接被丢弃;所以不能用消息进行事件计数
- 系统调用可以被中断:像read、wait、accept这样会阻塞进程一段时间的(慢速系统调用),当处理程序捕获到一个信号时,被中断的系统调用返回时不再继续,直接返回错误;
8.5.5 可移植信号处理
- 不同系统对于信号的处理语义是有差异的(中断的慢速系统调用是重启还是放弃);
- sigaction函数指明了信号处理语义:
- sigaction包装函数:Signal
此函数信号处理语义如下: - 当前处理信号被屏蔽
- 信号不会排队(不会有超过1个的相同信号在排队)
- 只要有可能,被中断的系统调用会重启;
- 一旦被设置了处理函数,就一直会存在,除非将handler参数设置为SIG_DEF或者SIG_IGN;
8.5.6 显示地阻塞和取消阻塞信号
- sigprocmask函数改变当前已阻塞信号的集合,具体行为依赖于how值:
- SIG_BLOCK:添加set中的信号到block集合中;
- SIG_UNBLOCk:从block集合中删除se集合中的信号;
- SIG_SETMASK:将block集合设置为set集合;
8.5.7同步流以避免讨厌的并发错误
- 当多个控制流对同一资源进行访问时,容易发生竞争;
- 处理:
- 对于信号造成的,可以先屏蔽信号
- 对于调度策略造成的可以随机休眠;
8.6 非本地跳转
- C语言提供用户级异常控制流形式,非本地跳转;从一个函数转移到另一个函数,无需经过调用-返回序列;
- longjump函数从env中恢复调用环境,然后触发最近一次调用的setjump返回,返回longjump第二个参数;longjump从不返回;
注意: setjump调用一次,返回多次
- 第一次调用时,保存env环境,返回0;
第二次调用longjump时,setjump返回longjump第二个参数;
另一个应用,从中断程序中不返回到打断位置,而是利用保存的环境,重新启动;