计算机系统大作业1190303125

 

 

 

计算机系统

大作业

题     目  程序人生-Hello’s P2P 

专       业     计算机科学与技术    

学     号        1190303125        

班     级         1903006          

学       生          董梓灿       

指 导 教 师           史先俊        

计算机科学与技术学院

2021年5月

摘  要

本文主要介绍了hello.c文件在Linux系统中运行的生命周期,涉及hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件的全过程,同时关注hello如何进行进程,存储和I/O管理以及信号,虚拟内存等知识。

关键词:Hello程序;预处理;编译;汇编;链接;进程;存储;虚拟内存;I/O;                      

 

 

 

 

 

 

 

 

目  录

 

 

第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

P2P:在Linux系统中,hello.c经过cpp的预处理生成hello.i,hello.i文件英国ccl的编译生成hello.s,hello.s经过as的汇编生成二进制文件hello.o,最后经过链接器ld生成hello可执行目标文件。在shell中输入启动的命令后,shell为他fork一个子进程,然后hello在这个子进程上运行,从程序变成了进程。

020:shell为这个子进程execve,映射虚拟内存,在程序入口出程序载入物理内存,并进入main函数执行目标代码。CPU为运行的hello分配时间片并执行逻辑控制流。当程序运行结束之后,父进程负责回收hello进程,内核删除相关的数据结构

1.2 环境与工具

1.2.1 硬件环境

X86-64 2.60GHz

1.2.2 软件环境

Windows10 64位,VMware 15  ubuntu 18.04 16GRAM 1T Disk

1.2.3 开发工具

Visual Studio 2019;CodeBlocks 64位;vi/vim/gedit+gcc,visual studio code

1.3 中间结果

hello.c:源代码

hello.i:预处理后的文本文件

hello.s:编译之后的汇编文件

hello.o:汇编之后的可重定位目标执行文件

hello:链接之后的可执行文件

hello0elf:hello.o的ELF格式

hello1elf:hello的ELF格式

hello0.txt:hello.o反汇编代码

hello1.txt:hello的反汇编代码

1.4 本章小结

本章主要介绍了P2P,020的概念,完成该论文所使用的软硬件设备以及开发工具等。最后介绍了本论文产生的中间结果。

第2章 预处理

2.1 预处理的概念与作用

预处理的概念:程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。

预处理的作用:预处理中会展开以#起始的行,试图解释为预处理指令,其中ISO C要求支持的包括#if/#ifdef/#ifndef/#else/#elif/#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。

2.2在Ubuntu下预处理的命令

图表 预处理命令

命令:gcc -E hello.c -o hello.i

通过这个命令我们将得到以下文件:

图表 预处理产生的hello.i文件

2.3 Hello的预处理结果解析

图表 hello.c

图表 hello.i

预处理得到hello.i文件打开后发现得到了扩展,到了3000多行,而源文件只有几十行。原文件中的宏进行了宏展开,增加的文本其实是我们include的三个头文件的源码,例如声明函数、定义结构体、定义变量、定义宏等内容。另外,如果代码中有#define命令还会对相应的符号进行替换。

2.4 本章小结

在本章中,我们主要介绍了预处理的概念和作用,他会对#include中宏就行宏展开,对#define的符号进行替换,对#if#else等进行条件编译等。并分析了.i文件

第3章 编译

3.1 编译的概念与作用

编译的概念:编译器主要做词法分析、语法分析、语义分析等,在检查无错误后后,把代码翻译成汇编语言。 编译器将文本文件hello.i 翻译成文本文件hello.s, 它包含一个汇编语言程序,即一条低级机器语言指令。

编译的作用:将C语言提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。

主要的工作如下:

1.     词法分析:词法分析是使用一种叫做lex的程序实现词法扫描,它会按照用户之前描述好的词法规则将输入的字符串分割成一个个记号。产生的记号一般分为:关键字、标识符、字面量(包含数字、字符串等)和特殊符号(运算符、等号等),然后他们放到对应的表中。

2.     语法分析:语法分析器根据用户给定的语法规则,将词法分析产生的记号序列进行解析,然后将它们构成一棵语法树。对于不同的语言,只是其语法规则不一样。用于语法分析也有一个现成的工具,叫做:yacc。

3.     语义分析:语法分析完成了对表达式语法层面的分析,但是它不了解这个语句是否真正有意义。有的语句在语法上是合法的,但是却是没有实际的意义,比如说两个指针的做乘法运算,这个时候就需要进行语义分析,但是编译器能分析的语义也只有静态语义。

4.     中间代码生成:我们的代码是可以进行优化的,对于一些在编译期间就能确定的值,是会将它进行优化的,比如说上边例子中的 2+6,在编译期间就可以确定他的值为8了,但是直接在语法上进行优化的话比较困难,这时优化器会先将语法树转成中间代码。中间代码一般与目标机器和运行环境无关。(不包含数据的尺寸、变量地址和寄存器的名字等)。中间代码在不同的编译器中有着不同的形式,比较常见的有三地址码和P-代码。

5.     目标代码生成与优化:代码生成器将中间代码转成机器代码,这个过程是依赖于目标机器的,因为不同的机器有着不同的字长、寄存器、数据类型等。最后目标代码优化器对目标代码进行优化,比如选择合适的寻址方式、使用唯一来代替乘除法、删除出多余的指令等。

3.2 在Ubuntu下编译的命令

图表 编译命令

gcc -S hello.i -o hello.s

图表 生成的hello.s文件

3.3 Hello的编译结果解析

图表 hello.s文件内容

图表 hello.s的头部

.file:源文件”hello.c”

.text:代码节

.rodata:只读数据节

.align:数据和指令的地址按八字节进行对齐

.string:声明一个字符串(LC0,LC1)

.global:声明全局符号main

.type:声明一个符号是数据类型还是函数类型

3.3.1 数据

1. 字符串:

这两个字符串是printf函数的参数。

.LC0:

       .string    "\347\224\250\346\263\225: Hello \345\255\246\345\217\267 \345\247\223\345\220\215 \347\247\222\346\225\260\357\274\201"

.LC1:

       .string    "Hello %s %s\n"

2. 局部变量i

局部变量i存放在栈上——距离%rbp-4的地址的内存中。

3. 各种立即数

各种立即数在程序中直接使用,前面加上$

4. int argc

Main函数中的参数argc直接被存储到了-20(%rbp)的位置

5.     char*argv[]

由上图可以看出argv的起始地址被保存在了-32(%rbp)的位置。

我们通过相对相对寻址的方式,获取我们需要的字符串的地址,如图中获取的是argv[1]的地址

3.3.2 全局函数

hello.c声明了一个全局函数int main(int argc,char *argv[]),这个全局函数被保存在了代码段中,.type和.global表明这是一个全局函数符号

3.3.3 赋值

源程序中设计一个赋值,将局部变量i初始化为0。

这种赋值我们主要是按照这个变量的类型的长度,通过mov赋值。

1byte:movb

1 word:movw

2 words:movl

3 words:movq

3.3.4 类型转换

在C源文件中,我们调用了atoi()函数,这个函数会将字符串argv[3]转变成整型变量

3.3.5 算数操作

在C源文件中,我们有算数运算i++。通过addl完成

汇编中,主要有以下算术运算:ADD(加), SUB(减), INC(加一), DEC(减一), AND(并),XOR(异或),NEG(取反),MUL(乘),DIV(除)等等。

3.3.6 关系操作

在C语言文件中,关系操作主要有两个,一个是if(argc!=4),另外一个是i<8。

关系操作主要有两种比较方式test(类似and)和cmp(类似sub),设置条件码,根据条件码,我们将选择跳转的位置,通过jXX(ja,je,jne,js,jns,jae,jbe,jb,jl,jle,jg,jge)进行跳转。

1.如果i小于等于7,则跳转到.L4的位置,否则,继续往下执行

2. 如果argc==4,则跳转到.L2的位置,否则继续执行。

3.3.7 函数操作

除了本身的main函数之外我们另外调用了getchar,printf,atoi,exit,sleep五个函数。

调用函数属于过程调用。

在过程调用的时候,我们需要首先将我们要传递的参数放在寄存器中,依次是rdi,rsi,rdx,rcx,r8,r9六个寄存器中,如果是结构体或者超出了6个寄存器可以安放的数目,则在栈中存放数据。之后我们调用call指令,调用我们需要的函数(会将返回地址先压栈)。进入函数中,我们要保存被调用者保护的寄存器,使用push,在结束的时候pop,并ret返回到我们之前压入栈中的返回地址。

例如:

我们先将argv[3]的地址放到%rdi寄存器中,作为参数传递。之后,我们调用atoi,在结束后,我们会返回到存入的PC的位置继续往下运行。

3.3.8 控制转移

这里的控制转移有两个。

一个是for循环for(int i=0;i<8;i++)

我们通过比较局部变量i与7的大小,如果小于等于,则跳转到.L4的位置,继续执行printf()和sleep()函数。否则,继续向下执行

另外一个是if(argc!=4)

比较argc与4的大小,如果相等,则跳转到.L2的位置,也就是创立局部变量i并初始化为0,以及之后的操作,否则,则继续向下执行,打印提示信息

3.4 本章小结

本章主要介绍了编译的概念和作用,编译器对各种数据类型和操作的处理。.s文件中存储的汇编代码是主要是一条条指令,了解汇编的机制,我们可以很容易的通过读取汇编文件来寻找提高程序性能的方法。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

汇编的概念:汇编是指把汇编语言书写的程序翻译成与之等价的机器语言程序。

汇编的作用:汇编器将汇编代码转变成可以执行的指令,生成目标文件。[4]

4.2 在Ubuntu下汇编的命令

图表 汇编的命令

命令:gcc -c hello.s -o hello.o

图表 汇编生成的hello.o文件

4.3 可重定位目标elf格式

ELF头

读取命令:readelf -h hello.o

从Magic开始,依次讲述了ELF的类别ELF64,数据的存储格式(补码格式+小端存储),版本,系统,ABI版本,文件类型(可重定位目标文件),系统的架构(x86-64)版本,起始地址(0x0),节头偏移(1160),以及节头部表中条目的大小和数量等信息等等。

图表 ELF头

节头部表:

命令:readelf -S hello.o

节头部表,包含了文件中出现的各个节的信息,包括节的名称、类型、地址、偏移量、大小、信息、链接、对齐等信息。我们的文件是可重定位信息,所以每一个介都是从0开始的,这样的话,可以方便重定位。我们通过街头表中的地址偏移量可以计算出各节的起始地址,以及空间的大小。除了.text其他文件都是不可执行的,只可以读写,只读数据段连写都不可以。

符号表.symtab:

命令:readelf -s hello.o

我们可以看到存放程序中定义和引用的函数和全局变量的信息。包括各个符号的名称,大小,类型,地址偏移,以及是不是全局变量。

可重定位节

命令:readelf -r hello.o

依次显示需要重定位部分的偏移量,信息,类型,符号值,符号名称和加数

4.4 Hello.o的结果解析

图表 objdump反汇编的结果

图表 编译的结果

可以看出两者并没有什么太大的差别,反汇编的结果比编译的结果多出机器代码。从反汇编可以看出,每一条汇编代码都可以通过机器代码来表示,进而可以由汇编代码向二进制的机器代码映射,从而将汇编代码转化为机器代码。

不同处:

1.     分支转移:在汇编代码中,我们的分支转移会转移到形如.L3这样的段名称的位置。而在反汇编的代码中,我们的分支转移的位置是一个确定的地址

2.     函数调用:在汇编代码中,我们的函数调用是直接call函数的名称。而再犯汇编代码中,我们的函数调用call的是一个地址。这是因为,在汇编代码中,共享库中的函数并没有被加载到代码中,不能确定他的地址,所以只能用函数名称代为表示。而在可重定位目标文件中,我们先把这些地址设置为下一条指令的地址,在重定位的时候,在设置为目标地址。

4.5 本章小结

在本章,我们主要介绍了汇编的相关知识,包括汇编的概念,作用,ELF文件的ELF头、可重定位表,.symtab符号表,节头表,反汇编与编译文件的差异等等。

(第41分)

第5章 链接

5.1 链接的概念与作用

链接的概念:将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。

链接的作用:使得分离编译成为可能。我们讲一个应用程序组织称更小,更好管理的块,独立修改和编译这些模块。最后,我们将这些模块链接在一起,形成一个可执行目标文件。

5.2 在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

图表 链接的命令

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可执行目标文件

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

ELF头

读取命令:readelf -h hello.o

从Magic开始,依次讲述了ELF的类别ELF64,数据的存储格式(补码格式+小端存储),版本,系统,ABI版本,文件类型(可执行目标文件),系统的架构(x86-64)版本,起始地址(0x400550),节头偏移(5936),以及节头部表中条目的大小和数量等信息等等。

节头部表:

命令:readelf -S hello.o

节头部表,包含了文件中出现的24个节以及各个节的信息,包括节的名称、类型、地址、偏移量、大小、信息、链接、对齐等信息。其中很多信息都是只读或者读写的,只有.plt,.text,.init,.fini四个节是可读可写的。

符号表.symtab:

命令:readelf -s hello.o

我们可以看到存放程序中定义和引用的函数和全局变量的信息。包括各个符号的名称,大小,类型,地址偏移,以及是不是全局变量。

重定位节

命令:readelf -r hello

包括各个重定位符号的名称,信息,偏移量,类型,符号值等

5.4 hello的虚拟地址空间

在edb可以看出我们这个可执行目标文件的内存地址从0x400000-0x400ff0 。

结合ELF文件,我们可以看到各个节的位置

5.5 链接的重定位过程分析

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

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

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

重定位有两部组成:

1.     重定位节和符号定义:连接器将所有相同类型的节合并为同一类型的节,然后,链接器将运行时内存地址赋予给新的聚合节,赋予给输入模块定义的每个节,以及输入模块定义的每个符号。例如,main的地址从原先的0x0到现在的0x400582

2.     重定位节中符号引用,连接器修改代码节和数据节对每个符号的引用,是的他们指向正确的运行时地址,依赖于可重定位目标模块中的重定位条目。原先的数据和代码位置,如从0x0 变成了重定位之后的位置。

图表 hello.o和hello的反汇编代码的比较

5.6 hello的执行流程

执行流程如下:

ld-2.27.so!_dl_start

ld-2.27.so!_dl_init

hello!_start

libc-2.27.so!__libc_start_main

libc-2.27.so!__cxa_atexit

libc-2.27.so!__libc_csu_init

libc-2.27.so!_setjmp

hello!main

hello!puts@plt

hello!exit@plt

hello!printf@plt

hello!sleep@plt

hello!getchar@plt

ld-2.27.so!_dl_runtime_resolve_xsave

ld-2.27.so!_dl_fixup

ld-2.27.so!_dl_lookup_symbol_x

libc-2.27.so!exit

5.7 Hello的动态链接分析

动态链接:使得不同的程序开发者和部门能够相对独立地开发和测试自己的程序模块,从某种意义上来讲大大促进了程序的开发效率,原先限制程序的规模也随之扩大。但是慢慢地静态链接的诸多缺点也逐步暴露出来,比如浪费内存和磁盘空间、模块更新困难等问题,使得人们不得不寻找一种更好的方式来组织程序的模块。动态链接通过延迟绑定来预测代码的运行时地址。延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:

PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。

GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。

我们发现了got的地址是0x600ff0,我们先来看调用之前的结果:

下面是调用之后的结果。

5.8 本章小结

本章主要介绍了链接的知识,指出程序如何通过ld从可重定位目标文件变成可执行目标文件的,包括elf文件以及各个节,重定位,虚拟地址空间,动态链接等等。

(第51分)

第6章 hello进程管理

6.1 进程的概念与作用

进程的经典定义:一个执行中的程序的实例

作用:为我们提供一种假象:

1.     我们程序好像独占使用处理器

2.     我们程序好像独占使用内存系统

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

Shell-bash作用:一个交互性的应用级程序,代表用户运行其他程序

处理流程:

(1) 终端进程读取用户由键盘输入的命令行。

(2) 分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量

(3) 检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令

(4) 如果不是内部命令,调用fork( )创建新进程/子进程

(5) 在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。

(6) 如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait)等待作业终止后返回。

(7) 如果用户要求后台运行(如果命令末尾有&号),则shell返回。

6.3 Hello的fork进程创建过程

终端通过fork()函数来创建一个子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到的与父进程用户及虚拟地址空间相同的(但是独立的)一份副本。包括代码段,数据段,共享库以及用户栈。子进程还活的父进程任何打开文件描述符相同的副本,意味着当父进程调用fork的时候,子进程可以读写父进程中打开的任何文件。父进程与新创建的子进程最大的区别在于有着不同的pid。进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。

当子进程终止的时候,会等待父进程通过waitpid或者wait进行回收。

a)  Hello的execve过程

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

Execve函数加载并运行可执行目标文件filename,挈带参数列好argv和环境变量列表envp,只有当出现错误,比如找不到filename,才会返回到调用程序,否则,永不返回。之后,调用启动代码,设置栈,并将控制传递给新程序的主函数main,用户栈结构如下:

加载可执行程序步骤:

(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。

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

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

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

6.5 Hello的进程执行

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

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

进程的执行依赖于两个假象:

1.     一个独立的逻辑控制流:程序好像独占CPU

2.     一个独立的地址空间:程序好像独占内存系统

上下文信息:程序正确运行所需的状态做成,包括存放在程序中的代码和数据,包括栈,通用目的寄存器,程序计数器,环境变量以及打开文件描述符的集合。用户每次运行称呼的时候,shell就会创建一个新的进程,并在这个进程的上下文中运行它们自己的代码和其他应用程序。

进程时间片:一个逻辑流与另一个逻辑流在时间上重叠,称为并发流。一个进程和其他进程轮流运行的概念叫做多任务,一个进程执行他的控制流的每一部分的每一个时间段叫做时间片。

内核通过上下文切换这个异常控制流来实现多任务。内核为每一个进程都维持一个上下文(内核重新启动一个被抢占的进程所需的状态)当进程执行的谋新试客,内核可以决定抢占单签进程,并重新开始一个先前被抢占的进程,这个决策叫做调度。当内核调度一个新的进程运行后,抢占当前进程,并使用上下文切换的机制来控制转移到新的进程

(1)    保存当前进程的上下文

(2)    恢复某个先前被抢占的进程被保存的上下文

(3)    将控制传递给这个新恢复的进程。

图表 17 进程上下文切换

处理器通常是用某个控制寄存器的一个模式为限制一个应用可以执行的指令以及他可以访问的地址空间范围,该寄存器描述了进程当前享有的特权。当设置了模式位后,进程就运行在内核模式中(超级用户模式),可以执行指令集中的任何指令,并且可以访问系统中任何内存位置。当没有设置模式位的时候呀,进程运行在用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据,反之,必须通过系统调用接口简写的访问内核代码和数据。

Hello运行在用户模式下,不断输出hello 1190303125 董梓灿\n,当调用sleep的时候,进入内核模式,系统进行上下文切换调用其他进程,定时器到时间之后,会发送一个信号,内核处理该信号,并将该进程进入就绪状态,由此,可以接着向下运行。

6.6 hello的异常与信号处理

Hello的异常:

1.     中断(interrupt):中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断的异常处理程序常常称为中断处理程序(interrupt handler)。总是返回到下一条指令。

2.     陷阱和系统调用(trap):陷阱是有意的异常,是执行一条之后零的结果。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。总是返回到下一条指令。

3.     故障(fault):故障是由错误情况引起的,它可能能够被故障处理程序修正。可能返回到当前指令。

4.     终止(abort):终止是不可恢复的致命错误造成的结果,通常是一些硬件错误。不会返回。[6]

信号:Linux信号,它允许进程和内核中断其它进程。一个信号就是一条消息,它通知进程系统中发生了一个某种类型的事件。

图表 18 信号

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。

Hello执行过程中:

正常执行,中间不停乱按包括回车:

当使用Ctrl+C的时候,内核会想进程发送SIGINT信号,强行终止进程

当时用Ctrl+Z的时候,进程会停止,我们调用ps可以看到当前有三个进程,分别是bash,hello,ps,我们可以看到对应的进程号和虚拟终端TTY。

当调用pstree的时候,我们可以看到进程树。

使用jobs,可以查看当前有多少在后台运行的命令。我们可以通过fg加上jobid将后台进程调至前台进行。

Kill可以向发送信号,对当前进程进行终止等操作。例如: kill -9 pid,强制杀死这个进程。

6.7本章小结

本章主要介绍了hello的进程相关知识,包括进程的概念,shell加载进程,fork,execve,信号与异常等

(第61分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

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

结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。

地址空间是一个非负整数地址的有序集合

逻辑地址:段地址:偏移地址(hello程序中的数据都是通过这种方式进行访问)

线性地址:非负整数地址的有序集合,虚拟地址和物理地址空间都是线性地址空间。

虚拟地址:被送往内存之前的地址(每个进程中,虚拟地址都是从0开始,都是独立的)

物理地址:计算机系统的只存被组织成一个有M个连续的字节大小的单元做成的数组,每个字节都有唯一的一个物理地址。

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

把内存分段,并设计了4个段寄存器,CS,DS,ES和SS,分别用于指令、数据、其它和堆栈。把内存分为很多段,每一段有一个段基址,当然段基址也是一个20位的内存地址。不过段寄存器仍然是16位的,它的内容代表了段基址的高16位,这个16位的地址后面再加上4个0就构成20位的段基址。而原来的16位地址只是段内的偏移量。这样,一个完整的物理内存地址就由两部分组成,高16位的段基址和低16位的段内偏移量,当然它们有12位是重叠的,它们两部分相加在一起,才构成完整的物理地址。

机器语言指令中出现的内存地址,都是逻辑地址,需要转换成线性地址,再经过MMU(CPU中的内存管理单元)转换成物理地址才能够被访问到。

在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中(段寄存器只有2字节)。Intel的设计是段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值(index)。

Linux中逻辑地址等于线性地址。为什么这么说呢?因为Linux所有的段(用户代码段、用户数据段、内核代码段、内核数据段)的线性地址都是从 0x00000000 开始,长度4G,这样 线性地址=逻辑地址+ 0x00000000,也就是说逻辑地址等于线性地址了。

这样的情况下Linux只用到了GDT,不论是用户任务还是内核任务,都没有用到LDT。GDT的第12和13项段描述符是 __KERNEL_CS 和__KERNEL_DS,第14和15项段描述符是 __USER_CS 和__USER_DS。内核任务使用__KERNEL_CS 和__KERNEL_DS,所有的用户任务共用__USER_CS 和__USER_DS,也就是说不需要给每个任务再单独分配段描述符。内核段描述符和用户段描述符虽然起始线性地址和长度都一样,但DPL(描述符特权级)是不一样的。__KERNEL_CS 和__KERNEL_DS 的DPL值为0(最高特权),__USER_CS 和__USER_DS的DPL值为3。

用gdb调试程序的时候,用info reg 显示当前寄存器的值:

cs             0x73     115

ss             0x7b     123

ds             0x7b     123

es             0x7b     123

可以看到ds值为0x7b, 转换成二进制为 00000000 01111011,TI字段值为0,表示使用GDT,GDT索引值为 01111,即十进制15,对应的就是GDT内的__USER_DATA 用户数据段描述符。

图表 19 逻辑地址转线性地址

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

Linux通过页表查找来对应物理地址。

准确的说分页是CPU提供的一种机制,Linux只是根据这种机制的规则,利用它实现了内存管理。

在保护模式下,控制寄存器CR0的最高位PG位控制着分页管理机制是否生效,如果PG=1,分页机制生效,需通过页表查找才能把线性地址转换物理地址。如果PG=0,则分页机制无效,线性地址就直接做为物理地址。

分页的基本原理是把内存划分成大小固定的若干单元,每个单元称为一页(page),每页包含4k字节的地址空间(为简化分析,我们不考虑扩展分页的情况)。这样每一页的起始地址都是4k字节对齐的。为了能转换成物理地址,我们需要给CPU提供当前任务的线性地址转物理地址的查找表,即页表(page table)。为了实现每个任务的平坦的虚拟内存,每个任务都有自己的页目录表和页表。

为了节约页表占用的内存空间,x86将线性地址通过页目录表和页表两级查找转换成物理地址。

线性地址分成VPN和VPO。其中VPO对应值PPO,而VPN则通过页表查找到对应的PPN,从而对应物理地址[8]

图表 20 线性地址转物理地址

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

在IntelCorei7中,存在TLB和四级页表进行VA到PA的变换。

TLB是通过VPN的位进行虚拟寻址的,TLB有4个组,VPN的低2位作为组索引,高6位作为标记,用来区别是不是映射到同一个TLB的不同的VPN。开始的时候,MMU从虚拟地址抽出VPN,检查TLB,看他是不是前面某个内存缓存的PTE的一个副本,抽出TLB索引和标记,如果命中,则将缓存中的PPN给MMU,加上VPO,完成VA到PA的变换。

如果不命中,则要在主存中取出相应的PTE。通过4级页表,每一级页表存储的是下一级页表的起始位置,最后一级页表存储的物理页号,我们通过地址逐级通过四级页表得到我们想要的PPN,结合VPO,完成映射。

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

我们通过每级cache大致相同,如下:

我们从L1cache开始,如果发生了缓存命中,则读出一个数据对象返回。

如果,没有缓存数据对象d,发生缓存不命中,则从下一级cache中取出包含d的那个块,如果此层已经满了,则会佛改一个现存的块。

覆盖现存的块叫做替换或驱逐这个块,被驱逐的叫做牺牲快。会调用LRU选择最后被访问的时间距现在最远的块。

7.6 hello进程fork时的内存映射

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

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

图表 21  execve的内存映射

7.7 hello进程execve时的内存映射

通过execve函数加载程序步骤

1.     删除已存在的用户区域。删除当前进程的虚拟地址的用户部分中已存在的区域结构

2.     映射私有区域:微信程序的代码数据,bss和栈区域创建爱你新的区域结构。所有的新的区域都是私有的,写时复制的

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

4.     设置程序计数器(PC):execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

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

缺页故障:

进程线性地址空间里的页面不必常驻内存,在执行一条指令时,如果发现他要访问的页没有在内存中(即存在位为0),那么停止该指令的执行,并产生一个页不存在的异常,对应的故障处理程序可通过从外存加载该页的方法来排除故障,之后,原先引起的异常的指令就可以继续执行,而不再产生异常。

缺页中断处理:

假设 MMU 在试图翻译某个虚拟地址 A 时,触发了⼀个缺⻚。这个异常导致控制转 移到内核的缺⻚处理程序,处理程序随后就执⾏下⾯的步骤:

(1) 虚拟地址 A 是合法的吗?换句话说,A 在某个区域结构定义的区域内吗?为了回答这个问题,缺⻚处理程序搜索区域结构的链表,把 A 和每个区域结构中的 vm_start 和 vm_end 做⽐较。如果这个指令是不合法的,那么缺⻚处理程序就触发⼀个段错误,从⽽终⽌这个进程。 因为⼀个进程可以创建任意数量的新虚拟内存区域,所以顺序搜索区域结构的链表花销可能会很⼤。因此在实际中,Linux在链表中构建了⼀棵树,并在这棵树上进⾏査找。

2) 试图进⾏的内存访问是否合法?换句话说,进程是否有读、写或者执⾏这个区域内⻚⾯的权限?例如,这个缺⻚是不是由⼀条试图对这个代码段⾥的只读⻚⾯进⾏写操作的存储指令造成的?这个缺⻚是不是因为⼀个运⾏在⽤户模式中的进程试图从内核虚拟内 存中读取字造成的?如果试图进⾏的访问是不合法的,那么缺⻚处理程序会触发⼀个保护异常,从⽽终⽌这个进程。

3) 此刻,内核知道了这个缺⻚是由于对合法的虚拟地址进⾏合法的操作造成的。它是 这样来处理这个缺⻚的:选择⼀个牺牲⻚⾯,如果这个牺牲⻚⾯被修改过,那么就将它交换出去,换⼊新的⻚⾯并更新⻚表。当缺⻚处理程序返回时,CPU 重新启动引起缺⻚的指令,这条指令将再次发送 A 到 MMU,这次,MMU 就能正常地翻译A⽽不会再产⽣缺⻚中断了[1]

7.9动态存储分配管理

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

当需要额外虚拟内存的时候,使用动态内存分配器。动态分配器维护者一个进程的虚拟内存区域——堆,每个进程内核为何这一个变量brk,指向堆的顶部。

分配器将堆视为一组不同度小的块的集合,每个块是一个连续的虚拟内存片(已分配或者空闲)空闲块可以被分配。空闲块保持空闲直到显示地被应用分配。一个已分配的块保持已分配,直到被释放。

有两种分配器:

1.     显示分配器,应用显式的释放任何已分配的块

2.     隐式分配器:分配器检测一个已分配块何时不再被程序使用,那么就是放这个块,又叫垃圾收集器。

通过malloc函数返回一个指针,指向大小至少为size的块。而free则会释放这个块。

隐式空闲列表:

分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块,大多数分配器讲这些信息潜入在块的本身;

在这种情况下,一个快是有一个固定大小的头部(一个字),有效载荷,邮寄可能的一些额外的填充组成的,投鞭编码了这个块的大小(包括头部和所有填充,)以及这个块是已分配的还是空闲的,如果强加一个最小大小限制(二个字),那么块大小就是最小大小限制的倍数(8字节的倍数),用头部(1个字,假定,32bit)的29个高位来存储块的大小,低三位来编码其他信息,低三位中的最低位(标志位)来表示该块分配否(0,1);

有效载荷后面是一片不使用的填充区域,需要填充的原因很多,例如应对外部碎片,或者满足对齐方式;

这种结构称之为隐式空闲链表,是因为空闲块 是通过头部中的大小字段隐含的连接在一起的;

放置策略:

1:首次适配:从头开始搜索空间链表,选择第一个合适的空闲块。

2:下一次适配:和首次适配很相识,只不过不是链表开始出开始每次搜索,而是从上一次查询结束的地方开始。

3:最佳适配:搜索每一个空闲块,选择合适所需请求大小的最小空闲块。

一旦分割器找到一个匹配的空闲块,他就必须做另一个决定,那就是分配这个空闲块中多少空间,一个是选择整个空间,简单快捷,但缺点是内部碎片,如果放置策略趋于产生好的匹配,额外的内部碎片可以接受;

然而,如果匹配的不太好,那么分配器通常会选择讲这个空间块分割为两个部分,一个部分为分配块,另外变成一个空的空闲块。

 合并空闲块:

当分配器释放一个已分配块时,可能有其他空闲块与这个释放块相邻。这些邻接的空闲块可能引起一种现象,"假碎片",就是许多可用的空闲你看被切割成小得,无法使用的空闲块,比如两个相邻的4Bytes的空闲块,这时请求大小为5bytes的空闲块,只有两者合并才能满足,单独一个都满足不了该请求。

四种合并方式:

1. 前后块都是空闲

2. 前后块都不是空闲

3. 前面快空闲,后面不空闲

4. 后面快空闲,前面不空闲

 

显式空闲列表: 

一种更好的方法是将空闲块组织为某种形式的显式数据结构,因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面,例如,堆可以组织成一个双向空闲列表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,

使用双向列表而不是隐式空闲列表(逻辑抽象上的列表),使首次适配的分配时间从块的总数的线性时间减少到了空闲块数量的线性时间,不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲列表中块的排序策略:

一种方法是用后进先出(LIFO)的顺序维护列表,将新释放的块放置在列表的开始处,。使用LIFO的顺序和首次适配的放置策略,分配器就会最先检测最近使用过的块,在这种情况下,释放一个块可以在常数时间内完成,如果使用是边界标志,那么合并也可以在常数时间内完成。

另一种方法是按照地址顺序来维护列表,其中列表中每个块的地址都小于他后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前缀。平衡点在于,按照地址排序的首次适配比LIFO排序的首次适配有更高的存储器的利用率,接近最佳适配的利用率。[9]

7.10本章小结

本章主要介绍了内存的三种地址空间,以及他们之间的转换。之后,我们介绍了fork和execve对内存文件的映射,以及malloc动态申请内存的过程。

(第7 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

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

设备的模型化:文件

设备管理:unix io接口

一个Linux文件就是一个m个字节的序列:

B0,B1,…,Bm-1.

所有的I/O设备都没被魔像华为文件,所有的输入和输出都被当做对象文件的读写来执行。这个将设备映射为文件的方式,允许Linux内核引出一个简单地低级的应用接口,成为Unix I/O,是的所有输入和输出都可以以一种统一一直的方式来执行。(打开文件,改变当前文件的位置,读写文件,关闭文件)

8.2 简述Unix IO接口及其函数

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

接口:连接CPU和外设之间的部件,完成CPU与外界的信息传送,包括辅助CPU工作的外围电路,如中断控制器,DMA控制器,定时器,告诉cache等等。

Unixi/o接口是一个Linux内核的简单地低级的应用接口,其功能和函数如下:

打开和关闭文件:int open(char*filename,int flags,mode_t mode)和int close(int fd)。

读写文件:ssize_t read(int fd, void* buf, size_t n)和ssize_t write(int fd, const void*buf, size_t n)。

改变当前文件的位置:off_t lseek(int fildes, off_t offset, int whence)。

8.3 printf的实现分析

Printf函数的函数体

研究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;

    }

该函数除了一个fmt还有一个…只作为参数,表示可变参数列表。   

    va_list arg = (va_list)((char*)(&fmt) + 4);

 va_list的定义:typedef char *va_list
    这说明它是一个字符指针。

其中的: (char*)(&fmt) + 4) 表示的是...中的第一个参数。

也就是说,当调用printf函数的适合,先是最右边的参数入栈。

fmt是一个指针,这个指针指向第一个const参数(const char *fmt)中的第一个元素。

fmt也是个变量,它的位置,是在栈上分配的,它也有地址。

对于一个char *类型的变量,它入栈的是指针,而不是这个char *型变量。

之后是

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返回的是一个长度,要打印出来的字符串的长度

之后:write(buf, i);   把buf中的i个元素的值写到终端。

write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL

之后我们可以找到INT_VECTOR_SYS_CALL的实现:
    init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER);
  调用sys_call:

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
   
   call save,是为了保存中断前进程的状态。
  syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,于是我们的字符串就显示在了屏幕上。[7]

8.4 getchar的实现分析

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 函数调用了Unix io函数 read,通过系统调用read读取键盘缓冲区中的 ASCII 码直到读到回车符然后返回整个字串,getchar读取字符串的第一个字符并返回,同时字符串长度减一。

8.5本章小结

本章我们主要介绍了文件个Unix io,同时对printf函数和read函数进行了分析

(第81分)

结论

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

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

当一个程序员编写hello.c后,hello.c文件经过cpp预处理成为了hello.i文件,之后,他又经过了ccl编译才成为了汇编文件hello.s,之后通过as汇编从文本文件变成了二进制文件——可重定位目标文件hello.o,hello.o通过链接与动态库libc.so等链接形成了最终的可执行目标文件hello

当hello形成一个可执行目标文件之后,我们将加载到一个新的进程中,我们在shell中输入./hello 1190303125 董梓灿 1,shell会对这个命令行进行分析,得到命令行参数,并在前台加载这个程序。.init通过fork创建一个子进程,之后,通过execve将这个hello程序加载到这个进程中,并完成内存映射。在程序运行的过程中,会遇到各种异常和信号,会与其他进程并行,我们通过异常处理和信号处理的程序进行处理。同时,当需要printf和getchar的时候,我们会将这个标准Io准变成UNIXIO,完成对hello内容的打印和字符的读入。

最后,进程结束,会发送一个SIGCHLD信号,从而被父进程回收。

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

附件

列出所有的中间产物的文件名,并予以说明起作用。

hello.c:源代码

hello.i:预处理后的文本文件

hello.s:编译之后的汇编文件

hello.o:汇编之后的可重定位目标执行文件

hello:链接之后的可执行文件

hello0elf:hello.o的ELF格式

hello1elf:hello的ELF格式

hello0.txt:hello.o反汇编代码

hello1.txt:hello的反汇编代码

参考文献

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

[1]    《深入理解计算机系统》

[2]    https://baike.baidu.com/item/预处理/7833652?fr=aladdin

[3]    https://www.cnblogs.com/li--chao/p/9229927.html

[4]    https://blog.csdn.net/wy122222222/article/details/111177535

[5]    https://baike.baidu.com/item/%E6%B1%87%E7%BC%96%E7%A8%8B%E5%BA%8F?fromtitle=%E6%B1%87%E7%BC%96&fromid=627224

[6]    https://blog.csdn.net/m0_38037668/article/details/116981280

[7]    https://www.cnblogs.com/pianist/p/3315801.html

[8]    https://blog.csdn.net/wxzking/article/details/5905214?utm_medium=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-1.base&depth_1-utm_source=distribute.pc_relevant_t0.none-task-blog-2%7Edefault%7EBlogCommendFromMachineLearnPai2%7Edefault-1.base

[9]    https://www.cnblogs.com/onlysun/p/4527190.html

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值