CSAPP 第八章 异常控制流

Undertake something difficult, otherwise you will never grow.

程序控制流的变化可能由以下两种情况导致

  • 程序状态(program state)发生变化
    • 跳转和分支
    • 函数调用和返回
  • 系统状态(system state)发生变化
    • 指令除以0
    • 信号
    • 系统定时器到时
    • 来自磁盘或是网卡的数据到达内存

为了响应系统状态的变化,需要异常控制流。

异常控制流存在于系统的各个层次

  • 硬件层
    • 异常: os和硬件实现
  • os层
    • 多任务: 上下文切换
  • os和用户态接口层
    • 进程控制: os软件和定时器硬件
    • 信号: os软件实现,用户进程负责处理
  • 应用层
    • 非本地跳转: c运行时库实现

1. 硬件层: 异常

异常: a transfer of control to the OS kernel in response to some event.

  • 事件(和处理器当前执行的指令可能有关也可能无关)发生
  • 处理器依据异常表
  • 间接过程调用
  • 执行异常处理程序

1.1. 异常和过程调用的区别

  • 返回地址
  • 处理器将额外的状态压入栈中,例如EFLAGS寄存器
  • 如果控制转移给内核,则状态被压入内核栈
  • 异常处理程序在内核态运行

异常可以分为四类,分别如下

类别 来源 同步/异步
中断 IO设备的信号 异步
陷阱 系统调用、brk等 同步
故障 潜在可恢复的异常,例如: 缺页 同步
终止 不可恢复的错误,例如: 除零 同步

对于x86-64而言,有256种异常类型。0~31对应处理器定义的异常,32~255对应操作系统定义的中断和陷阱。一些异常如下

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

注意1: Linux shell将一般保护故障报告为段故障(segmentation fault)。程序引用了未定义的虚拟内存或者程序试图写入一个只读的文本段会导致段故障。

注意2: x86-64系统上,系统调用通过syscall指令提供,%rax包含了系统调用号。此外%rdi, %rsi, %rdx, %r10, %r8, %r9包含最多6个系统调用参数。系统调用返回时,%rcx, %r11都会被破坏而%rax包含返回值,返回值-1到-4095间的整数都表示发生了错误。

2. 操作系统层: 上下文切换与进程

基于异常,操作系统通过上下文切换实现了进程抽象。

  • 时间上: 抽象CPU控制权,独立的逻辑控制流
    • 并发
  • 空间上: 抽象内存地址空间,私有的地址空间

注意: 代码段总是从地址0x400000处开始,地址空间顶部保留给内核代码。

3. 操作系统与用户态接口层: 进程控制和信号

3.1. 进程控制

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

// pid_t被定义为int
pid_t getpid(void);
pid_t getppid(void);

void exit(int status);
  • 运行
    • 进程在CPU上运行或者等待被调度。
  • 停止
    • 进程的执行被挂起,且不会被调度。当收到SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU信号时,进程停止。当收到SIGCONT信号,进程再次开始执行。
  • 终止
    • 进程收到一个信号,其默认行为是终止进程
    • 从主程序返回
    • 调用exit函数

创建子进程

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

pid_t fork(void);
  • 调用一次,返回两次
  • 并发执行
  • 相同但是独立的地址空间
  • 共享文件

回收子进程

  • 僵死进程: 一个终止了但还未被回收的进程
    • 如果父进程终止,进程会让init进程成为孤儿进程的父进程
    • 注: 对于长期运行的程序例如shell和服务器,总是应该回收子进程的退出状态

waitpid函数被父进程用于等待子进程终止

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

pid_t waitpid(pid_t pid, int *statusp, int options);

// 在wait.h头文件中定义了下面这些常量
#define WNOHANG 1 /* Don't block waiting. */
#define WUNTRACED 2 /* Report status of stopped children. */

注意: 当父进程存活,且父进程对子进程调用waitpid前,内核中子进程数据结构会一直存在。即子进程集合不会改变。

  • 返回值
    • 成功: 则返回子进程pid
    • 选项为WNOHANG: 返回0
    • 其它: 返回-1
      • 调用进程没有子进程,errno被设置为ECHILD
      • waitpid被信号中断,errno被设置为EINTR
  • pid参数: 判定等待集合的成员
    • pid > 0等待集合是一个单独的进程
    • pid = -1等待集合是父进程的所有子进程
  • options参数
    • 为0: 即默认行为,挂起调用进程的执行直到等待集合中的一个子进程终止。若等待集合中的一个已经终止,则立即返回。
    • WNOHANG: 若等待集合中的任何子进程都还没有终止,则立即返回。可以用于需要在等待子进程终止的同时处理其它任务的情形。
    • WUNTRACED: 挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止,返回终止进程的pid。用于检查已终止或者被停止的子进程
    • WCONTINUED: 挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到SIGCONT信号重新开始执行
  • 检查已回收子进程的退出状态: wait.h头文件定义了解释status参数的宏
    • WIFEXITED: 谓词,子进程通过调用exit或正常返回而终止
    • WEXITSTATUS: 返回退出状态
    • WIFSIGNALED: 谓词,子进程被信号终止
    • WTERMSIG: 返回导致子进程终止的信号的编号
    • WIFSTOPPED: 谓词,子进程当前停止的
    • WSTOPSIG: 返回引起子进程停止的信号的编号
    • WIFCONTINUED: 谓词,子进程收到SIGCONTINUED继续执行

wait函数是waitpid函数的简化版本

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

pid_t wait(int *statusp);
  • 返回值
    • 成功: 返回子进程的pid
    • 出错: 返回-1
  • 调用wait(&status)等价于调用waitpid(-1, &status, 0)

进程休眠

1
2
#include <unistd.h>
unsigned int sleep(unsigned int secs);
  • 如果请求的时间到了,则返回0
  • 否则,返回剩余休眠的时间秒数
    • 该进程被信号中断而过早返回
1
2
#include <unistd.h>
int pause(void);
  • pause让调用函数休眠直到收到一个信号
  • 始终返回-1

加载并运行程序

1
2
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
  • argv: 以null结尾的参数列表
  • envp: 以null结尾的指针数组,每个指针指向一个环境变量字符串,格式为name=value
    • libc定义的全局变量environ维护了所有的环境变量,注意setenv会影响environ的值,并能通过getenv获取,但是不会影响envp的值,envp始终为进程刚执行时的环境变量的值。

在execve加载文件之后,它会调用c运行时库的启动代码,设置栈,并将控制传递给新程序的主函数,主函数的原型是

1
int main(int argc, char **argv, char **envp);

main开始执行时,用户栈的结构如下

注意: argc, argv, envp保存在寄存器中作为参数传递。

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

char *getenv(const char *name);
int setenv(const char *name, const char *newvalue, int overwrite);
void unsetenv(const char *name);

3.2. 进程级别的异常控制流: 信号

之前讨论过的所有同步(陷阱、故障、终止)和异步(中断)异常均由内核中的异常处理程序负责处理,正常情况下进程不可见。

这一节介绍由用户进程负责处理的一种异步异常: 信号。内核通过信号(signal)通知用户进程底层异常的发生

  • 进程除以0: 内核发送SIGFPE信号
  • 进程执行非法指令: 内核发送SIGILL信号
  • 进程非法内存引用: 内核发送SIGSEGV信号
  • Ctrl+C键: 内核发送SIGINT信号给前台进程组中的每个进程
  • 一个进程可以向其它进程发送SIGKILL信号
  • 子进程终止: 内核发送SIGCHLD信号

传递信号到目的进程需要两步

  • 发送信号: 内核通过更新目的进程上下文的某个状态发送信号给目的进程。有两种原因会导致信号的发送
    1. 内核检测到系统事件的发生
    2. 一个进程调用kill显式要求内核发送信号
  • 接收信号: 内核强迫目的进程对信号做出响应时,该进程就接受了信号。目的进程能够有如下的响应方式
    • 忽略(或称阻塞)信号
    • 终止
    • 执行信号处理程序

通过pending位向量,内核为每个进程维护着待处理信号的集合。通过blocked位向量,内核为每个进程维护着阻塞信号集合。同一时刻,同一类型最多只有一个待处理/阻塞信号。之后到来的同类型的信号会被丢弃。

3.3. 发送信号

Unix系统基于进程组的概念提供信号发送机制

  • 每个进程只属于一个进程组,进程组由一个正整数进程组ID标识
  • 默认情况下,子进程和父进程同属于一个进程组
  • Unix shell使用作业(job) 表示 对一条命令行求值而创建的进程组
    • 在任意时刻,至多只有一个前台作业和0或多个后台作业
1
2
3
4
5
6
7
8
9
10
11
12
#include <unistd.h>

pid_t getpgrp(void);
// 如果pid为0,则使用当前进程的pid
// 如果pgid为0,则使用pid指定的进程的pid作为pgid
int setpgid(pid_t pid, pid_t pgid); 
// 如果pid大于0,则发送信号给pid进程
// 如果pid等于0,则发送信号给调用进程的进程组中的每个进程(含自己)
// 如果pid小于0,则发送信号给进程组|pid|中的每个进程
int kill(pid_t pid, int sig);
// 返回前一次设置闹钟剩余的秒数
unsigned int alarm(unsigned int secs);
  • /bin/kill程序能够像另外的进程发送信号
    • /bin/kill -[信号号] -[pid号]
  • 从键盘发送信号
    • 在键盘上输入Ctrl + C会导致内核发送SIGINT到前台进程组中的每个进程

3.4. 接收信号

进程从内核态转移回用户态时,内核会检查pending & ~blocked的信号,并选择一个待处理的信号强制进程接收该信号。每种信号都有预定义的默认行为

  • 进程终止: SIGKILL
  • 进程终止并转储内存
  • 进程停止(挂起)直到被SIGCONT重启
  • 进程忽略该信号: SIGCHILD

signal程序被用于修改信号的默认行为。

注意: SIGSTOP和SIGKILL信号的默认行为不能被修改。

1
2
3
4
#include <signal.h>

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

注意: 如下图所示,信号处理程序可以被其它信号中断

3.5. 阻塞信号

  • 隐式阻塞: 内核默认阻塞新到来的当前正在处理的信号
  • 显示阻塞: 应用程序可以使用sigprocmask函数显示阻塞和解除阻塞信号
1
2
3
4
5
6
7
8
9
10
#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);
// Returns: 0 if OK, −1 on error
int sigismember(const sigset_t *set, int signum);
// Returns: 1 if member, 0 if not, −1 on error

3.6. 编写信号处理程序

  • 主程序和信号处理程序并发运行
  • 如何以及何时接收信号的规则不定
  • 不同系统有不同的语义

编写原则:

  • 处理程序尽可能简单
  • 处理程序中只调用异步信号安全的函数
    • 异步信号安全
      • 可重入的或者
      • 不能被信号处理程序中断
      • 注: printf, sprintf, malloc, exit非异步信号安全。唯一安全的输出函数是write
  • 保存和恢复errno: 在进入处理函数时将errno保存在局部变量中,返回时恢复
  • 阻塞所有的信号,保护对共享数据结构的访问
  • volatile声明全局变量
    • volatile限制编译器每次引用变量时,都要从内存中读取
  • sig_atomic_t声明全局标志变量

3.6.1. 信号处理

由于未处理信号不会排队,因此如果存在一个未处理的信号则表示至少有一个信号到达

因此如果根据信号回收资源,需要考虑隐含的资源已经能够释放,但是信号被屏蔽的情况。例如对于子进程资源的回收,需要使用while(waitpid(-1, NULL, 0) > 0)回收。

3.6.2. 可移植的信号处理

  • 不同unix系统的signal的语义不一定相同
    • 有的Unix系统在信号k被处理程序捕获后,对k信号的响应会恢复默认情况,需要重新对信号k设置
  • 系统调用可能被中断
    • 有的Unix系统在信号处理程序返回时,例如read, write, accept这样的阻塞系统调用不会继续阻塞,而是返回一个错误条件,并将errno设置为EINTR。此时需要手动重新执行系统调用

为解决上面的问题,要使用Posix标准定义的sigaction函数

1
2
3
#include <signal.h>

int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);

通常使用一个底层调用sigactionSignal封装函数。

3.6.3. 解决信号处理并发问题

  • 使用sigprocmask同步进程
    • 首先通过sigprocmask阻塞信号。
    • 接着调用fork(),以及相关逻辑。同时子进程可能执行完毕,pending收到SIGCHILD信号。
    • 接着再通过sigprocmask解除阻塞。

3.6.4. 显示地等待信号

1
2
3
#include <signal.h>
int sigsuspend(const sigset_t *mask);
// Returns: −1
  • 使用sigsuspend等待信号: sigsuspend函数等价于原子地调用下面三个函数
    • sigprocmask(SIG_SETMASK, &mask, &prev)
    • pause()
    • sigprocmask(SIG_SETMASK, &prev, NULL)

通过使用sigsuspend可以避免在调用sigprocmask()pause()之前信号到来以至于进程始终阻塞的情况。

4. 用户层: 非本地跳转(nonlocal jump)

非本地跳转是一种用户级的异常控制流形式。它从一个函数转移到另一个正在执行的函数。

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

int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);
// Returns: 0 from setjmp, nonzero from longjmps

setjmp保存当前调用环境,后面供longjmp使用。

注意: setjmp返回值不能保存在变量中,但是可以用于switch以及条件语句中。

1
2
3
4
#include <setjmp.h>

void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);

longjmpenv缓冲区中恢复环境,然后转移到最近一次初始化envsetjmp的返回。

总的来说,非本地跳转有两种应用

  • 从一个深层嵌套的函数调用中立即返回
    • try语句中的catch子句类似于setjmpthrow语句类似于longjmp
  • 使一个信号处理程序分支到一个特殊的代码的位置,而不是返回之前中断的位置