hello的一生 hit csapp大作业
万恶的大作业还要发自媒体
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 23 -
6.3 Hello的fork进程创建过程... - 23 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 27 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 27 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 28 -
7.5 三级Cache支持下的物理内存访问... - 29 -
7.6 hello进程fork时的内存映射... - 30 -
7.7 hello进程execve时的内存映射... - 30 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P
Hello程序的生命周期是从一个源程序开始的,即程序员通过编辑器创建并保存的文本文件,文件名是hello.c。源程序实际上就是一个由值为0和1组成的位(又称为比特)序列,8个位被组成一组,称为字节。每个字节表示程序中的某些文本字符。为了在系统上运行hello.c,并把它翻译成一个可执行目标文件hello。这个过程分为四个阶段完成(预处理器,编译器,汇编器和链接器)一起构成了编译系统。
如图先通过预处理器生成了修改了的源程序hello.i,然后通过编译器生成了汇编程序hello.s再通过汇编器生成可重定位的目标程序(二进制文件)hello.o最后将hello.o与调用的库中的其他可重定位的目标程序通过链接器链接生成了可执行目标程序hello就可以运行啦!
020
程序开始运行前,进程中并没有这个程序这是最开始的zero-0,程序运行时赋予他一个PID即一个进程的编号记录程序当前的运行状态,当程序运行结束后程序将终止,等待父进程或者shell回收这个进程。回收完成后shell中又没有这个程序运行的消息所以说是从zero-0 到 zero-0
1.2 环境与工具
1.2.1 硬件环境
CPU:Intel(R) Core(TM) i7-8750H @ 2.20GHz (64位)
GPU:Intel(R) HD Graphics 630(1024MB)
Nvidia GeForce GTX 1060(6144MB)
物理内存:16.00GB
磁盘:2TB HDD
512GB SSD
1.2.2 软件环境
Vmware 14.11;
Ubuntu 18.04 64位;
1.2.3 开发工具
Code::Blocks;
gedit,gcc,notepad++;
1.3 中间结果
Hello.i 预处理之后的文本文件
Hello.s 编译之后的汇编文件
Hello.o 汇编之后的可重定位文件
Hello 连接之后的可执行目标文件
Asm.txt hello.o的反汇编代码
Asm1.txt hello的反汇编代码
1.4 本章小结
本章主要介绍P2P和020的过程,以及描述了实验的软硬件环境和实验中间结果。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
(以下格式自行编排,编辑时删除)
概念:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。得到另外一个C程序,通常情况下是以.i为文件扩展名。
作用:
-
- 读取系统调用的库文件中的内容,并把它直接插入到程序中去。
- 根据宏定义将程序中的对应的宏进行替换。
- 针对#ifdef等进行条件编译。
2.2在Ubuntu下预处理的命令
Gcc -E -o xxx.i xxx.c
如图通过使用该命令生成了修改过的源程序代码文件hello.i
2.3 Hello的预处理结果解析
使用vim打开hello.i文件
可以看到生成的hello.i文件有3118行而原来的hello.c文件一共只有29行,hello.i文件将hello.c文件中调用的三个库进行了展开的程序,新的hello.i程序中已经没有#include某个库这样的语句了。
如图我们还可以看到hello.i文件中有很多描述库在计算机中位置的语句。
2.4 本章小结
本章主要讲述了C语言在预处理过程中做了哪些事,简单讲述了预处理过程的概念和作用,讲述了预处理的结果以及对程序的影响。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:编译器(ccl)将文本文件从hello.i翻译成文本文件hello.s,它包含了一个汇编语言程序。该程序包含函数main的定义如下图所示:
定义中2~7行的每条语句都以一种文本格式描述了一条低级机器语言指令。
作用:它为不同高级语言的不同编译器提供了通用的输出语言。
3.2 在Ubuntu下编译的命令
gcc -S -o xxx.s xxx.i
如图通过该命令生成了hello.s文件
3.3 Hello的编译结果解析
3.3.1 对于数据的处理
全局变量:hello.c中的全局变量有一个 sleepsecs 它是一个int类型的数但是却赋给了它一个浮点的值。此时编译器会自动进行隐式类型转化将sleepsecs的初值改为2并把它存入data节中。如图所示:
通过图中的信息我们还可以看到这个程序是4字节对齐的并且sleepsecs的类型是object(对象)
局部变量:hello.c中有argc 和 i 两个局部变量。这两个参数在该程序中分别被保存在运行时堆栈中。
其中argc被保存在 -20(%rbp)中,i被保存在 -4(%rbp)中。
立即数:直接写入到汇编程序中去并没有保存到堆栈或者寄存器。
字符串:hello.c中出先得字符串数据一共有两个如图所示:
分别是两个printf语句中得格式串。
编译后他们被存储在rodate节中只读不能更改!
于赋值的处理
Hello.c程序中共有两个赋值语句。对于sleepsecs的赋值和对于i的赋值。
其中对于sleepsecs的赋值由于sleepsecs是全局变量将这个信息存储在了.data节中这一点我们在3.3.1节中已经讲过了,那么对于i的赋值汇编语言运用了mov语句如图:
3.3.3 对于类型转换的处理
Hello.c程序中的类型转换只有对于sleepsecs的类型转换,它将一个浮点数赋值给了一个整型数,然后在编译过程中对该变量做了隐式类型转换。
3.3.4对于算术操作的处理
Hello.c程序中的算术操作看上去只有i++一个,对于这个操作在汇编代码中
通过每次将1加到存储变量i位置的堆栈中去进行更新。
但是我们通过查看汇编代码发现
这句话也是一个算术操作,他将存储在rodate节中的printf语句的格式串传递给了rdi作为调用printf函数的参数。同理这句话也是。
3.3.5 对于关系运算的处理
Hello.c中一共有两个关系运算。
argc!=3:判断argc不等于3。hello.s中使用
计算argc-3然后设置条件码,为下一步je利用条件码进行跳转作准备。
i<10:判断i小于10。hello.s中使用
计算i-9然后设置条件码,为下一步jle利用条件码进行跳转做准备。
3.3.6 对于数组操作的处理
数组: hello.c程序中有一个char*数组argv数组得值也被存入到内存得相应得值中去,汇编程序通过地址查询相应数组得值。
具体来说:将argv数组存储的起始位置压入栈中,因为每个char*类型的长度为8byte所以该地址加八即使下一个char*的位置(注意到rsi是第二个参数,rdx是第三个参数可以推得)
3.3.7 对于控制转移的处理
条件控制以上图为跳转依据。
Hello.c中的控制转移一共有两个,一个是if条件判断句的控制转移,另外一个是控制循环是否终止的控制转移。
对应if的控制转移中
如果不等于3就继续执行否则跳转到.L2处。
对于循环中的条件控制
首先有一个判断循环是否终止的条件如果循环终止的话就继续执行后续代码否则挑战到.L4执行循环部分的操作。
3.3.8 对于函数的处理
- Main函数
通过系统启用函数__libc_start_main使用call语句调用main函数,先将dst压入栈中,再通过rdi和rsi传递参数argc 和 argv, 最后将返回值放到rax中返回零即return 0
-
- Printf函数
将格式串信息放在rdi中,将剩下的参数信息依次放在rsi,rdx,rcx中进行参数传递。具体调用过程使用call语句调用。
其中第一个printf语句因为只需要输出相应字符串信息,所以编译器将其优化为puts
-
- Exit函数
传递参数edi值为1 通过call 调用exit函数
-
- Sleep函数
传递参数使得edi的值为sleepsecs通过call调用sleep函数。
-
- Getchar函数
直接通过call调用getchar函数并没有传递参数。
3.4 本章小结
本章详细讲述了编译的概念和作用,具体分析hello程序事如何被编译器编译成一个汇编程序的,具体分析了hello程序中的每一部分被编译成汇编以后的存储方式或者记录方法,以及hello中一些常量和格式串在汇编中的记录方法。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将保存结果保存在目标文件hello.o中。
作用:将汇编语言翻译成一条条机器语言方便机器执行该段代码
4.2 在Ubuntu下汇编的命令
命令: gcc -c -m64 -no-pie -fno-PIC hello.s -o hello.o
4.3 可重定位目标elf格式
ELF Header(描述了整个ELF表的信息)
Section Headers(分别对应了各节的基本信息如偏移量)
重定位节的信息首先整个重定位位于hello.o二进制文件偏移量为0x340的位置上,其每个重定位信息给予了相对首地址的偏移量,通过info提供了重定位信息。在hello中需要重定位的信息有puts,exit,printf,sleepsecs,sleep,getchar 以及rodata(存放的是跳转表)
4.4 Hello.o的结果解析
通过objdump -d hello.o > asm.txt 生成了反汇编代码,通过比较两个代码对照分析,其整体大致相同不一样的地方主要有一下几点。
- 新生成的反汇编文件中不在有形如.L2的跳转表信息,链接之后这类信息会指向固定的地址,所以不在使用分治转移的结构而是指向确定的地址位置。其次每个在使用call函数的时候后面不在接函数名字,而是加上了一个相对便宜地址,这个地址和下一条指令的位置相结合在重定位的时候确定了该调用的位置。
- 其次,反汇编而成的代码中已经有了清楚的机器代码而不再是单一的汇编指令
- 对于printf函数中传递的格式串参数,在hello.s中通过
指向rodata节中的信息而在反汇编代码中也由全零暂时代替,在重定位过后才会根据rodata信息更新该位置的值
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
4.5 本章小结
本章具体描述了编译器事如何将hello.s的汇编指令翻译成hello.o的机器指令的,针对hello.s与hello.o的不同做了分析,注意到了跳转信息已经转换成了具体的地址位置,对汇编指令到机器指令的变换有了一定的了解。
(第4章1分)
第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
5.3 可执行目标文件hello的格式
ELF Header(描述了整个ELF表的信息)
如图section Headers描述了各节的信息,其中Name对应各节的名字,Type对应记录了每节的类型 ,address对应了这一节的信息在虚拟内存中的存储位置。Offset对应了每一节相对于0x400000的偏移地址。Size记录了每一节的大小。Align记录了对齐位数,Info记录这一节的信息,后两位对应类型,前六位对应在symtab节中的ndx值。
5.4 hello的虚拟地址空间
可以在Data Dump中查询虚拟地址各段的信息。
我们可以看到程序从可执行文件中加载的信息从0x400000处开始存放,hello中存放位置为0x400000—0x400ff0。由5.3节中section Headers节中的信息可以知道这些虚拟内存中相应位置存储的信息是什么。
其次我们可以看到elf文件中有一个program Headers节可以看到可执行文件共分为8个节每个节对应的偏移地址,虚拟地址位置,物理地址位置,文件大小,存储大小,标志和对齐方式都存储在该节中。Program header节告诉系统如何创建进程映像。用来构造进程映像的目标文件必须具有程序头部表,可重定位文件不需要这个表。
其中PHDR存储的是程序头表,INTERP存储的是程序执行前需要调用的解释器,LOAD存储的是程序目标代码和常量信息,DYNAMIC存储的是动态链接器所使用的信息,NOTE存储的是辅助信息,GNU_EH_FRAME存储的是保存异常信息,GNU_STACK存储的是使用系统栈所需要的权限信息。GNU_RELRO存储的是保存在重定位之后的只读信息的位置。
5.5 链接的重定位过程分析
使用objdump -d -r hello > asm1.txt 生成反汇编代码。观察asm.txt与asm1.txt分体hello与hello.o的不同主要有以下几点:
- 原先由0填充的跳转地址,已经由相对偏移地址,变成了该函数或者语句所在的虚拟内存地址。
如图可与4.4节中的图对比
- 程序添加了许多动态链接库中的函数,程序原先调用的库函数都被复制到了程序的代码中来。
- 原先存储在data及radata节中的信息,已经被程序放到了虚拟内存中去,再次调用时会直接从虚拟内存的相应位置读取(如sleepsecs及printf函数的格式串
5.6 hello的执行流程
使用edb单步调试执行hello注意call的调用
载入:
_dl_start
_dl_init
开始执行:
__stat
_cax_atexit
_new_exitfn
_libc_start_main
_libc_csu_init
运行:
_main
_printf
_exit
_sleep
_getchar
_dl_runtime_resolve_xsave
_dl_fixup
_dl_lookup_symbol_x
退出:
Exit
5.7 Hello的动态链接分析
首先我们通过ELF文件的DYNAMIC节的信息知道了动态链接乡相关的信息存储的地方然后我们先通过edb单步运行到dl_init函数前查看相应位置的信息
我们还可以知道通过ELF文件知道GOT表的存储位置如图:
由已经学习的知识,我们知道编译器对于PIC(位置无关代码)使用了GOT来确定他的位置,每个GOT条目中生成了一个重定位记录,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标正确的绝对地址,每个引用全局目标都有自己的GOT。对于PIC函数的调用,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用称为延迟绑定的技术来解决了这个问题,它把函数地址的解析推迟到它第一次被调用的时候来进行,这样能避免动态链接器在加载时进行的成百上千的不必要的重定位,具体的方法是使用了PLT和GOT来协作在运行时解析函数的地址。在这里PLT中的每个条目存储的是一个16字节代码。PLT[0]可以跳转到动态链接器中,每个函数对应一个PLT条目,当GOT和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块的入口点。其余的每个条目依次对应一个被调用的函数,初始时每个条目指向对应PLT条目的第二条命令,所以在dl_init之前如下图是GOT表:
当运行完dl_init函数后GOT表的变化如下:
注意到GOT[1]和GOT[2]的值被更新了GOT[2]指向了ld-linux.so模块的入口点,GOT[1]指向了reloc entries的位置
其中存储了各个函数的地址信息。
5.8 本章小结
本章主要介绍了链接的概念与作用并结合hello程序讲述了链接过程中虚拟地址的分配、重定位以及main函数运行前程序的初始化过程观察到了GOT数组的变化过程对重定位有了深入的理解。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中,上下文是由程序正常运行所需要的状态组成的。每个状态包括存放在内存中的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。
作用:进程提供给应用程序关键抽象:
- 独立的逻辑控制流:它提供一个假象好像我们的程序独占地使用处理器。
- 私有的地址空间:它提供一个家乡,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
1. 定义:Shell 是一个交互型的应用级程序,它代表用户运行其他程序。
2. 功能:Shell会打印出提示符,等待来自stdlin的输入,根据输入执行特定地操作,这样就产生了一种错觉,似乎输入的文字(命令行)控制了程序的执行。
3. 处理流程:命令行是一串ASCII字符由空格分隔。字符串的第一个单词是一个可执行程序,或者是shell的内置命令。命令行的其余部分是命令的参数。如果第一个单词是内置命令,shell会立即在当前进程中执行。否则,shell会新建一个子进程,然后再子进程中执行程序。新建的子进程又叫做作业。通常,作业可以由Unix管道连接的多个子进程组成。如果命令行以&符号结尾,那么作业将在后台运行,这意味着在打印提示符并等待下一个命令之前,shell不会等待作业终止。否则,作业在前台运行,这意味着shell在作业终止前不会执行下一条命令行。 因此,在任何时候,最多可以在一个作业中运行在前台。 但是,任意数量的作业可以在后台运行。
6.3 Hello的fork进程创建过程
父进程通过fork创建一个子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程获得与父进程任何打开文件描述符相同的副本,这就意味着父进程调用fork时,子进程可以读写父进程任何打开的任何文件。父进程和新创建的子进程之间最大的区别在于他们拥有不同的PID。
Fork在执行时被调用一次,但是却返回两次,一次是返回到父进程,一次是返回到新创建的子进程。
父进程和子进程是并发运行的独立进程。内核能够以任意方式交替执行它们的逻辑控制流中的命令。
其运行流程图大致如下:
6.4 Hello的execve过程
Execve函数在当前进程的上下文中加载并运行一个新的程序。Execve加载并运行可执行目标文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。所以与fork调用一次返回两次不一样,execve调用一次并且从不返回。使用execve函数之后,会清楚原先程序在虚拟内存中存储的信息,并重新初始化。程序新开始时的栈帧如下:
6.5 Hello的进程执行
逻辑控制流:唯一得对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态连接到程序的共享对象中的指令的PC的值称为逻辑控制流。
上下文信息:操作系统内核使用一种称为上下文切换的异常控制流来实现多任务。内核为每个进程维护一个上下文,上下文就是内核重新启动一个被抢占进程所需的状态。它由一些对象的值组成,这些对象包括通用目的的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈核各种内核数据结构。
进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用控制寄存器中的一个模式位来记录当前进程运行的模式,如果模式位已经设置,则程序运行在内核模式中,一个运行在内核模式中的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。,没有设置模式位时,进程就运行在用户模式中,用户模式中的进程步韵熙执行特权指令。
调度:当进程执行的某些时刻内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程,称为调度。
调度的过程:进程收到一个信号,进程A挂起,进入内核模式(发生了一次上下文切换)运行另外一个进程B,当运行过程中进程B收到一个信号,停止运行进程B,进入用户模式(又发生一次上下文切换)
具体对于hello中sleep函数的进程调度,在运行到sleep之前,程序未收到信号保持执行的状态,运行到sleep函数之后,进程收到挂起信号,挂起当前进程切换进入其他进程。2s后sleep调用截止,当前运行的进程又收到一个信号,此时再次发生上下文切换,返回hello程序的运行。(返回这个进程时会恢复寄存器,堆栈的状态)具体流程图大致如下:
hello的异常与信号处理
- 当程序正常运行的时候运行截图如下:
- 当在程序运行时按回车只会增加输出之间的空行再次不做截图及说明了。
- 当在程序运行时按下Ctrl+Z时hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。发送SIGSTP信号给进程,收到信号后进程挂起不在运行效果如图:
通过ps发现此时进程只是被挂起并没有终止,没有被回收如果忘记回收会使得进程变成僵尸进程影响电脑性能。
- 当程序运行时按下Ctrl+C时,发送SIGINT信号给进程,进程立即终止,并回收该进程。如图:
6.7本章小结
本章初步讲述了进程的概念和作用讲述了shell如何在用户程序运行时通过fork函数创建一个新的进程以及通过execve函数加载一个新的进程讲述了shell如何在用户和系统内核直接按通过上下文切换建立了一个交互运行的桥梁,还通过简单距离讲述了信号机制在程序运行中的作用,了解到了前端程序和后台程序。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:很简单,就是你源程序里使用的地址,或者源代码经过编译以后编译器将一些标号,变量转换成的地址,或者相对于当前段的偏移地址。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有唯一的物理地址。
虚拟地址:虚拟地址就是逻辑地址,又叫虚地址。
线性地址:分段机制下CPU寻址是二维的地址即,段地址:偏移地址,CPU不可能认识二维地址,因此需要转化成一维地址即,段地址*16+偏移地址,这样得到的地址便是线性地址(在未开启分页机制的情况下也是物理地址)。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式内存管理方式就是直接将逻辑地址转换成物理地址,也就是CPU不支持分页机制。其地址的基本组成方式是段号+段内偏移地址。每个段选择符大小为16位,段描述符为8字节。GDT为全局描述表,LDT为局部描述符。段描述符存放在描述符表中,也就是GDT或LDT中,段首地址存放在段描述符中。
从上图可以看出段选择符由三个部分组成,从右向左依次是RPL、TI、index(索引)。RPL在此不做介绍。先来看TI,当TI=0时,表示段描述符在GDT中,当TI=1时表示段描述符在LDT中。Index表示了在这个描述符表中的偏移量。如果index为1,则偏移8个字节。
7.3 Hello的线性地址到物理地址的变换-页式管理
最简单得线性地址(虚拟地址)到物理地址的变换是通过页式的管理方式,VM系统通过将虚拟内存分割为称为虚拟页的大小固定的块,类似地将物理内存分割称为物理页地址翻译地过程是从VA到PA地过程。
形式上来说,地址翻译过程是一个N元素地虚拟地址空间中的元素和一个M元素的物理地址空间中元素之间的映射。
CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含两个部分;一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(v*n)。
MMU利用v*n来选择适当的PTE,以此将页表中物理页号和虚拟地址中的VPO串联起来,得到相应的物理地址,注意:因为物理和虚拟页面都是P字节的所以物理页面偏移和VPO是相同的。翻译过程如下图:
7.4 TLB与四级页表支持下的VA到PA的变换
TLB即翻译后备缓冲器,它是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块,TLB通常有高度的相连度。所以每次在翻译地址的过程时可以先在TLB中查询是否命中以直接获取物理地址。
多级页表:如果只用一个单独的页表进行地址翻译,会有一个占据内存大量空间的页表驻留在内存中,因此我们需要压缩页表,也就是使用了多级页表。一级页表中的每个PTE负责映射到虚拟地址空间中的一个片,这里每一个片都是由1024个连续的页面组成(也就是二级页表)再用这些二级页表的PTE覆盖整个空间。两级页表层次结构如下图所示:
当TLB与四级页表相结合其地址翻译过程如下:先将这个虚拟地址的v*n分为TLB标记部分和TLB索引部分检查是否再TLB命中如果命中直接取出物理地址,否则的化虚拟地址被划分为4个v*n和一个VPO每个v*n(i)对应了第i级页表的索引,通过这个索引最后对应了一个固定的PPN将这个PPN与VPO结合得到新的物理地址,并把这个物理地址的信息存入TLB缓存。
多级页表v*n的划分的示意图如下:
7.5 三级Cache支持下的物理内存访问
系统在得到物理内存后,先将物理内存分为CT(标记)+ CI(索引)+CO(块偏移)然后在L1cache中找按是否命中如果命中直接返回否则的化进入下一级cache,做相同的操作,如果L2,L3cache都没有命中会从主存中取出这个地址的值。得到地址存储的信息后,会把信息写入上一级的cache中,如图所示:
7.6 hello进程fork时的内存映射
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任意一个后来进行写操作时,写时复制机制就会创建新的页面,因此也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
Exeve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地代替了当前程序。加载并运行a.out需要以下几个步骤。
- 删除已经存在地用户区域,即删除当前进程虚拟地址地用户部分中的已经存在的区域结构。
- 映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些结构都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.test和.data区。Bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。
- 映射共享区域,如果a.out程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC) execve做的最后一件事情就是设置当前进程上下文的程序计数器,并使之指向代码区域的入口点。
如图显示了加载器是如何映射用户地址空间区域的:
7.8 缺页故障与缺页中断处理
DRAM缓存的不命中陈称为缺页。DRAM缓存的不命中触发一个缺页故障,缺页故障嗲用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果该牺牲页已经做了更改,那么内核会将它复制回磁盘,否则不会进行复制即写回,然后将牺牲页从DRAM中出去,更新该页的位置放入待取的页面。然后CPU重新执行造成缺页故障的命令此时将可以正常运行。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块用来分配,空闲块保持空闲,直到它显示地被应用所分配。一个已分配地块保持已分配状态直到它被释放,这种释放要么是应用程序显式执行地,要么是内存分配器自身隐式执行地。
分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
动态存储分配管理需要考虑地式分配速率和堆栈地利用绿,其中影响堆栈利用率低地主要原因是一种称为碎片地现象,非为外部碎片和内部碎片。
其中内部碎片是在一个已分配块比有效荷载大时发生地。外部碎片是当空西安内存合计起来能够满足一个分配请求,但他们不是连续地时候发生地。
普通堆地组织结构如下图:
为了提高动态存储分配地吞吐率和利用率需要考虑空闲块组织、放置、分割以及合并地问题。
为了方便空闲块地合并往往采用边界标记地堆块格式,在每个块地结尾处添加一个脚部,其中脚部就是头部地副本,其格式如图所示:
使用边界标记地格式合并空闲块共分为4种情况比较简单,在此不做赘述。
除此意外,更好地空闲块组织格式是使用显示空闲链表,在每个块地头部后面加上前驱和后继标记将空闲块当作链表一样串接起来。其示意图如下:
更好地一种方式是采用分离地空闲链表,即在显示空闲链表地基础上将这种链表按照空闲块地大小分成若干个链表这样每次查询就不需要在整个空闲块组成地链表中查询只需要查询一部分。
链表地链接形式主要有两种一种是LIFO排序即后进先出顺序,将新释放地块放在链表地开始处,另外一种是使用插入排序地思想将每个链表维护成一个按照size大小升序的序列,这样采用首次适配与最佳适配得到的效果相同。
7.10本章小结
本章介绍了系统的存储器地址空间的概念讲述了虚拟地址、物理地址、线性地址以及逻辑地址的概念,还阐述了逻辑地址到线性地址、线性地址到物理地址的翻译过程,讲述了intel的管理方式。同时结合TLB与多级页表详细阐述了如何优化页地址的翻译过程讲述了系统运行时的存储方式。本章还讲述了进程运行时使用fork函数创建新的进程,以及execve函数加载新进程时系统对内存空间做了哪些事,其中比较有趣的是私有的写时复制,大大节省了内存空间的占用。本章还描述了系统是如何应对缺页故障现象的,讲述了动态存储分配的多种管理方式。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
每个Linux文件都有一个类型来表明它在系统中的角色:
普通文件:包含人一数据。应用程序常常需要区分文本文件和二进制文件,文本文件是只含有ASCLL或Unicode字符的普通文件;二进制文件是所有其他文件,对于内核来说这二者没有却别。
目录:是包含一组链接的文件,其中每一个链接都将一个文件名映射到另一个文件。
套接字:是用来与另一个进程进行跨网络通信的文件。
其他文件类型包括命名通道、符号链接以及字符和块设备。
unix io接口包括打开和关闭文件、读和写文件以及改变当前文件的位置。
8.2 简述Unix IO接口及其函数
打开和关闭文件:
Open()函数:这个函数回打开一个已经存在的文件或者创建一个新的文件,可以添加参数只读,只写和可读可写。
Close()函数:这个函数关闭一个已经打开的文件。
读和写文件:
应用程序通过分别调用read和write函数来执行输入和输出的。
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf
返回值-1表示一个错误,返回值为0表示EOF。否则返回值表示的是实际传送的字节数量。
write函数从内存buf位置复制至多n个字节到描述符fd的当前文件位置。
改变文件位置。
8.3 printf的实现分析
研究printf的实现,首先来看函数的函数体:
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char *)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
可以看到printf接受了一个fmt的格式,然后将匹配到的参数按照fmt格式输出。我们看到printf函数中调用了两个系统调用分别是vsprintf和write,先看看vsprintf函数:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char *p;
char tmp[256];
va_list p_next_arg = args;
for (p = buf; *fmt; fmt++)
{
if (*fmt != '%')
{
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt)
{
case 'x':
itoa(tmp, *((int *)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
可以看到这个函数的作用是将所有参数内容格式化后存入buf,然后返回格式化数组的长度。而另一个函数write是一个输出到终端的系统调用在此不做赘述。
所以printf函数的执行过程是:从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
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函数调用了系统函数read,读入BUFSIZE字节到buf,然后返回buf的首地址,注意到只有当n = 0时才会调用read函数,如果n = 0还会返回EOF文件终止符。异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章简单讲述了Linux的IO设备管理方式讲述了文件和和unix io的接口介绍了几个unix io接口的函数并结合hello讲述了printf函数以及getchar函数的运行机理。
(第8章1分)
结论
简单回顾hello的一生,它在出生时便知道自己的 命运被编译器编译然后运行完成自己的使命,它大致需要经过预处理、编译、汇编和链接四个操作。
预处理器将hello.c预处理称为hello.i, 获得了更多新的知识buff(如hello加载的库文件)
然后编译器将hello.i翻译成了汇编语言hello.s,变成了一个更接近于机器的语言。
之后汇编器又将hello会变成可重定位二进制文件hello.o就这样hello学会了机器语言,但是它还有很多困惑(外部文件尚未被链接,多种信息需要重定位)
于是链接器又将hello.o与外部文件链接解决了hello的困惑使得hello称为一个完成的可执行目标文件。
当在shell中下达了执行hello的指令,shell通过execve创建了一个新进程并赋予hello一个全兴的PID,此时shell会为hello配置号它的工作刚问(如代码段,数据段,bss,虚拟内存信息,堆栈信息等等)
当hello收到异常信号时,它会自动挂起等待shell的下一步命令。
最后当hello完成工作后,shell会收回它的工作PID,hello将等待它的下一次被调用。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
Hello.c 源程序
Hello.i 预处理之后的文本文件
Hello.s 编译之后的汇编文件
Hello.o 汇编之后的可重定位文件
Hello 连接之后的可执行目标文件
Asm.txt hello.o的反汇编代码
Asm1.txt hello的反汇编代码
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1]https://blog.****.net/gdj0001/article/details/80135196
[2] https://www.cnblogs.com/pianist/p/3315801.html
[2] 深入理解计算机系统 Randal E.Bryant, David R.O’Hallaron
(参考文献0分,缺失 -1分)