当前位置: 首页 > 工具软件 > ECF > 使用案例 >

ECF 计算机系统异常控制流详解 (一)

於乐
2023-12-01

首先讨论本地跳转与非本地跳转

本地跳转:以C语言为例,从某个函数通过goto跳转函数跳转到另一个函数执行,叫做本地跳转。

非本地跳转:例如使用setjmp()与longjmp()函数进行异常控制,保存当前函数状态并跳转到另一进行函数的过程,叫做非本地跳转。

非本地跳转是C语言中处理异常时更为优美的方式。

然后我们来理解一下异常

异常实际上就是异常控制流的一部分,由计算机硬件与计算机系统共同负责完成。即控制流中的突变,用来响应处理器状态的某些变化。

控制流我们该怎么理解呢?

程序计数器负责存储下一个指令的地址,我们假设有一系列指令地址:a0,a1…ak,每一个从ai过渡到ai+1的过程叫做控制转移,而这样的一个控制转移序列就叫做处理器的控制流。

而控制流的突变自然就叫做异常控制流(ECF)。

也是我们今天讨论的主题。

异常处理流程我们也可以很简单地去理解:

发生与当前指令有关或无关的处理器状态变化 --> 处理器检测到事件发生 --> 通过异常表进行间接过程调用到异常处理程序(操作系统子程序)进行处理 --> 异常处理程序处理完成并返回状态。

返回状态可能是返回到异常前正在处理的指令继续执行,可能返回之前指令的下一条指令继续执行,也可能直接终止该程序。

这些操作是由异常处理子程序完成的。

1.1 异常的具体处理
接下来我们讲一下异常的具体处理流程。

异常的处理是需要硬件与软件的紧密结合的,我们可以简单理解为每一个异常其实都对应了一个非负整数表示的异常号。这些异常号里有处理器设计者分配的关于零除、缺页等异常号与内核设计者分配的关于系统调用与来自外部I/O设备的信号。

这个抽象的过程可以理解为:

1、计算机启动时系统分配并初始化一张异常表

异常表是一张跳转表,从0开始,表目K则包含处理异常K的处理程序代码地址(n-1对应n-1)。

2、处理器确定异常号并触发异常

在系统执行某个程序时若处理器检测到发生了一个事件,处理器将首先确定异常号,随后触发异常。

触发异常的过程是间接过程调用。即要通过前面处理器确定的异常号K去跳转到相应处理程序。异常表的起始地址存储在一个叫做异常表基址寄存器的特殊寄存器当中。

异常K条目的地址:异常表基址寄存器值+4*异常号

3、异常处理结束

过程调用时,在跳转到处理程序之前,处理器会将返回地址与特殊状态信息压入栈中保存。返回地址可以是当前指令或下一条指令地址。

同时如果控制从用户程序转移到内核,所有这些项目都被压到内核栈中,而不是压到用户栈中。

异常处理程序运行在内核模式下,对所有的系统资源都有完全的访问权限。

硬件与软件的合作体现在一旦硬件触发异常,就会由异常处理程序在软件中进行处理。

1.2 异常的类别

异常主要有四大类别:中断、陷阱、故障、终止。

1、中断

中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。

I/O设备通过向处理器的一个引脚发送信号,并将异常号放到系统总线上,来触发中断。中断处理的完整流程:首先在当前指令执行过程中处理器检测到中断引脚电压增高,于是在当前指令执行完毕后将控制传递给处理程序,此后中断处理程序运行,在运行结束后处理程序将返回到起始程序控制流的下一条指令。

剩下的三类都是同步发生的,我们都叫做故障指令。

2、陷阱

陷阱与系统调用息息相关

实际上我们可以理解诸如fork()/read()等需要访问内核的关键函数在机器级代码上都用到了类似 syscall n 的指令。应用程序执行一次系统调用,控制将传递给处理程序,陷阱处理程序运行后将返回到syscall之后的指令。

系统调用与普通的函数调用截然不同。既体现在运行模式上普通函数是用户态而系统调用是内核态,也体现在存储上普通函数是函数栈而系统调用可以访问定义或存储在内核中的栈。

3、故障

故障是由程序运行时的错误引起。根据程序运行时引起故障的错误类型,最终的处理结果只有两种可能:处理成功或失败。

若当前指令出现故障,控制将转移给处理程序,随后故障处理程序开始运行。若能正常处理,则会重新执行当前指令;若不能恢复正常,处理程序将返回到内核中的abort 例程,该例程会终止引起故障的程序。

4、终止

终止是不可恢复的致命错误造成的后果,通常是一些硬件错误,如DRAM被损坏。终止处理程序永远将控制返回给abort例程,从而终止掉对应应用程序。

1.3 进程

进程可以理解为运行程序的实例,程序运行在进程的上下文中。

进程提供了一种每个程序独立使用处理器与内存的假象。

1、逻辑控制流

进程可以使每个程序都好像在独立地使用处理器,在于处理器逻辑控制流的并发。单独调试某个程序时,PC值的序列就叫做该程序的逻辑控制流。

2、并发流

一个逻辑流的时间与另一个逻辑流的时间重合,就称之为并发流

这两个流也被称为并发地运行。

并发(Concurrency):多个流同时执行的现象称为并发。

一个进程和其他进程轮流运行的概念称为多任务。

多任务同时叫做时间分片,一个进程执行它的控制流的每一个时间段叫做 时间片

注意并发与并行的区别,并行是特殊的并发,且只要同时进行的控制流就是并发。

3、私有地址空间

进程为每个程序提供私有的虚拟地址空间,且不能被其他进程访问。

地址空间的组织结构从上到下依次为:保留给内核、动态栈、共享库的内存区域、运行时堆、读写段、只读段。

4、两种模式

这里包含了用户模式与内核模式,是通过某个控制寄存器中的模式位去实现控制的。

之所以设置两种模式,是为了保证地址空间的私有化与安全问题。用户模式只能访问特定的地址空间内存,而内核模式可以执行任意操作并访问任意内存区域的数据。

5、上下文切换

操作系统多任务的实现是通过上下文切换来实现的。

操作系统为每个进程维护一个上下文,上下文由一些对象的值组成,包括但不限于描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的文件表。

调度(scheduling) 是指内核决定抢占当前进程,并重新开始一个先前被抢占了的进程。这是由内核中成为调度器(scheduler)的代码进行处理的。而将控制从被抢占进程切换到被调度进程的机制叫做上下文切换。

磁盘文件读取造成的阻塞、显式sleep函数调用、中断等都可以触发上下文切换。

1.4 进程控制

C语言中有大量可以操作进程的系统调用,这一节我们将对其进行解析。

首先我们引入两个文件:

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

这里有两个常见函数:getpid()与getppid()

pid_t getpid(void);
pid_t getppid(void);

getpid()返回调用进程的PID,而getppid则返回父进程的PID。

后面的操作需要我们理解程序运行的三种状态:运行、停止、终止。

运行状态时程序要么在CPU上运行,要么在等待被执行被最终会被内核调度。

停止状态是因为进程收到SIGSTOP/SIGTSTOP/SIGTTIN/SIGTTOU等信号,此时进程被挂起并不会被调度,直到再次受到SIGCONT信号好再次运行。

终止状态时进程就永远的停止了。一般进程终止的原因是收到终止信号、从主程序返回(return INT;)、调用了exit函数退出。

exit:

#include<stdlib.h>

void exit(int status);

父进程可以通过fork()函数创建一个子进程:

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

pid_t fork(void);

子进程与父进程几乎相同,但也有一些不同之处。子进程会得到一份与父进程完全相同但又同时独立的一份副本,包括代码、数据段、堆、共享库以及用户栈。子进程还可以读写任何父进程已打开的文件。子、父进程最大的区别是PID的不同。

这里要特别注意fork()函数的返回形式。 fork()函数有一个很著名的特点就是调用一次但返回两次。在父进程中返回子进程的PID号,但在子进程中返回为0。这也用来显示的区分程序是在子进程还是在父进程中执行,因为程序的PID总是非0。

下面我们结合一段简单的程序来理解这个过程:

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

最后的输出也非常明显:

parent: x=-1
child: x=1

这里程序虽然简单,但反映了子、父进程的多个特点。

首先是相同但完全独立的地址空间。 if 语句内执行的是子进程的代码,我们可以看到输出的x=1,证明未进行自增操作时原本的x=0是被子进程继承了的,但父进程输出的x=-1,证明子、父进程中都初始化了x=0,但对x的操作是完全独立的。

Fork()函数调用一次返回两次。 这里我们可以明显看到Fork函数在子进程中是返回0的,Fork 函数一次返回到父进程,一次返回到子进程。

并发执行。 可以看到父、子进程都在屏幕中进行了输出,是并发执行的。至于先后顺序没有明确的限制,可能在其他系统上的输出顺序又与此处不同。但两者一定是并发执行的。

共享文件。 因为父进程调用Fork()时,stdout是打开的,所以子进程的输出也会同样显示在屏幕上。

理解嵌套使用fork函数的程序是需要谨慎的。这里可以利用拓扑排序来表示程序语句的一个可行的全序排列,并利用进程图画出程序运行的过程。

练习题 8.2 考虑下面的程序:

int main(){
	int x=1;
	if(Fork()==0){
		printf("p1: x=%d\n",++x);
	}
	printf("p2: x=%d\n",--x);
	exit(0);

A.子进程的输出是什么?

p1: x=2
p2: x=1

B.父进程的输出是什么?

p2: x=0

注意这里的Fork调用方式,且子进程并没有退出。

下面我们开始理解子进程的回收过程

首先需要理解什么是僵死进程(Zombie)。

当一个进程由于某种原因终止时,内核并不会马上把它从系统中清楚,而是会被保存在一种已终止的状态中,直到被它的父进程回收(reaped)。当父进程成功回收已终止的子进程时,子进程的退出状态将由内核传递给父进程,然后已终止的进程将被真正抛弃。

这里一个已经终止但仍未被回收的进程被称为僵死进程

如果进程在还未被回收时其父进程便终止了,此时内核会安排init进程作为该子进程新的父进程。

init进程的PID为1,是所有进程的父进程。

可以使用wait()或waitpid()函数去回收子进程。

这里我们讲解一下waitpid()函数。

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

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

当options=0,即默认情况下,若其等待集合(wait set)中的一个子进程终止,或等待集合中的一个进程在刚被调用时就已经终止了,那么waitpid立即返回已终止进程的pid。此时,已终止的子进程已经被回收,内核会将其从系统中完全清除掉。

我们从pid、statusp、options三个参数来对这个函数进行分析讲解。

首先是pid。 如果pid>0,即我们传入了某个子进程的pid号,那么等待集合就是一个单独的子进程;如果我们传入-1,那么等待集合就由父进程的所有子进程组成。

然后是options。 该参数默认情况下是0,具体逻辑在上文已讲过。这里再介绍一下几个参数:WNOHANG、WUNTRACED、WCONTINUED。

WNOHANG: 如果等待集合中的任何子进程都还没有终止,那么waitpid将立即返回。

WUNTRACED: 挂起调用进程的执行,直到等待集合中的一个进程变成已终止或被停止。该选项与默认选项的不同在于该选项将返回终止进程的PID,便于检查。

WCONTINUED: 挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或一个被停止的进程收到SIGCONT信号开始重新执行。

可以选择组合参数。 WNOHANG|WUNTRACED:如果都未被终止,立即返回0;如果有一个进程被终止,则返回该进程的PID。

最后是statussp参数。 status是statussp指向的值。

如果statussp是非空的,那么waitpid就会在status中放上关于导致返回的子进程的状态信息。

此时将体现子进程的终止原因与状态。可自行参考开发文档中定义的几个宏。这几个宏被定义在了<sys/wait.h>中,而errno需包含<errno.h>。

最后如果调用进程中没有子进程呢?此时的waitpid将返回-1,如果waitpid被一个信号中断,那么它也将返回-1。两者的不同在于前者会将errno设置为ECHILD,后者会将errno设置为EINTR。

练习题 8.3 写出下面程序所有可能的输出序列:

int main(){
	if(Fork()==0){
		printf("a");
		fflush(stdout);
	}
	else{
		printf("b");
		fflush(stdout);
		waitpid(-1,NULL,0);
	}
	printf("c");
	fflush(stdout);
	exit(0);

这道题需要真正理解进程图的拓扑排序概念,即最后一次c的输出不可能在a、b之前(bcac、ccab等是不可能的)。且由于子进程未终止,c将输出两次,所以最终的答案是:abcc、bacc 、acbc。

至于wait函数,其实是waitpid的简化版本,调用wait(&status)等价于调用waitpid(-1,&status,0)。

下面这段程序有助于理解waitpid:

#include "all.h"
#define N 2

int main(){
	int status,i;
	pid_t pid;
	for(i=0;i<N;i++)
		if((pid=Fork())==0)
			exit(100+i);
	while((pid=waitpid(-1,&status,0))>0){
		if(WIFEXITED(status))
			printf("%d,%d\n",pid,WEIXTSTATUS(status));
		else
			printf("%d\n",pid);
	}

	if(errno !=ECHILD)
		unix_error("waitpid error");

	exix(0);

这段代码的输出是:

22966,100
22967,101

这里创建了两个进程,其中pid是进程号,status会返回exit内的数字,表示退出状态。也可以选择将pid创建为数组,保存子进程的pid,在回收过程中就能指定子进程pid,从而按顺序进行回收。

练习题 8.4 考虑下面的程序:

int main(){
	int status;
	pid_t pid;
	printf("Hello\n");
	pid=Fork();
	printf("%d\n",!pid);
	if(pid!=0){
		if(waitpid(-1,&status,0)>0){
			if(WIFEXITED(status)!=0)
				printf("%d\n",WEXITSTATUS(status));
			}
	}
	printf("Bye\n");
	exit(2);

A.这个程序会产生多少输出行?

6行。 这里非常简单,读者自行体会。注意exit()函数是最后才调用的。

B.这些输出行的一种可能的顺序是什么?

参考前面讲到的进程图的拓扑排序。 一种可能输出如下:

Hello
1
0
Bye
2
Bye

要注意这里打印出的1。

因为对于子进程来讲,pid会返回0,所以 !pid 就为1。对于父进程返回的pid值,一定是非0的,所以 !pid 会返回0。

我们再补充一下sleep与puase函数。

#include<unistd.h>

unsigned int sleep(unsigned int secs);

如果是在请求的时间到了过后sleep正常退出,则会返回0;否则将返回剩余需要休眠的秒数。 sleep函数会因为收到信号而提前退出。

puase函数与sleep函数相似,区别在于不用传入参数,该函数会使调用函数休眠直到进程收到一个信号。

练习题 8.5 编写一个sleep的包装函数,叫做snooze,带有下面的接口

unsigned int snooze(unsigned int secs);

snooze函数和sleep函数的行为完全一样,除了它会打印出一条消息来描述进程实际休眠了多长时间。

Slept for 4 of 5 secs

Answer:

unsigned int snooze(unsigned int secs){
	unsigned int remain_time = sleep(secs);
	printf("Slept for %d of %d secs",secs-remain_time,secs);
	return remain_time;
}

读者结合前面对sleep函数的讲解可自行体会。

接下来我们讲解一下程序的加载与运行

首先介绍一个关键函数:execve()

execve()函数将在当前进程的上下文中加载并运行一个新程序。

#include <unistd.h>
int execve(const char *filename,const char *argv[],const char *envp[]);

首先execve函数只调用但没有返回值,只有找不到filename的时候才会返回到调用程序。该函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量envp。argv与envp都指向一个以null为结尾的指针数组,却别在于argv每个指针都指向一个参数字符串,如’ls’;而envp的每个指针都指向一个环境变量字符串,每个串都是形如"name=value"的名字-值对,如"PWD=/usr/droh"。

execve在加载了filename文件后,将调用启动代码,启动代码设置栈后会将控制转递给新程序的主函数。

在初始化的用户栈中,栈底首先是以null结尾的环境变量字符串,随后是以null结尾的命令行字符串,之后是envp[n]…envp[0]的一系列环境变量指针与argv[argc]…argv[0]一系列参数字符串指针,在栈的顶部是系统启动函数libc_start_main的栈帧。

我们参考main的形参:

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

argc给出了argv[]数组中非空指针的数量,argv(我们注意到这里的形参是指针)指向数组argv[]中的第一个条目,envp指向envp[]数组中的第一个条目。

linux提供了几类函数去操作环境数组 ,如 getenv、setenv、unsetenv 等。

后面进入很精彩的内容,我们将尝试写一个简单的shell

如果不考虑信号的问题,我们的shell可以简单地设计为这样几个部分:
eval、parseline、builtin_command、main

eval函数调用builtin_command函数,该函数检查第一个命令行参数是否是一个内置的shell命令,如果是就立刻执行这个命令,并返回1,否则返回0。这里我们的shell只设置quit一个内置命令。

如果builtin_command返回0,那么我们就让shell创建一个子进程,并在子进程中执行所请求的程序,如果用户要求在后台运行该程序呢?我们的shell就将返回到循环的顶部,等待下一个命令。正常情况下shell会使用waitpid等待作业终止,再开始下一轮迭代。

parseline函数可以判断程序是否在后台执行。如果输入的最后一个参数是&,那么parseline返回1,表示会在后台执行,否则将在前台执行。

首先是最简单的builtin_command函数:

int builtin_command(char **argv){
	if(!strcmp(argv[0],"quit")) exit(0);
	if(!strcmp(argv[0],"&")) return 1;
	return 0;

这里中间一句判断是为了忽略掉&。

然后是关键的eval函数:

void eval(char *cmdline){
	char *argv[MAXARGS]; /*MAXARGS=128*/
	char buf[MAXLINE]; /*For copy cmdline*/
	int bg;
	pid_t pid;
	strcpy(buf,cmdline);
	bg=parseline(buf,argv);/*As we just said,for Judge*/
	if(argv[0]==NULL) return;/*Ignore empty lines*/
	if(!builtin_command(argv)){
		if((pid=Fork())==0){
			if(execve(argv[0],argv,environ)<0){
				printf("%s: Command not found.\n",argv[0]);
				exit(0);
				}
		}
		if(!bg){ /*Will Wait*/
			int status;
			if(waitpid(pid,&status,0)<0) unix_error("waitfg: waitpid error");/*So smart*/
		}
		else printf("%d %s",pid,cmdline);
	}
	return;

这里waitpid那里的用法与返回值请读者回看之前的讲解。

最后是我们的parseline函数:

int parseline(char *buf,char **argv){
	char *delim;
	int argc;
	int bg;
	buf[strlen(buf)-1]=' '; /*To replace The '\n'*/
	while(*buf && (*buf==' ')) buf++; /*buf++ means The Pointer Step Ahead */
	argc=0;
	while((delim=strchr(buf,' '))){
		argv[argc++]=buf;
		*delim='\0';
		buf=delim+1;
		while(*buf && (*buf==' ')) buf++
	}
	argv[argc]=NULL;
	if(argc==0) return 1;
	if((bg=(*argv[argc-1]=='&'))!=0) argv[--argc]=NULL;
	return bg;

这里argv[argc++]=buf;*delim='\0';值得好好理解。

这个shell的缺陷在于并没有回收后台的子进程,修改这个缺陷需要使用到信号,这也是我们下一节会重点分析的对象。

本节至此结束,篇幅较长,难免错漏,欢迎交流与指正。

 类似资料: