异常控制流
系统需要能够对系统状态的改变做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定和程序的执行相关。如:一个硬件定时器定期产生信号,这个事件必须得到处理。包到达网络适配器,必须放在内存中。程序向磁盘请求数据,然后休眠,直到被通知数据已经就绪。子进程终止时,创造这些子检查点父进程必须得到通知
现代系统通过使控制流突变来对这些情况做出反应,把这些突变称为异常控制流(ECF).异常控制流发生在计算机系统的各个层次:
硬件层
硬件检测到事件会触发控制突然转移到异常处理程序操作系统层
内核通过上下文切换将控制从一个用户进程转移到另一个用户进程应用层
一个进程可以发送信号到另一个进程,接受者会将控制突然转移到它的一个信号处理程序
异常
异常是异常控制流的一种形式,由两部分实现:
硬件实现
操作系统实现
异常的基本思想:
异常就是控制流中的突变,用来相应处理器状态的某些变化
处理器中,状态被编码成不同的位和信号,状态变化叫做事件
任何情况下,处理器检测到事件发生时,就会通过一个叫异常表的跳转表,进行一个间接过程调用,到一个专门设计用来处理这类事件的操作系统子程序
异常处理程序完成处理后,根据引起异常的事件类型,会发生下列三种情况中一种:
处理程序将控制返回给当前指令Icurr,即当事件发生时正在执行的指令
处理程序将控制返回给Inext,如果没有发生异常将会执行的下一条指令
处理程序终止被中断的程序
异常处理
系统中为可能出现的每种类型的异常都分配了一个唯一的非负整数的异常号,其中一些号码由处理器的设计者分配,其他号码由操作系统内核设计者分配
系统启动时,操作系统分配和初始化一张叫做异常表的跳转表,使得条目k包含异常k的处理程序的地址
异常表格式:
运行时,处理器检测到发生了一个事件,并且确定了相应的异常号k,随后处理器触发异常,方法是执行间接过程调用,通过异常表的表目k,转到相应的处理程序
图示(处理器如何使用异常表来形成适当的异常处理程序的地址):
异常与过程调用的不同之处:
过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中,但根据异常的类型,返回地址要么是当前指令,要么是下一条指令
处理器也把一些额外的处理器状态压到栈中,在处理程序返回时,重新开始执行被中断的程序也会需要这些状态
如果控制从用户程序转移到内核,所有这些项目都被压到内核栈里面,而不是压到用户栈中
异常处理程序运行在内核模式下,意味着它们对所有的系统资源都有完全的访问权限
异常的类别
类型 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自IO设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可以恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
中断
异步发生,来自处理器外部的IO设备的信号的结果,硬件中断不是由任何一条专门的指令造成的,从这意义来说是异步,硬件中断的异常处理程序常常叫做中断处理程序
图示(中断的处理):
陷阱 & 系统调用
陷阱:有意的异常,是执行一条指令的结果
陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用
用户程序经常需要访问内核请求服务。如读文件,创建新的进程等。为了允许对这些内核服务的受控制的访问,处理器提供了一条特殊的“syscall n”指令,当用户程序想要请求服务n时,可以执行这条指令,执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序
图示(系统调用的处理):
故障
故障由错误引起,可能能够被故障处理程序修正,当故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它,否则处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序
图示(故障的处理):
终止
终止时不可恢复的致命错误造成的结果,通常是一些硬件错误
Linux/x86-64中的异常
Linux/x86-64故障和终止
除法错误
应用试图除以0时,或者一个除法指令的结果对于目标操作数来说太大了的时候。就会发生除法错误。
一般保护故障
许多原因会引起一些不为人知的一般故障保护,如一个程序引用了一个未定义的虚拟内存区域等
缺页
一个会重新执行产生故障的指令的一个异常示例
机器检查
机器检查是在导致故障的指令执行中检测到致命的硬件错误时发生的
Linux/x86-64系统调用
图示(Linux下常见的系统调用):
c程序用syscall函数可以直接调用任何系统调用,但有更好的做法,对于大多数系统调用,标准C库提供了一组方便的包装函数,这些包装函数将参数打包到一起,以适当的系统调用指令陷入内核,然后将系统调用的返回状态传递回调用程序
x86-64系统上,系统调用是通过一条syscall的陷阱指令提供的,所有到Linux系统调用的参数都是通过通用寄存器而不是栈传递的
例子:
hello程序,用系统级函数write,而不是printf
int main()
{
write(1,"hello,world\n",13);
_exit(0);
}
上述代码的汇编版本:
000000000000068a :
68a: 55 push %rbp
68b: 48 89 e5 mov %rsp,%rbp
68e: ba 0d 00 00 00 mov $0xd,%edx
693: 48 8d 35 aa 00 00 00 lea 0xaa(%rip),%rsi # 744 <_IO_stdin_used+0x4>
69a: bf 01 00 00 00 mov $0x1,%edi
69f: b8 00 00 00 00 mov $0x0,%eax
6a4: e8 b7 fe ff ff callq 560
6a9: bf 00 00 00 00 mov $0x0,%edi
6ae: e8 9d fe ff ff callq 550 <_exit@plt>
6b3: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
6ba: 00 00 00
6bd: 0f 1f 00 nopl (%rax)
进程
异常时允许操作系统内核提供进程概念的基本构造块
进程的经典定义就是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存中的程序的代码和数据,栈,通用目的寄存器的内存,程序计数器等
逻辑控制流
即时系统有许多程序在运行,进程也可以向每个程序提供一种假象,好像它在独占的使用处理器,假设想要调试单步执行程序,可以看到一系列的程序计数器的值,这些值唯一对应于包含在可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令,这个PC值的序列叫做逻辑控制流,或简称逻辑流
下面图示中:
运行着三个进程,处理器的物理控制流被分成三个逻辑流,每个进程一个,每个竖直的条表示一个进程的逻辑流的一部分,三个逻辑流执行是交错的,A运行一会,B开始运行到完成,C运行一会
上图的关键点在于进程是轮流使用处理器的
每个进程执行它的流的一部分,然后被抢占,轮到其它进程
并发流
一个逻辑流的执行时间上与另一个流重叠,叫做并发流
多个流并发执行叫做并发,一个进程和其它进程轮流运行叫做多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片
私有地址空间
进程也为程序提供了另一种假象:
程序好像是在独占的使用系统地址空间
考虑一台n位地址的机器,地址空间是 2^n个可能的地址集合,进程为每个cx提供它自己的私有地址空间
尽管每个私有地址空间相关联的内存的内容是不同的,但是每个这样的空间都有一样的通用结构
x86-64Linux进程的地址空间的组织结构:
用户模式 & 内核模式
处理器需要提供一种机制:限制一个应用可以执行的指令以及它可以访问的地址空间范围
处理器通常是某个控制寄存器中的一个模式位来提供这种功能,该寄存器描述了进程当前享有的特权
设置了模式位,进程就运行在内核模式,此时进程可以执行指令中任何指令,可以访问系统中的任何内存位置
没有设置模式位,运行在用户模式,此时进程不允许执行特权指令,如停止处理器,改变模式位,发起IO操作等
运行应用程序代码的进程初始时在用户模式,进程从用户模式切换为内核模式的唯一方法就是通过中断,故障或者陷入系统调用这样的异常
上下文切换
操作系统内核使用一种叫做上下文切换的较高层形式的异常控制流来实现多任务
内核为每个进程维护一个上下文。
上下文就是重新启动一个被抢占的进程所需的状态。包括:
由一些对象的值组成:
- 通用目的寄存器
浮点寄存器
程序计数器(PC)
- 用户栈
- 状态寄存器
- 内核栈
各种内核数据结构
- 描绘地址空间的页表
- 包含当前进城信息的进程表
- 进程已打开文件信息的文件表
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定叫做调度(shedule),由内核中称为调度器(scheduler)的代码处理的。
- 当内核选择一个新的进程运行时,我们就说内核调度了这个进程。
当调度进程时,使用一种上下文切换的机制来控制转移到新的进程
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程
什么时候会发生上下文切换
内核代表用户执行系统调用:
- 如果系统调用因为某个事件阻塞,那么内核可以让当前进程休眠,切换另一个进程。
- 或者可以用sleep系统调用,显式请求让调用进程休眠。
- 即使系统调用没有阻塞,内核可以决定执行上下文切换
中断也可能引发上下文切换:
所有系统都有某种产生周期性定时器中断的机制,典型为1ms,或10ms。
每次定时器中断,内核就能判断当前进程运行了足够长的时间,切换新的进程。
上图中,进程A和B,A初始运行在用户模式,直到通过系统调用read陷入内核,内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器从磁盘到内存的数据传输后,磁盘中断处理器
磁盘读取数据费时比较长,所以内核执行从A到B的上下文切换,切换之前,内核代表A在用户模式下执行命令,在切换的第一部分中,内核代表A在内核模式下执行指令,然后在某一时刻,它开始代表B(仍旧是内核模式)执行指令,切换后,内核代表B在用户模式下执行指令
B在用户模式下运行一段时间之后,直到磁盘发出一个中断信号,表示数据已经从磁盘传送到内存了,内核判定B已经运行了足够长时间,就执行一个从B到A的上下文切换
进程控制
Unix提供了大量从C程序中操作进程的系统调用
获取进程ID
每个进程都有一个唯一的正数进程ID(PID)
#include
#include
pid_t getpid(void);
pid_t getppid(void);
- PID是每个进程唯一的正数。
- getpid()返回调用进程的PID,getppid()返回它的父进程的PID。
- 返回一个类型pid_t的值,在Linux系统下在type.h被定义为int
创建和终止进程
进程总是处于下面三种状态
运行。进程要么在CPU中执行,要么等待执行,最终被内核调度。
停止。进程的执行被挂起,且不会被调度。
- 收到SIGSTOP,SIGTSTP,SIDTTIN或者SIGTTOU信号,进程就会停止。
- 直到收到一个SIGCONT信号,在这个时刻,进程再次开始运行。
- 信号是一种软件中断的形式。
终止。进程永远停止。
- 收到一个信号。信号默认行为是终止进程。
- 从主程序返回
- 调用exit函数
父进程通过调用fork函数创建一个新的运行子进程
#include
#include
pid_t fork(void);
返回:子进程返回0,父进程返回子进程的PID,如果出错,返回-1;
新创建的子进程几乎但不完全与父进程相同。
子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝。
- 包括文本,数据和bss段,堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝。
意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。
父进程和新创建的子进程之间最大的区别在于有不同的PID 。
fork()函数会第一次调用,返回两次,一次在父进程,一次在子进程。
- 返回值用来明确是在父进程还是在子进程中执行。
fork函数一个比较令人疑惑的点:
它只被调用一次,却会返回两次
- 一次是在调用进程(父进程)中
- 一次是在新创建的子进程中
父进程中,fork返回子进程的PID,子进程中,fork返回0
因为子进程的PID总是为非0,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行
使用fork来创建子进程的父进程例子:
#include "sys/types.h"
#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"
int main()
{
pid_t pid;
int x = 1;
pid = fork();
if(pid==0)
{
printf("child: x=%d\n",++x);
exit(0);
}
printf("parent : x=%d\n",--x);
exit(0);
}
编译执行:
parent : x=0
child: x=2
上述例子说明:
调用一次,返回两次
并发执行
一样但是独立的地址空间
共享文件
回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终结的状态,知道被它的父进程 回收(reap)。
当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程。
一个终止了但还未被回收的进程叫做僵死进程
如果父进程没有回收,而终止了,那么内核安排init进程来回收它们。
- init进程的的PID位1,在系统初始化时由内核创建的。
- 长时间运行的程序,如shell,服务器,总是应该回收他们的僵死子进程
一个进程可以通过调用waitpid函数来等待它的子进程终止或停止
#include
#include
pid_t waitpid(pid_t pid ,int *status, int options);
返回:如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1.
进程休眠
sleep函数将一个进程挂起一段指定时间
#include
unsigned int sleep (unsigned int secs);
返回:还要休眠的描述
如果请求的时间量到了,sleep返回0否则返回还剩下的要休眠的秒数
pause函数,让调用进程休眠,知道该进程收到一个信号
#include
int pause(void);
加载并运行程序
execve函数在当前进程的上下文中加载并运行了一个新程序。
#include
int execve(const char *filename,const char *argv[],const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数argv和环境变量列表envp。
只有当出现错误时,execve才会返回到调用程序