异常
从处理器加电一直到断电,程序计数器始终执行着一个序列的指令。每次从一个指令到下一条指令的过渡被称为控制转移。而这个控制转移序列则被称为处理器的控制流。最简单的控制流是一个平滑的序列,由诸如跳转,调用,返回一类的指令造成的。这些指令都是必要的,使程序能根据程序内部状态做出反映。
但是系统也应该能对系统状态的变化做出反应,这些系统状态不会被内部程序变量捕获,也不一定和程序的执行相关。可能是某个硬件向系统发出的信号或是请求。这个时候原本的控制流是难以处理这些情况的,所以现代的系统通过使控制流发生突变,从而对这些情况做出反应。一般而言,我们将这些突变称作异常控制流(ECF)
异常则是异常控制流的一种形式,指的是控制流中的突变,用来响应处理器的某些变化,下图就反映了这个过程:

当处理器状态发生一个重要的变化时,这个状态的变化我们称之为事件。事件和当前执行的指令相关,比如以0作为除数,算数溢出……
当处理器检测到事件发生时,它就会通过一张叫异常表的跳转表,进行一个间接过程的调用(异常),到一个专门设计用来处理这些事件的操作系统子程序(异常处理程序)。事件经过处理后,根据事件类型,程序会进入其中一种状态:
- 控制返回给当前指令
I_curr
,即事件发生时的指令 - 控制返回给
I_next
,如果没有发生异常将会执行的下一条指令 - 终止该程序
异常处理
我们进一步的了解一下,异常处理的过程中都发生了什么。
系统为每种类型的异常都分配了一个唯一的非负整数的异常号。号码的分配也有所区别,处理器设计者分配的异常号码通常是零除,内存访问违例,算数溢出一类的。另一部分是,操作系统的内核的设计者分配的,如系统调用和来自外部I/O设备的信号.
在系统启动时,操作系统会分配和初始化一张称为异常表的跳转表,使得表目k包含异常k的处理程序的地址:

当运行时,处理器检测到发生了一个事件,且确定了其异常号k时。处理器会触发异常,执行间接过程调用,通过异常表的表目k,跳转到相应的处理程序,其过程如下 :

异常表基址寄存器是用来存放异常表地址的特殊寄存器,在异常表中,异常号是到异常表的索引。
异常类似于过程调用,但是有些区别:
- 过程调用时,会把返回地址压入栈中(确定的)。但是,根据异常类型,返回的地址会有所不同,返回地址要么是当前指令(事件发生时的执行的指令),要么是下一条指令
- 由于要切换到异常处理程序,所以我们需要保存额外的处理器状态(通用寄存器,PC,条件寄存器…)以保存上下文。
- 如果控制从用户程序转移到内核,所有这些项目都被压到内核栈中,而不是用户栈。
- 异常处理程序运行在内核模式下,它们对所有系统资源都有访问权
当硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。在处理程序处理完毕之后,通过“从终端返回”指令,可选的返回到被中断的程序,该指令将保存的状态弹回寄存器中。并恢复用户模式,将控制返回给呗中断的程序。
异常的类别
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)、终止(abort)

中断
中断时异步发生的,时来自处理器外部的I/O设备的信号的结果。硬件中断不是由指令造成的,且不可预测,所以我们说它是异步的。硬件中断的异常处理程序称之为中断处理程序

设备会将异常号放到系统总线上,并且向处理器的中断引脚发送信号。当处理器发现中断引脚电压升高,就会读取异常号,调用中断处理程序。当处理程序返回时,就将控制返回给下一条指令。这样从外界看,就好像没有发生过中断一样。
剩下的异常类型都是同步发生的,他们是执行当前指令的结果。我们把这类指令叫做故障指令。
陷阱和系统调用
陷阱是有意的异常,是一条指令执行的结果。它可以在用户程序和内核之间提供一个像过程一样的接口,即系统调用。
用户程序经常需要像内核请求服务。如读取文件(read)、创建一个新的进程(fork)、加载一个新的程序(exec)、终止当前进程(exit)。为了支持对这些内核服务的访问,处理器支持syscall n
指令,当用户想要请求服务n
时,可以执行这个指令。执行syscall
会导致一个到异常处理程序的陷阱,这个处理程序会解析参数,调用合适的内核程序。

看起来系统调用和函数调用是一样的,但是实际上函数调用是在用户模式下进行,用户模式限制了函数可执行的指令的类型,而且他们只能访问于调用函数相同的栈。系统调用则是运行在内核模式中,内核模式允许指令调用特权指令,并访问内核中的栈。
故障
故障是由错误情况导致的,它可能会被故障处理程序修正。当故障发生,处理器会将控制传递给故障处理程序。如果故障被修读,就将控制传递会引起故障的程序,重新执行。否则,船里程序会返回abort
历程例程,从而终止引起故障的应用程序。

终止
终止是不可恢复的错误造成的结果 。终止处理程序不会将控制返回给应用程序,而是但会给一个abort例程,从而终止这个应用。

Linux/x86-64系统中的异常
为了认识的更加具体,我们可以看看x86_64系统定义的一些异常。其中0~31
的号码对应Intel架构定义的异常。32~255
的号码对应的是操作系统定义的中断和陷阱。
这是一些比较常见的:

故障和终止
- 除法错误:当试图除0时,或者一个除法指令的结果对于目标操作数而言太大的时候,就会导致除法错误。在LinuxShell里面,执行一个有除法错误的程序会报告
Floating point exception
- 一般保护故障:这个故障比较容易触发,通常是因为程序引用了一个未定义的虚拟内存区域,或者是尝试写一个只读文本段。Linux不会恢复这类故障,会报告为段故障
Segmentation fault
- 缺页:这是一个会重新执行产生故障的指令的一个异常示例。处理程序会将适当的磁盘上的虚拟内存的一个页面映射到物理内存的一个页面,然后重新执行这个产生故障的指令。
- 机器检查:机器检查是在导致故障的指令执行中检测到致命的硬件错误时发生的。
系统调用
下面展示一些常用的系统调用

C语言中可以用syscall来进行系统调用。不过没必要,因为在<unistd.h>头文件中,封装了许多对操作系统底层服务的访问接口。我们将这些系统调用和包装函数称为系统级函数。
在Linux系统中,我们使用syscall
陷阱指令来实现系统调用。它的调用过程如下:
使用寄存器%rax
包含系统调用号,使用寄存器%rdi %rsi %rdx %r10 %r8 %r9
来依次传递参数。从系统调用返回时,%r11 %rcx
会被破坏(因为rcx用来存放返回地址,r11存放标志寄存器),%rax
存放返回值。如果返回值是负数则说明发生错误。
可以通过查看系统级函数的编译来看到这个参数传递的过程:

进程
在系统上运行一个程序时,我们会得到一个假象,我们的程序似乎是系统中的唯一一个程序,独占着内存和处理器的使用。但事实并非如此。
系统中的每个程序都运行在某个进程的上下文中。上下文由程序正确运行所需的状态组成。这个状态包括许多,如存放在内存中的数据和代码,它的栈、通用寄存器的内容、程序计数器、环境变量、和打开文件描述符的集合。
每次运行一个新的程序时,shell就会创建一个新的进程,然后再这个新进程的上下文中运行这个程序。应用程序也是如此,创建新进程,并且再新进程的上下文中运行自己的代码和其他应用程序。不过我们只需要关注进程提供给应用程序的关键抽象:
- 一个独立的逻辑控制流:提供一个假象,让我们认为程序独占处理器
- 一个私有的地址空间:提供一个假象,让我们以为程序独占内存系统
逻辑控制流
通常系统中同时有很多程序在进行,进程可以向程序提供一个假象,自以为独占处理器与内存。但实际上并非如此。
我们将一个程序的顺序执行的PC值的序列称为逻辑控制流。将处理器执行的PC值的序列称为物理控制流。那么,在处理器的视角中控制流的转移实际上是这样的:

进程实际上是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占(暂时挂起),然后轮到其他进程。再次运行这个进程时,由于进程的上下文信息不变,所以运行在这些进程之一的上下文中的程序,它自认为是始终独占处理器的。
并发流
如果一个逻辑流得执行在时间上和另一个流重叠,称为并发流,这两个流称为并发的运行。多个流并发的执行的一般现象称为并发。一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段就叫做时间片。因此多任务也叫时间分片。例如上图的进程A就是由两个时间片组成的。
这里我们还要提到一下并行和并发的区别。并行是并发的一个真子集,只不过并行是并发的运行在不同的处理器核或计算机上的。现代计算机的并行能力,是基于计算机数或处理器核数上的,单一的处理器核无法实现并行。这一点要加以区分。
私有地址空间
进程也为每个程序提供一个假象,好像它独占了系统地址空间。这是因为进程为每个程序提供了自己的私有地址空间(进程地址空间)。一般而言,和这个空间中某个地址相关联的内存字节,是不能被其他进程读或写的。这个意义上来说,这个地址空间是私有的。
尽管和每个私有地址空间相关联的内存的内容一般是不同的,但是每个这样的空间都有相同的通用结构:

地址空间的底部是留给用户程序的,地址空间的顶部总是留给内核。这里需要注意,代码段总是从地址0x0040000
开始的。这个进程的地址空间是进程上下文的一部分。
用户模式和内核模式
为了进一步提供进程的抽象能力,操作系统需要一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。
处理器通过控制某个控制寄存器中的一个模式位来提供这种功能,这个寄存器会描述当前的进程所享有的特权。当设置了模式位时,进程就运行在内核模式下。在内核模式下的进程可以执行指令集中的所有指令,访问系统中的任何内存地址。
没有设置模式位时,在用户模式下的进程,不允许执行特权指令,比如停止处理器,改变模式位,发起IO操作…….。也不允许进程直接引用地址空间中内核区的代码和数据。否则会引起故障保护,用户程序只能通过系统调用接口间接的访问内核的代码和数据。
初始时,应用程序代码的进程是在用户模式中的,当发生异常时。控制传递到异常处理程序,处理器从用户模式切换到内核模式。处理程序在内核模式中运行,当它返回到应用程序时,处理器将内核模式切换回用户模式
当然除此之外,Linux提供了一系列的机制可以让用户进程访问内核的数据结构的内容。在/proc
下我们可以访问进程的属性还有一般的系统属性。/sys
中则可以查看关于系统总线和设备的底层信息…….
上下文切换
操作系统内核通过上下文切换的机制来实现多任务。这个机制是基于底层的异常机制之上的。
内核为每个进程维护一个上下文。上下文就是内核重新启动一个进程所需要的状态。它由一系列的对象的值组成。这些对象有通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和一系列内核数据结构(例如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表……)组成的。
在进程执行的某些时刻,内核可以挂起当前进程转而执行恢复执行其他被挂起的进程。这个决策被称为调度,由内核中的调度器处理。当内核选择一个新的进程时,我们就称内核调度了这个进程。内核调度了一个新的进程后,就抢占当前进程。使用上下文切换的机制来控制转移新的进程。
上下文切换主要分为三个过程:
- 保存当前进程的上下文
- 恢复某个先前被挂起的进程的上下文
- 将控制传递给新恢复的进程
了解了上下文切换,我们再来看看上下文切换的场景:
- 执行系统调用sleep
- 系统调用因为等待某个事件而阻塞时
- 中断发生(有的系统会有周期性的定时中断器,以免处理器在单个进程运行太长时间)
- ……..
总而言之就是尽可能安排任务,不要让处理器空转。下面这个图片就很好的体现了这个过程:
