程序人生-Hello’s P2P

目  录

第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的预处理结果解析... - 6 -

2.4 本章小结... - 6 -

第3章 编译... - 7 -

3.1 编译的概念与作用... - 7 -

3.2 在Ubuntu下编译的命令... - 7 -

3.3 Hello的编译结果解析... - 7 -

3.4 本章小结... - 7 -

第4章 汇编... - 8 -

4.1 汇编的概念与作用... - 8 -

4.2 在Ubuntu下汇编的命令... - 8 -

4.3 可重定位目标elf格式... - 8 -

4.4 Hello.o的结果解析... - 8 -

4.5 本章小结... - 8 -

第5章 链接... - 9 -

5.1 链接的概念与作用... - 9 -

5.2 在Ubuntu下链接的命令... - 9 -

5.3 可执行目标文件hello的格式... - 9 -

5.4 hello的虚拟地址空间... - 9 -

5.5 链接的重定位过程分析... - 9 -

5.6 hello的执行流程... - 9 -

5.7 Hello的动态链接分析... - 9 -

5.8 本章小结... - 10 -

第6章 hello进程管理... - 11 -

6.1 进程的概念与作用... - 11 -

6.2 简述壳Shell-bash的作用与处理流程... - 11 -

6.3 Hello的fork进程创建过程... - 11 -

6.4 Hello的execve过程... - 11 -

6.5 Hello的进程执行... - 11 -

6.6 hello的异常与信号处理... - 11 -

6.7本章小结... - 11 -

第7章 hello的存储管理... - 12 -

7.1 hello的存储器地址空间... - 12 -

7.2 Intel逻辑地址到线性地址的变换-段式管理... - 12 -

7.3 Hello的线性地址到物理地址的变换-页式管理... - 12 -

7.4 TLB与四级页表支持下的VA到PA的变换... - 12 -

7.5 三级Cache支持下的物理内存访问... - 12 -

7.6 hello进程fork时的内存映射... - 12 -

7.7 hello进程execve时的内存映射... - 12 -

7.8 缺页故障与缺页中断处理... - 12 -

7.9动态存储分配管理... - 12 -

7.10本章小结... - 13 -

第8章 hello的IO管理... - 14 -

8.1 Linux的IO设备管理方法... - 14 -

8.2 简述Unix IO接口及其函数... - 14 -

8.3 printf的实现分析... - 14 -

8.4 getchar的实现分析... - 14 -

8.5本章小结... - 14 -

结论... - 15 -

附件... - 16 -

参考文献... - 17 -

第1章 概述

1.1 Hello简介

1.1.1  hello的P2P过程

Hello.c作为c语言这门高级语言写出的源程序,想要变成真正运行在系统中的一个进程,中间需要许多的步骤。首先hello.c需要经过预处理,编译,汇编,链接等来生成一个可执行程序,然后系统会为新的程序新建进程,到这一步就算完成一个程序的P2P了。

      1.1.2  hello的020过程

为hello创建一个进程后系统就会在其中运行hello的程序,这时改变的包括堆,栈等程序上下文的不同,而当hello运行结束后,会有专门的处理方法回收这些死去的进程,使得它没有留下痕迹,这就是hello的020的过程。

1.2 环境与工具

环境      Intel core i7 CPU,virtual box ,Ubuntu

工具:        Codeblocks,gcc,edb,gdb,

1.3 中间结果

hello.i 预处理产生文件

hello.s 编译产生文件

hello.o 汇编产生文件

hello.out 链接产生文件

hello 链接产生可执行文件

1.4 本章小结

       主作为开头的一张主要介绍了hello的P2P以及O2O过程,并介绍了此次大作业所使用的硬件设备以及软件环境,并且列出了此次大作业产生的一系列文件

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念

hello.c是一个高级的C语言程序,这是因为这种形式能够被人读懂,所以为了在系统上运行hello.c程序需要经历一系列的步骤,其中第一步就是利用预处理器进行预处理。所谓预处理就是将程序中一些以#开头的程序行进行解释和处理,并在处理后删除这些#开头的程序行,生成有效代码,为后续的编译完成铺垫。

2.1.2 预处理的作用

      预处理主要执行以下几个方面的工作:

  1. 宏定义命令:使用#define来定义一个宏,预处理时会将程序中所有的宏替换为其在宏定义中被定义的值并删除这条指令,这种方式更像一种文本替换而非对一个变量的创建于调用,宏变量一般全大写以便与正常变量区分。
  2. 文件包含定义:使用#include来将另一个源文件的内容合并到源程序中,预处理时会从标准库路径(<xxx.h>)或用户工作路径(”xxx.h”)查找指定的头文件,找到后将头文件的内容插入源程序中并删除这条命令。
  3. 条件编译命令:使用#if,#ifdef,#ifndef,#else,#endif来进行条件编译,根据表达式的值和变量是否被定义来选择一部分程序进行编译,这种选择就是在预处理阶段完成的。
  4. 替换注释语句:在预处理过程中会把注释语句和语段用空格进行替换,为后续的编译步骤做好准备。

2.2在Ubuntu下预处理的命令

在Ubuntu的命令行中使用gcc编译器进行编译的命令是:

gcc  -E  hello.c  -o  hello.i

其中-E参数代表只进行预处理,hello.i是为预处理得到的文本进行命名。

 

图2.1 预处理的命令与结果

2.3 Hello的预处理结果解析

 

图2.2预处理得到的hello.i

可以看到经过预处理本来只有而是几行的hello.c程序变成了一个三千多行的程序,这是因为hello.c中包括stdio.h,unist.h.stdlib.h这三个头文件,在预处理时gcc用这三个头文件中的具体内容替换了那三行#include命令,导致程序变成了三千多行,前面的都是那些头文件中的定义与函数,最后的二十几行才是我们本来的hello程序。另外可以看到,本来在hello.c中的用以说明程序用法的注释不见了,这就是预处理将注释替换为空格的结果。

2.4 本章小结

总的来说,预处理就是把hello.c完全的转换为有效代码,通过替换宏,替换头文件,处理条件编译,用空格替换注释这些操作使得其中的每一行都是可以在编译时正确得到翻译的有效代码。由于为了我们程序员的方便出现了头文件引用,注释这些功能,所以不能直接对源程序进行编译,必须经过预处理的hello.c才能被编译,这就是预处理的必要性与其作用。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念

编译指的是编译器(ccl)将文本文件hello.i翻译成文本文件hello.s的过程,得到的hello.s中包含一个汇编语言程序,汇编语言为不同的高级语言提供了通用的输出语言,从而使得高级语言转为汇编语言后再被汇编器翻译成机器语言。总的来说编译就是把预处理得到的程序翻译成汇编语言的过程。

3.1.2编译的作用

如同上面说的,编译将高级语言写出的程序翻译成汇编语言的过程,这一步最大的作用就是将不同的高级语言下的程序统一为汇编语言,这样在P2P过程中的后一步,汇编的过程中,汇编器就可以只处理一种汇编语言,将其再翻译为机器语言。并且编译提供了程序的汇编语言形式,可以给我们从汇编层面检查和改进我们程序的机会。

注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序

3.2 在Ubuntu下编译的命令

在Ubuntu下用gcc编译的命令是:

gcc  -S  hello.i  -o  hello.s

其中-S参数指明结果为汇编程序。

 

图3.1 hello.i到hello.s的编译命令与生成的文件

3.3 Hello的编译结果解析

 

 

 

图3.2  汇编程序hello.s

3.3.1 汇编程序整体解析

汇编程序hello.s整体有80行代码,其中前后各有一部分是编译器生成的代码,与main函数中具体内容无关,不做解析,从12行开始main函数开始,一直到60行在各种分支下main函数都会结束,整体来说通过hello.s通过栈和寄存器存储数据并完成函数间的参数传递,通过条件控制实现条件分支和for循环,通过调用系统的函数完成信息的读入与输出。

3.3.2 数据类型解析

在本程序中涉及到的数据类型包括整型,字符串和地址。这里传给main函数的参数有两个,都是系统定义好的,第一个argc是一个整形量,表示命令行传入的参数个数,它最初在存放函数第一个参数的rdi存储器中,然后在第22行被赋给栈中某个位置,作为一个整型变量占四个字节的位置。而main的第二个参数argv是一个字符串数组,记录命令行输入的每个单词,这个字符串数组的第0项的地址作为第二个参数被保存在rsi寄存器中,并在第23行被存入栈中。后面逐项处理这个字符串数组时,也是通过地址的计算得出这个字符串每项的首地址并各自存放在不同寄存器中从而获取这每一项中存储的内容。

总结来说,汇编语言通过寄存器和栈存储所有类型的数据,但其占有的长度不同,例如整型是32位4个字节。而像字符串数组复杂的数据结构,可以通过地址进行索引,一个地址是64位8个字节,这里需要注意区分寄存器自身的地址和存储的目标对象的地址。一个字符串数组可以通过保存首项的地址进行存储,而之后的每项也通过地址进行索引,比如首项地址+8后取出地址存放的数据就是字符串数组的第二项,通过这样的方式,汇编程序就可以实现对整型,字符串,数组这些简单或复杂的数据类型的管理。

3.3.3 赋值操作解析

本程序中存在一个循环变量i,这里i在for循环中被赋了初值0,这体现在程序第31行,hello.s中用栈中rbp-4到rbp这四位来存储i,其中第31行使用movq指令将常数0赋给i。

汇编程序中赋值主要使用movq指令,leaq指令等,其中leaq指令是movq指令的变形,作用是加载有效地址。

3.3.4 类型转换解析

在源程序hello.c中使用了atoi这一从字符串到整型的类型转换来获取sleep函数的参数,也就是两次打印之间休眠的时间,在其对应的汇编程序中同样调用了系统写好的atoi这一函数完成类型转换,其会在后面的链接时获得对应的具体实现。

3.3.5 算术操作解析

本程序中使用的算术操作包括加法和减法,分别由addq和subq两个命令完成。程序中使用算出操作完成的工作包括循环变量i的每次循环+1,这一步在第51行,另一部分是在进行地址计算时使用的加法和减法,比如想找到字符串数组下一项的首地址就应该把前一项的首地址+8,或者在首项的地址上加上8的倍数,这也是本程序索引字符串数组中每一项使用的方法。

3.3.6 逻辑/位操作解析

本程序中没有用到与位相关的操作以及逻辑操作,这些操作在汇编程序中有些有专门与之对应的命令,有些则需要使用mov命令根据具体情况具体实现。

3.3.7 关系操作和控制转移的解析

本程序中使用了几种不同的关系操作,包括不等于,小于等于关系的判断。这些关系操作是由基于条件码的跳转指令完成的,在cmpl指令后使用各种跳转指令即可达到不同的目的,例如je指令是cmpl两个参数相等时跳转到指定行,否则继续向下。首先在24,25行将argc的值和4进行比较,如果相等就跳转到L2,否则就继续执行,这里实现了源程序中对参数个数的检查,如果是4继续,否则输出错误指令并退出。这一过程就完成了对源程序中if-else语句的翻译。另外,在53,54行比较了循环变量i和7,如果i小于等于7则跳转到L4执行循环体,否则继续向下执行跳出循环并结束程序。这一部分完成的是对源程序中for循环的翻译。C语言中使用的关系操作在汇编语言中使用条件控制的跳转指令均可以完成,而C语言中各种类型的控制转移在汇编语言中基本也依靠条件控制来实现,但switch语句尤其自身对应的汇编语言命令。

3.3.8 数组/结构/指针操作解析

源程序hello.c中使用了一个字符串数组argv,并在printf函数和sleep函数中分别调用了它的某一项或几项作为函数的参数,在汇编语言中数组的操作和索引是通过基于地址的计算方法来完成的。比如在这个程序中,传给main函数的第二个参数argv本质是其首项的首个字符的地址,这个地址被存放在了栈中(rbp-32),之后当程序需要索引数组某一项的值时。汇编语言首先取出存放在栈中的这个首项地址,然后将其加上8的倍数,具体多少取决于想要哪一项,再把得到的新地址存放在一个寄存器中,这样将这个寄存器中的内容作为参数传递给函数后,函数从这个地址取出的内容就是字符串数组的对应一项,程序34-47行中使用了三次这种方法来获取argv的后三项作为参数。

前面已经说过,数组通过地址来保存和索引,指针也是一个地址,这里我们只要区分好一个寄存器中保存的到底是一个具体的值还是这个值所在的地址,以及区分好赋值时是将栈的某个地址赋给寄存器还是这个地址中存放的内容赋给寄存器就好。

3.3.9 函数操作的解析

这个汇编程序中涉及到的函数有main函数,printf,getchar,atoi,puts,exit,sleep其中后面这些都是标准库中提供的函数,需要在后面链接时加入程序。汇编语言中程序的返回值默认存放在rax寄存器中,传入参数默认按顺序存储在rdi,rsi,rdx,rcx等6个寄存器中,多的只能存在栈里,所以这里22,23行就是把传入的第一个参数argc和第二个参数argv存储在栈中,后面L4部分中通过把argv的后三项分别按顺序保存在rdi,rsi,rdx这三个寄存器中,使得调用printf和sleep这两个系统函数时可以获取到它们作为参数。本程序没有用到rax作为返回值的情况,程序中出现的rax都被直接赋了初值(rbp-32)。

总的来说,通过规定好的寄存器和栈指针,汇编语言可以实现向函数传入参数以及接受函数的返回值,完成源程序中的函数操作,

此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析

3.4 本章小结

编译就是把预处理后的程序翻译成汇编语言的过程,通过汇编语言的方式实现C语言中的算术与逻辑操作,控制转移,数组,指针,函数等各种结构和行为的。汇编语言有时与源程序并不能一一对应,尤其是编译器进行优化之后,理解汇编这个过程以及掌握汇编语言有助于我们再写程序时避免一些基础的错误,并从汇编语言的层面进行程序的设计与优化,就像我们在实验三中进行的一样。所以编译和汇编语言是十分重要,需要好好掌握的。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

       4.1.1 汇编的概念

汇编是指通过汇编器,将汇编代码转化为二进制目标代码的过程。得到的二进制目标代码是一些01串,它包含所有指令的二进制表示,但是还没有填入全局值的地址,已经是机器代码的格式。

       4.1.2 汇编的作用

汇编的作用简单来说就是把汇编代码转换为二进制目标代码(机器代码),也就是把hello.s文件翻译成hello.o文件。因为计算机最终执行各种命令只能通过识别01串,所以用任何高级语言编写的程序,包括汇编语言,最终都必须被转换为01串形式的命令,才能被CPU理解和执行,根据汇编代码生成这种01形式的代码,就是汇编的作用。

注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。

4.2 在Ubuntu下汇编的命令

在Ubuntu下用gcc进行汇编的命令是:

gcc  -c  hello.s  -o  hello.o

其中-c参数指明只进行到编译,也就是不进行链接就输出目标程序。

 

图4.1  利用gcc进行汇编的指令与结果

4.3 可重定位目标elf格式

       4.3.1  ELF头分析

 

图4.2  ELF头

       ELF头以一个十六字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,也就是图中的Magic后面的数字序列。ELF头剩下的部分包含帮助连接器与反分析和解释目标文件的信息。Type后描述的是ELF文件类型,此处为REL文件。Machine后描述的是ELF文件的CPI平台属性,Version后描述的是ELF版本号,一般都为1,Entry point address后是ELF程序入口的虚拟地址(对重定位文件来说一般是0),后面两行记录了section header table在文件中的偏移,Flags后是ELF标志位,用来标识一些ELF文件平台相关的属性,下一行的size是这个ELF header本身的大小,再下两行是程序头表的大小和数量,这里没有程序头表(因为是重定位文件),再下两行是每个section header的大小section header的数量,最后一行是Section Header字符串表在Section Header Table中的索引。

       4.3.2  对各个节的分析

 

图4.3  ELF文件的各个节

                图中列出了ELF文件的各个节,其中.text是已编译程序的机器代码,.rodata是只读数据,.data是已初始化的全局和静态C变量,.bss是未初始化的全局和静态C变量,.symtab是一个符号表,存放在程序中定义和引用的函数和全局变量的信息,.strlab是一个字符串表,.rela.text是一个.text节中位置的列表。

分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。这些描述目标文件的节在最后都被写在了节头部表中,ELF 节头表是一个节头数组。每一个节头都描述了其所对应的节的信息,如节名、节大小、在文件中的偏移、读写权限等。编译器、链接器、装载器都是通过节头表来定位和访问各个节的属性的。

 

图4.4  ELF文件中的节头表

       4.4.3 对重定位表的分析

 

图4.5  ELF文件的重定位表

重定位就是将符号定义和符号引用进行连接的过程。可重定位文件需要包含描述如何修改节内容的相关信息,从而使可执行文件和共享目标文件能够保存进程的程序镜像所需要的正确信息。重定位表是一个Elf_Rel类型的数组结构,每一项对应一个需要进行重定位的项。其中offset是需要被修改的引用的节偏移,info标识被修改引用应该指向的符号,type告知链接器如何如何修改新的引用,(主要是R_X86_64_PC32间接寻址和R_X86_64_32绝对地址寻址两种),addend是一个有符号常数,一些类型的重定位要使用他对被修改引用的值做偏移调整,比如图中的ELF文件对.rodata进行了-4的偏移调整,对puts也进行了-4的偏移调整等。

一个可重定位文件的ELF格式主要就包括ELF头,节,节头部表,重定位表这几部分,通过这些数据进行的重定位对后续的链接工作有相当的意义。

4.4 Hello.o的结果解析

 

 

图4.6  objdump反汇编hello.o的结果

4.4.1 反汇编结果的分析

反汇编得到的代码每一行有三部分,分别是虚拟地址,机器代码,对应的反编译出的汇编代码,其中机器代码为几个16进制的数,它们在内存中是01串,分别表示了要进行的操作种类以及进行操作的参数来自哪个寄存器,将机器代码按照对应规则进行翻译,就可以得到反汇编代码,不同的二进制数被翻译为对应的指令和寄存器。这样得到的反汇编代码应该是与hello.s类似的,但也有一些不同,接下来我们分析这些不同。

4.4.2 反汇编代码与hello.s的不同

首先的不同是一些结构上的不同,比如hello.s中存在一些cfi指令指示函数的开始与结束等,而在反汇编代码中没有对应的内容。在调用标准库函数时,hello.s中使用@PLT进行说明,但在反汇编代码中,使用重定向的方法指明想要调用的函数在内存中的地址,这可以通过间接寻址来实现,即根据PC当前值加上若干数后再给出目标函数开头处的偏移值。

接下来是在栈中存储时操作数的不同,hello.s中main函数的两个参数分别被保存在rbp-20和rbp-32,而在反汇编得到的代码中,它们分别被保存在rbp-14和rbp-20中(行c,f),但其实这本质是相同的,因为在hello.s中使用的是十进制的表示方法,而在饭会变得结果中使用的是十六进制的表示方式,导致看上去不同。在后面计算argv不同享的首地址的时候也出现了类似的问题,但其本质是相同的。

接下来进行分支转移与函数调用的不同,在这一部分的第一段已经提到过,hello.s对标准库函数标注@PLT来直接调用,但反汇编的结果通过重定向与间接寻址找到其在内存中的虚拟地址,目前为空,等待链接后就会填入正确的虚拟地址来供调用。而分支转移在hello.s中使用的都是条件控制的转移,用L2,L3等做标识符,而在反汇编结果中,有jmp实现的条件控制的转移,也有callq实现的转移,并且使用的是虚拟地址作为转移目标的确定方式。整体转移的逻辑还是与hello.s中相同,即实现了一个if分支与一个for循环。这里需要注意19行指令跳转到第零行结束了程序,没有继续,和源程序中hello.c意思一致。

4.5 本章小结

汇编是把汇编程序中转换为二进制机器代码的过程,并在这个过程中进行了重定向相关的操作,即hello.s文件被翻译成了hello.o这一可重定向文件。通过对hello.o的ELF格式的分析我们了解到作为一个可重定向的文件它具有记录了不同信息的许多小节与指明重定向方式的重定向表,而通过对hello.o的反汇编的分析我们观察到它与hello.s的种种不同,这些不同表明了它更贴近机器的特性,如使用十六进制而非十进制,使用虚拟内存类完成跳转指令等。总的来说,汇编是将程序转换为机器可以理解的内容的关键一步,在P2P的过程中十分重要,通过对可重定向文件的反汇编分析,我们也能获得对应程序的一些信息,所以汇编和汇编器也是十分重要的内容。

(第41分)

5章 链接

5.1 链接的概念与作用

5.1.1  链接的概念

链接时将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接是由一个名为链接器的程序自动执行的,它把hello.o这个可重定位目标程序转换为hello这个可执行目标程序。经过链接这一步,P2P的过程就算完成了,最终我们得到了一个可以执行的程序。

5.1.2  链接的作用

链接的作用就是把各个模块组织结合成一个可执行的程序,这些模块中有用户编写的,有系统提供的标准库,也有用户自己引入的外部库。链接器在软件开发中有着关键的作用,它使得分离编译成为可能,从而我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小,更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个是,只需简单的重新编译它,并重新链接应用,而不必重新编译其他文件。

注意:这儿的链接是指从 hello.o 到hello生成过程。

5.2 在Ubuntu下链接的命令

       在Ubuntu下对进行链接的命令是:

       ld  -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 /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello.out

 

图5.1  Ubuntu下对hello.o进行链接

5.3 可执行目标文件hello的格式

利用readelf可以查看hello.out这一可执行文件的elf格式,分为如下几个部分。

       5.3.1  ELF头

 

图5.2  hello.out的ELF头

此处的ELF头与hello.o的区别包括类型变味了EXEC(可执行文件),存在一个程序进入结点0x4010f0,这个程序就从这里进入(.text的开始位置)。ELF头各项的含义与hello.o基本相同,可以看到ELF头占了64字节,每个程序头表占56字节,共12个,每个节头部表占64字节,共30个,最终总共偏移了14800个字节。

       5.3.2  各节信息

 

 

图5.3  hello.outELF文件的节信息

可执行程序的节数比其对应的可重定向程序要更多,这里每个节的name指明了节名,type指明节类型,link,info指明节链接信息,offset是偏移值,用程序的虚拟地址的起始加上偏移值就可以得到各个节的起始地址,size指明每一节的大小,entsize指明节项大小(节中某些大小相等的符号),align指明节的对齐方式。这样我们可以根据这张表得到每一节的起始地址和大小等各种信息。

       5.3.3  程序头表的解析

 

图5.4  ELF文件的程序头表

这是hello.outELF格式的程序头表,它描述了每个段在文件中的位置、大小以及它被放进内存后所在的位置和大小。程序头表有一列是type类型,该类型用来精确的描述各段。PHDR保存程序头表,INTERP指定程序从可执行文件映射到内存之后,必须调用的解释器(就像java需要java虚拟机解释一样)。这个解释器可以通过链接其他程序库,来解决符号引用。LOAD表示一个需要从二进制文件映射到虚拟地址空间的段,其中保存了常量数据(字符串等)、程序目标代码。DYNAMIC保存了动态链接器(前面interp指定的解释器)使用的信息。GNU_STACK:权限标志,用于标志栈是否是可执行。GNU_RELRO:指定在重定位结束之后哪些内存区域是需要设置只读。

而每一段的Filesiz关键字和Memsiz关键字给出了每一段的文件大小和占用内存的大小,PhysAddr关键字给出的是每个段的物理地址,offset是偏移值,剩下的flags,align给出的是其它的信息,这样我们就知道了一个可执行程序每个段在内存中的位置和其所占空间的大小。

可执行程序的ELF文件还包括一些其他的部分,比如重定向表,符号表等,它们提供了其他的信息,这里不做详细的解析了。

5.4 hello的虚拟地址空间

 

 

图5.5  hello.o main函数机器语言程序的开头段

 

图5.6  edb中显示的虚拟内存与其存储内容

       用edb加载hello.out,在edb中可以查看虚拟地址空间各段信息,可以看到在5.3中我们分析了程序的入口是虚拟地址4010f0,并且在ELF文件中的节头部表中也可以看到,.text这一存储了已编译程序的机器代码的节的起始地址也是4010f0,那么在edb中找到4010f0,观察发现果然4010f0存储的就是我们编写的hello.c这个源程序对应的机器代码(与上面反汇编代码中的机器代码部分比较),说明4010f0这个虚拟地址就是我们的机器程序的起始位置。

       而从程序头表的角度来看,根据5.3的结果,程序目标代码(机器代码)属于LOAD段应该保存的内容,而从5.3也可以看到,有一个LOAD段是从40100开始,大小为0x2f5字节,这就包含了4010f0这个虚拟地址,说明这个LOAD段确实包含了我们的程序的机器代码。

       类似的,根据节头部表和程序头表中各个节和段的虚拟地址,在edb中可以找到对应的部分。

5.5 链接的重定位过程分析

5.5.1  hello.out与hello.o的反汇编结果的不同与链接的过程

相比于hello.o,hello.out的反汇编结果有几处不同,首先,多了很多节与函数,包括_init, .plt,调用的各个函数如sleep,printf等,_start, _dl_relocate_static_pie, register_tm_clones, __do_global_dtors_aux, frame_dummy, __libc_csu_init, __libc_csu_fini, _fini。这些都是可执行文件相比可重定向文件编译时多出来的部分,其中有些用于初始化,有些定义了开始与结束,各自都有各自的作用。

除了多出了许多部分,hello.out的反汇编中对地址的调用与hello.o并不相同,hello.o中的跳转的目标是行的号码,并非一个实际地址的格式,而函数调用时也没有指定地址,而是给出了重定向的方法,而在hello.out中,jne,callq等指令都直接指向一个确切的虚拟内存中,比如jmp 40123d,而对函数的调用也已经给出了具体的虚拟地址,比如先callq 4010d0的<sleep@plt>,而在<sleep@plt>中给出了具体的sleep函数所在的地址,也就是0x25fd,这就是链接完成的事情,根据可重定位文件中提供的信息,找到具体某个函数在虚拟地址中的地址,并使得可执行文件可以到这个地址来调用它,从而将各个库,源程序全部链接在一起,通过虚拟地址互相调用。

5.5.2  对hello.o到hello重定位的分析

在hello.o中标识了诸如R_X86_64_PLT32    sleep-0x4这种类型的重定位或者R_X86_64_PC32    .rodata-0x4这种重定位信息,在从hello.o到hello的过程中,链接器根据这样的重定位信息计算出诸如rodata这样一个节或者sleep这样一个函数的首地址应当被重定向到虚拟内存的哪个部分。其中R_X86_64_PC32就是根据当前程序开始的地址进行间接寻址定位,而R_X86_64_PLT32应当是根据PLT中具体函数的位置进行定位,定位后再减去后面的偏移值就得到某一节或者某个函数的起始地址。具体的例子比如sleep和rodata已经给出,计算出具体的地址后,就可以在可执行文件中直接取址,比如jmp 40123d和sleep中的bnd jmpq *0x2f5d(%rip)。

5.6 hello的执行流程

 

图5.7   使用edb运行hello.out

从加载开始有如下过程:

函数名称                 地址

ld-2.27.so!_dl_start 0x7f4ea4db4ea0

hello!_init 0x0000000000400488

hello!main 0x00000000004005e7

hello!puts@plt 0x00000000004004b0

hello!printf@plt 0x00000000004004c0

hello!getchar@plt 0x00000000004004d0

hello!exit@plt 0x00000000004004e0

hello!sleep@plt 0x00000000004004f0

hello!_start 0x0000000000400500

ld-2.27.so!_dl_fixup 0x00007f83ffb93f64

ld-2.27.so!_dl_lookup_symbol_x 0x00007f83ffb8f0b0

ld-2.27.so!do_lookup_x 0x00007f83ffb8e240

ld-2.27.so!__assert_fail 0x00007f8a388bb790

ld-2.27.so!__GI___tunables_init 0x00007f8a388b8c50

ld-2.27.so!__libc_check_standard_fds 0x00007f8a388bc6c0

ld-2.27.so!__strerror_r 0x00007f8a388bb670

ld-2.27.so!__tunable_get_val 0x00007f8a388b9250

ld-2.27.so!_dl_add_to_namespace_list 0x00007f8a388ac000

ld-2.27.so!_dl_cache_libcmp 0x00007f8a388b7f70

libc-2.27.so!exit 0x00007fce8c889128

使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。

5.7 Hello的动态链接分析

hello在调用.so共享库函数时,会涉及到动态链接。现代系统在处理共享库在地址空间中的分配的时候,采用了位置无关代码(PIC)方式。位置无关代码指,编译共享模块的代码段,是把它们加载到内存的任何位置而无需链接器修改。用户对GCC使用-fpic选项指示GNU编译系统生成PIC代码。共享库的编译必须总是使用该选项。

PIC代码引用包括数据引用和函数调用。对数据引用有一个事实,就是代码段中任何指令和数据段中任何变量之间的距离是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。在编译器想要生成对PIC全局变量引用时,在数据段开始的地方创建了全局偏移量表(GOT)

动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址,在汇编代码中也无法像静态库的函数那样体现。

当某个动态链接函数第一次被调用时先进入对应的PLT条目例如PLT[2],然后PLT指令跳转到对应的GOT条目中例如GOT[4],其内容是PLT[2]的下一条指令。然后将函数的ID压入栈中后跳转到PLT[0]。PLT[0]通过GOT[1]将动态链接库的一个参数压入栈中,再通过GOT[2]间接跳转进动态链接器中。动态链接器使用两个栈条目来确定函数的运行时位置,用这个地址重写GOT[4],然后再次调用函数。经过上述操作,再次调用时PLT[2]会直接跳转通过GOT[4]跳转到函数而不是PLT[2]的下一条地址。

hello程序对动态链接库的引用,基于数据段与代码段相对距离不变这一个事实,因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。

在dl_init前后发生变化如下:

通过EDB调试,能够看出这个变化。先观察调用dl_init前,动态库函数指向的地址。从上面可以找到got表头的地址00403ff0,可以观察到运行dl_init前其中存储的值为0,而在运行dl_init后,存储的值发生了变化,指向了动态链接器入口的位置。

 

  

图5.8  程序动态链接前got的值

 

图5.9  程序运行后got的值

5.8 本章小结

这一章主要分析了与hello的链接相关的过程,链接也分为静态链接和动态链接,可以通过edb,gdb这些工具来进行观测,动态链接是系统实现多个函数同时调用某些系统库函数的策略。当经历链接这步后,原来的程序中空白的部分已经被补全,现在的hello.out已经是一个可执行的程序,可以说hello的P2P的过程在这一步之后完成了重要的一部分。

(第51分)

6章 hello进程管理

6.1 进程的概念与作用

6.1.1  进程的概念

进程被定义为一个执行中程序的实例。系统的每个程序都运行在某个进程的上下文中。而上下文是由程序正确运行所需的状态组成的,这个状态包括存放在内存中的程序和它们的代码数据,以及程序的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述器的集合。

6.1.2  进程的作用

进程的作用是为应用程序提供关键抽象。首先是一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器,就好像我们的程序是系统中当前运行的唯一的程序一样,处理器好像是在无间断的一条一条的执行我们程序中的命令。其次是一个私有的地址空间,它也提供一个假象,好像我们的程序独占地使用内存系统,好像我们的程序中的代码和数据是系统内存中的惟一的对象。进程的概念对于计算机科学十分重要,对于异常控制的实现也有重要的作用。

6.2 简述壳Shell-bash的作用与处理流程

6.2.1  壳Shell的作用

在计算机科学中,Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(command interpreter,命令解析器)。它类似于DOS下的COMMAND.COM和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。而bash是Shell的一种实现,是一个软件,也可以说是Shell的一种。而Shell-bash最基本的功能提供一个操作界面给用户,接受并分析用户输入的命令,进行相应的处理(执行命令)或运行相应的程序,shell还具有管道,命令替换等其他功能。

6.2.2  Shell-bashd的处理流程

Shell首先接受一行用户输入的命令行,对其进行分析,如果是一个内置命令,就按照已有的处理方法立即执行这条命令,如果不是就查找是否存在这样的程序,存在则执行这个程序,若也不是则返回错误继续循环。期间用户的Ctrl+C和Ctrl+Z不会影响shell。其中对命令的执行和对程序的运行分为在前台运行和在后台运行,在前台运行的命令和程序,Shell会等待其结束才继续接受用户的命令,而用&符号标识的在后台运行的程序和命令不会被等待。

6.3 Hello的fork进程创建过程

当shell认为hello是一个可执行程序后会调用fork()函数创建一个新的进程,这个新的进程时代有自己独立虚拟地址空间的,具体的过程是:首先内核为新的进程创建其需要的各种数据结构,并分配给它一个惟一的PID。为了给hello这个新进程创建虚拟内存,fork创建了当前进程的mm_struct,区域结构和页表的原样副本。然后,fork将父进程个子进程中每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

这样的创建方式可以保证私有地址空间的抽象概念,当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的人一个后来进行写操作是,写时复制机制就会创建新页面,从而保证抽象的私有地址的概念。

新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)份副本,包括代码和数据段、堆、共享库以及用户栈。 子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时, 子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。

fork函数是有趣的(也常常令人迷惑),因为它只被调用一次,却会返回两次: 一次是在调用进程(父进程)中,一次是在新创建的子进程中。在父进程中,fork返回子进程的PID。在子进程中,fork返回0。因为子进程的PID总是为非零, 返回值就提供一个明确的方法来分辨程序是在父进程还是在子进程中执行。

通常父进程用waitpid函数来等待子进程终止或停止,其函数声明为:

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

在父进程调用fork后,到waitpid子进程终止或停止这段时间里,父进程执行的操作,和子进程的操作(如果没有什么其它复杂的操作的话),在时间顺序上是拓扑排序执行的。有可能,这段时间里父子进程的逻辑控制流指令交替执行。而父进程的waitpid后的指令,只能在子进程终止或停止后,waitpid返回后才能执行。

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序。函数声明如下:

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

execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。正常情况下,execve调用一次,但从不返回。

创建新进程后在新进程中调用execve函数会在当前进程中加载并运行包含在可执行文件hello.out中的程序,加载并运行hello.out需要以下几个步骤。第一步,删除已存在的用户区域,即删除当前进程虚拟地址的用户部分中的已存在的区域结构。第二步,映射私有区域,为新程序的diamante,数据,bss和栈区域创建新的区域结构,这些新的区域均为私有的,写时复制的。第三步,映射共享区域,即与hello.out链接的共享对象(如标准C库libc.so),它们都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。第四步,设置程序计数器(PC),设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。经过这样几步,execve函数就完成了hello.out这个可执行程序的加载。

6.5 Hello的进程执行

6.5.1  从进程时间片与逻辑控制流的角度看hello.out的执行

逻辑控制流是一个程序运行的一系列指令对应到程序计数器PC中得到的一个序列,这个概念是相对于处理器中实际处理命令的物理控制流提出的。在处理器中,一个物理控制流被分为若干逻辑控制流,每个进程都拥有自己的一个,当执行加载了hello.out程序的进程时,它也会有一个自己的逻辑控制流,各个进程的逻辑控制流是交错进行的,每个轮流进行一会,也就是说hello.out的进程并不是被连续的执行完成的,而是与其他进程一同被轮流执行。

如果一个逻辑流的执行在时间上与另一个流重叠,称为并发流,通俗易懂的说就是这两个进程存在轮流进行的过程,在并发的现象中,一个进程执行它的控制流的一部分的每一时间段叫做时间片,而多任务就是时间分片,hello.out的进程在执行时会被分为若干时间片分别执行,这是由并发流的存在来实现的。

6.5.2  从用户模式,内核模式的切换看hello.out的执行

用户模式和内核模式是由某个控制寄存器中的一个模式位来提供这种功能的,当这个模式为被设置时,进程就运行在内核模式中,一个运行在内核模式中的进程可以执行指令集中的任何指令,并且可以访问系统中任何内存设置,而用户模式只能读,写将读写权限开放给它的内存。运行hello.out这个程序的进程初始时运行在用户模式中,其从用户模式转变为内核模式的唯一方法是通过诸如中断,故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,此时变为内核模式,当它返回到应用程序代码时,处理器会把模式改回用户模式,比如hello.out的某个地方发生了异常,使得系统进入异常处理程序时就会进入内核模式来处理异常,当返回时,hello.out的进程就又返回了用户模式防止其进行某些错误行为。

6.5.3  从上下文切换看hello.out的执行

操作系统内核使用一种成为上下文切换的较高层形式的异常控制流来实现多任务,具体来说,内核为每个进程维持一个上下文。也就是内核重新启动一个被抢占的程序所需要的状态,组成它的包括通用目的寄存器,浮点寄存器,程序计数器,用户栈,状态寄存器等的值,还有描述地址空间的页表,包含有关当前进程信息的进程表以及包含进程已打开文件信息的文件表。在执行hello.out的进程时,内核可以决定抢占这个进程来进行调度,比如当hello.out执行sleep函数时,这个调用显示的请求其调用进程休眠,此时内核就可能会进行上下文切换来执行别的进程,但会保存下此时hello.out的进程的上下文状态,当sleep函数到时间后,在返回到hello.out进程中,这时就可以根据之前保存的hello.out的进程的上下文复原进程到休眠前的状态,从而实现上下文的切换,hello.out的进程在执行时会经历很多次上下文切换。

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。

6.6 hello的异常与信号处理

6.6.1  hello.out可能出现的异常,信号及其处理

      hello.out可能出现的异常首先是当输入的参数数量不是4时,程序会调用exit函数结束程序,这会触发一个SIGCHLD信号的产生,意为已给子进程停止或终止,其对应的处理方法是系统的默认行为——忽略,也就是系统忽略了运行hello.out的进程的结束这一异常,继续返回执行父进程,事实上,正确的输入参数并等待程序完成其执行也会产生这种信号并被系统忽略。由于hello.c这个源程序中并不存在系统调用的函数,所以不会产生陷阱这种系统有意为之的异常。

处理一个信号的过程如下,通常接收到信号会触发控制转移到信号处理程序,在信号处理程序运行并完成处理后,将程序返回给被中断的程序,当然也有可能不再返回而是结束这个进程。

6.6.2  对键盘操作和命令的解析

       当在运行过程中按下回车时,会进行换行

 

图6.1  程序运行时按Enter

运行时按下Ctrl+C,程序被强行中断,这是因为生成了一个SIGINT信号,它代表键盘的中断,系统处理这个异常的方式就是中断当前的进程。

 

图6.2  程序运行时按Ctrl+C

程序运行时按下不停乱按,hello.out会继续输出不会停止,因为它是一个处在前台的进程,而当hello.out运行结束后,会将乱按的内容按照shell的方式进行解读,由于是乱按的,无法解释为命令,也没有对应的应用程序,所以返回了错误提示。

 

图6.3  程序运行时不停乱按

程序运行时按下Ctrl+Z程序会被停止,或者说挂起,这是因为生成了一个信号SIGTSTP,意思是来自终端的停止信号,系统对这个信号进行对应的处理程序的调用,具体的处理方式就是停止当前进程直到下一个SIGCONT信号让这个进程继续进行

 

图6.4  程序运行时按下Ctrl+Z

按下Ctrl+Z挂起程序后可以输入各个命令

       输入ps命令:显示当前进程的状态

 

图6.5  程序运行时按下Ctrl+Z后执行ps命令

              输入jobs命令:显示当前已经启动的作业状态

 

图6.6  程序运行时按下Ctrl+Z后执行jobs命令

              输入pstree命令:将所有进程以树状图显示

 

 

 

 

 

图6.7  程序运行时按下Ctrl+Z后执行pstree命令

              输入fg命令:将进程调到前台执行,也就是继续进程

 

图6.8  程序运行时按下Ctrl+Z后执行fg命令

              输入kill命令:杀死这个停止的进程(产生SIGKILL信号):

 

图6.8  程序运行时按下Ctrl+Z后执行kill命令

6.7本章小结

本章主要讲述了hello.out这个程序的进程管理,首先系统会创建一个新进程,在新进程中加载和调用hello.out这个可执行程序,在执行hello.out的过程中涉及到并发流,切换进程工作模式,进行上下文切换,这些操作是为了提高系统整体的效率并保护系统的安全。同时有一套异常处理机制来处理各种异常,包括系统有意为之,程序中出现的调用和出现某些错误,这样的一套异常处理机制保证了hello.out能完成运行,能切换到内核模式再切换回来,并且保证内核不会因为某些错误卡死。

(第61分)

7章 hello的存储管理

7.1 hello的存储器地址空间

7.1.1  逻辑地址

逻辑地址是指由程序产生的与段相关的偏移地址部分,是指在计算机体系结构中应用程序角度看到的内存单元、存储单元、网络主机的地址。简单的说就是一个根据程序的偏移量算出的相对地址,比如hello.o反编译结果中出现的各个地址,都属于逻辑地址。或者我们在使用高级语言编程时,使用的基于地址的运算方式使用的也都是逻辑地址。

7.1.2  线性地址

是一个32位无符号整数,可以用来表示高达4G的地址。线性地址是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。也就是说,当启用了页表之后,线性地址生成式加的这个基址并非物理内存中实际的。

7.1.3  虚拟地址

CPU通过生成一个虚拟地址来访问主存,这样生成的虚拟地址再被送到内存之前会被先转换成适当的物理地址,这一步也叫地址翻译。虚拟地址是在虚拟内存的基础上生成的。Linux为每个进程维护了一个单独的虚拟地址空间,其中代码段总是从0x400000开始的,然后是已初始化的数据,未初始化的数据,运行时堆等部分。虚拟地址是进程提供给一个程序自己占据了所有的内存这一假象的基础。

7.1.4  物理地址

计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每个字节都有一个唯一的物理地址PA。物理地址是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。

7.2 Intel逻辑地址到线性地址的变换-段式管理

linux是通过分段机制,将逻辑地址转化为线性地址。通过数据段寄存器ds,可以找到hello.out进程数据段所在的段描述符,再通过段描述符找到相应的线性地址。寄存器ds中保存了16位的段选择符,一个段选择符是由如下部分组成的,一个长度为13的索引号,一位TI,指明段描述符是在全局描述符表(GDT, TI=0)中或局部描述符表(LDT, TI=1)中,还有RPL表示请求者特权集。段描述符中记录了段的属性,如首字节的线性地址,段的访问级别(DPL)等。其中BASE包含了段的首字节的线性地址而DPL用于限制对这个段的存取。为0时,只有内核态可以访问;为1时,都可以访问。当我们由逻辑地址通过段描述符表寻找对应的线性地址是只要把BASE中记录的基地址加上逻辑地址中的偏移地址就可以得到线性地址。这就是段式管理。

7.3 Hello的线性地址到物理地址的变换-页式管理

分页管理机制实现线性地址到物理地址的转换。如果不启用分页管理机制,那么线性地址就是物理地址。在保护模式下,控制寄存器CR0中的最高位PG位控制分页管理机制是否生效。如果PG=1,分页机制生效,把线性地址转换为物理地址。如果PG=0,分页机制无效,线性地址就直接作为物理地址。必须注意,只有在保护方式下分页机制才可能生效。只有在保证使PE位为1的前提下,才能够使PG位为1,否则将引起通用保护故障。

 分页机制把线性地址空间和物理地址空间分别划分为大小相同的块。这样的块称之为页。通过在线性地址空间的页与物理地址空间的页之间建立的映射,分页机制实现线性地址到物理地址的转换。线性地址空间的页与物理地址空间的页之间的映射可根据需要而确定,可根据需要而改变。线性地址空间的任何一页,可以映射为物理地址空间中的任何一页。

 采用分页管理机制实现线性地址到物理地址转换映射的主要目的是便于实现虚拟存储器。不像段的大小可变,页的大小是相等并固定的。根据程序的逻辑划分段,而根据实现虚拟存储器的方便划分页。

分页管理机制通过上述页目录表和页表实现32位线性地址到32位物理地址的转换。控制寄存器CR3的高20位作为页目录表所在物理页的页码。首先把线性地址的最高10位(即位22至位31)作为页目录表的索引,对应表项所包含的页码指定页表;然后,再把线性地址的中间10位(即位12至位21)作为所指定的页目录表中的页表项的索引,对应表项所包含的页码指定物理地址空间中的一页;最后,把所指定的物理页的页码作为高20位,把线性地址的低12位不加改变地作为32位物理地址的低12位。

 

图7.1  页式管理的图示

7.4 TLB与四级页表支持下的VA到PA的变换

首先解释一下四级页表,四级页表是一个多级页表,一共有四级,一个k级的多级页表的前k-1级中的PTE都指向其下一级某个页表的基址,而第k级的页表中的每个PTE包含某个物理界面的PPN,或者一个磁盘块的地址,为了构造物理地址,MMU必须访问k个PTE,也就是说对一个四级页表,前面三级页表中存储的地址斗士队下一级页表的索引,第四级的页表中才最终存储了物理地址。

接下来解释TLB与四级页表怎样实现虚拟内存到物理内存的变化,TLB是CPU中的页表缓存,一个虚拟地址中包含两个部分,虚拟页面偏移与虚拟页号,而MMU利用VPN(虚拟页号)来选择适当的PTE,将页表条目中的物理页号和虚拟地址中的VPO(虚拟页面偏移)串联起来就得到相应的物理地址。具体来说还要分两种情况,

  1. 当页面在TLB中命中时,CPU硬件执行以下步骤:

笫l步:处理器生成一个虚拟地址, 并把它传送给MMU。

第2步:MMU生成PTE地址,并从高速缓存/主存请求得到它。

笫3步:高速缓存/主存向MMU返回PTE

第4步:MMU构造物理地址, 并把它传送给高速缓存/主存。

第5步:高速缓存/主存返回所请求的数据字给处理器 。

  1. 而如果页面缺页就需要硬件和操作系统内核协作完成:

第1步到笫3步:与上一情况前三步相同。

第4步:PTE中的有效位是零,所以MMU触发了-次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。

笫5步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。

第6步:缺页处理程序页面调入新的页面,并更新内存中的PTE。

第7步: 缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺 页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命 中,在MMU执行了图7.4.4-b中的步骤之后,主存就会将所请求字返回给处理器。

7.5 三级Cache支持下的物理内存访问

当完成虚拟内存到物理地址的转换后,我们就得到了真正的物理地址,接下来需要对物理内存进行访问,而高速缓存cache被分为三级,越往上的空间越小,但运行速度也快很多倍,当访问一个物理内存时,CPU总会现在一级Cache中查看是否命中,如果命中就可以调用其中内容,而如果没有则访问二级Cache,如此类推知道访问磁盘。

当具体判断某一级Cache中是否命中时,cache将从地址中抽取出s个索引位,这些位被解释成一个对应与一个租号的无符号整数,也就是某个高速缓存组的编号,然后在组中每一行搜索,找到一个有效的行(有效位被设置),其经过地址中表明的块偏移后,高速缓存中标记与地址中的标记相同,若有这样的一行说明命中,否则说明不命中,需要从下一级中寻找一行来进行替换。

7.6 hello进程fork时的内存映射

当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

第一步,删除已存在的用户区域,即删除当前进程虚拟地址的用户部分中的已存在的区域结构。第二步,映射私有区域,为新程序的diamante,数据,bss和栈区域创建新的区域结构,这些新的区域均为私有的,写时复制的。第三步,映射共享区域,即与hello.out链接的共享对象(如标准C库libc.so),它们都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。第四步,设置程序计数器(PC),设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

7.8 缺页故障与缺页中断处理

DRAM(虚拟内存的缓存)的缓存不命中被称为缺页,当CPU引用虚拟内存中的某个字时会对PTE进行读取,如果从有效位推断出对应的虚拟内存未被缓存,就说明出现了不命中,这时会触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,改程序会选择一个牺牲页,如果其中的内容发生了改变就写回到磁盘,同时修改页表条目表示这一条已不再缓存在主存中了。接下来,内核从磁盘复制未被缓存的虚拟内存到内存中的对应部分并更新PTE,随后返回,当异常处理程序返回时,它会重新启动导致缺页的命令,该指令会把导致缺页的虚拟地址重新发送到地址翻译硬件,但这次我们需要的虚拟内存已在缓存中,此时页命中就可以由地址翻译硬件正常处理。

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。

7.9.1  动态内存分配的两种风格

显式分配器, 要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来 分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。

隐式分配器, 另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器, 而自动释放未使用的巳分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。

7.9.2  动态内存分配的要求和目标

显式分配器必须在一些相当严格的约束条件下工作,它要处理任意请求序列,立即响应请求,只使用堆,对齐块(满足对齐要求),不修改已经分配的块,而目标是最大化吞吐率和最大化内存利用率。

7.9.3  对空闲链表的两种实现

因为碎片的问题,必须存在空闲链表来整合可用的空间,空闲链表有隐式的和显式的两种,隐式的链表空闲块之间通过头部中的大小字段隐含地连接着,分配器可以通过遍历堆中所有块来间接遍历所有空闲块,其优点是简单,但显著的缺点是任何操作都需要对空闲链表进行搜索,这种搜索的开销与堆中已分配块的空闲块的总数乘线性关系。而显式空闲链表是将堆的空闲块组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。进行内存管理。在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。其存在的缺点是空闲块必须够大来包含所需要的指针,这潜在地提高了内部碎片的程度。

7.9.4  带边界标记的合并

通过边界标记技术,在每个块的结尾处添加一个脚部,它是头部的一个副本,当每个块都有这样一个脚部时,分配器就可以通过检查前后块的头部和脚部,判断其前面和后面块的起始位置和状态,从而分配器在释放当前块的时候会和其前后空闲的块进行合并。这种方法的一个缺点是每个块都同时保有一个头部和一个脚部。增加了内存开销。

7.10本章小结

本章中主要讲述了和内存管理有关的知识,包括逻辑地址,线性地址,物理地址,虚拟地址之间的关系与转换,还包括用页表实现的虚拟地址到物理地址的寻址过程,Cache的工作过程,创建进程和运行程序时的内存映射情况,以及系统管理动态内存的方法。正是因为这些不同形式的存储方法各自发挥了各自的作用,并且计算机的软硬件实现了它们之间的互相转换,hello.out的程序才能从磁盘一路被加载到CPU中最终被执行,可以说内存的知识是计算机系统中十分重要的一部分。

(第7 2分)

8章 hello的IO管理

8.1 Linux的IO设备管理方法

在unixI/O中,所有的I/O设备(例如网络,磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这使得所有输入和输出可以通过一种统一且一致的方法来执行。Linux中就是基于UnixI/O来管理IO设备。

设备的模型化:文件

设备管理:unix io接口

8.2 简述Unix IO接口及其函数

8.2.1  打开和关闭文件

进程是通过调用open函数来打开一个存在的文件或者创建一个新文件的,函数声明如下:

int open(char *filename, int flags, mode_t mode);

open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。mode参数指定了新文件的访问权限位。作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置成mode&~umask。

而进程通过调用close函数来关闭一个打开的文件。

8.2.2  读写文件

应用程序是通过分别调用read和write函数来执行输入和输出的。函数声明如下:

ssize_t read(int fd, void *buf, size_t n);

ssize_t write(int fd, const void *buf, size_t n);

read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。(通过lseek函数程序能够显式地修改当前文件的位置)。

8.2.3  其他几个接口

      首先用于读取的有能够检索文件的元数据的stat和fstat函数,其函数声明如下:

      int stat(const char *filename,struct stat *buf);

      int fstat(int fd,struct stat *buf);

      其次,程序可以用readdir系列函数来读取目录的内容,这个系列中海油opendir和closedir函数。

      最后,程序可以使用dup2函数进行I/O重定向,其函数声明为:

             Int dup2(int oldfd,int newfd);

这个函数会复制描述表表项oldfd到描述符表项new-fd以前的内容,如果new-fd已经打开了,dup2会在复制oldfd之前关闭newfd。

8.3 printf的实现分析

 

           图8.1  printf函数的实现代码

 

                                                                                                                     

                                  图8.2  vsprintf的代码实现

printf首先通过栈的地址的寻找方式,将printf的第一个参数,也就是格式化的字符串传递给arg这个字符指针,然后调用vsprintf,这个函数的作用是对指定的参数匹配后格式化输出,它的返回值就是最终printf要打印的字符串长度,最后调用write函数将这个i个字符进行打印,完成后返回自己打印的字符串长度i。

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

 

                    图8.3  getchar的代码实现

getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。

从代码上来看,bb是缓冲区的开始,int变量n初始化为0,只有在n为0的情况下,从缓冲区中读BUFSIZ个字节,就是缓冲区中的内容全部读入。这时候,n的值被修改为,成功读入的字节数,正常情况下n就是一个正数值。返回时,如果n大于0,那么就返回缓冲区的第一个字符。否则,就是没有从缓冲区读到字节,返回EOF。

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章主要讲了linux系统的I/O管理,并通过printf和getchar两个函数具体实现方法的分析了解了一些linux系统对IO的处理方式。作为最后一部分,I/O功能让hello.out可以接受用户的输入并做出输出,至此,hello.out相关的分析也全部完成了。

(第81分)

结论

用计算机系统的语言,逐条总结hello所经历的过程。

Hello.c经过预处理生成hello.i,hello.i经过编译生成hello.s,hello.s经过汇编生成hello.o也就是可重定向目标程序,hello.o再经过链接就可以生成hello.out可执行程序,当运行hello.out时,系统会先fork一个新的进程,并在新的进程中使用execve加载hello.out,在这个过程中进程管理和计算机内部的地址管理都发挥了至关重要的作用,另外系统的I/O也发挥了它们的作用,最终hello.out执行完后被父进程回收,结束自己的历程。

在对这样一个程序的全方位分析的过程中,总结了计算机系统这门课程中涉及到的大部分内容,最终通过对一个hello的理解和感悟将计算机系统课上学到的内容融会贯通,这就是我认为的这次大作业的意义所在。

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。

(结论0分,缺失 -1分,根据内容酌情加分)

附件

hello.i 预处理产生文件

hello.s 编译产生文件

hello.o 汇编产生文件

hello.out 链接产生文件

hello 链接产生可执行文件

(附件0分,缺失 -1分)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值