csapp第八章——异常控制流
[TOC]
异常控制流
现代系统通过使控制流发生突变来对系统状态的变化做出反应 。一般而言 ,我们把这些突变称为异常控制流 (Exceptional Control Flow, ECF) 。
异常
异常是异常控制流的一种形式,它一部分由硬件
实现,一部分由操作系统
实现。
异常 (exception) 就是控制流中的突变,用来响应处理器状态中的某些变化。
当处理器检测到有事件发生时,它就会通过一张叫做异常表 (exception table)
的跳转表,进行一个间接过程调用(异常)
,到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序 (exception handler)) 。
异常处理
异常处理程序运行在内核模式
下,这意味它们对所有的系统资源都有完全的访问权限。
异常的类别
同步异步表示的结果的获取方式是主动获取还是被动接收;阻塞非阻塞表示的是获取这个动作是否可以立即返回不用等待。
中断
中断是异步
发生的, 是来自处理器外部的 I/0 设备的信号的结果。例如定时器计时结束后会向处理器发送一个中断。
陷阱和系统调用
陷阱是有意的异常,是执行一条指令的结果 。如读文件(read),创建一个新的进程(fork)。
故障
故障由错误情况引起,它可能能够被故障处理程序修正。
一个经典的故障示例是缺页异常
,当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。
终止
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如 DRAM 或者SRAM 位被损坏时发生的奇偶错误。
Linux/86-64 系统调用
进程
异常是允许操作系统内核提供进程 (process) 概念的基本构造块。
进程的经典定义就是一个执行中程序的实例。
系统中的每个程序都运行在某个进程的
上下文 (context)
中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的
程序的代码和数据
,它的栈
、通用目的寄存器的内容
、程序计数器
、环境变量
以及打开文件描述符
的集合.- 每次用户通过向 shell 输入 个可执行目标文件的名字,运行程序时,
shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。
进程提供给应用程序的关键抽象:
- 一个
独立的逻辑控制流
,它提供一个假象,好像我们的程序独占地使用处理器。 - 一个
私有的地址空间
,它提供一个假象,好像我们的程序独占地使用内存系统。
逻辑控制流
进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占 (preempted) (暂时挂起),然后轮到其他进程。
并发流
一个逻辑流的执行在时间上与另一个流重叠,称为并发流 (concurrent flow)
, 这两个流被称为并发地运行。
多个流并发地执行的一般现象被称为并发 (concurrency)
。
一 个进程和其他进程轮流运行的概念称为多任务(multitasking)
。
一个进程执行它的控制流的一部分的每一时间段叫做时间片 (time slice)
。
因此,多任务也叫做时间分片 (time slicing)
。
私有地址空间
x86-64:
用户模式和内核模式
处理器将某个控制寄存器中的一个模式为(mode bit)来区别进程运行在用户模式还是内核模式(超级用户模式)。
/proc
文件系统它允许用户模式进程访问内核数据结构的内容。
/sys
文件系统,它输出关于系统总线和设备的额外的低层信息。
上下文切换
操作系统内核使用一种称为上下文切换 (context switch)
的较高层形式的异常控制流来实现多任务。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度 (scheduling)
, 是由内核中称为调度器 (scheduler) 的代码处理的。
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程
由于从磁盘中读取文件需要几十毫秒,当进程A阻塞(需要的文件未读取完成),cpu会去执行其他进程,当读取完成时,磁盘发送中断异常到cpu,继续执行指令。
系统调用错误处理
Unix 系统级函数遇到错误时,它们通常会返回-1, 并设詈全局整数变量errno
示什么出错了。
1 | void unix_error(char *msg) /* 错误处理包装函数 */ |
如Fork
1 | pid_t Fork(void) |
进程控制
获取进程ID
每个进程都有一个唯一的正数进程 ID(PID)
。getpid()
通过函数返回调用进程的 PID。getppid()
函数返回它的父进程的 PID( 创建调用进程的进程)。
1 |
|
getppid ()
函数返回一个类型为 pid_t
的整数值,在 Linux 系统上它在types.h
中被定义为int
。
创建和终止进程
- 进程总是处于运行、停止和终止三种状态。
fork函数:
子进程返回0,父进程返回子进程的PID
,如果出错则返回-1。
调用一次,返回两次
- 并发执行
相同但是独立的地址空间
- 共享文件
可以通过进程图来理解带有嵌套的fork函数
回收子进程
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收 (reaped) 。当父进程回收己终止的子进程时,
内核将子进程的退出状态传递给父进程
,然后抛弃己终止的进程,从此时开始,该进程就不存在了。一个终止了但还未被回收的进程称为
僵死进程 (zombie)
。- 如果一个父进程终止了,内核会安排
init
进程成为它的孤儿进程的养父。 init
进程PID=1, 是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。
waitpid
1 |
|
当等待集合中的进程终止后返回终止进程pid。
判定等待集合的成员
如果pid>0, 那么等待集合就是一个单独的子进程,它的进程 ID 等于 pid。
如果pid= -1, 那么等待集合就是由父进程所有的子进程组成的。修改默认行为
- 检查已回收子进程的退出状态
- 错误条件
如果调用进程没有子进程
,那么 waitpid 返回-1
, 并且设置errno
为ECHILD
。如果waitpid 函数被一个信号中断
,那么它返回-1, 并设置 errno
为EINTR
。
- wait函数
1 |
|
waitpid的简化版。等价于waitpid(-1,&status,0)
。
- 使用waitpid的示例
1 | /* $begin waitpid2 */ |
让进程休眠
sleep
函数将一个进程挂起一个指定时间。
1 |
|
时间到了,返回0;时间未到,返回剩余时间。
pause函数
使调用函数休眠,直到该进程收到一个信号。
加载并运行程序
1 |
|
execve
调用一次从不返回
,execve 函数在当前进程的上下文中加载并运行一个新的程序 它会覆盖当前进程的地址空间,但并没有创建一个新进程 。
argv 变量
指向一个以 null 结尾的指针数组
,其中每个指针都指向一个参数字符串。按照惯例, argv [0] 是可执行目标文件的名字。
envp 变量
指向一个以null 结尾的指针数组
,其中每个指针指向一个环境变量字符串,每个串都是形如”name=value” 的名字-值对。
main 函数有3个参数:
argc
, 它给出argv[ ]
数组中非空指针的数量argv
,指向argv[ ]
数组中的第一个条目。envp
,指向 envp[]
数组中的第一个条目。
linux可以通过getenv
和setenv
来修改环境变量。
程序与进程
进程是执行中程序的一个具体的实例;程序总是运行在某个进程的上下文中。
利用fork和execve运行程序
1 | /* $begin shellmain */ |
信号
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。
软件形式的异常
,称为 Linux 信号,它允许进程和内核中断其他进程。
1 | man signal //信号手册 |
信号术语
- 发送信号
- 接受信号
- 待处理信号,在任何时刻,
一种类型至多只会有一个待处理信号
,多余的会被丢弃。 一个待处理信号最多只能被接收一次
。内核为每个进程在pending 位向量
中维护着待处理信号的集合,而在blocked 位向量(也称为信号掩码signal mask)中维护被阻塞的信号集合
。
发送信号
进程组
1
2
3
4
5
6
7
8
pid_t getpgrp(void); //返回:调用进程的进程组 ID
int setpgid(pid_t pid, pid_t pgid); //返回:若成功则为 0, 若错误则为-1
setpgid(0, 0); //会创建一个新的进程组,若进程名为15213,那么其进程组 ID 15213, 并且把进程 15213 加入到这个新的进程组中。如果 pid是0, 那么就使用当前进程PID 如果 pgid是0, 那么就用pid指定的进程的 PID 作为进程组 ID 。
1 | ps -ef //常用 |
- 使用
/bin/kill 程序
发送信号
1 | /bin/kill -9 15213 |
- 从键盘发送信号
Unix shell 使用作业 (job) 这个抽象概念来表示为对一条命令行求值而创建的进程,任何时刻,至多只有一个前台作业和 0个或多个后台作业
。
Ctrl+C | 内核发送一个 SIGINT 信号到前台进程组中的每个进程。默认情况下,结果是终止前台作业。 |
Ctrl+Z | 发送一个 SIGTSTP信号到前台进程组中的每个进程。默认情况下,结果是停止(挂起)前台作业。 |
- 用kill函数发送信号
1 |
|
- pid>0,发送给进程pid。
- pid=0,发送给
调用进程所在进程组
的每个进程。 - pid<0,发送给
进程组|pid|中的每个进程
。
父进程用kill函数发送 SIGKILL 信号给它的子进程。
1 |
|
- 用alarm函数发送信号
进程可以通过调用 alarm 函数向它自己发送 SIGALRM 信号
。
1 |
|
接收信号
当内核把进程 从内核模式
切换到用户模式
时,它会检查进程的未被阻塞的待处理信号的 (pending & ~blocked)
- 如果这个集合为空(通常情况下),那么内核将控制传递到 的逻辑控制流中的下一条指令
- 如果集合是非空的,那么内核选择集合中的某个信号,并且且强制进程接收信号,收到这个信号会触发进程采取某种行为, 一旦进程完成了这个行为,那么控制就传递回进程的逻辑控制流中的下一条指令。
每个信号类型都有一个预定义的默认行为。
- 进程终止
- 进程终止并转储内存。
- 进程停止(挂起)直到被 SIGCONT 信号重启。
- 进程忽略该信号。
可以通过使用 signal 函数
修改和信号相 联的默认行为,唯一的例外是 SIGSTOP
和SIGKILL
,它们的默认行为是不能修改的。
1 |
|
signal 函数可以通过设置handeler来改变和信号 signum 相关联的行为:
- 如果
handler SIG_IGN
, 那么忽略类型为 signum 的信号。 - 如果
handler SIG_DFL
, 那么类型为 signum 的信号行为恢复为默认行为 - 否则, handler 就是用户定义的函数的地址,这个函数被称为
信号处理程序
。
1 |
|
练习题8.7
编写一个叫做 snooze 的程序,它有一个命令行参数,用这个参数调用
练习题 8. 中的 snooze 函数,然后终止。编写程序,使得用户可以通过在键盘上输入Ctrl+C 中断 snooze 函数。
1 | void handler(int sig) |
阻塞和解除阻塞信号
隐式阻塞机制
。内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。
显式阻塞机制
。应用程序可以使用 sigprocmask 函数
和它的辅助函数,明确地阻塞和解除阻塞选定的信号。
sigprocmask 函数改变当前阻塞的信号集合 (blocked 位向量)。
1 |
|
具体行为以来how的值
SIG_BLOCK
: set 中的信号添加到 blocked (blocked=blocked I set)SIG_ UNBLOCK
: blocked 中删除 set 中的信号 (blocked=blocked &~set)SIG_SETMASK
: block=set
编写信号处理程序
- 安全的信号处理
处理程序要尽可能简单。
在处理程序中只调用
异步信号安全的函数
。(可重入的且不能被信号处理程序中断)。
异步信号安全的函数
信号处理程序中产生输出唯一安全的方法是使用write
函数。
保存和恢复errno
。
在进入处理程序时把 errno 保存在某个局部变最中,在处理程序返回前恢复它。
阻塞所有的信号,保护对共享全局数据结构的访问
。用volatile 声明全局变量
volatile限定符
强迫编译器每次在代码中引用时,都要从内存中读取值。
- sig_atomic_t 声明标志。
对sig_atomic_t声明的变量的读和写会是原子的(不可中断的)。
- 正确的信号处理
1 |
|
由于信号处理函数没有解决信号不会排队等待这样的情况。当接受并捕获第一个信号进入处理程序,第二个信号就传送并添加到了待处理信号集合里。然而,因为 SIGCHLD 信号被 SIGCHLD理程序阻塞了,所以第二个信号就不会被接收。第三个信号到达。有了一个待处理的 SIGCHLD, 第三个信号会被丢弃。
通过修改handler函数来修正这一问题
1 |
|
- 可移植的信号处理
Posix 标准定义了 sigaction
函数,它允许用户在设置信号处理时,明确指定他们想要的信号处理语义。
同步流以避免讨厌的并发错误
- 通过设置和解除阻塞的
SIGCHLD
信号来避免进程在添加进job组
前终止导致把不存在的子进程添加到作业列表中。
1 |
|
显示地等待信号
- 当
linux shell
创建一个前台作业时,在接收下一条用户命令之前,它必须等待作业终止,被SIGCHLD
处理程序回收。
1 |
|
1 |
|
非本地跳转
- 通过
longjmp
和setjmp
实现。
1 |
|
- 类似于 C 语言中的 try-catch 块,在函数调用链中实现了异常的传递。
1 |
|
操作进程的工具
- STRACE: 打印一个正在运行的程序和它的子进程调用的每个系统调用的轨迹。对于好奇的学生而言,这是一个令人着迷的工具。用 -static 编译你的程序,能得到一个更干净的、不带有大量与共享库相关的输出的轨迹。
- PS: 列出当前系统中的进程(包括僵死进程)。
- TOP: 打印出关千当前进程资源使用的信息,HTOP更加详细。
- PMAP: 显示进程的内存映射
- /proc: 一个虚拟文件系统,以 ASCII 文本格式输出大量内核数据结构的内容,用户程序可以读取这些内容 比如,输入 “cat /proc/loadavg”, 可以看到你的 Linux 系统上当前的平均负载。
小结
异常控制流 (ECF) 发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制
。
在硬件层 ,异常是由处理器中的事件触发的控制流中的 突变。控制流 传递给一个软件处理程序,该处理程序进行一些处理,然后返回控制给被中断的控制流。
有四种不同类型的异常:中断、故障、终 止和陷阱。在操作系统层,内核用 ECF 提供进程的基本概念 进程提供给应用两个重要的抽象: 1) 逻辑控制流,它提供给每个程序一个假象,好像它是在独占地使用处理器, 2) 私有地址空间,它提供给每个程序一个假象,好像它是在独占地使用主存
在操作系统和应用程序之间的接口处,应用程序可以创建子进程,等待它们的子进程停止或者终止,运行新的程序,以及捕获来自其他进程的信号。信号处理的语义是微妙的,并且随系统不同而不同。然而, 在与 Posix 兼容的系统上存在 一些机制,允许程序 清楚 地指定期望的信号处理语义。
最后 ,在应用层, 程序可以使用非本地跳转来规避正常的调用 返回栈规则,并且直接从一个函数分支到另一个函数。