异常控制流

  • 现代系统通过使控制流发生突变来对这些情况做出反应。一般而言,我们把这些突变称为异常控制客流。异常控制流发生在计算机系统的各个层次。比如,在硬件层,硬件检测到的事件会触发控制突然转移到异常处理程序。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到领域到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。
    • 理解ECF将帮助你理解重要的系统概念。ECF是操作系统给用来实现I/O、进程和虚拟内存的基本机制。
    • 理解ECF将帮助你理解应用程序是如何与操作系统交互的。
    • 理解ECF将帮助你编写有趣的新应用程序
    • 理解ECF将帮助你理解并发
    • 理解ECF将帮助你理解软件异常如何工作

异常

  • 异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。
  • 异常就是控制流中的突变,用来响应处理器状态中的某些变化。
  • 当处理器状态中发生一个重要的变化时,处理器正在执行某个当前指令$I_{curr}$。在处理器中,状态被编码为不同的位和信号。状态变化称为事件。事件可能和当前指令的执行直接相关。比如,发生虚拟内存缺页、算术溢出,或者一条指令试图除以零。另一方面,事件也可能和当前指令的执行没有关系。比如,一个系统定时器产生信号或者一个$I/O$请求完成。
  • 在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用,到一个专门设计用来处理这类事件的操作系统子程序。当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3种情况中的一种
    1. 处理程序将控制返回给当前指令$I_{curr}$,即当事件发生时正在执行的指令
    2. 处理程序将控制返回给$I_{next}$,如果没有发生异常将会执行的下一条指令
    3. 处理程序终止被中断的程序

异常处理

  • 系统种可能的每种类型的异常都分配了一个唯一的非负整数的异常号。其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点以及算术运算溢出。后者的示例包括系统调用和来自外部I/O设备的信号。
  • 在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目k包含异常k的处理程序的地址。
  • 在运行时(当系统在执行某个系统时),处理器检测到发生了一个事件,并且确定了相应的异常号k。随后,处理器触发异常,方法是执行间过程调用,通过异常表的表目k,转到相应的处理程序。异常号是到异常表中的索引,异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器里。
  • 异常类似于过程调用,但是有一些重要的不同之处:
    • 过程调用时,在跳转到处理程序之前,处理器将返回地址压入栈中。然后,根据异常的类型,返回地址要么是当前指令(当事件发生时正在执行的指令),要么是下一条指令(如果事件不发生,将会在当前指令后执行的指令)。
    • 处理器也把一些额外的处理器状态压到栈中,在处理程序返回时,重新开始执行被中断的程序会需要这些状态。
    • 如果控制从用户程序转移到内核,所有这些项目都被压到内核栈中,而不是压到用户栈中。
    • 异常处理程序运行在内核模式下,这意味着它们对所有的系统资源都有完全的访问权限
  • 一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完事件之后,它通过执行一条特殊的“从中断返回”指令,可选地返回到被中断的程序,该指令将适当的状态弹回到处理器的控制和数据寄存器中,如果异常中断的是一个用户程序,就将状态恢复为用户模式,然后将控制返回给被中断的程序。

异常的类别

  • 异常可以分为四类:中断、陷阱、故障和终止

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

中断

  • 中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序

陷阱和系统调用

  • 陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用

故障

  • 故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序
  • 一个经典的故障示例是缺页异常,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。

终止

  • 终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。

Linux/x86-64系统中的异常

  • 有高达256种不同的异常类型[50]0~31的号码对应的是由Intel架构师定义的异常,因此对任何x86-64系统都是一样的。32~255的号码对应的是操作系统定义的中断和陷阱。

    • 异常号描述异常类型
      0除法错误故障
      13一般保护故障故障
      14缺页故障
      18机器检查终止
      32~255操作系统定义的异常中断或陷阱

Liniux/x86-64故障和终止

除法错误
  • 当应用试图除以零时,或者当一个除法指令的结果对于目标操作数来说太大了的时候,就会发生除法错误。Unix不会试图从除法错误中恢复,而是选择终止程序。Linux Shell通常会把除法错误报告为“浮点异常”。
一般保护故障
  • 许多原因都会导致不为人知的一般保护故障,通常是因为一个程序引用了一个未定义的虚拟内存区域,或者因为程序试图写一个只读的文本段。LInux不会尝试恢复这类故障。Linux shell通常会把这种一般保护故障报告为“段故障”。
缺页
  • 缺页是会重新执行产生故障的指令的一个异常示例。处理程序将适当的磁盘上虚拟内存的一个页面映射到物理内存的一个页面,然后重新执行这条产生故障的指令。
机器检查
  • 机器检查是在导致故障的指令执行中国检测到致命的硬件错误时发生的。机器检查处理程序从不返回控制给应用程序。

Linux/x86-64系统调用

  • Linxu提供几百种系统调用,当应用程序想要请求内核服务器时可以使用,包括读文件、写文件或是创建一个新进程。

  • C语言用syscall函数可以指调用任何系统调用。然而,实际中几乎没必要这么做。对于大多数系统调用,标准C库提供了一组方便的包装函数。这些包装函数将参数打包到一起,以适当的系统调用指令陷入内核,然后将系统调用的返回状态传递回调用程序。

  • x86-64系统上,系统调用是通过一条称为syscall的陷阱指令来提供的。所有到Linux系统调用的参数都是通过通用寄存器而不是栈传递的。按照惯例,寄存器%rax包含系统调用号,寄存器%rdi%rsi%rdx%r10%r8%r9包含最多6个参数。第一个参数在%rdi种,第二个在%rsi种,以此类推。从系统调用返回时,寄存器%rcx%r11都会被破坏,%rax包含返回值。-4095-1之间的负数返回值表明了错误,对应于负的errno

    • 编号名字描述编号名字描述
      0read读文件33pause挂起进程直到信号到达
      1write写文件37alarm调度告警信号的传送
      2open打开文件39getpid获得进程ID
      3close关闭文件57fork创建进程
      4stat获得文件信息59execve执行一个程序
      9mmap将内存页映射到文件60_exit终止进程
      12brk重置堆顶61wait4等待一个进程终止
      32dup2复制文件描述符62kill发送信号到一个进程

进程

  • 异常是允许操作系统内核提供进程概念的基本构造块,进程是计算机科学中最深刻、最成功的概念之一。
  • 进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
    • 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器
    • 一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用内存系统

逻辑控制流

  • 即使在系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,好像它在独占的使用处理器。如果想用调式器单步执行程序,我们会看到一系列的程序计数器的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象的指令。这个PC值的序列叫做逻辑控制流,或者简称逻辑流。

并发流

  • 计算机系统中逻辑流有许多不同的形式。异常处理程序、进程、信号处理程序、线程和Java进程都是逻辑流的例子。
  • 一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地执行。
  • 多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)。因此,多任务也叫做时间分片(time slicing)。
  • 并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发地,即使它们是运行在同一个处理器上。不过,有时会发现确认并行流是很有帮助的,它是并发流的一个真子集。如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流,它们并行地运行,且并行地执行。

私有地址空间

  • 进程也为每个程序提供一种假象,好像它独占的使用系统地址空间。在一台n位地址的机器上,地址空间是$2^n$个可能地址的集合,$0,1,···,2^n-1$。进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上说,这个地址空间是私有的。
  • 尽管和每个私有地址空间相关联的内存的内容一般是不同的,但是每个这样的空间都有相同的通用结构
  • 地址空间底部是保留给用户程序的,包括通常的代码、数据、堆和栈段。代码段总是从地址$0 ×400000 $开始。地址空间顶部保留给内核(操作系统常驻内存的部分)。地址空间的这个部分包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代码、数据和栈。

用户模式和内核模式

  • 处理器提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围
  • 处理器通常是用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式种(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置
  • 没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器,改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
  • 运行应用程序代码的进程初始时是在用户模式中地。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
  • Linux提供了一种聪明的机制,叫做/proc文件系统,它允许用户模式进程访问内核数据结构的内容。/proc文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你可以使用/proc文件系统找出一般的系统属性,比如CPU类型,或者某个特殊的进程使用的内存段。

上下文切换

  • 操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。上下文切换机制是建立在前面已经讨论过的那些较低层异常机制之上的。
  • 内核为每个进程维持一个上下文。上下文就是内核重新启动一个被强占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。比如描述地址空间的页表,包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表
  • 在进程执行的某些事件,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策叫做调度,是由内核中称为调度器的代码处理的。

回收子进程

  • 当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程被称为僵死进程。

信号

  • 一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。
  • 每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。

信号术语

  • 传送一个信号到目的进程是由两个不同步骤组成的
    • 发送信号。内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号可以有如下两种原因
      1. 内核检测到一个系统事件,比如除零错误或者子进程终止
      2. 一个个进程调用了kill函数,显式地要求内核发送一个信号给目的进程。一个进程可以发送信号给他自己
    • 接受信号。当目的进程被内核强迫以某种方式对信号地发送做出反应时,它就接收了信号。进程可以忽略这个广告信号,终止或者通过执行一个称为信号处理程序的用户函数捕获这个信号。
  • 一个发出而没有被接收的信号叫做待处理信号。在任何时刻,一种类型至多只会有一个待处理信号。如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待;他们只是被简单地丢弃
  • 一个待处理信号最多只能被接收一次。