程序人生:Hello’s P2P

计算机系统

大作业

题     目  程序人生-Hello’s P2P 

专       业        计算机类        

学     号       1190200604       

班     级        1903004         

学       生        宋明阳      

指 导 教 师         史先俊        

计算机科学与技术学院

2021年5月

摘  要

本文从预处理、编译、汇编、链接、执行、内存管理等几个方面叙述了一个计算机程序hello.c的整个执行过程,通过多方面的对比介绍阐述了程序在不同阶段所经历的处理所完成的具体工作以及所具有的意义。通过对hello.c在Linux下的生命周期的探索,我们可以对计算机系统对程序的管理,程序在计算机系统中的生命周期有一个更深层次的了解和认识。

关键词:程序;计算机系统;hello.c;                           

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分

目  录

第1章 概述................................................................................................................ - 4 -

1.1 Hello简介......................................................................................................... - 4 -

1.2 环境与工具........................................................................................................ - 4 -

1.3 中间结果............................................................................................................ - 4 -

1.4 本章小结............................................................................................................ - 5 -

第2章 预处理............................................................................................................ - 6 -

2.1 预处理的概念与作用........................................................................................ - 6 -

2.2在Ubuntu下预处理的命令............................................................................. - 7 -

2.3 Hello的预处理结果解析................................................................................. - 7 -

2.4 本章小结............................................................................................................ - 8 -

第3章 编译................................................................................................................ - 9 -

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

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

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

3.4 本章小结.......................................................................................................... - 16 -

第4章 汇编.............................................................................................................. - 17 -

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

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

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

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

4.5 本章小结.......................................................................................................... - 21 -

第5章 链接.............................................................................................................. - 23 -

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

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

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

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

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

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

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

5.8 本章小结.......................................................................................................... - 32 -

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

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

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

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

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

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

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

6.7本章小结.......................................................................................................... - 40 -

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

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

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

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

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

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

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

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

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

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

7.10本章小结........................................................................................................ - 52 -

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

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

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

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

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

8.5本章小结.......................................................................................................... - 56 -

结论............................................................................................................................ - 57 -

附件............................................................................................................................ - 58 -

参考文献.................................................................................................................... - 59 -

第1章 概述

1.1 Hello简介

hello.c从被程序员编写出来到被计算机执行再到执行完毕经历了一系列过程,它完整地体现了一个程序的声明周期。

首先,hello.c要经过预处理器的预处理,生成.i文件,这个文件是预处理器对程序中所有的预处理命令进行替换的结果。然后,编译器要对程序进行编译,生成hello.s汇编语言源程序,此时的hello.s已经离机器语言很近了。然后,汇编器会对其进行解析,生成可重定位目标程序hello.o,此时的文件已经被加上可重定位信息,又向可执行文件靠拢了一步。最后,链接器解析重定位信息,将相应的库文件打包,连接,整合成一个真正的可执行程序,至此,hello.c就变成了一个可执行程序。

当我们使用./hello运行hello时,shell首先调用fork函数为我们的hello创建一个新的进程,并使用execve函数将其信息替换成hello自己的信息,并为其分配自己的虚拟内存空间。随后hello作为一个进程,即一个实例化的程序就运行在了计算机中了,随后,操作系统内核还有针对hello运行时的各种异常和各种信号进行处理,对hello和其他进程进行相应的调度,让他们正确地运行。hello运行时还调用了许多I/O函数,在这些函数中,最底层的实现都是用的Unix I/O,可见Unix提供的I/O接口是十分重要的。

1.2 环境与工具

1.硬件环境:

CPU::Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz   2.59 GHz

硬盘:东芝NVMe 512G SSD

内存:Kingston 2667 16GB

2.软件环境:

主机操作系统:Windows10 64位 家庭中文版

虚拟机操作系统:Ubuntu 20.04 (VMware Workstation pro 14下)

3.开发者工具:

edb,Visual Studio2019,gdb,objdump,readelf

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

中间文件名字

文件作用

hello.i

经过预处理器处理的程序

hello.s

经过编译器编译所形成的汇编语言源程序

hello.o

经过汇编器汇编生成的可重定位目标程序

hello

可执行程序

1.4 本章小结

       本章我们主要对hello的P2P过程进行了一个简单的概括,并阐述了探索hello的P2P过程我们所用到的软件以及硬件环境,并介绍了一下为了执行hello我们所生成的中间文件的名字和作用。

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

预处理主要指在程序编译之前由预处理器对程序所做的处理,通常前几个阶段由预处理器实现。预处理中会展开以#起始的行,试图解释为预处理指令,其中ISOC/C++要求支持的包括:条件编译指令:(#if/#ifdef/#ifndef/#else/#elif)、宏定义(#define)、源文件包含(#include)、行控制(#line)、错误指令(#error)、和实现相关的杂注(#pragma)以及空指令(#)。C语言的预处理主要有三个方面的内容:1.宏定义2.文件包含3.条件编译。下面将就主要的三个方面内容对其功能进行详细说明:

  1. 宏定义:

又称宏代换、宏替换,简称宏,分为带参数宏和无参数宏,它的格式如下:

#define 标识符文本

它主要包括以下6种功能和用法

  1. 使用宏定义常量,例如:#define PI 3.14
  2. 使用宏给运算符重命名,例如: #define and &&//将运算符&&定义为and
  3. 使用宏重新定义类型的别名,例如:#define uint unsigned int
  4. 给关键字定义别名,例如:#define JG struct//将关键字struct定义为JG
  5. 定义一段代码片段,例如:

#define RET(x) if(x==0){ \

printf(“error!”); \

return 0; \

}

  1. 作为编译开关,让一些代码编译或者不编译
  1. 文件包含:

文件包含的一般形式为: #include <文件名> 或:#include”文件名”。 文件包命令功能是指定文件插入该位置取代该命令行,从而把指定文件和当前源文件程序连成一个源文件,以便程序可以在链接阶段有选择地选用包含文件中的对应文件,调用相关函数,从而完成目的。

  1. 条件编译:

使用#ifdef、#else和#endif等标签,有选择地执行一部分代码,具体做法为:使用“#ifedf 标识符A”的方式来引起一段代码,这段代码以 “#endif”结束,程序的行为是只有在定义了标识符A这个宏的时候,才会执行这段代码,否则执行“#else”下的代码段,或跳过这段代码(没有#else标签的情况下)。

2.2在Ubuntu下预处理的命令

预处理命令:linux>gcc hello.c -E -o hello.i

Ubuntu下命令行截图如图所示:

                      图2-1 命令行截图

2.3 Hello的预处理结果解析

  hello.c预处理的结果部分截图如图所示:

              图2-2 预处理文件

可以看到,hello.c预处理生成的hello.i非常的长,达到了数千行,但是在这其中,我们仍然可以找到我们C源代码的全部内容,只是以“#”开头的全部预处理命令已经被处理和替换,其中的宏被全部展开,若有以“#define”开头的宏替换此时也已经被全部替换,所有的文件包含全部被转换为具体的路径写入了该文件,预处理器还做了定义一些辅助性的结构体、变量名修改等工作。

2.4 本章小结

在hello的P2P旅程中,预处理是它走的第一步,从源码到预处理文件,预处理器通过预处理让它包含的信息更加全面,也迈出了从“人的语言”到“机器语言”的第一步。

程序首先进行的预处理阶段,预处理器为程序补充了很多我们再编写代码时缺省的内容,可以说,正是预处理器大大减轻了我们编写程序的负担。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

百度百科中给出的定义为:将某一种程序设计语言写的程序翻译成等价的另一种语言的程序的程序, 称之为编译程序。百科中给出的定义可能比较广义,涵盖了我们这里的许多阶段,因此从狭义上说,我们这里的编译指的就是将之前生成的预处理文件转换为汇编语言所描述的文件。

汇编语言与机器指令是一一对应的,所以可以说将程序转换为汇编语言,在某种程度上就是将程序转换为了机器可以识别的语言。汇编阶段是将高级语言编写的程序向底层机器语言转化的第二步,也是最重要的一步之一。因为它可以完成高级语言向底层汇编语言的转化,是机器的一步重要抽象,有了它,程序员就可以使用人类更易懂的高级语言进行编程,具有很重要的意义。

3.2 在Ubuntu下编译的命令

编译命令:linux>gcc hello.i -S -o hello.o

              图3-1 编译指令

    1. Hello的编译结果解析

hello编译生成的hello.s文件如图所示:

                          图3-2 .hello.s程序

3.3.1数据

(1)字符串

这里我们首先以以字符串常量为例,可以看到在源程序中我们共有2个字符串常量,都是作为printf函数的格式串出现的,对比编译生成的汇编语言程序我们可以发现,这两个字符串前都被编译器声明了.rodata标签,意思是告诉链接器在连接生成可执行目标文件时将这两个字符串放在.rodata节,即这两个字符串都将被放在了只读数据区,用户只能读取,无法修改。

           图3-3 hello.s中的字符串

而对于一般形式的字符串,对于定义的全局变量字符串,若它是以字符数组形式定义的字符串,则它会被编译器声明放在.data节,之后用户可以修改,若它是以字符指针定义的,则它一样会被放在.rodata节,只读。若它是局部的字符串变量,则它的指针将被放在堆栈中进行管理,随用随创建,其真值同样会被放在.rodata节中,若是局部的字符数组,则它与数组一样,都在堆栈中被统一管理。

(2)其他类型的常量与变量

       其他类型的常量与字符串类型大抵相同,即已初始化的全局变量和静态变量会被声明放在.data节中,全局未初始化的变量会被放在.bss中作一个占位符。而局部变量会被放在堆栈和寄存器中进行统一的管理。

       例如我们的hello.c中的循环变量i,在编译器处理时,编译器就将其放在了栈的基指针上面4字节的位置,可见局部变量在这里编译器是将其放在堆栈中的。

       图3-4 对应的汇编代码操作

3.3.2.赋值

1.等号(=)赋值

(1)局部变量

       局部变量赋值的时候,无论是赋初值还是后来赋值,都是编译器直接将代码中书写的数值作为立即数(利用mov指令)传送到局部变量所存储的位置,即堆栈中或寄存器中。

(2)全局变量

       全局变量赋初值的时候,编译器直接将其值分配给了.data节,若未赋初值,则。

2.逗号操作符

(1)作分隔符时

       作分隔符时,编译器对其各个部分单独处理,互不影响。

(2)作运算符时

       编译器直接将其当成常量,从其中选择正确的数值作为常量来传送。

3.3.3.类型转换、sizeof运算符

       编译器在之前的阶段就已经算好了,在此处就将其当做常量来处理,直接将其作为一个立即数,处理将其传送到对应的内存或立即数中。

3.3.4算数操作与逻辑/位操作

       按照下表对应的指令或方式进行处理:

运算符

对应操作

+

转换为汇编指令add p,q

-

转换为汇编指令sub p.q

*

转换为汇编指令 impl p,q注1

/

转换为汇编指令 div reg 注1

++/--

按照执行的顺序在对应程序块的执行之前或之后使用汇编指令 add $1,%reg

复合运算

在汇编语言中与非复合的运算处理方式几乎一样,即现将其中一个操作数移到一个寄存器中,再将这个与另一个操作数调用相应的算数汇编指令,结果保存在寄存器中

&&和||

连续调用2次test指令,若全为1(||至少1个为1,即ne),则得到结果为1

若是非0的转换为0,若是0转换为1

&,|,~,^

按位操作,分别对应汇编语言的and,or,not,xor

移位操作

逻辑移位SHL,SHR对应无符号数的<<和>>,算数移位SAL,SAR对应有符号数的<<和>>

关系操作

利用对应的汇编逻辑操作(cmp)来设置标志寄存器,随后利用标志寄存器进行判断后执行条件跳转或条件转移指令

注1:对于优化较好的编译器,往往采用移位和加减的混合指令的形式,而不是单纯的impl和div

            表3-1 操作符的处理方式

3.3.5数组、指针、结构的操作

(1)数组

       由于数组被组织为一个内存中连续的序列,所以访问的时候,只需要数组的基址和步长就可以对对应的元素进行操作了。所以汇编器会将数组的基址作为一个常数存储在.data节中,在引用数组时,往往采用寄存器间接寻址的方法,例如:movq %rax(0x7fff9600),%rdx,来进行访问。

       在hello中,此时的数组是作为参数在堆栈中传递的,所以程序是通过堆栈的基指针加上偏移量进行访问的,如图所示:

图3-5  编译器对数组引用的处理方式

(2)指针操作

       对于取地址操作,编译器往往使用lea(装载有效地址)指令,来对地址进行传送,因为在编译器生成汇编语言的时候,一个个的变量在编译器看来就是一个个地址下的一个个数据,或者是一个个寄存器下的一个个数据,所以取地址在编译器或机器看来是一件十分自然的事情,故采用lea指令或mov指令都可以完成取地址操作。

       对于取地址内数据,编译器采用间接寻址的方式来对给定的地址中的数据进行访问,即采用*操作符,例如movq *(%rax),%rdx指令的意思就是将%rax中存储的数据作为地址,再取这个地址中的内容,将其传递给rdx寄存器。

(3)结构体操作

       结构体中的操作类似于数组的取数操作,编译器在将结构体进行管理的时候,只需要知道结构体的基址,在加上偏移量即可对相应的结构体成员进行访问,因为结构体有对齐机制,所以每个成员的大小和位置并不是很统一,所以寻址的偏移量是采用立即数的方式进行的。

3.3.6控制转移

(1)if/else

       这部分主要是使用钢材上表中所给出的关系运算符来设置标志寄存器,通过利用标志寄存器来进行跳转而完成的,在我们的hello中,有一处if语句,是判断main函数的一个参数与一个立即数的大小的,可以看到,如果满足条件,就执行相关的代码块中的代码,如果不满足,就跳过对应的代码。具体操作如图所示:

                       图3-6 分支指令

可以看到,这段代码比较了一个堆栈中的变量与4的关系,若等于就跳过接下来的代码,直接前往.L2若不等,则继续执行,退出。

(2)for循环

       for循环的处理模式是:首先给循环变量赋初值,然后跳转到循环体的末尾进行条件判断,若符合条件则跳转到循环体头部执行循环体,若不符合条件则退出循环体,在执行循环体的时候也是一样,每执行循环体一次都要执行位于循环体最后的条件判断语句,若不满足则直接退出循环。在hello中编译器对for循环的处理如图所示:

     图3-7 for循环处理方式示意

3.3.7函数调用

(1)函数传参及函数调用

       这里我们主要介绍X86-64指令集下的函数传参及调用,针对单个的普通的参数,使用寄存器进行传参,按照%rdi,%rsi,%rdx,%rcx,%r8.%r9分别对应第1-6个参数的方式,进行参数的传递。对于更多的参数或结构体参数、数组参数,则使用堆栈压栈的方式来进行传递。

       例如在我们的hello中,main函数的两个参数,argc和argv,就是通过%rdi和%rsi来进行传递的(如图3-8所示)。

            图3-8 函数传递参数示意

(2)函数返回

       在函数返回时,主要涉及恢复堆栈现场,设置返回值,和返回等工作。编译器恢复堆栈现场的处理方式为调用leave指令,该指令等价于movq %rbp,%rsp popq %rbp两条指令,目的在于恢复调用者的堆栈结构。若有返回值,则主要通过%rax寄存器来进行返回。返回时直接调用ret指令即可返回,该指令等价于两个动作,将堆栈顶部的返回地址取出,放到指令寄存器PC中,然后执行下一条指令即可返回。

3.4 本章小结

在本章我们介绍了hello的P2P历程的第二步,即从一个预处理的程序,由编译器生成它的汇编语言程序,我们介绍了linux下编译的命令,编译的结果实例。还对编译的结果进行了分析,其中包括编译器对常量、变量的处理方式,对基本的操作符的处理方式,对条件控制的处理方式,以及对函数的调用方式和返回方式,通过编译,程序实现了由高级语言向底层汇编语言、机器语言的跨越,是编程思想中一步重要的抽象,具有很重要的意义。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

汇编器(as)将编译好的.s程序翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在.o文件中。.o文件是一个二进制文件,它包含的是程序的二进制指令编码,如果我们直接打开,将看到乱码。

汇编器使得汇编语言变成了真正的机器语言,完成了从高级语言到机器语言的最后一步抽象,使得我们编写的hello程序真正变成了一个机器程序,它其中还包含重定位信息,便于链接器在将它和已编写好的库函数连接时能够正确连接。

4.2 在Ubuntu下汇编的命令

汇编的指令为:linux>gcc hello.s -c -o hello.o

           图4-1汇编指令

应截图,展示汇编过程!

4.3 可重定位目标elf格式

4.3.1节头部表

       使用指令:linux>readelf -h hello.o可以查看节头的信息,如图4-2所示:

                 图4-2节头部表信息

       其中类别表示该ELF可执行文件的类型,数据中定义了文件的编码方式,字节排列顺序,此外,还定义了程序版本,系统版本,系统架构等信息,相当于该程序的概述内容。

4.3.2各节内容

       命令:使用命令linux>readelf -S hello.o来查看各个section的详细信息:

           图4-3各个节中的信息

       可以看到,该elf可重定位目标文件共有14个节其中有一个伪节,所以共有13个节,其中每个节的名称和大小以及访问权限、标记、信息、对齐大小都如图所示,其中每个旗标的意义如图4-4所示:

             图4-4 旗标意义

4.3.3可重定位节信息

命令:使用linux>readelf -r hello.o可以显示可重定位节的相关信息,如图4-5所示:

                  图4-5hello.o中的可重定位节的相关信息

       其中可重定位包括2个节,分别是rela.text节和rela.eh_frame节,分别对应代码和框架中的可重定位信息,其中偏移量指的是该可重定位信息在相应节中的偏移,信息包括可重定位类型等信息,其中可重定位类型这里有两种,一种是R_X86_64_PC32,另一种是R_X86_64_PLT32,分别对应利用函数计数器PC进行相对重定位的方式和利用函数入口进行相对重定位的方式,符号值是当前符号的值,名称是重定向到的目标的名称,后面的加数(attend)是一个有符号常数,一些重定位要使用它对被修改引用的值做偏移调整。

4.4 Hello.o的结果解析

objdump -d -r hello.o  分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。

对比第三章的hello.s文件可以发现两者的模式基本相同,汇编指令也几乎一样,但是还会有区别的,具体体现在:1..o文件是机器语言文件,而.s文件是单纯的汇编语言文件。2. 可重定位目标文件中加入了许多重定位信息,这点是源文件中没有的,3.可重定位目标文件中对堆栈结构进行了微调和优化,其堆栈的操作与原.s文件中不太一样。

可以说可重定位目标文件加上了更加完善的信息,使得程序从“编写好”到“可执行”更加进了一步。

               图4-6 反汇编之后的hello.o程序

可重定位目标文件更能说明机器语言与汇编语言的映射关系,以及机器语言的构成等,更加偏向于底层。

4.5 本章小结

本章我们介绍了汇编的指令及作用以及汇编之后文件的变化和改变,汇编是由编写好的代码向可执行程序迈出的重要一步,是机器语言到汇编语言的映射,也是重定位思想的集中体现,具有重要意义。

(第41分)

第5章 链接

5.1 链接的概念与作用

链接指的是由链接器链接多个可重定位目标文件生成一个可执行目标文件的过程。链接分为静态链接和动态链接,像Linux LD程序这样的静态链接器以一组可重定位目标文件作为输入,生成一个完全连接的,可以加载运行的可执行目标文件作为输出。动态链接时常常使用共享库来被链接器统一加载,执行,一个共享库中的程序可以被多个程序连接,共享。

5.2 在Ubuntu下链接的命令

以下格式自行编排,编辑时删除

命令:linux> 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 hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o -o hello

                图5-1 hello的链接指令

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

1.ELF头:

                 图5-2 hello的ELF头

 可以看到,hello的ELF头与hello.o的ELF头最大的区别就在于它的节数变成了27个,而且文件类型变成了可执行程序。

2.各个节的信息

       通过readelf软件查询它的各个节的信息,可以发现,节的数量变多了,除了.o文件中所具有的.data,.text等节,还有.interp,.hash等其他的节。

       在该节信息中,各个头部标签的格式和含义与上一章中的信息相同,在这里就不再赘述了。

                  图5-3 readelf查看各个节的信息

5.4 hello的虚拟地址空间

使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。

用edb打开hello,查看它的虚拟内存信息(如图5-4所示),发现整个虚拟内存被分成了几部分:低地址区是用户的代码和数据区域,中间的几个区域是共享库的映射区域,然后是用户的栈,用户的栈上面就是内核代码区。

结合上面5.3节的内容可对比发现,从标号为1的节(第一节).interp到标号为11的节(第11节)全部落在了edb中划定的第一个区域中,再看读写许可发现这是只读的内容。从第12到第16节在第二个区域内,这部分也是只读的,第17/18两个小节落在第三个区域内,也是只读的,在这里edb中的标签与readelf中给出的旗标是一致的,接下来第19-22小节落在了edb给出的内存第四区域内,这部分是可读可写数据区,由此可见,在程序被加载后,.init,.text,.rodata节等只读数据区会被加载在低地址处,而.data节等可读可写数据区会被加载在他们的上面。

可执行目标文件中并不包含内核代码和共享库映射等的信息,所以edb中下面展现的信息在上节中是没有的,应该注意到,读写段与共享库的映射区域中间还是有相当一部分的空档的,这部分对应的就是空档和用户堆,因为hello程序并没有调用malloc函数,所以这里做了省略。下面的共享库映射区域和内核内存都是在程序被夹在后才能显示出来的。

                   图5-4 程序被edb加载后的虚拟内存情况

5.5 链接的重定位过程分析

以下格式自行编排,编辑时删除

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。

结合hello.o的重定位项目,分析hello中对其怎么重定位的。

5.5.1 hello与hello.o

打开hello的反汇编代码,我们可以看到最大的区别就在于hello的反汇编代码已经将hello.o中的全部可重定位信息处都变成了实际的虚拟地址。此外,hello的上下文中不只有我们编写的main函数的代码,还有很多其他函数的代码,它们是在链接的时候被添加进来的。

             图5-5 对hello反汇编之后的结果

5.5.3重定位过程

在X86-64架构下,重定位主要有两种方式,即R_X86_64_PC32和R_X86_64_32两种方式,其中R_X86_64_PC32是通过程序计数器PC来进行相对寻址的,主要通过计算出重定位目标地址对当前程序计数器PC的差值,从而将其填充为相应的数值,而R_X86_64_32则是按照算法计算出重定位目标地点的直接地址,再将其填充上即可,每个重定位信息都在重定位条目的中,重定位条目的格式如图5-6所示:

                    图5-6 重定位条目的格式

关于两种重定位方式的算法,如图5-7所示:

                                          图5-7 重定位算法

       到程序连接为可执行程序的时候,链接器会解析每个重定位条目,将其解析为正确的地址,并将其替换为最终重定位完成时的实际的虚拟地址,最后在加载时就能获得正确的结果。

5.6 hello的执行流程

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

call main之前程序执行的所有程序的名字及地址:

序号

名字

地址

1

0x7f81128acdf0

2

0x7f328e7c5c10

3

_start

0x4010f0

4

__libc_start_main

0x7fdb7673cfc0

5

__cxa_atexit

0x7fdb7675ff60

6

__libc_csu_init

0x401260

7

_setjump

0x7fdb7675be00

8

main

0x4011d6

main函数执行结束后,程序终止,程序调用了位于共享文件库中的exit函数,结束了进程。

5.7 Hello的动态链接分析

分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。

1.动态链接的执行过程

动态链接共享库是一个目标模块,在运行和加载时,可以加载到任何地址,并和一个在内存中的程序链接起来执行。只是在执行之前,编译器和静态链接器并不知道它的具体位置,只有等到执行的时候,动态链接器才将他们链接起来。因此,GNU编译器使用延迟绑定的技术,该技术也叫做运行时绑定,即在运行的时候在绑定起来。

延迟绑定是通过GOT和PLT实现的,存放函数地址的数据表,称为重局偏移表(GOT, Global Offset Table),而那个额外代码段表,称为程序链接表(PLTProcedure Link Table),GOT是数据段的一部分,PLT是代码段的一部分。其中PLT是一个数组,其中每个条目都是一个统一编码的占位符,到重定位的时候,每个条目都负责调用一个具体的函数。

下面就hello的执行过程来说明PLT与GOT的关系,可以看到在对应printf的位置(编译器将它优化为puts)(如图5.8(a)),首先需要跳转到.ptlt节的某个位置,进入后发现其结构类似于跳转表,然后进入跳转表,发现接下来要跳转的位置正是一个名为puts@plt的函数(如图5-8(b)所示),我们继续跟进查看,

发现又进入了一张跳转表,还是要进行跳转,下一次跳转的地址如图8-5(c)(d)所示,这次才真正跳转到了一个高地址区域,在执行过几个系统的内核函数后,发现这时终于来到了位于共享库中的puts函数,至此,系统完成了对位于共享里中的puts的调用。

                    (a)

                (b)

                (c)

              (d)

                      (e)

              图5-8 plt和got的执行过程

我们再来剖析一下由main函数执行到puts函数的过程,首先我们经历了2张跳转表,通过对比地址发现,这两张跳转表正是.plt和.got.plt,因此.plt的作用就是先去.got.plt节里面找地址,如果找到的话,就去执行函数,如果没有,就会出发链接器去找地址,而.got.plt则主要是用来存储地址的。

所以简单来讲,程序的动态链接相关的两个部分的关系大概示意图如图5-9所示:

                       图5-9 各个跳转表之间的关系

这里的访问与位置无关代码(PIC)有关,在第一次调用共享库的内容时,PLT需要跳转到动态链接器进行重定位,在之后的访问中就不在需要了。而对于.so文件中定义的全局变量的访问,程序只需要访问.GOT表中的相关条目即可实现重定位。

2.plt节和.got.plt节在init前后的变化

       由readelf查看hello的.plt节的相关地址,如图5-10所示:

               图5-10 plt节相关内容

然后进入edb调试,打开相应地址查看内存,发现内容一致(如图5-11),开始执行文件之后,发现.plt节的数据并未发生变化。再查看.got.plt节,发现就有了明显的变化(图5-12至图5-14),因此可断定,在_init函数中,函数修改了共享库的跳转表.got.plt,从而完成了动态链接。

                     图5-11 .plt节之前之后的对比(上面是之前的)

                图5-12 hello文件中.got.plt节的内容

    

                图5-13 .got.plt节的内容

                 图5-14 .got.plt节之后的内容

5.8 本章小结

这一章我们介绍了hello的连接,介绍了hello是如何从一个可重定位程序变成可执行程序的,介绍了静态链接的基本原理,动态链接的关键技术,以及静态链接中对各个重定位条目的处理细节等,链接其实是将程序员写的程序与其他早已写好的可执行程序链接起来形成一个新的文件,将一些常用的函数或是结构体单独分离出来,这样大大减轻了程序员的工作量,具有很重要的意义。

(第51分)

第6章 hello进程管理

6.1 进程的概念与作用

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

进程是实例化的程序,是程序在操作系统中的存在状态,进程给用户程序提供了两种重要的假象:1.用户程序好像是系统中唯一运行着的程序,每时每刻都在独立地使用处理器。2.用户程序每时每刻都在独立地使用内存。从另一个概念上来讲,进程是对处理器、主存和I/O设备的抽象表示,是计算机领域的一个重要抽象。

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

1.shell的简介

shell是一个交互型的应用级程序,代表用户运行其他程序。是指“为使用者提供操作界面”的软件(即命令解释器)它接收用户命令,然后调用相应的应用程序。shell分为2类,图形界面shell和命令行式shell,在不特殊声明的情况下shell常常指命令行式shell。

shell是一个命令解释器,shell提供了用户与操作系统之间通讯的方式。这种通讯可以以交互方式(从键盘输入,并且可以立即得到响应)进行,或者以非交互方式(shell脚本)执行。shell脚本是放在文件中的一串shell和操作系统命令,它们可以被重复使用。本质上,shell脚本就是命令行命令简单的组合然后重定向输出到一个文件里面。其中非交互式的shell脚本更加适合处理操作系统底层的业务。

2.shell的作用

简单地说,shell有如下功能:

  1. 作为命令解释器解释用户从命令行输入的命令(或在非交互状态下由shell脚本输入),shell使用parseline解析命令行,将其分解成几个分开的单词,如遇到无效字符或特殊的字符,shell将对其进行忽略或作替换。对命令行解释结束后,将对其进行命令处理。
  2. 制定用户环境,在初始化用户要执行的文件时,对于文件的运行方式、运行环境、运行时的各种设置有着极多的复杂设定,shell会代替用户将这些机制设置好。
  3. shell还能作为一个解释性的编程语言,通过非交互式的shell脚本方式,将shell变成一门编程语言,将需要的命令写在脚本中让shell执行
  4. shell还有管道和重定向的功能,前者指运行一个进程组,该进程组中包含用户所定义的两个或多个变量后者指将文件作为shell的输入或输出的功能。
  5. 此外,shell还有命令补全、查看命令历史、命令替换、别名机制、通配符等功能。
  1. shell的处理流程

(1) 从标准输入读取用户输入的命令。

(2) 将读入的字符串进行拆分,将其分为一个一个的分词,并从中获取各个参数。

(3) 如果名字是内置命令则立即处理并等待用户的下一个命令行

(4) 如果不是内置命令,则按照相应的路径调用对应的程序为其创建子进程并执行它。

(5) 在运行时shell接受信号(包括从键盘输入的信号,系统其它信号等),并对这些信号做相应的处理。

6.3 Hello的fork进程创建过程

shell通过调用fork函数来创建一个新的运行的子进程,这个新的子进程就是未来的hello进程,新的子进程几乎但不完全与它的父进程相同,子进程得到与父进程用户级虚拟地址空间相同(但是独立)的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程打开文件描述符相同的副本。shell通过fork创建子进程hello后,两个进程是并发执行的,也就是说此时的hello和shell在交替地执行指令。创建后他们的PID是不同的,这也是两者的差别之一。

例如,我们再命令行中输入如下命令:linux>./hello 1190200604 宋明阳,shell首先会对我们输入的命令行进行解析,发现不是一个内置命令,所以会调用fork函数为我们创建一个新的进程来执行我们的hello程序。

6.4 Hello的execve过程

以下格式自行编排,编辑时删除

ececve函数加载并执行可执行文件,且待参数列表和环境变量列表envp,我们将刚才命令行中的参数和环境变量都作为参数调用ececve函数即可执行它。

加载并运行hello主要有以下几个步骤:.

1.删除已存在的用户区域,即删除当前fork函数执行后子进程中存在的所有shell的用户信息。

2.映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.text和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。

3.映射共享区域。如果hello与共享对象(或目标)连接,比如c标准库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

4.设置程序计数器(PC)exexcve做的最后一件事情就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

其中加载器映射的hello的虚拟内存空间如图所示:

        图6-1加载器是如何映射用哪个户地址空间的区域的

6.5 Hello的进程执行

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

从我们调用execve后开始,hello程序就已经加载好并可以开始运行了,hello就变成了一个存在于操作系统中的进程,被内核调度并执行。

操作系统使用一种称为“上下文切换”的较高层形式的异常控制流来实现多任务。上下文切换机制是建立在较低层次的异常机制上的。内核为每个进程都维护一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。它有一些对象的值组成,这些对象包括通用目的寄存器,浮点寄存器、程序计数器,用户栈,状态寄存器,内核栈和各种内核的数据结构。

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的,当内核选择一个新的进程执行的时候,我们说内核调度了这个进程。在内核调度了这个新的进程的运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移给新的进程。

在上下文切换时内核主要做三件事:1)保存当前进程的上下文。2)恢复某个先前被抢占的进程的上下文。3)将控制传递给这个新恢复的进程。

当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件而发生阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据到达。

中断也可以引发上下文切换,比如,所有的系统都有某种产生周期性定时器中断的机制,通常为1毫秒或10毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。

除了上述两种发生上下文切换的场景,还有一种可能导致上下文切换的情形,那就是进程的时间片用尽了。时间片是分时操作系统分配给每个正在运行的进程微观上的一段CPU时间(即从进程开始运行直到被抢占的时间),所以说当时间片用尽之后,操作系统也会进行上下文切换,切换到另一个进程。图6-2向我们展示了一个发生在操作系统中上下文切换的例子:

           图6-2 一个上下文切换的例子

6.6 hello的异常与信号处理

在我们的hello程序中可能遇到很多种类的异常,但是最常见的情况是我们用户造成的中断异常,中断是异步发生的,是来自处理器外部的I/O设备信号的结果,比如我们乱按键盘,晃动鼠标,都回想处理器芯片上的一个引脚发送信号,并将异常号放在总线上,来触发中断,中断是异步的异常,所以在CPU处理完当前的指令后,注意到有一个引脚上的信号变成了高电平,立刻就会从总线获取异常号并调用相关的异常处理子程序,异常处理子程序对异常进行处理返回后,它就将控制返回给源程序的下一条指令,好像没发生过中断一样。例如我们运行hello时,不停地乱按或按回车,hello程序好像什么事也没发生,一直执行直到完毕,只是我们输入的乱码和按的回车会被shell当成下一条命令进程处理,但那对我们的程序没有影响,不是我们关心的内容。如图所示:

                图6-3 中断异常

hello运行过程中也会产生很多种信号,例如,我们在hello运行过程中按ctrl+Z,会向hello发送一个SIGTSTP信号,准确来说是我们想shell发送了一个SIGTSTP信号,然后sehll会将该信号转发给我们的hello程序,hello接收到这个信号之后的默认行为是hello会停止,暂停当前的执行,如图所示:

              图6-3 SIGTSTP信号默认行为

这时我们使用ps命令查看,发现hello还在任务栏中:

            图6-4 ps命令

然后我们使用pstree命令,打开进程树,发现hello仍在我们的进程树中:

                图6-5 pstree指令

然后我们用kill命令给hello发送一个代号为9的信号,即SIGINT,这时再打开ps发现hello已经终止运行了:

             图6-6 kill命令

在接收到SIGTSTP后我们可以使用fg命令来让暂停的hello程序恢复运行,如图所示:

              图6-7 fg命令

在hello运行中,我们也可以输入Ctrl+C来对hello发送一个SIGINT信号,发送的原理与ctrl-z同,发送过后我们可以发现hello停止运行了:

              图 6-8 Ctrl+C

6.7本章小结

本章我们介绍了hello的进程控制过程,从hello经过一系列处理到它成为一个可执行文件,我们现在终于可以执行它了。执行hello时,先由bash调用fork函数为其创建一个新的进程,然后在新的进程中,在调用execve函数加载它,在它执行开始时,系统还要对它的上下文和时间片进行管理和调度,来让它正常地运行。它还要接受各种信号,并对其进行相应的处理,到此时hello已经真正地变成一个执行着的程序了。

(第61分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

首先我们对hello进行反汇编,使用linux>objdump -d hello 查看它的地址:

            图7-1 反汇编之后的hello

可以看到,这里所显现出来的地址即为hello的逻辑地址,即段地址:偏移地址。逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元、存储单元、网络主机的地址,是程序汇编之后生成的用于指明指令和操作数位置的地址。

       虚拟地址:也叫线性地址,它和逻辑地址一样,区别于物理地址,都是程序内的一种抽象的表示,它与一边与磁盘文件建立映射,一边与物理内存建立映射,而且每个进程都有自己的虚拟内存空间,是内存管理和多进程的一种有效手段。

       线性地址:就是虚拟地址。

       物理地址:计算机系统的主存被组织为一个有M个连续字节大小的单元组成的数组,每个字节都有一个唯一的物理地址,这个地址即为数据在计算机主存中真实的地址。当CPU要访问内存中的某个数据时,就是通过物理地址,来将地址通过总线传送给主存从而获得信息的。

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

以下格式自行编排,编辑时删除

Intel采用几个段寄存器来划分不同的段,段寄存器主要有SS(栈段寄存器),ES/GS/FS(辅助段寄存器),DS(数据段寄存器),CS(代码段寄存器)。这些寄存器存储着各个段表的起始地址。段表也是段描述符表,由段描述符组成,有三种类型:全局描述符表(GDT),局部描述符表(LDT),中断描述符表(IDT),其中GDT只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段。LDT存放某任务(即用户进程)专用的描述符。IDT包含256个中断门、陷阱门和任务门描述符。段描述符是一种数据结构,它分为2类:1. 特殊系统控制段描述符,包括:局部描述符表(LDT)描述符和任务状态段(TSS)描述符2. 控制转移类描述符,包括:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符,其中段描述符的定义如图所示:

                      图7-2 段描述符定义       

在将逻辑地址转换为虚拟地址时,首先将逻辑地址分为2部分,高16位为段选择符,被用于取得段的基址,低32位作为段内偏移量,被选中的段描述符先被送至描述符cache,每次从描述符cache中取32位段基址,与32位段内偏移量(有效地址)相加即可得到虚拟地址,整个过程如图所示,其中GDT和LDT首地址被存放在段寄存器中,用户是不可见的:

              图7-3 寻址过程

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

1.页表

页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。页表就是一个页表条目的数组,虚拟地址空间中每个页在页表的一个固定偏移处都有自己的页表条目,用于记录该虚拟页面对应的物理地址,是否有效,是否被修改过等信息。

当CPU要请求一个虚拟页面的信息时,CPU就要访问页表,来确定虚拟页面的存储和位置信息,若这个页面信息刚好在物理内存中,就触发一个页命中,此时只需将对应信息读出并返回给CPU即可,若页表中对应条目的有效位为0,则触发一个缺页异常,意思是对应的虚拟页没有被缓存到主存中。缺页异常处理子程序会将对应的虚拟页缓存到主存中,然后再次重启刚才的命令来读取对应页。

整个页表的组织结构如图7-4所示:

       图7-4 页表组织结构

线性地址到物理地址的变换被称为地址翻译,地址翻译的时候,虚拟地址被分成2部分:n-p位的虚拟页号VPN和p位的页内偏移VPO,其中前n-p位虚拟页号用于到页表里去寻找物理页号,后面的VPO直接作为物理页表中的页内偏移,整个组织过程如图所示:

                    图7-5 虚拟地址结构

图7-5展示了当页面命中时,CPU硬件执行的地址翻译步骤:

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

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

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

第4步:MMU构造物理地址,并把它传送给高速缓存/主存。第5步:高速缓存/主存返回所请求的数据字给处理器。

                图7-6 地址翻译命中

图7-6展示了当地址翻译不命中时的策略:

第1步到第3步:和图7-4中的第1步到第3步相同。

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

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

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

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

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

以下格式自行编排,编辑时删除

现代CPU为了节省页表所耗费的内存空间,采用了一种叫多级页表的技术,core i7cpu采用了4级页表,具体做法为,将虚拟页号分为4部分,第i个部分存储着第i+1级页表的基址,然后第i+1个部分是第i+1级页表的表内便宜,最后一级页表中存储的就是最后真实的物理地址。整个组织起来的结果如图7-7所示:

                          图7-7多级页表

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

为了加快对页表中PTE的访问速度,现代CPU设置了一各TLB寄存器,可以将常用的页表条目缓存在其中以便查看。与正常的高速缓存相同,TLB对地址的组织也分为三部分:TLB标记,TLB索引和TLB偏移,在翻译地址时,首先用虚拟地址的索引位到TLB中去寻找PTE,若找到了,则返回,若未找到,则触发一个不命中,再到下一级TLB中去找,若三级TLB都没找到,则到主存中去找。

加上TLB后整个的地址翻译过程如图7-8所示:

              图7-8 地址翻译

第1步:CPU产生一个虚拟地址。

第2步和第3步:MMU从 TLB中取出相应的PTE。

第4步:MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存。第5步:高速缓存/主存将所请求的数据字返回给CPU。

此时若TLB不命中,MMU将把对应的条目逐层加载进TLB,并驱逐一个其他条目,最后从TLB中读取。

7.6 hello进程fork时的内存映射

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

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

7.7 hello进程execve时的内存映射

调用ececve后加载并运行hello主要有以下几个步骤:.

1.删除已存在的用户区域,即删除当前fork函数执行后子进程中存在的所有shell的用户信息。

2.映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.text和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。

3.映射共享区域。如果hello与共享对象(或目标)连接,比如c标准库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

4.设置程序计数器(PC)exexcve做的最后一件事情就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

其中execve映射的hello的虚拟内存空间如图所示:

        图7-9加载器是如何映射用哪个户地址空间的区域的

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

当对应的虚拟页面没有被加载进物理内存,而CPU又要访问这个页面的内容时,就会触发一个缺页故障,缺页即DRAM缓存不命中。缺页故障会引发异常,这时系统就会调用缺页异常处理子程序来对其进行处理。具体的处理办法为:1.首先选择一个牺牲页,这里的牺牲页可以是不经常访问的那个也,2.将缺页的页面换入物理内存,并将有效位设置为3.返回给产生缺页异常的那个指令,并重新执行导致缺页异常的指令。

值得一提的是,操作系统不会等待虚拟页面加载进入物理内存后再执行它,而是将控制权交给其他进程让其他进程先执行。

7.9动态存储分配管理

1.动态内存分配的基本概念

在程序运行时程序员使用动态内存分配器获得虚拟内存。

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。堆是一个请求二进制0的区域,它在.bss节后开始,并向上生长,从低地址向高地址生长。对于每个进程,内核都维护着一个变量brk指向堆的顶部。

分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,这个片要么是已分配的,要么是未分配的(空闲的)。已分配的块显式地保留为应用城西使用。空闲块可以用来分配给应用程序。一个已分配的块将保持已分配的状态,直到它被释放,这种释放要么是程序员编写指令让它执行的,要么是程序自己执行的(垃圾回收)。

2.动态分配器的类型

目前的内存分配器主要有两种风格,一种是显式分配器,另一种是隐式分配器,他们在分配内存的时候都要求程序员显式地指示机器进行分配,区别在于回收内存空间的时候,显式分配器要求程序员显式地释放任何已经分配的块,例如c语言中的malloc和free函数,c++和java中的new和delete。而隐式分配器则要求分配器自动检测一个已分配的块何时不再被程序所使用,然后释放这个块,即垃圾回收机制。

3.几种动态内存分配器的实现技术

(1)隐式空闲链表

带标签的隐式空闲链表中每一个表项对应每一个已分配块和空闲块,每一个表项由头部和脚部以及中间的有效载荷和填充(可选)组成,具体的组成结构如图所示:

                图7-10 隐式空闲链表块结构

    • 对齐要求

系统的对齐要求和分配器对块格式的选择会对分配器上的最小块大小有强制要求。任何已分配块和空闲块的大小必须大于这个最小值。如果我们设置了以双字对齐(8字节),那么每一个块的大小必须是8字节的倍数,算上头部块和尾部块的大小,每次最小请求的块的大小为2个双字,即16个字节的大小。

②放置策略

当一个应用请求k字节的块时,分配器需要搜索整个空闲链表以找到合适的位置放置这个块,使用隐式空闲链表的分配器有3个放置策略:首次适配、下一次适配和最佳适配。1)首次适配:首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。优点是可以率先搜索前面容易留下碎片的地方,使得利用率更高,但是缺点是每次都需要从最开始开始搜索,吞吐量较低。2)下一次适配:下一次适配从上一次查询结束的地方开始搜索,搜索下一个可以放置的块。下一次适配运行起来要比首次适配快,但是内存里用率比首次适配要低。3)最佳适配:最佳适配每次都要遍历所有的空闲块,找到适合所需大小最小的空闲块,优点是利用率高,但缺点是运行速度慢。

分割空闲块有两个策略,1是选择用整个空白块,但是这样会造成内部碎片的增加。2是将整个大的空闲块分为2部分,并将其分配出去。

③合并空闲块

合并空闲块就是将相邻的两个空闲块合并为1个空闲块,以避免“假碎片”所造成的无法分配内存。

合并策略有立即合并和推迟合并2种1)立即合并:在每次释放结束后立即查看前后有没有可以合并的空闲块并合并,但是这样的方式在反复地申请和释放序列中可能会造成“抖动”,块会马上合并然后又被分割。2)延迟合并:在每次申请内存失败时再扫描整个堆,合并空闲块。

因为每个块的头部和尾部都有边界标记,即标记了该块的大小和空闲等信息,所以在每次释放合并的时候可以考查前后两个块是否是否是空闲的,它们的空闲情况可以分为以下3种情况:1)前后都不空闲:不合并2)前面(后面)空闲,后面(前面)不空闲:将前面(本块)的头部作为头部,后面(本块)的脚部作为脚部,两块大小之和作为块大小填写角标。3)前面和后面都空闲:将前面的头部作头部,后面的脚部作脚部,三块大小之和作为块大小。(2)显式空闲链表

显式空闲链表将每个空闲块组织为一个双向链表的结构,其中已分配的块与带边界标记的隐式空闲链表结构同,空闲块在隐式空闲链表的基础上又增加了祖先和后继2个指针,分别指向前一个和后一个块,其具体结构如图所示:

                 图7-11显式空闲链表块结构

显式空闲链表的空闲块合并和空闲块拆分策略与隐式链表相似,这里主要叙述它的链表维护策略。

在释放块时对于显式空闲链表有2种维护方式:后进先出(LIFO)和按照地址顺序来维护的方式。

    • 按后进先出(LIFO)的方式合并:

将新释放的块放在链表的开始处,在使用这种策略时,分配器会最先检查最近使用过的块。在这种情况下释放一个块连合并带插入都可以在常数的时间内完成。

    • 按地址的顺序合并

释放一个块之后需要将其按照地址顺序排列并将其插入对应的位置,所以每次释放后都需要遍历查找该块所处的位置,会造成速度变慢,但是这样做会提高内存利用率

(3)分离的空闲链表

分离空闲链表就是维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫做大小类(size class)。分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为n的块时,它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。分离的空闲链表有简单分离存储、分离适配和伙伴系统等多种具体实现形式。

7.10本章小结

本章我们通过hello小程序介绍了现代计算机内存组织形式的相关内容,介绍了三种不同抽象层级的地址,两种不同的地址空间,以及它们在一个程序的执行过程中具体是如何运作的,虚拟内存是操作系统概念中的重要抽象,它简化了程序的连接加载和共享过程,也简化了内存的分配,可以说具有重要意义。

(第7 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

linux将I/O设备(网络、磁盘、中断等)全都抽象成文件进行统一管理,所有的输入和输出都当成对应文件的读和写来执行,这种优雅的将设备映射成文件的方式,允许Linux内核引出一个简单并且低级的I/O接口,称为LinuxI/O,这使得所有输入和输出都能以一种统一的方式来执行。

8.2 简述Unix IO接口及其函数

以下格式自行编排,编辑时删除

  1. Unix接口:
  1. 打开文件:打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
  2. Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。8.3 printf的实现分析
  3. 改变当前的文件位置 :对于每个打开的文件,内核保持着一个文件位置,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,设置文件的当前位置。
  4. 读写文件。一个读操作就是从文件复制n>0个字节到内存。
  5. 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
  1. Unix I/O函数
  1. open函数:进程通过open函数来打开一个已存在的文件或创建一个新文件,open函数定义如下:int open(char* filename,int flags,mode_t mode) openfilename转换为一个文件描述符,并且返回描述符的数字。返回的描述符总是在进程中当前没有打开的最小描述符。
  2. close函数 unix通过调用close函数来关闭一个打开的文件。函数定义:int closeint fd
  3. read函数read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值一1表示一个错误,而返回值О表示 EOF。否则,返回值表示的是实际传送的字节数量。函数定义如下: ssize_t read(int fd,void *buf,size_t n)
  4. write 函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。图10-3展示了一个程序使用read和 write调用一次一个字节地从标准输入复制到标准输出。函数定义: ssize_t wirte(int fd,const void *buf,size_t n)

8.3 printf的实现分析

由网上查阅的printf的源码可知printf是调用了vprintf实现的,而vprintf又是调用了vfprintf实现的,所以继续查看vfprintf的代码,vfprintf的代码非常长而且繁杂,观察过后我们可以发现这里是针对键盘上输入的内容进行处理的,对每个字符都有它的处理和转码方式,最后我们回归本真,在vsprintf中发现了对系统级函数write的调用:

1.       va_start(args, fmt); 

2.       write(1,printbuf,i=vsprintf(printbuf, fmt, args)); 

3.       va_end(args); 

接下来我们进入write函数进行查看:这里我只找到了write函数的汇编语言版本:

1.write:

2. mov eax, _NR_write

3. mov ebx, [esp + 4]

4. mov ecx, [esp + 8]

5. int INT_VECTOR_SYS_CALL //即0x80

发现write函数将几个栈总的参数放入了寄存器中,并调用了编号为int 0x80的syscall,这里我们知道,syscall是用作I/O中断的一种方式。我们继续查看syscall的函数源代码:

sys_call: 

  1. call save
  2. push dword [p_proc_ready] 
  3. sti      
  4. push ecx  
  5. push ebx 
  6. call [sys_call_table + eax * 4] 
  7. add esp, 4 * 3 
  8. mov [esi + EAXREG - P_STACKBASE], eax  
  9. cli 
  10. ret

发现这里又调用了一个函数,这里调用的函数就是最底层的字符显示驱动程序,字符显示驱动子程序通过ASCII码在字模库中找到点阵并将点阵信息存储到显存中,此时CPU就会通过调用显卡的相关驱动程序,显卡内的处理程序会按照一定的刷新频率去读取vram,并通过信号线向显示器传输RGB分量等信息,完成字符的显示。

8.4 getchar的实现分析

先看getchar的源代码:

1. int getchar(void) 

2. { 

3.  static char buf[BUFSIZ]; 

4.  static char *bb = buf; 

5.  static int n = 0; 

6.  if(n == 0) 

7.  { 

8.   n = read(0, buf, BUFSIZ); 

9.   bb = buf; 

10.  } 

11.  return(--n >= 0)?(unsigned char) *bb++ : EOF; 

12. }

异步异常-键盘中断的处理:当用户按键时,键盘会触发一个IO中断,具体为CPU上键盘的引脚变为高电平,这时CPU 就知道是键盘发送了信息,于是便在下一条指令执行完毕后开始执行键盘中断处理子程序,异常处理子程序从总线上的键盘端口获得键盘按键的扫描码,在将其进行处理,转换为ASCII码并将其放入标准输入的缓冲区中。

通过源码可以看出,getchar调用了unixio中的read函数从标准输入中向缓冲区中读取字符,若成功读取到了一个字符,则返回读取到的字符,若未能成功读取到字符,则返回EOF。

8.5本章小结

本章介绍了与hello程序有关的unixI/O的相关内容,可以看到很多标准I/O的底层实现都是通过UnixI/O函数来完成的,所以说UnixIO应用广泛,有重要意义。

(第81分)

结论

hello从一个程序员写出来的源程序到被执行,经历了一系列过程,首先,预处理器对hello进行预处理,解析其中的预处理命令,为它做好准备。然后编译器将它编译为汇编代码程序,使得他更向机器语言迈进了一步。然后汇编器解析汇编语言命令,将其真正地翻译成了机器语言,形成了一个可重定位目标文件,接着链接器解析重定位信息,将相应的库文件打包,连接,整合成一个真正的可执行程序。

接着我们在shell中调用相关命令去执行这个程序,shell会调用fork函数。execve函数为我们创建一个新进程,并将hello加载到其中,为其分配虚拟内存,这时hello就准备好开始运行了。在hello运行过程中,系统内核还要应对各种异常,人为的或者是其他形式的异常,并调用相关的异常处理子程序进行处理,还要应对各种信号,并对信号做相应处理。在执行过程中,hello程序也调用了位于静态库中的标准I/O函数,这些函数都是最底层UNIX i/o函数的封装,是底层的高级抽象。最后,hello程序执行完毕,shell作为其父进程将其回收。

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

附件

中间文件名字

文件作用

hello.i

经过预处理器处理的程序

hello.s

经过编译器编译所形成的汇编语言源程序

hello.o

经过汇编器汇编生成的可重定位目标程序

hello

可执行程序

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

参考文献

为完成本次大作业你翻阅的书籍与网站等

[1]  xu_1995 预处理 CSDN https://blog.csdn.net/xu_1995/article/details/79756616

[2]  printf函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html

[3]  兰德尔E.布莱恩特 大卫.R.奥哈拉伦 深入理解计算机系统 原书第三版

(参考文献0分,缺失 -1分)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值