深入理解计算机系统 第8章 笔记整理
这次回顾深入理解计算机系统第8章 ,这一章介绍了异常控制流。
电子书地址:
备注:图片和总结内容均来自于电子书。
参考资料:
https://baike.baidu.com/item/%E5%8F%AF%E9%87%8D%E5%85%A5%E5%87%BD%E6%95%B0/4521100?fr=aladdin
第8章:异常控制流
重要概念
- 控制流:
- 平滑流;
- 异常控制流(Exceptional Control Flow, ECF);
- 异常:
- 异常号和异常表;
- 异常表基址寄存器;
- 异常和过程调用的区别;
- 类别:
- 异步:
- 中断;
- 同步(也叫作故障指令):
- 陷阱;
- 系统调用;
- 跳转表;
- 故障;
- 终止;
- 陷阱;
- 异步:
- C++, Java中的异常是“软件”异常,需要和硬件异常区分;
- Segmentation fault
- 程序与进程的区别;
- 并发,并行;
- 用户模式和内核模式;
- 进程状态:
- 运行;
- 终止;
- 停止;
- execve, fork;
- 信号和信号处理程序;
- 转储内存;
简介
控制流
从给处理器加电开始直到断点,假设程序计数器的序列为:
其中$a_k$是指令$I_k$的地址。$a_k$到$a_{k+1}$的过渡称为控制转移,控制转移的序列称为控制流。
控制流的类型
- 平滑流
- $I_k, k_{k+1}$在内存中相邻。
- 异常控制流(Exceptional Control Flow, ECF)
- 突变的控制流(用来应对系统状态变化,例如硬件定时器定期产生信号,子进程终止后通知父进程等等)。
8.1 异常
概念
异常:
- 控制流中的突变;
- 异常控制流的一种形式;
- 一部分由硬件实现,一部分由操作系统实现;
异常基本思想:
- 当处理器状态中发生一个重要的变化时,处理器正在执行某个指令$I_{\mathrm{curr}}$;
- 状态变化称为事件;
- 事件可能和当前指令的执行密切相关;
- 例如除以0;
- 也能没有关系;
- 例如系统定时器产生信号;
- 处理器检测到有事件发生后,通过一张叫做异常表的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序(exception handler));
- 异常处理程序完成处理后,根据引起异常的类型,发生以下三种情形;
- 处理程序将控制返回给当前指令$I_{\mathrm{curr}}$,即当事件发生时正在执行的指令;
- 处理程序将控制返回给$I_{\mathrm{next}}$,即如果没有发生异常将会执行的下一条指令;
- 处理程序终止被中断的程序;
异常控制流图示:
异常处理
- 系统中每种类型的异常都对应一个非负异常号;
- 在系统启动时,操作系统分配和初始化一张异常表,表目$k$包含异常$k$的处理程序地址;
- 异常表样式:
- 异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器中;
- 异常表寻址过程:
- 异常表样式:
- 异常和过程调用的区别:
- 异常的返回地址是当且指令或者下一条指令;过程调用的返回地址由寄存器确定;
- 处理器会将一些额外的处理器状态压到栈里;
- 如果控制从用户程序转移到内核,那么这些项目被压到内核栈而不是用户栈中;
- 异常处理程序运行在内核模式下,对所有的系统资源都有完全的访问权;
异常的类别
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
说明:
- 异步异常是由处理器外部的I/O设备中的事件产生的,同步异常是执行一条指令的直接产物;
- 陷阱,故障和终止也叫作故障指令;
- 有时也划分为异步异常(中断),同步异常(陷阱,故障和终止);
- 某些手册会拥“异常”仅表示同步事件引起的控制流改变;
中断
- 异步发生,是由来自处理器外部的I/O设备的信号的结果;
- 由于不是由任何一条指令造成的,所以为异步;
- 也称为硬件中断;
- 对应的异常处理程序通常称为中断处理程序;
中断处理的流程:
陷阱和系统调用
- 陷阱是有意的异常,是执行一条指令的结果;
- 最重要的用途是在用户程序和内核程序之间提供一个像过程一样的接口,叫做系统调用;
- 为了让用户使用内核服务,处理器提供了指令“syscall n”,执行syscall会导致一个到异常处理程序的陷阱;
陷阱处理的流程:
备注:
- 普通函数运行在用户模式中;
- 系统调用运行在内核模式中;
故障
- 故障由错误引起,能够被故障处理程序修正;
- 如果处理程序可以修正错误,那么久返回到引起故障的指令并重新执行;
- 否则返回到内核中的abort例程,abort会终止引起故障的应用程序;
故障处理的流程:
例子:缺页异常
- 当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障;
- 缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令;
- 当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障地运行完成了;
终止
- 终止是不可恢复的致命错误造成的结果;
- 通常是硬件错误;
- 终止处理程序从不将控制返回给应用程序;
- 处理程序将控制返回给一个abort例程,该例程会终止该应用程序;
终止处理的流程:
Linux/x86-64系统中的异常
故障和终止
例子:
异常号 | 描述 | 异常类别 |
---|---|---|
0 | 除法错误 | 故障 |
13 | 一般保护故障 | 故障 |
14 | 缺页 | 故障 |
18 | 机器检查 | 终止 |
32-255 | 操作系统定义的异常 | 中断或陷阱 |
说明:
- 一般保护故障
- 通常产生原因是程序引用了一个未定义的虚拟内存区域,或者程序试图写一个只读的文本段;
- Linux不会尝试恢复这类故障;
- 通常会把这种一般保护故障报告为“段故障”(Segmentation fault);
- 机器检查
- 在导致故障的指令执行中检测到致命的硬件错误时发生的;
- 机器检查处理程序从不返回控制给应用程序;
系统调用
- Linux提供几百种系统调用,每个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量,注意跳转表和异常表不同;
- 一般不使用syscall n的形式,因为标准C库提供了一组方便的包装函数;
- 系统调用的参数是通过寄存器而不是栈传递的;
- %rax包含系统调用号,寄存器%rdi、%rsi、ordx、%r10、%r8和%r9包含最多6个参数(分别表示第一,二……个参数);
- 从系统调用返回时,寄存器%rcx和%r11都会被破坏,%rax包含返回值。-4095到-1之间的负数返回值表明发生了错误,对应于负的errno。
系统调用示例:
编号 | 名字 | 描述 | 编号 | 名字 | 描述 |
---|---|---|---|---|---|
0 | read | 读文件 | 33 | pause | 挂起进程直到信号到达 |
1 | write | 写文件 | 37 | alarm | 调度告警信号的传送 |
2 | open | 打开文件 | 39 | getpid | 获得进程ID |
3 | close | 关闭文件 | 57 | fork | 创建进程 |
4 | stat | 获得文件信息 | 59 | execve | 执行一个程序 |
9 | mmap | 将内存页映射到文件 | 60 | _exit | 终止进程 |
12 | brk | 重置堆顶 | 61 | wait4 | 等待一个进程终止 |
32 | dup2 | 复制文件描述符 | 62 | kill | 发送信号到一个进程 |
8.2 进程
- 异常是允许操作系统内核提供进程(process)概念的基本构造块;
- 进程的定义为一个执行中程序的示例;
- 每个程序都运行在某个进程的上下文中;
- 上下文是由程序正确运行所需状态组成;
- 状态包括代码,数据,栈,寄存器等等;
- 进程给提供给应用程序的关键抽象:
- 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器;
- 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统;
逻辑控制流
- 程序计数器(PC值)的序列叫做逻辑控制流,简称逻辑流;
- 进程是轮流使用处理器的,每个进程执行它的流的一部分,然后被抢占;
逻辑流示例:
并发流
- 如果一个逻辑流在执行时间上与另一个流重叠,称为并发流;
- 流$X$和$Y$相互并发,当且仅当$X$在$Y$开始之后和$Y$结束之前开始,或者$Y$在$X$开始之后和$X$结束之前开始;
- 在示例中,
- 并发执行:$A,B$;$A,C$;
- 未并发:$B,C$;
- 多个流并发地执行的一般现象称为并发;
- 一个进程和其他进程轮流运行的概念称为多任务;
- 一个进程执行它的控制流的一部分的每一时间段叫做时间片;
- 多任务也叫作时间分片;
- 并发流和处理器核,计算机数无关;
- 只要两个流在时间上重叠,那么就是并发的;
- 如果两个流并发地运行在不同的处理器或者计算机上,那么称为并行流,它们并行地运行,并行地执行;
私有地址空间
进程也为每个程序提供一种假象,好像它独占地使用系统地址空间;
一台$n$位地址的机器上,地址空间是$2^n$个可能地址的集合,$0,1,\ldots, 2^n -1$;
进程为每个程序提供它自己的私有地址空间;
- 某个进程关联的内存字节不能被其他进程读或者写;
进程私有地址空间的结构:
用户模式和内核模式
- 处理器是用某个控制寄存器中的一个模式位(mode bit)来描述了进程当前享有的特权;
- 当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式);
- 该模式下的进程可以执行任何指令,访问系统中的任何内存位置;
- 当没有设置了模式位时,进程就运行在用户模式;
- 该模式下的进程不允许指令特权指令,例如停止处理器,发起I/O等等;
- 当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式);
- 运行应用程序的进程初始时是在用户模式中;
- 通过异常,控制转移到异常处理程序,处理器将模式从用户模式变为内核模式;
- 当返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式;
- Linux中/proc文件系统运行用户模式进程访问内核数据结构;
- 例如/proc/cpuinfo
上下文切换
- 操作系统内核使用一种称为上下文切换的较高形式的异常控制流来实现多任务;
- 该机制建立在8.1中的较低层异常机制之上;
- 内核为每个进程维持一个上下文;
- 上下文是由程序正确运行所需状态组成;
- 状态包括代码,数据,栈,寄存器等等;
- 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的;
- 内核使用上下文切换的机制来将控制转移到新的进程;
- 上下文切换:
- 保存当前进程的上下文;
- 恢复某个先前被抢占的进程被保存的上下文;
- 将控制传递给这个新恢复的进程;
- 系统调用,中断都有可能引发上下文切换;
- 上下文切换示例:
8.3 系统错误处理
当Unix系统级函数遇到错误时,通常会返回-1,并设置全局整数变量errno来表示什么出错了,一个程序示例如下:
if ((pid = fork()) < 0) {
fprintf(stderr, "fork error: %s\n", strerror(errno));
exit(0);
}
为了方便使用,课本使用了错误处理包装函数,具体可见csapp.h。
8.4 进程控制
获取进程ID
获取进程ID的函数:
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
pid_t getppid(void);
返回: 调用者或其父进程的PID,pid_t在Linux中为int。
创建和终止进程
- 进程总是处于下面三种状态之一
- 运行
- 在CPU上执行;
- 或者在等待被执行且最终被内核调度;
- 停止
- 进程的执行被挂起(suspended),且不会被调度;
- 当收到SIGSTOP,SIGTSTP,SIGTTIN或者SIGTTOU信号时进程就会停止;保持停止直到收到SIGCONT信号,此时进程再次开始运行;
- 终止
- 进程永远地停止;
- 因为三种原因终止;
- 收到一个默认行为是终止进程的信号;
- 从主程序返回;
- 调用exit函数;
- 运行
exit函数:
#include <stdlib.h>
void exit(int status);
该函数不返回。
fork函数:
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
返回: 子进程返回0,父进程返回子进程的PID,如果出错,则为一1。
fork函数说明
父进程使用fork函数创建子进程;
创建的子进程几乎但不完全与父进程相同;
- 子进程的虚拟地址空间和父进程的虚拟地址空间相同的(但是相同)一份副本;
- 包括代码,数据段,堆,共享库和用户栈;
- 包括文件描述符相同的副本;
- 最大的区别在于有不同的PID;
- 子进程返回0;
- 父进程返回子进程的pid;
- 子进程的pid总是为非零;
- 子进程的虚拟地址空间和父进程的虚拟地址空间相同的(但是相同)一份副本;
fork函数只被调用一次,但是返回两次;
- 两次返回分别在父进程和子进程中;
子进程和父进程是并发执行的,不能保证执行的顺序;
进程图可以帮助学习fork函数,例如:
int main() { Fork(); Fork(); printf("hello\n"); exit(0); }
该函数对应进程图:
例子
#include <sys/types.h>
#include <unistd.h>
#include "csapp.h"
int main(){
pid_t pid;
pid_t cur_pid;
cur_pid = getpid();
pid = Fork();
if (pid == 0){
// child
printf("This is child!\n");
printf("pid of child: x = %d\n", getpid());
printf("pid of parent: x = %d\n", getppid());
exit(0);
}
// parent
printf("This is parent!\n");
printf("pid of child: x = %d\n", pid);
printf("pid of parent: x = %d\n", cur_pid);
exit(0);
}
编译:
gcc -o pid pid.c -lpthread
结果:
This is parent!
pid of child: x = 634
pid of parent: x = 633
This is child!
pid of child: x = 634
pid of parent: x = 633
回收子进程
- 当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除;
- 相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped);
- 当父进程回收已终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。
- 一个终止了但还未被回收的进程称为僵死进程(zombpie);
- 如果父进程先于子进程终止,那么内核会安排init进程成为它的孤儿进程的养父;
- init进程的PID为1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先;
- 长期运行的程序应该回收僵死进程,因为它们消耗内存资源;
相关函数
waitpid
waitpid函数等待它的子进程终止或者停止:
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *statusp, int options);
返回:如果成功,则为子进程的PID,如果WNOHANG,则为0,如果其他错误,则为-1。
基本说明:
- 默认情况下(当options=0时),waitpid挂起调用进程的执行,直到它的等待集合(wait set)中的一个子进程终止。
- 如果等待集合中的一个进程在刚调用的时刻就已经终止了,那么waitpid就立即返回。
- 在这两种情况中,waitpid返回导致waitpid返回的已终止子进程的PID。
- 此时,已终止的子进程已经被回收,内核会从系统中删除掉它的所有痕迹。
补充说明:
- 判断等待集合的成员
- 由pid确定:
- 如果pid > 0,那么等待集合就是一个单独的子进程,进程ID等于pid;
- 如果pid = -1,那么等待集合就是由父进程所有的子进程组成;
- 由pid确定:
- 修改默认行为
- 由参数options确定;
- 可选项为可以通过将options设置为常量WNOHANG、 WUNTRACED和WCONTINUED的各种组合来修改默认行为;
- 具体见8.4.3;
- 检查已回收子进程的退出状态
- 如果statusp参数是非空的,那么waitpid就会在status中放上关于导致返回的子进程的状态信息,status是 statusp指向的值;
- wait.h头文件定义了解释status参数的几个宏;
- WIFEXITED(status)
- WEXITSTATUS(status)
- WIFSIGNALED(status)
- WTERMSIG(status)
- WIFSTOPPED(status)
- WSTOPSIG(status)
- WIFCONTINUED(status)
- 具体见8.4.3,517;
- 如果调用进程没有子进程,那么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。
说明:
- wait(&status)等价于调用wait(-1, &status, 0);
例子
见8.4.3,520页。
让进程休眠
sleep函数将一个进程挂起一段指定的时间:
#include <unistd.h>
unsigned int sleep(unsigned int secs);
返回: 还要休眠的秒数。
说明:
- 如果请求的时间量已经到了,sleep返回0,否则返回还剩下的要休眠的秒数;
- 例如sleep被一个信号中断而过早地返回;
pause函数:
#include <unistd.h>
int pause(void);
总是返回-1。
说明:
- 该函数调用函数休眠,直到该进程收到一个信号;
加载并运行程序
execve函数:
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
如果成功,则不返回,如果错误,则返回-1。
说明:
execve函数在当前进程的上下文中加载并运行一个新程序;
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp;
只有当出现错误时,例如找不到filename,execve才会返回到调用程序;
所以,与fork一次调用返回两次不同,execve调用一次并从不返回;
参数列表和环境变量组织结构:
在 execve加载了filename之后,它调用7.9节中描述的启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型:
int main(int argc, char **argv, char **envp);
或者
int main(int argc, char *argv[], char *envp[]);
- 参数说明:
- argc给出argv[]数组中非空指针的数量;
- argv指向argv[]数组中的第一个条目;
- envp指向envp[]数组中的第一个条目;
- 参数说明:
getenv函数:
#include <stdlib.h>
char *getenv(const char *name);
返回: 若存在则为指向name的指针,若无匹配的,则为NULL。
说明:
- getenv函数在环境数组中搜索字符串“name=value”。如果找到了, 它就返回一个指向value的指针,否则它就返回 NULL;
setenv,unsetenv函数:
#include <stdlib.h>
int setenv(const char *name, const char *newvalue, int overwrite);
返回: 若成功则为0, 若错误则为一1。
void unsetenv(const char *name);
返回:无。
说明:
- 如果环境数组包含一个形如“name=oldvalue”的字符串,那么unsetenv会删除它,而setenv会用newvalue代替 oldvalue,但是只有在overwirte非零时才会这样。
- 如果name不存在,那么setenv就把 “name=newvalue” 添加到数组中。
bash中等价命令:
- export, unset
8.5 信号
简介
- 硬件和软件合作提供基本的低层异常机制;
- 操作系统利用异常来支持进程上下文切换;
- 这部分将介绍被称为Linux信号的软件形式异常;
- 信号允许进程和内核中断其他进程;
- 一条信号是一条小消息,通知进程系统中发生了某种类型的事件;
- 信号的意义在于提供一种机制,通知用户进程发生了某些低级的硬件异常,这些异常在正常情况下对用户进程不可见;
常见Linux信号
序号 | 名称 | 默认行为 | 相应事件 |
---|---|---|---|
1 | SIGHUP | 终止 | 终端线挂断 |
2 | SIGINT | 终止 | 来自键盘的中断 |
3 | SIGQUIT | 终止 | 来自键盘的退出 |
4 | SIGILL | 终止 | 非法指令 |
5 | SIGTRAP | 终止并转储内存 | 跟踪陷阱 |
6 | SIGABRT | 终止并转储内存 | 来自abort函数的终止信号 |
7 | SIGBUS | 终止 | 总线错误 |
8 | SIGFPE | 终止并转储内存 | 浮点异常 |
9 | SIGKILL | 终止 | 杀死程序 |
10 | SIGUSR1 | 终止 | 用户定义的信号1 |
11 | SIGSEGV | 终止并转储内存 | 无效的内存引用(段故障 ) |
12 | SIGUSR2 | 终止 | 用户定义的信号2 |
13 | SIGPIPE | 终止 | 向一个没有读用户的管道做写操作 |
14 | SIGALRM | 终止 | 来自alarm函数的定时器信号 |
15 | SIGTERM | 终止 | 软件终止信号 |
16 | SIGSTKFLT | 终止 | 协处理器上的栈故障 |
17 | SIGCHLD | 忽略 | 一个子进程停止或者终止 |
18 | SIGCONT | 忽略 | 继续进程如果该进程停止 |
19 | SIGSTOP | 停止直到下一个SIGCONT | 不是来自终端的停止信号 |
20 | SIGTSTP | 停止直到下一个SIGCONT | 来自终端的停止信号 |
21 | SIGTTIN | 停止直到下一个SIGCONT | 后台进程从终端读 |
22 | SIGTTOU | 停止直到下一个SIGCONT | 后台进程向终端写 |
23 | SIGURG | 忽略 | 套接字上的紧急情况 |
24 | SIGXCPU | 终止 | CPU时间限制超出 |
25 | SIGXFSZ | 终止 | 文件大小限制超出 |
26 | SIGVTALRM | 终止 | 虛拟定时器期满 |
27 | SIGPROF | 终止 | 剖析定时器期满 |
28 | SIGWINCH | 忽略 | 窗口大小变化 |
29 | SIGIO | 终止 | 在某个描述符上可执行I/O操作 |
30 | SIGPWR | 终止 | 电源故障 |
说明:
- “转储内存”是一个历史术语,指把代码和数据内存段的映像写到磁盘上;
信号术语
发送一个信号到目的进程是由两个不同步骤组成:
发送信号;
- 内核通过更新目的进程上下文中某个状态,发送一个信号给目的进程;
- 发送信号有两种原因:
- 内核检测到一个系统事件;
- 一个进程调用了kill函数;
接收信号;
目的进程被内核强迫以某种方式对信号的发送作出反应时,它就接受了信号;
进程对信号的反馈有三种方式:
- 忽略;
- 终止;
- 执行信号处理程序(signal handler);
基本思想:
说明:
- 一个发出而没有被接收的信号叫待处理信号;
- 一种类型最多只会有一个待处理信号,如果一个进程有一个类型为$k$的待处理信号,那么接下来任何发送到该进程的类型为$k$的信号都会被抛弃;
- 待处理信号最多只能被接收一次;
- 内核为每个进程在pending位向量(信号掩码)维护待处理信号的集合,在blocked位向量中维护着被阻塞的信号集合;
- 当传送了一个类型为$k$的信号,内核会设置pending中的第$k$位;
- 当接收了一个类型为$k$的信号,内核就会清除pending中的第$k$位;
- 进程可以选择性的阻塞接收某种信号;
- 当一种信号被阻塞时,它仍然可以发送,但是不会被接收,直到取消阻塞为止;
发送信号
进程组函数
getpgrp函数:
#include <unistd.h>
pid_t getpgrp(void);
返回: 调用进程的进程组ID。
setpgid函数:
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
返回: 若成功则为0,若错误则为-1。
说明:
- getpgrp函数返回当前进程的进程组ID;
- setpgid将进程pid的进程组改为pgid;
- 如果pid是0,那么就使用当前进程的PID;
- 如果pgid是0,那么就用pid指定的进程的PID作为进程组ID;
用/bin/kill程序发送信号
使用方式:
/bin/kill signal pid
说明:
- /bin/kill程序发送信号|signal|给进程PID中;
- 如果signal < 0,则将信号|signal|发送给进程组PID中每个进程;
- 使用/bin/kill是为了指定绝对路径,因为有些Unix shell中有内置的kill命令;
从键盘发送信号
Unix shell使用作业(job)这个抽象概念来表示为对一条命令行求值而创建的进程;
在任何时刻,至多只有一个前台作业和0个或多个后台作业;
前台后台进程组示例:
Ctrl + C会导致内核发送一个SIGINT信号到前台进程组中的每个进程,默认结果是终止前台作业;
Ctrl + Z会发送一个SIGTSTP信号到前台进程组中的每个进程,默认结果是停止(挂起)前台作业;
用kill函数发送信号
kill函数:
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
返回:若成功则为0,若错误则为-1。
说明:
- 如果pid > 0,那么kill发送信号sig给进程pid;
- 如果pid = 0,那么kill发送信号sig给调用进程所在进程组中的每个进程,包括调用进程自己;
- 如果pid < 0,那么kill发送信号sig给进程组|pid|中的每个进程;
用alarm函数发送信号
alarm函数:
#include <unistd.h>
unsigned int alarm(unsigned int secs);
返回: 前一次闹钟剩余的秒数,若以前没有设定闹钟,则为0。
说明:
- alarm函数安排内核在secs秒后发送一个SIGALRM信号给调用进程;
- 如果secs = 0,那么不会调度安排新的闹钟(alarm);
- 在任何情况下,对alarm的调用都将取消任何待处理的(pending)闹钟;
- 并且返回任何待处理的闹钟在被发送前还剩下的秒数;
- 如果没有待处理的闹钟,就返回零;
接收信号
接收信号的流程:
- 当内核把进程$p$从内核模式切换到用户模式时,它会检查进程$p$的未被阻塞的待处理信号的集合(pending & ~blocked);
- 如果该集合为空,内核将控制传递到$p$的逻辑控制流中的下一条指令$I_{\text{next}}$;
- 如果集合非空,内核选择集合中的某个信号$k$(通常是最小的$k$),然后强制$p$接收信号$k$;
- 收到信号$k$会触发进程采取某种行为;
- 完成行为后,控制传递到$p$的逻辑控制流中的下一条指令$I_{\text{next}}$;
信号的默认行为,具体可见常见Linux信号一节:
- 进程终止;
- 进程终止并转储内存;
- 进程停止(挂起)直到被SIGCONT信号重启;
- 进程忽略该信号;
进程可以通过signal函数修改和信号关联的默认行为(除了SIGSTOP和SIGKILL),signal函数描述:
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum,sighandler_t handler);
返回:若成功则为指向前次处理程序的指针,若出错则为SIG_ERR(不设置errno)。
说明:
如果handler是SIG_IGN,那么忽略类型为signum的信号;
如果handler是SIG_DFL,那么类型为signum的信号行为恢复为默认行为;
否则,handler就是用户定义的函数的地址,这个函数被称为信号处理程序,只要进程接收到一个类型为signum的信号,就会调用这个程序;
- 通过把处理程序的地址传递到signal函数从而改变默认行为,这叫做设置信号处理程序(installing the handler);
- 调用信号处理程序被称为捕获信号;
- 执行信号处理程序被称为处理信号;
信号处理程序可以被其他信号处理程序中断;
阻塞和解除阻塞信号
- 阻塞信号分为隐式和显式;
- 隐式:
- 如果程序捕获了信号$s$,当前正在运行处理程序$S$,如果发送给进程另一个信号$s$,那么$s$会变成待处理而没有被接收;
- 显式:
- 使用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。
说明:
- sigprocmask改变blocked位向量,具体行为和how的值有关:
- how = SIG_BLOCK:把set中的信号添加到blocked中(blocked=blocked | set);
- how = SIG_UNBLOCK:从blocked中删除set中的信号(blocked=blocked & ~set);
- how = SIG_SETMASK:block=set;
- 如果oldset非空,那么blocked位向量之前的值保存在oldset中;
- sigemptyset初始化set为空集合;
- sigfillset函数把每个信号都添加到set中;
- sigaddset函数把signum添加到set;
- sigdelset从set中删除signum;
- 如果signum是set的成员,那么sigismember返回1,否则返回0。
编写信号处理程序
- 信号处理程序非常棘手,原因如下:
- 处理程序与主程序并发运行,共享同样的全局变量,因此可能与主程序和其他处理程序互相干扰;
- 如何以及何时接收信号的规则常常有违人的直觉;
- 不同的系统有不同的信号处理语义;
后续给出编写安全,正确和可移植的信号处理程序的基本规则。
安全的信号处理
保守的编写程序的原则:
- G0. 处理程序要尽可能简单。
- G1.在处理程序中只调用异步信号安全的函数。
- 异步信号安全的函数(简称安全的函数)能够被信号处理程序安全地调用;
- 或者是可重入,即可以在任何时刻被打断;
- 或者不能被信号处理程序中断;
- 具体例子见8.5.5(534页);
- 产生输出唯一安全的方法是使用write;
- 异步信号安全的函数(简称安全的函数)能够被信号处理程序安全地调用;
- G2.保存和恢复errno。
- 如果处理程序调用_exit终止进程,那么就不需要保存恢复errno;
- G3.阻塞所有的信号,保护对共享全局数据结构的访问。
- G4.用volatile声明全局变量。
- volatile int g;
- 告诉编译器不要缓存该全局变量;
- G5.用sig_atomic_t声明标志。
- 在常见的处理程序中,处理程序会写全局标志来记录收到了信号;
- 主程序周期性地读这个标志,响应信号,再清除该标志;
- C提供整型数据类型sig_atomic_t,对它的读和写是原子的(不可中断的);
- volatile sig_atomic_t flag;
- 只适用于单个读写,不适用于flag++, flag = flag + 10;
补充:
安全的输出函数:
#include "csapp.h"
ssize_t sio_put1(long v);
ssize_t sio_puts(char s[]);
返回: 如果成功则为传送的字节数,如果出错,则为一1。
void sio_error(char s[]);
返回: 空。
正确的信号处理
未处理的信号是不排队的,具体例子见课本537页。
可移植的信号处理
- 不同的系统有不同的信号处理语义;
- signal函数的语义各有不同;
- 系统调用可以被中断;
- 像read、write和accept这样的系统调用潜在地会阻塞进程一段较长的时间,称为慢速系统调用;
为了解决这些问题,Posix标准定义了sigaction函数:
#include <signal.h>
int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);
返回: 若成功则为0,若出错则为-1。
该函数比较复杂,一般使用包装函数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) < O)
unix_error("Signal error");
return (old_action.sa_handler);
}
Signal包装函数设置了一个信号处理程序,其信号处理语义如下:
- 只有这个处理程序当前正在处理的那种类型的信号被阻塞;
- 和所有信号实现一样,信号不会排队等待;
- 只要可能,被中断的系统调用会自动重启;
- 一旦设置了信号处理程序,它就会一直保持,直到signal带着handler参数为SIG_IGN或者SIG_DFL被调用;
补充:
- posix(Portable Operating System Interface)的中文名称为可移植操作系统接口;
同步流以避免讨厌的并发错误
- 例子见542页;
- 在这部分的例子中,main函数中的addjob和处理程序中调用deletejob之间存在竞争;
- 如果addjob赢得竞争,则结果正确;否则结果错误;
显式地等待信号
sigsuspend函数:
#include <signal.h>
int sigsuspend(const sigset_t *mask);
返回: -1。
说明:
sigsuspend函数暂时用mask替换当前的阻塞集合,然后挂起该进程,直到收到一个信号,其行为要么是运行一个处理程序,要么是终止该进程;
- 如果它的行为是终止,那么该进程不从sigsuspend返回就直接终止;
- 如果它的行为是运行一个处理程序,那么sigsuspend从处理程序返回,恢复调用sigsuspend时原有的阻塞集合;
sigsuspend函数等价于如下代码的原子版本:
sigprocmask(SIG_SETMASK, &mask, &prev); pause(); sigprocmask(SIG_SETMASK, &prev, NULL);
例子见课本544。
8.6 非本地跳转
C语言提供了一种用户级异常控制流形式,称为非本地跳转(nonlocaljump);
它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用——返回序列;
非本地跳转是通过setjmp和longjmp函数来提供的。
setjmp函数:
setjmp函数在env缓冲区中保存当前调用环境,以供后面的longjmp使用,并返回0。
- 调用环境包括程序计数器、栈指针和通用目的寄存器。
setjmp的返回值不能赋值给变量,例如如下写法是错误的:
rc = setjmp(env); /* wrong! */
但是可以安全地用在switch或条件语句的测试中;
longjmp函数:
- longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近次初始化env的setjmp调用的返回。然后setjmp返回,并带有非零的返回值retval。
sigsetjmp和siglongjmp函数是setjmp和longjmp的可以被信号处理程序使用的版本;
说明:
- 调用和返回关系:
- setjmp函数只被调用一次,但返回多次;
- 一次是第一次调用setjmp,调用环境保存在缓冲区env时;
- 一次是为每个相应的longjmp调用;
- longjmp被调用一次,但从不返回;
- setjmp函数只被调用一次,但返回多次;
- 非本地跳转的应用:
- 允许从一个深层嵌套的函数调用中立即返回(通常是由某错误引起);
- 使一个信号处理程序分支到一个特殊的代码位置,而不是返回到被信号到达中断了的指令的位置;
- 与C++和Java中的软件异常类比:
- C++和Java中的异常是C语言的setjmp和longjmp函数的更加结构化版本;
- try语句中的catch类似于setjmp;
- throw类似于longjmp;
函数接口:
#include <setjmp.h>
int setjmp(jmp_buf env);
int sigsetjmp(sigjmp_buf env, int savesigs);
返回: setjmp返回0,longjmp返回非零。
void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);
从不返回。
8.7 操作进程的工具
略过,见课本。