第 8 章 异常控制流
# 第 8 章 异常控制流
引言
控制流 (flow of control 或 control flow) 的定义
- 从当前指令到下一条指令的过渡称为控制转换 (control transfer)
- 控制转移的序列称为控制流
异常控制流 (Exception Control FLow,ECF) 的定义
- 系统必须能够对系统状态的变化做出反应
- 现代系统通过使控制流发生突变来对系统状态的变化做出反应
- 把这些突变称为异常控制流
- 突变是相对于平滑的控制流而言的
ECF 的应用
- ECF 是操作系统用来实现 I/O、进程和虚拟内存的基本机制
- 应用程序通过使用一个叫做陷阱(trap)或者系统调用(system call)的 ECF 形式,向操作系统请求服务
- 操作系统为应用程序提供了强大的 ECF 机制
- 用来创建新进程、等待进程终止
- 通知其他进程系统中的异常事件,以及检测和响应这些事件
- 操作系统为应用程序提供了强大的 ECF 机制
- ECF 是计算机系统中实现并发的基本机制
- 软件异常允许程序进行非本地跳转(即违反通常的调用/返回栈规则的跳转)来响应错误情况
- 非本地跳转是一种应用层 ECF
# 8.1 异常
异常的基本概念
- 异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现
- 异常(exception)就是控制流中的突变,用来响应处理器状态中的某些变化
- 处理器状态中的变化(事件)触发从应用程序到异常处理程序的突发的控制转移(异常)
- 在异常处理程序完成处理后,它将控制返回给被中断的程序或者终止
示意图
- 状态变化称为事件(event)
- 事件可能和当前指令的执行直接相关
- 发生虚拟内存缺页、算术溢出,或者一条指令试图除以零
- 事件也可能和当前指令的执行没有关系
- 一个系统定时器产生信号或者一个 I/O 请求完成
- 事件可能和当前指令的执行直接相关
# 8.1.1 异常处理
异常号与异常表
- 系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号(exception number)
- 一些号码是由处理器的设计者分配的
- 包括被零除、缺页、内存访问违例、断点以及算术运算溢出
- 其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的
- 包括系统调用和来自外部 I/O 设备的信号
- 一些号码是由处理器的设计者分配的
- 在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目
包含异常 的处理程序的地址 异常表的示意图
- 异常表的起始地址放在一个叫做异常表基址寄存器(exception table base register)的特殊 CPU 寄存器里
异常处理程序地址的生成
# 8.1.2 异常类别
四类异常
- 异常可以分为四类:中断(interrupt),陷阱(trap)、故障(fault)和终止(abort)
中断
- 中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果
- 硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的
- 硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)
- 中断处理过程
陷阱和系统调用
- 陷阱是有意的异常,是执行一条指令的结果
- 陷阱处理程序将控制返回到下一条指令
- 陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用
- 陷阱处理过程
系统调用和普通函数调用的区别
- 普通的函数运行在用户模式中
- 用户模式限制了函数可以执行的指令的类型
- 而且它们只能访问与调用函数相同的栈
- 系统调用运行在内核模式中
- 内核模式允许系统调用执行特权指令
- 并访问定义在内核中的栈
- 普通的函数运行在用户模式中
故障
- 故障由错误情况引起,它可能能够被故障处理程序修正
- 当故障发生时,处理器将控制转移给故障处理程序
- 如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它
- 比如虚拟内存中的缺页异常
- 否则,处理程序返回到内核中的
abort
例程 abort
例程会终止引起故障的应用程序
- 如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它
- 故障处理示意图
终止
- 终止是不可恢复的致命错误造成的结果
- 通常是一些硬件错误,比如 DRAM 或者 SRAM 位被损坏时发生的奇偶错误
- 终止处理程序从不将控制返回给应用程序
- 终止处理示意图
# 8.1.3 Linux/x86-64 系统中的异常
x86-64 系统中定义的一些异常
- 有 256 种不同的异常类型
- 0-31 的号码对应的是由 Intel 架构师定义的异常
- 32 - 255 的号码对应的是操作系统定义的中断和陷阱
- 异常示例
- 一般保护故障(异常 13)
- 通常是因为一个程序引用了一个未定义的虚拟内存区域
- 或者因为程序试图写一个只读的文本段
- Linux 不会尝试恢复这类故障
- Linux shell 通常会把这种一般保护故障报告为
Segmentation fault
- Linux shell 通常会把这种一般保护故障报告为
- 一般保护故障(异常 13)
Linux x86-64 系统调用
- 常用的系统调用
- C 程序用
syscall
函数可以直接调用任何系统调用- 将系统调用和与它们相关联的包装函数都称为系统级函数
- 所有到 Linux 系统调用的参数都是通过通用寄存器而不是栈传递的,按照惯例
- 寄存器
%rax
包含系统调用号 - 寄存器
%rdi、%rsi、%rdx、%r10、%r8
和%r9
包含最多 6 个参数 %rax
包含返回值
- 寄存器
# 8.2 进程
进程的基本概念
- 异常是允许操作系统内核提供进程(process)概念的基本构造块
- 进程是计算机科学中最深刻、最成功的概念之一
- 进程是一个执行中程序的实例
- 系统中的每个程序都运行在某个进程的上下文(context)中
- 上下文是由程序正确运行所需的状态组成的
- 这个状态包括
- 存放在内存中的程序的代码和数据
- 栈、通用目的寄存器的内容、程序计数器
- 打开文件描述符的集合
- 内核栈和各种内核数据结构
- 比如描述地址空间的页表
- 包含有关当前进程信息的进程表
- 这个状态包括
- 进程提供给应用程序的关键抽象
- 一个独立的逻辑控制流
- 它提供一个假象,好像我们的程序独占地使用处理器
- 一个私有的地址空间
- 它提供一个假象,好像我们的程序独占地使用内存系统
- 一个独立的逻辑控制流
# 8.2.1 逻辑控制流
逻辑控制流示意图
- 关键点在于进程是轮流使用处理器的
- 每个进程执行它的流的一部分,然后被抢占(preempted)(暂时挂起)
- 然后轮到其他进程
# 8.2.2 并发流
并发流的概念
- 一个逻辑流的执行在时间上与另一个流重叠,称为并发流(concurrent flow),这两个流被称为并发地运行
- 多个流并发地执行的一般现象被称为并发(concurrency
- 一个进程和其他进程轮流运行的概念称为多任务(multitasking)
- 一个进程执行它的控制流的一部分的每一时间段叫做时间片(time slice)
- 因此,多任务也叫做时间分片(time slicing)
并行的概念
- 如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow)
- 它们并行地运行(running in parallel),且并行地执行(parallel execution)
# 8.2.3 私有地址空间
私有地址空间的内容
- 进程为每个程序提供它自己的私有地址空间
- 一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的
- 从这个意义上说,这个地址空间是私有的
- 私有空间有相同的通用结构
- 进程地址空间示意图
- 地址空间底部是保留给用户程序的
- 包括通常的代码、数据、堆和栈段
- 代码段总是从地址 0x400000 开始
- 地址空间顶部保留给内核(操作系统常驻内存的部分)
- 包含内核在代表进程执行指令时(比如当应用程序执行系统调用时)使用的代码、数据和栈
- 进程地址空间示意图
# 8.2.4 用户模式和内核模式
用户模式和内核模式的主要内容
- 为了给操作系统内核提供一个进程抽象,处理器需要限制一个应用可以执行的指令以及它可以访问的地址空间范围
- 处理器通常是用某个控制寄存器中的一个模式位(mode bit)来描述进程当前享有的特权
- 当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)
- 一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置
- 没有设置模式位时,进程就运行在用户模式中
- 用户模式中的进程不允许执行特权指令(privileged instruction)
- 比如停止处理器、改变模式位,或者发起一个 I/O 操作
- 也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据
- 任何这样的尝试都会导致致命的保护故障
- 用户程序必须通过系统调用接口间接地访问内核代码和数据
- 用户模式中的进程不允许执行特权指令(privileged instruction)
- 当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)
- 进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常
- Linux 中的
/proc
文件系统,它允许用户模式进程访问内核数据结构的内容- 比如 CPU 类型(
/proc/cpuinfo
) - 或者某个特殊的进程使用的内存段(
/proc/<process-id>/maps
)
- 比如 CPU 类型(
# 8.2.5 上下文切换
上下文切换的基本内容
- 操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务
- 上下文可以理解为内核重新启动一个被抢占的进程所需的状态
- 调度(scheduling)
- 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling)
- 调度是由内核中称为调度器(scheduler)的代码处理的
- 当内核选择一个新的进程运行时,我们说内核调度了这个进程
- 在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程
- 上下文切换的过程
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程
DMA 传输示例
- 在等待磁盘传输数据的过程中, CPU 可以去执行其它进程
- 磁盘传输完成后,会产生磁盘中断信号,这时操作系统可以切换到进程 A 继续执行
# 8.3 系统调用异常处理
当 Unix 系统级函数遇到错误时,它们通常会返回 —1,并设置全局整数变量 errno
来表示什么出错了
错误处理包装函数
- 为了避免代码变得臃肿,可以对系统级函数使用错误处理包装函数
- 包装函数调用基本函数,检査错误,如果有任何问题就终止
程序示例
void unix_error(char *msg) /* Unix-style error */ { fprintf(stderr, "%s: %s\n", msg, strerror(errno)); exit(0); } pid_t Fork(void) { pid_t pid; if ((pid = fork()) < 0) unix_error("Fork error"); return pid; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 8.4 进程控制
# 8.4.1 获取进程 ID
提示
每个进程都有一个唯一的正数(非零)进程 ID(PID)
#include <sys/types.h> #include <unistd.h> pid_t getpid(void); pid_t getppid(void); // 返回:调用者或其父进程的 PID
1
2
3
4
5
6
7
# 8.4.2 创建和终止进程
从程序员的角度来看,进程总是处于下面三种状态之一
- 运行
- 进程要么在 CPU 上 执行,要么在等待被执行且最终会被内核调度
- 停止
- 进程的执行被挂起(suspended),且不会被调度
- 当收到
SIGSTOP、SIGTSTP、SIGTTIN
或者SIGTTOU
信号时,进程就停止 - 并且保持停止直到它收到一个
SIGCONT
信号,在这个时刻,进程再次开始运行
- 终止
- 进程永远地停止了。进程会因为三种原因终止:
- 收到一个信号,该信号的默认行为是终止进程
- 从主程序返回
- 调用
exit
函数
- 进程永远地停止了。进程会因为三种原因终止:
exit
函数以 status
退出状态来终止进程
- 另一种设置退出状态的方法是从主程序中返回一个整数值
#include <stdlib.h>
void exit(int status);
// 该函数不返回
2
3
4
5
父进程通过调用 fork
函数创建一个新的运行的子进程
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
// 返回:子进程返回 0,父进程返回子进程的 PID,如果出错,则为 -1。
2
3
4
5
6
- 新创建的子进程几乎但不完全与父进程相同
- 子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本
- 包括代码和数据段、堆、共享库以及用户栈
- 子进程还获得与父进程任何打开文件描述符相同的副本
- 意味着当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件
- 父进程和新创建的子进程之间最大的区别在于它们有不同的 PID
- 子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本
fork
函数只被调用一次,却会返回两次- 一次是在调用进程(父进程)中
- 在父进程中,
fork
返回子进程的 PID
- 在父进程中,
- 一次是在新创建的子进程中
- 在子进程中,
fork
返回 0
- 在子进程中,
因为子进程的 PID 总是为非零,返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行
- 一次是在调用进程(父进程)中
- 父进程和子进程是并发运行的独立进程
- 内核能够以任意方式交替执行它们的逻辑控制流中的指令
使用 fork
时,编译时的命令
gcc fork.c ../src/csapp.c -o fork -I ../include -lpthread
# 8.4.3 回收子进程
僵死进程 (zombie)
- 当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除
- 相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)
- 当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程
- 从此时开始,该进程就不存在了
- 一个终止了但还未被回收的进程称为僵死进程
- 如果一个父进程终止了,内核会安排
init
进程成为它的孤儿进程的养父init
进程的 PID 为1
- 是在系统启动时由内核创建的,它不会终止,是所有进程的祖先
- 但对于长时间运行的程序,比如 shell 或者服务器,总是应该回收它们的僵死子进程
- 因为即使僵死子进程没有运行,它们仍然消耗系统的内存资源
一个进程可以通过调用 `waitpid` 函数来等待它的子进程终止或者停止
函数原型
#include <sys/types.h> #include <sys/wait.h> pid_t waitpid(pid_t pid, int *statusp, int options); // 返回:如果成功,则为子进程的 PID,如果 options 为 WNOHANG,且任何子进程都还没有终止,则为 0,如果其他错误,则为 -1。
1
2
3
4
5
6默认情况
- 默认情况下(当
options=0
时)waitpid
挂起调用进程的执行,直到它的等待集合(wait set)中的一个子进程终止- 如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么
waitpid
就立即返回
- 在这两种情况中,
waitpid
返回导致waitpid
返回的已终止子进程的 PID- 此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹
- 默认情况下(当
指定等待集合的成员,
pid
参数- 由参数
pid
来确定- 如果
pid>0
,那么等待集合就是一个单独的子进程,它的进程 ID 等于pid
- 如果
pid=-1
,那么等待集合就是由父进程所有的子进程组成的
- 如果
- 由参数
修改默认行为,
options
参数WNOHANG
- 如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为 0)
- 默认的行为是挂起调用进程,直到有子进程终止
WUNTRACED
- 挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止
- 默认的行为是只返回已终止的子进程
WCONTINUED
- 挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到
SIGCONT
信号重新开始执行
- 挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到
可以用或运算把这些选项组合起来
检查已回收子进程的退出状态
statusp
- 如果
statusp
参数是非空的- 那么
waitpid
就会在status
中放上关于导致返回的子进程的状态信息 status
是statusp
指向的值
- 那么
- wait.h 头文件定义了解释
status
参数的几个宏WIFEXITED(status)
- 如果于进程通过调用
exit
或者一个返回(return
)正常终止,就返回真
- 如果于进程通过调用
WEXITSTATUS(status)
- 返回一令正常终止的子进程的退出状态
- 只有在
WIFEXITED()
返回为真时,才会定义这个状态
WIFSIGNALED(status)
- 如果子进程是因为一个未被捕获的信号终止的,那么就返回真
WTERMSIG(status)
- 返回导致子进程终止的信号的编号
- 只有在
WIFSIGNALED()
返回为真时,才定义这个状态
WIFSTOPPED(status)
- 如果引起返回的子进程当前是停止的,那么就返回真
WSTOPSIG(status)
- 返回引起子进程停止的信号的编号
- 只有在
WIFSTOPPED()
返回为真时,才定义这个状态
WIFCONTINUED(status)
- 如果子进程收到
SIGCONT
信号重新启动,则返回真
- 如果子进程收到
- 如果
错误时的返回
- 如果调用进程没有子进程,那么
waitpid
返回 -1,并且设置errno
为ECHILD
- 如果
waitpid
函数被一个信号中断,那么它返回 -1,并设置errno
为EINTR
- 如果调用进程没有子进程,那么
wait
函数wait
函数是waitpid
的简单版本
#include <sys/types.h> #include <sys/wait.h> pid_t wait(int *statusp); // 返回:如果成功,则为子进程的 PID,如果出错,则为 -1
1
2
3
4
5- 调用
wait(&status)
等价于调用waitpid(-1, &status, 0)
# 8.4.4 让进程休眠
sleep
函数
#include <unistd.h>
unsigned int sleep(unsigned int secs);
// 返回:还要休眠的秒数。如果 sleep 函数被一个信号中断而过早地返回,其返回值可能大于 0
2
3
4
pause
函数
#include <unistd.h>
int pause(void);
// 总是返回 -1
2
3
4
- 该函数让调用函数休眠,直到该进程收到一个信号
# 8.4.5 加载并运行程序
execve
函数
#include <unistd.h>
int execve(const char *filename, const char *argv[],
const char *envp[]);
// 如果成功,则不返回,如果错误,则返回 -1
2
3
4
5
execve
函数在当前进程的上下文中加载并运行一个新程序execve
函数加载并运行可执行目标文件filename
- 且带参数列表
argv
和环境变量列表envp
- 只有当出现错误时,例如找不到
filename
,execve
才会返回到调用程序 - 否则,
execve
调用一次并从不返回
- 且带参数列表
新程序的主函数有如下形式的原型
int main(int argc, char **argv, char **envp);
1参数列表的组织结构
环境变量列表的组织结构
一个新程序开始时,用户栈的典型组织结构
#
操纵环境数组的函数
#include <stdlib.h>
char *getenv(const char *name);
// 返回:若存在则为指向 name 的指针,若无匹配的,则为 NULL
int setenv(const char *name, const char *newvalue, int overwrite);
// 返回:若成功则为 0,若错误则为 -1
void unsetenv(const char *name);
// 返回:无
2
3
4
5
6
7
8
9
10
11
程序和进程的区别
- 程序是一堆代码和数据
- 程序可以作为目标文件存在于磁盘上,或者作为段存在于地址空间中
- 进程是执行中程序的一个具体的实例
- 程序总是运行在某个进程的上下文中
fork
函数与 execve
函数的区别
fork
函数在新的子进程中运行相同的程序- 新的子进程是父进程的一个复制品
execve
函数在当前进程的上下文中加载并运行一个新的程序- 它会覆盖当前进程的地址空间,但并没有创建一个新进程
- 新的程序仍然有相同的 PID
- 并且继承了调用 execve 函数时已打开的所有文件描述符
# 8.4.6 利用 fork
和 execve
来运行程序
实际应用
- 像 Unix shell 和 Web 服务器这样的程序大量使用了
fork
和execve
函数 - shell 是一个交互型的应用级程序,它代表用户运行其他程序
- shell 执行一系列的读/求值(read/evaluate)步骤,然后终止
- 读步骤读取来自用户的一个命令行
- 求值步骤解析命令行,并代表用户运行程序
- shell 执行一系列的读/求值(read/evaluate)步骤,然后终止
# 8.5 信号
Linux 信号是一种更高层的软件形式的异常,它允许进程和内核中断其他进程
- 内核通知用户进程发生异常的信号,比如
- 如果一个进程试图除以 0,那么内核就发送给它一个
SIGFPE
信号(号码 8) - 如果一个进程执行一条非法指令,那么内核就发送给它一个
SIGILL
信号(号码 4) - 如果进程进行非法内存引用,内核就发送给它一个
SIGSEGV
信号(号码 11)
- 如果一个进程试图除以 0,那么内核就发送给它一个
- 其他信号对应于内核或者其他用户进程中较高层的软件事件,比如
- 如果当进程在前台运行时,键入
Ctrl+C
,那么内核就会发送一个SIGINT
信号(号码 2)给这个前台进程组中的每个进程 - 一个进程可以通过向另一个进程发送一个
SIGKILL
信号(号码 9)强制终止它 - 当一个子进程终止或者停止时,内核会发送一个
SIGCHLD
信号(号码 17)给父进程
- 如果当进程在前台运行时,键入
Linux 中的信号
# 8.5.1 信号术语
信号处理程序 (signal handler)
- 收到信号会触发控制转移到信号处理程序
- 在信号处理程序完成处理之后,它将控制返回给被中断的程序
待处理信号 (pending signal)
- 一个发出而没有被接收的信号叫做待处理信号(pending signal)
- 接收可以理解为被信号处理程序处理
- 在任何时刻,一种类型至多只会有一个待处理信号
- 如果一个进程有一个类型为
的待处理信号 - 那么任何接下来发送到这个进程的类型为
的信号都不会排队等待 - 它们只是被简单地丢弃
因此,不可以使用信号对其它进程中发生的事件进行计数
- 那么任何接下来发送到这个进程的类型为
- 内核为每个进程在
pending
位向量中维护着待处理信号的集合- 只要传送了一个类型为
的信号,内核就会设置 pending
中的第位
- 只要传送了一个类型为
- 如果一个进程有一个类型为
- 一个进程可以有选择性地阻塞接收某种信号
- 内核在
blocked
位向量中维护着被阻塞的信号集合 - 当一种信号被阻塞时,它仍可以被发送
- 通过设置
pending
位向量来实现
- 通过设置
- 但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞
- 内核在
一个待处理信号最多只能被接收一次
- 只要接收了一个类型为
的信号,内核就会清除 pending
中的第位
# 8.5.2 发送信号
Unix 系统提供了大量向进程发送信号的机制。所有这些机制都是基于进程组(process group)这个概念的
每个进程都只属于一个进程组,进程组是由一个正整数进程组 ID 来标识的
getpgrp
函数返回当前进程的进程组 ID#include <unistd.h> pid_t getpgrp(void); // 返回:调用进程的进程组 ID
1
2
3
默认地,一个子进程和它的父进程同属于一个进程组
一个进程可以通过使用
setpgid
函数来改变自己或者其他进程的进程组#include <unistd.h> int setpgid(pid_t pid, pid_t pgid); // 返回:若成功则为o,若错误则为 -1。
1
2
3
4- 如果
pid
是 0,那么就使用当前进程的 PID - 如果
pgid
是 0,那么就用pid
指定的进程的 PID 作为进程组 ID - 如果
pid
和pgid
均为 0,则使用当前进程的 PID 作为进程组 ID
- 如果
使用 /bin/kill
程序发送信号
linux> /bin/kill -9 15213 # 发送信号 9(SIGKILL)给进程 15213
linux> /bin/kill -9 -15213 # 发送一个 SIGKILL 信号给进程组 15213 中的每个进程
2
从键盘发送信号
- Unix shell 使用作业(job)这个抽象概念来表示为对一条命令行求值而创建的进程
- 在键盘上输入 Ctrl+C 会导致内核发送一个
SIGINT
信号到前台进程组中的每个进程- 默认情况下,结果是终止前台作业
- 输入 Ctrl+Z 会发送一个
SIGTSTP
信号到前台进程组中的每个进程- 默认情况下,结果是停止(挂起)前台作业
用 kill
函数发送信号
进程通过调用
kill
函数发送信号给其他进程(包括它们自己)#include <sys/types.h> #include <signal.h> int kill(pid_t pid, int sig); // 返回:若成功则为 0,若错误则为 -1
1
2
3
4
5
6如果
pid
大于零,那么kill
函数发送信号号码sig
给进程pid
如果
pid
等于零,那么kill
发送信号sig
给调用进程所在进程组中的每个进程- 包括调用进程自己
如果
pid
小于零,kill
发送信号sig
给进程组|pid|
(pid
的绝对值)中的每个进程
用 alarm
函数发送信号
进程可以通过调用
alarm
函数向它自己发送SIGALRM
信号#include <unistd.h> unsigned int alarm(unsigned int secs); // 返回:前一次闹钟剩余的秒数,若以前没有设定闹钟,则为0
1
2
3
4alarm
函数安排内核在secs
秒后发送一个SIGALRM
信号给调用进程
# 8.5.3 接收信号
接收信号的过程
- 当内核把进程
p
从内核模式切换到用户模式时- 例如,从系统调用返回或是完成了一次上下文切换
- 它会检查进程
p
的未被阻塞的待处理信号的集合(pending &~blocked)- 如果这个集合为空(通常情况下)
- 那么内核将控制传递到
p
的逻辑控制流中的下一条指令
- 那么内核将控制传递到
- 然而,如果集合是非空的
- 那么内核选择集合中的某个信号
(通常是最小的 ),并且强制 接收信号 - 收到这个信号会触发进程采取某种行为
- 一旦进程完成了这个行为,那么控制就传递回
的逻辑控制流中的下一条指令
- 那么内核选择集合中的某个信号
- 如果这个集合为空(通常情况下)
每个信号类型都有一个预定义的默认行为,是下面中的一种
- 进程终止
- 进程终止并转储内存
- 进程停止(挂起)直到被
SIGCONT
信号重启 - 进程忽略该信号
进程可以通过使用 signal
函数修改和信号相关联的默认行为
SIGSTOP
和SIGKILL
,它们的默认行为是不能修改的
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
// 返回:若成功则为指向前次处理程序的指针,若出错则为 SIG_ERR(不设置 errno)
2
3
4
5
6
signal
函数可以通过下列三种方法之一来改变和信号signum
相关联的行为- 如果
handler
是SIG_IGN
,那么忽略类型为signum
的信号 - 如果
handler
是SIG_DFL
,那么类型为signum
的信号行为恢复为默认行为 - 否则,
handler
就是用户定义的函数的地址,这个函数被称为信号处理程序- 只要进程接收到一个类型为
signum
的信号,就会调用这个程序 - 通过把处理程序的地址传递到
signal
函数从而改变默认行为,这叫做设置信号处理程序(installing the handler) - 调用信号处理程序被称为捕获信号
- 即是接收信号
- 执行信号处理程序被称为处理信号
- 只要进程接收到一个类型为
- 如果
- 信息处理程序可以被其他信号处理程序中断
# 8.5.4 阻塞和解除阻塞信号
Linux 阻塞信号的隐式和显式的机制
- 隐式阻塞机制
- 内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号
- 显式阻塞机制
- 应用程序可以使用
sigprocmask
函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号
- 应用程序可以使用
sigprocmask
函数及其辅助函数
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
//返回;如果成功则为 0,若出错则为 -1
int sigismember(const sigset_t *set, int signum);
// 返回:若 signum 是 set 的成员则为 1,如果不是则为 0,若出错则为 -1
2
3
4
5
6
7
8
9
10
11
sigprocmask
函数改变当前阻塞的信号集合(8.5.1 节中描述的 blocked 位向量)- 具体的行为依赖于
how
的值SIG_BLOCK
:把 set 中的信号添加到 blocked 中(blocked=blocked | set)。SIG_UNBLOCK
:从 blocked 中删除 set 中的信号(blocked=blocked &~set)。SIG_SETMASK
:block=set
- 如果
oldset
不为NULL
,那么 blocked 位向量之前的值保存在oldset
中
- 具体的行为依赖于
sigemptyset
初始化 set 为空集合sigfillset
函数把每个信号都添加到 set 中sigaddset
函数把signum
添加到 setsigdelset
从 set 中删除signum
- 如果
signum
是set
的成员,那么sigismember
返回1
,否则返回0
# 8.5.5 编写信号处理程序
处理程序有几个属性使得它们很难推理分析
- 处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰
- 如何以及何时接收信号的规则常常有违人的直觉
- 不同的系统有不同的信号处理语义
编写安全、正确和可移植的信号处理程序的一些基本规则
G0. 处理程序要尽可能简单
G1. 在处理程序中只调用异步信号安全的函数
- 所谓异步信号安全的函数(或简称安全的函数)能够被信号处理程序安全地调用,原因有二
- 要么它是可重入的(例如只访问局部变量,见 12.7.2 节)
- 要么它不能被信号处理程序中断
- 许多常见的函数(例如
printf
、sprintf
、malloc
和exit
)都不在此列 异步信号安全的函数
- 所谓异步信号安全的函数(或简称安全的函数)能够被信号处理程序安全地调用,原因有二
G2. 保存和恢复
errno
G3. 对共享的全局数据结构进行访问时,阻塞所有的信号
G4. 用
volatile
声明全局变量- 用
volatile
类型限定符来定义一个变量,告诉编译器不要缓存这个变量- 强迫编译器每次在代码中引用变量时,都要从内存中读取变量的值
- 用
G5. 用
sig_atomic_t
声明标志- 在常见的处理程序设计中,处理程序会写全局标志来记录收到了信号
- 主程序周期性地读这个标志,响应信号,再清除该标志
- C 提供一种整型数据类型
sig_atomic_t
,对它的单个的读和写保证会是原子的(不可中断的)- 因为可以用一条指令来实现它们
- 不适用于像
flag++
或flag=flag+10
这样的更新
- 在常见的处理程序设计中,处理程序会写全局标志来记录收到了信号
🔲 待做事项
- 补全 12.7.2 节的链接
- Shell lab
- 后台子程序回收时,使用了
sprintf
函数,这个需要尽量避免- 可以考虑换用
csapp.c
中的函数
- 可以考虑换用
- 后台子程序回收时,使用了
可称植的信号处理
Posix 标准定义了
sigaction
函数,它允许用户在设置信号处理时,明确指定他们想要的信号处理语义#include <signal.h> int sigaction(int signum, struct sigaction *act, struct sigaction *oldact); // 返回:若成功则为 0,若出错则为 -1。
1
2
3
4借助
sigaction
实现的Signal
包装函数handler_t *Signal(int signum, handler_t *handler) { struct sigaction action, old_action; action.sa_handler = handler; sigemptyset(&action.sa_mask); /* Block sigs of type being handled */ action.sa_flags = SA_RESTART; /* Restart syscalls if possible */ if (sigaction(signum, &action, &old_action) < 0) unix_error("Signal error"); return (old_action.sa_handler); }
1
2
3
4
5
6
7
8
9
10
11
12- 其信号处理语义如下
- 只有这个处理程序当前正在处理的那种类型的信号被阻塞
- 和所有信号实现一样,信号不会排队等待
- 只要可能,被中断的系统调用会自动重启
- 一旦设置了信号处理程序,它就会一直保持
- 直到
Signal
带着handler
参数为SIG_IGN
或者SIG_DFL
被调用
- 直到
- 其信号处理语义如下
# 8.5.6 同步流以避免讨厌的并发错误
注意子进程继承了它们父进程的被阻塞集合
- 所以在调用
execve
之前,或者调用fork
之后- 需要根据实际情况小心地解除原主进程中被阻塞的相关信号
# 8.5.7 显式地等待信号
sigsuspend
函数
函数原型
#include <signal.h> int sigsuspend(const sigset_t *mask); // 返回:-1
1
2
3
4
5其相当于下到语句
sigprocmask(SIG_BLOCK, &mask, &prev); pause(); sigprocmask(SIG_SETMASK, &prev, NULL);
1
2
3- 其中,原子属性保证对
sigprocmask
(第 1 行)和pause
(第 2 行)的调用总是一起发生的,不会被中断- 这样就消除了潜在的竞争,即在调用
sigprocmask
之后但在调用pause
之前收到了一个信号
- 这样就消除了潜在的竞争,即在调用
- 其中,原子属性保证对
使用示例
/* $begin sigsuspend */ #include "csapp.h" volatile sig_atomic_t pid; void sigchld_handler(int s) { int olderrno = errno; pid = Waitpid(-1, NULL, 0); errno = olderrno; } void sigint_handler(int s) { } int main(int argc, char **argv) { sigset_t mask, prev; Signal(SIGCHLD, sigchld_handler); Signal(SIGINT, sigint_handler); Sigemptyset(&mask); Sigaddset(&mask, SIGCHLD); while (1) { Sigprocmask(SIG_BLOCK, &mask, &prev); /* Block SIGCHLD */ if (Fork() == 0) /* Child */ exit(0); /* Wait for SIGCHLD to be received */ pid = 0; while (!pid) Sigsuspend(&prev); /* Optionally unblock SIGCHLD */ Sigprocmask(SIG_SETMASK, &prev, NULL); /* Do some work after receiving SIGCHLD */ printf("."); } exit(0); } /* $end sigsuspend */
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
42
43
44
# 8.6 非本地跳转
非本地跳转(non local jump)是一种用户级异常控制流形式
- 将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用—返回序列
- 非本地跳转是通过
setjmp
和longjmp
函数来提供的
setjmp
和 longjmp
函数原型
#include <setjmp.h> int setjmp(jmp_buf env); int sigsetjmp(sigjmp_buf env, int savesigs); void longjmp(jmp_buf env, int retval); void siglongjmp(sigjmp_buf env, int retval);
1
2
3
4
5
6setjmp
函数在env
缓冲区中保存当前调用环境,以供后面的longjmp
使用,并返回 0- 调用环境包括程序计数器、栈指针和通用目的寄存器
setjmp
的返回的值不能被赋值给变量- 不过它可以安全地用在
switch
或条件语句的测试中
- 不过它可以安全地用在
longjmp
函数从env
缓冲区中恢复调用环境,然后触发一个从最近一次初始化env
的setjmp
调用的返回- 然后
setjmp
返回,其返回值为retval
retval
一般设置为非 0
- 然后
sigsetjmp
和siglongjmp
函数是 setjmp 和 longjmp 的可以被信号处理程序使用的版本- 在
siglongjmp
可达的代码中,应该只调用安全的函数
- 在
用法示例
用于错误处理
- 如果在一个深层嵌套的函数调用中发现了一个错误情况
- 可以使用非本地跳转直接返回到一个普通的本地化的错误处理程序
- 而不是费力地解开调用栈
示例
/* $begin setjmp */ #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); } /* $end setjmp */
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
42
- 如果在一个深层嵌套的函数调用中发现了一个错误情况
在信号处理中用于软重启
示例
/* $begin restart */ #include "csapp.h" sigjmp_buf buf; void handler(int sig) { siglongjmp(buf, 1); } int main() { if (!sigsetjmp(buf, 1)) { Signal(SIGINT, handler); Sio_puts("starting\n"); } else Sio_puts("restarting\n"); while(1) { Sleep(1); Sio_puts("processing...\n"); } exit(0); /* Control never reaches here */ } /* $end restart */
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
# 8.7 操作进程的工具
Linux 系统提供了大量的监控和操作进程的有用工具
- STRACE
- 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹
- 用
-static
编译的程序,可以得到一个更干净的、不带有大量与共享库相关的输出的轨迹
- PS
- 列出当前系统中的进程(包括僵死进程)
- TOP
- 打印出关于当前进程资源使用的信息
- PMAP
- 显示进程的内存映射
- /proc
- 一个虚拟文件系统,以 ASCII 文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容
- 比如,输入
cat /proc/loadavg
,可以看到 Linux 系统上当前的平均负载