hello的一生
作者:互联网
摘要
本文通过对一个简单的hello程序的创建、运行、到终止的跟踪,阐述了预处理、编译、汇编、链接、进程管理、存储管理、IO管理等的原理以及和hello程序结合的内容。
关键词:汇编;链接;进程管理;hello程序的一生;
目录
第一章 概述
1.1hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
Hello的P2P:首先,在hello.c中编写程序,此时program形成。在linux下,hello.c经过cpp的预处理变为hello.i、经过ccl的编译变为hello.s、经过as的汇编变为hello.o、经过ld的链接变为可执行程序hello。在shell中,输入执行hello的指令之后,shell调用fork函数创建一个子进程,再在子进程中调execve函数,将hello加载到这个子进程中,覆盖掉原子进程,即hello变为了一个进程(process),完成P2P。
Hello的020:在hello成为进程之后,操作系统为该进程划分时间片,让它不断与其他进程上下文切换,并发执行。Hello的执行中,不断从内存中取指令,此时用到了MMU将虚拟内存转换为物理内存,再利用了快表、4级页表、3级cache等来进行加速从内存中取出数据。在Hello的执行中,还可能接收并处理来自键盘等IO设备的信号,还可能产生缺页等异常,还会经过上下文切换进入异常处理程序。在hello执行完毕之后,由其父进程shell来对其进行回收,从此hello进程不复存在,完成020。
1.2环境与工具
1.2.1 硬件环境
Intel64 CUP;2.6GHZ;RAM16G;256G Disk
1.2.2 软件环境
Windows10 64位;Vmware15;Ubuntu 18.04 64位
1.2.3 开发与调试工具
Linux下:gcc,vim,edb,gdb,readelf
Windows下:HexEdit
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 作用 |
hello.c | hello程序的源代码(文本文件) |
hello.i | hello.c预处理处理得到的文件(文本文件) |
hello.s | hello.i编译后得到的汇编代码(文本文件) |
hello.o | hello.s汇编后得到的可重定位目标文件(二进制文件) |
hello | Hello.o与其他文件链接后得到的可执行文件(二进制文件) |
1.4 本章小结
本章对hello程序进行了一个P2P、020的简介,列出了编写本论文用到的软硬件环境、工具、中间产物及其作用等。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理概念:在编译之前对源文件提前进行的内存处理。预处理过程扫描源代码,对其进行初步转换,产生新的源代码提供给编译器,可见预处理过程先于编译器对源代码进行处理。预处理格式为:#+指令关键字。
作用:1.预处理过程读入源代码,检查包含预处理指令的语句和宏定义,并对源代码进行相应的转换。2.预处理过程还会删除程序中的注释和多余的空白字符。3.预处理还会将源程序引用的所有库导入并且合并成为一个完整的文本文件。
2.2在Ubuntu下预处理的命令
命令:gcc hello.c -E -o hello.i
图2.2
2.3 Hello的预处理结果解析
图2.3
Hello.i仍然为文本文件。由hello.i与hello.c的对比可以看出,在hello.c的基础上,预处理对其添加了许多内容,其中大量使用了typedef语句,extern关键字以及结构体。hello.c文件的头文件被拷贝进了hello.i文件,而宏定义也被逐一替换。
2.4 本章小结
本章对.c文件的预处理进行了介绍,了解了.c文件是如何经过处理头文件,宏替换等一系列操作而被预处理的,阅读了.i文件,了解了其与.c文件的区别。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:编译阶段是检查语法,生成汇编的过程。就是把代码转化为汇编指令的过程,汇编指令只是CPU相关的。
作用:将.i文件翻译成汇编文件.s。编译过程还具备语法检查、方便调试、加上特定参数还可以使目标程序不同程度的优化、使不同逻辑的高级语言产生相同的汇编语言,便于后续将其一一对应翻译为机器码,而不受高级语言限制。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
图3.2
3.3 Hello的编译结果解析
3.3.0汇编文件头
开头如下:
图3.3.0
.file:源文件名(.c文件)
.text:代码段
.globl:全局变量
.data:数据段
.align:对齐方式,按4字节对齐
.type:指定其为对象类型或者函数类型
.size:大小
.long:长整型 其值为2
.section .rodata:以西是.rodata节
.string:字符串常量
3.3.1数据
- 常量
在.c程序中出现的常量,在汇编中都以立即数的形式出现:比如:
在汇编中为:
即3表示为$3。
- 变量
全局变量:
其在汇编中表示为:
可知,全局变量保存在.rodata节中。其值为2(由于2.5强转而来),为对象类型,占据4个字节。
局部变量:
在汇编中表示为:
说明i存放在栈中,地址为%rbp-0x4.
main函数的两个参数:
在汇编中体现如下:
说明argc变量存储在%edi中,并且转存在%rbp-0x20中
说明argv变量存储在%rdi中,并且转存在%rbp-0x32中
- 类型
虽然sleepsecs全局变量在程序中被定义为int类型,但是在汇编中被定义为long类型。
3.3.2赋值
在c语言中的变量的赋值语句在汇编中均使用mov语句来实现
将i赋值为0.
将sleepsecs赋值为2
局部变量保存在栈中,初始化的全局变量保存在.data中
3.3.3类型转换
由此可以看出,c程序对sleepsecs进行了强制类型转换,将float强行转换为int类型,但是实际上汇编把他当作long类型。强制类型转换满足取整数部分的原则
3.3.4算数操作
即为自增运算,其在汇编中实现如下:
3.3.5关系操作
,在汇编中实现如下:
,在汇编中实现如下:
,采取的和9比较,跳转条件为小于等于,与i<10意义相同。
3.3.6数组操作
在调用printf时,将使用了argv[1],argv[2]来作为printf的参数,在汇编中实现如下:
,其使用了加载有效地址指令leaq来计算LC1的所在段的段地址,为%rip+.LC1并将所计算的地址传递给%rdi作为printf的参数
3.3.7控制转移
,其汇编实现如下:
先使用cmp指令来比较,再利用设置的条件码来进行条件跳转
,其汇编实现如下:
与if类似,只不过跳转到的位置为for循环内部的起始代码位置
3.3.8函数操作
参数传递: argc和argv,分别保存在%edi和%rsi。
函数调用:系统自动启动。
函数返回:将%eax设置为0并且返回,对应于return 0
参数传递:单参数的printf函数被汇编为puts,调用时只传入了字符串参数首地址;
而for循环中多参数的printf函数被汇编为printf,调用时传入了 argv[1]和argc[2]的地址。
函数调用:if语句成立时调用,for循环中调用
对应的汇编为:
参数传递:传入的参数为立即数$1,执行退出命令
函数调用:if语句成立时调用
对应的汇编为:
参数传递:传入参数sleepsecs
函数调用:for循环中调用
对应的汇编为:
参数传递:无参数
函数调用:在main中被调用
3.4 本章小结
本章对.i文件进一步被生成的.s文件进行了了解和解释,分别从8个方面对汇编文件进行了对应解释,更加熟悉了c语言与汇编语言的对应关系,更加熟练了解了汇编语言,更加了解了高级语言对应的汇编层面的实现。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:编译后的文件到生成机器语言二进制程序的过程,机器码可以被CPU直接执行。
作用:可以将汇编语言一一对应翻译为计算机可以识别的机器码,它将汇编语言变为成可重定位目标程序的格式保存,即.o文件,还可以利用汇编产生的.o文件反汇编来调试代码。
4.2 在Ubuntu下汇编的命令
命令:gcc hello.s -c -o hello.o
图4.2
4.3 可重定位目标elf格式
首先输入 readelf -h hello.o查看hello.o的ELF头的信息
图4.3.1 hello.o的ELF头的信息
由图4.3.1头信息可得知ELF文件的基本信息,如:该.o文件为可重定位文件;ELF头的大小为64字节,程序头有0个,节头有13个...等等。
输入readelf -S hello.o查看hello.o的节头信息
图4.3.2 hello.o的节头信息
再输入readelf -s hello.o查看符号表
图4.3.3 符号表
再输入readelf -r hello.o查看重定位信息
图4.3.4 重定位信息
分析可知,再.rela.text中有8个项目需要重定位,包含1个变量、2个段以及5个函数,.rela.eh_frame有1个项目需要重定位,包含1个段。
输入readelf -a hello.o查看ELF所有信息
图4.3.5 ELF所有信息
由以上可知ELF格式文件由ELF头、节头、符号表、重定位信息等组成。
4.4 Hello.o的结果解析
输入objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
输入objdump -d -r hello.o 后输出如下:
图4.4.1 hello.o的反汇编
而之前的.s文件如下:
图4.4.2 hello.s
对比分析可知:
机器语言与汇编语言位一一对应。
区别如下:
| .s文件中 | objdump中 |
分支转移 | 类似于.L4的跳转目录 | 指令的地址,如jmp 6f |
函数调用 | 使用函数名称 | 使用函数地址 |
进制 | 使用十进制 | 使用16进制 |
全局变量 | 使用sleepsecs(%rip)访问 | 使用0x0(%rip)访问 |
4.5 本章小结
通过对本章的完成,我深入了解了.o文件,更加熟练地使用了readelf指令查看ELF格式文件,之后还对之前生成的.s文件与objdump反汇编生成的文件进行了对比,明白了两者之间的细微区别。还了解了机器语言与汇编语言的对应关系。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:分为静态链接和动态链接。静态链接是指把要调用的函数或者过程链接到可执行文件中,成为可执行文件的一部分。动态链接所调用的函数代码并没有被拷贝到应用程序的可执行文件中去,而是仅仅在其中加入了所调用函数的描述信息。编译时,加载时,运行时都可以链接。
作用:把多个可重定位文件合并在一起,找到这些文件之间的关系,生成一个大的、有绝对位置的目标程序,使得机器可以执行。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.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
图5.2
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
使用readelf -a hello命令得到如下信息:
图5.3.1 ELF头信息
与hello.o不同为:类型为可执行文件,且程序头大小为56字节,有8个程序头,而段头数量也增加为25。
图5.3.2 段头信息
对25个段头的信息进行了一一列出,还包含了整体信息,比如名称,类型,载入虚拟地址的起始地址,在程序中偏移量,对齐信息等等。
图5.3.3 程序头信息
图5.3.4 重定位节信息
图5.3.5 符号表信息
等等一系列的信息,具体可以使用readelf -a hello来查看。与上一章的分析基本一致。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
使用edb加载hello之后得到如下结果:
图5.4.1
hello程序的虚拟地址从0x400000开始,到0x400ff0结束。
而根据刚刚通过readelf工具查看的ELF格式文件中的节头表可以知道各个段的信息。
比如:
图5.4.2
根据.rodata的地址为0x400640可以查看其内容:
图5.4.3
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
不同之处:
- hello的反汇编中显示已经被重定位了,虚拟地址以已经给出,而hello.o的反汇编中的地址为相对地址,未完成重定位。
图5.5.1 hello的反汇编
图5.5.2 hello.o的反汇编
- hello的反汇编中增加了一些函数,这些函数都在main中或者函数初始化时用到了。例如:
图5.5.3 hello的反汇编
重定位的过程:
(1)所有类型相同的节被连接器合并在一起后,此节就作为可执行目标文件的对应的节。之后链接器把运行时的内存地址赋给新的合成的一个“大的”节、赋给输入模块定义的每个节以及输入模块定义的每个符号,至此,程序中每条指令和全局变量都有唯一运行时的地址。
(2)在重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。链接器通过可重定位目标文件中的重定位条目的数据结构来对符号进行重定位。
(3)而重定位条目是当编译器遇到对最终位置未知的目标引用时,生成的一个数据结构。代码的重定位条目放在.rel.txt,如下:
图5.5.4 .rel.txt
而其中,这些表头符号含义如下:
Offset | 偏移量 |
Type | 修改方式 |
Name | 需要修改的名称 |
Addend | 偏移调整 |
重定位算法如下:
图5.5.5 重定位算法
以对hello.o中的puts分析为例:
图5.5.6
Refptr = s+r.offset=0x4004b0+1d=0x4004dd
因为r.type==R_x86_64_PLT32,则进入第一个if语句
*refptr =0x4004b0+(-0x4)-(0x40054e+1)=-0xa2=-163=ffffff5d
小端表示为5d ff ff ff 结果正确。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
如下:
即为
即为
图5.6
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接:要解决空间浪费和更新困难这两个问题最简单的办法就是把程序的模块相互划分开来,形成独立的文件,而不再将他们静态的链接在一起。简单地讲,就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接。也就是说,把链接这个过程推迟到了运行时再进行,这就是动态链接(Dynamic Linking)的基本思想。
在调用共享库函数时,编译器为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
而由hello的ELF文件可知
图5.7.1 hello的ELF文件
.got段起始地址为0x600ff0,
.got.plt段起始地址为0x601000,进入edb调试
在未执行dl_init之前,.got段内容为:
图5.7.2 .got段
在未执行dl_init之前,.got.plt段内容为:
图5.7.3 .got.plt段
发现在0x600ff0之后16字节全为0,0x601008之后的16个字节全为0
执行dl_init之后,.got段内容为:
图5.7.4 .got段
执行dl_init之后,.got.plt段内容为:
图5.7.5 .got.plt段
发现0x600ff0之后的8个字节发生了变化,变为:0x7f9fe61d4ab0,而0x601008之后的16个字节也发生了变化,0x601008后8个字节变为:0x7f9fe67cd170,0x601010后8个字节变为:7f9fe65bb680,说明hello已经被动态链接。
5.8 本章小结
通过对本章的完成,我了解了链接的概念与作用,以及了解了可执行文件hello的格式以及ELF内容,hello的虚拟地址分析,执行流程,动态链接过程分析等等,让我更加对链接有了深入的认识。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是正在运行的程序的实例。
作用:可以让程序实例有两个假象:1.一个运行的程序实例好像独占了整个cpu。2。一个运行的程序实例好像独占了整个内存。进程使得一个物理设备可以并发地执行多个程序实例。
6.2 简述壳Shell-bash的作用与处理流程
作用:Shell是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。实际上Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果。BASH:是GNU的Bourne Again Shell,是GNU操作系统上默认的shell。
处理流程:shell在内存中长时间运行。根据以下程序得知其内部程序:
图6.2
因此shell重复以下过程:
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
(3)检查第一个命令行参数是否是一个内置的shell命令
(4)如果不是内部命令,调用fork( )创建子进程
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序
(6)shell使用waitpid等待前台作业终止回收,使用机制信号来回收后台作业
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的、处于运行状态的子进程。
使用函数int fork(void)
子进程返回0,父进程返回子进程的PID
新创建的子进程几乎但不完全与父进程相同:
1.子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本
2.子进程获得与父进程任何打开文件描述符相同的副本
3.子进程有不同于父进程的PID
fork函数:被调用一次,却返回两次(分别是父子进程)
6.4 Hello的execve过程
使用int execve(char *filename, char *argv[], char *envp[])函数,在当前进程中载入并运行程序
filename:可执行文件:目标文件或脚本(用#!指明解释器,如 #!/bin/bash)
argv :参数列表,惯例:argv[0]==filename
envp:环境变量列表:如"name=value" strings (e.g., USER=droh),getenv, putenv, printenv
覆盖当前进程的代码、数据、栈,保留有相同的PID,继承已打开的文件描述符和信号上下文
调用一次并从不返回,除非有错误,例如:指定的文件不存在
图6.4 新程序启动后的栈结构
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
以hello中的这个循环为例:
图6.5.1
printf为用户进程中的一部分,而sleep为系统内核进程的一部分,因此此处有上下文切换过程,如下:
图6.5.2 上下文切换
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
正常结果:
图6.6.1 正常结果
不停乱按:
图6.6.2 不停乱按结果
图6.6.3
不停乱按时,乱按输入的字符被缓存到stdin(标准输入),hello中运行到getchar 的时读一个“\n”结尾的字串,该字符串会当做 shell 命令行输入(且为最后一次以\n结尾的字符串)。图中输入了ksafdjbl.\n与fasjnddas.na\n,运行hello程序结束之后屏幕输出了fasjnddas.na作为命令行。
Ctrl-Z:
图6.6.4 按下Ctrl_Z
Ctrl_Z为挂起前台作业,由图可知该进程被挂起(stopped),但是被挂起的hello进程没有被回收,它的jid为1,在后续输入fg之后,该hello又被唤醒,命令行先输出./hello 1190202328 江经这个进程名称,再继续接着刚刚中断的位置执行,后续输出与原来一致,也需要等待输入一个字符才能结束该进程。
在按下Ctrl_Z之后继续按下如下命令:
ps:
图6.6.5 按下ps
显示当前所有进程(图中因为我运行了两个hello程序,按下了两次ctrl_z,因此有两个作业都被挂起,因此有两个hello)
jobs:
图6.6.6 按下jobs
显示当前所有作业及其状态(图中因为我运行了两个hello程序,按下了两次ctrl_z,因此有两个作业都被挂起)
pstree:
图6.6.7 按下pstree
显示进程树,由于太多,因此只截图部分示意
fg:
图6.6.8 按下fg
将后台的进程调入前台运行,图中我将作业号为1的作业调入前台运行,即刚刚被挂起的hello进程,因此该进程继续输出8个hello 1190202328 江经后等待一个字符输入,之后结束进程,然后被回收。
kill:
图6.6.9 按下kill
发送信号给某个进程或者进程组。图中我发送9号信号(杀死进程)给2582号进程(即为hello的一个进程),因此显示1号任务(对应进程号2582)被杀死(killed)。
Ctrl-C:
图6.6.10 按下Ctrl_C
Ctrl_C为终止前台作业,由图可知该进程杀死,而输入ps之后观察,进程中已经没有hello(pid=2755)了,hello进程接收到ctrl_c之后就被终止,且被回收。
6.7本章小结
通过对本章的学习,我学会了进程的相关概念,比如:如何通过fork来创建进程,如何通过wait等一系列函数来回收进程,如何通过execve来加载覆盖一个进程。以及信号的一些概念,如信号的发送,阻塞,接收等。对Linux的上下文切换等机制有了更深的了解。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:在有地址变换功能的计算机中,访内指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。例如hello的反汇编代码中的指令地址。
线性地址:逻辑地址到物理地址变换之间的中间层。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
虚拟地址:即为线性地址。
物理地址:指出现CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
图7.2
步骤如上图。首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、看段选择符的T1=0还是1,知道当前要转换是(全局段描述符)GDT中的段,还是局部段描述符(LDT)中的段,再根据相应寄存器(GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中),得到其地址和大小。于是就得到了一个数组了。
2、拿出段选择符中前13位即图中的index,可以在这个数组中,查找到对应的段描述符,这样基地址Base就知道了。
3、Base + offset即为被转换的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
图7.3.1 符号表
首先将线性地址分为VPN+VPO的形式,再去页表中寻找PPN,如果页表中没有,则发生缺页,进入缺页异常处理程序,选择一个牺牲页,换入该页,再重新运行访问页面的指令。在查找到对应的PPN之后,将其与VPO组合变为PPN+VPO,即为物理地址。如下图:
图7.3.2 翻译示图
7.4 TLB与四级页表支持下的VA到PA的变换
如果有TLB,则首先将线性地址分为VPN+VPO的形式,再将VPN分为TLBT+TLBI的形式,根据这标记和索引去TLB中寻找,如果命中,则直接取出其中存放的PPN;如果不命中,则去页表中寻找,此时将VPN分解为VPN1+VPN2+VPN3+VPN4,一级一级地查找,如果到对应的PPN,则取出PPN;如果找不到,则产生异常,进入缺页异常处理程序,选择一个牺牲页,将其替换,再重新执行刚刚的指令,得到PPN。
在查找到对应的PPN之后,将其与VPO组合变为PPN+VPO,即为物理地址。
图7.4 i7地址翻译实例
7.5 三级Cache支持下的物理内存访问
如上图,在得到物理地址之后,将物理地址拆分为三部分:CT(标记)+CI(组索引)+CO(块偏移),首先在L1cache中寻找,如果未命中,则继续在L2中寻找,如果还未命中,则继续在L3中寻找,如果L3未命中,则去内存中寻找,直到找到,返回结果。
7.6 hello进程fork时的内存映射
mm_struct(内存描述符):描述了一个进程的整个虚拟内存空间
vm_area_struct(区域结构描述符):描述了进程的虚拟内存空间的一个区间
用fork创建虚拟内存时:
1.fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID
2.创建当前进程的mm_struct,vm_area_struct和页表的原样副本
3.两个进程的每个页面都标记为只读页面
4.两个进程的每个vm_area_struct都标记为私有,这样就只能在写入时复制。
以此保证在fork之后,父子进程的内存映射几乎完全相同。
7.7 hello进程execve时的内存映射
函数原型:int execve(char *filename, char *argv[], char *envp[])
在当前进程中载入并运行程序,在此例中为shell进程首先fork一个子进程,再在子进程中调用execve函数加载hello进程。
filename:可执行文件,本例中应该为hello
argv :参数列表,惯例:argv[0]==filename(hello)
envp:环境变量列表
调用execve函数之后覆盖当前进程的代码、数据、栈
保留有相同的PID,继承已打开的文件描述符和信号上下文
如下图:
图7.7 execve函数执行后栈
加载hello的具体过程如下:
1.删除已存在的用户区域,删除当前进程(shell调用fork创建的子进程)虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域,为新程序的代码、数据、.bss 和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,.bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3.映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC),设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
1.段错误:首先,先判断缺页的虚拟地址是否合法。遍历所有的合法区域结构,如果这个虚拟地址对所有的区域结构都无法匹配,那么就返回一个段错误(segment fault)
2.非法访问:接着,查看这个地址的权限,判断一下进程是否有读写改这个地址的权限。如下图:
3.如果不是以上两种情况则为正常缺页,通过查询页表PTE可以知道虚拟页所在在磁盘的位置。缺页处理程序从指定的位置加载页面到物理内存中,并更新PTE。然后控制返回给引起缺页故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中,因此指令可以没有故障地运行完成。具体流程如图步骤(1)(2)(3)(4)所示:
图7.8.1 段错误示例
图7.8.2 缺页处理流程
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
在程序运行时程序员使用动态内存分配器 (比如 malloc) 获得虚拟内存。(数据结构的大小只有运行时才知道。)动态内存分配器维护着一个进程的虚拟内存区域,称为堆。层次关系如图7.9.1。内存镜像如图7.9.2。
图7.9.1 层次关系
图7.9.2 内存镜像
分配器将堆视为一组不同大小的 块(blocks)的集合来维护,每个块要么是已分配的,要么是空闲的。
分配器的类型:
1.显式分配器: 要求应用显式地释放任何已分配的快
例如,C语言中的 malloc 和 free
2.隐式分配器: 应用检测到已分配块不再被程序所使用,就释放这个块
比如Java,ML和Lisp等高级语言中的垃圾收集 (garbage collection)
动态内存管理的基本方法与策略:
方法 1: 隐式空闲链表(Implicit list):通过头部中的大小字段—隐含地连接所有块。如图7.9.3
图7.9.3 隐式空闲链表
如果块是对齐的,那么一些低阶地址位总是0,因此不要存储这些0位,而是使用它作为一个已分配/未分配的标志。但是读大小字段时,必须将其屏蔽掉。其内部结构如图7.9.4:
图7.9.4 块结构
其搜索策略如下:
1.首次适配 (First fit):从头开始搜索空闲链表,选择第一个合适的空闲块。
为总块数 ( 包括已分配和空闲块 ) 的线性时间,但是在靠近链表起始处留下小空闲块的 “碎片”。
2.下一次适配 (Next fit):和首次适配相似,只是从链表中上一次查询结束的地方开始。
比首次适应更快,因为避免重复扫描那些无用块。一些研究表明,下一次适配的内存利用率要比首次适配低得多。
- 最佳适配 (Best fit):查询链表,选择一个最好的空闲块。
剩余最少空闲空间,保证了碎片最小——提高内存利用率,但是通常运行速度会慢于首次适配。
还可以利用边界标记 ( Boundary tags ) [Knuth73]来实现双向查找,方便合并空闲块。即:在空闲块的“底部”标记“大小/已分配”,这允许我们反查 “链表” ,但这需要额外的空间。如图7.9.5:
图7.9.5 块结构
合并策略:
立即合并 (Immediate coalescing): 每次释放都合并
延迟合并 (Deferred coalescing): 尝试通过延迟合并,即直到需要才合并来提高释放的性能。例如:1.为 malloc扫描空闲链表时可以合并2.外部碎片达到阈值时可以合并。
方法 2: 显式空闲链表(Explicit list):在空闲块中使用指针。如图7.9.6
图7.9.6 显式空闲链表
其每块内部结构如图7.9.7
图7.9.7 块结构
显式空闲链表维护空闲块链表, 而不是所有块, “下一个” 空闲块可以在任何地方,因此需要存储前/后指针,而不仅仅是大小(size),还需要边界标记,用于块合并,但是,只需跟踪空闲块,因此可以使用有效载荷区域。
插入原则:
1.LIFO (last-in-first-out) 策略:后进先出法,将新释放的块放置在链表的开始处
优点: 简单,常数时间
缺点: 研究表明碎片比地址顺序法更糟糕
2.地址顺序法(Address-ordered policy),按照地址顺序维护链表: addr(前一个块) < addr(当前回收块) < addr(下一个块)
优点: 研究表明碎片要少于LIFO (后进先出法)
缺点: 需要搜索
方法 3: 分离的空闲列表(Segregated free list):按照大小分类,构成不同大小的空闲链表,如图7.9.8:
图7.9.8 分离的空闲列表
每个尺寸类 (size class)中的块,构成一个空闲链表,通常每个小的尺寸/size,都是一个单独的类,对于大的尺寸/size : 按照2的幂分类。
分离适配策略如下:
申请块:
分配器维护一个空闲链表数组,每个空闲链表和一个大小类关联,链表是显式或隐式的。
当分配器需要一个大小为n的块时:搜索相应的空闲链表,其大小要满足m > n。 1.如果找到了合适的块:拆分块,并将剩余部分插入到适当的可选列表中;如果找不到合适的块, 就搜索下一个更大的大小类的空闲链表,直到找到为止。
- 如果空闲链表中没有合适的块:向操作系统请求额外的堆内存 (使用sbrk()),从这个新的堆内存中分配出 n 字节,将剩余部分放置在适当的大小类中。
释放块:
合并,并将结果放置到相应的空闲链表中。
分离适配的优势
方法 4: 块按大小排序:在每个空闲块中使用一个带指针的平衡树,并使用长度作为权值。
7.10本章小结
通过堆本章的学习,我深入理解了虚拟内存的概念,以及如何将虚拟地址转换为物理地址,再利用物理地址来通过cache寻找内存的相应内容;我还深入理解了执行了fork、execve之后的内存映射;还深入理解了动态内存管理的基本方法与策略。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个 Linux 文件就是一个 m 字节的序列: § B0 , B1 , .... , Bk, .... , Bm-1 ¢ 所有的I/O设备都被模型化为文件:
例如: /dev/sda2(用户磁盘分区) /dev/tty2(终端) 甚至内核也被映射为文件: §/boot/vmlinuz-3.13.0-55-generic(内核映像) /proc (内核数据结构)
这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O:
8.2 简述Unix IO接口及其函数
打开和关闭文件:open()and close()
读写文件:read() and write()
改变当前的文件位置:seek()
指示文件要读写位置的偏移量:lseek()
8.3 printf的实现分析
研究printf的实现,首先来看看printf函数的函数体 :
图8.3.1 printf函数的函数体
在形参列表里有这么一个token:... ,这个是可变形参的一种写法。 当传递参数的个数不确定时,就可以用这种方式来表示。 很显然,我们需要一种方法,来让函数体可以知道具体调用时参数的个数。
先来看printf函数的内容:
va_list arg = (va_list)((char*)(&fmt) + 4);
va_list的定义: typedef char *va_list
这说明它是一个字符指针。 其中的: (char*)(&fmt) + 4) 表示的是...中的第一个参数。
下面我们来看看下一句:
i = vsprintf(buf, fmt, arg);
让我们来看看vsprintf(buf, fmt, arg)是什么函数。
图8.3.2 vsprintf(buf, fmt, arg)函数
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
write系统函数:
图8.3.3 write系统函数
INT_VECTOR_SYS_CALL的实现:
init_idt_desc(INT_VECTOR_SYS_CALL, DA_386IGate, sys_call, PRIVILEGE_USER);
INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
sys_call的实现:
图8.3.4 sys_call的实现
其作用可以理解为:显示格式化了的字符串。
因此得知printf函数执行如下:
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar函数源代码如下:
图8.4 getchar函数
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar 函数落实到底层调用了系统函数 read,通过系统调用 read 读取存储在键盘缓冲区中的 ASCII 码直到读到回车符然后返回整个字串。
8.5本章小结
通过对本章的学习,我了解了Linux的IO设备管理方法以及Unix IO接口及其函数;以及深入理解了printf的实现以及getchar的实现。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
- 写代码:hello.c中编写程序
- 预处理: hello.c经过cpp的预处理变为hello.i
- 编译:hello.i经过ccl的编译变为hello.s
- 汇编:hello.s经过as的汇编变为hello.o
- 链接:hello.o经过ld的链接变为可执行程序hello。
- 运行:在shell终端输入命令行运行hello程序
- 创建子进程:在shell中,输入执行hello的指令之后,shell调用fork函数创建一个子进程
- 加载hello程序:shell在子进程中调execve函数,将hello加载到这个子进程中,覆盖掉原子进程,即hello变为了一个进程。
- 上下文切换:操作系统为该进程划分时间片,hello程序执行时间达到该时间片之后,内核进行上下文切换到其他进程执行。或者,当hello程序执行到sleep函数,内核进行上下文切换,进程切换到处理休眠的进程,当sleep函数调用完成时,内核进行上下文切换将控制再次传递给hello进程。
- 执行中取指令:Hello的执行中,不断从内存中取指令,此时用到了MMU将虚拟内存转换为物理内存,再利用了快表、4级页表、3级cache等来进行加速从内存中取出数据。
- 动态内存申请:当hello程序执行printf函数时,printf会调用 malloc 向动态内存分配器申请堆中的内存。
- 信号处理:在Hello的执行中,还可能接收并处理来自键盘等IO设备的信号,还可能产生缺页等异常,还会经过上下文切换进入异常处理程序。
- 执行完毕:在hello执行完毕之后,由其父进程shell来对其进行回收,从此hello进程不复存在。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
计算机系统的设计与实现的深切感悟:计算机系统的设计最核心的部分就是软硬件的交界面,在这个界面中,既要将硬件抽象为一个个的接口,还要通过软件的设计来运用好这些接口,从而方便更高层应用的使用。而计算机设计的最主要目的是:快!为了加快计算机速度,在计算机内部采用了cache,TLB等一系列结构,充分利用时间空间局部性来加快计算机速度。
创新理念:可以尝试增加cache层级来加快速度,需要实验验证。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明其作用。
文件名 | 作用 |
hello.c | hello程序的源代码(文本文件) |
hello.i | hello.c预处理处理得到的文件(文本文件) |
hello.s | hello.i编译后得到的汇编代码(文本文件) |
hello.o | hello.s汇编后得到的可重定位目标文件(二进制文件) |
hello | Hello.o与其他文件链接后得到的可执行文件(二进制文件) |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
- Randal E. Bryant and David R. O'Hallaron. Computer Systems: A Programmer's Perspective, 3/E (CS:APP3e). Carnegie Mellon University, 2015.
- https://www.csdn.net
- https://baike.baidu.com
- https://www.cnblogs.com/pianist/p/3315801.html
(参考文献0分,缺失 -1分)
标签:文件,一生,汇编,hello,地址,进程,链接 来源: https://blog.csdn.net/Louis210/article/details/118071148