ICS08 异常控制流
type
status
date
slug
summary
tags
category
icon
password
异常控制流
异常控制流 (Exceptional Control Flow, ECF) 机制是现代操作系统实现并发、进程间通信以及处理硬件事件的核心机制。本文将探讨 ECF 的不同类型,包括异常、进程上下文切换和信号,并深入讲解进程控制的各个方面,如进程创建、终止和清理。
系统级异常
应用程序运行时必然要通过操作系统与各种硬件产生交互,来响应各种系统事件,例如:主存或磁盘读写操作完成;文件未找到;内存不足等等。另一方面,用户可以通过键盘输入 (例如
Ctrl+C
) 中止程序执行。程序自身也可能因异常情况 (例如除以零) 而终止或报错。操作系统该如何处理异常呢?高效的异常处理机制不能依赖于频繁的轮询(每隔一段时间间隔检查是否有异常),间隔过短则影响程序性能,间隔过长则可能导致异常处理不及时。现代系统采用基于事件的异常控制流,由硬件和操作系统协同处理系统事件。当检测到异常时,控制流从程序手中被重定向到相应的异常处理程序,也即异常控制流。
异常控制流的主要类型
异常控制流主要分为以下三种类型:
- 异常 (Exceptions): 由硬件和操作系统共同实现,用于响应系统事件并改变控制流。
- 进程上下文切换: 操作系统利用定时器硬件,在多个进程之间快速切换,营造并行执行的假象。
- 信号 (Signals): 由操作系统实现,用于进程间通信或其他系统事件通知。
异常 (Exceptions)
操作系统将计算机状态分为内核态和用户态。正常运行时,系统处于用户态执行用户代码;发生异常时,处理器保存用户态上下文 (例如程序计数器 PC),并切换到内核态执行异常处理程序。在内核态下,程序被赋予更高的权限。
每种异常都有唯一的异常代码 k。操作系统内核根据 k 值查阅异常表 (Exception Table) 或中断向量表 (Interrupt Vector Table),找到对应的异常处理器 (Exception Handler) 并执行。异常处理器通常执行以下三种操作之一:
- 将控制流返回到用户态,继续执行产生异常的指令。例如,程序访存时发生缺页异常,内核就应该在完成调页后将控制流返回到访存指令,完成访存。
- 将控制流返回到用户态,继续执行产生异常的指令的下一条指令。例如下文中提到的系统调用。
- 终止用户态程序。例如发生不可恢复的故障。
同步异常和异步异常
异常可以分为同步异常和异步异常:
- 同步异常/内部异常: 由执行用户代码直接引起的异常,例如页面错误、除以零等算术错误、程序终止等。同步异常又可细分为:
- 陷阱 (Traps): 人为触发的异常,例如系统调用、断点、特殊指令。
- 故障 (Faults): 非人为触发的可恢复异常,例如页错误(Page Fault)。
- 中止 (Aborts): 非人为触发的不可恢复异常,通常导致程序终止。
- 异步异常/外部异常/中断: 由用户代码之外的事件引起的异常,例如 I/O 操作完成、定时器中断、硬件中断等。这类异常不是由当前执行的用户代码引起的。
系统调用 (System Call)
系统调用是陷阱的一种,允许用户态程序请求内核服务,例如读写文件、创建进程、获取系统时间等。系统调用通过特定的指令触发,并将调用的功能码存放在指定寄存器 (例如
%rax
) 中。内核处理完成后,将控制流返回到系统调用指令的下一条指令。故障 (Fault)
故障是非人为触发的可恢复异常。例如我们将在下一章中学到的,页错误发生时,处理器中的内存管理单元 (MMU) 发现虚拟地址无法映射到物理地址。内核处理页错误后,控制流返回到产生错误的指令,重新执行该指令。
进程 (Process)
“Process is one of the most profound ideas in computer science.” —— CSAPP3e
进程是程序运行时的实例。一个程序可以创建为多个进程,这些进程可以在同一处理器或不同处理器上运行。进程是操作系统提供的核心抽象之一,它提供了两个关键抽象:
- 逻辑控制流 (Logical Control Flow): 操作系统通过进程上下文切换,使每个进程都感觉自己拥有连续的处理器时间。
- 私有地址空间 (Private Address Space): 操作系统通过虚拟内存机制,使每个进程都感觉自己独占整个内存空间。这点将在下一章提及。
进程的地址空间包含代码、数据、堆、栈以及保存的寄存器等信息。进程切换时,操作系统需要保存当前进程的上下文,并恢复下一个进程的上下文。
如果两个进程交替使用同一个处理器,则称它们为并发 (Concurrent);否则,称它们为串行 (Sequential)。
进程控制 (Process Control)
创建子进程
进程通过调用
fork()
函数创建子进程。fork()
调用一次,返回两次:在父进程中返回子进程的 PID(Process ID,进程编号),在子进程中返回 0。如果 fork()
失败,则在父进程中返回 -1。子进程是父进程的副本,包括虚拟地址空间,并从 fork()
函数返回后开始执行。进程间的调度是由操作系统而非用户决定的。这也就引出了(对初学者而言)进程最令人迷惑的性质:父进程和子进程的执行顺序是不确定的。
清理子进程
子进程通过调用
exit()
函数终止自己的执行;进程在终止后会变成僵尸进程 (Zombie Process)。僵尸进程仍然占用一些系统资源,因而父进程有责任清理僵尸子进程。父进程可以使用
wait()
或 waitpid()
函数等待子进程终止并回收其资源。waitpid()
函数提供更精细的控制,可以等待特定的子进程或指定等待选项。waitpid(pid_t pid, int* child_status, int options)
:等待特定(pid
)子进程结束- 参数
pid
pid > 0
:等待进程ID为 pid 的子进程。pid == 0
:等待与调用进程在同一个进程组的任意子进程。pid < -1
:等待进程组ID为 pid 的任意子进程。pid == -1
:等待任意子进程(与wait
类似)。
- 参数
options
: - 设置为宏
WNOHANG
时,启用“非阻塞等待模式”,立即返回,如果此时没有子进程结束,返回0;不启用“非阻塞等待模式”时,父进程阻塞,直到有子进程结束时再返回 - 设置为宏
WUNTRACED
时,跟踪终止(terminated)和停止/暂停(stopped)的子进程,而不仅是终止的子进程。不同宏之间用按位或|
连接。
- 成功时,返回已终止的子进程的进程ID。失败时,返回 -1,并设置
errno
。
- 将子进程退出状态存放在
child_status
所指向的int
型变量中,通过WIFEXITED(child_status)
检查子进程是否正常退出,WEXITSTATUS(child_status)
返回子进程退出状态(即子进程调用exit()
的参数)
如果父进程先于子进程终止,子进程会成为孤儿进程 (Orphan Process),并被系统进程 (通常是
init
或 systemd
) 收养。启动新程序
execve()
函数用新的程序替换当前进程的虚拟内存映像,但不改变进程 ID。execve()
成功后,其后的代码不会被执行;失败则返回 -1。函数原型:
execve(const char* filepath, char *const argv[], char *const envp[])
argv[]
是传递给新程序的参数数组,每个元素是char
型指针。以NULL
结尾。
envp[]
是传递给新程序的环境变量数组,同样以NULL
结尾
进程控制总结
典型的进程控制流程如下:
- 父进程调用
fork()
创建子进程。
- 子进程调用
execve()
启动新程序。
- 父进程调用
waitpid()
等待子进程终止并回收资源。
信号
信号 (Signal) 是一种软件中断,用于通知进程系统中发生的特定事件。本文将探讨信号的机制,包括信号的发送、接收和处理,以及如何编写安全的信号处理程序。
从Linux Shell开始
Linux 系统启动时,首先执行
init
(PID 为 1) 进程,然后启动登录 shell。Shell 负责解释用户输入的命令,并执行相应的操作。Shell 的核心功能是一个循环:- 从命令行读取用户输入。
- 解析命令并执行。
Shell 命令可以是内置命令或外部程序。对于外部程序,shell 会创建一个子进程来执行程序。如果命令以
&
结尾,则在后台执行;否则,在前台执行。其代码框架如下:后台执行的程序不会阻塞 shell,用户可以继续输入其他命令与shell交互。然而,后台进程结束后会变成僵尸进程,占用系统资源。为了避免这种情况,可以使用信号机制来处理子进程的终止。
信号 (Signals)
信号是一种异步通知机制,用于告知进程系统中发生的特定事件。信号由内核发送,有时是应其他进程的请求。每个信号都有一个唯一的整数 ID (1-30)。信号只携带两个信息:信号类型和信号发生。
一些常见的信号:
SIGINT
(ID=2): 用户按下Ctrl+C
。
SIGKILL
(9): 强制终止进程 (无法被忽略或捕获)。
SIGCHLD
(17): 子进程终止或停止。
除
SIGKILL
和 SIGSTOP
外,其他信号的处理方式都可以被修改。信号控制 (Kernel)
当一个信号已被发送,但还未被接收,则称其正在pending(待处理)。同类型的pending信号最多只能有一个,这是信号最为重要的性质之一。因此,信号不是队列式的:如果一个进程已经有一个类型为 k 的 pending 信号,则后续发送给该进程的 k 类型信号将被丢弃(discard)。
进程可以屏蔽(block)特定类型信号的接收,该类型的信号不会再被该进程接受(但仍会处于pending状态),直到进程取消屏蔽(unblock)
内核维护每个进程的信号相关信息,包括两个位向量(bit vector):
- Pending : 已经发送但尚未被接收的信号。
- Blocked : 被进程屏蔽的信号。
当一个信号被发送时,内核将对应的 pending 位设置为 1;当信号被接收时,将其设置为 0。同一类型的 pending 信号最多只能有一个。
当内核将控制权交给一个进程时,会检查该进程是否有 pending 信号。如果有未被屏蔽的 pending 信号,内核会强制进程执行相应的操作。
进程组 (Process Group)
每个进程都属于一个进程组,由进程组 ID (PGID) 标识。PGID 通常与进程组中最老的进程的 PID 相同。
信号发送
用户可以通过以下方式发送信号:
- 命令行: 使用
kill
命令。例如,kill -9 1234
向 PID 为 1234 的进程发送ID为9的SIGKILL
信号。
- 键盘: Ctrl+C (向当前前台进程)发送
SIGINT
信号,而 Ctrl+Z 发送SIGTSTP
信号。
- C 函数: 使用
kill()
函数。例如,kill(pid, SIGUSR1)
向 PID 为pid
的进程发送SIGUSR1
信号。
信号接收
进程接收到信号后,内核会强制其执行预定义的操作。这些操作包括:
- 忽略信号: 对某些信号,进程可以选择忽略它们。
- 终止进程: 某些信号的默认操作是终止进程。
- 调用信号处理程序: 进程可以注册/安装自定义的信号处理程序。
可以使用
signal()
函数安装信号处理程序:进程可以使用如下方法来屏蔽信号:
- 使用函数
sigemptyset
来创建一个类型为sigset_t
的空信号集合
- 使用函数
sigaddset
将某信号加入sigset_t
集合
- 使用
sigprocmask
设置blocked信号
sigprocmask
参数说明参数名 | 类型 | 说明 |
how | int | 控制信号屏蔽字的操作方式(见下表)。 |
set | const sigset_t* | 指向 sigset_t 类型的信号集合,指定要添加、删除或替换的信号。如果为 NULL ,则不修改。 |
oldset | sigset_t* | 如果不为 NULL,存储调用前的信号屏蔽字(即之前被阻塞的信号集合)。 |
how
参数的取值及含义值 | 含义 |
SIG_BLOCK | 将 set 中的信号加入到当前屏蔽字中(阻塞这些信号)。 |
SIG_UNBLOCK | 从当前屏蔽字中移除 set 中的信号(解除对这些信号的阻塞)。 |
SIG_SETMASK | 将当前屏蔽字替换为 set 中的信号集合(完全覆盖当前屏蔽字)。 |
安全的信号处理程序
编写安全的信号处理程序需要注意以下几点:
- 保持简洁: 信号处理程序应该尽可能简单,避免执行耗时操作。
- 使用异步信号安全函数: 避免在信号处理程序中调用非异步信号安全的函数,例如
printf
、malloc
、exit
等。
- 保存并恢复
errno
: 在进入和退出信号处理程序时,保存并恢复errno
的值。
- 保护共享数据结构: 使用信号量或其他同步机制来保护共享数据结构,防止数据竞争。
- 使用
volatile
关键字: 将全局变量声明为volatile
,以防止编译器优化导致数据不一致。
常见的非异步信号安全的函数包括:
- 动态内存分配函数:如
malloc
、free
,因为它们可能修改全局状态。
- 标准 I/O 函数:如
printf
、scanf
,因为它们可能使用内部缓冲区和锁。
- 非可重入函数:如
strtok
,因为它们可能依赖静态数据。
- 线程相关函数:如
pthread_mutex_lock
,因为它们可能引发死锁。
异步信号安全函数 (Async-Signal-Safe Functions)
信号是异步的,它们可以在程序执行的任意时间点触发,甚至可能中断正在使用重要全局数据结构的关键代码段。异步信号安全函数可以在信号处理程序中安全地调用。这些函数要么是可重入的,要么不会被其他信号中断。
一些常见的异步信号安全函数:
_exit()
abort()
signal()
(部分实现)
sigaddset()
,sigdelset()
,sigemptyset()
,sigfillset()
kill()
,sigaction()
,sigprocmask()
,sigpending()
write()
,read()
,lseek()
(部分实现)
可重入函数
在多个执行路径(如多线程、信号处理程序或递归调用)中同时调用时,能够正确执行而不产生副作用的函数。这类函数通常不依赖静态或全局数据,也不调用不可重入的函数。以下是可重入函数的主要特征:
1. 不使用静态或全局数据
可重入函数不依赖静态变量、全局变量或其他共享状态,因为这些数据可能被其他执行路径修改,导致不可预测的行为。
2. 不调用不可重入的函数
可重入函数不会调用不可重入的函数(如
malloc
、printf
等),因为这些函数可能修改全局状态或使用内部锁。3. 仅使用局部变量
可重入函数的所有数据都存储在栈上(即局部变量),每个调用都有自己的独立数据副本,不会与其他调用冲突。
4. 不修改共享资源
可重入函数不会修改共享资源(如文件、硬件设备等),除非通过原子操作或锁来确保线程安全。例如下面的第一个函数,加法操作看似是原子的(atomic,指不可被拆分),然而经过编译后,加法操作至少包含三个汇编指令:访存,从栈上取值到寄存器上;通过ALU进行加法操作;访存,将寄存器的值存储到栈上。想象该函数生成了两个进程A和B,有可能进程A刚完成第一步访存,操作系统就进行进程间切换,由进程B执行第一步访存。由于A和B获取到的是相同的值,它们也会向栈上存储相同的值。该函数总共执行了两次,但最终变量
data
只增加了1 。这就涉及到了进程间的同步,我们在第十二章中会有详细的介绍。可重入函数的核心特点是独立性和无副作用,能够在任何执行环境中安全调用。在编写信号处理程序、多线程程序或递归函数时,使用可重入函数是避免竞态条件和未定义行为的关键。
sigsuspend()
sigsuspend()
函数用于安全地等待信号。它会临时替换进程的信号掩码,然后挂起进程,直到接收到未被屏蔽的信号。- 参数
mask
: 一个sigset_t
类型的指针,这个掩码临时替换进程的当前信号屏蔽字,表示在挂起期间希望阻塞的信号掩码。这意味着在挂起期间,只有mask
中未阻塞的信号才能唤醒进程。
- 返回值:
sigsuspend
在接收到一个信号并执行完相应的信号处理程序后返回 -1,并将errno
设置为EINTR
(因为挂起被信号中断)。
下面展示一段shelllab中的代码,以此说明该函数的用处:
非本地跳转 (Nonlocal Jumps)
非本地跳转允许程序从一个函数跳转到另一个函数,而不需要通过正常的函数调用和返回机制。
setjmp()
和 longjmp()
函数提供了非本地跳转的功能。setjmp(jmp_buf env)
: 保存当前执行环境到env
中。
longjmp(jmp_buf env, int val)
: 恢复之前由setjmp()
保存的执行环境,并使setjmp()
返回val
。
在上面的例子中,第一次
setjmp
返回0,进入第一个分支;longjmp()
会使程序跳转到 setjmp()
的位置,并使 setjmp()
返回 1。Prev
ICS07 链接
Next
ICS09 虚拟内存
Loading...