文章目录
题 目 :程序人生-Hello's P2P
专 业 : 计算学部
学 号 :120L021102
班 级 :2003010
学 生 :张子健
指 导 教 师 :郑贵滨
摘要
HelloWorld程序的一生是从hello.c源文件的编写开始的,本文从这里开始对HelloWorld程序的生命周期进行分析。hello.c程序首先经过预处理器(cpp)进行预处理生成hello.i文件,接着经过ccl(编译器)编译生成汇编语言文件hello.s,然后被汇编器(as)翻译成一个可重定位目标文件hello.o,最后被链接器程序(ld)符号解析和重定位后变为一个可执行文件hello。随着shell接收到一个./hello样式的命令,内核开始调用fork函数创建进程并通过execve加载可执行程序hello进入内存开始执行。在这期间由CPU和操作系统控制程序逻辑流的运行,中断,上下文切换和异常的处理,最后进程结束由父进程进行回收,hello程序就此走向“生命”的尽头。
关键词:预处理;编译;汇编;链接;进程;存储;IO;
第1章 概述
1.1 Hello简介
1.首先通过编写hello.c得到hello.c源文件
2预处理器(cpp)将hello.c预处理生成hello.i文件。
3.编译器(ccl)将hello.i编译生成汇编语言文件hello.s。
4.汇编器(as)将hello.s翻译生成可重定位目标文件hello.o。
5.链接器(ld)将hello.o和各种系统目标文件组合起来生成可执行目标文件hello。
6.在shell中输入./hello命令,shell通过fork函数创建进程,调用execve映射虚拟内存,进入程序入口并将其载入物理内存,然后开始进入main函数执行目标程序。
7.CPU和操作系统为运行着的hello进程分配时间片执行逻辑控制流,进行调度。程序运行完成后,由父进程回收hello子进程,删除与之相关的数据。
1.2 环境与工具
硬件环境:Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz ,16GB
软件环境:Windows10 64位; Vmvare16; Ubuntu 20.04 LTS 64位
开发与调试工具:gcc; vim; edb
1.3 中间结果
文件名称 | 文件描述 |
hello.c | 源程序 |
hello.i | hello.c预处理之后的文件 |
hello.o | hello.i编译后的文件 |
hello.s | hello.o汇编后的文件 |
hello | 链接之后的可执行目标文件 |
hello.out | hello反汇编得到的可重定位目标文件 |
1.4 本章小结
本章主要介绍了hello程序P2P的主要过程以及该过程产生的几个主要文件,并介绍了实验的软硬件环境与运行调试工具。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:所谓的预处理是指在进行正式编译(此法分析,代码生成,优化等)之前所做的工作。预处理由预处理程序负责完成,当对一个源文件进行编译时,系统将自动引用预处理程序对源程序中的预处理命令部分做处理。
预处理的作用:执行宏替换;处理条件编译指令,过滤掉不必要的代码;处理头文件,将头文件中的定义加入到产生的输出文件中,供编译程序进行处理。
2.2在Ubuntu下预处理的命令
Ubuntu下预处理hello.c的命令:gcc -E -o hello.i hello.c
生成的hello.i文件如下:
2.3 Hello的预处理结果解析
hello.i文件里的内容截图如下:
Hello的预处理结果解析:通过预处理器,hello.c文件被预处理为hello.i文件,打开文件我们可以看到hello.i中的内容大大增加。其中,hello.i文件对源文件的宏进行了展开,比如将头文件中的内容添加了进来,如果有#define命令,还会用实际的值进行替换。
2.4 本章小结
本章主要介绍了预处理的相关概念和作用,并通过打开生成的hello.i文件对预处理器执行预处理的功能进行了分析,是对源程序内容的补充和替换。
第3章 编译
3.1 编译的概念与作用
编译的概念:编译器将hello.i文件翻译成文本文件hello.s,hello.s是一个汇编语言文件,其是一个汇编语言程序。编译过程以一个高级语言程序为输入,生成一个汇编语言程序。
编译的作用:编译将高级语言编写的程序翻译成汇编语言程序,分为5个阶段,词法分析;语法分析;语义检查和中间代码生成;代码优化;其中主要进行词法分析和语法分析的工作,分析过程中发现有语法错误,给出提示信息。
3.2 在Ubuntu下编译的命令
在Ubuntu下编译的命令:gcc -S -o hello.s hello.i
生成的hello.s文件如下:
3.3 Hello的编译结果解析
3.3.1 数据
1.字符串
hello.s文件中有两个字符串,这两个字符串是只读的,都在数据段rodata中,如图所示,它们都被作为printf函数的参数。
2.局部变量
hello.c程序中main函数中声明了一个局部变量i,编译器在进行编译的时候会将局部变量i放入堆栈中。在hello.s中可以找到变量i被放置的位置,在寄存器-4(%rbp)所指的位置,见下图:
3.参数char *argc[]
参数argc是用户传给main函数的参数,argc被放在-20(%rbp)的位置,被放置在堆栈中,如下图所示:
4.参数char*argv[]
参数argv是main函数的第二个参数,数组的起始地址存放在-32(%rbp)的位置,数组中的元素都是一个指向一个字符类型的指针,argv同样被放到堆栈之中,如下图所示。
5.立即数
立即数是在汇编代码中显示的数,它们直接体现在汇编代码当中。
3.3.2赋值操作
hello程序中的赋值操作主要体现在将i赋值为0处,而这个操作在hello.s文件中。如图所示:
汇编代码是用mov指令来实现的。mov指令的种类是根据源操作数的字节大小来分的,具体有以下4种:
movb是将源操作数的一个字节赋值给目的操作数;movw是将源操作数的两个字节赋值给目的操作数;movl是将源操作数的四个字节赋值给目的操作数,movq是将源操作数的八个字节赋值给目的操作数。
3.3.3类型转换
在hello程序中,仅有一处涉及到了类型转换,是用atoi函数将argv中的字符串类型的参数转换为整形。除此之外,int、float、double、short、char这几种数据类型之间可以进行相互转化。
3.3.4 算术操作
hello程序中涉及到的算术运算主要有i的自增运算i++,i是int类型的变量,在汇编代码中通过指令addl来实现,如下所示:
常见的汇编算术运算指令有inc,dec,neg,add,sub等。
3.3.5关系操作
hello程序中涉及到的关系操作主要有两处,第一处发生在if(argc!=4),这是一个条件判断语句,比较后会设置条件码,决定着是否发生跳转,在hello.s文件中被编译为cmpl$4, -20(%rbp),如下图所示:
第二处关系操作发生在hello.c程序中的for循环条件处,即for(i=0;i<8;i++),该语句在判断后设置条件码,为下一步的jle做准备。该条指令在hello.s中被编译为cmpl$7,-4(%rbp),如下图所示:
3.3.6控制转移指令
汇编语言通过设置条件码来进行控制程序的跳转。通过阅读hello.s文件中的汇编代码,我可以得到hello程序中有如下几条控制转移指令。
hello.s中的指令cmpl $4,-20(%rbp)处判断argc是否等于4。如果等于4,则不执行je指令;反之i如果不等于4,则执行后续的语句,如下图所示:
hello.s中的指令cmpl $7, -4(%rbp),在for循环中,每次都要判断i是否小于8来决定是否继续循环。判断i是否符合循环的条件,若符合则直接跳转到.L4中继续执行,如下图所示:
3.3.7 函数操作
调用函数过程如下:
1.传递控制:当运行函数时,程序计数器PC需设置为函数的入口点,当函数返回时,程序计数器PC应设置为函数调用之后的那条指令的地址;
2.传递数据:如果数P调用Q,则P可以向Q提供一个或多个参数,Q也可以向P返回一个值;
3.分配和释放内存:开始时,Q为局部变量分配空间,返回时释放这部分空间。
hello.c涉及到的函数有:
(1)main函数:参数为argc和argv[];
(2)print函数:参数为上述提到的两个字符串;
(3)exit函数:参数为1;
(4)sleep函数:参数为atoi(argv[3]);
(5)getchar函数:没有参数。
函数的返回值存储在寄存器%eax中。
3.3.8全局函数
hello.s文件中声明了一个全局函数int main(int argc,char *argv[]),如下图所示:
3.4 本章小结
本章主要介绍了编译阶段处理hello.i文件的各个方面。我们可以看到编译器在数据,赋值语句,类型转换,算术操作,逻辑操作,位操作,函数操作,关系操作,控制转移这几处对hello程序进行了了处理,将数据类型和操作转变为对应的汇编语言。至此,hello程序由高级的C语言被处理为了较为低级的汇编语言。
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:汇编器(as)将.s文件中的汇编程序翻译成机器语言并将这些指令打包成可重定目标程序的格式存放在.o类型的目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
汇编的作用:将hello.s文件的汇编语言翻译成hello.o文件机器语言,使其在链接后能够被机器识别并执行。
4.2 在Ubuntu下汇编的命令
在Ubuntu下编译的命令:gcc hello.s -o hello.o
生成的hello.o文件如下:
4.3 可重定位目标elf格式
由于.o文件无法直接打开,所以我们可以使用在linux下使用生成hello.o文件的elf格式命令:readelf -a hello.o >hello.elf。
接下来分析hello.elf文件中的内容:
1.ELF头:ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
2.节头部表:包含了文件中出现的各个节的含义,包括节的地址、偏移量和大小、链接、对齐等信息;
3.重定位节:重定位节保存的是.text节中需要被修正的信息,调用外部函数的指令和引用全局变量的指令需要重定位,调用局部函数的指令不需要重定位。Hello程序中需要被重定位的有printf、puts、exit、sleep、sleepseces、getchar和.rodata中的.L0和.L1。其中,.rela.eh_frame节是.eh_frame节的重定位信息。
4.symtab节:符号表,用来存放程序中定义和引用的函数或全局变量的信息。重定位需要引用的符号都在其中声明。name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字。value是符号的地址,对于可重定位模块来说,value是距定义目标的节的起始位置的偏移。对于可执行目标文件来说,value是一个绝对运行时地址。size是目标的大小。type是数据或者函数。binding表示符号是本地符号还是全局符号。ABS表示不该被重定位的符号。UNDEF表示未定义的符号,即本目标引用的其他模块定义的符号。COMMON表示还未被分配位置的未初始化目标数据。
4.4 Hello.o的结果解析
使用命令objdump -d -r hello.o进行反汇编,
生成的hello.txt文件如下:
hello.o的反汇编文件hello.txt中main部分代码如下:
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
返汇编文件hello.txt与hello.s的区别:
1.控制转移
hello.txt
hello.s
由上述两图对比可以看出,hello.txt与hello.s在跳转指令处有所不同,反汇编的跳转指令用的不是段名称比如.L3,而是用的确定的地址,分支转移表示为主函数+段内偏移量。段名称只是在汇编语言中便于编写的助记符,hello.s中函数被分为多个块,在运行时在多个块中进行跳转,即使用段名称进行跳转。
2.函数调用
hello.txt
hello.s
由上述两张图可以看出,hello.s中call后面为函数名称,而反汇编代码中call后面为相对地址。
3.立即数进制不同
hello1.txt
hello.s
由上述两张图可以看出,在反汇编文件中,立即数全部是以16进制表示的,因为16进制与2进制之间的转换比十进制更加方便,更适合机器表示,所以都转换成了16进制。
4.5 本章小结
本章介绍了汇编的过程。汇编器将汇编语言转化为机器语言,生成了可重定位目标程序hello.o,可以通过readelf查看其信息。重点讲述了可重定位文件的ELF头,节头表,符号表和重定位节.rela.t的内容。最后比较了通过objdump指令得到的反汇编文件hello.txt结果与汇编文件hello.s的区别。
第5章 链接
5.1 链接的概念与作用
链接的概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载或复制到内存并执行。链接可以执行于编译、加载、运行时。在现代系统中,链接是由叫做链接器的程序自动执行的。
链接的作用:链接使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。
5.2 在Ubuntu下链接的命令
在Ubuntu下链接的命令:ld -o hello -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
生成的hello文件:
使用objdump反汇编hello文件
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
命令:readelf -a hello > hello1.elf
1、ELF头
ELF头描述文件的总体格式。可以看到,可执行文件的ELF头与可重定位目标文件的ELF头有以下不同:
文件的类型不同,可执行文件的类型不再是REL而是EXEC。
程序的入口点不一样,因为连接上了库文件,使得main函数不再是从0x0开始。同理节头的开始位置也发生了变化。
节头的数量产了变化,上次的节头数量为14个,这次变为27个。
2.节头部表
可以看到,与hello.o不同,在可执行文件中经过重定位每个节的地址不再是0,而是根据自身大小加上对齐规则计算的偏移量。
3.重定位节.rela.text:
4.符号表
符号表中存放着程序定义和引用的全局变量和函数,不包含局部变量的条目。其中,name是字符串表中的字节偏移,指向符号的以null结尾的字符串名字。value是一个绝对运行时地址。size是目标的大小,以字节为单位。type通常是数据,或函数。binding字段表示符号是本地的还是全局的。
对比可执行目标文件ELF格式的符号表,我们可以发现,在可执行文件中多出了.dynym节。这里面存放的是通过动态链接解析出的符号,这里我们解析出的符号是程序引用的头文件中的函数。保存与用于动态数据链接导入模块内部相关的数据导入值和导出通用符号,这些内容里面并不包括模块内部的符号。
5.4 hello的虚拟地址空间
由程序头可知LOAD可加载的程序段的地址为0x400000。
使用edb打开hello观察hello加载到虚拟地址的状况,并查看各段信息。
在0x400000~0x401000段中,程序被载入,虚拟地址0x400000开始,到0x400fff结束,根据节头部表,可以通过edb找到各个节的信息。从ELF头中可以看出程序的入口地址为0x4010f0,即.txt节,虚拟地址开始于0x4010f0,大小为0x145。
其他各个节的信息均可按此类方法找到。
5.5 链接的重定位过程分析
输入命令行objdump -d -r hello > hello1.txt分析hello与hello.o的不同,说明链接的过程。结合hello.o的重定位项目,分析hello中对其如何重定位。如图5-7、5-8所示,与hello.o生成的反汇编文件对比发现,hello1.txt中多了许多节。hello0.txt中只有一个.text节,而且只有一个main函数,函数地址也是默认的0x000000。而hello1.txt中有.init,.plt,.text三个节,而且每个节中有许多的函数。这是因为库函数的代码都已经链接到了程序中,程序各个节变得更加完整,跳转的地址也具有参考性。主函数中,在hello.o反汇编代码中出现以main加上相对偏移的跳转已经全部被重写计算,这是因为在重定位后main函数有了全新的地址,使得这个计算成为可能。同时对子函数的call引用也在重定位后重写计算了。
hello比hello.o多出的节头表如下所列。
.interp:保存ld.so的路径
.note.ABI-tag
.note.gnu.build-i:编译信息表
.gnu.hash:gnu的扩展符号hash表
.dynsym:动态符号表
.dynstr:动态符号表中的符号名称
.gnu.version:符号版本
.gnu.version_r:符号引用版本
.rela.dyn:动态重定位表
.rela.plt:.plt节的重定位条目
.init:程序初始化
.plt:动态链接表
.fini:程序终止时需要的执行的指令
.eh_frame:程序执行错误时的指令
.dynamic:存放被ld.so使用的动态链接信息
.got:存放程序中变量全局偏移量
.got.plt:存放程序中函数的全局偏移量
.data:初始化过的全局变量或者声明过的函数
hello0.txt
hello1.txt
hello重定位的过程:
(1)重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
(2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。执行这一步,链接器依赖于可重定位目标模块中称为的重定位条目的数据结构。
(3)重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目,代码的重定位条目放在.rel.txt中
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
ld-2.31.so!_dl_start 0x7f48cf65a3b0
ld-2.31.so!_dl_init 0x7f48cf668b40
hello! _start 0x4010f0
libc-2.31.so! __libc_start_main 0x7f6fd3044fc0
hello!puts@plt 0x401030
hello!exit@plt 0x401070
hello!printf@plt 0x401040
hello!sleep@plt 0x401080
hello!getchar@plt 0x401050
libc-2.31.so!exit 0x7f6fd3067a70
5.7 Hello的动态链接分析
当程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。但 这需要链接器修改调用模块的代码段,GNU编译系统使用一种称为延迟绑定的技术将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过两个数据结构之间的交互来实现的,分别是GOT和PLT,GOT是数据段的一部分,而PLT是代码段的一部分。PLT与GOT的协作可以在运行时解析函数的地址,实现函数的动态链接。
过程链接表PLT是一个数组,每个条目是16字节代码。PLT[0]是一个特殊条目,跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。由5.3.2中的节头部表知,存储PLT的.plt节的开始地址为0x401020。
在hello的反汇编结果中可以查看到每个PLT条目。PLT[0]是一个特殊条目,跳转到动态链接器中。接下来每个条目对应一个调用的库函数,例如PLT[1]对应的是puts函数;PLT[2]对应的是printf函数。
全局偏移量表GOT是一个数组,每个条目为8字节地址,和PLT联合使用。GOT[0] 和GOT[1]包含动态链接器在解析函数地址时会使用的信息,GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。由5.3中的节头部表知,存储GOT的.got.plt节的开始地址为0x40400。在edb中查看初始时的GOT条目。除了PLT[0]外,每个PLT对应的GOT条目初始时都指向这个PLT的第二条指令。在函数第一次被调用时,动态链接器会修改相应的GOT条目。
5.8 本章小结
本章主要介绍链接链接的概念和作用,说明了链接生成可执行文件的过程。整个过程中用截图展示可执行文件的ELF信息,节的内容等。最后分析了程序是如何实现的动态链接的。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:经典定义就是一个执行中程序的实例。广义定义是进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
进程的作用:向用户提供了一种假象:程序好像是系统中当前运行的唯一程序,独占使用处理器和内存;处理器无间断的执行程序中的指令,程序中的代码和数据是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Linux系统中Shell是一个交互型应用级程序,代表用户控制操作系统中的任务,是命令行解释器,以用户态方式运行的终端进程。流程如下:
在Shell命令行中键入$./hello,终端进程读取用户由键盘输入的命令行;分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量;检查首个命令行参数是否是一个内置的Shell命令:如果不是内部命令,调用fork( )创建新进程/子进程;然后在子进程中,用获取的参数调用execve( )执行指定程序;如果用户没要求后台运行,即命令末尾没有&,否则Shell使用waitpid或wait等待作业终止后返回;如果用户要求后台运行,则Shell返回。
6.3 Hello的fork进程创建过程
在Shell命令行中键入$./hello命令,命令行会首先判断是否是一个内置的Shell命令,如果是内置命令则立即对其进行解释。否则将其看成一个可执行目标文件,再调用fork创建一个新的子进程并在其中执行。
终端程序通过调用fork函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈,但是PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。fork函数只被调用一次,却会返回两次:一次在父进程中,返回子进程的PID;一次在子进程中,返回0。
6.4 Hello的execve过程
当调用fork()函数创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,需要以下步骤:
①删除已存在的用户区域。删除之前进程在用户部分中已存在的结构。
②创建新的代码、数据、堆和栈段。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
③映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
④设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
6.5 Hello的进程执行
逻辑控制流是PC值序列。进程会向每个程序提供一种独占使用处理器的假象,即使有其他程序在运行。如果使用调试器单步调试执行程序,我们会看到一系列的程序计数器的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。
并发流:一个逻辑流在执行时间上与另一个逻辑流重叠。
内核模式和用户模式:内核模式和用户模式不是两个进程,而是一个进程的不同模式,由一个模式位来控制,当设置了模式位时,进程就运行在内核模式中,这时候这个进程就可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,用户程序必须通过系统调用接口间接地访问内核代码和数据。运行程序代码的进程一开始是处于用户模式,只有当发生中断、故障或者陷入系统调用这样的异常时,转而去执行异常处理程序,这时进程才会变为内核模式。当它返回到应用程序代码时,处理器就把模式从内核模式改为用户模式。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
上下文信息:内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
上下文切换:内核为每个进程维持一个上下文,上下文就是在进程执行的某些时刻,内核可以决定枪战当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度。系统调用和中断也可能引发上下文切换。
进程hello初始运行在用户模式中,直到它通过执行系统调用sleep陷人到内核。内核中的陷阱处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘中断处理器。在上下文切换切换之前,内核代表进程hello在用户模式下执行指令,之后内核进行上下文切换将当前进程的控制权交给其他进程并执行,当定时器到时时发送一个中断信号,此时进入内核状态执行中断处理,重新运行hello进程。
当hello调用getchar之前,运行在用户模式,之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
6.6.1 hello的异常
hello在运行中,可能会出现以下异常:
中断:在hello程序执行的过程中来自外部I/O设备引起的异常。发生中断后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令。结果是程序继续执行,就好像没有发生过中断一样,
陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep函数的时候会出现这个异常。陷阱处理程序将控制返回给下一条指令。
故障:由错误情况引起,可能能被故障处理程序修复。比如缺页故障。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的abort例程,abort 例程会终止引起故障的应用程序。
终止:不可恢复的致命错误造成的结果,通常是硬件错误,比如DRAM或者SRAM位损坏的奇偶错误。
6.6.2 不停乱按包括回车
如果乱按过程中没有按回车,则只会在屏幕上显示输入的内容。如果输入回车,则getchar读回车,并把回车前的字符串当作shell输入的命令。
6.6.3 Ctrl+Z
如果输入Ctrl+Z会发送一个SIGTSTP信号给前台进程组的每个进程,结果是停止(挂起)前台作业。
6.6.4 Ctrl+C
如果在程序运行过程中输入Ctrl+C,会让内核发送一个SIGINT信号给到前台进程组中的每个进程,结果是终止前台进程。
6.7本章小结
本章介绍了有关进程管理的多个概念。介绍了Shell的作用和处理流程,以及利用fork创建子进程、利用execve加载进程的方法。展示hello程序执行的具体过程,以及异常信号的处理机制。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址,是由一个段标识符加上一个指定段内相对地址的偏移量,在hello中就是各部分在段内的偏移地址。
线性地址:虚拟地址,和逻辑地址类似,也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件也是内存的转换前地址。在hello里面为虚拟内存地址。
虚拟地址:CPU未开启分页功能时,线性地址就被当做最终的物理地址来用;若开启了分页功能,则线性地址就叫作虚拟地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU链接的地址总线相对应,即内存单元的绝对地址。在hello程序中就是虚拟内存地址经过翻译后获得的地址。
如下图所示,在hello1.txt文件中,显示出的地址为虚拟地址。虚拟地址经过地址翻译得到物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成:段标识符和段内偏移量。段标识符是一个16位长的字段组成,称为段选择符,其中前13位是一个索引号。后面3位包含一些硬件细节,表示具体的是代码段寄存器还是栈段寄存器抑或是数据段寄存器索引号。多个段描述符组成一个数组“段描述符表”,通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,由8个字节组成。索引号就是段描述符的索引。段描述符具体描述了一个段地址,这样,很多段描述符就组成段描述符表。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。
Base字段表示包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。段选择符中的T1字段若为0,则用GDT,否则用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
逻辑地址转换为线性地址的一般步骤:
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],判断段选择符的T1为0或1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小,得到一个数组。在这个数组中根据段选择符中前13位查找到对应的段描述符,获得基地址Base。线性地址为Base + offset。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址即虚拟地址(VA)到物理地址(PA)之间的转换通过分页机制完成,而分页机制是对虚拟地址内存空间进行分页。
系统将虚拟页作为进行数据传输的单元。Linux下每个虚拟页大小为4KB。物理内存也被分割为物理页, MMU(内存管理单元)负责地址翻译,MMU使用页表将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。
7.4 TLB与四级页表支持下的VA到PA的变换
先来分析TLB。TLB也就是翻译后备缓冲器,是MMU中一个小的具有高相联度的集合,实现虚拟页号向物理页号的映射,页数很少的页表可以完全放在TLB中。TLB将一个n-p位VPN分为t位的组索引和n-t-p位的标记。在访问时与cache几乎一致,先通过组索引找到所在组,在通过标记位判断是否是我们要访问的虚拟地址,如果命中则从中读取物理页号,并通与VPO组合成物理地址访问数据并将数据返回给CPU。如果不命中则必须从下一级TLB或者内存中寻找。
假设有四级页表。翻译地址的过程如下:首先通过CR3寄存器中存放的一级页表的地址结合VPN读取其中的数据。可以看到一到三级页表中存放的数据是指向下一级页表的首地址,而不是物理页号。逐步访问到第四级页表,第四级页表中装的就是物理页号,通过第四级页表读出的物理页号链接上虚拟地址中的VPO就可以获得物理地址了。翻译过程如图7-4所示。要注意由于每一级页表条目都有权限,如果权限出错就会产生段错误。
7.5 三级Cache支持下的物理内存访问
在从TLB或者页表中得到物理地址后,根据物理地址从Cache中寻找。到了L1里面以后,寻找物理地址要检测是否命中,不命中则紧接着寻找下一级Cache L2,接着L3,如果L3也不命中,则需要从内存中将对应的块取出放入Cache中,其中可能会发生块的替换等其它操作。这里使用到CPU的高速缓存机制,一级一级往下找,直到找到对应的内容。
7.6 hello进程fork时的内存映射
在shell输入命令行后,内核调用fork创建子进程,为hello程序的运行创建上下文,并分配一个与父进程不同的PID。通过fork创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构。
映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
映射共享区域:如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
设置程序计数器PC:exceve最后设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
下一次调用这个进程时,它将从这个入口点开始执行。
7.8 缺页故障与缺页中断处理
缺页故障的概念是,当指令引用一个虚拟地址,而与该地址相对于的物理页面不在内存中,因此必须从磁盘中取出时,就会发送缺页故障。
缺页中断处理:假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常当值控制转移到内核的缺页处理程序,处理程序随后指向下面的步骤:
(1)地址是否合法? 搜索区域链表,确认地址在合法的某个区域内;否则,非法引发段错误。
(2)访问是否合法? 有读、写或执行区域内页面的权限。否则,违反许可,触发保护异常,段错误 。
(3)此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法操作造成的。它是这样来处理这个缺页的:选择一个牺牲页,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动导致缺页的指令,这条指令将再次发送A到MMU。这次MMU就能正常的翻译A,而不会再产生缺页中断了。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
7.9.1 动态内存分配器
malloc函数返回一个指针,指向大小为至少参数字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。在32位模式中,malloc返回的块的地址总是8的倍数。在64位模式中,该地址总是16的倍数。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。分配器有两种基本风格,都要求显示的释放分配块:
显式分配器:要求应用显式地释放任何已分配的块。例如, C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。
隐式分配器:也叫做垃圾收集器。分配器检测一个已分配块不再被程序所使用,那么就释放这个块。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.9.2 隐式空闲链表
隐式空闲链表的空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合,但是需要某种特殊标记的结束块。
隐式空闲链表组织堆
隐式空闲链表的优点是简单,但是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系,且系统对齐要求和分配器对块格式的选择会强制要求分配器上的最小块大小。没有块可以比这个最小值还小。例如,如果我们假设一个8字节的对齐要求,那么每个块的大小都必须是8字节的倍数。即使应用只请求一字节,分配器也仍然需要创建一个两字的块。
放置已分配的块当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求的空闲块。分配器搜索方式的常见策略是首次适配、下一次适配和最佳适配。
当分配器找到一个匹配的空闲块时,通常将空闲块分割为两部分。第一部分变为了已分配块,第二部分变为了空闲块。
当分配器找不到合适的空闲块,一个选择是合并那些在物理内存上相邻的空闲块,如果这样还不能生成一个足够大的块,分配器会调用sbrk函数,向内核请求额外的内存。
当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种假碎片现象,有许多可用的空闲块被切割成为小的、无法使用的空闲块。而Knuth提出的一种采用边界标记的技术可以快速完成空闲块的合并:
使用边界标记的堆块格式合并可以在常数时间内完成。
7.9.3 显示空闲链表
显示空闲链表是将空闲块组织为某种形式的显示数据结构。在每个空闲块中,都包含一个前驱和后继的指针。使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到空闲块数量的线性时间。释放一个块的时间取决于空闲链表中块的排序策略:
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
使用双向空闲链表的堆块格式
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章介绍了存储管理的有关内容。介绍了存储器的地址空间:物理地址、虚拟地址、逻辑地址、线性地址,然后对段式管理和页式管理进行了较为详细的描述,同时还讨论了VA到PA的变换、物理内存访问、fork和execve的内存映射、缺页故障和缺页处理、动态存储分配管理等内容。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件,即所有IO设备都被模型化为文件,所有的输入和输出都能被当做相应文件的读和写来执行。
设备管理:Unix I/O接口,Linux内核有一个简单、低级的接口,成为Unix I/O,是的所有的输入和输出都能以一种统一且一致的方式来执行。
一个linux文件就是一个m个字节的序列。所有的I/O设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做相应的文件的读和写来执行。这种将设备优雅地映射为文件的方式,运行linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix IO接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux Shell创建的每个进程都有三个打开的文件:标准输入(描述符为0),标准输出(描述符为1),标准错误(描述符为2)。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
4.读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放他们的内存资源。
Unix I/O函数:
1.int open(char* filename,int flags,mode_t mode)
进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
2.int close(fd)
进程通过调用close函数关闭一个打开的文件,fd是需要关闭的文件的描述符。
3.ssize_t read(int fd,void *buf,size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
4.ssize_t wirte(int fd,const void *buf,size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
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;
}
printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
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);
}
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall
syscall实现:
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将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),于是我们的打印字符串就显示在了屏幕上。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。先来看getchar的源代码:
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实现的。getchar通过read函数从缓冲区中读入一行,并返回读入的第一个字符,若读入失败则返回EOF。read的具体实现如下:
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要介绍了Linux的I/O设备的基本概念和管理方法,以及Unix I/O接口及其函数,分析了printf和getchar函数的实现。
结论
hello的一生是这样走完的。
(一) 编写,将代码键入hello.c。
(二) 预处理,将hello.c调用的所有外部的库展开合并到一个hello.i文件中。
(三) 编译,将hello.i编译成为汇编文件hello.s。
(四) 汇编,将hello.s会变成为可重定位目标文件hello.o。
(五) 链接,将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello。
(六) 运行:在shell中输入“./hello 120L020412 张玄靓 1”。
(七) 创建子进程:shell进程调用fork为其创建子进程。
(八) 运行程序:shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数。
(九) 执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。
(十) 访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
(十一) 动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存。
(十二) 信号:如果运行途中键入Ctrl-C、Ctrl-Z则调用shell的信号处理函数分别停止、挂起。
(十三) 结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。
通过本次实验,我感悟到计算机系统设计的精巧以及严谨性,每一种机制都能够恰到好处地完成应该完成的功能,并且消耗尽可能少的资源。几乎任何一种异常情况都能够得到合适地处理。可以说我们能有如今的计算机编程环境以及各种基于计算机的应用,都离不开这些底层系统的设计与实现。当然,计算机的发展远远没有结束,人们还在追求更快的数据传输,更大的存储空间,这些都是我们以后要学习研究的。
附件
列出所有的中间产物的文件名,并予以说明起作用。
1:hello.c:源程序;
2:hello.i:预处理之后的文本文件;
3:hello.s:编译之后的汇编文件;
4:hello.o:汇编之后的可重定位目标文件;
5:hello:链接之后的可执行目标文件;
6:hello.elf:hello.o的elf格式,查看hello.o的各节信息;
7:hello1.elf:hello的elf格式,查看hello的各节信息;
8:hello1.txt:hello.o的反汇编文件;
9:hello2.txt:hello的反汇编文件。
参考文献
[1] 深入理解计算机系统原书第3版-文字版.pdf
[2]https://www.cnblogs.com/diaohaiwei/p/5094959.html
[3]https://www.cnblogs.com/pianist/p/3315801.html
[4] https://www.cnblogs.com/xelatex/p/3491305.html
[5]https://blog.csdn.net/yueyansheng2/article/details/7886004