正在上传…重新上传取消
计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2021111171
班 级 2103102
学 生 chencaoyu
指 导 教 师 刘宏伟
计算机科学与技术学院
2022年5月
本文通过对一个简单程序 hello 的一生的分析,对我们所学进行梳理与回顾,介绍了预处理、编译、汇编、链接等一系列操作形成hello的进程,和程序对控制流的管理、内存空间的分配、信号的处理、对 I/O 设备的调用彻底解释hello从创建到结束的过程,搭建出一个较为完整的知识体系。
关键词:hello程序;Ubuntu;编译;汇编;链接;进程存储管理;虚拟内存;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
(1).P2P
Hello的P2P是指hello.c文件从可执行程序(Program)变为运行时进程(Process)的过程。在Linux的Ubuntu操作系统下,调用C预处理器(C Pre-Processor)得到ASCII码的中间文件hello.i;接着调用C编译器(ccl)得到ASCII汇编语言文件hello.s;然后运行汇编器(as)得到可重定位目标文件hello.o;最后通过链接器(ld)得到可执行目标文件hello。打开shell,输入命令./hello后,shell 通过fork产生子进程,hello 便从可执行程序(Program)变成为进程(Process).
- .020
Hello的020是指hello.c文件“From 0 to 0”。初始时内存中并无hello文件的相关内容,这便是“From 0”。OS的进程管理调用fork函数产生子进程(process),调用execve函数,并进行虚拟内存映射(mmp),并为运行的hello分配时间片以执行取指译码流水线等操作;OS的储存管理以及MMU解决VA到PA的转换,cache、TLB、页表等加速访问过程,IO管理与信号处理综合软硬件对信号等进行处理。当程序运行结束后,shell 父进程会负责回收 hello 子进程,内核删除相关数据结构,这边是“to 0”.
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2GHz;8G RAM;256GHD Disk
1.2.2 软件环境
Windows10 64位;Vmware 14;Ubuntu 16.04 LTS 64位;
1.2.3 开发与调试工具
gcc, readelf, objdump, edb, ld, gedit
1.3 中间结果
hello.i:hello.c的预处理文件
hello.s:hello.i的编译文件
hello.o:hello.s的汇编文件
helloo.elf:hello.o文件的ELF格式
hello.objdump:hello.o文件的反汇编文件
hello.elf:hello文件的ELF格式
hello.objdumpp:hello文件的反汇编文件
hello:可执行文件
1.4 本章小结
本章简单介绍了hello从程序到执行,再到结束的整个过程;提供了完成本次实验的硬件软件环境和所使用的开发与调试工具;并列出了中间结果。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器(cpp) 根据以字符#开头的命令,修改原始的C 程序,形成完整的文件。C语言的预处理主要有三个方面的内容: 1.宏定义;(#define 标识符 文本) 2.文件包含;(#include "文件名") 3.条件编译。(#ifdef,#else,#endif)
作用:
- 宏替换。例如:#define a 10,则在预处理时将所有的a变为10。
- 文件包含。预处理器读取头文件中的内容,并插入到程序文本中。例如:#include <stdio.h>则将stdio.h的代码扎入到hello中。
- 删除注释。
- 条件编译。根据某个条件判断进行静态编译。主要有#ifdef, #else, #elif, #endif, #ifndef等条件语句。
2.2在Ubuntu下预处理的命令
预处理命令:cpp hello.c > hello.i
正在上传…重新上传取消
2.3 Hello的预处理结果解析
1.使用命令 vim hello.i 查看预处理结果的内容:
main 函数的代码在 hello.i 的最下面,第3102-3105表示这个程序已经预处理完毕。
正在上传…重新上传取消
在main函数内代码出现之前是大段的头文件 stdio.h unistd.h stdlib.h 的依次展开。展开的具体流程概述如下(以stdio.h为例):CPP先删除指令#include <stdio.h>,并到Ubuntu系统的默认的环境变量中寻找 stdio.h,最终打开路径/usr/include/stdio.h下的stdio.h文件。若stdio.h文件中使用了#define语句,则按照上述流程继续递归地展开,直到所有#define语句都被解释替换掉为止。除此之外,CPP还会进行删除程序中的注释和多余的空白字符等操作,并对一些值进行替换。
2.4 本章小结
本章主要介绍了预处理的概念和应用功能,以及Ubuntu下预处理的指令,并结合Ubuntu系统下hello.c文件实际预处理之后得到的hello.i程序对预处理结果进行了解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
1.编译的概念
编译就是将源语言经过词法分析、语法分析、语义分析以及经过一系列优化后生成汇编代码的过程。具体到我们实验,就是将预处理得到的ASCII码的中间文件hello.i翻译成ASCII汇编语言文件hello.s的过程。
- 作用
编译器经过词法分析、语法分析、语义分析等过程,在检查无错误后将代码翻译成汇编语言,得到的汇编语言代码可供编译器进行生成机器代码、链接等操作。
3.2 在Ubuntu下编译的命令
编译的命令:gcc -S hello.i -o hello.s
正在上传…重新上传取消
3.3 Hello的编译结果解析
首先查看hello.s的内容:
正在上传…重新上传取消
其中,
.file:声明源文件
.string:声明一个string类型
.text:声明以下是代码段
.secetion .rodata 声明以下是rodata节
.align 声明对指令或者数据的存放地址进行对齐的 方式
.long 声明一个long类型
.globl:声明一个全局变量
.type 声明是函数类型还是对象类型
.size 声明大小
3.3.1整数
可以在 C 文件里面看见,有 int argc; int i;这2个整型变量,编译器处理分析如下:
正在上传…重新上传取消
(1)int argc:声明参变量个数。 将int argc赋值给了-20(%rbp),因此第一个形式参数页储存在栈上,且所在栈空间位置为-20(%rbp)。
正在上传…重新上传取消
(2)int i:编译器将局部变量存储在寄存器或者栈空间中,在 hello.s 中编译器将
i 存储在栈上空间-4(%rbp)中。
正在上传…重新上传取消
(3)立即数:其他整形数据的出现都是以立即数的形式出现的,直接出现在汇
编代码中。
3.3.2字符串
声明在.LC0 和.LC1 段中的字符串(.rodata 节)
正在上传…重新上传取消
3.3.3数组
数组是一段数据类型相同的物理位置相邻的变量集合,对数组的索引实际就 是在第一个元素地址的基础上通过加索引值乘以数据大小来实现 argv 数组作为一个 char* 类的数组,char* 的大小是 8 个字节,所以 argv[1] 加 0x8,而 argv[2]加 0x10。
正在上传…重新上传取消
3.3.4类型
编译器对类型的体现通过申请不同大小的空间,进行不同的操作等方式。例如char大小为1个字节,那么只会申请1个字节的空间,而int大小为4字节,则会申请4个字节的空间。例如int i只有4个字节的空间。
3.3.5 算数操作
1.算术和逻辑操作类指令分四类:加载有效地址,一元操作,二元操作和移位,
如下:
正在上传…重新上传取消
2.leaq 指令,类似 mov 指令,它左侧的数看似是给出一个地址,在内存中从给定的地址取操作数,传给右边的目的地。但其实没有取,而是直接将左侧的数对应的地址传给了右侧的目的地。
本代码里面涉及的操作为: i++,对计数器i自增
leaq 操作:
正在上传…重新上传取消
3.3.7.关系操作
hello.c中有argc!=4,根据图3-3可知,汇编语言为cmpl $4, -20(%rbp)。
hello.c中有i<10,根据图3-3可知,汇编语言为cmpl $8, -4(%rbp)。
所以,编译器通常通过将比较编译为cmp指令实现。根据不同的数据大小,有cmpb、cmpw、cmpl和cmpq。比较之后,通过jmp系列指令跳转。
正在上传…重新上传取消
3.3.8 函数操作:
函数调用的基本过程。首先将%rbp 通过 push 指令存储在栈中,并将%rsp 栈指 针赋值给%rbp。通过 sub 指令减小栈指针地址从而为局部变量分配空间。然后 将%edi 和%rsi 寄存器中的值存储在栈中。具体函数分析如下:
1.main 函数本身的参数读取
参数部分给出了 int argc,char *argv[]两个参数,其中%edi 代表 argc,%rsi 代表argv[]。然后分配和释放内存
正在上传…重新上传取消
2.printf 的调用
先传递数据后控制转移
第一次实际上调用的是 puts(只有一个字符串参数)
正在上传…重新上传取消
第二次为 printf
正在上传…重新上传取消
3.sleep 函数的调用
先将 sleepsecs 的值传给%edi,再调用 sleep@PLT(PLT 后面 PIC 函数动态链接 时使用)
正在上传…重新上传取消
4.getchar 函数的调用:
%eax 置零,call getchar@PLT
正在上传…重新上传取消
5.exit 函数的调用
exit 状态为 1
正在上传…重新上传取消
所以 movl $1,%edi
正在上传…重新上传取消
3.4 本章小结
本章主要介绍了编译的概念与作用,以及在Ubuntu下编译的指令,重点介绍了编译器将hello.i转变为hello.s的过程,并对每种数据和操作都进行了概述。(主要包括整数,字符串,数组)和操作(赋值操作,类型转换,算术和位级操作,关系操作,指针数组结构操作以及控制转移和函数操作)
第4章 汇编
4.1 汇编的概念与作用
概念:
汇编指令和机器指令一一对应,前者是后者的符号表示,它们都属于机器级指
令,所构成的程序称为机器级代码。
作用:
汇编程序(汇编器 as)用来将汇编语言源程序转换为机器指令序列(机器语言
程序)。把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文
件中
4.2 在Ubuntu下汇编的命令
命令行:linux> gcc -c hello.s -o hello.o或者as hello.s -o hello.o
正在上传…重新上传取消
4.3 可重定位目标elf格式
1.典型的ELF可重定位目标文件的格式
正在上传…重新上传取消
2.读取可重定位目标文件。
键入命令行readelf -a hello.o >hello.elf将elf可重定位目标文件输出定向到文本文件hello.elf中
正在上传…重新上传取消
3.ELF头
以 16 字节的序列开始,对应于图中的 magic 部分,描述了生成该文件的系统的字的大小和字节顺序。
ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF 头的大小、目标文件的类型、机器类型、字节头部表(section header table)
的文件偏移,以及节头部表中条目的大小和数量等信息。
正在上传…重新上传取消
4.节头表部分:
节头记录了各节名称及大小、类型及全体大小、地址及旗标、连接、信息和偏移量及对齐信息。
正在上传…重新上传取消
5.重定位节
当链接器把这个目标文件和其他文件组合时,需要修改表中的这些位置。一般,调用外部函数或者引用全局变量的指令都需要修改。
在hello.o的重定位节中包含了main函数调用的puts、exit、printf、sleep、getchar函数,还有只读区域.rodata节。表格记录了它们的偏移量、信息、类型、符号值、符号名称及加数。另用rela.eh_frame记录了.text的信息。
若重定义类型为R_X86_64_PC32,重定位一个使用32位PC相对地址的引用。若若重定义类型为R_X86_64_32,重定位一个使用32位绝对地址的引用。根据重定位条目和重定位算法即可得到相应的重定位位置。正在上传…重新上传取消
6.符号表
符号的序号 Num、符号的地址(偏移量)Value、符号的大小 Size、符号的类型 Type、 符号的名称 Name;
正在上传…重新上传取消
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o > hello.objdump
正在上传…重新上传取消
将图hello.objdump与hello.s对比发现,大多数代码是一样的,有以下几点不同:
- hello.s中包含.type .size .align以及.rodata只读数据段等信息,而hello.objdump中只有函数的相关内容。
- 分支转移。hello.s主要借助.L0,.L1等转移,而hello.objdump直接借助地址进行跳转(未链接的地址)
正在上传…重新上传取消
- 函数调用。hello.s中直接调用函数的名称,而hello.objdump中利用下一条地址相对函数起始地址的偏移量,链接重定位后才能确定地址。
正在上传…重新上传取消
说明机器语言
机器语言:二进制的机器指令的集合;
机器指令:由操作码和操作数构成的;
机器语言:灵活、直接执行和速度快。
汇编语言:主体是汇编指令,是机器指令便于记忆的表示形式,为了方便程序员读懂和记忆的语言指令。
汇编指令和机器指令在指令的表示方法上有所不同。
4.5 本章小结
本章介绍了汇编的概念及其作用,及在Ubuntu下汇编的命令。对可重定位目标elf格式进行分析,同时对hello.o文件进行反汇编,将反汇编文件与编译文件进行对比。使得我们对该内容有了更加深入地理解。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行,链接可以执行于编译时,也就是源代码被翻译成机器代码时,也可以执行于加载时,也就是程序被加载器加载到内存并执行时,甚至执行于运行时,在现代系统中,链接是由叫做链接器的程序自动执行的。
作用:使得分离编译成为可能,我们可以独立的修改和编译模块,当我们改变这些模块的其中一个时,只需简单的重新编译它,并重新链接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
方法1: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
方法2:gcc hello.o -o hello
正在上传…重新上传取消
5.3 可执行目标文件hello的格式
命令行:readelf -a hello >hello_elf
正在上传…重新上传取消
可执行目标文件的格式:
正在上传…重新上传取消
hello 的 ELF 具体的形式:
(1).hello.out 文件的文件头
基本信息:有头的大小、程序头的大小、节头的大小、节头的数量、字符串索引节
头等信息。当然也显示这是一个可执行文件。
正在上传…重新上传取消
(2).段头表
正在上传…重新上传取消
(3).符号表(符号表的详细信息在之前已经解释过了):
正在上传…重新上传取消
包含49个入口
正在上传…重新上传取消
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
正在上传…重新上传取消
图5-4.ELF内容
通过ELF可知,程序从0x00400000到0x00400fff。
正在上传…重新上传取消
可知包含:
PHDR:程序头表
INTERP:程序执行前需要调用的解释器
LOAD:程序目标代码和常量信息
DYNAMIC:动态链接器所使用的信息
NOTE::辅助信息
GNU_STACK:使用系统栈所需要的权限信息
GNU_RELRO:保存在重定位之后只读信息的位置
其余的节的内容是存放在0x00400fff后面。
5.5 链接的重定位过程分析
命令行 linux> objdump -d -r hello
正在上传…重新上传取消
hello 与 hello.o 的不同:
hello.o 没有经过链接,所以 main 的地址从 0 开始,并且不存在调用的如 printf
这样函数的代码。另外,很多地方都有重定位标记,用于后续的链接过程。hello.o
反汇编代码的相对寻址部分的地址也不具有参考性,没有经过链接并不准确。
而对于 hello 这个已经链接好的可执行目标文件来说,库函数的代码都已经链接到
了程序中,程序各个节变得更加完整,跳转的地址也具有参考性。
结合 hello.o 的重定位项目,分析 hello 中对其怎么重定位的:
使用 ld 命令链接的时候,将 hello.c 中用到的函数都加入链接器,当链接器解
析重定条目时发现对外部函数调用,比如说:
正在上传…重新上传取消
这个是一个使用 32 位 PC 相对地址的引用,未链接之前是用 00 00 00 00 填充的,
下面及时给出了具体重定位的信息:符号为 sleepsecs,偏移量为-0x4,即加数
r.addend 为 0x-4。
链接器直接修改 call 之后的值为目标地址与下一条指令的地址之差,指向相应
的字符串。使用书上的公式计算出结果 00 20 0a ae,观察链接后的填充值,得到验
证。(小端序)
正在上传…重新上传取消
5.6 hello的执行流程
程序地址 | |
ld-2.27.so!_dl_start | 0x7fce 8cc3 8ea0 |
ld-2.27.so!_dl_init | 0x7fce 8cc4 7630 |
libc-2.27so!_libc_start_main | 0x7fce 8c86 7ab0 |
-libc-2.27.so!_cxa_atexit | 0x7fce 8c88 9430 |
-libc-2.27.so!_libc_csu_init | 0x4005c0 |
hello!_start | 0x400500 |
ld-2.27.so!_setjmp | 0x7fce 8c88 4c10 |
ld-2.27.so!exit | 0x7fce 8c88 9128 |
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条目。
调用 dl_init 之前的全局偏移表:
正在上传…重新上传取消
调用 dl_init 之后的全局偏移表:
正在上传…重新上传取消
由图可知,已经发生动态链接,GOT条目已改变。
5.8 本章小结
本章结合实验中的hello可执行程序依此介绍了链接的概念及作用,在Ubuntu下链接的命令行;并对hello的elf格式进行了详细的分析对比,同时分析了hello的虚拟地址空间;并通过反汇编hello文件,将其与hello.o反汇编文件对比,详细了解了重定位过程;遍历了整个hello的执行过程,在最后对hello进行了动态链接分析。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量、以及打开文件描述符的集合。
作用:每次用户通过向 shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.2 简述壳Shell-bash的作用与处理流程
作用:代表用户运行其他程序。
处理流程:
1)从终端读入输入的命令。
2)将输入字符串切分获得所有的参数
3)如果是内置命令则立即执行
4)否则调用相应的程序为其分配子进程并运行
5)shell 应该接受键盘输入信号,并对这些信号进行相应处理
6.3 Hello的fork进程创建过程
执行中的进程调用fork()函数,就创建了一个子进程。其函数原型为pid_t fork(void);对于返回值,若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。
首先对于hello进程。我们终端的输入被判断为非内置命令,然后shell试图在硬盘上查找该命令(即hello可执行程序),并将其调入内存,然后shell将其解释为系统功能调用并转交给内核执行。
shell执行fork函数,创建一个子进程。这时候我们的hello程序就开始运行了。值得注意的是,hello子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本。但是子进程持有的是上述存储空间的“副本”,这意味着父子进程间不共享这些存储空间。
同时Linux将复制父进程的地址空间给子进程,因此,hello进程就有了独立的地址空间。
- 画出进程图
---------------hello程序----
|
---------+------------------------------
fork
6.4 Hello的execve过程
创建进程后,在子进程中通过判断 pid 即 fork()函数的返回值,判断处于子进
程,则会通过 execve 函数在当前进程的上下文中加载并运行一个新程序。execve 函 数加载并运行可执行目标文件 filename, 且带参数列表 argv 和环境变量列表
envp 。只有当出现错误时,例如找不到 filename, execve 才会返回到调用程序。
所以,与 fork 一次调用返回两次不同, execve 调用一次并从不返回。
在 execve 加载了可执行程序之后,它调用启动代码。启动代码设置栈,并将
控制传递给新程序的主函数,即可执行程序的 main 函数。此时用户栈已经包含了 命令行参数与环境变量,进入 main 函数后便开始逐步运行程序
6.5 Hello的进程执行
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
hello在前台printf中,由于调用sleep,进入后台内核模式,开始sleep2.5s,然后中断信号,再回到前台继续执行下一个操作。
6.6 hello的异常与信号处理
1. hello 执行过程中会出现哪几类异常:
Hello 在执行的过程中,可能会出现处理器外部 I/O 设备引起的异常,执行指
令导致的陷阱、故障和终止。第一种被称为外部异常,常见的有时钟中断、外部
设备的 I/O 中断等。第二种被称为同步异常。陷阱指的是有意的执行指令的结果,
故障是非有意的可能被修复的结果,而终止是非故意的不可修复的致命错误。
2.会产生哪些信号,又怎么处理的:
在发生异常时会产生信号。例如缺页故障会导致 OS 发生 SIGSEGV 信号给用
户进程,而用户进程以段错误退出。
中断:SIGSTP:挂起程序
终止:SIGINT:终止程序
3.各命令及运行结截屏:
Ctrl-C:直接终止进程:
Ctrl-Z:如果在程序运行过程中输入 Ctrl-Z,那么会发送一个 SIGTSTP 信号给
前台进程组中的进程,从而将其挂起:
回车程序会忽略:
随机按键盘:(不响应)
ps: 查看进程及其运行时间
jobs: 查看当前暂停的进程
pstree(部分截图):
fg: 输入 fg 使进程重新在前台执行
kill: 使用 kill 杀死特定进程
6.7本章小结
异常控制流发生在计算机系统的各个层次,是计算机系统中提供并发的基本机制。
shell 中执行是通过 fork 函数及 execve 创建新的进程并执行程序。进程拥有着 与父进程相同却又独立的环境,与其他系统进并发执行,拥有各自的时间片,在 内核的调度下有序进行程序的执行。
在应用层,一个进程可以发送信号到另一个进程,而接收者会将控制突然转移
到它的一个信号处理程序。一个程序可以通过回避通常的栈规则,并执行到其他
函数中任意位置的非本地跳转来对错误做出反应。
异常分为中断、陷阱、故障和终止四类,均有对应的处理方法。操作系统提供
了信号这一机制,实现了异常的反馈。这样,程序能够对不同的信号调用信号处
理子程序进行处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址是指由程序产生的与段相关的偏移地址部分,即hello.o 里面的相对偏移地址。
线性地址:地址空间是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间,即hello里面的虚拟内存地址。
虚拟地址:CPU 通过生成一个虚拟地址,即hello里面的虚拟内存地址。
物理地址:计算机系统的主存被组织成一个由M 个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。就是hello在运行时虚拟内存地址对应的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成,段标识符和段内偏移量。段标识符由 16 位字段组成,前 13 位为索引号。索引号是段描述符的索引,很多个描述符,组成了一个数组,叫做段描述表,可以通过段描述标识符的前 13 位,在这个表中找到一个具体的段描述符,这个描述符就描述了一个段,每个段描述符由八个字节组成。base 字段,描述了段开始的线性地址,一些全局的段描述符,放在全局段描述符表中,一些局部的则对应放在局部段描述符表中。由 T1 字段决定使用哪个。
以下是具体的转化步骤:
1. 给定一个完整的逻辑地址,[段选择符:段内偏移地址]
2. 看段选择符 T1,知道要转换的是 GDT 中的段还是 LDT 中的段,通过寄存器得到地址和大小。
3. 取段选择符中的 13 位,再数组中查找对应的段描述符,得到 BASE,就是基地址。
4. 线性地址等于基地址加地址偏移量。
7.3 Hello的线性地址到物理地址的变换-页式管理
在保护模式下,控制寄存器CR0的最高位PG位控制着分页管理机制是否生效,
如果 PG=1,分页机制生效,需通过页表查找才能把线性地址转换物理地址。如果
PG=0,则分页机制无效,线性地址就直接做为物理地址。
分页的基本原理是把内存划分成大小固定的若干单元,每个单元称为一页 (page),每页包含 4k 字节的地址空间(为简化分析,我们不考虑扩展分页的情况)。这样每一页的起始地址都是 4k 字节对齐的。为了能转换成物理地址,我们需要给 CPU 提供当前任务的线性地址转物理地址的查找表,即页表(page table)。 注意,为了实现每个任务的平坦的虚拟内存,每个任务都有自己的页目录表和页 表。
为了节约页表占用的内存空间,x86 将线性地址通过页目录表和页表两级查找
转换成物理地址。
32 位的线性地址被分成 3 个部分:
最高 10 位 Directory 页目录表偏移量,中间 10 位 Table 是页表偏移量,最低12 位 Offset 是物理页内的字节偏移量。
页目录表的大小为 4k(刚好是一个页的大小),包含 1024 项,每个项 4 字(32
位),项目里存储的内容就是页表的物理地址。如果页目录表中的页表尚未分配,
则物理地址填 0。
页表的大小也是 4k,同样包含 1024 项,每个项 4 字节,内容为最终物理页的物理内存起始地址。
7.4 TLB与四级页表支持下的VA到PA的变换
页表一般都很大,并且存放在内存中,所以处理器引入 MMU 后,读取指令、
数据需要访问两次内存:首先通过查询页表得到物理地址,然后访问该物理地址
读取指令、数据。为了减少因为 MMU 导致的处理器性能下降,引入了 TLB, TLB 是 Translation Lookaside Buffer 的简称,可翻译为“地址转换后援缓冲器”,也可简 称为“快表”。简单地说,TLB 就是页表的 Cache,其中存储了当前最可能被访问到 的页表项,其内容是部分页表项的一个副本。只有在 TLB 无法完成地址翻译任务时,才会到内存中查询页表,这样就减少了页表查询导致的处理器性能下降。
TLB 中的项由两部分组成:标识和数据。标识中存放的是虚地址的一部分,而数据部分中存放物理页号、存储保护信息以及其他一些辅助信息。虚地址与 TLB
中项的映射方式有三种:全关联方式、直接映射方式、分组关联方式。 Core i7 MMU 使用四级的页表将虚拟地址翻译成物理地址。36 位 VPN 被划分成四个 9 位 VPN,分别用于一个页表的偏移量。具体结构如图所示
7.5 三级Cache支持下的物理内存访问
得到了物理地址VA,首先使用物理地址的CI进行组索引(每组8路),对8路的块分别匹配 CT进行标志位匹配。如果匹配成功且块的valid标志位为1,则命中hit。然后根据数据偏移量 CO取出数据并返回。
若没找到相匹配的或者标志位为0,则miss。那么cache向下一级cache,这里是二级cache甚至三级cache中寻找查询数据。然后逐级写入cache。
在更新cache的时候,需要判断是否有空闲块。若有空闲块(即有效位为0),则写入;若不存在,则进行驱逐一个块(LRU策略)。
7.6 hello进程fork时的内存映射
shell 通过 fork 为 hello 创建新进程。当 fork 函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给 hello 进程唯一的 PID。为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和样表的原样副本。它将两个进程中的每个页面都标记为只读,并将每个进程中的每个区域结构都标记为写时复制。在新进程中返回时,新进程拥有与调用 fork 进程相同的虚拟内存,随后的写操作通过写时复制机制创建新页面。
具体实现如图所示:
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含可执行目标文件hello中的程序,加载、运行 hello 需要以下步骤:
1. 删除已存在的用户区域。删除 shell 虚拟地址的用户部分中的已存在的区域结构。
2. 映射私有区域。为 hello 的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text 和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中。栈和堆区域也是请求二进制零的,初始长度为零。
3. 映射共享区域。如果 hello 程序与共享对象(或目标)链接,比如标准 C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4. 设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
经过这个内存映射的过程,在下一次调度hello进程时,就能够从hello的入口点开始执行了。
7.8 缺页故障与缺页中断处理
页面命中完全是由硬件完成的,而处理缺页是由硬件和操作系统内核协作完成的,如截图1。
截图1:缺页中断处理
下面是整体的处理流程
1.处理器生成一个虚拟地址,并将它传送给MMU
2.MMU生成PTE地址,并从高速缓存/主存请求得到它
3.高速缓存/主存向MMU返回PTE
4.PTE中的有效位是0,所以MMU出发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
5.缺页处理程序确认出物理内存中的牺牲页,如果这个页已经被修改了,则把它换到磁盘。
6.缺页处理程序页面调入新的页面,并更新内存中的PTE
7.缺页处理程序返回到原来的进程,再次执行导致缺页的命令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面已经换存在物理内存中,所以就会命中。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为一组不同大小的块(block) 的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
基本方法:这里指的基本方法应该是在合并块的时候使用到的方法,有最佳适配和第二次适配还有首次适配方法,首次适配就是指的是第一次遇到的就直接适配分配,第二次顾名思义就是第二次适配上的,最佳适配就是搜索完以后最佳的方案,当然这种的会在搜索速度上大有降低。
策略:这里的策略指的就是显式的链表的方式分配还是隐式的标签引脚的方式分配还是分离适配,带边界标签的隐式空闲链表分配器允许在常数时间内进行对前面块的合并。这种思想是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。显式空间链表就是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继指针,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。为了分配一个块,必须确定请求的大小类,并且对适当的空闲链表做首次适配,查找一个合适的块。如果找到了一个,那么就(可选地)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到合适的块,那么就搜索下一个更大的大小类的空闲链表。如此重复,直到找到一个合适的块。如果空闲链表中没有合适的块,那么就向操作系统请求额外的堆内存,从这个新的堆内存中分配出一个块,将剩余部分放置在适当的大小类中。要释放一个块,我们执行合并,并将结果放置到相应的空闲链表中。
7.10本章小结
1、存储器管理的主要任务
存储器管理的主要任务是为多道程序的并发运行提供皮好的存储器环境。它
包括以下内容:
(1)能让每道程序“各得其所”,并在不受干扰的环境中运行,还可以使用户
从存储空间的分配、保护等烦琐事务中解脱出来;
(2)向用户提供更大的存储空间,使更多的作业能同时投入运行或使更大的作
业能在较小的内存空间中运行;
(3)为用户对信息的访问、保护、共享以及动态链接等方面提供方便;
(4)能使存储器有较高的利用率。
2、存储器管理的主要功能
为了实现存储器管理的主要任务,存储器管理应具有以下几个方面的功能。
(1)内存分配。根据分配策略,为多道程序分配内存,并实现共享。同时对程
序释放的存储空间进行回收。
(2)地址映射。每道程序都有自己的逻辑地址,在多道程序环境中,内存空间
被多道程序共享,这就必然导致程序的逻辑地址与在内存中的物理地址不一致。
因此,存储器管理必须提供地址映射功能,用于逻辑地址和物理地址间的变换。
地址映射通常在硬件支持下完成。
(3)内存保护。确保每道程序在自己的内存空间中运行,互不干扰。内存保护
一般由硬件完成。
(4)内存扩充。利用虚拟存储技术从逻辑上扩充内存空间,为用户营造一个比
实际物理内存更大的存储空间。程序运行过程中常常涉及到动态内存分配,动态
内存分配通过动态内存分配器完成,能够对堆空间进行合理地分配与管理,分割
与合并。现代使程序内存分配器采取多种策略来提高吞吐量以及内存占用率,从
在灵活使用内存的基础上保证了效率。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:在设备模型中,所有的设备都通过总线相连。每一个设备都是
一个文件。设备模型展示了总线和它们所控制的设备之间的实际连接。在最底层,
Linux 系统中的每个设备由一个 struct device 代表,而 Linux 统一设备模型就是在kobject kset ktype 的基础之上逐层封装起来的。
设备管理:是通过 unix io 接口实现的
8.2 简述Unix IO接口及其函数
Unix I/O接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K 。
4.读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数:
1.进程是通过调用open 函数来打开一个已存在的文件或者创建一个新文件的:
int open(char *filename, int flags, mode_t mode);
open 函数将filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。返回:若成功则为新文件描述符,若出错为-1。
2.进程通过调用close 函数关闭一个打开的文件。
int close(int fd);
返回:若成功则为0, 若出错则为-1。
3.应用程序是通过分别调用read 和write 函数来执行输入和输出的。
ssize_t read(int fd, void *buf, size_t n);
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。
ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。图10-3 展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出。返回:若成功则为写的字节数,若出错则为-1。
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;
}
vsprintf 的作用就是格式化。它接受确定输出格式的格式字符串 fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
Write 系统函数:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
write 有个参数:1
1 表示的是 tty 所对应的一个文件句柄。
在 linux 里,所有设备都是被当作文件来看待的,这个 1 就是表示往当前显示
器里写入数据
int INT_VECTOR_SYS_CALL 表示要通过系统来调用 sys_call 这个函数。
最后:printf()函数不能确定参数,只会根据 format 中的打印格式的数目依次打
印堆栈中参数 format 后面地址的内容。这样就存在一个可能的缓冲区溢出问题。
syscall 将字符串中的字节“Hello 1170300815 范天祥”从寄存器中通过总线复
制到显卡的显存中,显存中存储的是字符的 ASCII 码。
字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存
储到 vram 中。
显示芯片会按照一定的刷新频率逐行读取 vram,并通过信号线向液晶显示器
传输每一个点(RGB 分量)。
于是打印字符串“Hello 1170300825 范天祥”就显示在了屏幕上。
8.4 getchar的实现分析
1.运行到getchar函数时,程序将控制权交给os。当你键入时,内容进入缓寸并在屏幕上回显。按enter,通知 os输入完成,这时再将控制权在交还给程序。
2.异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
3.getchar调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Unix I/O,通过LinuxI/O设备管理方法以及Unix I/O接口及函数了解系统级I/O的底层实现机制,并通过对printf和getchar函数的底层解析加深对Unix I/O以及异常中断等的了解。
(第8章1分)
结论
hello的一生主要经过一下过程:
1.程序员通过 I/O 设备在编译器中通过高级语言写下 hello.c,并存储在内存中;
2.预处理阶段:预处理器 cpp 根据以字符 # 开头的命令,修改原始的 C 程序,比如 Hello.c 中第一行 #include<studio.h> 命令告诉预处理器读取系统文件
stdio.h 的内容,并把它直接插入到程序中。结果就得到另一个 C 程序,通常是以 .i
作为文件扩展名。
3.编译阶段:编译器 ccl 将文本文件 hello.i 翻译成文本文件 hello.s,它包含
一个汇编语言程序,汇编语言程序中的每条语句都以一种标准的文本格式确切的
描述一条低级机器语言指令。汇编语言能为不同高级语言的不同编译器提供通用
的输出语言。
4.汇编阶段:汇编器 as 将 hello.s 翻译成机器语言指令,把这些指令打包成
一种叫做可重定位目标程序的格式,并将结果保存在目标文件 hello.o hello.o
文件是一个二进制文件,它的字节编码是机器预言指令而不是字符。如果我们用
文本编辑器打开 hello.o 文件,将会是一堆乱码。
5.链接阶段:在 hello.c 程序中,我们看到程序调用了 printf 函数,它是每个
C 编译器都会提供的标准 C 库中的一个函数。printf 函数存在于一个名为
printf.o 的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我 们的 hello.o 程序中。链接器 ld 就是负责处理这种合并,结果就得到一个 hello 文件,它是一个可执行目标程序,可以被加载到内存中,由系统运行。
6.在 shell 里运行hello程序;
7. fork 创建子进程,shell 调用;
8. 运行程序,调用 execve;
9. 执行指令,为 hello 分配时间片,hello 执行自己的逻辑控制流;
10.三级cache访问内存,将虚拟地址映射成物理地址;
11. 信号、异常控制流,hello 对不同的信号会执行不同操作;
12. kill hello,回收子进程;
感悟:通过完成hello的一生这一篇论文,我学习了计算机对一个程序处理的过程,梳理了程序运行的机制,在一个可执行文件执行的背后都隐藏了什么。它将计算机系统各个章节联系了起来,让我对所学知识有了更深的理解。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
hello.c :提供源程序
hello.i :cpp 预处理之后的文件
hello.s :cc1 之后的汇编语言格式文件
hello.o :as 之后的可重定位目标文件
hello :ld 之后的可执行目标文件
helloftx.elf :hello.o 的 ELF 格式文件
hello.objdump :Objdump 得到的 hello 的反汇编代码
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,缺失 -1分)