第八章—异常控制流
写在前面的话
本章的一个特点是引入的概念很多而且相似( 进程组和作业 , 异常和系统调用和信号 ), 所以 我们要是时刻记得引入的新概念跟之前的老概念有什么不一样, 又有哪些相同之处, 这样才更容易接收新的概念. 举个例子: 信号的本质是软件层次的异常, 那既然是异常, 它大致的形式就是发生了一个事件, 然后控制突变至一个处理程序, 然后返回或者终止. 只不过事件对于异常来说是状态的变化, 对于信号来说是一个信号的传递, 处理程序在异常中叫做异常处理程序, 在信号中叫做信号处理程序.
上面的例子有可能会有错误, 但是我个人是这样理解的, 如果理解错了后面再改.
基本介绍
从处理器加电开始, 直到断点为止, 程序计数器假设是一个值的序列
从a(k)到a(k + 1)的过渡称为控制转移(control transfer)(这里也纠正了我的一个错误的认知, 就是我认为一定要跳转到另一个不相连的地址才叫做控制转移, 其实两个mov指令也是控制转移), 这样的控制转移序列叫做处理器的控制流(flow of control).
当系统状态(系统内部的硬件, 内核, 信号等等)发生变化时, 现代系统通过使控制流发生突变来对这些情况做出反应, 这些突变就是异常控制流(Exceptional Control Flow, ECF).
下面是一些例子:
- 硬件层, 硬件检测到事件后触发控制转移到异常处理程序
- 操作系统, 内核通过上下文切换将控制从用户进程转移到另一个用户进程
- 应用层, 进程发送一个信号到另一个进程, 接收者会将控制转移到它的一个信号处理程序.
异常
异常(exception)是异常控制流的一种形式, 用来响应处理器状态(状态被编码为不同的为和信号, 状态的变化称为事件)中的某些变化(也就是事件).
异常表
在系统启动时, 操作系统分配和初始化异常表.
异常表的其实地址放在一个叫做异常表基址寄存器的特殊CPU寄存器中
当处理器检测到有事件发生时, 它会通过一张叫做异常表(exception table)的跳转表, 进行一个间接过程调用(异常), 到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序).
异常处理
处理器检测到了发生了一个事件(状态发生了变化), 随后处理器触发了异常, 随后执行间接调用.
异常跟函数调用非常相似, 但是这里指出它们之间的不同之处:
- 函数调用会把返回地址压入栈中(一般都是call的下一条指令), 而异常处理则有三种情况: 返回到当前指令, 返回到当前指令的下一条指令, 不返回直接退出
- 异常会压入额外的处理器状态到栈中.
- 异常处理程序运行在内核模式下
异常类别
异常可以分为四类:
- 中断 interrupt
- 陷阱 trap
- 故障 fault
- 终止 abort
中断
中断时异步发生的, 是来自处理器外部的I/O设备的结果. 其处理程序为中断处理程序.
注意的是, 这个异常类型是异步发生的.
剩下的陷阱, 故障和终止都是同步发生的, 被称为故障指令(faulting instruction).
陷阱和系统调用
1 | 陷阱是有意的异常, 是执行一条指令的结果. 陷阱最重要的用途是在用户程序和内核之间提供一个像是 |
用户程序可以通过系统调用向内核发送服务请求来读取文件, 创建进程. 这个过程像是函数调用一样,但是不同在于, 系统调用会进入内核模式, 使用内核的代码数据和栈.
故障
故障是由错误情况引起的. 当故障发生时, 处理器将控制转移给故障处理程序.
执行完故障处理程序后, 有两种结果:
- 错误情况被修正, 控制返回给引起故障的指令, 并重新执行
- 错误情况没被修正, 处理程序返回abort例程, abort例程会终止引起故障的应用程序.
终止
终止是不可恢复的致命错误造成的结果, 通常是一些硬件错误.
Linux/x86-64系统中的异常
这一个小节主要是举出一些异常实例方便我们理解什么是异常
1. Linux/x86-64故障和终止
- 除法错误, 当发生除零的时候, 发生除法错误(异常 0 ), Unix不会试图恢复, 而是终止程序, 并发送错误报告”浮点错误(Floating exception)”
- 一般保护错误, 一般为越界操作, 比如试图”写”一个只读数据段, 程序会终止, 并发送”段故障(Segmentation fault)”
- 缺页(异常 14), 当发现引用一个数据的地址未被缓存到内存中时(还在磁盘), 会进入缺页处理程序, 然后将相应的虚拟页(磁盘)映射到物理页(主存)中, 然后返回到当前的指令重新执行(第九章会更加细致的讲这个缺页故障, 这一段的括号内容在本章中是可以忽略的)
2. Linux/x86-64系统调用
注意, 每个系统调用都有一个唯一的整数号, 对应一个到内核中跳转表的偏移量. (这个跳转表跟异常表不一样), 也就是说系统调用有它自己的系统调用表, 这个是独立与异常表的.
进程
1 | 异常是允许操作系统内核提供进程概念的基本构造块 |
进程的经典定义是: 一个执行中程序的实例. 每个程序都运行在某个进程的上下文(context)中.
- 上下文: 是由程序正确运行所需的状态组成的. 这其中包括: 代码和数据, 栈, 通用寄存器中的内容, 程序计数器, 环境变量, 以及打开文件描述符的集合
进程给应用程序提供的关键抽象:
- 一个独立的逻辑控制流, 它提供一个假象, 好像我们的程序独占地使用处理器
- 一个私有的地址空间, 提供一个假象, 好像我们的程序独占地使用内存系统
逻辑控制流(独占使用处理器的假象)
一个单独的程序的PC值的序列叫做逻辑控制流, 简称逻辑流.
看到下面的图对于理解逻辑流是非常非常非常有帮助的(非常的清晰)
每个进程都执行它的流的一部分, 然后被抢占(调度), 将控制给其他的进程.
并发流
计算机系统中逻辑流有许多不同的形式. 异常处理程序, 进程, 信号处理程序, 线程和Java进程都是逻辑流的例子.
一个逻辑流的执行在事件与另一个流重叠, 称为并发流. (理解为夹汉堡, 有被夹在中间看起来像是一个汉堡的就是并发流)
私有地址空间
进程为每个程序提供它自己的私有地址空间.
上下文切换
操作系统内核使用一种称为上下文切换(context switch)的较高层形式的异常控制流来实现多任务.
内核为每个进程维持一个上下文, 上下文就是内核重启一个被抢占的进程所需的状态. 它由一些对象的值组成, 这些对象: 通用目的寄存器, 浮点寄存器, 程序计数器, 用户栈, 状态寄存器, 内核栈和各种内核数据结构.(注意这里的主语是内核内核内核, 要记住!!! 不然后面会很混)
- 内核可以决定抢占当前进程, 这种决策叫做调度
当内核代表用户执行系统调用时, 可能会发生上下文切换.
系统调用错误处理
当Unix系统调用(原书是说系统级函数, 二者等价)遇到错误时, 它们通常会返回-1, 并设置全局整数变量errno来表示什么出错了
errno与strerror函数配合使用
1 | if ((pid = fork) < 0) |
进程控制
Unix提供了大量从C程序中操作进程的系统调用.(进程控制本质还是通过系统调用来实现的)
获取进程ID
可以获取当前进程的pid直接使用即可
创建和终止进程
前置知识
首先先要了解进程的三种状态:
- 运行: 正在CPU上执行或等待被执行(即最终会被内核调度)
- 停止: 进程的执行是被挂起的, 且最终不会被调度(这是与运行的暂时停止不同的概念, 一个是暂时停止, 一个是一直停止直到解除停止状态)
- 终止: 进程永远的终止了, 原因有三种: 1. 收到一个信号, 该信号的默认行为是终止进程. 2.从主程序返回. 3. 调用exit函数
这里再补充exit函数的常识: 参数为0则表示正常退出, 参数为1表示非正常退出, 返回为void
进程
父进程通过调用fork函数创建一个新的运行的子进程
1 |
|
fork是理解父子进程相对有难度的地方, 必须结合书中给出的实例来看才能够理解, 关于概念的阐述书上的要比我解释的简洁准确的多了, 这里就不再赘述.
画图会跟容易懂一些
其实书中也有类似上图这样的方法
回收子进程
既然我们已经讲了怎么创建子进程, 那么也就要再讲一讲怎么回收子进程.
一个进程因为某种原因终止(终止的三个原因在上面有提到过, 再回去看看), 内核不会立即将其从内存清除, 这时候进程就是僵死进程了, 它们没什么用还占用我们的内存, 所以需要回收 所以我们回收的目标就是这些僵死进程.
这里又有关键的概念了
1 | 内核将子进程的退出状态传递给父进程, 然后抛弃以终止的进程. |
回收的工作交给waitpid()执行
1 |
|
默认情况下(options为零的时候), waitpid挂起调用进程的执行, 直到它的等待集合中的一个子进程终止. 如果已经有一个子进程终止了, 那么waitpid直接返回, 否则waitpid会等待. 这两种情况在最终回收了子进程后waitpid会返回回收的子进程的pid.
参数一pid 判定等待集合的成员
- 如果pid > 0, 等待的就是这个pid
- 如果pid = -1, 等待的就是当前进程的所有子进程
参数三options 修改waitpid的行为
- WNOHANG: 不等待子进程终止, 如果已经有子进程终止则返回pid, 没有子进程终止则直接返回0.
- WUNTRACED: 等待子进程终止或挂起
- WCONTINUED: 等待子进程终止或一个停止进程收到SIGCONT信号重新启动
- 可以组合如下一条
- WNOHANG | WUNTRACED: 如果已经有子进程终止或停止则返回pid, 没有子进程终止或停止则直接返回0.
参数二status 检查回收子进程的退出状态
详情看书
错误情况
如果没有子进程, waitpid则返回-1, 并设置errno为ECHILD
使用waitpid()的实例
回收子进程的实例
这个实例回收时是无序回收, 有可能进程2比进程1创建的晚, 却回收的比进程1早, 下面的一个实例是有序地回收子进程
让进程休眠
sleep函数将一个进程挂起一段指定的实践
1 |
|
加载并运行程序
execve函数在当前进程的上下文中加载并运行一个新程序
1 |
|
execve函数加载并运行可执行目标文件filename, 且带参数列表argv和环境变量列表envp. 只有当出现错误时, 例如找不到filename, execve才会返回到调用程序, 所以, 与fork调用一次返回两次不同, execve从不返回
两个参数:
- arg变量执行一个以null结尾的指针数组, 每个指针都指向一个参数字符串
- env记录了启动代码
利用fork和execve运行程序
shell读取来自用户的一个命令行, 求值步骤解析命令行, 并代表用户运行程序.
信号
前面讲的都是硬件与软件相结合提供基本的低层异常机制, 而在本节中, 我们将研究一种更高层的软件形式的异常, 称为Linux信号.
1 | 这里再重复一遍: 一种更高层的软件形式的异常, 称为Linux信号 |
每个信号类型对应于某种系统事件(前面有提到过, 对应状态的变化), 信号提供了一种机制, 通知用户进程发生了这些异常.
下面一些实例:
- 一个进程试图除零, 内核会给该进程发送一个SIGFPE信号(发信号的是内核, 接收的是该进程)
- 一个子进程终止, 内核会发送一个SIGCHLD信号给父进程(发信号的是内核, 接收的是父进程而不是子进程)
信号术语
- 发送信号: 内阁通过更新目的进程上下文中的某个状态(发生了一个事件), 发送一个信号给目的进程. 原因如下: 1. 检测到一个系统事件(上面的两个实例都是事件). 2. 一个进程调用了kill函数, 显示要求内核发送一个信号给目的进程(注意主语是内核)
- 接收信号: 目的进程被内核强迫以魔种方式对信号的发送作出反应, 它就接收了信号. 进程可以: 忽略这个信号, 终止, 或执行一个称为信号处理程序的用户层函数捕获这个信号
一个发出而没有被接收的信号叫做待处理信号, 一个类型的信号只能拥有一个待处理信号, 多出来的信号不会排队, 而是直接被抛弃
在别人的博客里看到了一个图片非常生动的表示了抛弃多余信号的机制, 但是找不到了, 所以就自己化了一个
发送信号
unix提供大量向进程发送信号的机制, 所有这些机制都是基于进程组(process group)这个概念的.
进程组
每个进程都属于一个进程组, 我们使用getpgrp()来查看当前进程所属的进程组ID
1 |
|
默认地, 一个子进程和它的父进程术语一个进程组.
进程可以通过使用setpgid函数来改变自己或其他进程的进程组
1 |
|
setpgid函数将pid的进程组该为pgid, 特殊情况:
- 如果pid是0, pgid不为0, 则是将当前进程归为pgid的进程组
- 如果pid不为0, pgid为0, 则是将pid作为其所在进程组的pgid(可以理解为让pid当它组的老大)
- 如果二者都为0, 则是创建一个新的进程组, 并用pid作为进程组的pgid(理解为自立门派???)
利用/bin/kill程序发送信号
1 | linux> /bin/kill -9 15213 |
从键盘发送信号
比如:
- Ctrl + c
- Ctrl + z
1 | 注意书中在这里引入了作业的概念(非常非常非常重要), 由于标题类型只有三级所以就用代码框表示四级标题 |
使用kill函数发送信号
1 |
|
kill有多种情况:
- 如果pid > 0, 则kill函数发送信号sig给进程pid
- 如果pid = 0, 则kill函数发送信号sig给调用进程所在进程组中的每个进程, 包括它自己
- 如果pid < 0, 则kill函数发送信号sig给进程组|pid|(pid的绝对值)中的每个进程.
用alarm函数发送信号
进程可以同过alarm函数向它自己发送SIGALRM信号
1 |
|
接收信号
当内核把进程p从内核模式切换到用户模式时, 它会检查进程p的未被阻塞的待处理信号的集合. 如空, 执行逻辑控制流中的下条指令, 如未空, 内核选择集合中的某个信号k, 并采取某个行为, 同时被选择的信号将从待处理信号的集合中删除.
每个信号都有一个预定义的默认行为:
- 进程终止
- 进程终止并转储内存
- 进程停止(挂起)直到被SIGCONT信号重启
- 进程忽略该信号
使用signal函数与信号关联相应的信号处理程序.
1 |
|
阻塞和解除阻塞信号
Linux阻塞信号分为隐式和显式:
- 隐式阻塞机制: 内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号.
- 显示阻塞机制: 使用sigprocmask函数, 明确的阻塞和解除阻塞信号
1 |
|
重要的sigprocmask函数作用时改变当前阻塞的信号集合, 具体行为依赖于how的值:
- SIG_BLOCK: 把set中的信号添加到blocked中
- SIG_UNBLOCK: 从blocked中删除set中的信号
- SIG_SETMASK: block = set
其他的set信号设置:
- sigemptyset: 初始化set未空集合
- sigfillset: 把每个信号都添加到set中
- sigaddset: 把signum添加到set中
- sigdelset: 从set中删除signum
- sigismember: 如果signum在set中, 则返回1; 如果signum不在set中, 则返回0
编写信号处理程序
我们处理信号时需要注意的点:
- 处理程序与主程序并发运行, 共享同样的全局变量, 可能互相干扰
- 信号的处理, 比如: 信号不会排队
- 不同系统有不同的信号处理语义
安全的信号处理
遵循以下原则, 安全的编程:
- 处理程序尽可能简单
- 在处理程序中只调用异步信号安全的函数
- 保存和恢复errno(有点像是栈的返回地址压入和弹出)
- 阻塞所有的信号
- 用volatile声明全局变量
- 用sig_atomic_t声明标志
正确的信号处理
关键思想是: 如果存在一个未处理的信号就表明至少有一个信号到达了 (这里可能会难以理解, 反过来的来讲就是: 拥有一个未处理的信号就表明你不知道是否有多余的信号未抛弃了)
来看一看实例会更容易理解一些
这里的问题是:
- 父进程接收到了第一个SIGCHLD信号, 并隐式阻塞该信号
- 父进程接收到了第一个SIGCHLD信号, 由于阻塞变成了待处理信号
- 父进程接收到了第一个SIGCHLD信号, 由于已经有待处理信号, 所以这个信号被抛弃, 然后僵死进程一直留在内存中占用空间.
这就是我们上面关键思想的意思.
我们改进一下SIGCHLD的信号处理程序:
可移植的信号处理
是不同系统间或同一系统不同版本间的问题.解决方案是使用包装函数.
同步流以避免讨厌的并发错误.
由于进程间的并发执行时系统在控制(意思时我们程序员无法控制), 逻辑流会交错在一起, 可能会导致后面的事情提前执行, 前面的事情延迟执行, 导致我们预料之外的错误.
下面的实例是一个并发流错误:
错误的过程:
- 父进程创建子进程, 但是内核调度子进程先执行
- 在控制回到父进程之前, 子进程已终止并发送SIGCHLD信号, 进入了相应的信号处理程序
- 在信号处理程序中由于不存在作业, 所以deletejob什么都没有做
- 然后控制返回给父进程, 添加了一个作业.
这就是并发流错误, 我们原本的设想是先addjob后deletejob, 但现实情况恰好可能(注意这个可能, 因为调度不是我们能控制的, 而且不是一成不变的, 有可能下一次就先调度父进程, 得到了正确的答案)相反.
我们将这种进程间调度前后的关系叫做竞争
下面是改进的版本
做出的改动是先在父进程生成子进程之前阻塞SIGCHLD信号, 然后在addjob不再阻塞SIGCHLD信号, 保证在进入handler中deletejob之前addjob.
显示地等待信号
有时候主程序需要显示地等待某个信号处理程序运行, 比如shell创建一个前台作业时.
父进程的等待效果靠的是while(!pid)来完成, 可这样非常浪费处理器资源 (想想汇编语言实现while循环)
作为替换我们使用sigsuspend函数
1 |
|
等同于
1 | sigprocmask(SIG_SETMASK, &mask, &prev); |
修改后的实例
非本地跳转
C语言提供了一种用户级异常控制流形式称为非本地跳转(我又来唠叨了, 本质是异常)
使用两个函数来实现
1 |
|
分别说一下这两个函数的作用:
- setjmp: 第一次调用setjmp()会保存调用环境(寄存器, 栈帧, IP), 并返回0, (注意: 返回值不能用于赋值)
- longjmp: 调用了longjmp会返回最近的setjmp, 并恢复调用环境, setjmp返回longjmp的参数retval
以下为实例: