第八章 异常控制流
- 控制流:控制转移序列。
- 控制转移:从一条指令到下一条指令。
- 异常控制流:现代操作系统通过使控制流发生突变来对系统状态做出反应,这些突变称为异常控制流。
- 平滑:指在存储器中指令都是相邻的。
- 突变:出现不相邻,通常由诸如跳转、调用、和返回等指令造成。异常控制流ECF:即这些突变。
8.1 异常
异常是异常控制流的一种形式,由硬件和操作系统实现。简单来说,就是控制流中的突变,用来响应处理器状态中的某些变化。
事件:即状态变化,与当前指令的执行可能直接相关,也可能没有关系。
8.1.1 异常处理
- 异常号:系统为每种类型的异常分配的唯一的非负整数。
- 异常表:系统启动时操作系统就会初始化一张条转变,使得条目k包含异常k的处理程序的地址。
- 异常号是到异常表中的索引,异常表的起始地址放在异常表基址寄存器。
- 异常类似于过程调用,但有一些重要的不同之处:
1.处理器压入栈的返回地址,是当前指令地址或者下一条指令地址。
2.处理器也把一些额外的处理器状态压到栈里
3.如果控制一个用户程序到内核,所有项目都压到内核栈里。
4.异常处理程序运行在内核模式下,对所有的系统资源都有完全的访问权限。
8.1.2异常的类别
- 中断:异步发生,是来自处理器外部的I/O设备的信号的结果,返回下一条指令。
- 陷阱:陷阱是有意的异常,是执行一条指令的结果,最重要的用途——系统调用。
- 故障:由错误状况引起,可能能够被故障处理程序修正,结果要么重新执行指令(就是返回当前指令地址),要么终止,典型示例:缺页异常。
- 终止:是不可恢复的致命错误造成的结果,通常是一些硬件错误
8.1.3 Linux/IA32系统中的异常
一共有256种不同的异常类型。
8.2 进程
- 进程就是一个执行中的程序实例。系统中的每个程序都是运行在某个进程的上下文中的。
- 上下文:由程序正确运行所需的状态组成的。
- 进程提供给应用程序的关键抽象:
1、一个独立的逻辑控制流:独占的使用处理器
2、一个私有的地址空间:独占的使用存储器系统
8.2.1 逻辑控制流
- 一系列的程序计数器PC的值,分别唯一的对应于包含子啊程序的可执行目标文件中的指令,或者是包含在运行时动态链接到程序的共享对象中的指令,这个PC值的序列就叫做逻辑控制流。
- 进程是轮流使用处理器的。每个进程执行它的流的一部分,然后被抢占,然后轮到其他进程。但是进程可以向每个程序提供一种假象,好像它在独占的使用处理器。
- 异常处理程序、进程、信号处理程序、线程、Java进程
8.2.2 并发流
- 一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发地运行。
- 并发:多个流并发执行的一般现象称为并发。
- 多任务:一个进程和其他进程轮流运行的概念称为多任务(也叫时间分片)。
- 时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
- 两个流并发的运行在不同的处理机核或者计算机上,称它们为并行流,它们并行地运行,且并行地执行。
8.2.3 私有地址空间
进程为程序提供的假象,好像它独占的使用系统地址空间。一个进程为每个程序提供它自己的私有地址空间。
8.2.4 用户模式和内核模式
- 用户模式和内核模式的区别就在于用户的权限上,权限指的是对系统资源使用的权限。具体的区别是有无模式位,有的话就是内核模式,可以执行指令集中的所有指令,访问系统中任何存储器位置;没有就是用户模式。
- 进程从用户模式变为内核模式的唯一方法是通过异常——中断,故障,或者陷入系统调用。
- linux提供了/proc文件系统,它允许用户模式进程访问内核数据结构的内容。将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。
8.2.5 上下文切换
- 操作系统内核使用上下文切换这种较高层形式的异常控制流来实现多任务。上下文切换机制建立在较底层异常机制之上。
- 上下文:内核重新启动一个被抢占的进程所需的状态。由一些对象的值组成,这些对象包括:通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构(页表、进程表、文件表)。
- 调度和调度器,在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,这种决定就叫做调度。是由内核中称为调度器的代码处理的。
- 上下文切换机制
1.保存当前进程的上下文
2.恢复某个先前被抢占的进程被保存的上下文
3.将控制传递给这个新恢复的进程。
- 可能发生上下文切换的原因:
1、内核代表用户执行系统调用时
2、中断
8.3 系统调用错误处理
- 当Unix系统级函数遇到错误时,它们典型地会返回-1,并设置全局整数变量errno来表示什么出错了。
- 通过使用错误处理包装函数,可以进一步地简化我们的代码。
- 包装函数调用基本函数,检查错误,如果有任何问题就终止。
8.4 进程控制
8.4.1 获取进程ID
每个进程都有一个唯一的正数进程ID(PID)。
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void); 返回调用进程的PID
pid_t getppid(void); 返回父进程的PID(创建调用进程的进程)
8.4.2 创建和终止进程
- 进程的三种状态:
1、运行。进程在CPU上执行,或等待被执行(会被调度)。
2、停止:进程被挂起(不会被调度)。收到 SIGSTOP 、 SIGTSTP 、 SIDTTIN 、 SIGTTOU 信号,进程停止,收到 SIGCONT 信号,进程再次开始运行。
3、终止:永远停止。
原因:
1、收到信号,默认行为为终止进程
2、从主程序返回
3、调用exit函数
- 创建进程
父进程通过调用fork函数来创建一个新的运行子进程:父进程与子进程有相同(但是独立的)地址空间,有相同的文件藐视符集合。
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
创建新进程可以使用 fork 函数。新创建的子进程和父进程几乎相同,它获得父进程用户级虚拟地址空间和文件描述符的副本,主要区别是它们的PID不同。
fork 函数调用一次,返回两次;父子进程是并发运行的,不能假设它们的执行顺序;两个进程的初始地址空间相同,但是是相互独立的;它们还共享打开的文件。因为有相同的程序代码,所以如果调用 fork 三次,就会有八个进程。
调用fork函数n次,产生2的n次方个进程。
- 终止进程
用exit函数。
#include <stdlib.h>
void exit(int status);
exit函数以status退出状态来终止进程。
8.4.3 回收子进程
- 回收:当一个进程终止时,内核并不立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收。
- 僵死进程:一个终止了但是还未被回收的进程称为僵死进程。父进程回收终止的子进程时,内核将子进程退出状态传给父进程,然后抛弃该进程。如果回收前父进程已经终止,那么僵死进程由 init 进程回收。
- 回收子进程的两种方法:1.内核的init进程 ;2.父进程waitpid函数
- 错误条件:如果调用进程没有子进程,那么waitpid返回-1,并且设置errno为ECHILD。:如果waitpid被一个信号中断,那么他返回-1,并且设置errno为EINTR。
8.4.4 让进程休眠
- sleep函数:sleep函数使一个进程挂起一段指定的时间。
- pause函数:让调用函数休眠,直到该进程收到一个信号。
8.4.5 加载并运行程序
#include <unistd.h>
int execve(const char *filename, const char *argv[], const char *envp[]);
成功不返回,失败返回-1.
execve函数调用一次,从不返回。
filename:可执行目标文件
argv:参数列表
envp:环境列表
新程序开始时:
getnev函数
#include <stdlib.h>
char *getenv(const char *name);
若存在则为指向name的指针,无匹配是null
在环境数组中搜寻字符串"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,只有在overwrite非零时成立。
如果name不存在,setenv会将"name=newvalue"写进数组。
8.4.6 利用fork和execve运行程序
- fork函数是创建新的子进程,是父进程的复制体,在新的子进程中运行相同的程序,父进程和子进程有相同的文件表,但是不同的PID
- execve函数在当前进程的上下文中加载并运行一个新的程序,会覆盖当前进程的地址空间,但是没有创建一个新进程,有相同的PID,继承文件描述符。
8.5 信号
- Unix信号:更高层的软件形式的异常允许进程中断其他进程。
8.5.1 信号术语
- 在操作系统和应用程序之间:进程之间传送信号
- 一种更高层次的软件形式的异常,称为unix信号,它允许进程中断其他进程。
- 低层的硬件异常是由内核异常处理程序处理的,正常情况下,对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。
- 发送信号的原因:
1.内核检测到一个系统事件
2.一个进程调用了kill函数,显式的要求内核发送一个信号给目的进程。
- 接收信号:
1.忽略
2.终止
3.执行信号处理程序,捕获信号
- 待处理信号:
只发出没有被接收的信号
任何时刻,一种类型至多只会有一个待处理信号,多的会被直接丢弃
一个进程可以选择性的阻塞接受某种信号,被阻塞仍可以被发送,但是不会被接收
一个待处理信号最多只能被接收一次。
8.5.2 发送信号
- 进程组:每个进程都只属于一个进程组,进程组是由一个进程组ID来标识的。默认的,一个子进程和它的父进程同属于一个进程组。在任何时刻,至多只有一个前台作业和0个或多个后台作业。外壳为每个作业创建一个独立的进程组,一个作业对应一个进程组。
- 用/bin/kill程序发送信号:/bin/kill程序可以向另外的进程发送任意的信号,格式是:/bin/kill -n m。n是信号,m是进程或进程组。当n>0时,发送信号n到进程m。当n<0时,使信号|n|发送到进程组m中的所有进程。
- 从键盘发送信号:进程组PID是取自作业中父进程的一个。作业:为对一个命令行求值而创建的进程。
- 用kill函数发送信号:用kill函数发送信号:发送SIGKILL信号
- 用alarm函数发送信号:进程可以通过调用alarm函数向它自己发送SIGALRM信号。
#include <unistd.h>
unsigned int alarm(unsigned int secs);
返回前一次闹钟剩余的秒数,若没有返回0.
8.5.3 接收信号
进程可以通过使用signal函数来修改和信号相关的默认行为。唯一的例外是SIGSTOP和SIGKILL,它们的默认行为不能被修改。
8.5.4 信号处理问题
- 当一个程序捕获多个信号时,容易有一些细问问题:
- 信号处理有以下特性:
1.信号处理程序阻塞当前正在处理的类型的待处理信号。
2.同种类型至多有一个待处理信号。
3.会潜在阻塞进程的慢速系统调用被信号中断后,在信号处理程序返回时不再继续,而返回一个错误条件,并将 errno 设为 EINTR 。
*对于第三点,Linux系统会重启系统调用,而Solaris不会。不同系统之间,信号处理语义存在差异。Posix标准定义了 sigaction 函数,使在Posix兼容的系统上可以设置信号处理语义。
8.5.5 可移植的信号处理
- 不同系统之间,信号处理语义的差异是Unix信号处理的一个缺陷。为了处理这个问题,Posix标准定义了sigaction函数。
- sigaction函数运用并不广泛,因为它要求用户设置多个结构条目。一个更简洁的方式就是定义一个包装函数,称为Signal,它调用sigaction。Signal包装函数设置了一个信号处理程序,其信号处理语义如下:
1、只有这个处理程序当前正在处理的那种类型的信号被阻塞
2、和所有信号实现一样,信号不会排队等候。
3、只要可能,被中断的系统调用会自动重启。
4、一旦设置了信号处理程序,它就会一直保持,直到Signal带着handler参数为SIG_IGN或者SIG_DFL被调用。
8.5.6 显式地阻塞和取消阻塞信号
- 应用程序可以使用sigprocmask函数显式地阻塞和取消阻塞选择的信号。
8.5.7 同步流以避免讨厌的并发错误
- 以某种方式同步并交流,从而得到最大的可行的交错的集合,每个可行的交错都能得到正确的结果。
8.6 非本地跳转
- c语言提供了一种用户级异常控制流形式,称为非本地跳转。通过setjmp和longjmp函数来提供。setjump函数在env缓冲区中保存当前调用环境,以供后面longjmp使用,并返回0.
- 调用环境:程序计数器,栈指针,通用目的寄存器
- longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回。然后setjmp返回,并带有非零的返回值retval。
- setjmp函数只被调用一次,但返回多次:一次是当第一次调用setjmp,而调用环境保存在缓冲区env中时,一次是为每个相应的longjmp调用。另一方面,longjmp只调用一次,但从不返回。sig—函数是setjmp和longjmp函数的可以被信号处理程序使用的版本。
- 非本地跳转的一个重要应用就是允许从一个深层嵌套的函数调用中立即返回,通常是由检测到某个错误情况引起的。另一个重要应用是使一个信号处理程序分支到一个特殊的代码位置,而不是返回到达中断了的指令位置。
8.7 操作进程的工具
- STRACE:打印一个正在运行的程序和他的子程序调用的每个系统调用的痕迹
- PS:列出当前系统中的进程,包括僵死进程
- TOP:打印出关于当前进程资源使用的信息
- PMAP:显示进程的存储器映射
代码运行
先将老师给的压缩包解压
当解压完这个压缩包后,我以为老师已经帮我们编译了一遍,然而我错了。没有权限。。。。。。。。。还得自己动手编译
exec1
这个代码和ls -l的功能是一样的。
exec2与exec1的区别就在于exevp函数的第一个参数,exec1传的是ls,exec2直接用的arglist[0],不过由定义可得这两个等价,所以运行结果是相同的
关于exec3.c函数中execlp()会从PATH 环境变量所指的目录中查找符合参数file的文件名,找到后便执行该文件,然后将第二个以后的参数当做该文件的argv[0]、argv[1]……最后一个参数必须用空指针(NULL)作结束
env
- 代码中涉及到getenv函数和setenv函数
- getenv函数是获得环境变量值的函数,参数是环境变量名name,例如”HOME”或者”PATH”。如果环境变量存在,那么getenv函数会返回环境变量值,即value的首地址;如果环境变量不存在,那么getenv函数返回NULL
- setenv函数是修改或添加环境变量的函数
- 1.如果name在环境中不存在,那么很好办,在环境中添加这个新的变量就OK。
setenv函数必须在environment list中增加一个新的entry,然后动态申请存储空间来存储name=value,并且使entry指向该空间。 - 2.如果在环境中name已经存在,那么
- (a)若overwrite非0,那么更新name的value(实质是更新环境表,指向新的value)
- (b)若overwrite为0,则环境变量name不变,并且也不出错
- setenv函数不必在environment list中增加一个新的entry。当overwrite为0, 则不必改动entry的指向;当overwrite非0, 则直接使该entry指向name=value,当然该name=value也是存储在动态申请的内存里。
environvar.c代码简单打印环境变量表,运行结果如下:
- 1.如果name在环境中不存在,那么很好办,在环境中添加这个新的变量就OK。
argv
- 头文件argv.h,下面的函数的功能是把命令行字符串转化为以NULL结尾的参数数组
int makeargv(const char *s, const char *delimiters, char ***argvp);
- 其中s为命令行字符串,delimiters为分割符,argvp为指向参数数组的指针,如果转化成功则返回标记的个数,如果错误则返回-1,并设置errno
- 由于argtest.c中有如下代码:
if (argc != 2) { fprintf(stderr, "Usage: %s string\n", argv[0]); return 1; }
- 只有当输入命令的个数等于2时,才能显示命令正确的结果。
- 在头文件作如下修改
fifo
pipe
pipedemo
pipedemo2
- 说明了如何将pipe和fork结合起来,创建一对通过管道来通信的进程。在程序中显示了从键盘到进程,从进程到管道,再从管道到进程以及从进程回到终端的数据传输流
stdindir1
- 将stdin定向到文件,程序中先关闭标准输入流,后打开文件,进行重定向
testtty
运行结果如下:
sigactdemo
代码打印出你所输入的字符,跟踪键盘输入
sigdemo1
sigdemo2
这个代码有点调皮