a1,a2, ... , an-1
,其中,每个 ak
是某个相应指令 Ik
的地址,每次从地址 ak
到 ak+1
的过渡称为 控制转移。而这样的控制转移序列叫做处理器的 控制流。
最简单的一种控制流是一个平滑的序列,其中每个指令Ik
和Ik+1
在内存中的位置都是相邻的。当然也有平滑流的突变,即指令Ik
和Ik+1
在内存中的位置不相邻,通常是由跳转、调用和返回这种程序指令造成的。这些指令都是一些必要机制,使得程序能够对由程序变量表示的内部程序状态的变化做出反应。
同理,系统也必须能够对系统状态的变化做出反应,这些系统状态不能由内部程序变量捕获,而且也不一定要会程序的执行相关,现代系统通过使控制流突变来对系统状态变化做出反应,一般将这种突变称为异常控制流。
调试器触发断点的一种方法是,将断点处的目标指令替换为一个不规范指令,并捕获由此引发的异常。
异常是异常控制流的一种形式,它的一部分由硬件实现,一部分由操作系统实现。异常就是控制流中的突变,用来响应处理器中的一些变化。如下图所示,当处理器状态发生一个重要的变化时,处理器正在执行某个当前指令Icurr
。在处理器中,状态被编码为不同的位和信号。状态变化称为事件(event)。
在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序exception handler),当异常处理完成处理后,根据引起异常的事件类型,会发生以下3种情况中的一种:
这个例子对异常解释的很形象:在我很小的时候,经常和一些小朋友弹弹珠,这时候玩儿的正起劲儿(正在执行当前指令),突然母亲大人在门口一声大喊:“xxx 回家吃饭了”(突发性事件)。我就必须要放下手头上正在玩儿的游戏,一溜小跑回家吃饭(异常处理程序)。吃完饭以后,我可以选择继续玩,玩其他游戏,或者不玩游戏(异常处理程序完成以后)。
系统为每种可能类型的异常分配了一个唯一的非负整数异常号。在系统启动时(通电或者重启),操作系统分配和初始化一张称为异常表的跳转表,使得表目k
包含异常k
的处理程序的地址。
在运行时(系统执行某个程序),处理器检测到一个事件,并确定了相应的异常号k
,然后处理器执行间接过程调用,通过异常表的表目k
,转到相应的程序。异常表的起始地址放在一个叫做异常表基址寄存器的特殊CPU寄存器中。
异常可以分为四类:中断(interrupt)、陷阱(trap)、故障(fault)和终止(abort),下表对这些类别属性做了点总结。
中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上说它是异步的。硬件中断的异常处理程序常常称为中断处理程序。
下面是一个中断处理程序的过程:外部I/O设备向处理器芯片的一个引脚发个信号,并将异常号放到系统总线上,以此来触发中断。
剩下的异常类型(陷阱、故障和终止)是同步发生的,是由执行当前指令引起来的,这类引起异常的指令叫做故障指令。
陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。
用户程序经常要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork)、加载一个新的程序(execve)、或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的"syscall n
"指令,当应用程序想要请求服务n
时,可以执行这条指令。执行syscall
指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核参数。下图描述了一个系统的调用过程。
故障由错误引起的,它可能能够被故障处理程序修正。当故障发生,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核的abort例程,abort会终止当前应用程序。下图是故障的处理:
终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,终止处理程序从不将控制返还给应用程序。
IA32有高达256种不同的异常,0-31的号码是Intel架构师定义的;32-255号是操作系统定义的中断和陷阱。下图是常见的异常示例:
理论上C程序可以使用syscall
函数直接调用任何系统调用,但是标准C提供了一组方便的包装函数,像这种直接调用系统调用的函数和包装用来系统调用的函数,称为系统级函数。
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成,这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器内容、程序计数器、环境变量以及打开文件描述符的集合。
每次用户向shell
输入一个可执行文件的名字运行程序时,shell就会创建一个新的进程,可执行文件在这个新进程的上下文中运行。应用程序也可以创建进程,并在这个新进程的上下文中运行他们自己的代码或其他应用程序。
进程提供给应用程序两个关键抽象。
如果我们调试程序单步执行,就会看到一系列程序计数器(PC)的值,这些值唯一的对应于程序的可执行文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,简称逻辑流。
考虑一个运行着三个进程的系统,处理器的一个物理控制流被分成了三个逻辑流,每个进程一个。每个竖线表示一个进程逻辑流的一部分。在这个例子中,三个逻辑流的执行是交错的。进程A运行一会儿,然后是进程B开始运行到完成。然后是进程C运行了一会儿,进程A接着运行直到完成。最后是进程C可以运行到结束了。
A、B、C三个进程是轮流使用处理器的,每个进程执行它的流的一部分,然后被抢占(暂时挂起),轮到其他进程。
一个逻辑流的执行在时间上与另一个流重叠,称为并发流,这两个流被称为并发运行。更准确的说,流X和流Y互相并发,当且仅当X在Y开始之后和Y结束之前开始,或者Y在X开始之后和X结束之前开始。如图8-12的进程A和进程B、进程A和进程C就是并发。由于进程B执行结束以后才开始进程C,所以B和C不算是并发。
多个流并发地执行的一般现象被称为并发,也就是某进程开始执行以后(并未完成),PC跳转到其他进程执行。一个进程和其他进程轮流的运行的概念称为多任务。一个进程执行它控制流的一部分的每一时间段叫做时间片,如上面进程A由两个时间片组成。因此,多任务也叫做时间分片。
【注】并发流的思想与流运行的处理器核数或者计算机数无关。如果两个流在时间上重叠,那么它们就是并发的,即使它们运行在同一个处理器上。如果两个流并发的运行在不同的处理器核或者计算机上,那么我们称它们为并行流,它们并行的运行并且并行的执行,并行流是并发流的一个真子集。
进程为每个程序提供它自己的私有地址空间,且这些空间具有相同的结构,如图8-13。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他的进程读或者写的,从这个意义上说,这个地址空间是私有的。
处理器为了安全起见,不至于损坏操作系统,必须限制一个进程可执行指令能访问的地址空间范围。就发明了两种模式内核模式和用户模式:
内核为每个进程维持一个上下文,上下文就是重新启动一个被抢占的进程所需要的状态,包括寄存器、程序计数器、用户栈、内核栈和各种内核数据结构。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种策略叫做调度。内核中有一个专门的调度程序,被称为调度器。
当内核选择一个进程时,就说内核调度了该进程,内核调度新进程抢占当前进程后,会使用上下文切换机制来进行控制转移,如从进程A切换到进程B:
当内核代表用户执行系统调用的时候,就会发生上下文切换,如上图所示,当进程A调用read函数的时候,内核代表进程A开始执行系统调用读取磁盘上的文件,这需要耗费相对很长的时间,处理器这时候不会闲着什么都不做,而是开始一种上下文切换机制,切换到进程B开始执行。当B在用户模式下执行了一段时间,磁盘读取完文件以后发送一个中断信号,将执行进程B到进程A的上下文切换,将控制权返回给进程A系统调用read指令后面的那条指令,继续执行进程A(注:在切换的临界时间内核模式其实也执行了B一个小段时间)。
指已创建一个或多个子进程的进程。在UNIX里,除了进程0以外的所有进程都是由其他进程使用系统调用fork创建的,这里调用fork创建新进程的进程即为父进程,而相对应的为其创建出的进程则为子进程,因而除了进程0以外的进程都只有一个父进程,但一个进程可以有多个子进程。
指的是由另一进程(对应称之为父进程)所创建的进程。子进程继承了对应的父进程的大部分属性,如文件描述符。在Unix中,子进程通常为系统调用fork的产物。在此情况下,子进程一开始就是父进程的副本,而在这之后,根据具体需要,子进程可以借助exec调用来链式加载另一程序。
关于资源。子进程得到的是除了代码段是与父进程共享的意外,其他所有的都是得到父进程的一个副本,子进程的所有资源都继承父进程,得到父进程资源的副本。既然为副本,也就是说,二者是单独的进程,都拥有自己的私有地址空间,继承了以后二者就没有什么关联了,子进程单独运行。
关于文件描述符。继承父进程的文件描述符时,相当于调用了dup函数,父子进程共享文件表项,即共同操作同一个文件,一个进程修改了文件,另一个进程也知道此文件被修改了。
每个进程都有一个唯一的正数(非零)进程ID(PID),getpid
函数返回调用进程的PID,getppid
函数返回它父进程的PID。
#include
#include
//pid_t 在Linux系统的types.h中定义为int
pid_t getpid(void);
pid_t getppid(void);
从程序员角度,我们可以认为进程总是处于下面三种状态之一:
SIGSTOP
、SIGTSTP
、SIGTTIN
或者SIGTTOU
信号时,进程就停止,并且保持停止直到它收到一个SIGCONT
信号,在这个时候,进程再次运行。exit
函数。#include
//exit函数以status退出状态来终止进程
void exit(int status);
父进程通过调用fork
函数创建一个新的运行的子进程(父子进程并发执行)。
#include
#include
pid_t fork(void);
子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本。父进程与子进程最大的区别是它们有不同的PID。
下面是一个创建子进程的实例,子进程和父进程拥有同样的代码片段,注意输出结果:
fork
函数被调用一次,却会返回两次:一次调用进程(父进程)中,一次是在新创建的子进程中。在父进程中fork
返回子进程的PID,在子进程中fork
返回0
,所以上面用例的输出结果不一样。x
中就可以看出来,起初x
在父进程和子进程的值都是1
,后面父子进程分别对值做了改变,但是两者的改变互不影响。进程图可以帮助理解fork
函数:
比较复杂的fork
函数应用:
当一个进程由于某种原因终止时,内核并不是立即把它从系统中清除。相反,进程被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。当父进程回收已经终止的子进程时,内核将子进程的退出状态传递给父进程,然后抛弃已终止的进程,从此时开始,该进程就不存在了。一个终止了但还没被回收的进程称为僵尸进程(zombie)。
如果一个父进程终止了,内核会安排init
进程成为它的孤儿进程的养父。init
进程的PID为1
,是在系统启动内核时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵尸子进程就终止了,那么内核会安排init
进程取回收它们。
一个进程可以通过调用waitpid
函数来等待它的子进程终止或者停止。
#include
#include
//返回:如果成功,则为子进程的PID,如果WNOHANG,则为0;如果其他错误则为-1.
pid_t waitpid(pid_t pid, int *statusp,int options);
waitpid
函数挂起正在执行的调用进程,直到他的等待集合中的一个子进程终止,返回子进程的ID。否则父进程会被阻塞,暂停运行。参数详解如下:
pid
: 等待集合由该参数确定(pid>0
集合为单独的一个子进程,进程ID为pid
;pid = -1
等待集合为所有子进程)。status
:检查已回收子进程的退出状态,有了这个信息父进程就可以了解子进程为什么会退出,是正常退出还是出了什么错误。书上有对这个参数详细的解释。options
: 修改默认的行为如果不想使用这些选项,则可以把这个参数设为0。书上有对这个参数详细的解释。返回值
:如果成功就返回子进程ID;如果没有失败返回-1(没有子进程的失败设置ECHILD;被中断设置EINTR)下面是对waitpid函数使用的一个示例:
上面程序的输出,但是在这个程序程序中那个子进程先退出哪个后退出是不确定的。
可以改动代码成下面示例,则子进程的创建和退出是以同样的顺序进行的:
sleep
函数将一个进程挂起一段指定的时间。如果请求的时间到了,sleep
返回0
,否则返回还剩下的要休眠的秒数。sleep
函数可以被信号中断,从而过早返回。
#include
unaigned int sleep(unsigned int sec);
pause
函数,该函数让调用函数休眠,直到该进程接收到一个信号。
#include
int pause(void);
execve
函数在当前进程的上下文中加载并运行一个新程序。execve
执行可执行目标文件filename
,且带参数列表argv
和环境变量列表envp
。
#include
int execve(const char*filename,const char *argv[],const char*envp[]);
下图是参数列表和环境变量列表所使用的的数据结构,均是以NULL
结尾的指针数组,每个指针均指向字符串:
理解进程与程序:进程是对处理器、内存和文件交互的抽象,文件可以是可执行文件,可执行文件也就是程序。所以可以说,进程是操作系统对一个正在运行的程序的一种抽象。
书上这一小节展示了一个简单shell
的main
程序,相当于对进程这个章节的内容作了一个实例汇总。
信号就像你每天早上起床而设置(调用kill函数)的闹钟一样,你接收到这个闹钟以后,被强迫要处理这个信号(一直闹也没办法睡觉啊),这时候你有三种选择:继续睡觉(忽略)、关闭闹钟(终止)、起床(执行闹钟给我的命令:信号处理程序)。
信号是一种更高层次的软件形式的异常,允许进程和内核中断其他进程,它提供了一种机制,通知用户进程发生了那些具体的异常。
8
。4
。11
。Ctrl+C
,内核会发送信号2
给前台进程组中的每个进程。9
强制将其终止。17
。Linux系统上提供了30中不同类型的信号(Linux 的各种 signal):
传送一个信号到目的进程有两个不同的步骤:
kill
函数。显示要求进程发送信号给目的进程。一个只发出而没有被接收的信号叫做待处理信号,一种类型至多只有一个待处理信号,如果一个进程有一个类型为k
的待处理信号,那么接下来发送到这个进程类型为k
的信号都会被简单的丢弃。一个进程可以有选择性地阻塞接收某种信号,它任然可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。
一个待处理信号最多只能被接收一次。内核为每个进程在pending
位向量维护着待处理信号的集合,而在blocked
位向量维护着被阻塞的信号集合。
只要发送一个类型为k
的信号,内核就会设置pending
中的第k
位,而只要接收了一个类型为k
的信号,内核就会清除pending
中的第k
位。
Unix系统提供大量向进程发送信号的机制,所有这些机制都是基于进程组(process group)这个概念的。
1)进程组
每个进程都属于一个进程组,进程组是由一个正整数进程组ID来标示,getpgrp()
函数返回当前进程的进程组ID。
#include
pid_t getpgrp(void);
默认,一个子进程和它的父进程同属于一个进程组,一个进程可以通过setpgid()
来改变自己或者其他进程的进程组。
#include
/**
*如果pid是0 ,那么使用当前进程的pid。
*如果pgid是0,那么使用指定的pid作为pgid(即pgid=pid)。
*例如:进程15213调用setpgid(0,0)
*那么进程15213会 创建/加入进程组15213.
*/
int setpgid(pid_t pid,pid_t pgid);
2)用/bin/kill
程序发送信号
/bin/kill
可以向另外的进程发送任意的信号。比如unix>/bin/kill -9 15213
,发送信号9(SIGKILL
)给进程15213
。unix>/bin/kill -9 -15213
,发送信号9(SIGKILL
)给进程组15213
中的每个进程。用/bin/kill
的原因是,有些Unix shell 有自己的kill
命令。
3)从键盘发送信号
对一个命令行求值而创建的进程,称为作业(jop)。在任何时刻,至多有一个前台作业和0个或多个后台作业。
Linux> ls | sort
上面命令会创建一个由两个进程组成的前台作业,两个进程通过Unix管道连接起来,一个进程运行ls
,另一个进程运行sort
。shell为每个作业创建了一个独立的进程组,进程组ID取自作业中父进程中的一个。下图是有一个前台作业和两个后台作业的shell(前台作业和后台作业):
在键盘输入ctrl-c
,内核会发送一个SIGINT信号到前台进程组的每个进程。在默认情况下,结果是终止
前台作业。类似,输入ctrl-z
内核会发送一个SIGTSTP信号给前台进程组的每个进程,在默认情况,结果是停止(挂起)
前台作业。
4)用kill
函数发送信号
进程通过调用kill
函数发送信号给其他进程,包括他们自己。
int kill(pid_t pid, int sig);
pid > 0
,发送信号sig
给进程pid
pid = 0
,发送信号sig
给进程所在进程组的每个进程pid < 0
,发送信号sig
给进程组abs(pid)
。5)用alarm
函数发送信号
进程可以通过调用alarm
函数向它自己SIGALRM
信号。
#include
unsigned int alarm(unsigned int secs);
//返回:前一次alarm剩余的秒数,若前一次没有设置alarm,则返回 0。
alarm
函数安排内核在secs
秒内发送一个SIGALRM
信号给调用进程。如果secs=0
那么不会调度alarm
,当然不会发送SIGALRM
信号。在任何情况,对alarm
的调用会取消待处理(pending)的alarm
,并且会返回被取消的alarm还剩余多少秒结束,如果没有pending的话,返回0
。
信号的处理时机是在从内核态切换到用户态时,会执行do_signal()函数来处理信号。
当内核把进程p从内核模式切换到用户模式时,它会检查进程p的未被阻塞的待处理信号集合。
每个信号都有预定义的默认行为,是下面的一种:
进程可以通过使用signal
函数修改和信号相关联的默认行为,但是SIGSTOP(19)
和SIGKILL(9)
是不能被修改的。
#include
typedef void (*sighandler_t)(int);
// 返回:成功为指向前次处理程序的指针,出错SIG_ERR.
sighandler_t signal(int signum,sighandler_t handler);
signal
函数通过下列三种方式之一改变和信号signum
相关联的行为:
handler
是SIG_IGN
,那么忽略类型为signum
的信号。handler
是SIG_DFL
,那么类型为signum
的信号恢复为默认行为。handler
就是用户定义的函数地址,这个函数称为信号处理程序,只要进程接收到一个类型为signum
的信号,就会调用handler
。当信号处理程序执行它的return
语句后,控制通常传递回控制流中进程被信号接收中断位置处的指令。
Linux
提供了信号阻塞的隐式和显式机制:
s
,然后运行处理程序S
,此时如果再发送另一个相同类型的信号s
给该进程,在处理程序S
执行完后,第二个信号s
会变成待处理而没有被接收。sigprocmask
函数和它的辅助函数,明确的阻塞和解除阻塞选定的信号。#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
sigprocmask
函数改变当前已阻塞信号的集合(4.1节描述的blocked
位向量),具体行为依赖how
值,如果oldset
非空,block
位向量以前的值会保存到oldset
中:
SIG_BLOCK
:添加set
中的信号到blocked
中(blocked = blocked | set
)。SIG_UNBLOCK
: 从blocked
删除set
中的信号(blocked = blocked &~set
)。SIG_SETMASK
: blocked = set
。还有以下函数操作set集合:
#include
// 初始化set为空集合
int sigemptyset(sigset_t *set);
// 把每个信号全部填入到set中
int sigfillset(sigset_t *set);
// 把signum添加到set
int sigaddset(sigset_t *set,int signum);
// 从set中删除signum
int sigdelset(sigset_t *set,int signum);
/*以上函数,返回:成功0,出错-1*/
//判断:若signum是set的成员,返回1,不是返回0,出错返回-1。
int sigismember(const sigset_t *set,int signum);
【链接】信号的阻塞与未决、解除信号阻塞需注意的问题
后面如果用到这一块的内容再做补充!也可以参考:https://www.cnblogs.com/zy691357966/p/5480537.html
后面如果用到这一块的内容再做补充!
后面如果用到这一块的内容再做补充!
C语言提供一种用户级异常控制流形式,称为非本地跳转(nonlocal jump),它将控制直接从一个函数转移到另一个当前正在执行的函数,不需要经过正常的调用-返回序列。非本地跳转是通过setjmp
和longjmp
函数来提供:
setjmp
函数在env
缓冲区保存当前调用环境,以供后面longjmp
使用,并返回0
。
#include
int setjmp(jmp_buf env);
//参数savesigs若为非0则代表搁置的信号集合也会一块保存
int sigsetjmp(sigjmp_buf env,int savesigs);//信号处理程序使用
longjmp
函数从env
缓冲区中恢复调用环境,然后触发一个从最近一次初始化env
的setjmp
调用的返回,然后从setjmp
返回(longjmp
不返回),并带有非零的返回值retval
。
#include
void longjmp(jmp_buf env,int retval);
void siglongjmp(sigjmp_buf env,int retval);//信号处理程序使用
setjmp
函数只被调用一次,但是返回多次,一次是第一次调用setjmp
保存当前调用函数,返回0
;一次是为每个相应的longjmp调用,返回retval
。而longjmp
不返回。
C++和Java提供的异常机制是较高层次的,是C语言setjmp
和longjmp
函数的更加结构化的版本。你可以把try
语句中的catch
字句看作setjmp
函数,相似地,throw
语句就类似与longjmp
函数。