题 目 程序人生-Hello’s P2P
摘 要
本篇介绍了hello从源程序开始,经历一步步的处理----预处理、编译、汇编、链接过程而最终生成可执行文件。由高级语言表示的.c源文件是不能直接在计算机上运行的,前面那些操作是必须的。接下来本文将针对这些处理过程展开,详述这中间的一些具体细节。此外,还涉及到动态内存分配、IO管理等等,这些是与程序运行息息相关的。
关键词:预处理 编译 汇编 链接 进程 存储管理 IO管理
目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 4 -
第2章 预处理 - 5 -
2.1 预处理的概念与作用 - 5 -
2.2在Ubuntu下预处理的命令 - 5 -
2.3 Hello的预处理结果解析 - 5 -
2.4 本章小结 - 5 -
第3章 编译 - 6 -
3.1 编译的概念与作用 - 6 -
3.2 在Ubuntu下编译的命令 - 6 -
3.3 Hello的编译结果解析 - 6 -
3.4 本章小结 - 6 -
第4章 汇编 - 7 -
4.1 汇编的概念与作用 - 7 -
4.2 在Ubuntu下汇编的命令 - 7 -
4.3 可重定位目标elf格式 - 7 -
4.4 Hello.o的结果解析 - 7 -
4.5 本章小结 - 7 -
第5章 链接 - 8 -
5.1 链接的概念与作用 - 8 -
5.2 在Ubuntu下链接的命令 - 8 -
5.3 可执行目标文件hello的格式 - 8 -
5.4 hello的虚拟地址空间 - 8 -
5.5 链接的重定位过程分析 - 8 -
5.6 hello的执行流程 - 8 -
5.7 Hello的动态链接分析 - 8 -
5.8 本章小结 - 9 -
第6章 hello进程管理 - 10 -
6.1 进程的概念与作用 - 10 -
6.2 简述壳Shell-bash的作用与处理流程 - 10 -
6.3 Hello的fork进程创建过程 - 10 -
6.4 Hello的execve过程 - 10 -
6.5 Hello的进程执行 - 10 -
6.6 hello的异常与信号处理 - 10 -
6.7本章小结 - 10 -
第7章 hello的存储管理 - 11 -
7.1 hello的存储器地址空间 - 11 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 11 -
7.5 三级Cache支持下的物理内存访问 - 11 -
7.6 hello进程fork时的内存映射 - 11 -
7.7 hello进程execve时的内存映射 - 11 -
7.8 缺页故障与缺页中断处理 - 11 -
7.9动态存储分配管理 - 11 -
7.10本章小结 - 12 -
第8章 hello的IO管理 - 13 -
8.1 Linux的IO设备管理方法 - 13 -
8.2 简述Unix IO接口及其函数 - 13 -
8.3 printf的实现分析 - 13 -
8.4 getchar的实现分析 - 13 -
8.5本章小结 - 13 -
结论 - 14 -
附件 - 15 -
参考文献 - 16 -
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:即From Program to Process,对源程序(Hello.c)进行编译预处理、编译、汇编、链接形成可执行文件(Hello),再在shell上输入命令,运行可执行文件,shell利用fork函数创建子进程。
020:From Zero-0 to Zero-0,在P2P的基础上,shell调用execve函数加载Hello,并进行虚拟内存映射,获得_start即可执行文件的程序开始处(第一条指令处),然后进入main函数开始执行。程序运行结束是,shell负责回收进程,并删除内核中相关的数据结构。
1.2 环境与工具
硬件环境:X64 CPU ,2.50GHz , 8G RAM
软件环境:Windows 10 64位 ,Vmware 14 ,Ubuntu 16.04 LTS 64 位
开发工具:gcc + gedit , Codeblocks , gdb edb
1.3 中间结果
hello.i:预处理后的文件
hello.s:hello.i编译后的文件
hello.o:hello.s汇编后的文件
hello.o_objdump:hello.o的反汇编代码
hello_objdump:hello的反汇编代码
hello.o_elf:hello.o的readelf文件
hell_elf:hello的readelf文件
hello:可执行文件
1.4 本章小结
本章,简单概括了的P2P、020过程,介绍了为完成该大作业所需要做的软硬件环境及开发工具准备。最后展示了整个大作业的中间结果。
第2章 预处理
2.1 预处理的概念与作用
概念:编译预处理指的是对源程序中的宏定义、引用的库文件进行处理,将其在程序中由实际代码替换。编译预处理命令全部以‘#’开头,同时其不是c语句,不必加分号。
作用:由于比如#define、#include
2.2在Ubuntu下预处理的命令
如图2.2-1和2.2-2,-o 生成了.i文件。
2.3 Hello的预处理结果解析
(以下格式自行编排,编辑时删除)
预处理后的.i文件扩到63940个字节,程序扩展到3042行。
图2.3
而前面的3025行主要是插入的各种引用的库函数的代码。如图2.3,.i文件开头包含了这些命令,这些命令指明了相应代码在文件中的起始位置,以及文件的路径。而main函数在文件的最后。
2.4 本章小结
第二章介绍了对.c程序进行编译预处理生成.i文件,以及编译预处理的概念、作用,最后展示了预处理后得到的文件。
第3章 编译
3.1 编译的概念与作用
概念:
编译是指利用编译程序由预处理得到的程序产生目标程序的过程。编译一般分为5个过程:词法分析、语法分析、中间代码生成、代码优化、目标代码生成。
作用:
编译将高级语言变成了等价的汇编语言程序。
其中,词法分析是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个单词符号,把作为字符串的源程序改造成单词符号串的源程序。
语法分析,语法分析器分析单词符号串是否形成符合语法规则的语法单位,检查是否构成一个符合要求的单位。
中间代码生成:中间代码作用是可使编译程序的结构在逻辑上更为简单明确。
代码优化:代码优化是指对程序进行多种等价变换,使得从变换后的程序出发,能产生更有效的目标代码。
目标代码生成:目标代码生成器把语法分析或优化后的中间代码变换成目标代码。
3.2 在Ubuntu下编译的命令
命令:gcc -S hello.i -o hello.s
图3.2
3.3 Hello的编译结果解析
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
如图3.3-1,局部变量出现在栈中。局部变量保存在%rbp-4处,赋初值为0.
如图3.3.2-1,判断argc != 4,使用cmpl,相等则跳转到.L2
。
图3.3-2
如图3.3.2-2,判断i < 8,汇编语言使用的是cmpl,将保存在%rbp-4处的i与7比较,若小等于7,则跳转到.L4处。
3.3.3控制转移
图3.3.3-1
图3.3.3-2
图3.3.3-3
如图3.3.3-1,图3.3.3-2,图3.3-3,三个图中均采用跳转指令。je即两数相等时跳转到对应位置;jmp为无条件跳转;jle为一数小于等于另一数时跳转。
3.3.4数组操作
main函数中的第二个参数是一个数组,该数组元素是指向字符串的指针,在该hello.c程序中,argv数组有3个元素,即3个指向字符串的指针。
图3.3.4-1
如图3.3.4-1,argv数组的首地址保存寄存器%rsi中,在main函数的栈中,将%rsi的值即数组首地址传给了%rbp-32处。
如图3.3.4-2,根据保存在栈中的argv数组首地址,再利用数组元素的相对偏移量读出数组中的三个指针argv[1],argv[2],argv[3]。
如图3.3.5,进行了强制类型转换,不过是调用atoi函数进行的。首先取出%rbp-32处保存的argv首地址,利用偏移量加24得到argv[3]的地址,便可得到argv[3]的指针,该指针指向字符串“秒数”。将指针传给%rdi作为参数后,接下来便可调用atoi函数完成强制类型转换了。
如图3.3.6-1,C语言中的i++操作。
图3.3.6-2
如图3.3.6-2,汇编语言中的addl实现了C语言中的++操作。
如图3.3.7-1,调用了atoi函数,具体分析同3.3.5强制类型转换中分析。
图3.3.7-2
如图3.3.7-2,调用了printf,参数有三个,第一个是.LC1(如图3.3.7-3),第二个是argv[1],第二个是argv[2]。printf函数如图3.3.7-4所示。
如图3.3.7-5,调用了sleep函数,参数为强制类型转换后的argv[3]。
图3.3.7-6
如图3.3.7-6,调用函数getchar,无参数,直接调用。
如图,调用exit函数,参数为1,退出程序。
图3.3.7-8
如图3.3.7-8,调用函数puts,参数为一个,如图3.3.7-9。而在C语言中调用的是puts,汇编语言中用的是puts。C语言中调用的printf如图3.3.7-10.
图3.3.7-9
图3.3.7-10
3.4 本章小结
本章介绍了由预处理得到的文件进行编译的过程,并对.s文件进行了分析。其中着重分析了汇编程序中的数据、变量、数组、函数调用、控制转移等等操作。
第4章 汇编
4.1 汇编的概念与作用
概念:
将汇编语言翻译成机器语言的过程称为汇编。汇编器生由.s文件生成可重定位目标文件,机器语言的二进制代码就保存在这些可重定位目标文件中。
作用:
汇编生成的机器语言是计算机可直接识别和执行的一种语言。
4.2 在Ubuntu下汇编的命令
命令:gcc -c hello.s -o hello.o
图4.2
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
ELF格式最开始部分是ELF头。如图4.3-1,。ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如X86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中没一个接都有一个固定大小的条目。
节头部表描述不同节的位置和大小。如图4.3-2。
.rela.text:一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。
如图4.3-4为ELF重定位条目,ELF定义了32种不同的重定位类型,其中重要的两种有R_X86_64_PC32和R_X86_64_32,即重定位相对引用和绝对引用。.rel.text中有8个记录。
图4.3-5 .symtab
如图4.3-5为.symtab节。.symtab为一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。和编译器中的符号表不同的是,.symtab符号表不包含局部变量的条目。
4.4 Hello.o的结果解析
机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合,而每一条机器指令由操作码和操作数组成。汇编语言的主体是汇编指令,汇编指令是机器指令便于记忆阅读的书写格式。
反汇编与hello.s如图4.4。
call:在hello.s中,call指令后跟着的是函数名称,而在反汇编程序中call后面跟着的相对于下一条指令的偏移地址。而在hello.s的反汇编中,call的相对地址都是0,即都指向下一条指令。这是因为在hello.s中,call的函数都是库函数,需要在动态库连接后才能确定其地址,因此相对地址就先设为0,并在.rel.text中添加重定位条目,以便修改。
分支转移:hello.s中,跳转指令后跟着的符号(段名称),而在反汇编中,跳转指令后跟着的是相对地址。
变量访问:hello.s中,对于printf中的打印字符串,调用时利用的是符号名;而在反汇编中,采用的是相对地址寻址的方式,但是相对地址都为0。这是因为地址是运行时确定,需要重定位,添加重定位条目,之后等待修改。
图4.4
4.5 本章小结
本章介绍了汇编的概念与作用,指出了机器语言与汇编语言的相关性、映射关系,通过hello.s以及反汇编具体体会了这种不同。
第5章 链接
5.1 链接的概念与作用
概念:
链接是将各种代码和数据片段收集并组合成一个文件的过程,这个文件可被加载(复制)到内存执行。链接可以执行于编译时、加载时、运行时。
作用:
对于程序中调用的库函数,比如printf,scanf,需要通过链接将相应的可重定位目标文件.o文件合并到一起,比如hello.o 与printf.o、getchar.o合并到hello.o中,生成可执行文件hello。经静态链接后生成的是完全连接的可执行文件,动态链接生成的是部分链接的可执行文件。部分链接的可执行文件在由加载器加载时再进行链接形成完全链接的可执行文件。
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.2 链接
5.3 可执行目标文件hello的格式
可执行目标文件的格式类似于可重定位目标文件的格式。
图5.3-1
hello的ELF头描述文件的总体风格,它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。如图5.3-1,可以看到ELF头的大小为64字节。
如图5.3-2、图5.3-3,节头部表描述了目标文件的各个节,从图中可以看到共有22个节。
程序头部表描述了连续的文件节与运行时内存段的映射,如图5.3-4.
符号表,如图5.3-5。
5.4 hello的虚拟地址空间
如图5.4-1,通过datadump可以查看各个段的内容。由图5.4-2可知各个段的起始地址、大小等信息。因此图5.4-1中包含.init、.plt、.text代码段。
如图5.4-3,只读代码段。
如图5.4-4,.plt、.got.plt、.datad段。
5.5 链接的重定位过程分析
不同:(1)hello中加入了C语言程序中调用的函数,如printf、exit、sleep、getchar等函数,而hello.o中还未进行链接,只保存重定位条目。(2)hello中还增加了.init节。(3)hello.o保存了重定位条目以便链接时确定地址,且使用相对偏移地址。hello中各段地址已经确定,hello中变成了虚拟内存地址。
链接过程:链接器完成两个主要任务:符号解析和重定位。符号解析即将每个符号引用和一个符号定义关联起来。重定位:链接器把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们只想这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执这样的重定位。
图5.5-1 重定位节
图5.5-2
如图5.5,为hello.o的重定位节。可知偏移量、类型、加数等,接下来可利用公式图5.5-2求出重定位后的地址。
5.6 hello的执行流程
0x401000 _init
0x401020 .plt
0x401030 puts@plt
0x401040 printf@plt
0x401050 getchar@plt
0x401060 atoi@plt
0x401070 exit@plt
0x401080 sleep@plt
0x401090 _start>
0x4010c0 _dl_relocate_static_pie
0x4010c1 main
0x401150 __libc_csu_init
0x4011b0 __libc_csu_fini
0x4011b4 _fini
5.7 Hello的动态链接分析
(以下格式自行编排,编辑时删除)
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定的技术解决这个问题,将过程地址的延迟绑定推迟到第一次调用该过程时。
延迟绑定要用到全局偏移量表(GOT)和过程链接表(PLT)两个数据结构。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,跳转到动态链接器中。每个条目都负责调用一个具体的函数。PLT[[1]]调用系统启动函数 (__libc_start_main)。从PLT[[2]]开始的条目调用用户代码调用的函数。GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[[1]]包含动态链接器在解析函数地址时会使用的信息。GOT[[2]]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。
以动态链接项目global_offset为例,图5.7-1位do_int前,图5.7-2位do_int后。刚开始global_offset是全0的状态,执行后被赋上了相应的偏移量值。
5.8 本章小结
在这一章中,我们了解了可重定位目标文件经过链接成为和执行文件的过程,从shell调用fork,再到execve调用加载器,再有内存映射。了解了链接过程中的更为细节的东西,明白了静态内存分配、动态内存分配的优缺点。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动。程序是指令、数据及其组织形式的描述,进程是程序的实体。
作用:进程是系统进行资源分配和调度的基本单位,是操作系统结构的基础。进程提供给了应用程序两个关键抽象:一个独立的逻辑控制流,它提供了一个假象,好像我们的程序独占地使用处理器;一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell是一个命令行解释器,解释用户输入的命令并送到内核,使得用户能够访问操作系统的服务。
处理流程:首先shell判断用户输入的是否是内置命令,若是内置命令,则执行相应操作;否则,必定是可执行程序,开始在制定路径寻找该文件,若没找到,则会报错,若找到了,则按照相应的参数执行该程序。
6.3 Hello的fork进程创建过程
shell运行一个程序时,父进程shell调用fork函数生成一个子进程,子进程是父进程的一个复制。子进程的虚拟地址空间与父进程相同,但是又相对父进程独立,相当于副本。fork函数调用一次,返回两次,且在父进程和子进程的返回值不一样。同时,子进程的ID与父进程的不一样。
6.4 Hello的execve过程
子进程通过execve函数系统调用启动加载器。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件的内容。最后加载器调到_start处,最终调用应用程序的main函数。
6.5 Hello的进程执行
输入合适的命令行参数后,开始执行hello程序。hello程序一开始运行在用户模式。所谓用户模式是指对内核不可访问,与之相对应的就有内核模式。内核为hello维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态。它由包括目的寄存器、程序计数器、用户栈、状态寄存器在内的对象的值构成。
同时,在hello运行的过程中,其他进程在这一过程中也有运行,多个流并发地执行的一般现象称为并发。图6.5-1即描述这种概念。
图6.5-1 逻辑并发流
hello运行时也是如此。当hello调用printf时,需要访问内核,这时就会进行上下文切换,于是进程就会从用户模式切换到内核模式。当从内核模式变回用户模式时,可能就返回切换到另一个进程了。这时会进行上下文切换,切换过程一般可分为3个部分,分别为:(1)保存当前进程的上下文;(2)恢复要执行进程之前保存的上下文;(3)将控制传递给新恢复的进程。过程如图6.5-2。
图6.5-2
而当hello运行一段时间后,定时钟中断的产生也能引起上下文切换。在hello中,调用了8次sleep,当全部调用完后,hello执行结束,程序终止。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
可能遇到的异常:
故障:缺页异常,在加载完成后hello的页被映射到内存中,但是实际的代码并未被复制到内存中,因此第一次运行时一定会发生缺页异常,从而将代码复制到内存中。
中断:来自键盘的信号也会产生中断,如在程序运行中输入的各类信号。
陷阱:有意的异常,如hello中的sleep。
终止:不可恢复的错误。程序会停止运行。
如图6.6-1,什么都不按(但是在最后按回车),程序正常执行,正常结束。
如图6.6-2,一直乱按,按的字符会输入到shell,但是并不会影响程序的正常运行。
如图6.6-3,按下ctr+z后程序停止运行。
如图6.6-4,按下ctr+z后再输入jobs,可以看到程序处于停止状态。
如图6.6-5,ctr+z后再输入pstree,以树状形式显式进程间的联系。
fg命令,将进程调到前台。如图6.6-6.
如图6.6-7,用命令kill -9 2513 将进程杀死。ps再查看状态。
如图6.6-8,按下ctr+c,停止运行。再输jobs查看进程,已无运行进程。说明hello进程终止,并且被回收。
6.7本章小结
本章介绍了进程的概念以及作用,同时回顾了从可执行文件到形成进程再到进程的执行,以及进程执行过程中的上下文切换、进程执行过程中的各种命令。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:在机器语言中,用来指定一个操作数或者是一条指令的地址。每一个逻辑地址都由一个段和一个偏移量组成,偏移量是段起始位置与实际位置之间的距离,实际上也就是hello.o中的相对偏移地址。
线性地址:逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。是hello中的虚拟内存地址。
虚拟地址:一个带虚拟内存的系统中,CPU从一个有N=2^n个地址空间中生成虚拟地址。虚拟地址其实就是线性地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。地址翻译会将hello的一个虚拟地址转化为物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图7.2-1所示。
图7.2-1
段描述符时对一个段的具体描述,有很多段,就又相应多的段描述符,这些段描述符就组成了“段描述符表”。如图7.2-2所示。
图7.2-2
全局的段描述符放在“全局段描述符表(GDT)”中,局部的段描述符放在“局部段描述符表(LDT)”中。如图7.2-3
图7.2-3
首先给定了一个完整的逻辑地址。接着根据一个位判断段描述符在全局还是局部的表中,判定完后就可以取出段描述符,接下来取出段描述符中的前13位,再组合便可组成线性地址,整个过程思路还是很清晰的。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址一一对应的页的地址。另一类“页”,我们称之为物理页,或者是页框、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。
7.4 TLB与四级页表支持下的VA到PA的变换
图7.4 (图片来源课本)
如图7,4,位i7的页表翻译。首先四级页表中,第一级页表相当于第二级页表的索引,第二级页表相当于第三级页表的索引,第三季相当于第四级的索引,第四级页表映射到物理地址。
采用多级页表是对内存的极大节约。只有经常使用的页表才会保存在内存中。
多级页表的不同之处就如上所述,其他过程与一级页表完全一样。对虚拟地址进行划分,假设共有48位,前36位为VPN,则根据VPN访问页表,根据页表判断相应地址的数据是否缓存,若缓存,可直接从页表中读出PPN,则VPO与PPN组成一个完整的物理地址,接下来便可访存。
7.5 三级Cache支持下的物理内存访问
图7.5(图片来源课本)
如图,为三级cache的模式图。在获得访问内存的物理地址后,进行索引。从物理地址中获取组索引、块偏移、以及标记位,接下来到L1中查找,若在L1中不存在,则到L2、L3,若仍然都不存在,直接到主存访问取出。之后还要将该块放进高速缓存中。
7.6 hello进程fork时的内存映射
当fork函数被新进程调用时,内核为新进程创建各种数据结构,并分配给它唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的文件。加载并运行hello有以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为新的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
(3)映射共享区域。hello与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器。execve设置当前进程上下文的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,将从这个入口点开始执行。
如图7.7为内存映射示意图。
图7.7
7.8 缺页故障与缺页中断处理
缺页故障:当要读取的某个页不在内存中,即不命中时,便出现了缺页故障。
而缺页故障又会触发缺页中断处理程序。
假设MMU在试图翻译某个虚拟地址是,触发了一个缺页。这个异常便会导致控制转移到内核的缺页处理程序,处理程序回执行以下三个步骤:
(1)检查虚拟地址是否合法。该步骤主要检查虚拟地址是否是指向某个区域结构定义的区域内,即是否在访问范围内。如果不在区域内,访问的页是不存在的,则会报错,并终止进程。
(2)要进行的内存访问是否合法。主要判断是否满足读、写或者执行这个区域内页面的权限。例如,如果是因为试图对一个只读页面进行写操作而引起的缺页中断,那么缺页处理程序会触发一个保护异常,并终止这个程序。
(3)在完成上面两个步骤成功到达第三个步骤时,已经确定了缺页是由于正常的不命中原因引起的,此时便可以从磁盘中将缺失的页读入内存。首先选择一个牺牲页面,如果牺牲页面有被修改过,则将牺牲页面写回磁盘,否则直接还如新的页。
这样,当却也处理程序返回时,CPU会重启引起缺页的指令,而这时就没有了缺页的情况。linux缺页处理大致如图7.8.
图7.8
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆.系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) .对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。如图7.9-1位堆得示意图。
分配器将堆视为一组不同大小的块的集合来维护.每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的.已分配的块显式地保留为供应用程序使用.空闲块可用来分配.空闲块保持空闲,直到它显式地被应用所分配.一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的.
分配器有两种基本风格. 两种风格都要求应用显式地分配块.它们的不同之处在于由哪个实体来负责释放已分配的块
显式分配器(explicit allocator):要求应用显式地释放任何已分配的块.例如,C标准库提供一种叫做malloc程序包的显式分配器.C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块.C++中的new和delete操作符与C中的malloc和free相当.
隐式分配器(implicit allocator):要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块.隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集( garbage collection).例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
而动态内存分配具有重要的意义,因为知道程序运行时才知道需要分配的内存的大小。
显示分配器有几个约束条件:
(1)能够处理任意请求序列。(2)立即响应分配请求。不允许分配器为了提高性能重新排列或者缓冲请求。(3)只使用堆。(4)对齐块(对齐要求)。(5)不修改已分配的的块。
一个实际的分配器要在吞吐率和利用率之间把握好平衡,就必须考虑一下几个问题:
(1)空闲块组织:如何记录空闲块。
(2)放置:我们如何选择一个合适的空闲块来放置一个新分配的块?
(3)分割:分配完后,如何处理这个空闲块中的剩余部分?
(4)合并:如何处理一个刚刚被释放的块?
隐式空闲链表是满足这些条件的较简单的空闲块组织方式。首先一个块由一个字的头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。此外,一般情况下还有对其的约束条件。块结构大致如图7.9-2所示,隐式空闲链表结构大致如图7.9-3所示。
那么问题来了,分配器如何实现合并?这里利用了带边界标记合并的技术。分为四种情况:(1)前面的块和后面的块都是已分配的;(2)前面的块是已分配的,后面的块空闲;(3)前面的块空闲,后面的块已分配;(4)前面的块和后面的块均已分配。如图7.9-4所示。
显式空闲链表相比隐式空闲链表具有更高的效率。显式空闲链表是一个双向空闲链表,显式空闲链表使首次适配的时间从块总数的线性时间减少到了空闲块总数的的线性时间。但是显式空闲链表也潜在地提高了内部碎片的程度。显式空闲链表堆块的格式如图7.8-5。
空闲链表中块的排序策略有几种:(1)后进先出。新释放的块放在来表开始处;(2)按照地址顺序维护链表。
而现在流行的方法有:分离的空闲链表,通常称为分离存储,就是维护多个空闲链表,减少分配时间。主要有简单分离存储、分离适配、伙伴系统等。
7.10本章小结
本章介绍了存储器的四种地址,以及几种地址之间的转换。操作系统采用虚拟内存系统,访存时需要由虚拟地址得到物理地址。虚拟内存为给程序的运行加载提供了便捷。
程序运行过程中又往往涉及内存分配,因此需要一个好的动态内存分配方案。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个linux文件就是一个m个字节的序列:B0, B1, … Bk, …, Bm-1
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:UNIX I/O。
8.2 简述Unix IO接口及其函数
Unix IO接口:
打开文件,内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件
改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k
关闭文件。当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中
unix IO函数:
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函数主要调用了vsprintf和write函数。
下面首先介绍vsprintf(buf, fmt, arg)是什么函数。
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);
}
从上面vsprintf函数可以看出,这个函数的作用是将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。
对write进心追踪:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
sys_call的实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
于是可以直到printf函数执行过程如下:
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar由宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
通过本章了解了IO设备管理方法,以及一些IO函数,并且深入分析了printf函数和getchar函数的实现。
结论
作为程序员的初恋—hello,它的一生是短暂的,但又是极其辉煌的!Hello带领我们做完了这个大作业,让我们认识到了从键盘一直到硬件的整个历程------预处理、编译、汇编、链接、动态内存分配、IO、虚拟内存等等,hello带领我们领略计算机世界的0和1,让我们为计算机高速缓存惊叹,为进程、并行的奇妙而喝彩,为高效的动态内存分配鼓掌,为虚拟内存所倾倒…是hello,把一幅美轮美奂的计算机画卷在我们面前展开。hello!它是不平凡的!
hello的梦想,在屏幕上运行,展现自己优美身姿,做一名合格“程序猿”!
hello,刚开始还只是襁褓之中,由高级语言汇集而成。但它经历预处理,褪去宏定义的羞涩外衣,学会了走路;接着,hello经历编译,hello离成为真正的“合格程序猿”又近了一步,它开始变幻成汇编语言了,离硬件的梦想更近了;然而,编译还不够,hello接着接受了会变得洗礼,它!变成了二进制指令的集合啦!硬件可以读懂它啦,它离梦想又近了一步;但,别高兴得太早,hello还只是没有地址不能运行的可重定位文件而已。但是,离梦想已经很近了。就在眼前!hello咬咬牙,开始了链接。hello与一个个必要的.o、文件链接,fork,加载,动态链接。历经千般苦难,终于,hello实现了自己的梦想,它运行了,它开始在屏幕上展现自己的优美了!然而,美好的日子总是异常短暂,一秒钟都不到…hello,它,它,它,就卒了…
但是hello表示,它还会静静在存储区等待,等待着某一天某个菜鸟再来打开它,运行它。
附件
hello.i:预处理后的文件
hello.s:hello.i编译后的文件
hello.o:hello.s汇编后的文件
hello.o_elf:hello.o的readelf文件
hell_elf:hello的readelf文件
hello.o_objdump:hello.o的反汇编代码
hello_objdump:hello的反汇编代码
hello:可执行文件
参考文献
[1]深入理解计算机系统 Randal E.Bryant , David R.O’Hallaron机械工业出版社
[2]https://www.cnblogs.com/diyingyun/archive/2012/01/03/2311327.html 博客园
[3]https://blog.csdn.net/zhengqijun_/article/details/72454714 printf函数解析
[4]百度百科 getchar计算机语言函数
https://baike.baidu.com/item/getchar/919709?fr=aladdin