第八章:异常控制流

《CSAPP》第八章:异常控制流



控制流的定义:程序计数器中保存有一个关于指令地址的序列,从序列中的第k项转移到第k+1项称为控制转移,这样的序列就叫做控制流

程序内部的语句会对指令造成变化,与此同时,系统在必要的时候也要对系统状态做出反应,也会对指令做出变化,这些变化的改变和所写程序并不一定相关,造成突变的控制流我们成为异常控制流(ECF,Exceptional Control Flow),这个概念发生在计算机系统的各个层次当中

  • 硬件层:当硬件检测的事件发生变化时,会触发异常处理程序
  • 操作系统层:内核通过上下文切换,将控制从一个用户转移到另一个用户上
  • 应用层:一个进程可以发送信号到另一个进程,而接受者会将控制突然转移到它的一个信号处理程序
  • 软件异常:一个程序可以回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来应对错误

理解ECF的作用

  • 对理解虚拟内存、I/O、进程等有帮助
  • 理解应用程序和操作系统的交互
  • 编写Unix Shell和Web服务器等应用
  • 理解并发
  • 理解C++和Java中异常机制的实现

异常

异常是ECF的一种形式,指控制流的突变,用来响应处理器状态中的某些变化

首先处理器中会保存有状态的编码,当状态变化(即事件)时,如发生虚拟内存缺页,算术溢出时,都会导致编码的变化(即状态),处理器会通过一张叫异常表的跳转表,进行间接过程调用(异常),到一个专门设计用来处理状态变化的程序中,程序执行完后,根据事件的类型决定接下来做什么

  • 异常处理程序将控制返回给事件发生时执行的指令
  • 将控制返回给事件发生时执行的指令的下一条指令
  • 直接中断程序

异常表

系统对每种可能出现的异常都分配了一个唯一的非负整数异常号,前一部分由处理器设计师分配,如零除,缺页,内存访问违例,其他号码由操作系统内核设计师分配,如系统调用和外部I/O信号
异常表中的表目k就对应着异常k的处理程序代码的地址,异常表的起始地址放在一个叫做异常表基址寄存器的特殊寄存器中
当处理器检测到事件的发生,并确定事件发生对应的异常号后,处理器通过执行间接过程调用,触发异常,并通过异常表上的对应程序进行处理

异常和过程调用的区别

  • 异常处理程序运行在内核模式下,因此对系统资源有完全的访问权限
  • 处理程序如果从用户程序转移到内核,则处理程序将被压入内核栈中,而不是用户栈中
  • 处理器会把一些额外的处理器状态压入栈中,当重新执行程序时需要这些状态
  • 过程调用在跳转到处理程序之前,将返回地址压入栈中,异常处理则根据类型,返回地址要么是当前指令,要么是下一条指令

分类

异步异常是指是来自处理器外部的I/O设备的信号的结果,不是由任意一条专门的指令造成的,同步异常是执行一条指令的直接产物

  • 中断
    该异常是异步的,例如网络适配器、磁盘控制器等向处理器芯片的一个引脚发送信号,并将异常号放在系统总线上,当当前指令完成执行后,处理器注意到引脚电压升高,就从系统总线读取异常号,并调用适当的中断处理程序,完成后,将控制返回给下一条指令,继续执行。

  • 陷阱
    最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用,用户经常需要向内核请求服务,如读一个文件(read), 创建新的进程(fork),终止当前进程(exit)。为了允许用户对内核服务的受控访问,处理器提供一条syscall n指令,执行该指令会导致一个到异常处理程序的陷阱,该处理程序能解析参数n,并调用内核程序。
    写程序的时候,系统调用和普通的函数调用并没有什么不一样,但实际上的实现却大不一样,主要区别在于一个运行在用户模式,一个是在内核模式

  • 故障
    最常见的故障就是缺页异常,当指令引用一个虚拟地址时,如果该地址对应的物理页面不在内存中,就会引发异常,并尝试从磁盘中读入内存,如果加载顺利,就将控制返回引发故障的当前指令,该指令再次执行的时候,虚拟地址对应的物理也页就存在于内存中了,但是如果加载失败,异常处理程序返回到内核中的abort例程,并终止该程序

  • 终止
    通常是一些致命的错误导致的,如DRAM损坏等,处理程序直接将控制返回个一个abort例程,并终止该程序

Linux的异常

常见故障和终止

  • 除法错误(异常号0):除以零,或者目标操作数太大时都会发生,显然该故障不能由机器自动修复,只能选择终止程序,Linux shell报告为”Floating exception”
  • 一般保护程序(异常号13):如引用未定义的虚拟内存等都为导致该故障,Linux shell报告为”Segmentation fault”
  • 缺页(异常号14):将在虚拟内存章节详细讲解
  • 机器检查(异常号18):检测到硬件导致的致命错误

系统调用(陷阱)

Linux提供上百种系统调用供应用程序请求内核服务时调用。标准C库提供一系列的包装函数,来调用系统调用。包装函数将参数和系统调用打包,并根据系统调用陷入内核程序,并将系统调用的返回状态转递给调用程序。常见系统调用实例如下图所示

进程

异常是允许操作系统内核提供进程概念的基本构造块

进程的定义就是一个执行中的程序实例,通过进程能给我们提供一个假象,好像当前程序是系统中唯一运行的程序一样,好像该程序能独占处理器和内存。但其实每个程序都运行在某个进程的上下文中(上下文包括程序代码和数据,栈、通用目的寄存器的内容,程序计数器,环境变量等)

例如当在shell中输入./hello时,shell就会创建一个新的进程,并在该进程的上下文中执行它

进程为应用程序提供了关键两点抽象

  • 独立的逻辑控制流:程序独占处理器的假象
  • 私有的地址空间:程序独占内存系统的假象

逻辑控制流

又称逻辑流(注意其和控制流的区别),当我们单步调试程序时,看到的一系列程序计数器的值,对应程序中的指令,这个值的序列就叫做逻辑控制流

处理器的一个物理控制流(也就是文章开头提到的控制流),被分成多个逻辑流,每个进程对应一个逻辑流,进程轮流使用处理器,然后被暂时挂起,换做其他进程使用处理器

逻辑流之所以叫逻辑流,因为它并不是实际上的流,它是间断的,而是逻辑抽象上的流,但是处理器的物理控制流是持续不断地,这个流会不停的被不同的逻辑流填充

异常处理流、进程、信号处理程序、线程和Java进程都是逻辑流的例子

并发流

一个逻辑流的执行时间与另一个流重叠,成为并发流,这两个流被称为并发地运行,准确说两个流的执行时间有交集时,成为并发

多个流并发执行的一般现象称为并发,多个进程轮流运行称为多任务

进程执行它的控制流的一部分的时间段叫做时间片,多任务也叫做时间分片

另一个概念叫并行流,它是出现在多个处理器时,它是并发流的真子集,如果两个流并发运行在不同处理器上,称为并行流,其实也是并发流,因为执行时间重叠,就称为并发

私有地址空间

前面提到进程为程序提供独占系统内存的假象,下图是Linux进程的地址空间的组织结构地址空间底部是保留给用户程序的,包括通常的代码、数据、堆、栈段,顶部是保留给内核,包含了内核在代表进程执行指令时使用的代码数据等

用户模式和内核模式

处理器用一个控制寄存器的一个模式位来限制应用可以执行的指令及其可以访问的地址空间范围,当设置了模式位,进程就处在内核模式中,并且可以执行指令集的任何指令,访问任何的内存位置

处在用户模式的进程不允许执行特权指令,例如停止处理器、改变模式位、或者发起一个人I/O操作,也不允许访问地址空间中内核区的数据,反之用户需要通过系统调用接口来间接的访问内核代码和数据

用户模式进入内核模式的唯一方法就是通过异常,当异常发生时,进入异常处理程序,就从用户模式进入了内核模式,处理程序返回时,又切换回用户模式

上下文切换

如前所示,上下文包括通用目的寄存器、浮点寄存器、程序计数器、用户栈等信息,它是内核重新启动一个被强占的进程所必须的信息

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

由内核来决定抢占当前进程,并重新执行之前被抢占的进程的这一决策过程称之为调度,由内核中称之为调度器的代码执行,当调度发生时,将采取上下文切换机制来将控制转移到新的进程中

上下文切换包含三个步骤

  • 保存当前进程的上下文
  • 恢复即将执行进程的上下文
  • 将控制转移给新的进程

实例一:用户程序进行系统调用,想要read磁盘文件,这时候系统调用可能会发生阻塞(因为读取磁盘文件需要时间),内核就会进行上下文切换,让当前进程休眠,运行其他程序,而不是等待磁盘数据,运行一段时间之后,磁盘发出一个中断信号,表示数据传输完成,内核就又切换回之前的进程,并继续运行

实例二:程序显示进行sleep系统调用,让进程休眠,内核自然会切换进程

系统调用错误处理

Unix系统级函数遇到错误时,都会返回-1,并设置全局整数变量errno来表示对应错误类型,并且通过错误包装函数可以简化代码

进程控制

给出了一些重要函数来进行进程控制

获取进程ID

每个进程都对应了唯一的正数进程ID(PID),通过getpidgetppid函数可以分别返回调用进程的ID和父进程的ID,返回值类型为pid_t,在Linux中该类型被定义为int

创建和终止进程

进程的三种状态

  • 运行:在处理器中执行,或者等待被执行被最终会被内核调度
  • 停止:进程被挂起,且不会被调度,说明他已经脱离流的执行队伍当中,知道收到信号才又会被执行
  • 终止:收到信号,且默认行为就是终止进程;从主程序返回;调用exit函数,以上三种行为都会导师进程终止

父进程通过fork函数创建新的子进程,新创建的子进程拥有父进程用户级虚拟地址空间的副本和父进程打开文件描述符相同的副本(即子进程可以读取任何父进程打开的文件),两者最大的区别在于两者的PID不同

fork函数的特性

  • 调用一次,返回两次,一次返回到父进程,一次返回到新创建的子进程
  • 并发执行,父进程和子进程是并发执行的独立程序,内核以任意方式来交替执行父子进程中的指令
  • 相同但是独立的地址空间
  • 共享文件,父子进程都能讲结果输出到屏幕,因为父进程中,stdout文件是打开的,并指向屏幕,子进程自然继承这个文件,将结果输出到stdout

对于有多个fork实例的程序就需要仔细推敲

回收子进程

进程终止时,不会立即被内核从系统中清除,将保持在一种已经终止的状态,这种终止但是没有被回收的进程称为僵死进程,这种进程虽然终止,但是依旧会消耗内存资源

当父进程回收僵死进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程

系统驱动时就会启动一个由内核创建的init进程,它是无敌进程,不会终止,而且是所有进程的祖先,其PID为1

如果父进程终止,内核将安排init进程成为被父进程抛弃的孤儿进程的养父,如果父进程的底下存在僵死的子进程没有被回收,内核就安排init进程去回收他们,承担起爸爸的责任

waitpid函数

函数原型:pid_t waitpid(pid_t pid,int *statusp,int options);

通过调用waitpid函数来等待它的子进程终止或者停止,默认情况下(options=0),该函数挂起调用进程的执行,直到进程的等待集合中的一各子进程终止,并且函数返回导致其返回的子进程的PID,子进程则被内核回收

等待集合:该函数的第一个参数pid决定了等待集合的成员

  • pid>0,等待集合就是一个单独的子进程,
  • pid=-1,等待集合就是由父进程所有子进程组成的

函数第三个参数可以修改默认行为,下面的常量都定义在wait.h头文件中,对应不同的值

  • WNOHANG:等待集合中没有子进程终止,就立即返回值0。如果不想等待子进程终止,而是想要做其他事情,这个选项有用
  • WUNTRACED:该参数和默认行为的唯一区别在于,默认行为是返回终止的子进程的PID,而这个参数是返回终止或者被停止的进程的PID
  • WCONTINUED:挂起调用进程的执行,直到等待集合中的进程终止或者集合中的一个被停止的进程收到SIGCONT信号重新开始执行
  • WNOHANG|WUNTRACED:立即返回,如果等待集合中的子进程没有终止或者停止,返回值为0;如果有一个终止或者停止,返回该进程的PID

Linux信号

更高层次的软件形式的异常,它允许进程和内核中断其他进程。底层的硬件异常由内核异常处理程序处理,一般对用户进程不可见,信号提供一种机制,能够通知用户进程发生了这些异常

发送信号

内核通过更新目的进程上下文中的某个状态,来发送一个信号给目的进程

进程组:每个进程都只属于一个进程组,进程组由一个正整数ID标识PID

kill:kill程序可以向另外的进程发送任意的信号,如果指定的PID为负则会导致信号被发送给进程组PID中的每个进程,PID等于零时,发送信号给调用进程所在进程组的每个进程,包括调用进程自己
键盘:在键盘中输入Ctrl+C会导致内核发送一个SIGINT信号到前台进程组中的每个进程,结果是终止前台作业;同理如果输入Ctrl+Z会发送一个SIGSTP信号到前台进程组的每个进程,结果是停止(挂起)前台作业
alarm:该函数安排内核在参数secs秒后发送一个SIGALRM信号给调用进程

接受信号

当目的进程被内核强迫以某种方式对信号的发生做出反应时,就接受了信号。进程可以忽略、终止、执行一个信号处理程序的用户层函数来对信号做出反应

当内核把进程p从内核模式切换成用户模式时,会检查进程p的未被阻塞的待处理信号的集合,集合非空时,内核会选择集合中的某个信号并强制进程p接受信号,每个信号类型都有一个默认行为

  • 进程终止
  • 进程终止并转储内存
  • 进程挂起知道被SIGCONT信号重启
  • 忽略该信号

进程也可以通过signal函数修改和信号相关联的默认行为(除了SIGSTOP和SIGKILL,这两个信号的默认行为不能修改)

signal函数原型sighander_t signal(int signum,sighandler_t handler);

handler是用户自定义的函数,称为信号处理程序,当进程接收到一个类型为signum的信号,就会调用这个处理程序。通过把处理程序的地址传给signal函数从而改变默认行为,这叫做设置信号处理程序,调用信号处理程序叫做捕获信号,执行信号处理程序叫做处理信号

阻塞信号

进程可以选择阻塞某种信号,则这种信号可以被发送但不会被接受。一个发送但没有被接受的信号叫Pending Signal(待处理信号)。一种类型至多只有一个Pending Signal。也就是说如果存在一个未处理的信号就表明至少有一个信号达到了,其他到达的信号都会丢失

内核为每个进程在Pending位向量中维护Pending Signal的集合,而Blocked位向量维护被阻塞的信号集合,当某K类信号被阻塞,内核就将设置Blocked位向量的第K位,当取消K类信号的阻塞时,内核就取消这种信号

Linux提供阻塞信号的隐式显示机制

  • 隐式阻塞进制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号
  • 显式阻塞机制:使用sigprocmask函数和它的辅助函数,明确阻塞和解除阻塞信号

sigprocmask函数原型int sigprocmask(int how,const sigset_t *set,sigset_t *oldset);,how参数决定了该函数的行为

处理信号

信号处理程序麻烦是因为它和主程序以及其他信号处理程序并发运行,下面的信号处理程序编写原则要遵守,否则由于并发出现的错误将会不可预测不可重复,给调试带来困难

  • 尽可能简单
  • 只调用异步信号安全的函数:这种函数要么是可重入的,要么不能被信号处理程序中断,许多常见函数(如printf、malloc)都不在此列
  • 保存和恢复errno:
  • 阻塞所有信号,保护对共享全局数据结构的访问
  • 用volatile申明全局变量
  • 用sig_atomic_t申明标志

非本地跳转

C语言提供一种用户级异常控制流形式,将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用-返回程序
非本地调转一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是检测到某个错误情况引起的,而不是费力地解开调用栈

  • strace:打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹
  • ps:列出当前系统中的进程(包括僵死进程)
  • top:打印关于当前进程资源使用的信息
  • pmap:显示进程的内存映射