CSAPP 第八章 异常控制流

作者 柚爸

异常控制和信号构成了软件和硬件协同工作的基础机制, 这一篇先把基础理论学一下, 下一篇来看系统中实际操作进程的代码.

  1. 异常
  2. 异常的类别和处理
  3. Linux X86-64系统中的异常
  4. 进程的概念
  5. 进程 – 用户模式和内核模式
  6. 进程 – 上下文切换

异常

异常控制流: Exceptional Control Flow , 指的是改变系统状态.
之前我们注重在程序内部的工作, 每一次程序执行一条指令, 都是改变了这条程序相关的状态. 然后整个系统如果状态发生变化, 也应该有能力处理这些变化, 这个变化并不一定是由程序产生的.
针对系统状态的变化的控制, 就是异常控制流. 所以异常并不是指中文字面里的不正常的情况, 而是指超出程序控制的系统状态的变化.

ECF的重要功能:

  1. 操作系统用来实现 I/O, 进程和虚拟内存的基本机制就是ECF
  2. 应用程序与操作系统交互也是通过ECF
  3. ECF可以理解并发
  4. ECF帮助理解软件异常是如何工作的

硬件和操作系统都实现异常机制, 然后互相配合, 处理异常. 异常指的是控制流中的突变. 事件则指的是处理器的状态变化. 当处理器检测到事件, 也就是状态变化的时候, 会根据一个叫做异常表的跳转表, 进行间接过程调用, 执行一个专门用来处理这些异常的程序. 这个程序由操作系统提供. 当异常处理程序处理完毕之后, 有三种情况:

  1. 处理程序将控制返回给当前指令
  2. 处理程序将控制返回给下一条指令
  3. 处理程序终止被异常打断的程序

这就是硬件和操作系统互相结合实现异常操作的原理. 至于像C++和Java中的try catch, 这属于是纯粹由软件自己引起的应用级ECF, 本质上是C语言中的setjmp和longjmp函数的更高层级版本.

异常的类别和处理

系统中所有的异常都分配了一个非负整数的异常号, 一些号码由处理器确定, 一些由操作系统内核确定. 比如Intel的CPU就规定了0-31的异常号, 而剩下的32-255则是由操作系统确定, 一共有256种异常.

系统启动的时候, 操作系统就分配一张异常表和对应的处理程序. 在事件发生的时候, 处理器就会触发异常, 然后执行对应的异常处理程序. 异常表的起始地址放在一个特殊的异常表基址寄存器中.

异常本质上也是一个过程调用, 但有一些不同:

  1. 普通过程调用的时候, 返回压入栈中的下一条地址. 但是异常处理结束之后, 可能返回当前指令, 下一条指令或者干脆中断程序
  2. 处理器会把额外的状态压入栈中, 以保存被打断的程序的状态
  3. 如果控制是转移到内核, 比如系统调用, 则会压入内核栈而不是用户栈
  4. 异常处理程序全部都运行在内核模式下, 所以对于所有的系统资源都有访问权限. 内核模式和用户模式是靠CPU的一个位寄存器来标志的.

可见, 异常由硬件触发, 然后交给软件来处理(所谓处理, 就是执行某些程序, 并不一定实际处理这个事件的影响). 处理完异常之后, 再有选择的返回中断处. 这中间还涉及到用户模式和内核模式的转换. 如果原本的程序是用户模式, 还会从内核模式切换到用户模式.

异常有四种类别, 处理结束后的行为不同:

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

中断指的是来自于程序运行之外的, I/O设备的信号. 这里的所谓同步和异步, 指的是异常是由于软件自身运行的原因, 叫做同步. 由于指令之外的因素造成的, 比如硬盘通过中断告诉CPUD完成了DMA操作, 这就是异步的. 异步的只有硬件信号的中断.
I/O设备会在某些工作做完的时候, 向CPU的一个引脚发出高电压, 然后放一个异常号在总线上, CPU检测到引脚高电压之后读取异常号, 然后找操作系统调用中断处理程序. 处理程序返回之后, 就继续执行下一条指令, 好像中断没有发生过一样.

陷阱 trap 或者叫系统调用 syscall 是有意触发的异常. 系统调用在汇编语言里是一条特殊的指令. 用户模式的程序对于系统服务, 是不能够直接使用的, 必须要使用系统中断指令向系统请求服务. 这个时候控制权转移给了系统, 系统运行在内核模式, 可以访问任意资源. 完成之后, 系统会把结果返回给用户程序, 然后继续执行用户程序.

故障指的是普通的错误情况, 可能能够被异常处理程序修正, 也可能不行. 如果可以修正, 会返回到用户程序当前执行的指令, 典型的比如内存缺页, 会加载完数据之后, 再次读取, 就OK了. 如果失败, 就会将控制权交给内核的abort例程, abort程序会中止用户程序.

终止指的是不可恢复的致命错误, 比如硬件故障, 发生这种异常的时候, 直接会引发内核的abort例程, 结束程序.

X86-64系统中的异常

X86-64系统中有0-31为Intel CPU 设计者定义的异常, 对于任何使用Intel CPU的操作系统都是一样的. 32-255的异常号由具体的操作系统定义. 以下是一些例子:

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

上边三个都是故障, 也就是说操作系统可以决定如何处理. Linux对于除法错误不会试图恢复, 而会终止程序. 对于一般保护错误也是如此. 但对于缺页故障, 则会在加载完数据之后重新执行命令.

至于18号错误, 绝对不会返回给应用程序, 一定会导致中止程序.

针对32-255的异常处理, Linux提供了很多种类的系统调用函数, 这些函数在写C语言的时候, 无需导入特定的库, 只需要以特定的名称书写即可.

当然, C标准库中提供了对于这些调用的封装, 所以一般无需直接使用. 然而多知道一些, 在系统环境编程的时候还是非常有用的.

所有系统调用的函数的方式和普通过程调用不同, 需要把系统调用号存储到%rax寄存器中, 然后1-6号参数按照寄存器的要求排列. 从系统调用返回的时候, %rax中存放着结果.

如果返回错误, 会返回-4095 ~ -1之间的错误号, 并会把全局变量 errno设置为这个错误号.

常用的系统调用函数有:

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

可以写一个完全使用系统函数的小程序:

int main(){
    write(1,"hello,world\n",13);

    return 0;
    //_exit(0)在我的系统上不能使用, 好奇怪
}

第一行就是调用了系统函数, write本身对应的调用号是1 ,这个是放在%rax中的. 参数位置的第一个1是表示stdout的描述符, 第二个是要写的字符串, 第三个是要写的长度.

查看这个函数的汇编:

main:
.LFB0:
        pushq   %rbp
        movq    %rsp, %rbp
        movl    $6, %edx
        movl    $.LC0, %esi
        movl    $1, %edi
        movl    $0, %eax
        call    write
        movl    $0, %eax
        popq    %rbp
        ret

在我的机器上, 生成的调用是把0放入了%eax ,不知道是为什么. 系统调用的指令也和CSAPP上的不同.

从本节可以知道, 异常是一个统称, 可以分为同步异常(陷阱, 故障和终止)和异步异常(即中断). 但有些语境下, 异常可能只指同步异常, 而用中断特指异步异常.

进程

进程是计算机科学中最深刻, 最成功的概念, 一个进程,就是指一个运行中的程序的实例, 包含所有的上下文也就是运行环境. 运行环境具体的说, 就是某个时刻这个程序所有的寄存器状态, 内存状态, 程序计数器, 环境变量和打开的文件描述符的集合.
每次用户在shell中启动一个程序, shell都会创建一个新的进程, 然后在新的进程的上下文中运行这个可执行目标文件.
用户的应用程序也可以创建新进程, 运行自己的代码或者其他应用程序.
这里不关心操作系统如何实现进程, 而是作为程序员的我们, 如何使用进程.
进程的核心就是两个关键抽象: 一是独立的逻辑控制流, 好像程序独占的使用处理器; 二是私有的地址空间, 好像程序可以独立的使用2e48-1地址的内存空间.

逻辑控制流就是一个PC的序列. 逻辑控制流在时间上有交叉, 称为并发(concurrency). 并行流(parallel)是并发流的一个真子集, 表示两个流并发的运行在不算的处理器核或者计算机上.

练习 8.1 考虑三个进程的情况

进程 起始时间 结束时间
A 0 2
B 1 4
C 3 5
  1. AB, AB的逻辑流有交叉的阶段, 所以AB是并发的.
  2. AC, AC的逻辑流没有交叉, 所以AC不是并发的.
  3. BC, BC的逻辑流有交叉的阶段, 所以BC是并发的.

进程 – 用户模式和内核模式

用户模式和内核模式是由CPU中的某个寄存器的一个位来标记的. 位被设置的时候, 进程就是内核模式, 也叫做超级用户模式. 这个进程可以执行指令集中的所有指令, 可以访问任何内存位置.

位没有被设置的时候, 进程就运行在用户模式中, 不允许执行特权指令比如停止处理器, 改变模式位, 发起I/O操作等. 进程从用户模式变为内核模式的方法就是出现异常.

Linux提供了 /proc 文件系统, 可以让内核数据结构的内容输出为一个用户程序可以读的文件. 还有 /sys 目录, 输出关于系统总线和设备的额外信息.

比如在系统中执行:

more /proc/cpuinfo

就可以看到CPU信息.

上下文切换

多任务是操作系统内核通过调度不同的进程来实现的. 其建立在之前的那些比较低级的异常处理机制上.

内核为每个进程维护一个上下文, 就是一系列的值.

内核在某个时刻, 内核中的调度器程序, 可以决定某个进程的运行与否. 会先保存当前进程的上下文, 然后恢复先前某个进程到内存中, 将控制交给这个进程.

在实际中, 调度算法很复杂, 有很多因素决定了内核运行哪个进程, 有些是自动的, 还有一些类似 sleep 系统调用, 会请求内核将调用进程休眠掉.

系统一般都有定时器机制, 通常会按照固定的时间向系统发送中断, 利用这个机制, 内核也可以进行调度.

系统调用错误处理

系统调用函数出现问题的话, 通常会返回-1, 并设置一个全局整数变量errno来表示为何出错. 根据errno的码, 可以得到具体的错误信息.

一般在使用系统进程调用函数之后, 必须马上判断是否产生错误, 这样程序才能健壮, 典型的如下:

#include <stdio.h>
#include <string.h>
#include <errno.h>

int pid;


int main(){

    if((pid = fork())<0) {
        fprintf(stderr, "fork error: %s\n", strerror(errno));
    } else {
	fprintf(stdout, "pid is %d\n", pid);
}

return 0;
}

所以可以用一个函数来包装, 对于基本函数, 就用大写开头的函数来包装:

//显示错误信息的函数
void unix_error(char *msg) {
    fprintf(stderr, "%s: %s\n", msg,strerror(errno));
    exit(0);
}


pid_t Fork(void){
    pid_t new_pid;
    if ((new_pid = fork()) < 0) {
        unix_error("Fork error");
    }
    return new_pid;
}

现在要调用fork(), 只需要调用Fork()即可, 如果有错误就会得到提示.