CSAPP-8-异常控制流

异常就是指令的控制流中的异常情况,用来响应处理器状态中的某些变化。
控制流即指令的控制转移序列。
所以异常控制流可以理解为处理器在正常指令运转中发生异常的控制处理逻辑。

异常的类型

异常可以分为四类:中断(interript)、陷阱(trap)、故障(fault)和终止(abort)。

上图中可以看出,异步异常是来自外部的I/O设备,同步异常是执行一条指令的直接产物。

中断

中断是异步发生的,是来自外部I/O设备的信号的结果。因为它不是由任何一条指令造成的,所以这个角度来看它是异步的。
例如网络适配器、磁盘控制器,通过向处理器芯片上的一个引脚发信号,并将异常信号放到系统总线上,来触发中断。

中断处理程序将控制返回应用程序控制流的下一条指令。

陷阱和系统调用

陷阱是执行一条指令的结果,陷阱最重要的用途是在用户程序和操作系统内核之间提供一个像过程一样的接口,即 系统调用。

用户程序,经常需要向系统内核请求服务。比如读一个文件,创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit)。为了提供这些内核服务给用户,处理器提供了 “syscall“ 指令。而执行syscall指令就会导致一个到异常处理程序的陷阱。

系统调用运行在内核中,内核允许系统调用执行特权命令,并访问定义在内核中的栈。

故障

故障是由错误引起的,它可能被修复程序修复。如果被故障修理程序修复,它就将控制返回到出故障的命令重新执行。

一个经典的故障示例是缺页异常。当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出,就会引发故障。却也故障处理程序,将从磁盘加载适当的页面,然后将控制返回引起故障的指令,再次执行内存中就有了虚拟地址对应的物理页面。

终止

终止通常是不可恢复的致命错误造成的。通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回应用程序。

终止处理程序将控制传递给一个内核abort例程,该例程将会终止这个应用程序。

进程

进程的经典定义就是一个执行中的程序的实例。系统中每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。

看到这里相信大家都有一个疑问,那就是进程在操作系统到底是如何实现的呢?
具体细节,感兴趣的可以去网上google,这里我们学习进程提供给应用程序的关键抽象:

  • 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占的使用处理器。
  • 一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用内存系统。

逻辑控制流

我们系统中通常是有许多程序在运行,进程也可以向每个程序提供一种假象,好像它在独占使用处理器,其实不然,每个程序执行时内部其实是一系列的程序计数器(PC)的值,这些值唯一对应包含在程序的可执行文件中的指令。这些PC值的序列成为逻辑控制流

下图形象的说明了当三个进程同时运行的系统处理过程:

并发流

多个流在执行时间上重叠,即一个流在另一个流开始之后,结束之前开始,即成为并发流。

私有地址空间

进程 为每个程序好像独占了系统地址空间。

  • 一个进程为每个程序提供它自己的私有地址空间。
  • 不同系统一般都用相同的结构。

用户模式和内核模式

处理器提供一种机制,限制一个应用程序可以执行的指令以及它可以访问的地址空间范围。这就是用户模式和内核模式。

  • 处理器通过控制寄存器中的一个模式位来提供这个功能。

    • 该寄存器描述了进程当前享有的特权。
      • 设置了模式位后,进程就运行在内核模式中(有时也叫超级用户模式)
        • 内核模式下的进程可以执行指令集的任何指令,访问系统所有存储器的位置。
      • 没有设置模式位时,进程运行在用户模式。
        • 用户模式不允许程序执行特权指令。
          • 比如停止处理器,改变模式位,发起一个I/O操作。
        • 不允许用户模式的进程直接引用地址空间的内核区代码和数据。
        • 任何尝试都会导致保护故障。
        • 用户通过系统调用间接访问内核代码和数据。
    • 进程从用户模式转变位内核模式的方法
      • 通过中断,故障,陷入系统调用这样的异常。
      • 在异常处理程序中会进入内核模式。退出后,又返回用户模式。
  • Linux提供一种聪明的机制,叫/proc文件系统。

    • 允许用户模式访问内核数据结构的内容。
    • /proc文件将许多内核数据结构输出为一个用户程序可以读的文本文件的层次结构。
      • 如CPU类型(/proc/cpuinfo)
      • 特殊进程使用的存储器段(‘/proc//maps’)

上下文切换

操作系统内核使用一种称为上下文切换的 较高层次 的异常控制流来实现多任务。

  • 上下文切换机制建立在之前讨论的较低层次异常机制上的。

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

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

    • 由一些对象的值组成
      • 通用目的寄存器
      • 浮点寄存器
      • 程序计数器(PC)
      • 用户栈
      • 状态寄存器
      • 内核栈
      • 各种内核数据结构
        • 描绘地址空间的页表
        • 包含当前进城信息的进程表
        • 进程已打开文件信息的文件表
  • 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决定叫做调度(shedule),由内核中称为 调度器(scheduler) 的代码处理的。

    • 当内核选择一个新的进程运行时,我们就说内核调度了这个进程。
  • 当调度进程时,使用一种上下文切换的机制来控制转移到新的进程

    • 保存当前进程的上下文
    • 恢复某个先前被抢占的进程被保存的上下文
    • 将控制传递给这个新恢复的进程
  • 什么时候会发生上下文切换

    • 内核代表用户执行系统调用。
      • 如果系统调用因为某个事件阻塞,那么内核可以让当前进程休眠,切换另一个进程。
      • 或者可以用sleep系统调用,显式请求让调用进程休眠。
      • 即使系统调用没有阻塞,内核可以决定执行上下文切换
    • 中断也可能引发上下文切换。
      • 所有系统都有某种产生周期性定时器中断的机制,典型为1ms,或10ms。
      • 每次定时器中断,内核就能判断当前进程运行了足够长的时间,切换新的进程。

进程控制

从程序员的角度,进程分为如下三种状态:

  • 运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度。
  • 停止。进程的执行被挂起,且不会被调度。
  • 终止。进程永远的停止了。进程会因为三种原因终止:1)收到一个信号,该信号的默认行为是终止进程,2)从主程序返回,3)调用exit函数。

进程的创建和终止

exit函数以status退出状态来终止进程。
父进程通过调用fork函数创建一个新的运行的子进程。
新创建的进程几乎但不完全和父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立)一份副本,包括代码和数据段、堆、共享库以及用户栈。

回收子进程

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

非本地跳转

C语言提供了一种用户级异常控制流形式,称为非本地跳转(nonlocal jump),它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回序列。非本地跳转是通过 setjmplongjmp 函数来提供的。

#include <setjmp.h>

int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs); // 返回:setjmp返回0,longjmp返回非零

setjmp函数在env缓冲区中保存当前调用环境,以供后面的longjmp使用,并返回0. 调用环境包括程序计数器、栈指针和通用目的寄存器。

#include <setjmp.h>

void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval); // 从不返回

longjmp 函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回。然后setjmp返回,并带有非零的返回值retval。

从上面的解释中,大家可能会迷惑。为什么setjmp被调用一次却返回多次:一次是当调用setjmp保存调用环境到缓冲区env时,一次是为每个相应的longjmp调用。另一方面,longjmp函数被调用一次,但从不返回。

非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误引起的。如果在一个深层次嵌套的函数调用中发现了一个错误情况,我们可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序,而不用费力的解开调用栈

下面展示一个示例,说明setjmp和longjmp是如何工作的,加深大家的理解。

#include "csapp.h"
jmp_buf buf;
int error1 = 0;
int error2 = 1;
void foo(void), bar(void);
int main()
{
/* code */
switch (setjmp(buf))
{
case 0:
foo();
break;
case 1:
printf("Detected an error1 condition in foo\n");
break;
case 2:
printf("Detected an error2 condition in foo\n");
default:
printf("Unknown error condition in foo\n");
break;
}
exit(0);
}
/* Deeply nested function foo*/
void foo(void)
{
if (error1)
{
longjmp(buf, 1);
}
bar();
}
void bar(void)
{
if (error2)
{
longjmp(buf, 2);
}
}

main函数首先调用setjmp以保存当前的调用环境,然后调用函数foo,foo依次调用函数bar。如果foo或者bar遇到一个错误,它们立即通过一次longjmp调用从setjmp返回。setjmp的非零返回值指明了错误类型,随后可以被解码,且在代码中的某个位置进行处理。

C++和Java 中的软件异常

C++ 和 Java 提供的异常机制是较高层次的,是C语言的setjmp 和 longjmp 函数的更加结构化的版本。你可以把try语句中的catch子句看做类似于setjmp函数。相似地,throw 语句就类似于longjmp函数。

操作进程的工具

Linux 系统提供了大量监控和操作进程的有用工具。

  • STRACE: 打印一个正在运行程序和它的子进程调用的每个系统调用的轨迹。
  • PS: 列出当前系统中的进程(包括僵死进程)。
  • TOP: 打印出关于当前进程资源使用的信息。
  • PMAP: 显示进程的内存映射。
  • /proc: 一个虚拟文件系统,以ASCII文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容。比如:输入“cat /proc/loadavg”,可以看到你的linux系统上当前的平均负载。

总结

  • 异常控制流(ECF)发生在计算机系统的各个层次,是计算机系统提供并发的基本机制。
  • 在硬件层,异常是由处理器中的事件触发的控制流中的突变。控制流传递给一个软件处理程序,该处理程序进行一些处理,然后返回控制给被中断的控制流。
  • 在操作系统层,内核用ECF提供进程的基本概念。进程提供给应用两个重要的抽象:1)逻辑控制流,它提供给每个程序一个假象,好像它们在独占地使用处理器,2) 私有地址空间,它提供给每个程序一个假象,好像它是在独占的使用主存。
  • 在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或终止,运行新的程序,以及捕获来自其他进程的信号。
  • 最后,在应用层,C程序可以使用非本地跳转来规避正常的 调用/返回栈规则,并且直接从一个函数分支到另一个函数。

关注下方我的公众号,领取进阶高级架构师视频,掌握第一手资料。

关注领取Java架构师免费资料


   转载规则


《CSAPP-8-异常控制流》 coderluo 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
CSAPP-9-虚拟内存 CSAPP-9-虚拟内存
虚拟内存定义 虚拟内存(VM)是对主存的抽象概念。虚拟内存提供了三个重要的能力:1)它将主存看成一个存储在磁盘上的地址空间的高速缓存,在主存中只保护活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式它高效的使用了主存。2)它为每
2019-12-19
下一篇 
必知必会-存储器层次结构 必知必会-存储器层次结构
相信大家一定都用过各种存储技术,比如mysql,mongodb,redis,mq等,这些存储服务性能有非常大的区别,其中之一就是底层使用的存储设备不同。作为一个程序员,你需要理解存储器的层次结构,这样才能对程序的性能差别了然于心。今天带大
2019-11-14
  目录