摘 要
本论文详细介绍了hello程序在linux系统中从生成源代码到成功运行完毕被系统回收的整个过程,按照执行的先后顺序模块化介绍了hello.c在计算机内部是系统具体执行了什么指令、如何执行的、用到了哪些知识等。
本论文参考CSAPP课本的章节内容,以hello.c为示例具体介绍了他的整个生命周期,是课本知识点的浓缩和综合,有助于学生深入浅出地理解这一门课程。
关键词:hello.c,生命周期,CSAPP,P2P,020
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
第2章 预处理
第3章 编译
第4章 汇编
第5章 链接
第6章 hello进程管理
第7章 hello的存储管理
第8章 hello的IO管理
结论
附件
参考文献
第1章 概述
1.1 Hello简介
P2P是指from program to process。在linux系统中,源文件hello.c经cpp的预处理生成文本文件hello.i,经ccl编译生成汇编文件hello.s,经id链接生成可执行程序hello。在输入命令(./hello)启动该程序后,在shell里OS为其fork,创建子进程,就成为了process。
020是指from zero to zero。hello程序开始运行成为进程后,OS为其execve,映射虚拟内存,调用程序入口时开始载入物理内存,进入main函数执行目标代码,然后分配时间片,执行逻辑控制流,执行正确的IO管理与信号处理来保证程序的正常运行,以及与用户的交互。hello程序运行结束后,父进程回收该子进程,内核删除进程产生的相关数据和分配的结构,恢复程序执行前的状态,实现020。
1.2 环境与工具
硬件:Inter(R)Core(TM)i7-7500U CPU @2.70GHz 2.90GHz
软件:Ubuntu18.04.1 LTS
开发与调试工具:gcc,gdb,HexEdit,readelf等
1.3 中间结果
hello.i 预处理后的文本文件,加载了头文件,进行了宏替换,完成条件编译
hello.s 编译后的汇编文件
hello.o 汇编后的可重定位目标文件,将汇编语言翻译成机器语言指令,并将指令打包成可重定位目标文件。
hello 链接产生的可执行目标文件
hello.elf hello的ELF格式文件
1.4 本章小结
本章主要介绍了hello文件的P2P和020过程,旨在使人初步了解程序在计算机系统中是如何被执行的,对计算机程序执行的原理及有关文件的作用、来源进行阐述,有利于加深人们对计算机相关知识和相关用语的理解。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器cpp根据以字符#开通的命令修改原始.c文件,加载头文件,将引用的所有库展开合并为一个文本文件,进行宏替换,完成条件编译。
作用:(1)展开所有宏定义,处理#define x y格式的语句,用y替代x;
(2)加载头文件,将#include格式的头文件插入到预编译指令的位置;
(3)完成条件编译,处理#if,#ifdef格式的语句;
(4)删除注释;
2.2在Ubuntu下预处理的命令
gcc -E -o hello.i hello.c
可以看到文件夹中生成了hello.i文件
打开hello.i如下图
2.3 Hello的预处理结果解析
hello.c中的代码只有二十多行,而我们打开hello.i可以发现这是一个很大的文件,有三千多行。仔细观察可以发现main函数里的代码没有变化,注释被删除,而头文件部分被三千行的一大坨代码取代,也就是依次展开的头文件的源代码。cpp到默认的环境变量下寻找头文件,如果头文件中仍然存在#define语句等,则cpp继续对此递归展开。这样一来编译器就可以直接将hello.i文件翻译成汇编文件。
2.4 本章小结
本章节简单介绍了c语言文件在编译之前的预处理过程,即.c文件生成.i文本文件的过程,对预处理的含义、具体执行过程和预处理结果进行了解析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
概念:编译是指利用编译程序从源语言编写的源程序产生目标程序的过程,这里是将预处理文本文件hello.i翻译成汇编语言程序hello.s的过程。
作用:编译过程是指对预处理生存的.i文件进行一系列词法分析、语法分析、语义分析,最后优化后生成相应的汇编代码文件。具体步骤如下:
(1)词法分析,将字符串转化为内部表示结构;
(2)语法分析,将上一步分析得到的标记流转化生成为一棵语法树;
(3)生成汇编代码,将语法树转化为汇编目标代码。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s:
之后就生成了hello.s文件:
打开hello.s如下图:
可以看出已经生成了汇编语言文件hello.s。
3.3 Hello的编译结果解析
3.3.1数据
hello.c中定义的全局变量int sleepsecs,编译器将其编译后变成如下汇编代码:
说明这个变量的大小是4个字节。
3.3.2赋值
(1)赋值语句sleepsecs=2.5,编译器将其编译后变成如下汇编代码:
说明初始化过的全局变量的值被存放在.data节中。
(2)赋值语句i=0,编译器将其编译后变成如下汇编代码:
3.3.3类型转换
变量sleepsecs是int类型的,但是赋值时所赋的值2.5是浮点数,因此这里编译器有一个隐式的数据类型转换,将2.5转换成整数2存入sleepsecs。
3.3.4 算数操作
计数变量i++的语句,编译器将其编译后变成如下汇编代码:
3.3.5 关系操作
判断语句往往编译成cmp系列语句和j系列条件跳转语句的组合,具体参考CSAPP课本中相关部分的介绍。
(1)判断语句argc!=3的,编译器将其编译后变成如下汇编代码:
(2)计数变量i<10的判断语句,编译器将其编译后变成如下汇编代码:
3.3.6 数组/指针/结构操作
取数组的第i位一般是按照取数组头指针加上第i位的偏移量来操作的;指针跟数组类似,如果x表示一个指针,rax表示其存储的寄存器,你要访问*x,那么就是(%rax);结构也是类似的,通过结构在结构体中的偏移量来访问。
printf函数里面的一系列对指针和数组的操作,编译器将其编译后变成如下汇编代码:
3.3.7 控制转移
if,for循环等控制转移语句都是由比较语句和条件跳转来实现的,比较语句被编译器编译成cmp语句,条件跳转则由je、jle等实现。
(1)if (argc!=3) 编译器将其编译后变成如下汇编代码:
(2)for循环语句编译器将其编译后变成如下汇编代码:
3.3.8 函数操作
一个函数的返回值一般存在寄存器eax中,如果要设定返回值的话,那就先将返回值传入eax,然后再用ret语句返回。
(1)输出语句printf("Usage: Hello 1173710111 張淑慧!\n"),首先编译器将输出的字符串进行编译,转换为如下代码:
然后将输出语句编译后变成如下汇编代码:
(2)输出语句printf("Hello %s %s\n",argv[1],argv[2]),首先编译器将输出的字符串进行编译,转换为如下代码:
然后将输出语句编译后变成如下汇编代码:
(3)系统函数sleep(sleepsecs),编译器将其编译后变成如下汇编代码:
3.4 本章小结
本章简单介绍了编译的概念和作用,并以hello.c的编译过程为实例详细剖析了一个c程序是如何被编译器翻译成汇编语言文件hello.s的,并分类展示出不同功能的c语句对应的汇编语句,并简单给出了对应的编译原理。
本章通过实例分析,有助于学生将所学的理论知识与实际样例相结合,更加立体地学习和感受编译原理,有助于加深对计算机执行程序的过程的理解。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编是指汇编器(as)将汇编文件hello.s翻译成机器语言指令,并把这些指令打包成一种叫做可重定位目标文件的格式,并将结果保存在目标文件hello.o中。
作用:汇编器是将汇编代码转变成机器可以执行的命令,每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,根据汇编指令和机器指令的对照表一一翻译即可。
4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o
之后就生成了二进制文件hello.o如下:
4.3 可重定位目标elf格式
用readelf打开hello.o文件,命令行为readelf -a hello.o,如下面各图所示。得到的elf格式文件组成如下:(1)ELF头,用于总的描述ELF文件各个信息的段;
(2)节头,描述了.o文件中出现的各个节的类型、位置、所占空间大小等信息;
(3)重定位节.rela.text,这个节包含了.text(具体指令)节中需要进行重定位的信息。这些信息描述的位置,在由.o文件生成可执行文件的时候需要被修改(重定位)。在这个hello.o里面需要被重定位的有printf , puts , exit , sleepsecs , getchar , sleep ,rodata里面的两个元素(.L0和.L1字符串)。.rela.eh_frame是eh_frame节的重定位信息。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
键入objdump -d -r hello.o命令查看hello.o的反汇编,得到下图:
对比第三章的hello.s(如下图):
可以看出,hello.o的反汇编比hello.s的汇编代码基本相同,只是在一些位置多了些注释,但是每一行汇编代码左侧都多了一行16进制编码:冒号前面的是运行的时候机器指令的位置,后面的是该行汇编代码对应的机器指令。实际上的机器语言是由0和1组成的二进制语句,显示时以16进制形式显示。
另外,汇编器在对hello.s文件进行汇编时,实现了以下操作:
1. 为每条语句加上了具体的地址,全局变量和常量都被安排到了具体的地址里面;
2. 操作数在hello.s里面都是十进制,在到hello.o里面的机器级程序时都是十六进制;
3. 跳转语句jx&jxx原来对应的符号都变成了相对偏移地址;
4. 函数调用时原来的函数名字也被替换成了函数的相对偏移地址。
4.4.1 分支转移
反汇编代码跳转指令的操作数使用的不是段名称如.L3,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
4.4.2 函数调用
在.s文件中,函数调用之后直接跟着函数名称,而在反汇编程序中,call的目标地址是当前下一条指令。这是因为hello.c中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text节中为其添加重定位条目,等待静态链接的进一步确定。
4.4.3全局变量访问
在.s文件中,访问rodata(printf中的字符串),使用段名+%rip,在反汇编代码中0+%rip,因为rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
4.5 本章小结
本章简单介绍了汇编这一过程,即汇编器(as)将汇编文件hello.s翻译成机器语言指令并存储到hello.o文件中的过程,同样以hello程序为实例,将理论知识与实际操作的过程穿插编排。
本章通过实例分析与理论知识讲解相结合,有助于学生将课本上课学到的知识与实际操作结合,融会贯通,更加深入地学习和感受汇编的过程,有助于加深对汇编语言和汇编过程的理解。
(第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的格式
键入readelf -a hello >hello.elf命令生成hello程序的ELF格式文件hello.elf。
打开hello.elf,首先是ELF头部分如下:
节头信息如下:
节头对hello中所有的节信息进行了声明,其中包括大小Size以及在程序中的偏移量Offset,因此根据节头中的信息我们就可以用HexEdit定位各个节所占的区间(起始位置,大小)。其中Address是程序被载入到虚拟地址的起始地址。
5.4 hello的虚拟地址空间
我们用edb打开hello,可以在Data Dump窗口看见hello加载到虚拟地址中的状况:
可以看出程序是在0x00400000地址开始加载的,结束的地址大约是0x00400fff。
再来分析分析elf里面的Program Headers:
PHDR:程序头表
INTERP:程序执行前需要调用的解释器
LOAD:程序目标代码和常量信息
DYNAMIC:动态链接器所使用的信息
NOTE::辅助信息
GNU_EH_FRAME:保存异常信息
GNU_STACK:使用系统栈所需要的权限信息
GNU_RELRO:保存在重定位之后只读信息的位置
其余的从.dynamic到.strtab节的内容是存放在0x00400fff后面
5.5 链接的重定位过程分析
对比5.3的节头信息和4.3的节头信息,hello相对于hello.o有如下不同:
1. hello.o中的相对偏移地址到了hello中变成了虚拟内存地址
2. hello中相对hello.o增加了许多的外部链接来的函数。
3. hello相对hello.o多了很多的节类似于.init,.plt等
4. hello.o中跳转以及函数调用的地址在hello中都被更换成了虚拟内存地址
分析可得比hello.o多出来如下节表头:
.interp:保存ld.so的路径
.note.ABI-tag
.note.gnu.build-i:编译信息表
.gnu.hash:gnu的扩展符号hash表
.dynsym:动态符号表
.dynstr:动态符号表中的符号名称
.gnu.version:符号版本
.gnu.version_r:符号引用版本
.rela.dyn:动态重定位表
.rela.plt:.plt节的重定位条目
.init:程序初始化
.plt:动态链接表
.fini:程序终止时需要的执行的指令
.eh_frame:程序执行错误时的指令
.dynamic:存放被ld.so使用的动态链接信息
.got:存放程序中变量全局偏移量
.got.plt:存放程序中函数的全局偏移量
.data:初始化过的全局变量或者声明过的函数
接下来分析一下汇编程序。
键入objdump -d -r hello得到将hello的反汇编,将hello.o的反汇编之后的结果存入hello_o.s
我们可以发现,在hello_o.s中调用函数都是使用call+
5.6 hello的执行流程
1. 先是加载程序_init (argc=1, argv=0x7fffffffde38, envp=0x7fffffffde48)
2. 0x00000000004004d0 in _start ()
3. 0x0000000000400480 in __libc_start_main@plt ()
4. 0x0000000000400670 in __libc_csu_init ()
5. 0x0000000000400430 in _init ()
6. 0x00000000004005b0 in frame_dummy ()
7. 0x0000000000400540 in register_tm_clones ()
8. 0x00000000004005f2 in main ()
9. 0x0000000000400460 in puts@plt ()
10. 0x00000000004004a0 in exit@plt ()
11. 0x0000000000400580 in __do_global_dtors_aux ()
12. 0x0000000000400500 in deregister_tm_clones ()
13. 0x00000000004006e4 in _fini ()
注:edb不会用,而且好难用。。。所以以上内容是使用gdb得出
5.7 Hello的动态链接分析
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。
在dl_init调用之后,0x601008和0x601010处的两个8B数据分别发生改变为0x7fd9 d3925170和0x7fd9 d3713680,其中GOT[1]指向重定位表(依次为.plt节需要重定位的函数的运行时地址)用来确定调用的函数地址,GOT[2]指向动态链接器ld-linux.so运行时地址。
在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。之后如果对同样的函数调用,第一次访问跳转直接跳转到目标函数。
因为在PLT中使用的jmp,所以执行完目标函数之后的返回地址为最近call指令下一条指令地址,即在main中的调用完成地址。
5.8 本章小结
本章主要介绍了链接的概念与作用,对hello的ELF格式文件包含的信息进行解析,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。
本章对链接的步骤和过程进行了详细的分解和解析,这样有利于学生在以后处理相关的问题的时候沉着应对。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:一个执行中程序的实例。
作用:每次用户通过向shell 输入一个可执行目标文件的名字,运行程序时, shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.2 简述壳Shell-bash的作用与处理流程
作用::Shell是一个用C语言编写的程序应用程序,他在操作系统中提供了一个用户与系统内核进行交互的界面。
处理流程一般是这样的:
1. 读取用户的输入
2. 分析输入内容,获得输入参数
3. 如果是内核命令则直接执行,否则调用相应的程序执行命令
4. 在程序运行期间,shell需要监视键盘的输入内容,并且做出相应的反应
6.3 Hello的fork进程创建过程
Shell通过调用fork 函数创建一个新的运行的子进程。也就是Hello程序,Hello进程几乎但不完全与Shell相同。Hello进程得到与Shell用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。Hello进程还获得与Shell任何打开文件描述符相同的副本,这就意味着当Shell调用fork 时,Hello可以读写Shell中打开的任何文件。Sehll和Hello进程之间最大的区别在于它们有不同的PID。
6.4 Hello的execve过程
每一个进程都有一段唯一属于自己的内存地址段,在execve运行时,开始先是从0x00400000(对于32位系统来说是0x8048000)开始程序的执行。先是从可执行文件中加载的内容,然后是运行时的堆栈和共享库的存储器映射区域。
当fork之后,子进程调用execve函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即hello程序。execve 函数加载并运行可执行目标文件hello, 且带参数列表argv 和环境变量列表envp 。只有当出现错误时,例如找不到hello, execve 才会返回到调用程序。所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回。
6.5 Hello的进程执行
Linux 系统中的每个程序都运行在一个进程上下文中,有自己的虚拟地址空间。当shell 运行一个程序时,父shell 进程生成一个子进程,它是父进程的一个复制。子进程通过execve 系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk), 新的代码和数据段袚初始化为可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。在内核和前端之前切换的动作被称为上下文切换。
6.6 hello的异常与信号处理
6.6.1 异常种类
hello执行过程中会出现的异常种类有:
(1)中断:SIGSTP:挂起程序
(2)终止:SIGINT:终止程序
6.6.2 各种命令的执行
1. ctrl-z:这个操作向进程发送了一个sigtstp信号,让进程暂时挂起。
(1)输入ps命令符可以发现hello进程还没有被关闭:
(2)jobs命令可以查看当前的关键命令(ctrl+Z/ctrl+C这类)内容,比如这时候就会返回ctrl+Z表示暂停命令:
(3)pstree是用进程树的方法把各个进程用树状图的方式连接起来:
(4)fg:发送SIGCONT信号继续执行停止的进程:
(5)5.kill -9 pid:发送SIGKILL信号给指定的pid杀死进程:
2.ctrl-c操作:这个操作向进程发送了一个sigint信号,让进程直接结束,输入ps命令可以发现当前hello进程已经被终止了:
3.运行过程中脸滚键盘并不影响进程:
6.7本章小结
本章简要阐述了进程的定义和作用,详细介绍了shell的一般处理流程,描述了shell如何在用户和系统内核之间建起一个交互的桥梁,以hello程序为例介绍了shell的基本操作以及各种内核信号和命令,还总结了shell是如何fork新建子进程、execve如何执行进程、hello进程如何在内核和前端中反复跳跃运行的。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:又称相对地址,是程序运行由CPU产生的与段相关的偏移地址部分。他是描述一个程序运行段的地址。
物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。他是在前端总线上传输的而且是唯一的。在hello程序中,他就表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。
线性地址:这个和虚拟地址是同一个东西,是经过段机制转化之后用于描述程序分页信息的地址。他是对程序运行区块的一个抽象映射,即一个程序应该在内存的哪些块上运行。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符和段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
索引号是“段描述符”的索引,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,每一个段描述符由8个字节组成,如下图:
其中Base字段,它描述了一个段的开始位置的线性地址,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中,由段选择符中的T1字段表示选择使用哪个,=0,表示用GDT,=1表示用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。如下图:
下面是转换的具体步骤:
1. 给定一个完整的逻辑地址[段选择符:段内偏移地址]。
2. 看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。可以得到一个数组。
3. 取出段选择符中前13位,在数组中查找到对应的段描述符,得到Base,也就是基地址。
4. 线性地址 = Base + offset。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(书里的虚拟地址VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。线性地址被分为以固定长度为单位的组,称为页(page)。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。如图:
如图可以看出:
1.分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点;
2.每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中;
3.每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位)。
依据以下步骤进行转换:
1.从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
2.根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了;
3.根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
4.将页的起始地址与线性地址中最后12位相加,得到最终我们想要的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
下图给出了Core i7 MMU 如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
得到物理地址之后,先将物理地址拆分成CT(标记)+CI(索引)+CO(偏移量),然后在一级cache内部找,如果未能寻找到标记位为有效的字节(miss)的话就去二级和三级cache中寻找对应的字节,找到之后返回结果。如下图:
7.6 hello进程fork时的内存映射
mm_struct(内存描述符):描述了一个进程的整个虚拟内存空间
vm_area_struct(区域结构描述符):描述了进程的虚拟内存空间的一个区间
在用fork创建虚拟内存的时候,要经历以下步骤:
1. 创建当前进程的mm_struct,vm_area_struct和页表的原样副本
2. 两个进程的每个页面都标记为只读页面
3. 两个进程的每个vm_area_struct都标记为私有,这样就只能在写入时复制。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
1) 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构;
2) 映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零;
3) 映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内;
4) 设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中, DRAM 缓存不命中称为缺页(page fault)
情况1:段错误:首先,先判断这个缺页的虚拟地址是否合法,那么遍历所有的合法区域结构,如果这个虚拟地址对所有的区域结构都无法匹配,那么就返回一个段错误(segment fault)
情况2:非法访问:接着查看这个地址的权限,判断一下进程是否有读写改这个地址的权限。
情况3:如果不是上面两种情况那就是正常缺页,那就选择一个页面牺牲然后换入新的页面并更新到页表。
下图为缺页异常示意图:
7.9动态存储分配管理
printf函数会调用malloc,下面简述动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。分配器分为两种基本风格:显式分配器、隐式分配器。显式分配器:要求应用显式地释放任何已分配的块。隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
一、 带边界标签的隐式空闲链表
1)堆及堆中内存块的组织结构:
在内存块中增加4B的Header和4B的Footer,其中Header用于寻找下一个blcok,Footer用于寻找上一个block。Footer的设计是专门为了合并空闲块方便的。因为Header和Footer大小已知,所以我们利用Header和Footer中存放的块大小就可以寻找上下block。
2)隐式链表所谓隐式空闲链表,对比于显式空闲链表,代表并不直接对空闲块进行链接,而是将对内存空间中的所有块组织成一个大链表,其中Header和Footer中的block大小间接起到了前驱、后继指针的作用。
3)空闲块合并
因为有了Footer,所以我们可以方便的对前面的空闲块进行合并。合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变Header和Footer中的值就可以完成这一操作。
二、 显示空间链表基本原理
将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如下图:
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
维护链表的顺序有:后进先出(LIFO),将新释放的块放置在链表的开始处,使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章介绍了储存器的地址空间,讲述了虚拟地址、物理地址、线性地址、逻辑地址的概念,还有进程fork和execve时的内存映射的内容。描述了系统如何应对那些缺页异常,最后描述了malloc的内存分配管理机制。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
文件的类型:
1. 普通文件(regular file):包含任意数据的文件。
2. 目录(directory):包含一组链接的文件,每个链接都将一个文件名映射到一个文件(他还有另一个名字叫做“文件夹”)。
3. 套接字(socket):用来与另一个进程进行跨网络通信的文件
4. 命名通道
5. 符号链接
6. 字符和块设备
设备管理:unix io接口
1. 打开和关闭文件
2. 读取和写入文件
3. 改变当前文件的位置
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 函数来执行输入和输出的。
(1)ssize_t read(int fd, void *buf, size_t n);
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
返回:若成功则为读的字节数,若EOF 则为0, 若出错为-1。
(2)ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。图10-3 展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出。
返回:若成功则为写的字节数,若出错则为-1。
8.3 printf的实现分析
1. 首先查看printf的代码:
printf需要做的事情是:接受一个fmt的格式,然后将匹配到的参数按照fmt格式输出。我们可以发现,首先arg获得第二个不定长参数,即输出的时候格式化串对应的值,其次他调用了两个外部函数,一个是vsprintf,还有一个是write。
2. 查看vsprintf代码:
从上面vsprintf函数可以看出,这个函数的作用是将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。
3. write函数是将buf中的i个元素写到终端的函数。
综上,printf的输出过程如下:
(1)从vsprintf生成显示信息,显示信息传送到write系统函数;
(2)write函数陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序;
(3)从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
(4)显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),这样,需要打印的字符串“Hello 1173710111 张淑慧”就显示在了屏幕上。
8.4 getchar的实现分析
(以下格式自行编排,编辑时删除)
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
1.查看getchar函数:
(1)异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
(2)可以看出,这里面调用了一个read函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。这个read函数是将整个缓冲区都读到了buf里面,返回值是缓冲区的长度。我们可以发现,如果buf长度为0,getchar才会调用read函数,否则是直接将保存的buf中的最前面的元素返回。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数。这有助于我们以后在写函数的时候在标准I/O 库没有的情况下编写自己的I/O函数。
(第8章1分)
结论
hello程序的一生:
(1)编写,在键盘鼠标等I/O设备下,hello.c被编写出来并以文件的方式储存在主存里面;
(2)预处理,将hello.c调用的所有外部的库展开合并,预处理成为文本文件hello.i;
(3)编译,将hello.i编译成为汇编文件hello.s;
(4)汇编,将hello.s汇编成为可重定位目标文件hello.o;
(5)链接,链接器将外部文件和hello.o链接在一起成为可执行二进制文件hello;
(6)运行:在shell中输入./hello 1173710111 张淑慧 运行该程序;
(7)创建子进程:shell进程调用fork为其创建子进程,shell调用execve,execve调用启动加载器,加映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数;
(8)执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。hello在执行的过程中可能会遇到异常和信号以及命令,执行异常信号的处理流程;
(9)内存申请和访问:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址,printf会调用malloc向动态内存分配器申请堆中的内存;
(10)结束运行:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。
(结论0分,缺少 -1分,根据内容酌情加分)
附件
hello.i 预处理后的文本文件,加载了头文件,进行了宏替换,完成条件编译
hello.s 编译后的汇编文件
hello.o 汇编后的可重定位目标文件,将汇编语言翻译成机器语言指令,并将指令打包成可重定位目标文件。
hello 链接产生的可执行目标文件
hello.elf hello的ELF格式文件
(附件0分,缺失 -1分)
参考文献
[1] 兰德尔 E.布莱恩特. 深入理解计算机系统[第三版]:美国:机械工业出版社,2016.
[2] C语言再学习 -- GCC编译过程https://blog.csdn.net/qq_29350001/article/details/53339861
[3] LINUX 逻辑地址、线性地址、物理地址和虚拟地址 https://www.cnblogs.com/zengkefu/p/5452792.html
[4] Linux内核中的printf实现https://blog.csdn.net/u012158332/article/details/78675427
[5] [转]printf 函数实现的深入剖析https://www.cnblogs.com/pianist/p/3315801.html.
[6] 进程的睡眠、挂起和阻塞https://www.zhihu.com/question/42962803
(参考文献0分,确实 -1分)