摘 要
本文对hello程序的生命周期进行了追踪,在Ubuntu20.04的环境下合理地利用一些工具,并结合计算机系统的相关知识来对hello.c程序进行相应的分析,以期展示出程序生成可执行文件,并最终在计算机内部运行的过程,从而理解程序在计算机内部的运行机制。
关键词:hello程序,文件,计算机系统,程序处理工具
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
附件
参考文献
P2P:
1.最初通过编辑器编写hello的程序建立.c文件,得到hello.c的源程序。
2.运行C预处理器——cpp将程序预处理生成hello.i文件。
2.1头文件的包含
2.2注释的删除
2.3宏定义的替换
2.4条件编译的选择
3.运行C编译器——ccl将其翻译成相应的后缀为S的汇编文件,这个过 程是将经过预处理之后生成的hello.i文件进一步的翻译,包括词法和语法的分 析,最终生成对应硬件平台的汇编语言。
4.调用汇编器——as将hello.s汇编成一个可重定位目标文件hello.o。
5.运行链接器程序ld将hello.o和系统目标文件组合起来,创建一个可执行目标文件hello,由存储器保存在磁盘中。(如图所示)
6.在bash下输入相关命令后即可调用fork函数为可执行文件创建子进程。
O2O:
1.在bash调用fork函数创建子进程后,调用execve函数来进行虚拟内存映射,通过mmp为hello程序开创了一片空间。
2.随着一连串的缺页故障,将hello加载到物理内存。
3.操作系统提供异常控制流和调度器等工具,为进程规划时间片
4.Unix I/O为其提供与程序员和系统文件交互的方式,让它不再孤单。当程序从main中返回,意味着程序的终止。
5.bash回收子进程,内核清除数据痕迹。
硬件环境:x64 CPU; 3.00GHz; 16.0GB(15.4GB可用) 机带RAM; 934.8GB存储
软件环境:版本 Windows 10 家庭中文版 版本号 20H2 操作系统内部版本 19042.1586 VMware Workstation 12 ; Ubuntu 20.04 LTS 64位;
开发与调试工具:gcc,gedit,ld,readelf,edb
文件 |
作用 |
hello.c |
hello的源程序 |
hello.i |
经过预处理后产生的文件 |
hello.s |
编译后产生的汇编文件 |
hello.o |
汇编后的可重定位目标执行文件 |
hello.elf |
hello.o的ELF格式 |
hello_objdump.txt |
hello.o反汇编代码文件 |
hello1.elf |
hello可执行程序的ELF格式 |
hello1_objdump.txt |
hello反汇编代码文件 |
本章对hello的一生进行了简要的介绍和描述,介绍了P2P的整个过程,介绍了本计算机的硬件环境、软件环境、开发工具,介绍了为编写本论文的中间文件的名称和其作用。
(第1章0.5分)
概念:预处理是运行C语言程序的第一个步骤,为编译器核心进一步编译程序提供经过改造后的代码,完成一些编译器无法完成的处理。
作用:预处理过程读入源代码,对其中的伪指令(以#开头的指令)和特殊符号进行处理。或者说是扫描源代码,对其进行初步的转换,产生新的源代码提供给编译器。预处理过程还会删除程序中的注释和多余的空白字符。
预处理命令:gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
1.程序经过预处理之后,生成hello.i文件,文件由23行代码增加到3060行,内容增加,且仍为可以阅读的C语言程序文本文件。
2.实现了模块化的拼接,最开始的一段代码,是hello.c拼接的各种库文件;接着有对结构体的定义;对变量的定义;对内部函数的声明;最后是程序的源代码。
2.4 本章小结
本章介绍了预处理的相关概念和作用,进行实际操作对预处理生成的hello.i文件,是对源程序进行补充和替换的结果。
(第2章0.5分)
概念:指的是从.i文件到.s,即预处理后的文件到生成汇编语言程序的过程
作用:将预处理得到的文件转换为汇编文件。经过预处理得到的输出文件中只有常量,如数字、字符串、变量的定义,以及C语言的关键字,如main,if,else,for,while,{,},+,-,*,,等等。编译器通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。可以达成以下效果:
编译命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1生成的hello.s文件的分析:
.file:声明源文件
.text:代码段
.rodata只读数据段
.global:声明全局变量
.align:声明对指令或者数据存放地址进行对齐的方式
.type:声明一个符号是函数类型还是数据类型
.size:声明大小
.string:声明一个字符串类型
.long:声明一个long类型
3.3.2数据:
1.常量
常数:
例如4作为立即数直接存在汇编代码中
字符串
程序中有两个字符串,这两个字符串都在.rodata节中:
第一个字符串”用法: Hello 120L020709 张瀚清 秒数!\n”是第一个printf传入的输出格式化参数。第二个字符串是终端输入的存储在argc[]为地址的数组中的”Hello %s %s\n”,是第二个printf传入的输出格式化参数。
2.全局符号
声明全局符号main,类型为函数
3.局部变量(包括数组,指针)
main函数声明了一个局部变量i,编译器进行编译的时候将局部变量i放在堆栈中。如图所示,局部变量i放在栈上-4(%rbp)的位置。
参数argc
参数argc作为用户传给main的参数,被放到栈中。第一个参数用%edi传递
数组:char *argv[]
char *argv[]是main函数的第二个参数,数组的每个元素都是一个指向字符类型的指针。数组的起始地址存放在栈-32(%rbp)的位置:
被两次调用传给printf
3.3.2赋值操作
hello.c中赋值操作:
for循环中i=0在汇编代码中使用mov指令实现
i++用addl实现
3.3.3算数操作
算术操作:
i++,汇编语言如上图
开辟栈空间用的subq操作:
计算参数地址用到的addq操作:
3.3.4关系操作
argc!=4;是条件判断语句,进行编译时,这条指令被编译为:cmpl $3,-20(%rbp),在比较之后还设置了条件码,根据条件码判断是否需要跳转。
i<8,作为判断循环条件指令被编译为cmpl $7,-4(%rbp),并设置条件码,为下一步jle利用条件码进行跳转做准备。
3.3.5控制转移
汇编语言中先设置条件码,然后根据条件码来进行控制转移,在hello.c中,有以下控制转移指令:
先对i赋初值然后无条件条件跳转至判断条件的.L3中,然后判断i是否符合循环的条件,符合直接跳转至循环体内部.L4中。
3.3.6函数操作
调用函数时有以下操作:(假设函数P调用函数Q)
在x86系统中函数参数的存储规则,第1到6个参数依次储存在%rdi、%rsi、%rdx、%rcx、%r8、%r9这六个寄存器中,若有更多的参数则保存在栈中的某些位置。函数的返回值保存在%rax寄存器中。
hello.c中的函数操作:
main函数:参数是int argc,char *argv[]
被call后才能执行:即被系统启动函数__libc_start_main调用,call指令将下一条指令的地址压栈,然后跳转到main函数,完成对main函数的调用,程序结束时,调用leave指令恢复栈空间为调用之前的状态,然后ret返回。
printf/puts函数:第一次参数是字符串,第二次参数是argv[1],argv[2]
第一次:
调用puts,不调用printf,由于在条件分支内部调用的printf输出内容是一个确定的字符串,并不需要格式控制,所以编译器自行将它优化为了puts函数输出字符串。
第二次:
在循环内部,调用printf函数
exit函数:参数是1
sleep函数:参数是atoi(argv[3])(其中再调用atoi函数)
unsigned int sleep(unsigned int seconds);
int atoi(const char *str);
将argv字符串数组中的第四个元素,也就是命令行的第四个命令转成int类型,将其作为参数传入sleep函数调用。
getchar函数:无参数
3.3.7类型转换
hello.c中atoi(argv[3])将字符串类型转换为整型。
本章主要介绍了编译器处理c语言程序的基本过程,函数从源代码变为等价的汇编代码,编译从多角度进行分析,通过理解编译器编译的机制,我们可以将汇编语言翻译成c语言,提高了反向工程的能力。同时还注意到编译器有一定的优化功能。
(第3章2分)
概念:
驱动程序运行汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,将结果保存在目标文件hello.o中,且是一个二进制文件,包含函数main的指令编码。
作用:
将汇编代码转换为机器指令,使其在链接后能被机器识别并执行。
汇编命令:gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
典型的ELF可重定位目标文件:
生成hello.o文件elf格式命令:readelf -a hello.o > hello.elf
4.3.1 ELF头:
以一个16字节的Magic序列开始,描述了生成该文件的系统的字的大小和字节顺序。ELF64表示ELF64位的可执行程序,下一行表示补码,小端序,目标文件类型为REL(可重定位目标文件),运行机器:X86-64 AMD,节头开始为文件开始1248字节偏移处。除此之外,还包括ELF头大小,节头部表中条目的大小和数量。
4.3.2节头:
记录各节名称、类型、地址(因暂时未被分配从而均为0:用于重定位)、偏移量(节相对于文件开始的偏移)、节大小、表项大小、flags、(与其他节的)关联、附加节信息、对齐方式(2的Align次方)。还可以看到代码段可执行但不可写,数据段和只读数据段不可执行。
4.3.3重定位信息:
.rela.text保存的是.text节中需要被修正的信息,任何调用外部函数或者引用全局变量的指令都需要被修正,即需要重定位,调用局部函数的指令不需要重定位,在可执行目标文件中不存在重定位信息。本程序需要被重定位的是printf、puts、exit、sleepsecs、getchar、sleep、.rodata中的.L0和.L1。
.rela.eh_frame节是.eh_frame节重定位信息。
4.3.4符号表:
符号表(.symtab)存放程序中定义和引用的函数和全局变量的信息。name是符号名称,对应于可重定位目标模块,value是符号在对应节中的偏移量,size为目标大小,type表示函数或者数据的类型,Bind表示是本地的还是全局的。
objdump -d -r hello.o 分析hello.o的反汇编,并与第3章的 hello.s进行对照分析。
反汇编文件:
与hello.s对照分析:
两者代码总体上基本相同,只有小部分差别:
hello.s文件中只显示了汇编代码,而在反汇编代码中显示的不仅仅是汇 编代码,还有机器指令码。
1.跳转指令:
汇编用的是段名称,比如.L3,只是在汇编语言中便于编写的助记符,不 是核心代码,转换成机器语言后消失:
可重定位文件用的是确定的地址:主函数+段内偏移量(main+0x80):
2.函数调用:
汇编调用call后跟的是函数名称:
可重定位文件call后跟的是一条重定位条目指引的信息(下一条指令), 由于这些函数都是共享库函数,地址是不确定的,最终需要链接器才能确 定函数的地址,因此call指令将相对地址设置为全0,然后在.rela.text节中为 其添加重定位条目,等待链接的进一步确定:
3.全局变量:
汇编中全局变量sleepsecs的格式是段地址+%rip:
可重定位文件中全局变量的格式是0+%rip,因为.rodata节中的数据是在 运行时确定的,需要重定位,现在填0占位,并在.rela.text节中为其添加 重定位条目
4.立即数:
汇编语言中用十进制值:
可重定位文件中用十六进制值,与二进制之间的转换更加方便:
4.5 本章小结
本章对hello.s进行了汇编,生成了hello.o可重定位目标文件,分析了ELF文件的各个部分,最后用反汇编的方式分析了汇编前后的不同之处,对汇编的过程进行了分析。
(第4章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
5.3 可执行目标文件hello的格式
生成ELF格式文件命令:readelf -a hello > hello1.elf
5.3.1ELF头:
内容与hello.o的ELF头大致相同
不同:
文件类型变为EXEC(可执行文件);
完成链接后入口点地址确定为0x4010f0;
节点数变成了27个。
5.3.2节头:
与hello.o的节头大致相同,不过多出了部分节:
.gnu.hash:表示符号的哈希表,用于加速查找符号
.dynsym,.dynstr,.dynamic:与动态链接符号相关
.gnu.version,.gnu.version_r:与版本有关的信息
.init,.fini:存放上述初始化和收尾的代码
.plt,.plt.sec,.got.plt:与位置无关代码有关,PLT全称为Procedure Linkage Table(过程链接表),GOT全程为Global Offset Table(全局偏移量表)
5.3.3重定位节:
5.3.4符号表:
可执行文件的符号表中多了很多符号,而且额外有一张动态符号表(.dynsym)。printf、puts、getchar等C标准库函数在动态符号表和符号表中都有表项。此外与可重定位目标文件不同的是,这些符号已经确定好了运行时位置。
使用edb打开hello可执行文件:
分析程序头LOAD可加载的程序段的地址为0x400000
从Data Dump窗口能观察hello加载到虚拟地址的状况:
用Goto Expression工具能跳转到需要的位置,查看各段信息:
在0x400000~0x401000段中,程序被载入,虚拟地址从0x400000开始,到0x400fff结束,根据5.3节中的节头部表可以找到任意一个节的信息。例如,由节头表得到.interp段的地址为0x4002e0,然后在edb中查找0x4002e0处的内容,显示为动态链接器:
5.5 链接的重定位过程分析
反汇编hello文件命令:objdump -d -r hello > hello1_objdump.txt
hello与hello.o的不同:
1.可执行文件完成了重定位,所以地址是虚拟地址:
可重定位文件未完成重定位,地址为相对偏移地址。
2.在5.3中已有论述,可执行文件比可重定位文件多了许多节
3.可执行文件的反汇编代码中增加了许多外部链接的共享库函数,比如puts@plt,exit@plt,sleep@plt,getchar@plt共享库函数等。
链接过程分析:
第一步,符号解析:在这一步中将代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。
第二步,重定位节和符号定义:在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
第三步,重定位节中的符号引用:在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中的重定位条目,代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。重定位算法如下图:
直接使用edb的analyze功能:
分析ld-2.31.so位置,得到链接过程
运行后在hello地址分析,得到函数执行过程:
5.7 Hello的动态链接分析
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,当程序运行时将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有模块都链接成一个单独的可执行文件。
所谓动态就是不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接,也就是把链接这个过程推迟到了运行时再进行。对于动态链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要为其添加重定位记录,当动态链接器在程序加载的时候再解析它。为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT和全局偏移量表GOT实现函数的动态链接。其中GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
在加载时,动态链接器会重定位GOT中的每个条目,使它包含正确的绝对地址,而PLT中的每个函数负责调用不同函数。
观察elf中.got.plt节的内容:
用edb观察地址0x404000:
得到调用dl_init之前0x404008后的16个字节均为0。
dl_init(即开始运行)后观察.got.plt:
发现.got.plt条目变化,出现了两个小端序地址。
本章介绍了链接的概念与作用,对hello的elf格式进行了分析,介绍了hello的虚拟地址空间,重定位过程,执行过程,动态链接过程,更加熟悉链接的过程。
(第5章1分)
概念:
经典定义是一个执行中程序的实例。广义定义是进程是一个具有一定独立 功能的程序关于某个数据集合的一次运行活动。进程不只是程序代码,还包括 当前活动,如PC、寄存器的内容、堆栈、数据段,还可能包含堆。
作用:
它提供一个假象,好像我们的程序独占地使用内存系统,处理器好像是无 间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯 一的对象。
进程提供给应用程序的关键抽象:一个独立的逻辑控制流,提供一个程序 独占处理器的假象;一个私有的地址空间,提供一个程序独占地使用内存系统 的假象。
作用:
Linux系统中,Shell是一个交互型应用级程序,为使用者提供操作界面, 接收用户命令,调用相应的应用程序,代表用户运行其他程序。
处理流程:
1.终端进程读取用户由键盘输入的命令行;
2.分析命令行字符串,切分并获取命令行参数,并构造传递给execve的argv 向量;
3.检查第一个命令行参数是否是一个内置的shell命令;
4.如果不是内部命令,调用fork()创建新进程/子进程执行指定程序;
5.在子进程中,用步骤2获取的参数,调用execve()执行指定程序;
6.如果命令末尾没有&号(没要求后台运行),则shell使用waitpid等待作 业终止后返回;
7.如果命令末尾有&号,则shell返回。
当我们输入./hello时,shell发现它不是一个内置命令,于是将其判定为可执行程序,shell先创建一个对应./hello的作业,再用fork()创建一个子进程。 子进程几乎但不完全与父进程相同。通过fork函数,子进程得到与父进程用户 级虚拟地址空间相同的但是独立的一份副本,拥有不同的PID。
当调用fork()函数创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序(hello程序),步骤如下:
1.删除虚拟地址中已存在的用户区域。
2.为新程序建立新的私有的区域结构:虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域:如果hello与共享对象链接,那么这些对象就是被动态链接到这个程序,然后再映射到用户虚拟地址空间中的共享区域。
4.设置程序计数器(PC):exceve做的最后一件事是设置当前进程的上下文中的PC,使之指向代码区域的入口点,下次调用这个进程时就从这个入口点开始执行。
进程上下文信息:
1)概念:
进程上下文是进程执行活动全过程的静态描述。我们把已执行过的进程指令和数据在相关寄存器与堆栈中的内容称为进程上文,把正在执行的指令和数据在寄存器与堆栈中的内容称为进程正文,把待执行的指令和数据在寄存器与堆栈中的内容称为进程下文。
2)进程上下文切换流程:
1.保存当前进程的上下文。2.恢复某个先前被抢占的进程被保存的上下文。3.将控制传递给这个新恢复的进程。
3)调度:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被强占的进程。
进程时间片:
一个进程执行他的控制流的一部分的每一个时间段叫做时间片(time slice),多任务也叫时间分片(time slicing)
用户态与核心态转换:
处理器通常使用某个控制寄存器中的一个模式位来提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存的位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,也不允许直接引用地址空间中内核区的代码和数据。
运行应用程序的代码的进程开始处于用户模式中。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式改为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式改回用户模式。
6.6 hello的异常与信号处理
异常的类别:
信号:
异常的处理:
6.6.1正常运行:
6.6.2在程序执行过程中乱按:
乱按的输入作为hello执行结束后的命令输入,提示command not found
6.6.3ctrl-c:
原本需要将对应输出打印八次,按下Ctrl-C后,内核给前台进程组中的每个进程发送一个SIGINT信号,默认情况下结果是终止前台作业,所以上面只打印了两次进程便终止了。
6.6.4ctrl-z/jobs/ps/fg:
按下Ctrl-Z之后,会导致内核给前台进程组的每个进程发送一个SIGTSTP信号,默认情况下结果是停止(挂起)前台作业;
ps命令显示当前进程状态;
jobs命令查看运行中作业;
使用fg命令重新将该进程放到前台运行时,进程从挂起的位置继续执行,将剩余工作接着做完。
6.6.5pstree命令:
pstree命令列出进程树:
查看指定pid的进程关系:
6.6.6kill:
kill命令发送了SIGKILL信号。
本章介绍了进程的概念和作用、shell-bash的处理过程与作用并且着重分析了调用fork创建新进程,调用execve函数执行hello,hello的进程执行过程,以及hello在运行时遇到的异常与信号处理。
(第6章1分)
逻辑地址:程序编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为[段选择符: 段内偏移量]。
线性地址:一个逻辑地址经过段地址机制转化后变成一个线性分页地址,先用段选择符到全局描述符表(GDT)中取得段基址,再加上段内偏移量,即得到线性地址。线性地址可以再经过物理地址变换以产生一个新的物理分页地址。IA 64中,由于不存在段,偏移量就不需要转换才能得到线性地址了,逻辑地址==线性地址。
虚拟地址:虚拟地址强调程序拿到的地址不是真实的物理地址,而是一个虚拟的地址,需要经过到线性地址再到物理地址的变换。
物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。物理地址是由线性地址转换而来的。
段式管理基本思想:
在段式存储管理中,将程序的地址空间划分为若干个段(segment),这样每个进程有一个二维的地址空间。在前面所介绍的动态分区分配方式中,系统为整个进程分配一个连续的内存空间。而在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。程序加载时,操作系统为所有段分配其所需内存,这些段不必连续,物理内存的管理采用动态分区的管理方法。
段式管理的数据结构:
为了实现段式管理,操作系统需要如下的数据结构来实现进程的地址空间到物理内存空间的映射,并跟踪物理内存的使用情况,以便在装入新的段的时候,合理地分配内存空间。
·进程段表:描述组成进程地址空间的各段,可以是指向系统段表中表项的索引。每段有段基址(baseaddress),即段内地址。
·系统段表:系统所有占用段(已经分配的段)。
·空闲段表:内存中所有空闲段,可以结合到系统段表中。
段式管理的地址变换:
在段式管理系统中,整个进程的地址空间是二维的,即其逻辑地址由段号和段内地址两部分组成。为了完成进程逻辑地址到物理地址的映射,处理器会查找内存中的段表,由段号得到段的首地址,加上段内地址,得到实际的物理地址。这个过程也是由处理器的硬件直接完成的,操作系统只需在进程切换时,将进程段表的首地址装入处理器的特定寄存器当中。这个寄存器一般被称作段表地址寄存器。
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
线性地址(虚拟地址)由虚拟页号VPN和虚拟页偏移VPO组成。首先,MMU从线性地址中抽取出VPN,并且检查TLB,看他是否因为前面某个内存引用缓存了PTE的一个副本。TLB从VPN中抽取出TLB索引和TLB标记,查找对应组中是否有匹配的条目。若命中,将缓存的PPN返回给MMU。若不命中,MMU需从页表中的PTE中取出PPN,若得到的PTE无效或标记不匹配,就产生缺页,内核需调入所需页面,重新运行加载指令,若有效,则取出PPN。最后将线性地址中的VPO与PPN连接起来就得到了对应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
Core i7采用四级页表的层次结构。CPU产生虚拟地址VA,虚拟地址VA传送给MMU,MMU使用VPN高位作为TLBT和TLBI,向TLB中寻找匹配。如果命中,则得到物理地址PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,把结果保存到TLB中,最后在TLB中取得结果,以此类推,最终在第四级页表中找到PPN,与VPO组合成物理地址PA,添加到PLT。
corei7采用四级页表层次结构,每个四级页表进程都有自己私有的页表层次结构,这种设计方法从两个基本方面减少了对内存的需求,如果一级页表的PTE全部为空,那么二级页表就不会继续存在,从而为进程节省了大量的内存,而且也只有一级页表才会有需要总是在一个内存中。四级页表的层次结构操作流程如下:36位虚拟地址被寄存器划分出来组成四个9位的片,每个片被寄存器用作到一个页表的偏移量。cr3寄存器内储存了一个l1页表的一个物理起始基地址,指向第一级页表的一个起始和最终位置,这个地址是页表上下文的一部分信息。VPN1提供了到一个l1PTE的偏移量,这个PTE寄存器包含一个l2页表的起始基地址.VPN2提供了到一个l2PTE的偏移量,一共四级,逐级以此层次类推。
7.5 三级Cache支持下的物理内存访问
获得物理地址之后,先取出组索引对应位,在L1中寻找对应组。如果存在,则比较标志位,相等后检查有效位是否为1.如果都满足则命中取出值传给CPU,否则按顺序对L2cache、L3cache、内存进行相同操作,直到出现命中。然后再一级一级向上传,如果有空闲块则将目标块放置到空闲块中,否则将缓存中的某个块驱逐,将目标块放到被驱逐块的位置。
当前进程调用fork时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的“写时复制”。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件hello.out中的程序,用hello.out程序有效地替代了当前程序,加载并运行需要以下步骤:
1.删除已存在用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
3.映射共享区域:hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC):execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,它将从这个入口开始执行,Linux将根据需要换入代码和数据页面:
7.8 缺页故障与缺页中断处理
1) 处理器将虚拟地址发送给 MMU ;
2-3) MMU 使用内存中的页表生成PTE地址;
4) 有效位为零, 因此 MMU 触发缺页异常;
5) 缺页处理程序确定物理内存中牺牲页 (若页面被修改,则换出到磁盘);
6) 缺页处理程序调入新的页面,并更新内存中的PTE;
7) 缺页处理程序返回到原来进程,再次执行导致缺页的指令。
动态存储分配概念:在程序运行时程序员使用动态内存分配器 (比如 malloc)获得虚拟内存,动态内存分配器维护着进程的一个虚拟内存区域,称为堆。分配器将堆视为一组不同大小 块(blocks)的集合,每个块要么是已分配的,要么是空闲的。
管理方式:使用显式空闲链表、隐式空闲链表、分离的空闲链表、按照尺寸排序的块来管理动态内存。对于隐式空闲链表,它使用首次适配、下一次适配、最佳适配三种策略来寻找空闲块,然后是用分割法分配空闲块,再使用立即合并策略和延迟合并策略来合并空闲块,最后使用最常见的方法来释放内存块(即调用free函数)。
本章介绍了存储管理的有关内容。介绍了存储器的地址空间:物理地址、虚拟地址、逻辑地址、线性地址,分析了段式管理和页式管理、VA到PA的变换、物理内存访问、fork和execve的内存映射、缺页故障和缺页处理、动态存储分配管理等内容。
(第7章 2分)
设备的模型化:所有IO设备都被模型化为文件,所有的输入和输出都能被当做相应文件的读和写来执行。
设备管理:Linux内核有一个简单、低级的接口,成为Unix I/O,是的所有的输入和输出都能以一种统一且一致的方式来执行。
Unix IO接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux Shell创建的每个进程都有三个打开的文件:标准输入(描述符为0),标准输出(描述符为1),标准错误(描述符为2)。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
4.读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放他们的内存资源。
Unix I/O函数:
①int open(char* filename,int flags,mode_t mode)
open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程当中没有打开的最小描述符,若打开失败则返回-1。
flags参数指明了进程打算如何访问这个文件,同时也可以是一个或者更多为掩码的或,为写提供给一些额外的指示,包括O_RDONLY(只读),O_WRONLY(只写),O_RDWR(可读写)。
mode参数指定了新文件的访问权限位。
②int close(fd)
进程通过调用close函数关闭一个打开的文件,fd是需要关闭的文件的描述符。
③ssize_t read(int fd,void *buf,size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
④ssize_t wirte(int fd,const void *buf,size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
printf函数体:
在形参列表里有这么一个token:...,这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。在printf内存调用了两个函数,一个是vsprintf,一个是write。
va_list的定义:typedef char *va_list ,这说明它是一个字符指针。
printf执行的流程就是:首先,printf开辟一块输出缓冲区,然后用vsprintf在输出缓冲区中生成要输出的字符串。之后通过write将这个字符串输出到屏幕上。
vsprintf函数:
vsprintf函数(在printf函数内部调用),vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
write函数:
在printf中调用系统函数write(buf,i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,
int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是我们的打印字符串就显示在了屏幕上。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
工作原理:getchar()是stdio.h中的库函数,它的作用是从stdin流中读入一个字符,也就是说,如果stdin有数据的话不用输入它就可以直接读取了,第一次getchar()时,确实需要人工的输入,但是如果你输了多个字符,以后的getchar()再执行时就会直接从缓冲区中读取了。
本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,Unix I/O 接口及其函数,printf 函数和 getchar 函数的工作过程。
(第8章1分)
hello的一生:
1.输入:将hello.c代码从键盘输入。
2.预处理(cpp):将hello.c进行预处理,将c文件调用的所有外部的库展开合并,生成hello.i文件。
3.编译(ccl):将hello.i文件进行翻译生成汇编语言文件hello.s。
4.汇编(as):将hello.s翻译成一个可重定位目标文件hello.o。
5.链接(ld):将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello,至此可执行hello程序正式诞生。
6.运行:在shell中调用可执行程序文件。
7.创建子进程:由于终端输入的不是一个内置的shell命令,因此shell调用fork ()函数创建一个子进程。
8.加载程序:shell调用execve函数,启动加载器,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数。
9.执行指令:CPU为进程分配时间片,在一个时间片中,hello享有CPU资源, 顺序执行自己的控制逻辑流。
10.访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
11.动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存。
12.信号管理:当程序在运行的时候我们输入ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
13终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。
计算机系统的设计可谓环环相扣,其运作方式每一步都有其合理之处,不是凭空产生的,无论是每一步处理对上一步的依赖,还是对下一步的支撑,其过程对于计算机本身来说逐渐由抽象不可理解的自然语言到具体的机器语言,对于用户则是实现了与计算机系统的交互。进程对处理器、主存和I/O设备的抽象,虚拟内存对主存和磁盘设备的抽象,文件对I/O设备的抽象都是非常亮眼的概念,存储器由下至上的层次结构也为计算机的性能提供了保障。
(结论0分,缺失 -1分,根据内容酌情加分)
文件 |
作用 |
hello.c |
hello的源程序 |
hello.i |
经过预处理后产生的文件 |
hello.s |
编译后产生的汇编文件 |
hello.o |
汇编后的可重定位目标执行文件 |
hello.elf |
hello.o的ELF格式 |
hello_objdump.txt |
hello.o反汇编代码文件 |
hello1.elf |
hello可执行程序的ELF格式 |
hello1_objdump.txt |
hello反汇编代码文件 |
(附件0分,缺失 -1分)
为完成本次大作业你翻阅的书籍与网站等
[1]Randal E. Bryant, David R. O'Hallaon. 深入理解计算机系统. 第三版. 北京市:机械工业出版社[M]. 2018-1-737
[2]x86-3-段式管理(segmentation) - Sna1lGo - 博客园
[3]操作系统的内存管理——页式、段式管理、段页式管理_一个月后又可以改名了!的博客-CSDN博客_段式管理
[4][转]printf 函数实现的深入剖析 - Pianistx - 博客园
[5]c : Where the printf() Rubber Meets the Road
(参考文献0分,缺失 -1分)