计算机体系结构——异常与控制流
异常
CPU 遇到不同的异常事件,停止当前的工作而去处理的过程,叫做异常处理。
内存中存在一个异常表,指明了各种异常代码对应的异常处理函数的首地址。
异常根据处理的结果分为三种:
- 异常处理结束之后,重新执行
I
c
u
r
r
I_{curr}
Icurr? 代码。
- 异常处理结束之后,执行
I
n
e
x
t
I_{next}
Inext? 代码。
- 异常处理结束之后,终止程序的运行。
在 CPU 内部存在一个异常表基址寄存器保存了异常表的首地址,异常代码作为偏移指向不同的异常处理器首地址。
与普通调用指令不同的是,异常处理运行在内核模式下,在内核区创建栈帧,处理完成后返回用户模式。
异常处理的类型
异常处理分为四种类型:
类型 | 原因 | 是否同步 | 返回行为 |
---|
中断 | 来自 IO 设备的信号 | 异步 | 经常返回
I
n
e
x
t
I_{next}
Inext? | 陷阱 | 有意图的行为 | 同步 | 经常返回
I
n
e
x
t
I_{next}
Inext? | 失败 | 可恢复的错误 | 同步 | 可能返回
I
c
u
r
r
I_{curr}
Icurr? | 终止 | 不可恢复的错误 | 同步 | 不返回 |
中断
中断来自于各种 IO 设备的信号,它是异步的,说明中断有时不是由特定的 CPU 指令触发,而是 IO 设备主动触发。
CPU 通常有外部中断引脚,总线通过将中断引脚置位高电平触发 CPU 中断, CPU 通过读取总线中的中断代码,选择对应的处理器处理。
陷阱
陷阱是一类有意图的异常处理。当用户模式的程序需要调用内核模式下的函数,通过触发陷阱进行调用,也称为系统调用。
例如当程序需要调用读取文件函数 read 或者创建进程函数 fork 的时候,可以通过系统调用。
在程序员的视角就是一类普通的函数调用。
失败
通常由
I
c
u
r
r
I_{curr}
Icurr? 触发,一类典型的失败就是内存页失败,对应的物理页并没有加载进内存, CPU 触发并处理,将磁盘中的页加载进内存,重新执行
I
c
u
r
r
I_{curr}
Icurr? 。
如果失败无法恢复,那么将会造成程序终止。
终止
这类通常是由
I
c
u
r
r
I_{curr}
Icurr? 造成的无法恢复的错误。将导致程序异常终止。
Intel 处理器中的异常
一个奔腾处理器能够最多定义 256 中不同的异常。从 0 到 31 由奔腾架构统一定义,从 32 到 255 由操作系统定义。
例如:
- 除法失败(异常代码 0)发生在被 0 除的情况等情况。 Unix 系统不会恢复此失败。会在 Shell 中打印 “Floating exception” 。
- 一般保护失败(异常代码 13)发生在错误的访问了非法内存。 Unix 系统不会恢复此失败。会在 Shell 中打印 “Segmentation faults” 。
- 页失败(异常代码 14)发生在页没有加载进内存, Unix 会恢复此错误。
- 机器检查终止(异常代码 18)发生在硬件错误。
- 系统调用通常使用
INT n 指令, n 指系统调用代码。
进程
进程是执行程序的实例,每一个程序都有自己的进程上下文。进程上下文包括程序代码、程序执行状态、内存、栈帧、寄存器等内容。
逻辑控制流
操作系统将一个 CPU 物理控制流,分成多个逻辑控制流,每个进程独占一个逻辑控制流。
当一个逻辑控制流执行的时候,其他的逻辑控制流可能会临时暂停执行。
一般来说,每个逻辑控制流都是独立的,除非使用了进程间通讯技术(IPC)。
当两个逻辑控制流在时间上发生重叠,我们说是并行的。
处理器在多个进程中来回切换称为多任务,每个时间当处理器执行一段控制流称为时间片。因此多任务也指时间分片。
私有地址空间
每个进程都有他独占的私有内存地址空间,其他进程没有访问的权限。
用户和内核模式
通常处理器提供了一个标志位说明当前的控制流处于的模式。当标志位置 1 时,运行在内核模式下,可以访问和执行所有的数据和代码。当标志位置 0 时,运行在用户模式下,只能访问和执行特定的数据和代码。
处理器默认执行在用户模式下,想进入内核模式,只能通过系统调用、中断等方式。
Linux 为用户模式提供了更方便的访问的内核数据的方法,使用 /proc 文件系统。
上下文切换
操作系统暂停一个进程而恢复另一个进程执行的过程称为上下文切换。
上下文是包含用于恢复进程执行的所有信息,包含寄存器、内存、文件等信息。
如何选择暂停的进程和恢复的进程称为调度,在内核区域存在调度器的代码。
调度器执行在内核模式,主要执行三个任务,第一保存被暂停的进程的上下文,第二加载即将执行进程的上下文,第三转移执行权给进程。
发生调度通常在进程发生了阻塞操作或者是时间片用尽。
系统调用和错误处理
Unix 系统提供了许多系统调用,可以通过 man syscalls 查看。
C 程序可以使用 _syscall 宏进行系统调用。
Linux 系统为用户封装了绝大多数系统调用变成一个单独的函数。用户可以直接执行这些函数而不需要处理系统调用,这些函数称为系统级函数。
当系统级函数执行错误的时候,通常会返回一个错误代码,并设置 errno 变量描述错误类型,用户可以通过 strerror(errno); 获得字符串描述。
进程控制
Linux 系统提供了很多进程控制的系统调用。
获取 PID
每个进程都有一个独一无二的进程 ID 称为 PID 。
getpid() 函数返回当前程序的 PID , getppid() 返回父进程 PID 。
进程的创建和终止
在程序员视角,进程分为三种情况:
- 运行。指进程正在运行或即将被 CPU 调度运行。
- 停止。 CPU 不会调度该进程,但是可以被稍后唤起。
- 终止。进程永远停止,且不会被唤起。
主动退出一个进程使用 exit() 函数,参数作为该进程的返回值。
更有趣的是进程的创建。 Linux 创建进程使用 fork 函数。
父进程通过 fork 函数创建子进程,子进程获得一份完全一样的父进程的上下文拷贝(但是和父进程分离)。子进程和父进程具有不同的 PID 。子进程和父进程同时在 fork 处继续运行。
有趣的是 fork 函数在子进程和父进程的返回值不同,在父进程返回子进程的 PID 在子进程返回 0 ,因为具有不同的返回值,因此可以判定当前程序是在子进程还是在父进程中。
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
int main()
{
pid_t pid;
int x = 1;
pid = fork();
if(pid == 0)
{
printf("child: x = %d\n",++x);
exit(0);
}
printf("parent: x = %d\n",--x);
exit(0);
return 0;
}
上面的代码将输出:
parent: x = 0
child: x = 2
fork 函数具有以下特性:
- 调用一次,返回两次。
- 并行执行。
- 复制但分离内存空间。
- 共用打开的文件。说明了子进程的输出同样在控制台上。
收割子进程
当一个进程终止,系统内核不会立即将他从内存中移除。而是等待被父进程收割,当一个父进程收割子进程,子进程的返回值会返回给父进程,然后再从内存中移除。当父进程结束时,然而并没有收割它的子进程,此时子进程叫做僵尸进程。
如果系统发现存在僵尸进程,那么 init 进程就会默认收割这些进程。 init 进程的 PID 是 1 是所有进程的最终父进程,所有进程由 init 所 fork 来。 init 进程由系统内核在系统初始化时创建。
父进程等待子进程的终止并收割通过一个叫 waitpid 的函数。
进程睡眠
将一个进程变成睡眠状态使用 sleep() 函数。注意, sleep 可能会过早的返回,因为他是通过信号控制。
另外一个函数 pause() 使得进程永远进入睡眠状态,直到收到一个恢复或者终止信号。
加载和执行外部程序
execve 函数能够加载外部可执行目标文件到当前进程中并执行。
execve 函数需要提供 argv 和 envp 数组,分别表示传递给待加载的 main 函数的参数和环境变量。
execve 不会返回除非目标程序没有执行,也不会创建进程,只是将新的可执行目标文件加载当前进程上下文开始执行。
Unix 系统提供了 getenv 和 setenv 以及 unsetenv 函数操作当前上下文中的环境变量。
信号
到目前为止,我们学会了进程管理、异常处理等低级的控制流模型。现在,我们介绍一种高级的控制流模型,用于进程和进程之间的空间,称为信号。
信号作用于两个不同的阶段:
- 信号发送。信号只能由系统向某一目标进程发送。操作系统通过修改某个进程的上下文中的相关信息实现发送信号。操作系统向进程发送信号只发生在:
- 操作系统检查到系统事件,例如被 0 除,向进程发送信号。
- 操作系统收到某一个进程的发送信号请求(使用
kill 函数),向目标进程传递信号。 - 接受信号。目标进程接受到操作系统发来的信号进行处理。
一个已经发送但还没有收到的信号叫等待信号。在同一时刻,一个类型的等待信号只能有一个,同类型的信号将被忽略。一个进程能够阻塞一个信号。这都是通过设置信号的 pending 和 blocked 标志位完成。
发送信号
发送信号通过一个进程组实现,一个进程唯一属于一个进程组。通过 getpgid 函数获取当前进程的进程组 ID 或者通过 setpgid 设置一个进程的进程组 ID 。
默认的,子进程和父进程属于同一进程组。
发送信号可通过 kill 命令发送,例如:
kill -9 15213 给 PID 为 15213 的进程发送信号 9 ( SIGKILL )。
如果给出一个负数 PID ,那么这个 PID 指的是进程组 ID 。将会给这个进程组的进程都发送信号。
还可通过 Shell 手动发送信号,用户输入的一行命令称为一个作业,一个作业可以由多个进程组成通过管道连接。同时,只有一个前台作业,多个后台作业。
当用户按下 Ctrl + C 将会给 Shell 发送 SIGINT 信号, Shell 将会给当前前台任务发送 SIGINT 信号。当用户按下 Ctrl + Z 将会给 Shell 发送 SIGSTP 信号, Shell 将会给当前前台任务发送 SIGSTP 信号。
或者通过 kill 函数发送信号。
或者通过 alarm 函数发送 SIGALRM 信号。
接受信号
使用 signal 函数修改进程处理信号的默认行为。
非本地跳转
Unix 提供了一系列非本地跳转函数。非本地跳转可以设置跳转点和跳转动作在一个进程中随意跳转从而绕过函数调用的过程。
setjmp 设置跳转点, longjmp 执行跳转。
用于跳出多函数嵌套,和信号处理。
工具
Linux 系统提供了一些工具用于进程管理:
strace 命令可以追踪一个进程和系统内核的交换,例如跟踪系统调用。ps 命令,枚举系统的所有进程。top 命令,列举系统当前执行信息。kill 发送信号。/proc 文件系统,是一个虚拟文件系统, Linux 将一些内核信息置于 ASCII 文件当中。
|