信息安全系统设计基础第十一周学习总结
【学习时间:8小时】 【学习内容:《深入理解计算机系统》第八章 】
一、课本知识梳理(个人理解部分用【】标出)
1.控制流
从处理器加点开始,直到断点为止,PC假设一个值的序列 a0,a1,a2……,a(n-1)(其中,每个ak是某个相应的指令Ik的地址)。每次从ak到a(k+1)的过渡称为控制转移。这样的控制转移序列称为控制流。 【理解:本质上是PC一连串的操作,“流”是一种形象化的说法,意味着处理器的顺畅执行】
2.异常控制流
- 概念:相较于最简单的“平滑序列”类型的控制流(即PC中相邻的指令在存储器中也相邻),程序变量表示的内部程序状态中的变化、系统状态的变化等突发情况使得控制系统做出的反映成为异常控制流(ECF)。
- 意义:
- 应用程序如何与操作系统实现交互。应用程序使用系统调用(system call)的ECF形式向操作系统请求服务;
- 实现并发的基本机制。
【理解:异常并不是“错误”,而是与正常的“平滑序列”不一样的突发状态;这样的异常是必须的也是很有用处的。】
3.异常
- 产生:处理器中的变化(事件)触发从应用程序到异常处理程序的突发的控制转移,也就是异常;
- 类型:被零除,缺页,存储器访问违例,断点,算术溢出;系统调用,来着外部I/O设备的信号
- 处理:在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表的跳转表进行一个间接过程调用,到一个专门处理这类时间的操作系统子程序(异常处理程序);
- 结果:当 exception handler处理结束之后,会有三种结果
- 处理程序将控制返回给事件发生的时候正在执行的指令;
- 处理程序将控制返回给如果没有发生异常将会执行的下一条指令;
- 处理程序终止被终端的程序 【注:异常表,其条目k中包含着异常k的处理程序的地址。其中,异常表的起始地址放在异常表基址寄存器中,而异常号是异常表中的索引,相当于偏移地址】
4.关于异常处理的返回问题
【执行异常的时候,压入栈中的是什么?】 异常虽然类似于过程调用,但在压入栈的数据方面有不同。它会把一些额外的处理器状态压入栈中;并且如果是转移到内核的程序,压入的是内核栈中。 一旦触发异常,剩下的工作就由异常处理程序在软件中完成。在处理程序处理结束之后,它通过执行一条特殊的“从中断返回”指令,可选择地返回到被中断的程序。
5.异常种类 之 中断
- 原因:由I/O设备的信号引起的结果,属于异步(不是由任何一条指令造成的)。
- 过程:
- i/o设备,例如定时器芯片、网络控制器等,通过处理器芯片上的一个引脚发信号,并将异常号(标识引起中断的设备)放在系统总线上;
- 在当前指令完成之后,处理器注意到引脚电压变化,从系统总线中读取异常信号,调用中断处理程序;
- 处理中断
- 处理器返回(无中断的时候)应该执行的下一条指令。
【理解:什么叫异步?什么叫同步?我认为,同步或者异步都是相对于指令的执行而言的,与指令执行同时发生的,也就是执行指令才会产生的就叫做同步;而不一定与指令开始执行同时的,也就是与指令无关的,就叫做异步】
6.异常种类 之 陷阱
- 原因:有意的异常,是执行指令的结果,属于同步。
- 解释:陷阱最重要的用途是在用户和程序与内核之间提供一个像过程一样的接口,即 系统调用。
- 过程:
- 用户程序需要或者希望向内核请求服务(比如创建或者终止进程、读文件等)的时候,执行 syscall n(n是想要请求的服务号)指令;
- 把控制权交给处理程序;
- 陷阱处理程序运行;
- 处理程序结束之后,返回到下一条指令。
- 区别:系统调用和函数调用对于程序使用者或者编写者来说并无差别,然而二者在实现的过程中有很大差别。系统调用运行在内核模式下,可以访问定义在其中的栈;而函数调用只能访问与被调用函数相同的栈。
7.异常类型 之 故障
- 原因:由潜在的可恢复的错误的情况引起,属于同步的;可能能够被修复然后返回当前指令。
- 过程
- 当前指令导致故障;
- 控制转移给处理程序;
- 故障处理程序运行,如果可以修正这个错误,就将控制引起故障的指令从而重新执行它;否则,返回内核中的abort例程,abort终止引起故障的程序。
8.异常类型 之 终止
- 原因:由不可恢复的致命错误造成;通常是一些硬件错误。
- 过程:
- 发生致命硬件错误;
- 传递控制给处理程序;
- 处理程序将控制返回给abort例程,该例程终止此应用程序
9.linuxIA32系统中的异常举例
- 除法错误:
- 当应用试图除以0的时候,或者当一个除法指令的结果对于目标操作数来说太大了,就会发生;
- unix选择终止该程序(一般报告为浮点异常)
- 一般故障保护:
- 异常号:13.通常是因为一个程序引用了一个未定义的虚拟存储区域,或者因为程序试图写一个只读的文本段;
- linux不会试图恢复该类故障(一般报告为段故障)
- 缺页
- 处理程序将磁盘上虚拟存储器相应的页面映射到物理存储器的一个页面,然后重新开始执行这条故障的指令
- 系统调用
- linux提供上百种系统调用,每种都有对应的整数号(即内核跳转表中的偏移量);
- 在IA32系统上,系统调用通过一条int n指令提供;
- 在C程序中,通过syscall函数进行系统调用。
8.补充:关于系统调用
- 知识补充:所有到linux的系统调用的参数都是通过通用寄存器而非栈来传递。按照惯例,寄存器%eax包含系统调用号,寄存器%ebx,%ecx,%edx,%esi和%ebp包含最多六个参数。%esp不适用,因为当进入内核模式的时候,内核会覆盖它。
-
举例说明 【以hello程序为例】
int main() { write(1,"hello world ",13);//用系统调用来写write函数 exir(0); }
write函数第一个参数是将输出发送到stdout,第二个是要写的字节序,第三个是字节序列长度(算上,和换行符是12个;然而字符串结尾的时候还会有结束符 )。
【针对write函数的系统调用】
movl $4,%eax//write函数的编号
movl $1,%ebx//以下是设置参数
movl $string,%ecx
movl $len,%edx
int $0x80//使用int来执行系统调用(系统调用的异常号是128)
9.进程
- 定义:一个执行中的程序的实例。
【解释:系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需要的状态组成的。这个状态包括放在存储器中的程序的代码和数据等】 【每次用户通过向外壳输入一个可执行目标文件的名字,并运行一个程序的时候外壳就会创建一个新的进程;然后在这个新进程的上下文中运行这个可执行目标文件】 【应用程序也能够创建新的进程,然后再这个新进程的上下文中运行自己的代码或者其他应用程序】
- 进程&程序 进程提供给了应用程序几个关键抽象:
- 一个独立的逻辑控制流——提供好像程序独占处理器的假象;
- 一个私有的地址空间——提供好像程序独占存储系统的假象;
10.逻辑控制流
【为什么会有好像程序独占处理器的假象?】 首先,进程计数器(PC)中的每一个值都唯一地对应于包含在程序的可执行目标文件中的指令,或者是包含在运行时动态链接的到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流。 其次,进程是轮流使用处理器的;每个进程执行它的流的一部分然后被挂起,其他进程执行。 然后,对于一个运行在其中一个进程上下文中的程序而言,它看上去就像是唯一地占用了处理器(只不过如果精确测量的话,会发现对于一个进程来说,它在执行期间好像被停顿了若干个很短的时间)。
11.并发流
- 引入:计算机系统中有很多逻辑流的不同形式,比如异常处理程序、进程、信号处理程序等;
- 概念:一个逻辑流的执行在时间上与另一个流重叠,称为并发流;多个流并发执行的现象称为并发;一个进程和其他进程轮流运行,称为多任务;又叫做时间分片。
- 对比:如果两个流并发地运行在不同的处理器核或者计算机上,那么我们称它们为并行流。
12.进程控制 之 获取进程ID
- 含义:每个进程都有一个唯一的进程ID(PID);
- 获取:getpid函数获取进程的PID;getppid获取创建调用进程的进程(即它的父进程)的PID。
- 注释:以上两个函数的返回值为pid_t,在linux系统中,它在types.h中被定义为int
13.进程控制 之 创建进程
-
过程:父进程通过调用fork函数来创建一个新的运行子进程
#include <sys/types.h> #include <unistd.h> pid_t fork(void);//子进程返回0;父进程返回子进程的PID;如果出错,则为-1
- 特点:
- 新创建的子进程拥有和父进程相同的,但是独立的用户级虚拟地址空间拷贝,包括文本、数据和bss段、堆和用户栈等;【子进程相当于父进程的克隆体】
- fork函数被创建之后,将返回两次:一次返回到父进程中,一次返回到子进程中;
- 子进程创建之后,与父进程并发执行;而二者执行的先后顺序是不可控制和预料的;
- 子进程和父进程拥有独立的地址空间,所以二者对某一相同变量值的修改是互相不受影响的。
- 共享文件
-
举例:
#include "csapp.h" int main() { pid_t pid; int x; pid = fork(); if(pid ==0) { printf("child:x = %d ",++x); exit(0); } printf("parent : x=%d ",--x); exit(0); }
程序运行结果为:
parent:x=0 child:x=2
【注释:子进程继承了父进程所有打开的文件,包括stdout文件;当父进程调用子进程的时候,后者的输出也是指向屏幕的】
14.进程控制 之 终止进程
- 终止类型:
- 运行:进程要么在CPU上运行,要么在等待被执行且最终被内核调度;
- 停止:进程的执行被挂起,且不会被调度。【当进程收到SIGSTOP,SIGTSTP,SIDTTIN,SIGTTOU信号的时候,进程就会停止,并且保持停止直到它收到一个SIGCONT信号,在此时再次运行】
- 终止:进程永远地停止。三种原因:1)收到一个信号,其默认为终止程序;2)从主程序返回;3)调用exit函数(exit(int stauts),其中status是退出状态)
15.回收子进程
- 含义:当一个进程由于某种原因终止的时候,内核并不是把它从系统中清除,而是保持在已经终止的状态中,直到被它的父进程回收。这时,内核将子进程的退出状态传递给父进程,然后抛弃已经终止的进程。这之后,该进程才可以说是“不存在”了。
- 补充:已经终止但是尚未被回收的进程叫做僵死进程。
- 特例:如果父进程没有回收它的子进程就终止了,那么内核就会安排init函数来回收它们,init函数的返回值是1。(即使僵死子进程没有执行,也会消耗系统的存储器资源)
16.等待回收子进程
-
引入:一个进程可以通过调用waitpid函数来等待它的子进程终止或者停止
#include<sys/types.h> #incldue<sys/wait.h> pid_t waitpid(pid_t pid,int *status,int options);//如果成功,返回子进程的PID,如果为WNOHANG,则为0,其他错误则为-1
-
说明:默认地,即当options=0的时候,waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止。如果等待集合中的一个进程在刚调用的时候就已经终止了,那么waitpid就立即返回。以上两种情况都会使得waitpid函数返回已经终止的子进程的PID,并且去除该进程。
- 解释
- 判断等待集合的成员
如果pid>0,那么等待的集合就是一个单独的子进程,它的进程ID等于pid; 如果pid<-1,那么等待集合就是由父进程的所有子进程组成的 - 修改默认行为
可以通过将options设置为常量WNOHANG和WUNTRACED的各种组合,修改默认行为: WNOHANG:如果等待集合中的任何子进程都还没有都还没有终止,那么就立即返回0; WUNTRACED:挂起调用进程的执行,直到等待集合中的一个变成已经终止的或者被停止,然后返回导致返回的子进程的PID; WNOHANG|WUNTRACED:立即返回。 - 检查已回收子进程的退出状态
非空的status参数会被放上status参数的关于返回的子进程的状态信息(wait.h定义了status参数的几个宏) WIFEXITED:如果子进程通过调用exit函数或者一个返回即return政策终止,就返回真; WEXITSTATUS:返回一个正常终止的子进程的退出状态(在WIFEXITED返回为真的时候才定义这个状态) WIFSINGALED:如果子进程是因为一个未被捕获的信号终止的,那么就返回真; WTERMSIG:返回导致子进程终止的信号的编号。只有在WIFSINGALED为真的时候,才定义这个状态。 - 错误条件 如果调用进程没有子进程,那么waitpid函数返回-1,并且设置errno为ECHLD;
如果waitpid函数被一个信号中断,那么它返回-1,并设置errno为EINTR
- 判断等待集合的成员
-
应用举例
#include "csapp.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("child %d terminated normally with exit status = %d ",pid,WEXITSTATUS(status)); else printf("child %d terminated abnormally ",pid); } if(errno != ECHILD) unix_error("waitpid error"); exit(0); }
- 父进程创建N个子进程,然后子进程以一个唯一的退出状态退出;
- waitpid函数被阻塞直到某个子进程终止,然后进入while循环测试是否是正常终止的;是正常的话就输出;
- 当回收了所有的子进程之后,再调用waitpid就返回-1,并且设置errno为ECHILD;如果不是正常终止的,就输出一个错误消息
17.进程休眠
-
函数:
unsigned int sleep(unsigned int secs);//返回还要休眠的秒数 【如果请求的时间量已经到了,则返回0】 int pause(void);//该函数让调用函数休眠
18.加载并运行程序
-
函数:
#include<unistd.h> int execve(const char *filename,const char *argv[],const char *envp[]);//加载并运行可执行目标文件,如果成功则无返回值,如果不成功则返回-1
- 参数解释:
- argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数串。按照惯例,argv[0]指向可执行目标文件的名字
【这里的可执行目标文件指的是用户输入的命令的名称,比如 ls,man等】 - envp变量指向一个以null结尾的指针数组,其中每一个指针指向一个环境变量串,格式是“NAME = VALUE”
- argv变量指向一个以null结尾的指针数组,其中每个指针都指向一个参数串。按照惯例,argv[0]指向可执行目标文件的名字
-
运行 在上面的函数加载了filename之后,它调用了启动代码。启动代码设置栈,将控制传给新程序的主函数
int main(int argc,char **argv,char **envp);//这里argc描述argv数组中非空元素的个数
对于32位的linux系统,执行的时候栈的情况如下:
19.利用fork和execve函数运行程序
- 引入:上述两个函数被大量使用在unix外壳中。
外壳是一个交互型的应用级程序,它代表用户运行其他程序。外壳执行一系列读/求值步骤,然后终止。读是读取来自用户的一个命令行,求值是解析命令行然后运行程序。 - 代码(摘录部分核心代码) 对求值代码的说明
- 解释
- 第10行,调用parseline函数解析以空格分隔的命令行参数,最终将argv向量传递给execve函数——要么是一个内置的外壳命令,要么是一个可执行目标文件;
- 如果最后一个参数是&,parseline会返回1,表示在后台执行该程序,如果不是,则返回0,表示在前台执行
- 紧接着,第14行,调用builtin_command函数,检查第一个命令是否是一个内置的外壳命令;是的话返回1,否则返回0。(内置命令如quit,pwd等)
- 如果上面的函数返回0.则外壳创建子进程,然后在子进程中执行所请求的程序
- 如果用户要求在后台运行程序,那么外壳返回循环顶部,等待下一个命令行;否则使用waitpid函数等待作业终止,然后开始下一轮迭代。
20.非本地跳转
- 引入:C语言提供了一种用户级异常控制流形式,称为非本地跳转;它将一个函数转移到一个当前正在执行的函数,而省略了调用-返回序列这一步
-
函数:setjmp,longjmp
int setjmp(imp_buf env);返回0 int sigsetjmp(sigjmp_buf env,int savesigs); void longjmp(jmp_buf env,int retval); void siglongjmp(sigjmp_buf env,int retval);
- 解释:以上两个函数配合使用
- setjmp函数在env缓冲区中保存当前调用环境(包括PC,栈指针,通用寄存器),以供后面的longjmp使用,并返回0;
- longjmp函数从env缓冲区中回复调用环境,然后触发一个从最近一次初始化env开始的setjmp函数调用的返回;
- setjmp函数返回,并带有非零的返回值retval
【在看了上面的解释之后,我仍然不理解非本地跳转的过程,于是又阅读了使用举例,疑惑才得到了解释:setjmp函数被调用一次而返回两次,依次是在保存当前环境的时候(返回0),另一次是被每一个相应的longjmp调用(返回错误类型)】
- 举例1
- 用于允许从一个深层嵌套的函数调用之中立即返回,而不是费力地解开栈
- 说明:main函数先调用setjmp保存以前的调用环境,然后调用函数foo;foo调用bar;如果这两个函数中一个遇到错误,就立即通过longjmp调用从setjmp返回;setjmp非零返回值指明了错误类型
- 举例2
- 用于使一个信号处理程序分支到一个特殊的代码位置而不是返回到被信号中断了的指令的位置
- 说明:在程序第一次启动的时候,调用setjmp保存上下文环境;随后主函数进入无限处理循环;用户键入ctrl-c之后,外壳发送SIGINT信号给进程,该进程捕获这个信号然后处理程序执行一个非本地跳转,回到主函数开始的地方
二、练习题筛选
1.考虑三个具有下述起始和结束时间的进程:
A(进程) 0(起始时间) 2(结束时间)
B(进程) 1(起始时间) 4(结束时间)
C(进程) 3(起始时间) 5(结束时间)
问:进程两两之间是否属于并发?
【A&C:不属于;B&C:属于;A&B:属于】
2.考虑下面的程序
#include "csapp.h"
int main()
{
int x =1;
if(fork()==0)
printf("printf1:x=%d
",++x);
printf("printf2:x =%d
",--x);
exit(0);
}
问:子进程的输出是什么?父进程的输出是什么? A:子进程输出是 printf1:x=2
B:父进程输出是 printf2:x=0
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);
}
【子进程会输出“ac”,父进程会输出“bc”;而父子进程执行先后不是确定的,所以输出序列可能是:acbc,abcc,bacc,bcac】
4.编写一个sleep的包装函数,叫做snooze,带有下面的接口
unsigned int snooze(unsigned int secs);//除了它会打印一条语句来描述休眠了多少秒之外,和sleep函数一样
解答:
unsigned int snooze(unsigned int secs)
{
unsigned int rc = sleep(secs);
printf("Slept for %u of %u seconds
",secs-rc,secs);
return rc;
}
5.编写一个叫做myecho的程序,它打印出它的命令行参数和环境变量,例如:
unix> ./myecho arg1,arg2
Command line arguments:
argv[ 0]:myecho
……
Envirionment variables:
envp[ 0]:PWD+/user0/droh/ics/code/ecf
……
解答: 补充函数
char *getenv(const char *name);//若存在,则返回指向name的指针,若无,则为null
该函数在环境数组中搜索字符串“NAME=VALUE”,如果找到则返回指向value的指针。 另外,根据8.4节的内容,main函数就可以很好地结合argv和envp两个数组,所以主体应是main函数;这样的话argv和envp数组的元素可以直接输出
#include "csapp.h‘
int main(int argc,char *argv[],char *envp[])
{
int i;
printf("Command line arguments:
");
for(i=0;i=argv[i]!=NULL,i++)
printf(" argv[%2d]:%s
",i,argv[i]);
printf("
");
printf("Envirionment variables:
");
for(i=0;envp[i]!=NULL;i++)
printf(" envp[%2d]:%s
",i,envp[i]);
exit(0);
}
三、疑问(已经在小组中提问)
1.关于waitpid函数:
P495
pidt waitpid(pidt pid,int *status,int options);//如果成功,返回子进程的PID,如果为WNOHANG,则为0,其他错误则为-1
P496
(修改默认行为部分)WUNTRACED:挂起调用进程的执行,直到等待集合中的一个变成已经终止的或者被停止,然后返回导致返回的子进程的PID
【疑问:无论options是默认的0还是WUNTRACED,最后的函数返回情况都是一样的吗?(书上的解释为:默认的options值会使得函数返回已经终止的子进程;而将options的值修改为WUNTRACED之后,返回的是导致返回的已终止及被停止的进程PID——这两者有什么不同?)】
四、心得
本章的内容较多,但是都是针对之前章节学习中有所涉及而不能解答的地方展开,比如:处理器怎样执行程序、怎样处理错误、怎样控制进程甚至怎样灵活地跳转等等;同时,也和其他课程有序衔接了起来(比如和操作系统中的进程控制部分就有相似之处)。所以读起来不时会有豁然开朗的感觉;越来越觉得原来处理器看似复杂曲折的“大脑”也有着简单、人性化的基础。