Skip to content

Chapter 8. Exceptional Control Flow

约 2990 个字 40 行代码 预计阅读时间 15 分钟

异常控制流是操作系统用来实现I/O、进程以及虚拟内存的基本机制

不平滑的控制流(PC 执行的相邻两条指令在地址上不相邻)通常是由跳转、调用和返回这些程序指令所造成的

现代系统通过使控制流发生突变对这些情况做出反应。我们称这种突变异常控制流Exceptional Control Flow,ECF)

异常

系统中为每种类型的异常都分配了唯一的异常编号,其中一些编号是由处理器的设计者分配的,如被零除、缺页、算数运算溢出等,其它号码是由操作系统内核设计者分配的,如系统调用以及来自外部I/O设备的信号

当处理器检测到异常事件的发生,并且确定了相应的异常编号 k ,会根据异常编号从异常表(Exception Table)中检索对应的异常处理程序来处理这个异常

Exception Table

异常表是在系统启动时,操作系统分配和初始化的一个跳转表,异常编号即为该跳转表的索引,表项中的内容是对应异常处理程序的起始地址

异常表的起始地址保存在 CPU 中的一个特殊寄存器中

异常表.png

综上所述,异常的处理类似一种间接的函数调用。不同的时,当发生函数调用时,在跳转到目标函数前,处理器首先将返回地址压入栈中;然而根据异常类型,异常处理的返回类型要么是当前指令,要么是下一条指令。除此之外,处理器处理异常时,需要额外将处理器一些状态压入栈中,被中断的程序重新开始执行时取出并恢复状态。

CSAPP 原书中将异常分为中断、陷阱、故障和终止四类,除了中断是异步的,其它都是同步的:

Class Cause Async/Sync Return behavior
Interrupt Signal from I/O device Async 返回下一个指令的地址
Trap Intentional exception Sync 返回下一个指令的地址
Fault Potentially recoverable error Sync 可能返回当前指令的地址
Abort Nonrecoverable error Sync 无返回

此处异步与同步的区别在于,异常产生原因在于 CPU 外部还是内部

  • 中断,顾名思义为外部 I/O 设备,如键盘等,向处理器的中断引脚发送信号来触发中断
    • CPU 执行完当前指令,从系统总线上读取异常号,调用相应的中断处理程序来处理中断
    • 中断处理完毕后,CPU 返回并继续执行下一条
  • 陷阱是一种故意触发的异常,它是一条指令执行的结果
    • 陷阱最重要的用途是为用户程序和操作系统内核之间提供一个类似函数的接口,当应用程序需要读取文件或创建新进程时,需要使用特殊指令 syscall 向内核请求服务, syscall 指令就会触发一个陷阱
  • 故障是由错误情况引起的,不过故障是有可能被故障处理程序修复的
    • 故障处理程序如果能修正这个错误,它就将控制返回到引起故障的指令,并重新执行
    • 故障处理程序如果不能修正这个错误,就会终止引起故障的应用程序
  • 终止是由不可恢复的致命错误导致的,通常是一些硬件错误,例如 RAM 的奇偶校验位出错
    • 对于这类硬件错误,终止处理程序直接将应用程序终止

进程与上下文

进程就是一个正在执行的程序实例,它会提供给程序一种程序在独占使用处理器以及内存系统的假象

逻辑控制流

逻辑控制流 (logical control flow) 是计算机系统中管理程序执行流程的一种机制。它简单来说就是程序执行的顺序或条件。

对于程序中的每个语句,在执行之前,计算机都需要根据逻辑控制流来判断是否需要执行这个语句。如果需要执行,就顺序执行这个语句;如果不需要执行,就跳过这个语句,继续执行下一个语句。

并行是两个进程在不同处理器上同时运行,而并发是两个进程在一个处理器上交替运行

为了限制应用程序执行某些特殊的指令以及限制其可以访问的地址空间范围,处理器通过 Control Register 的模式位来实现这个功能,该寄存器描述了当前进程的权限。

  • 当设置了控制寄存器的模式位后,进程就运行在内核模式 (Kernel Mode) ,也称为超级用户模式
    • 对于一个运行在内核模式的进程可以执行指令集中的任何指令,并可以访问系统中任意的内存位置
  • 如果没有设置模式位,进程就运行在用户模式 (User Mode)
    • 处于用户模式的进程不允许执行特权指令,其中特权指令指可以停止处理器、改变模式位以及发起 I/O 操作的指令
    • 除此之外,用户模式的进程也不能直接引用内核区域的代码和数据,但可以通过系统调用来间接访问
  • 通常,应用程序一开始运行在用户模式,进程从用户模式切换到内核模式需要通过中断、故障或者函数调用的方式。当这类异常发生,执行异常处理程序处理异常,处理器就会从用户模式变为内核模式;当返回到引用程序继续执行时,又会从内核模式改回用户模式

内核为每个进程维持了一个上下文,上下文就是内容重新启动一个被抢占的进程所需的状态,它由一些对象的值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈以及各种内核数据结构,其中内核数据结构包括描述地址空间的页表、包含有关当前进程信息的进程表以及包含进程已打开文件的信息表

在进程执行的某些时刻,内核可以决定抢占当前进程,然后重新开始执行先前被抢占的进程,这种决策称为进程调度,是由内核中的调度器来执行的

其中,上下文切换分为三步:

  • (一) 保存当前进程
  • (二) 恢复某个先前被抢占的进程的上下文
  • (三) 将控制传递给这个新恢复的进程

进程的创建

函数 fork

创建进程需要用到系统函数 fork

我们在 shell 中输入命令 ./hello ,可以把 shell 看成父进程,可执行程序 hello 看成子进程。这个过程中,父进程通过调用 fork 函数来创建一个新的可以运行的子进程。

通常调用一个函数只会返回一次,然而函数 fork 调用一次会返回两次,一次是返回到父进程,另外一次是返回到新创建的子进程

#include<sys/types.h>
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>

//pid_t fork(void);
//fork 函数的定义,在 Linux 系统上,返回值类型 pid_t 在文件 types.h 中定义为 int 类型

int main(){
        pid_t pid = fork();
        int x = 1;
        if(pid==0){
                printf("child:%d\n",++x);
                exit(0);
        }
        printf("parent:%d\n",--x);
        exit(0);
}

/* Output:
*child:2
*parent:0
*/

当调用子进程时,fork 返回 0 ;调用父进程时,fork 返回非零。由于 fork 本质是一个复制进程的函数,它将父进程复制了一份,并运行在不同的 pid 上,所以两个返回值实际上是互不影响地执行的:

fork函数示例.png

函数 execve

fork 函数不同的是,execve 调用之后不会返回。

调用 execve 函数需要三个参数,其定义如下:

1
2
3
4
#include<unistd.h>
int execve(const char *filename,
           const char *argv[],
           const char *envp[]);
  • filename 表示可执行程序的文件名
  • argv 表示执行程序所需输入的参数列表
  • envp 表示环境变量列表

环境列表 envp 的输入格式可以为 ["pwd=114",...,"USER=admin"]

可以使用如下 C 语言程序查看自己系统上的环境变量:

1
2
3
4
5
6
7
8
#include <stdio.h>

int main(int argc,char *argv[], char *envp[]){
    int i;
    printf("Environment variables:\n");
    for(i=0;envp[i]!=0;i++)
        printf("envp[%2d]:%s\n",i,envp[i]);
}

函数 execve 的作用就是调用加载器,在执行可执行程序 main 函数之前,将参数传入。其中,函数 main 的参数如下:

int main(int argc,char *argv[], char *envp[]);

其中第一个参数 argc 表示数组 argv 中非空指针的数量

函数 waitpid

当一个进程由于某种原因终止时,内核并不是立即将其从系统中清除,需要等待它的父进程回收(实际上,大部分内存已经回收,但是仍会有部分存留等待父进程回收)

一个终止运行但是还未被回收的进程称为僵死进程(zombie)

在 Linux 系统中,父进程可以通过函数 waitpid 来等待它的子进程终止或停止,其参数如下:

1
2
3
4
#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *statusp, int options);
  • 第一个参数 pid 指的是目标子进程的 pid
    • (1)pid > 0 :等待的进程是一个单独的子进程,且其 ID 为 pid 的值
    • (2)pid = -1 :等待的进程是由父进程创建的所有子进程组成的集合
  • 第二个参数 statusp 非空,用于存放状态信息
    • 该参数传入的时候选择一个整数类型的地址即可,无需赋初值:pid=waitpid(-1,&status,0);

Linux Signal

Linux 信号允许内核和进程中断其它进程,部分 Linux 系统支持的信号列表如下:

Number Name Default action Corresponding event
1 SIGHUP Terminate Terminate line hangup
2 SIGINT Terminate Interrupt from keyboard
3 SIGQUIT Terminate Quit from keyboart
4 SIGILL Terminate Illegal instruction
5 SIGTRAP Terminate & dump core Trace trap
8 SIGFPE Terminate & dump core Floating-point exception
9 SIGKILL Terminate Kill program
11 SIGSEGV Terminate & dump core Invalid memory reference
18 SIGCONT Ignore Continue process if stopped

正常情况下, 底层硬件的异常是由内核异常处理程序处理的,对用户不可见。信号提供了一种机制,用来通知用户进程发生了哪些异常情况。当一个进程试图执行除以 0 的算数时,内核会发送给该进程一个 SIGFPE 信号,告诉浮点异常。

每个进程都只属于一个进程组,每个进程组都有自己的 ID 值作为唯一标识,可以使用函数 getpgrp 来获取当前进程所属的进程组 ID 值,且默认情况下,子进程和它的父进程属于同一个进程组。

向进程发送信号的几种方式如下:

  • (一) 通过 bin 目录下的 kill 程序向程序发送任意信号
    • /bin/kill -9 15213 该命令向进程号为 15213 的进程发送信号 9,即杀死进程
    • /bin/kill -9 -15213 与上面不同,该命令向进程组 15213 中的每一个进程都发送信号 9
    • 有些 shell 内置 kill 命令,不需要引用完整路径
  • (二) 从键盘发送信号
    • 按下键盘 ctrl+c 会发送一个中断信号到前台进程组的所有进程中
    • 按下键盘 ctrl+z 会挂起前台作业
  • (三) 使用函数 kill
    • 函数 kill 在 C 语言中参数为: int kill(pid_t pid,int sig);
    • 如果参数 pid 的值大于 0,那么函数发送信号 sig 给该进程
    • 如果参数 pid 等于 0,那么函数发送信号 sig 给调用进程所在进程组中的所有进程(包括调用进程自己)
    • 如果参数 pid 小于 0,那么函数发送信号 sig 给进程组 |pid| 中的每个进程
  • (四) 使用函数 alarm
    • 其在 C 语言中参数为: unsigned int alarm(unsigned int secs);
    • 进程可以使用函数 alarm 向它自己发送 SIGALRM 信号,其中参数 secs 表示多少秒后发送
Comments: