异常控制流

2020-04-20  本文已影响0人  Cool_Pomelo

异常控制流

系统需要能够对系统状态的改变做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定和程序的执行相关。如:一个硬件定时器定期产生信号,这个事件必须得到处理。包到达网络适配器,必须放在内存中。程序向磁盘请求数据,然后休眠,直到被通知数据已经就绪。子进程终止时,创造这些子检查点父进程必须得到通知

现代系统通过使控制流突变来对这些情况做出反应,把这些突变称为异常控制流(ECF).异常控制流发生在计算机系统的各个层次:

异常

异常是异常控制流的一种形式,由两部分实现:

异常的基本思想:

图1.png

异常就是控制流中的突变,用来相应处理器状态的某些变化

处理器中,状态被编码成不同的位和信号,状态变化叫做事件

任何情况下,处理器检测到事件发生时,就会通过一个叫异常表的跳转表,进行一个间接过程调用,到一个专门设计用来处理这类事件的操作系统子程序

异常处理程序完成处理后,根据引起异常的事件类型,会发生下列三种情况中一种:

异常处理

系统中为可能出现的每种类型的异常都分配了一个唯一的非负整数的异常号,其中一些号码由处理器的设计者分配,其他号码由操作系统内核设计者分配

系统启动时,操作系统分配和初始化一张叫做异常表的跳转表,使得条目k包含异常k的处理程序的地址

异常表格式:

图2.png

运行时,处理器检测到发生了一个事件,并且确定了相应的异常号k,随后处理器触发异常,方法是执行间接过程调用,通过异常表的表目k,转到相应的处理程序

图示(处理器如何使用异常表来形成适当的异常处理程序的地址):

图3.png

异常与过程调用的不同之处:

异常的类别

类型 原因 异步/同步 返回行为
中断 来自IO设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可以恢复的错误 同步 可能返回到当前指令
终止 不可恢复的错误 同步 不会返回

中断

异步发生,来自处理器外部的IO设备的信号的结果,硬件中断不是由任何一条专门的指令造成的,从这意义来说是异步,硬件中断的异常处理程序常常叫做中断处理程序

图示(中断的处理):

图4.png

陷阱 & 系统调用

陷阱:有意的异常,是执行一条指令的结果

陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用

用户程序经常需要访问内核请求服务。如读文件,创建新的进程等。为了允许对这些内核服务的受控制的访问,处理器提供了一条特殊的“syscall n”指令,当用户程序想要请求服务n时,可以执行这条指令,执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序

图示(系统调用的处理):

图5.png

故障

故障由错误引起,可能能够被故障处理程序修正,当故障发生时,处理器将控制转移给故障处理程序,如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它,否则处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序

图示(故障的处理):

图6.png

终止

终止时不可恢复的致命错误造成的结果,通常是一些硬件错误

Linux/x86-64中的异常

Linux/x86-64故障和终止

除法错误

应用试图除以0时,或者一个除法指令的结果对于目标操作数来说太大了的时候。就会发生除法错误。

一般保护故障

许多原因会引起一些不为人知的一般故障保护,如一个程序引用了一个未定义的虚拟内存区域等

缺页

一个会重新执行产生故障的指令的一个异常示例

机器检查

机器检查是在导致故障的指令执行中检测到致命的硬件错误时发生的

Linux/x86-64系统调用

图示(Linux下常见的系统调用):

图7.png

c程序用syscall函数可以直接调用任何系统调用,但有更好的做法,对于大多数系统调用,标准C库提供了一组方便的包装函数,这些包装函数将参数打包到一起,以适当的系统调用指令陷入内核,然后将系统调用的返回状态传递回调用程序

x86-64系统上,系统调用是通过一条syscall的陷阱指令提供的,所有到Linux系统调用的参数都是通过通用寄存器而不是栈传递的

例子:

hello程序,用系统级函数write,而不是printf


int main()
{

    write(1,"hello,world\n",13);
    _exit(0);
}

上述代码的汇编版本:


000000000000068a <main>:
 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 <write@plt>
 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运行一会

图8.png

上图的关键点在于进程是轮流使用处理器的

每个进程执行它的流的一部分,然后被抢占,轮到其它进程

并发流

一个逻辑流的执行时间上与另一个流重叠,叫做并发流

多个流并发执行叫做并发,一个进程和其它进程轮流运行叫做多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片

私有地址空间

进程也为程序提供了另一种假象:

程序好像是在独占的使用系统地址空间

考虑一台n位地址的机器,地址空间是 2^n个可能的地址集合,进程为每个cx提供它自己的私有地址空间

尽管每个私有地址空间相关联的内存的内容是不同的,但是每个这样的空间都有一样的通用结构

x86-64Linux进程的地址空间的组织结构:

图9.png

用户模式 & 内核模式

处理器需要提供一种机制:限制一个应用可以执行的指令以及它可以访问的地址空间范围

处理器通常是某个控制寄存器中的一个模式位来提供这种功能,该寄存器描述了进程当前享有的特权

运行应用程序代码的进程初始时在用户模式,进程从用户模式切换为内核模式的唯一方法就是通过中断,故障或者陷入系统调用这样的异常

上下文切换

操作系统内核使用一种叫做上下文切换的较高层形式的异常控制流来实现多任务

内核为每个进程维护一个上下文。

上下文就是重新启动一个被抢占的进程所需的状态。包括:

由一些对象的值组成:
各种内核数据结构

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定叫做调度(shedule),由内核中称为调度器(scheduler)的代码处理的。

当调度进程时,使用一种上下文切换的机制来控制转移到新的进程

什么时候会发生上下文切换

内核代表用户执行系统调用:

中断也可能引发上下文切换:

图10.png

上图中,进程A和B,A初始运行在用户模式,直到通过系统调用read陷入内核,内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器从磁盘到内存的数据传输后,磁盘中断处理器

磁盘读取数据费时比较长,所以内核执行从A到B的上下文切换,切换之前,内核代表A在用户模式下执行命令,在切换的第一部分中,内核代表A在内核模式下执行指令,然后在某一时刻,它开始代表B(仍旧是内核模式)执行指令,切换后,内核代表B在用户模式下执行指令

B在用户模式下运行一段时间之后,直到磁盘发出一个中断信号,表示数据已经从磁盘传送到内存了,内核判定B已经运行了足够长时间,就执行一个从B到A的上下文切换

进程控制

Unix提供了大量从C程序中操作进程的系统调用

获取进程ID

每个进程都有一个唯一的正数进程ID(PID)

#include<sys/types.h>
#include<unistd.h>

pid_t getpid(void);
pid_t getppid(void);

创建和终止进程

进程总是处于下面三种状态

运行。进程要么在CPU中执行,要么等待执行,最终被内核调度。
停止。进程的执行被挂起,且不会被调度。
终止。进程永远停止。

父进程通过调用fork函数创建一个新的运行子进程

#include<sys/types.h>
#include<unistd.h>

pid_t fork(void);
返回:子进程返回0,父进程返回子进程的PID,如果出错,返回-1;

新创建的子进程几乎但不完全与父进程相同。

子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝。
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进程来回收它们。

一个进程可以通过调用waitpid函数来等待它的子进程终止或停止

#include<sys/types.h>
#include<sys/wait.h>

pid_t waitpid(pid_t pid ,int *status, int options);
返回:如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1.

进程休眠

sleep函数将一个进程挂起一段指定时间

#include <unistd.h>

unsigned int sleep (unsigned int secs);
返回:还要休眠的描述

如果请求的时间量到了,sleep返回0否则返回还剩下的要休眠的秒数

pause函数,让调用进程休眠,知道该进程收到一个信号

#include<unistd.h>

int pause(void);

加载并运行程序

execve函数在当前进程的上下文中加载并运行了一个新程序。

#include <unistd.h>

int execve(const char *filename,const char *argv[],const char *envp[]);

execve函数加载并运行可执行目标文件filename,且带参数argv和环境变量列表envp。

只有当出现错误时,execve才会返回到调用程序

上一篇下一篇

猜你喜欢

热点阅读