本文以简单的hello程序为例,分析一个程序在计算机上从无到有再到无的全过程(020)。在这个过程中,程序员以hello.c的形式赋予hello以生命的开始,hello.c经过预处理、编译、汇编、链接,变成可执行文件。再由shell为其生成、加载子进程,分配内存空间,乃至最后的回收。和其他的所有程序一样,hello的一生都是依附于计算机系统的,相信在研究hello的一生过程中,我们也会对计算机系统有更深刻的认识。
关键词:p2p,020,汇编,编译,进程,计算机系统
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在Ubuntu下预处理的命令 - 5 -
2.3 Hello的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在Ubuntu下编译的命令 - 6 -
3.3 Hello的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在Ubuntu下汇编的命令 - 7 -
4.3 可重定位目标elf格式 - 7 -
4.4 Hello.o的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
5.1 链接的概念与作用 - 8 -
5.2 在Ubuntu下链接的命令 - 8 -
5.3 可执行目标文件hello的格式 - 8 -
5.4 hello的虚拟地址空间 - 8 -
5.5 链接的重定位过程分析 - 8 -
5.6 hello的执行流程 - 8 -
5.7 Hello的动态链接分析 - 8 -
5.8 本章小结 - 9 -
第6章 hello进程管理 - 10 -
6.1 进程的概念与作用 - 10 -
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
6.3 Hello的fork进程创建过程 - 10 -
6.4 Hello的execve过程 - 10 -
6.5 Hello的进程执行 - 10 -
6.6 hello的异常与信号处理 - 10 -
6.7本章小结 - 10 -
第7章 hello的存储管理 - 11 -
7.1 hello的存储器地址空间 - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 11 -
7.5 三级Cache支持下的物理内存访问 - 11 -
7.6 hello进程fork时的内存映射 - 11 -
7.7 hello进程execve时的内存映射 - 11 -
7.8 缺页故障与缺页中断处理 - 11 -
7.9动态存储分配管理 - 11 -
7.10本章小结 - 12 -
第8章 hello的IO管理 - 13 -
8.1 Linux的IO设备管理方法 - 13 -
8.2 简述Unix IO接口及其函数 - 13 -
8.3 printf的实现分析 - 13 -
8.4 getchar的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -
Hello来源于一个程序员通过编辑器编写的hello.c文件,hello.c文件是使用易于程序员理解的高级语言(C语言)编写的。之后由预处理器(cpp)修改原始的C程序,得到另一个C程序hello.i,再由编译器(ccl)将其翻译为文本文件hello.s,其中保存的是一个汇编语言程序。再经过汇编器(as)处理,成为hello.o这个可重定位目标程序。最后由链接器(ld)对hello.o进行链接,生成可执行文件hello。
Hello程序诞生之后,程序员通过在shell中运行该程序,使操作系统(os)利用fork函数为hello生成子进程,再由shell为其分配内存空间,这就是p2p过程,Hello从程序到进程的过程。
而shell在运行该程序时,会调用fork函数生成子进程,调用execve函数加载子进程,再为Hello分配虚拟内存空间,OS为程序分配时间片,最终使程序内购在硬件层面上运行。而当程序运行结束后,shell又会通过父进程完成对子进程的回收,释放其占用的内存,删除有关的数据结构,抹去hello程序的所有痕迹。可谓是“一条龙服务”,从无到有再到无,这个过程就是020过程。
硬件环境:Core i7-6700HQ CPU,
软件环境:Ubuntu 18.04.1 LTS,Vmware 11, 2.60GHz,8.00GB RAM
开发工具:gcc,edb,gdb,objdump,winhex
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.c 用C语言编写的hello程序源代码,是所有产物的源头
hello.i 经预处理处理得到的文本文件
hello.s 编译后得到的汇编代码
hello.o 汇编操作得到的可重定位目标执行文件
hello.out 链接后得到的二进制文件
hello 可执行程序
除了列出了整个实验用到的环境和工具外,本章主要从整体层面介绍了Hello程序,简单地概括了Hello程序的p2p,020过程。并通过对中间过程的列出,对整个实验的流程进行了自顶向下的设计。
1.预处理的概念:
预处理是指在进行编译的第一遍扫描之前所做的工作,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。在预处理过程中,源代码被分割或处理成为了特定的符号用来支持宏调用。
2.预处理的作用:
对于C语言来说,其提供的预处理主要包括三种:宏定义、文件包含和条件编译。
宏定义,也可以叫做宏替换,顾名思义,就是将程序中所有人为宏,全部替换为机器能够理解的默认的表示方法。如:对#define pi 3.14的预处理结果就是将程序中所有出现的pi替换为常数3.14。
文件包含,是指在一个C语言程序中通过#include预处理指令完成对其他文件的包含调用,如:#include
条件编译,是指对对一部分内容设定编译条件,只有满足该条件才会被编译。这种条件常出现在#if,#else一类的指令后。
以hello程序为例,在进行预处理时,我们常使用的命令是gcc -E -o hello.i hello.c,其中hello.c是我们要进行预处理的文件,hello.i是生成的预处理后的文件。
如截图所示,在键入该命令行后,我们将得到预处理后的文件hello.i,非常的方便。
打开我们刚刚生成的hello.i文件,我们会发现之前只有几十行代码的hello.c文件被扩展成了3118行的文件。从一些痕迹中还可以看出hello.c文件的影子,被处理之后得到的hello.i文件仍然是一个C语言书写的文件。
在hello.i文件的最后,出现了hello.c文件中的main函数,在此之前的代码中,hello.i中涵盖了宏替换,条件编译以及文件包含的结果。
在本章中主要介绍了预处理的目的,常用命令以及得到的结果。经过了预处理,hello.c文件成为了hello.i文件,迈出了“人生的第一步”。本节的内容虽然简单,但却为以后内容打下基础。
1.编译的概念:
编译是指利用编译程序从源语言编写的源程序产生目标程序的汇编格式的过程,也指用编译程序产生目标程序的动作。简单地说,就是将程序员编写的高级语言的程序转换为汇编语言格式的程序。对Hello程序而言,编译就是由编译器(ccl)将文本文件hello.i翻译成包含汇编语言程序的另一个文本文件hello.s的过程。
2.编译的作用:
编译的作用就是将程序从高级语言转化为汇编语言的格式,汇编语言没有高级语言高级,虽然对于程序员来说更不好理解,但是对于机器来说,执行起来的效率却更高。编译整体可以分为五个阶段:词法分析,语法分析,语义检查和中间代码生成,代码优化,以及目标代码的生成。
以hello程序为例,我们可以通过以下的代码将hello.i程序编译为hello.s的形式。
gcc -S hello.i -o hello.s
执行该命令后,生成了hello.s文件。由于hello.s仍是一个文本文件,所以可以使用gedit文本编辑器打开。
hello.s文件截图:(都是汇编指令)
hello程序中使用了全局变量,函数调用的参数,函数中的局部变量三种类型的数据。
a.全局变量:
在hello程序中,只声明了一个全局变量,sleepsecs这个整型全局变量。接下来对其进行分析:
在汇编文件的开头处,可以看到关于sleepsecs的信息,其值初始化为2,且别存放在.rodata节(静态c变量被保存在.data节中),也就是只读数据节中。需要注意的是,在.c文件中对sleepsecs的初始化值为2.5,但是由于它是一个整型数据类型,所以会默认保留下有效数据部分,其对齐方式设置为4,大小占4个字节。
b.函数中调用的参数:
在hello程序中,主函数调用了argc这个整型数,和argv这个二维char型数组。
在调用main函数之前,int argc会作为第一个参数被保存在%edi中,而char* argv[]会作为第二个参数被保存在%rsi中,在上图汇编指令中,二者被存放在-20(%rbp)和-32(%rbp)中。
c.函数中的局部变量:
在main函数中声明了int型的i变量,我们无法直接从汇编代码中看到有关i的声明过程,但是结合hello.c代码我们可以找到一些i的痕迹。
比如上图中的第一个标记处,对-4(%rbp)赋0,实际上就是for循环中对i的初始化。而第二个标记处,实际就是for循环中每次循环对i的加一操作。在整个汇编文件中对局部变量没有明确体现声明,但是在栈中为其分配了内存空间,这个内存空间就是局部变量唯一的标志。正因如此,当调用其他函数时,也可以声明相同名称的在其他函数中出现过的局部变量,因为他们的内存空间分配不同,再上一个函数被调用后,栈中元素已经全部出栈。
在hello程序中,只出现了一次类型转换,而且是一个隐式类型转换。
就是在int sleepsecs = 2.5中,在对sleepsecs的初始化时,对一个整型数据却赋了一个浮点型的数据2.5,这显然是不妥的。所以这里涵盖了一个隐式的类型转换,也就是编译器默认的类型转换。对这个浮点数2.5,转换为int型,得到整型数2。
算数操作使一个程序中最基本的一项运算操作,但在Hello这个比较简单的程序中只以i++的形式出现了一次。
在hello.c中只出现了这一次,但在汇编文件中计算地址肯定离不开算数操作,在计算地址时出现过类似功能的语句有很多,下面给出书上对汇编指令中算数操作的总结:
由于关系操作常和条件判断语句一同出现,所以将二者放在一小节中加以描述。如果不算for循环中最后的退出条件,则在hello程序中在if(argc!=3)语句中二者结合使用,一起出现过一次。
其中的-20(%rbp)为局部变量i对应的内存地址,比较二者结果,如果相等则跳转到.L2,相等继续向下执行。
赋值是最基本的一条指令,hello中涉及到两次赋值操作:第一次是对sleepsecs的初始化步骤中,令sleepsecs=2.5,第二次是i=0。
在对全局变量sleepsecs的初始化步骤中,使用的是在.rodata节中直接声明为long型,大小为2的方法。
而对于局部变量i的声明,在hello.s中使用的是movl指令,
其实常用的赋值方法还可以使用leaq指令,使用加载有效地址的方法完成赋值操作。
条件分支语句已经在前面的内容中讨论,所以在本小节中只对循环结构进行讨论。在hello程序中,主要使用了while循环。
C语言中的while循环主要包括三个内容,计数器初始化,计数器更新,以及跳出条件判定。
上图中的第一个标记处就是对i的初始化操作,对其完成0的赋值,此处使用的movl指令。第二个标记处是对计数器的更新,每次将i对应的内存地址处存储的值减1.第三个标记处是对跳出条件的判断,利用汇编指令中的cmpl语句,如果i值小于等于9,则跳转到L4处,继续循环,反之则跳出循环。
绝大多数程序都不可能只依靠一个函数来完成自己的工作,都离不开对其他函数的操作,本小节中主要结合hello程序从参数传递、函数调用和函数返回三方面讨论对函数的操作。
一个程序在运行的过程中,可能会对很多函数进行调用,最常使用的是call指令,如hello.s中:
call指令可以调用程序员在.c文件中自己编写的函数,也可以调用已经封装好的现成的函数。
当调用一个函数时,有时还需要对该函数中传递参数。以hello程序为例,hello程序向主函数中传入了int argc,char *argv[]两个参数。像这一类被传入其他函数中的参数,在其所在函数被调用之前,往往将其按照被调用的顺序先保存在相应的寄存器中。在hello中,第一个参数被保存在%edi,第二个参数被保存在%rsi中
在被调用函数中通过对寄存器的操作就可以完成对传入参数的使用了。
被调用的函数通常将返回值保存在寄存器%eax中,在被调用函数中,我们可以事先对%eax寄存器进行设定,以此作为对被调用函数状态的一个体现。
本章主要讨论了对Hello程序的编译操作,复习了编译的概念与作用,并给出了在ubuntu环境下的编译命令。最后以Hello程序为例,分析了C语言中不同的数据结构以及操作会对应怎样的汇编结果。
编译的核心就是将高级语言变得“低级”,使得更容易被机器理解,更容易被机器执行。经过了这一步,hello.c已经变成了hello.s,又走出了“人生的重要一步”。
1.汇编的概念:
把汇编语言翻译成机器语言的过程称为汇编,汇编器(as)将hello.s翻译成机器语言指令。把这些指令打包成一种叫做可重定位目标程序的格式,并将结构保存在目标文件hello.o中。hello.o是一个二进制文件,其包含的17个字节是函数main的指令编码。
2.汇编的作用:
汇编操作后得到的hello.o文件是一个二进制文件,更为“低级”,但是更容易被机器理解。
以hello程序为例,输入命令:
gcc hello.s -s -o hello.o
可以将hello.s文件汇编为hello.o文件
1.hello.o的文件头:
在命令行中键入readelf -h hello.o以查看hello.o文件的文件头。如下图所示,
ELF头以一个16字节的序列开始,在这个序列中描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。
2.hello.o的节头部表:
键入readelf -S hello.o命令以查看hello.o文件的节头部表,运行结果如下图:
不同的节的位置和大小都是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。从表中我们可以得到有关各节的信息,比如各节的大小,对应的偏移量等。
3.hello.o的符号表
键入readelf -s hello.o可以查看该文件的符号表
从符号表中我们可以存放在程序中定义和引用的函数和全局变量的信息。每个可重定位目标文件在.symbol中都有一张符号表。
我们没有办法直接打开hello.o文件查看内容,所以在这里使用objdump工具,查看hello.o的反汇编表示。
我们可以看到,除了汇编代码之外,在左面还打印出了一些二进制表示的机器码,这些机器码就是汇编这一步骤的产物,但是由于我们无法直接打开hello.o文件进行查看,所以只能以这种形式被表现出来。在右侧就是hello程序对应的汇编代码。同样都是汇编代码,但是这些代码与hello.s中的汇编代码来源并不相同。在这里与hello.s做比较。
二者对比之下,发现核心内容基本相同,但在分支跳转以及函数调用时却有一定的不同之处。
1.分支跳转:在hello.s文件中,所有的跳转指令后都会接.Lx一类的段名称,但在反汇编操作得到的汇编代码中,跳转指令后只会接对应的地址。因为.Lx一类的段名称在汇编过程中都被转换成了相应的地址,其本身并没有被保留,所以反汇编后得到的汇编代码中也只会以地址的形式给出要跳转的目标语句。
2.函数调用:在hello.s中,用call指令进行调用函数时,总会在call指令后直接加上函数名,而在反汇编得到的汇编代码中,call指令后会跟着的是下一条指令的地址(一般以main地址加偏移量的形式给出)。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
在本章中,主要探究了程序的汇编过程,即程序从汇编代码形式向机器码形式的转变。重新复习回顾了汇编的概念与作用,并给出了汇编的ubuntu环境下的指令。并通过对readelf工具的操作,完成了对hello.o的elf文件的查看。最后还对比了反汇编后得到的汇编代码与hello.s文件中汇编代码的异同,并分析了造成不同的原因。
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。链接是由叫做链接器的程序自动执行的。
链接器在软件开发中扮演着一个关键的角色,因为其使得分离编译成为可能。
命令如下图所示:
在键入如图所示的命令行后,我们可以看到可执行程序hello的生成。结果如图所示:
通过键入readelf -a hello的命令,我们可以使用readelf工具查看hello的ELF格式文件,从中我们可以得出各段的基本信息。
不同节的位置和大小是由节头部表描述的,其中目标文件的每个节都有一个固定大小的条目。
可以查看节头部表信息:
类似地,也可以查看符号表的信息:
其中每个段都对应着不同的起始地址,并结合在ELF文件中记录的偏移地址,我们可以得到其对应的虚拟内存中的虚拟地址。
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
在edb的Data Dump窗口中,我们可以看到hello程序的二进制信息。这个窗口中显示了该进程中虚拟地址空间各段的信息。从0x400000到0x401000,这些虚拟内存空间地址中存储了程序各段的信息,结合上一部分中的地址,我们可以查看到各段的二进制信息。
如:符号表的信息的起始虚拟地址为0x400200,其中包含了49个条目。
键入objdump -d -r hello指令,得到hello文件反汇编后的汇编代码。
和hello.o对应的汇编代码相比较,我们可以看到各指令对应的地址为重定位后得到的对应的虚拟内存的地址,从0x400000开始,而hello.o反汇编后得到的汇编指令对应的地址都是从0000000开始的。具体的虚拟内存地址是由虚拟内存起始地址加上该节的偏移量计算得出的。
除此之外,还多了一些函数,比如初始化函数_init,_fini函数,plt表等。
这些多出来的节就是链接的重定位过程带来的产物。在重定位的过程中,编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
在main之前调用的子程序及其对应地址:
0x400488: _init
0x4004a0: .plt
0x4004b0: puts@plt
0x4004c0: printf@plt
0x4004d0: getchar@plt
0x4004e0: exit@plt
0x4004f0: sleep@plt
0x400530: _dl_relocate_static_pie
在main之后调用的子程序及其对应地址:
0x4005c0: __libc_csu_init
0x400630: __libc_csu_fini
0x400634: _fini
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来,这个由动态链接器执行的过程被称为动态链接。
假设程序调用一个由共享库定义的函数。编译器无法预测这个函数运行时的地址,因为定义它的共享模块在运行时可以加载到任意位置。使用延迟绑定技术可以解决这个问题。
延迟绑定技术主要利用了GOT和PLT两个数据结构来实现,GOT是一个数组,其中每个条目是8字节地址。我们在这里可以主要对GOT进行观察分析。
首先在节头表中找到GOT的首地址,如下图所示:
其首地址为0x601000,然后在edb中查看该地址对应的信息。
发现从其实地址开始的16个字节均为0,调用_dl_init函数后,GOT中的内容发生了变化。其中,GOT[[2]]是动态链接器在ld-linux.so模块中的入口点,也就是对应地址0x00007f5f:549fa750。
hello程序需要调用由共享库定义的函数puts,printf。以调用puts函数为例,程序会进入puts函数对应的plt表中,找到对应的条目,通过GOT表进行间接跳转,将puts的ID压入栈中,跳转到PLT[0]。由PLT[0]通过GOT间接地把动态链接器的一个参数压入栈中,再简介跳转进动态链接器,由动态链接器使用两个栈条目来确定puts的运行时位置。
这就是动态链接的过程。
本章主要复习回顾了链接的有关知识,在链接过程中hello程序在经过重定位后,由hello.o变成了可执行程序hello。在经过链接过程后,我们得到了一个二进制可执行程序hello,并对链接的概念与作用,重定位过程以及动态链接的机制有了更深的理解。
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。进程的一个经典的定义就是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。也可以说成,程序是指令、数据及其组织形式的描述,而进程是程序的实体。
在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统中当前运行的唯一的程序一样。我们的程序好像是独占地使用处理器和内存。处理器就好像是无间断地一条接一条地执行我们程序中的指令。最后,我们程序中的代码和数据好像是系统内存中唯一的对象。这些都是进程带给我们的。
1.shell的作用:
shell俗称壳,区别于核的概念,是指为使用者提供操作界面的软件。作用是用来接收用户命令,然后调用相应的应用程序。每次用户通过向shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
2.shell的处理流程:
shell先打印一个命令行提示符,等待用户在stdin上输入命令行,然后对这个命令行进行求值。书上给出了对命令行求值的代码,其首要任务是调用parseline函数,这个函数解析了以空格分隔的命令行参数,并构造最终会传递给execve的argv向量。第一个参数被假设为要么是一个内置的shell命令名,马上就会解释这个命令,要么是一个可执行文件,会在一个新的子进程的上下文中加载并运行这个文件。
如果最后一个参数是一个“&”字符,parseline函数返回1,表示应该在后台执行该程序。否则它返回0,表示应该在前台执行这个程序。
在解析了命令行后,eval函数调用builtin_command函数,该函数检查第一个命令行参数是否是一个内置的shell命令,如果是,就立即解释这个命令,并返回1。否则返回0。
如果builtin_command返回0,则创建一个子进程,并在子进程中执行所请求的程序。
当我们在shell中键入./hello 1170300122 鲁子建 的时候,有shell的流程我们可以知道,shell会先判断是不是内部指令,发现不是之后,会运行hello程序,并通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。
execve函数加载并运行可执行目标文件hello。该函数的作用就是在当前进程的上下文中加载并运行一个新的程序。只有当出现错误时,比如找不到目标文件,execve函数才会返回到调用程序。所以,与fork函数一次调用返回两次不同,execve调用一次且从不返回。
execve函数首先要做的是将hello的绝对路径拷贝到系统空间中。之后会将该可执行文件打开,并保存hello的必要信息。但hello并不能真正意义上的自己运行,需要由“代理人”来代理,遍历内核中的formats队列,找到认领hello的代理人。在找到了正确的代理人后,代理人放弃从以前父进程继承来的资源,对信号处理表,用户空间和文件进行处理。最后完成对空间的申请,并准备用户内存,开始启动进程。
正常运行hello程序的结果:
上下文信息:上下文由程序正确运行所需的状态组成,这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程时间片:分时操作系统分配给每个正在运行的进程微观上的一段CPU时间。在抢占内核中,指的是从进程开始运行到被强占的时间。
调度的过程:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
以执行sleep函数为例,sleep函数请求调用休眠进程,sleep将内核抢占,进入倒计时,当倒计时结束后,hello程序重新抢占内核,继续执行,其过程如下图所示:
异常的类别:
信号的类别:
1.先正常运行程序:
2.ctrl+c:
在程序运行过程中键入Ctrl + c,向当前进程中传入中断信号,程序被中止。
3.Ctrl+z:
在程序运行过程中键入Ctrl + z指令,会发送一个SIGTSTP信号到前台进程组中的每个进程,会停止(挂起)前台作业。
4.不停乱按,且按下回车:
如果不停乱按键盘,且按下了回车,则键入的命令将被保存在缓冲区,等heelo程序停止后,shell便会对它们按照命令行进行处理。
不按额外的回车,则无任何效果。
在本章中,我们对异常控制流进行了复习。我们利用shell,使用命令行控制shell运行hello程序,将hello的进程执行,对hello传入了不同的信号,得到了不同的结果。同时还对shell的概念、作用以及fork和execve函数进行了总结。
逻辑地址:是指由程序产生的与段相关的偏移地址部分。hello程序的偏移地址就是逻辑地址。
线性地址:如果一个地址空间中的整数是连续的,我们将其称为一个线性地址空间。
虚拟地址:程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为“段:偏移量”的形式。现代处理器使用的就是虚拟寻址的寻址方式。在虚拟地址被送入内存之前应该先转换成适当的物理地址。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有唯一的物理地址,其中第一个字节的地址为0,接下来的字节地址为1,以此类推。CPU访问内存最自然的方式就是使用物理结构,这种方式被称为物理寻址。hello程序的唯一的对应主存中的地址。
段式管理,是指把一个程序分成若干个段进行存储,每个段都是一个逻辑实体。它的产生是与程序的模块化直接有关的。段式管理是通过段表进行的,它包括段号或段名、段起点、装入位、段的长度等。此外还需要主存占用区域表、主存可用区域表。在段式存储管理系统中,为每个段分配一个连续的分区,进程中的各个段可以不连续地存放在内存的不同分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
段式管理是不连续分配内存技术中的一种。其最大特点在于他按照用户观点,即按程序段、数据段等有明确逻辑含义的“段”,分配内存空间。克服了页式的、硬性的、非逻辑划分给保护和共享与支态伸缩带来的不自然性.段另一个好处就是可以充分实现共享和保护,便于动态申请内存,管理和使用统一化,便于动态链接,其缺点是有碎片问题。
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将 程序的逻辑地址空间划分为固定大小的页,而物理内存划分为同样大小的页框。程序加载时,可将任意一页放人内存中任意一 个页框,这些页框不必连续,从而实现了离散分配。该方法需要CPU的硬件支持,来实现逻辑地址和物理地址之间的映射。在页式存储管理方式中地址结构由两部 构成,前一部分是页号,后一部分为页内地址。
这种管理方式的优 点是,没有外碎片,每个内碎片不超过页大比前面所讨论的几种管理方式的最大进步是,一个程序不必连续存放。这样就便于改变程序占用空间的大小。缺点是仍旧要求程序全部装入内存,没有足够的内存,程序就不能执行。
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。用于组选择和行匹配的所以和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引是由VPN的t个最低位组成的,而TLB标记是由VPN中剩余的位组成的。
分为五步:
第一步,CPU产生一个虚拟地址。
第二步和第三步,MMU从TLB中取出相应的PTE
第四步,MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存中。
第五步,高速缓存将所请求的数据字返回给CPU。
下图中给出了第四级页表中条目的格式,当P=1时,地址字段包括一个40位PPN,它指向物理内存中某一页的基地址。又强加了一个要求,要求物理页4KB对齐。![在0L3FxXzQxODI0MTgx,size_16,color_FFFFFF,t_70)
每个条目引用一个 4KB子页表,
P: 子页表在物理内存中 (1)不在 (0).
R/W: 对于所有可访问页,只读或者读写访问权限.
U/S: 对于所有可访问页,用户或超级用户 (内核)模式访问权限.
WT: 子页表的直写或写回缓存策略.
A:引用位 (由MMU 在读或写时设置,由软件清除).
D: 修改位 (由MMU 在读和写时设置,由软件清除)
Page table physical base address: 子页表的物理基地址的最高40位 (强制页表 4KB 对齐)
XD: 能/不能从这个PTE可访问的所有页中取指令.
先将虚拟地址转换为物理地址,再对物理地址进行分析,物理地址一般由CT、CI、CO组成,用CI位进行索引,如果匹配成功且valid值为1,则称为命中,根据偏移量在L1cache中取数,如果不命中,在分别到L2、L3和主存中重复上述过程,如图所示:
在下图中给出第一级、第二级、第三级页表中条目的格式。当P=1时,地址字段包含一个40位物理页号(PPN),它指向适当的页表的开始处。这里强加了一个要求,要求物理页表4KB对齐。
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的pid。为了给这个新进程创建虚拟内存。它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
虚拟内存和内存映射在将程序加载到内存的过程中扮演着关键的角色。假设运行时在当前进程中的程序执行了如下的execve调用:execve(“hello.out”,NULL,NULL)。
加载并运行hello.out需要以下几个步骤:
第一步,删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
第二步,映射私有区域。为hello.out的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello.out文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。
第三步,映射共享区域。如果hello.out程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
第四步,设置程序计数器。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
在虚拟内存中的习惯说法中,将DRAM缓存不命中称为缺页。下图中展示了在缺页之前示例页表的状态。CPU引用了VP3的一个字,而VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3并未被缓存,并触发了一个缺页异常。缺页异常调用内核中的缺页异常处理程序,改程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4。如果VP4已经被修改,那么内核就会将它复制回磁盘中。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不在缓存在主存中这一事实。
而接下来,内核就会从磁盘复制VP3到内存中的PP3,更新PTE3,然后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送到地址翻译硬件,但现在,VP3已经缓存在主存中了,那么页命中也能由地址翻译硬件正常处理了,下图展示了缺页之后页表的状态。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。尽管系统之间的细节不同,但并不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放。这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
对于隐式链表来说,其空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。注意,我们需要某种特殊标记的结束块。其优点在于简单,缺点是任何操作的开销都要求对空闲链表进行搜索,该搜索所需的时间与堆中已分配块和空闲块的总数呈线性关系。其放置策略包括首次适配、下一次适配以及最佳适配等策略。
对于显式链表来说,将空闲块组织为某种形式的显式数据结构。根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。一种方法是用后进后出的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
在本章中,我们复习回顾了虚拟内存有关的知识,对hello程序在执行过程中的存储空间分配作了分析。并总结归纳了段式管理、页式管理的内容,对TLB与四级页表支持下的VA到PA的变换,三级Cache支持下的物理内存访问等内容也做出了讨论。最后还列出了动态存储分配管理的内容。
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备都被模型化为文件(例如网络、磁盘和终端),而所有的输入和输出都被当作相应文件的读和写来完成,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单的,低级的应用接口,成为Unix I/O,这样做可以使所有的输入和输出都能以一种统一且一致的方式来执行。
1.Unix IO接口:
a.打开文件:一个应用程序通过要求内核打开相应的文件,宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符。它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
b.shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出和标准错误。
c.改变当前的文件位置:对于每个打开的文件,内核保持着一个文件的位置k,初始为0,这个文件的位置是从文件开头起始的字符偏移量。应用程序能够通过执行seek操作,将文件的当前位置设置为k。
d.读写文件:读操作就是从文件中复制n>0个字节到内存中,从当前文件位置k开始,然后将k增加到k+n。而写操作就是从内存复制字节到文件中。
e.关闭文件:当应用完成了对文件的访问后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
2.Unix IO函数:
a.open 函数:调要open函数可以打开或创建一个文件。
b.create函数:创建一个文件,也可通过以特定参数使用open来实现。
c.close函数:读文件进行关闭。
d.Iseek函数:为一个打开的文件设置其偏移量。
e.read函数:从打开的文件中读数据到buf。
f.write函数:写入文件。
g.pread,prwrite函数:主要用于解决文件共享问题。
h.dup函数
i.syns函数:用于解决延迟写问题,保证磁盘上实际文件系统和缓冲区高速缓存中内容的一致性。
8.3 printf的实现分析
printf函数的函数体如下:
int printf(const char *fmt, …)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
其中的vsprintf函数具体如下:
int vsprintf(char *buf, const char fmt, va_list args)
{
char p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case 'x':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
其中的vsprintf函数的功能在于按照格式接受确定输出格式的格式字符串fmt。再用格式字符串对个数变化的参数进行格式化,产生格式化输出,并返回字串的长度。
之后会调用write函数,将栈中参数放入寄存器,并完成对syscall函数的调用,syscall函数如下所示:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
简单地说,syscall的功能就是显示格式化了的字符串。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
其源代码如下所示:
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return(–n>=0)?(unsigned char)*bb++:EOF;
}
getchar函数通过调用read函数,将整个缓冲区中的内容读到buf中,并将buf中的第一个元素返回。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
本章主要讨论了有关hello程序的IO管理方面的内容,介绍了Linux下的IO设备管理,UNIX的IO接口以及相关的函数。并对printf函数、getchar函数的具体实现进行了分析。
用计算机系统的语言,逐条总结hello所经历的过程。
1.由程序员通过编辑器,使用高级语言完成对hello.c源代码的编写
2.预处理器(cpp)对hello.c进行预处理,得到预处理后的hello.i文本文件
3.gcc编译器对hello.i进行编译操作,得到hello.s的汇编文件,此时的hello程序已经由高级语言被转化为了汇编指令。
4.汇编器(as)将hello.s转化为hello.o可重定位目标文件。
5.链接器对hello.o以及相关头文件进行链接,生成了可执行文件hello。
6.在shell中键入命令行,运行hello程序。
7.shell调用fork函数为hello生成子进程,用execve为hello加载子进程。
8.MMU将hello程序中的虚拟内存地址通过页表映射成物理地址,进而完成内存的分配
9.在程序运行过程中,可以向进程传入不同的信号
10.hello程序运行结束后,shell通过父进程回收子进程,释放进程占用的内存,清理hello留下的“痕迹”
列出所有的中间产物的文件名,并予以说明起作用。
hello.c 用C语言编写的hello程序源代码,是所有产物的源头
hello.i 经预处理处理得到的文本文件
hello.s 编译后得到的汇编代码
hello.o 汇编操作得到的可重定位目标执行文件
hello.out 链接后得到的二进制文件
hello 可执行程序
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
[7] printf函数分析https://www.cnblogs.com/pianist/p/3315801.html
[8] unix环境高级编程之一( 基本I/O函数)https://www.cnblogs.com/nuistlr/archive/2012/07/08/2581519.html