CSAPP学习笔记 第八章 异常控制流

程序一般是逐个运行内存中相邻的指令,诸如跳转、调用和返回这样一些程序指令则会改变这一状况,让程序以不同的顺序执行,这样一些程序指令都是必要的机制,使得程序能够对由程序变量表示的内部程序状态中的变化做出反应。

但是系统也必须能够对系统状态的变化做出反应,这些系统状态不是被内部程序变量捕获的,而且也不一定要和程序的执行的相关。比如,一个硬件定时器定期产生信号,这个事件必须得到处理。包到达网络适配器后,必须存放在内存中。程序向磁盘请求数据,然后休眠,直到被通知说数据已就绪。当子进程终止时,创造这些子进程的父进程必须得到通知。

除了程序指令,系统各部分都有不同的机制来触发异常控制流。比如,在硬件层,硬件检测到的时间会触发控制突然转移到另一个用户进程。在操作系统层,内核通过上下文切换将控制从一个用户进程转移到另一个用户进程。在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他函数中任意位置的非本地跳转来对错误做出反应。

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

问题

  • 什么是系统调用?
  • 并发和并行有什么区别?
  • 用户态、内核态和上下文切换是什么?
  • 如何创建并使用多进程编程?
  • 什么是非本地跳转?有什么用处?

异常

异常就是控制流中的突变,用来响应处理器状态中的某些变化。
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序)。当异常处理程序完成处理后,根据引起异常的事件的类型,会发生以下3种情况中的一种:
1) 处理程序将控制返回给当前指令,即当事件发生时正在执行的指令。
2) 处理程序将控制返回给下一指令,即如果没有发生异常时将会执行的下一条指令。
3) 处理程序终止被中断的程序。

系统中可能的每个类型的异常都分配了一个唯一的非负整数的异常号,一般包括两类。一类的号码由处理器的设计者分配,包括被零除、缺页、内存访问违例、断点以及算术运算移除,另一类的号码则由操作系统内核的设计者分配,包括系统调用和来自外部I/O设备的信号。

一旦硬件触发了异常,剩下的工作就是由异常处理程序在软件中完成。

异常的类别

1.中断

中断是异步发生的,是来自处理器内部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常成为中断处理程序。

剩下的异常类型(陷阱、故障和终止)是同步发生的,是执行当前指令的结果。我们把这类指令叫做故障指令。

2.陷阱和系统调用

陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。

用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve),或者终止当前进程(exit)。为了允许这些内核服务的受控的访问,处理器提供了一条特殊的“syscall n”指令,当用户程序想要请求服务n时,可以执行这条指令。执行syscall指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序。

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

3.故障

故障由错误情况引起,它可能被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort例程会终止引起故障的应用程序。

一个经典的故障示例是缺页异常。

4.终止

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

Linux/x86-64 系统中的异常

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

C程序用syscall函数可以直接调用任何系统调用。然而,实际中几乎没必要这么做。对于大多数系统调用,标准C库提供了一组方便的包装函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//hello.c
int main()
{
write(1, "hello, world\n", 13);
_exit(0);_
}

//code/ecf/hello-asm64.sa
.section .data
string:
.ascii "hello, world\n"
string_end:
.equ len, string_end - string
.section .text
.globl main
main:
# First, call write(1, "hello, world\n", 13)
movq $1, %rax # write is system call 1
movq $1, %rdi # Arg1: stdout has descriptor 1
movq $string, %rsi # Arg2: hello world string
movq $len, %rdx # Arg3: string length
syscall # Make the system call

# Next, call _exit(0)
movq $60, %rax # _exit is system call 60
movq $0, %rdi # Arg1: exit status is 0
syscall # Make the system call

进程

进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。

并发流

多个流并发地执行的一般现象被称为并发(concurrency)。一个进程和其他进程轮流运行的概念称为多任务(multitasking)。一个进程执行它的控制流的一部分的每一个时间段叫做时间片(time slice)。因此,多任务也叫作时间分片(time slicing)。

如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow),它们并行地运行(running in parallel),且并行地执行(parallel execution)。

上下文切换

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling)。

上下文切换 1.保存当前进程的上下文 2.恢复某个先前被抢占的进程被保存的上下文 3. 将控制传递给这个新恢复的进程。

进程控制

创建和终止进程

从程序员的角度,我们可以认为进程总是处于三种状态之一: 运行,停止,终止。

1
2
3
4
#include <stdlib.h>

void exit(int status);
//返回: 该函数不返回。

exit函数以status退出状态来终止进程(另一种设置退出状态的方法是从主程序中返回一个整数值)。

父进程通过调用fork函数创建一个新的运行的子进程。

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

pid_t fork(void);
//返回: 子进程返回0,父进程返回子进程的PID,如果出错,则为-1。

fork函数只被调用一次,却会返回两次:一次是在调用进程(父进程)中,一次是在新创建的子进程中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//code/ecf/fork.c

int main()
{
pid_t pid;
int x = 1;

pid = Fork();
if (pid == 0) { /* Child */
printf("child : x=%d\n", ++x);
exit(0);
}

/* Parent */
printf("parent: x=%d\n", --x);
exit(0);
}

这个简单的例子包含了一些事情:

  1. 调用一次,返回两次。
  2. 并发执行。 父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的指令。
  3. 相同但是独立的地址空间。 父进程和子进程在fork函数返回之后那一刻的地址空间是相同的,每个进程有相同的用户栈、相同的本地变量值、相同的堆、相同的全局变量值,以及相同的代码。然而,因为父进程和子进程是独立的进程,它们都有自己的私有空间。后面,父进程和子进程对x所做的任何改变都是独立的,不会反应在另一个进程的内存中。
  4. 共享文件。 子进程继承了父进程所有的打开文件。

回收子进程

当一个进程由于某种原因终止时,内核并不是立即将它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收。当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程成为僵死进程。
如果一个父进程终止了,内核会安排init进程成为它的孤儿进程的养父。

一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止。

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

pid_t waitpid(pid_t pid, int *statusp, int options);
//返回: 如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1;。

加载并运行程序

1
2
3
4
5
#include <unistd.h>

int execve(const char *filename, const char *argv[],
const char *envp[]);
//如果成功,则不返回,如果错误,则返回-1。

只有当出现错误时,例如找不到filename,execve才会返回到调用程序。

execve函数在当前进程的上下文中加载并运行一个新的程序。它会覆盖当前进程的地址空间,但并没有创建一个新进程。新的程序仍然有相同的PID,并且继承了调用execve函数时已打开的所有文件描述符。

信号

信号使得进程和内核能够中断其他进程。

每种信号类型都对应于某种系统事件。低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。

发送信号

内核通过更新目的进程上下文中的某个状态,发送一个信号给目的进程。发送信号可以有如下两种原因:

  1. 内核检测到一个系统事件,比如除零错误或者子进程终止
  2. 一个进程调用了kill函数,显式地要求内核发送一个信号给目的进程。一个进程可以发送信号给它自己。

接收信号

当目的进程被内核强迫以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数捕获这个信号。

非本地跳转

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

1
2
3
4
5
#include <setjmp.h>

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

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

1
2
3
4
5
#include <setjmp.h>

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

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

用法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//code/ecf/setjmp.c
#include "csapp.h"

jmp_buf buf;

int error1 = 0;
int error2 = 1;

void foo(void), bar(void);

int main()
{
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");
break;
default:
printf("Unknown error condition in foo\n");
}
exit(0);
}

/* Deeply nested function foo */
void foo(void)
{
if (error1)
longjmp(buf, 1);
bar();
}

void bar(void)
{
if (error2)
longjmp(buf, 2);
}

C++和Java提供的异常机制是较高层次的,是C语言的setjmp和longjmp函数的更加结构化的版本。