摘 要
hello.c将要完成从一个.c文件到可执行文件的转变,经历过预处理、编译、汇编、链接的修修改改,缝缝补补,成为能被机器加载到内存的可执行文件。在终端输入./hello后,shell为其调用fork创建子进程,调用execve映射虚拟内存,mmap为其创建虚拟空间,MMU为其翻译地址实现访存,最终却仿佛一切都没有发生过,hello的痕迹从这个机器中被抹除,在数字变化间体会计算机系统的工作原理,在文件转换间挖局hello的秘密,这些精确的步骤相联后会产生怎样的魔法,hello的人生又是怎样的精彩,跟着本文一起探究,发掘。
关键词:hello;linux;计算机系统;进程;内存;地址
第一章 概述
1.1 Hello简介
P2P: From Program to
Process
hello.c,一个c文件(Program),在真正成为一个程序被机器执行之前,还要先经过预处理,编译,汇编,链接后,才能变成一个可执行目标文件。在终端输入./hello,shell调用fork函数,产生子进程,这个时候才真正的完成P2P的过程。
020:From Zero-0 to Zero-0
接下来shell调用execve,映射虚拟内存,mmap为其创建了一个虚拟地址空间,进入程序入口后程序开始载入物理内存,将代码段和数据段都写入到虚拟内存里面,然后进入main函数执行目标文件。进程给它一个假象,好像它一个人独占了整个内存和CPU,CPU不断从代码段中读取指令,并设置PC,仿佛全世界都是为它服务,hello从0到1,拥有了很多,但是随着hello的运行,期间可能会发生很多意外,调度器为其划分时间片,也可能被其他进程抢占,进行上下文切换,若发生异常,还会触动异常处理程序,验证的话还可能直接被终止。
hello小心翼翼地运行着,PC终会取被设置到最后一行指令的地址,当最后的指令执行完,hello也就终止了,shell回收子程序,内核删除所有相关数据,一切从1又到了0。这就是整个020的过程。
1.2 环境与工具
硬件环境: Intel Core i7-8550U @1.80GHz 四核, x64CPU ,8G RAM
, 256 SSD + 500G Disk
软件环境:windows 10 , Vmware
Workstation 15 PRO, Ubuntu 18.04.1 LTS
开发和调试工具:gcc , codeblocks , gedit , readelf
, edb ,
1.3 中间结果
1.4 本章小结
简单的介绍了hello准备要经历的人生,也是我们接下来要说明的事情,也介绍了一下硬件和软件环境,在这样的环境下,hello的一生又是怎样的跌宕起伏,接下来就一探究竟吧。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器(cpp)会根据以字符#开头的命令,修改原始的C程序文件,包括删除注释,处理宏定义(#define),读取系统头文件的内容(#include)并将其插入到程序文本中(例如#include <stdio.h>)。
作用:
-
删除注释如‘//’‘/* */’
-
读取#include <文件名>中文件的内容,并把它直接插入到程序文本中
-
删除#define,将符号常量全部进行替换,例如#define PI 3.1415926,将所有的PI换成3.1415926
-
生成.i文件,这个.i文件才是一个完整的能够被编译的c程序文件。
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
通过gedit 打开生成hello.i文件,查看里面的内容
.i文件一共3113行,我们的main程序在文件的最末尾出,可以再看一下前面的内容
开头是一些系统头文件的路径,根据预处理命令的功能,预处理器已经把系统头文件的内容插入到.i文件中了,其中包括声明一些变量,结构体等等,头文件中的内容通过递归展开被包含进该文件中。这就是为什么.i文件能有3000多行的原因,这才是一个完整的能够被编译器进行编译的程序文件。
2.4 本章小结
我们所打的hello.c程序并不是机器能直接识别的程序文件,它还有其他很多的组成成分,就像是在一道美味的佳肴,不能简单的直接下锅,而配合其他佐料,才能完成美味的升级,预处理就是这样一个过程,将所有的准备工作的做好,标注该删的删,符号常量该换的换,头文件该插入的插入,为下一步编译做好准备。
第3章 编译
3.1 编译的概念与作用
概念:编译的过程主要是编译器将文本文件hello.i翻译成hello.s,其中步骤包括语法分析,语义分析,实现代码优化,生成汇编代码文件。
作用:将高级语言编写的程序翻译成机器语言也就是汇编代码的形式。
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
通过编译后,生成了hello.s文件,可以看到.s文件和我们的使用的高级语言程序有很大的不同,它将我们的高级语言翻译成了汇编语言,我们可以通过汇编指令读懂这些汇编代码,并对应回我们的高级语言。
3.3.1 数据
这里有包含了几个信息
字符串: “Hello %s %s\n”,是第二个printf传入的输出格式化参数
globl:
main,main被定义为强符号,属于globl
整型:%edi中存的就是第一个参数int argc。int i则是局部变量存在栈中,在rbp-4的地址处。
数组:%rsi中存的是第二个参数char *argv[]
3.3.2 赋值
这段代码对应于for循环中的i=0的操作
3.3.3算术操作
对应于i++ ,令i每次循环都+1
3.3.4关系操作
这里对应操作argc !=4,程序判定argc是否等于4
这里对应操作i <8,程序判定i是否小于8,在汇编代码里的操作则是判断i是否等于7,这样就可以不用再进行i++的这一步操作了,稍微简化了操作
3.3.5数组/指针操作
这张图的中的汇编代码是对应取argv[1]和argv[2]的值。
其中 rbp-32对应的是argv的首地址,将首地址给rax,rax再+16取到argv[2]的地址,在通过(rax)取值得到argv[2]的字符串,接下来的取argv[1]和argv[3]的过程都是一样的。
3.3.6控制转移
控制转移操作的实现在C语言代码中有if else ,while , do while , for ,switch等,
在汇编代码中则通过cmp和jmp这两个系列的语句实现
hello.c中的控制转移操作有两个一个是if ,第二个是for
下面两张图表示的是if的实现,在汇编代码里,通过cmpl先比较argc和4的大小,并设置条件码ZF,je则通过条件码ZF判断是否进行跳转到L2
下面两张图对应的是for循环的实现,在L2中,先对i进行初始化,令i=0,在通过jmp指令直接跳转到L3中,先对i进行判断,看看是否满足循环的条件,满足则进入循环,循环里的操作大部分都是我们之前在3.3.5中讲的数组取值的操作,最后在L4的尾部,会进行一次(rbp-4)+1的操作,对应于i++,然后再进行循环判断
3.3.7函数调用
函数是过程的一种,过程提供了一种封装代码的方式,用一组指定的参数和可选的返回值实现某种功能。然后,可以在程序中不同地方调用这个函数。
运行时栈
C语言过程调用机制的一个关键特性在于使用了栈数据结构提供的后进先出的内存管理原则。程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存空间所需要的信息,比如局部变量就存在栈中。在调用一个函数的时候程序需要为这个函数创建一个栈,比如在main函数的汇编代码中我们就可以看到push操作,然后是movq %rsp,%rbp这个操作已经是在给main函数创建一个私有的栈帧,然后subq $32,%rsp则是在给栈帧分配空间,将寄存器中存的值存在栈中。
转移控制
将控制从调用函数中转移到被调用函数只需要简单地把程序计数器(PC)设置为被调用函数代码的起始位置。不过,从被调用函数返回的时候,处理器必须记录好它需要继续执行调用函数的执行的代码位置,所以在创建被调用函数的栈帧前,会先把被调用函数的下一条指令的地址也就是返回地址先压栈,所以返回地址就会在rbp的上面(高地址)。我们会通过call指令调用函数,对应的ret指令会从栈中弹出返回地址,并将PC设为返回地址。
数据传送
当调用一个函数时,我们还可能需要把数据进行参数传递,还可能从函数中返回一个值。在x86-64中,大部分函数间的参数传递和返回值都是通过寄存器实现的。例如本题中,寄存器%rax是存返回值的,寄存器%rdi和%rsi存的是第一个参数argc和第二个参数argv。
通过以上的几个步骤,简单分析一下hello程序中涉及的函数调用
main函数:
传递控制:main函数虽然作为主函数,同样是要通过call指令才能调用的,它是被系统启动函数调用的。
创建栈帧:在一开始的时候就已经将rbp压栈,并使rbp = rsp,rsp-32,开辟了一个空间用于main函数参数和局部变量的数据存储。
结束的时候,先设置返回值0给寄存器%rax,通过leave指令,清空栈帧,然后ret返回,将PC设置为返回地址。
puts函数:
hello.c中是没有调用puts函数的,但是在编译器在编译的时候,帮我们做了一些优化,将第一个printf函数改成了puts函数,因为第一个printf函数输出的内容是字符串常量,所以编译器在编译时就选择用puts函数输出。
-
传递数据:在调用之前,通过leaq指令将这个字符串常量的首地址给了寄存器%rdi。
-
控制传递:call puts@PLT
printf函数
传递数据:通过leaq指令,将printf()中的字符串的起始地址给寄存器rdi
控制传递:call
printf@PLT
atoi函数
传递数据:将argv[3]的首地址计算出来后,再去值给寄存器rdi
控制传递:call atoi@PLT
sleep函数
传递数据:将atoi函数的返回值给寄存器edi,edi是rdi的32位。
控制传递:call
sleep@PLT
getchar函数
传递控制:call
getchar@PLT
3.4 本章小结
本章阐述了编译器是如何处理C语言中的各个数据和各种操作的,对hello.c和hello.s两两进行对照。
或许一开始都会感到很迷惑,我们既然是用各种语言写代码,经过预处理的的添加后,还需要再经过编译器翻译成汇编语言,而且对人来说汇编语言明显比高级语言难读,第一眼望去,看着基本都是一样指令,不停的mov传值,一会给这个寄存器,一会给那个,还有不同的寻址方式,一会立即数寻址,一会间接寻址,简简单单的一段循环在汇编里面就变成了复杂难读的汇编代码,不仅枯燥还不太美观。当然这是对于我们程序员来说的,但是对于机器来说,机器读不懂我们的高级语言,必须经过翻译成机器语言才能执行,或许程序都是需要通过复杂繁琐的工序才能真正获得新生。
第4章 汇编
4.1 汇编的概念与作用
汇编阶段:汇编器(as)将.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件.o中。
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
ELF可重定位目标文件格式
使用readelf查看elf文件
ELF头:以16B的序列Magic开始,Magic描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包括帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表(section
header table)的文件偏移,以及头部表中条目的大小和数量等信息
Section
Headers:节头部表,包括文件中出现的各个节的语义,包括节的类型、位置和大小等信息。
重定位节:.rela.text节,一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标和其他文件组合时,需要修改这些位置。7条重定位信息分别对.L0、puts函数、exit函数、.L1、printf函数、sleep函数、getchar函数进行重定位声明。具体包含的信息如下图所示。
symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。其中可以看到倒数几行表示的是hello.c中的函数的信息,如main、puts、exit等
4.4 Hello.o的结果解析
使用如下命令分析hello.o的反汇编
我们可以比对一下这时候的反汇编和之前hello.s的汇编有什么区别。
首先,一眼看去很明显的区别就是反汇编出来的文件,在所有的汇编指令前面都有一个数字,这个数字是每个指令对应的地址偏移量,由于还没有进行链接,所以这些地址只是从0开始,并且每个偏移量后面还跟着16进制的数,这些就是传说中的机器指令,机器可以通过读取机器指令来执行操作。接下来再仔细对照其中的命令。
-
跳转指令
可以从下面两图中发现一个明显的区别,左图的je指令后面跟的是一个具体的数字,这个数字代表的就是要跳转的地址偏移量,而在右图中是还没有的,这是为了给后面链接后对符号重定位做准备。
-
调用函数
在左图中,call指令后跟着一个数字,而在右图中是没有的。这个数字的含义是下条指令的地址偏移量,因为调用函数的时候我们需要将返回地址压栈,所以这个数字对应的就是这个返回地址。
-
对全局变量和常量的寻址
从上下两图的对比可以看出,在上图通过间接寻址来找字符串常量的地址,这里比较特殊,因为是leaq指令,虽然是间接寻址但是没有进行内存引用,并且由于这个时候我们是不知道这个时候.rodata节的偏移量是多少,所以无法有确定的值,只能是0x0(%rip)。
-
hello.s中还有很多.开头的伪指令,在反汇编的文件中都没有了。
-
立即数变成16进制
左图反汇编的文件中操作数中的立即数变成了16进制。
4.5 本章小结
hello.s文件又经过了汇编处理器处理,变成了ELF可重定位目标文件,感觉变成了根本不能看的二进制文件了,这个时候才是机器真正能读取的指令了,但是,还没完呢,既然是可重定位目标文件,那还要进行重定位,反汇编出来的代码已经有这个苗头了,各个符号的偏移量已经给出,就等着给链接器进行链接重定位解析符号了。而我们也能通过反汇编查看机器指令和汇编指令之间的映射,更加深刻地去理解它到底是个什么东西,这里面有遵循着什么规则,又有什么神秘地方……
第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查看hello的elf格式
ELF头:描述文件的总体格式,包括程序的入口点,也就是当程序执行时要执行的第一条指令的地址。
节头:Section
Headers,对hello的各个节的信息做了说明,包括各个段的名字,大小,类型,地址,偏移,对齐要求等信息,其中地址是程序被加载时读入.text内容后在虚拟地址空间中的起始地址,一般都是从0x400000起始的。如下图所示。
程序头:
动态偏移表:Dynamic section
重定位节:
符号表:
5.4 hello的虚拟地址空间
运行时内存映像入下图:
通过edb打开hello程序
通过查看Data
Dump 可知程序在加载时读入的起始地址为0x400000
结束地址为0x400fff
接下里再对照之前的节头部分,找各个段的起始地址
只读代码段:.init 、.text、.rodata从0x4004c0开始到0x400690,其中程序入口起始点为0x400550
读/写段:.data、.bss,
5.5 链接的重定位过程分析
使用objdump -d -r 分别对hello和hello.o进行分析。
看到两个汇编代码,明显通过连接后的hello的反汇编中,不仅仅只有main函数,而且有了很多其他很多的函数,包括静态链接后的函数printf等,如下图(只给出了一部分代码截图)。但从这些代码中,我们可以明显地看出和hello.o反汇编出的代码有很大的区别。
符号解析和重定位完成:在可重定位的目标文件中,地址都是从0开始的,但在可执行目标文件的反汇编中,地址是直接从0x4004c0开始的,并且从下图中可以看出,leaq,call和je等指令后面都跟上了具体的数字,说明这个时候链接器已经完成了对符号的解析和重定位,所有的符号都已经确定下来,所以才能给出确定的地址偏移。
-
重定位:在调用链接器ld对可重定位目标文件进行链接时,和静态库一起进行链接,将一些需要的外部预编译好了的目标文件合并到我们的hello.o程序中。链接器会根据汇编器给出的重定位条目,在将目标文件合并成可执行文件时修改相应的引用。代码的重定位条目在.rel.text中,已初始化数据的重定位条目在.rel.data中。
其中最基本的两种重定位类型为:重定位PC相对引用,重定位绝对引用。
正是通过这两个基本的重定位类型,完成重定位符号引用,也正是我们所看到的,指令后面都有确定的地址偏移。
5.6 hello的执行流程
5.7 Hello的动态链接分析
可以加载而无需重定位的代码称为位置无关代码(PIC)
-
PIC数据引用
编译器通过运用一下这个有趣的事实来生成全局变量的PIC引用:无论我们在内存中的何处加载一个目标模块(包括共享目标模块),数据段与代码段的距离总是保持不变。因此代码段和数据段的绝对内存的位置事无关的的。
想要生成对全局变量PIC引用的编译器利用这个事实,它在数据段开始的地方创建了一个表,叫做全局偏移量表(GOT)。在GOT中,每个被这个目标模块引用的全局数据(过程或全局变量)都有一个8字节条目。编译器还为GOT重每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使它包含目标的正确的绝对地址。每个引用全局目标模板都有自己的GOT。
PIC函数调用
对于动态共享链接库中PIC函数,编译器没办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表(PLT)+全局偏移量表(GOT)实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
我们可以在节头找到.GOT和.GOT.PLT,根据地址在edb中进行观察。
刚进入程序时.got.plt的内容如下:
执行完后内容如下,发现GOT表多了两个地址所以
GOT[1]:0x7fc9b764d170
GOT[2]:0x7fc9b743b750
其中GOT[1]指向重定位表,GOT[2]指向动态链接器
在之后的调用函数时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递个木白哦函数。
5.8 本章小结
hello.o终于迎来了最后一步——链接。在链接的过程中,链接器对可重定位目标文件进行符号引用和重定位,将每个符号都放在确定的位置,还要和静态库链接,把printf函数真正加入到hello中,但还有一个叫共享库的家伙硬插一脚,完成最后的涅槃,动态链接器只在加载或运行时才真正的将共享库和程序链接,让hello等了好久才真正出世。
第6章 hello进程管理
6.1 进程的概念与作用
进程的经典定义就是一个执行中的程序的实例。每个进程都有自己的地址空间,一般情况下,包括文本区域、数据区域、堆和栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
进程给用户一种假象,好像当前运行的程序是当前运行的唯一的程序一样,独占处理器和内存,程序的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell的作用:shell是一个用c语言编写的程序,它是用户使用linux的桥梁。Shell指一种应用程序,shell应用程序提供了一个界面,用户通过这个界面访问操作系统内核的服务。
处理流程:
从终端读入输入的命令
对输入的命令进行解析成各个参数
判断是否有内置命令,有就立即执行
否则寻找是否有相应的可执行文件
通过调用相应的程序为其分配虚拟地址空间并运行
Shell应该接受键盘输入的信号,并对信号产生相应的反应
回收子进程(僵死进程)
6.3 Hello的fork进程创建过程
在终端输入命令:./hello后,终端会对输入的命令进行解析,根据语义./hello,终端会在当前目录下寻找可执行目标文件hello并调用fork函数为其创建一个新的运行的子进程,子进程得到与父进程的虚拟地址空间和父进程的是相同但又独立的的一份副本,即子进程可以读写父进程中打开的任何文件。父进程和子进程时并发运行的独立进程,在子进程执行期间,父进程默认选项是等待子进程终止。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。
execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。只有当出现错误的时候,例如找不到hello,evecve才会返回到shell中。execve加载了hello之后,它会调用一个叫启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的堆和栈会被初始化为0,通过将虚拟地址空间的页映射到可执行文件的页大小的片,加载器从可执行目标文件中读入.init /.text/.rodata/.data/.bss。然后开始执行。
6.5 Hello的进程执行
用户模式和内核模式:
处理器通常是用某个控制寄存器中的一个模式位来提供这种功能的,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式(又是也叫超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统的任何内存位置。
时间片:
一个进程执行它的控制流从一部分时间段叫做时间片。
上下文信息:
上下文就是内核重新启动一个被抢占的进程所需的状态,它右通用寄存器、浮点你寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
操作系统内核使用一种称为上下文切换的较高层次形式的异常控制流来实现多任务。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占的进程。这种决策叫做调度,是由内核中被称为调度器的代码处理的。
hello程序开始执行的时候,在调用sleep函数之前,如果hello程序被抢占,就进行上下文切换,
当调用了sleep函数时,进入内核模式,处理器处理休眠并请求主动释放当前进程。
计时器开始计时,内核进行上下文切换,执行其他的进程。
当计时结束时,计时器发送一个中断信号,处理器处理中断信号,并进行上下文切换,重新执行hello进程。
6.6 hello的异常与信号处理
异常可以分成四类:中断、陷阱、故障和终止
正常运行hello程序,当程序执行停止后,输入回车,被shell回收。
运行hello程序,未结束前输入ctrl-z停止进程,进程停止后,往命令行输 ps,可以看到hello还在后台,并未被回收。
再输入jobs,可以看到hello进程已经停止
再输入pstree,就打印出了所有的进程关系
再输入fg,进程重新变成前台,继续执行,并能被正常回收。
重新运行程序,未结束前输入ctrl-z使进程停止,再使用kill指令,由下图可以看出hello进程已经终止,进程被成功杀死。
重新运行程序,并在进程终止前输入ctrl-c,发现进程已经终止。
重新运行程序,并在运行过程中不停乱按,终端会显示乱按输入的字符,但不影响程序的正常运行,并且最后按回车被正常回收。
6.7本章小结
好不容易hello诞生了,但是成长的旅途不总是一帆风顺的,会遇到很多不同的异常,这些异常可能导致它中断,故障,被迫进行上下文切换,或是被其他进程抢占,甚至可能导致hello提前终止,生命的脆弱可见一斑。在hello程序运行的时候,hello也相当于是shell的子程序,由shell调用fork和execve函数运行,当hello终止的时候,还是会被残忍地回收,不然就浪费内存了。
第7章 hello的存储管理
7.1 hello的存储器地址空间
物理地址:在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址。计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。给定这种简单的结构,CPU访问内存的最自然的方式称为物理寻址。
逻辑地址:是指由程序产生的和段相关的偏移地址的部分。程序代码经过编译后出现在汇编中地址。逻辑地址由选择符(在实模式下是描述符,在保护模式下是用来选择描述的选择符)和偏移量(偏移部分)组成。
线性地址:逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式。分页机制中线性地址作为输入。
虚拟地址:实际就是线性地址。虚拟地址由cpu给出来访问主存。
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址如何转换为线性地址:
段式管理:逻辑地址à线性地址==虚拟地址
段寄存器,用于存放段选择符
代码段(CS):程序代码所在段
栈段(SS):栈所在段
数据段(DS):全局静态数据区所在段
逻辑地址由两部分组成:段标识符,段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号,剩下3位包含硬件信息。
TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)。
RPL=00,为第0级,位于最高级的内核态,RPL=11,为第三级,位于最低级的用户态,第0级高于第3级
高13位-8K个索引用来确定当前使用的段描述符在描述符表中的位置
环保护:内核工作在0环,用户工作在3环,中间环留给中间环留给中间软件用。
段描述符和段描述符表:
段描述符是一种数据结构,实际上就是段表项,分两类:
1.
用户的代码段和数据段描述符
2.
系统控制段描述符
描述符表实际上就是段表,由段描述符组成,有三种类型
全局描述符表GDT
局部描述符表LDT
中断描述符表IDT
逻辑地址向线性地址转换
被选中的段描述符先被送至描述cache,每次从描述符cache中取32位段内偏移量(有效地址)相加得到线性地址
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理:虚拟地址à物理地址
虚拟内存:虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组
页表:页表是一个页表条目(PTE)的数组,将虚拟页地址映射到物理页地址。
CPU给出线性地址给翻译器MMU,MMU将虚拟地址分成VPN和VPO
传统的地址翻译:通过VPN直接在页表中寻找PPN。
利用TLB加速的地址翻译,会在下一节中详细讲。
页命中:虚拟内存中的一个字存在于物理内存中,即(DRAM缓存命中)
缺页:虚拟内存中的字不在物理内存中(BRAM缓存不命中),通俗地讲就是内存中没有这个页,所以导致MMU找不到对应的物理地址。
缺页的处理:MMU触发缺页中断,缺页异常处理程序在内存中选择一个牺牲页,从其他存储器(磁盘)中读入这个页替换牺牲页,接着导致缺页的指令重新启动:页面命中。
当我们到想要的PPN后,需要再将物理页号PPN和物理页偏移PPO(也就是VPO,因为页大小相等,所以VPO和PPO相等)加起来就是物理地址了。
如下图,这里假设页大小为2^12=4KB
7.4 TLB与四级页表支持下的VA到PA的变换
利用TLB加速地址翻译:TLB被称为翻译后备缓冲器,其中每一行都保存着一个由单个PTE组成的块。通过这种方式我们可以再把VPN分成TLBT(TLB标记)和TLB索引(TLBI),根据索引和标记在TLB中寻找对应的PPN,TLB命中可以减少内存访问,就和之前的cache命中类似,这里少了行,也可以理解成一组只有一行,类似直接映射。如下图所示。
多级页表:
用来压缩页表的常用方法是使用层次结构的页表。
以二级页表为例:
一级页表:每个PTE指向一个页表(常驻内存)
二级页表:每个PTE指向一页
使用多级页表的地址翻译:
虚拟地址-页偏移量(VPO)=VPN,将VPN分成K个VPN,第K个VPN映射K级页表的标记,最终不断地映射下去,最终可以找到一个唯一的PPN,PPO+PPO(VPO)=物理地址,如下图所示
用TLB可以加速地址翻译但是TLB不命中时依旧会有不命中处罚。
利用多级页表的地址翻译可以进行十分大的物理地址翻译。
7.5 三级Cache支持下的物理内存访问
通用的高速缓存存储器组织结构:
考虑一个计算机系统,其中每个存储地址有m位,形成M = 2m个不同的地址。一个机器的高速缓存被组织成一个有S=2s个高速缓存组的数组。每个组包含E个高速缓存行。每个行是由一个B=2^b字节的数据块组成的,一个有效位指明这个行是否包含有意义的信息,还有t=m-(b+s)个标记位,它们唯一地标识存储在这个高速缓存行中的块。
当一条加载指令指示CPU从主存中地址A读一个字时,它将地址A发送到高速缓存。如果高速缓存正保存着地址A处的那个字的副本,就立刻返回那个字给CPU。
接下来我们讨论高速缓存如何获得对应的地址的字,只针对L1高速缓存详细讨论,因为L2和L3的运作过程和L1都是一样的。
直接映射高速缓存:行数E=1的高速缓存
直接映射高速缓存是最容易实现和理解的。
CPU执行一条读内存字w的指令,它向L1高速缓存请求这个字,如果L1高速缓存中有这个字的副本,就得到L1cache命中,高速缓存会很快抽取这个字并返回给CPU;若没有,则会导致cache不命中,L1就需要向下级L2请求这个字块替换它的一个牺牲块,再抽取这个字给CPU,这期间CPU必须等待。
高速缓存是否命中,然后抽取出被请求的字的过程分三步:1.组选择 2.行匹配 3.字抽取
组选择:
从w的地址中抽取出s个组索引位,这些位被解释成一个对应一个组号的无符号整数。如下图所示
行匹配和字选择:
我们已经选择了某个组后,还要确定有效位是否为1。若为0,则cache不命中;若为1,再判断标志位是否匹配,标志位不匹配,则cache不命中;若标志位匹配,则cache命中。
前面步骤都完成后,就要根据块偏移b找到对应的块。块偏移位提供了所需要的字的第一个字节的偏移。若下图所示。
Cache不命中时,我们要进行行替换:
如果cache不命中,那么它就需要从存储器层次结构中的下一层取出被请求的块,然后将新的块存储在组索引指示的一个高速缓存行中,这个时候我们需要选择一个牺牲行,对于直接映射来说,就只有一个行,就直接替换就好。
组相联高速缓存:
直接映射高速缓存只有一个行,可能会导致冲突不命中从而发生抖动,引起计算机性能大幅度降低。组相联高速缓存就是每个组保存有多于一个高速缓存行。一个1<E<C/B的高速缓存通常称为E路组相联高速缓存。
例如图就是E=2的情况,当然E可以大于2。
组相联高速缓存的组选择和直接映射高速缓存一样,在行匹配的时候会有区别,因为这个时候E>1,所以我们判断每一行的有效位,匹配有效位为1的标志位,接下里就和直接映射高速缓存一样了。
全相联高速缓存:
即E=C/B组成的高速缓存,只有一个组。
组选择和行匹配和字选择,都和前面无法原理一样。
7.6 hello进程fork时的内存映射
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mmu_struct、区域结构和页表的原样副本。它将两个进程的每个页面标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
- 删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构
2.映射私有区域,为新程序的代码、数据、bss、和栈区域创建新的区域,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data、.bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
映射共享区域,hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC),execve做的最后一件事就是设置当前进程上下文的PC,使其指向代码区的入口。
7.8 缺页故障与缺页中断处理
缺页故障是一种故障,当指令引用一个虚拟地址,MMU会查找页表,当找不到对应的物理地址时,就会触发缺页中断。
缺页:虚拟内存中的字不在物理内存中(BRAM缓存不命中),通俗地讲就是内存中没有这个页,所以导致MMU找不到对应的物理地址。
缺页的处理:MMU触发缺页中断,缺页异常处理程序在内存中选择一个牺牲页,从其他存储器(磁盘)中读入这个页替换牺牲页,接着导致缺页的指令重新启动,页面命中。
7.9动态存储分配管理
在程序运行时程序员使用动态内存分配器(比如malloc)获得虚拟内存
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块(blocks)的集合来维护,每个块要么是已分配,要么是空闲的。
每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显示地被应用程序所分配。
一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
动态内存分配器从堆中获得空间,将对应的块标记为已分配,回收时将堆标记为未分配。而分配和回收的过程中,往往涉及到分割、合并等操作。
分配器的类型:
1.显示分配器:要求应用显示地释放任何已分配的块。
2.隐式分配器:应用检测到已分配块不再被程序所使用,就释放这个块。
malloc和free函数
C标准库提供了一个称为malloc程序包的显示分配器。程序通过malloc函数从堆中分配块。
程序是通过调用free函数来释放已分配的堆块。
当请求一个K字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是放置策略确定的。
有三种常见的放置策略:
-
首次适配:从头开始搜索空闲链表,选择合适的空闲块。
-
下一次适配:从上一次查询结束的地方开始搜索。
-
最佳适配:检查每个空闲块,选择适合所需要请求的大小的最小空闲块
分割空闲块:一旦分配器找到一个空闲块,它必须做另一个策略决定,就是分配这个空闲块的多少空间。一是整个块,当容易造成较多的内部碎片;二是分割空闲块,但也可能造成较多的外部碎片。
合并空闲块:当释放一个已分配块时,可能和其他的空闲块相邻,这个时候我们可以选择将它们合并。分配器可以选择立即合并,也可以选择推迟合并。还有一种策略是带边界标记的合并,就是加一个脚部,这个脚部和头部一样,都储存着这个块的大小信息和分配情况。
7.10本章小结
真是生完娃后还得养娃,要让一个程序正常的运行并且实现它应有的功能,还需要在很多地方做工作。一个进程既然给它了一个虚拟地址空间,那么自然要有一种方法让它的虚拟地址能映射到物理地址,这样才能内存引用。我们有了地址翻译,还得要有良好的内存管理,要想办法使得程序在内存使用上变得高效,这才是我们想要的结果。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
文件就是一个字节序列。所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
1)打开和关闭文件。进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的。会调用close函数关闭一个打开的文件。Open函数将文件名转换为一个文件描述符,并且反汇描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件: O_ RDONLY:只读, O_ WRONLY只写。O_ RDWR可读可写。访问权限由unmask来实现
2)读和写文件:引用程序通过分别调用read和write函数来执行输入和输出的。
Read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf,返回值1表示一个错误,返回值0表示EOF,否则返回值表示的是实际传送的字节数量。
Write函数从内存位置buf赋值之多n个字节到描述符fd的当前文件位置。
3)查找文件,通过调用lseek函数,应用程序能够显示地修改当前文件的位置,这部分内容不在我们的讲述范围之内。
8.3 printf的实现分析
printf函数原型如下图
其中*fmt是格式化用到的字符串,而后面省略的则是可变的形参,即printf(“%d”, i)中的i,对应于字符串里面的缺省内容。
vsprintf函数的函数原型如下。
调用函数vsprintf的作用是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。Write则将vsprintf的输出逐个的写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用int 0x80或syscall。字符显示驱动子程序:从ASCII码到字模库,再到vram(存储每个点的RGB信息)。
8.4 getchar的实现分析
异步异常-键盘中断的处理: 当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断信号,中断信号请求抢占当前进程,运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码换成ASCII码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
对于printf和getchar的实现过程有了更清晰的理解,原来真正发挥作用的并不是这两个函数,而是这两个函数中调用的write和read系统函数,它们都是属于系统内核的函数,有点感觉像是在套娃。
(第8章1分)
结论
Hello程序经过了一系列的风风雨雨,这种改造,那种添加,总算了出生了,但是hello的一生也是很短暂的,它所经历的一切,在我们看来,也就是一瞬间。
从一开始hello.c,经过预处理变得完整的hello.i,在经过编译器的改造,变成了能够被机器所接纳读懂的hello.s,还要再通过汇编称为可重定位目标文件,将自己身上的每个部分都毫无保留的告诉机器,最后通过链接器,加上静态库,共享库一起链接,最终称为了可以被机器执行的hello可执行目标文件。
但是出生后的hello并不是就一帆风顺了,它还要通过shell爸爸调用fork函数为它创建进程,再调用execve函数给它调用启动加载器,成为一个真正在shell上下文运行的程序。成为程序后还有各种考验,要成为一个真正的能实现功能的进程还得能够实现进程的操作,还有可能被其他进程抢占,被停止,甚至被kill,社会太残忍了啊~
有了自己的虚拟地址空间后,还要靠翻译器MMU将虚拟地址翻译成物理地址,才能内存引用。还有堆和栈的管理,hello已经摇身一变成为了越来越让人难以看懂的东西了。。。但是不管怎么样,程序员还是会不断地去了解,想办法提高hello对内存的使用效率,无论是堆的管理,还是在代码上的优化,都不断地让hello进化,越来越满足人们的需求。
最后,完成使命的hello,还是被shell回收,内核删除这个进程创建的所有数据结构,正如它轻飘飘地走了,不带走一片云彩,却又会当我们在终端再次输入./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.