计算机系统——异常与信号

一、异常控制流
  从给处理器加电开始,处理器只是简单地读取和执行一个指令序列,这个指令序列就是处理器的控制流。现代系统通过使控制流发生突变,而对程序状态和系统状态的变化做出反应,称为异常控制流

1.1 异常
  异常使指为了响应某个事件而将控制转移给操作系统内核的情况,其中内核指操作系统常驻内存的部分。异常的处理需要软件与硬件的配合,当处理器检测到事件时,会通过异常表进行处理。
  异常表是一张跳转表,系统中可能的每一种类型的异常都分配了唯一的非负整数异常号,异常表的表目k包含了k类异常的处理程序的地址。当处理器检测并确定了事件的类型,即其异常号,便执行间接调用过程,通过表目跳转到响应的异常处理程序。异常处理类似于过程调用,但是:
  -处理器将可能的返回地址压栈,而返回地址可能是当前指令地址或下一指令地址,这取决于异常事件;
  -处理器会将额外的处理器状态也压栈。

1.2 中断
  异常根据发生的时钟周期可以分为同步异常异步异常中断【Interrupts】是异步发生的,由外部I/O设备产生,处理终端的程序称为中断处理程序。中断的处理步骤为:
  -执行当前指令的同时,中断引脚电压变高;
  -完成当前指令,并将控制转移给中断处理程序;
  -中断处理程序运行;
  -中断处理程序完成,将控制转移给调用程序,返回地址为下一指令。
外部I/O设备产生中断的情况包括键入、网络数据到达、磁盘数据到达等。其余的异常均是由于当前指令同步引起,这类指令称为故障指令

1.3 陷阱
  陷阱【Traps】是一种人为设置的异常,系统调用提供了用户程序与内核之间的接口。陷阱的处理步骤为:
  -应用程序调用系统调用接口syscall();
  -将控制转移给陷阱处理程序;
  -陷阱处理程序运行;
  -陷阱处理程序完成,将控制转移给调用程序,返回地址为下一指令。
  每一个x86-64系统调用都有一个唯一的ID号,用于各种系统操作。例如打开文件的ID为2,用户需要调用函数

open(const char *filename, int options);

其部分二进制编码与汇编代码为

00000000000e5d70 <__open>:
	...
	e5d79: b8 02 00 00 00 00	mov $0x2, %eax
	e5d7e: 0f 05				syscall
	e5d80: 48 3d 01 f0 ff ff	cmp $0xfffffffffffff001, %rax
	...
	e5dfa: c3					ret

其中,%rax储存了系统调用号,即0x2。

1.4 故障
  故障【Faults】是意外发生但可能被修复的异常,例如缺页、浮点异常等。故障的处理步骤为:
  -当前指令导致了一个故障;
  -将控制转移给故障处理程序;
  -陷阱处理程序运行;
  -陷阱处理程序完成,若修复,则将控制转移给调用程序,返回地址为当前指令,重新执行;否则返回内核的abort程序终止引起故障的应用程序。
  考虑在页大小为4KB的系统中的如下代码

int a[1000];
int x;
int main(){
	a[10] = 1;
	a[1000] = 3;
	a[10000] = 4;
	return 0;
}

其部分二进制编码与汇编代码为

8048300: c7 05 28 90 04 08 01 00 00 00		movl $0x1, 0x8049028
8048309: c7 05 a0 9f 04 08 03 00 00 00		movl $0x3, 0x8049fa0
8048313: c7 05 40 2c 05 08 04 00 00 00		movl $0x4, 0x8052c40

其中:
  -由于页大小为4KB,即0x1000个字节,故该指令的页的起始地址为0x8048000,因此,在取指阶段不会引起缺页;
  -在0x8048300地址处的代码访问了0x8049028的数据,而其位于起始地址为0x8049000的页中,从而引起了缺页故障,系统将处理缺页,并在结束后重新执行该指令;
  -在0x8048309地址处的代码访问了0x8049fa0的数据,其位于起始地址为0x8049000的页中,由于是第二次访问,故不会引起缺页故障,但实际上,该地址存储的已经不属于该数组,有一种可能的情况就是该地址是x的地址,而将x赋值为了3;
  -在0x8048313地址处的代码访问了0x52c40的数据,其已经偏离了数组首地址9个页面,可能超出了可读写区的范围,而发生保护违例,页故障处理程序使操作系统向用户进程发送不尝试恢复的信号,用户进程则以段错误【srgmentation fault】退出。

1.5 中止
  中止【Aborts】是意外发生的不可恢复的致命错误造成的异常,如非法指令等。中止的处理步骤为
  -当前指令发生可致命错误;
  -将控制转移给中止处理程序;
  -中止处理程序运行;
  -中止处理程序完成,返回内核的abort程序终止引起中止的应用程序。


二、进程
  进程【process】是一个执行中程序【program】的实例。

2.1 用户进程
  进程是操作系统对CPU执行的程序的运行过程的一种抽象。进程有自己的生命周期,由于任务启动而创建,随着任务完成或中止而消亡,所占用的资源也随着进程的中止而释放。
  程序是代码与数据的集合,而代码是机器指令序列,因而程序是一个静态概念;而进程是一个程序的一次运行活动,具有动态含义。一个可执行目标文件,即程序可以执行多次,即一个程序可能对应不同的进程。
  操作系统以外的任务属于用户的任务,而计算机处理用户任务由进程完成,为了强调进程完成的是用户的任务,通常称进程为用户进程
  进程提供给应用程序两个关键的抽象:
  -逻辑控制流,每个程序似乎独占处理器;
  -私有空间地址,每个程序似乎独占内存系统。

2.2 逻辑控制流
  即使系统中通常有许多其他程序在运行,进程也可以向每个程序提供一种假象,每个进程似乎在独占CPU。在调试器单步执行程序时,可以看到一系列的程序计数器的值,指向程序的可执行目标文件或共享对象中的指令,这个PC值的序列称为逻辑控制流
  逻辑控制流表明了进程是轮流使用处理器的。每个进程执行流的一部分,在使用结束后,将寄存器状态保存到内存,然后被暂时挂起。同时,PC切换控制的地址,加载保存在内存的寄存器状态,调度下一个进程执行。
  多核处理器则具有多个CPU,在同一时间内执行多个进程,并且共享主存与缓存,并通过内核负责处理器的调度,核执行独立的进程。
  每个进程是一个逻辑控制流,如果两个逻辑控制流在时间上有重叠,则称两个流为并发流。虽然并发进程的控制流在物理上不相交,但是可以认为并发进程就是并行运行的。

2.3 上下文切换
  进程由常驻内存的操作系统代码块,即内核管理。内核为每一个进程维护一个上下文【context】,即内核重新启动一个被挂起的进程所需的状态。操作系统通过处理器调度让处理器轮流执行多个进程,实现不同进程中指令交替执行的机制称为进程的上下文切换
  处理器调度等事件会引起用户进程在正常执行时被打断,形成异常控制流,而上下文切换机制则很好的解决了这类异常控制流,实现了一个进程安全切换到另一个进程执行的过程。
上下文

  进程的代码数据等物理实体与支持进程运行的环境称为进程上下文;由进程的程序块、数据块、运行时堆、用户栈等组成的用户空间信息称为用户级上下文;由进程标识信息、现场信息、控制信息和系统内核栈等组成的内核空间信息称为系统级上下文;用户级上下文的地址空间与系统级上下文的地址空间构成了进程的整个存储器映像,形如
储存其映像
2.4 系统级错误处理
  当Linux系统级函数遇到错误时,通常返回-1并设置全局整数变量errno来标记出错原因。原则上讲,除了少数返回未空的函数,每个系统级函数的返回状态必须要进行检查。例如在调用fork()时检查错误的代码

if ((pdi = fork()) < 0){
	fprintf(stderr, "fork error: %s \n", stderror(errno));
	exit(0);
}

其中,stderror()返回描述与erron值相关联的错误的文本串,这似乎使得代码臃肿且难以读懂。可以通过错误报告函数,形如

void unix_error(char *msg){
	fprintf(stderr, "%s: %s \n", msg, stderror(errno));
	exit(0);	
}

从而使检查系统级函数错误变得简洁,形如

if ((pdi = fork()) < 0){
	unix_error("fork error");
}

而使用错误处理包装函数,可以进一步简化代码,对于一个给定的基本函数foo,定义一个具有相同参数的包装函数Foo,包装函数用于调用基本函数,检查错误,进行终止,例如fork()函数的错误处理包装函数为

pid_t Fork(void){
	pid_t pid;
	if ((pdi = fork()) < 0){
		unix_error("Fork error");
	}
	return pid;
}

那么对fork()函数的调用仅需

pid = Fork();

2.5 进程控制
  Unix提供了大量从C中操作进程的系统调用。

  每个进程都有一个唯一的非零正数进程ID【process ID,PID】。使用getpid函数返回调用进程的PID,使用getppid函数返回父进程的PID,形如

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppdi(void);

函数将会返回类型为pid_t的正整数的调用者的PID或调用者的父进程的PID。在Linux的types.h中,pid_t定义为int。

  进程可以被认为处于如下状态之一:
  -运行,进程在CPU执行或挂起等待内核调度;
  -停止,由于某些信号被挂起且不会被调度,直到受到某些信号;
  -终止,进程由于信号终止、执行结束、调用终止函数而永远的停止。
其中,中止函数形如

#include <stdlib.h>

void exit(int status);

调用进程将以status退出状态来终止程序,由于执行后程序便中止了,故exit()函数不返回。

  父进程可以通过调用fork()函数创建一个新的运行的子进程,形如

#include <sys/types.h>
#include <unistd.h>

pid_t fock(void);

子进程将执行父进程代码中的fock()及其后续代码,并且该函数在父进程中返回子进程的PID,而在子进程中返回0,或在错误时返回-1。在fock()执行成功时,其被调用了一次,但返回了两次。子进程几乎但不完全与父进程相同:
  -子进程将得到与父进程虚拟地址空间相同但又是独立的一份副本;
  -子进程将得到与父进程任何打开文件描述符相同的副本。
  考虑如下代码

int main(){
	pid_t pid;
	int x = 1;
	pid = Fork();
	
	/* Child */
	if (pid == 0){
		printf("child: x = %d\n", ++x);
		exit(0);
	}

	/* Parent */
	printf("parent: x = %d\n", --x);
	exit(0);
}

运行可以得到

parent: x = 0
child: x = 2

该程序有如下微妙的特点:
  -父进程与子进程并发运行,内核以任意方式交替执行,并不能确定父进程还是子进程先运算输出;
  -如果在fork()返回后立刻暂停两个进程,那么两个进程的地址空间完全相同;然而,由于两个进程是独立的,其私有的地址空间也是独立的,父进程与子进程在之后任何状态的改变都是独立的;
  -父进程与子进程输出在同一窗口,因为子进程继承了父进程所有打开的文件,两者输出的指向是相同的。

  当进程终止时,内核并不是立即把它从系统中清楚,相反的,其会保持在一种已经终止的状态,称为僵死进程,直到其父进程回收。当父进程回收已经终止的子进程,内核会将子进程的退出状态传递给父进程,然后删除已中止的进程,从此时开始,该进程就不存在了。
  如果父进程没有回收其僵死子进程就终止了,内核会安排init进程回收,其中init进程的PID为1,在系统启动时创建且不会终止,是所有进程的祖先。
  长时间运行的程序应当主动回收僵死子进程,因为僵死子进程虽然没有运行,但是仍然消耗系统的内存资源。一个进程可以调用waitpid()函数来将调用函数挂起,等待子进程终止或停止,形如

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *statups, int options);

其中,如果pid置为-1,则父进程会等待所有子进程中任一进程的终止或停止。其在时返回子进程的pid,或在错误时返回0或-1,这取决于失败的类型。其有一个简单版本wait(),形如

pid_t wait(int *statups);

其会挂起当前进程的执行,直到一个子进程终止,并在成功时返回子进程的pid。若*statups不指向空,则该指针指向的整数型会写入关于终止原因和退出状态的信息。

  execve函数会在当前进程的上下文中加载并运行一个新程序,形如

int execve(const char *filename, const char *argv[], const char *envp[]);

其中:
  -filename指向可执行目标文件或脚本;
  -argv为参数列表,并且惯例的,首个元素为filename;
  -envp为环境变量列表。
其会覆盖当前进程的代码、数据与栈,保留PID,继承已经打开的文件与信号。除非在调用过程中出现错误会返回-1,否则将不再返回。在execve()加载了filename之后,就会调用启动代码,设置栈,并将控制传递给主函数,形如

int main(int argc, const char *argv[], const char *envp[]);

在主函数执行时,用户栈的组织结构如下
启动栈
其中,argv[0]指向命令行字符串,envp[0]指向环境变量字符串。考虑在子进程中用当前环境下执行

linux> /bin/ls -lt /usr/include

其等价于在当前进程使用

char argv_0[] = {"/bin/ls"};
char argv_1[] = {"-lt"};
char argv_2[] = {"/usr/include"};
char argv_3[] = {"\0"};
const char *argv[] = {argv_0, argv_1, argv_2, argv_3};
const char *environ[] = {/* envir define */};
execve(argv[0], argv, environ);

三、信号
  信号允许进程和内核中断其他进程。一个信号就是一条消息,其通知进程系统中发生了某个类型的事件。

3.1 信号
  信号类似于异常和中断,其从内核发送到一个进程。信号的类型通过正整数ID来标识,且仅包含ID和目的进程两个信息。典型的有:
  -SIGINT,ID为2,默认行为终止,来自键盘的中断;
  -SIGKILL,ID为9,默认行为终止,用于杀死程序
  -SIGSEGV,ID为11,默认行为终止,由于无效的内存引用,即段故障;
  -SIGALRM,ID为14,默认行为终止,来自于alarm函数的定时器信号;
  -SIGCHLD,ID为17,默认行为忽略,来自于子进程的停止或终止。
内核发送信号的形式为更新目的进程上下文中的某个状态。发送信号可能来源于:
  -内核检测到一个系统事件;
  -一个进程调用了kill,显式的要求内核发送一个信号到目的进程,包括该进程本身。
而当目的进程被内核强迫以某种方式对信号的发送做出反应时,就称为接收信号。进程可以通过如下方式响应信号:
  -忽略信号;
  -终止进程;
  -调用信号处理程序捕获信号,其类似于响应异步中断。

  一个发出而没有被接收的信号叫做待处理信号。在任何时刻,在一个进程中,一个种类至多有一个待处理信号,并且信号不会排队等待,如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待,而只是被简单的丢弃。一个待处理信号最多只能被接收一次。
  内核为每个进程维护着待处理位向量pending,其是待处理信号的集合,在传送了类型为k的信号时,内核会设置pending中的第k位。

  一个进程可以选择阻塞接受某种信号。阻塞的信号可以被发送,但是不会被接收,直到进程取消了对该信号的阻塞。
  内核位每个进程维护着阻塞维向量blocked,其是被阻塞信号的集合,通过sigprocmask()设置和清除,也称为信号掩码。

3.2 信号的发送
  Unix系统提供了向进程发送信号的机制,基于进程组这个概念。每个进程都只属于一个进程组,进程组由一个正整数进程组ID标识。使用getpgrp()返回当前进程的进程组ID,形如

#include <unistd.h>
pid_t getpgrp(void);

默认的,子进程和其父进程同属于一个进程组。一个进程可以通过setpgid()改变进程的进程组,形如

#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);

其将进程pid的进程组改为pgid,且pid为0时表示调用者进程的pid;如果pgid为0,那么就将pid作为pgid,且将进程pid加入gpid为pid的进程组中,例如pid为15213的进程调用了setpgid(0, 0),那么该进程被加入gpid为15213的进程组中。该函数在成功时返回0,失败时返回-1。

  /bin/kill程序可以向另外的进程或进程组发送任意的信号,如

linux> /bin/kill -9 15213

会将信号9,即SIGKILL发送给进程15213;或者使用负数代表进程组,如

linux> /bin/kill -9 -15213

会将SIGKILL发送给进程组15213的全部进程。

  Unix shell使用作业的抽象概念来表示为对一条命令行求值而创建的进程。在任何时刻,至多存在一个前台作业和多个后台作业。在键入ctrl-c或ctrl-z时,分别会导致内核发送一个SIGINT、SIGTSTP信号,默认情况下,结果分别是终止前台作业与挂起前台作业。

  进程通过调用kill()函数发送信号给其他进程,包括该进程本身,形如

#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);

其中,pid指示目的进程,在为正数时指向进程pid;在为零时指向调用进程所在进程组的所有进程,包括进程本身;在为负时指向进程组-pid中的每个进程。sig表示了不同的信号。该函数在成功时返回0,在失败时返回1。

3.3 信号的接收
  当内核将进程从内核模式切换到用户模式时,内核将控制传递给进程之前,会检查phd = pengding & ~blocked,即进程未被阻塞的待处理信号的集合。当集合为空时,控制将传递给进程的逻辑控制流中的下一指令;否则,选择集合中最小的非零位,强制进程接收该信号,处罚信号对进程采取某种行为,并在信号处理后,若进程存在,则控制将传递给进程的逻辑控制流中的下一指令。
  每个信号类型都有一个预定义的默认行为,是下面中的一种:
  -进程终止;
  -进程终止并转储内存,即将代码和数据内存段的映像写到磁盘上;
  -进程挂起,直到被SIGCONT信号重启;
  -进程忽略该信号。
进程可以通过使用signal函数修改和信号相关联的默认行为,除了SIGSTOP与SIGKILL,形如

#include <signal.h>
typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, signhandler_t handler);

其中,handler的取值与功能为
  -SIG_IGN,忽略类型为signum的信号;
  -SIG_DFL,将类型未signum的信号行为恢复为默认;
  -否则需要一个用户定义的函数的地址,该函数称为信号处理程序
该函数在成功时返回指向信号处理程序的指针,在出错时返回SIG_ERR。

  使用信号处理程序改变信号的默认行为,称为设置信号处理程序;调用信号处理程序称为捕获信号;执行信号处理程序称为处理信号。在处理信号返回时,控制将传递给控制流中被信号接收所中断的指令处。
  信号处理程序是与主程序同时运行的独立逻辑流,而不是进程。其本身可以被其他信号处理程序中断。

3.4 信号的阻塞和解除
  信号的阻塞提供隐式阻塞机制,即内核默认阻塞与当前正在处理信号类型相同的待处理信号;以及显式阻塞,通过调用函数sigpromask()函数,形如

#include <signal.h>

int sigpromask(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);
int sigismember(const sigset_t *set, int signum);

其中:
  -sigpromask()用于改变阻塞的信号集合,成功时返回0,否则返回-1;
  -sigemptyset()初始化阻塞集合为空,成功时返回0,否则返回-1;
  -sigfillset()将所有信号类型加入集合,成功时返回0,否则返回-1;
  -sigaddset()添加阻塞的信号类型,成功时返回0,否则返回-1;
  -sigdelset()删除阻塞的信号类型,成功时返回0,否则返回-1;
  -sigismember()判断信号是否在阻塞集合中,是则返回1,不是则返回0,不成功则返回-1。

3.5 非本地跳转
  C提供了一个用户级异常控制流形式,称为本地跳转,将控制直接从一个函数转移到另一个正在执行的函数,而不需要经过正常的调用返回序列。其实现形如

#include <setjmp.h>

int setjmp(jum_buf env);

其中,在第一次调用时,setjmp()返回0,且不能赋值给变量。setjmp()在env缓冲区保存当前的调用环境,并使用longjmp函数从env缓冲区中恢复调用环境,然后触发一个从最近一次初始化env的setjmp调用的返回,形如

#include <setjmp.h>

int longjmp(jum_buf env, int retval);

setjmp()与longjmp()的关系似乎令人疑惑,setjmp()只调用一次,但返回多次,分别是调用setjump()与响应的longjmp()调用;而longjmp()被调用一次却不返回。

  非本地跳转的一个重要应用是允许从一个深层嵌套的函数调用中立即返回,例如发现错误情况,就可以直接返回到一个普通的本地化的错误处理程序,而不是费力的反复返回来解开调用栈。
  为了理解非本地跳转的该应用,考虑如下的代码:

#include "csapp.h"

jmp_buf buf;
int error1 = 0;
int error2 = 1;

void foo(void);
void var(void);

int main(){
	swtich(setjmp(buf)){
	case 0:
		foo();
		break;
	case 1:
		printf("Detect an error1 condition in foo\n");
		break;
	case 2:
		printf("Detect an error2 condition in foo\n");
		break;
	default:
		printf("Unknown error condition in foo\n");
	}
	exit(0);
}

void foo(void){
	if (error1)
		longjmp(buf, 1);
	bar();
}

void bar(void){
	if (error2)
		longjmp(buf, 2);
}

其在进入main()后的开关语句中,setjmp()保存了环境,并返回了0,执行情况0,即调用foo(),foo()在正确的情况下调用bar(),完成调用,执行exit(0)关闭进程。如果调用foo()的过程中出现error1,则会跳转到开关语句,setjmp()返回1,执行情况1,报告error1的错误;如果调用bar()的过程出现error2,则会跳转到开关语句,setjmp()返回2,执行情况2,报告error2的错误。

  非本地跳转的另一个重要应用是使信号处理程序分支到一个特殊的代码位置,而不返回到被信号到达中断了的指令的位置。例如程序通过信号和非本地跳转重新定义中断的动作,使得进程不立即终止,而是回到主程序的入口,实现软重启。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值