本文作为计算机系统的大作业,主要研究一个简单的程序hello的一生,分析从它被程序员写出到经历预处理、编译、汇编、链接以及最终执行的过程,也结合存储器分析了在执行过程中可能发生的中断与处理方式。在这个过程中对于计算机如何处理程序有了更深的理解。
关键词:计算机系统、简单程序、预处理、编译、汇编、链接;
目 录
2.2在Ubuntu下预处理的命令.......................................................................... - 5 -
3.2 在Ubuntu下编译的命令............................................................................. - 6 -
4.2 在Ubuntu下汇编的命令............................................................................. - 7 -
5.2 在Ubuntu下链接的命令............................................................................. - 8 -
5.3 可执行目标文件hello的格式.................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程..................................................... - 10 -
6.3 Hello的fork进程创建过程..................................................................... - 10 -
6.6 hello的异常与信号处理............................................................................ - 10 -
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 -
第1章 概述
1.1 Hello简介
关于Hello的P2P:作为一个计算机程序,hello被程序员以高级语言的文本写作,保存为.c文件,而后或是在编写程序的软件,或是在终端,程序员输入了编译运行的指令。于是,hello波澜壮阔的一生就开始了。它先经过cpp预处理器进行预处理,之后走到ccl编译器,将文本文件翻译为汇编程序,紧接着又马不停蹄来到汇编器as,将汇编语言翻译为机器语言指令,最后经过连接器ld链接,生成了另一个它——名为hello的可执行程序(是二进制文件)。然后,新的hello又继续着它打工人的悲惨生活,操作系统将新建一个进程并加载程序,hello完成了从一个程序到进程的转化。
关于Hello的O2O:加载运行前,hello在内存中位留有自己的痕迹,程序运行完毕后,也没有给内存留下最后一丝波纹,这就是hello作为程序的zero to zero。在完成编译链接后,二进制文件被shell的execve函数加载执行,它为hello分配了虚拟内存并映射到物理内存,之后开始运行hello文件。随着shell对程序员输入的./hello指令的读入,写入内存,读到\n判断输入结束,之后hello程序将出现在它未留痕迹的主存中,经过CPU对于机器语言的执行,再经历取指、从主存中取数等操作,最终在I\O设备之一的显示器上打出一行“hello world!”这也是hello程序一生中最高光的时刻,完成了自己的使命。最后,在关闭对话框进程结束之时,shell控制着进程回收函数杀死hello进程,为其他进程腾出空间。至此,hello函数完美谢幕,完成了使命,却未在主存中留下一丝痕迹。
1.2 环境与工具
1)硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
2)软件环境:Windows11 64位;VMware Workstation 16 player;Ubuntu 64位
3)开发和调试工具:gdb;edb;readelf;objdump;Code::Blocks20.03
1.3 中间结果
Hello.c:hello程序的高级语言源代码(文本文件)
Hello.i:经过预处理修改了的源程序(文本文件)
Hello.s:编译器翻译成的汇编程序(文本文件)
Hello.o:汇编器翻译汇编程序为二进制机器语言指令,这个文件是可重定位的目标程序(二进制文件)
Hello:调用Printf.o和其他库函数链接后得到的可执行文件(二进制文件)
1.4 本章小结
对hello程序的从预处理开始到执行结束的阶段做了简要的概括,对本大作业的实验环境和编译运行产生的中间结果做了列举和解释。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器根据以字符‘#’开头的命令,修改原始的C程序。比如hello程序开头的#include<stdio.h>命令在读取时就是将studio.h库中的内容插入到文本文件当中得到新的文本文件hello.i。
作用:可以进行转换处理,如将头文件源代码注入(#include)或者将A映射为B(#define)还可以进行条件编译,即有选择性地选择源代码。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i(输入的命令)或者cpp hello.c hello.i
可以看到程序有3060行,前面的行是添加的库函数的内容,从3047-3060行是输入的hello.c文本文件的内容
2.4 本章小结
本章讲了预处理的相关内容,介绍了预处理的概念和作用,对于hello.c文件进行了预处理操作并且发现格式符合预期。Hello.i中的#include已经消失,转而变为对应库中的源码插入了main函数前。
第3章 编译
3.1 编译的概念与作用
概念:编译是指编译器将文本文件hello.i翻译成文本文件hello.s的过程,其中hello.s包含一个汇编语言程序,其实就是从高级语言转到汇编语言的过程。
作用:将高级语言转化成更接近于机器语言的汇编语言同时,汇编语言可以看成介于高级语言和机器语言中间的一种便于理解的机器语言,它既与机器指令一一对应,又更便于人类理解。这也是为下一步汇编奠定基础。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s 或者/usr/lib/gcc/x86_64-linux-gnu/9/cc1 hello.i -o hello.s
(编译命令)
(编译结果展示)
3.3 Hello的编译结果解析
3.3.1数据
常量:无
变量:i(储存在虚拟内存栈中,具体位置为栈帧减32前的下四位。)如图:
可以看到rbp保存着栈帧rsp减32前的值,而后,将0存入那个位置,通过比较其与8的大小(小于等于)决定着是否开启循环,这与C语言代码中的i功能一致,所以rbp-4的位置存储着的值就是i。(如下图)
变量:argv[1],argv[2],argv[3]
由于调用了main函数,而main函数有两个参数,这两个参数的首地址分别存储在rdi和rsi当中,而argv是第二个参数,所以首地址理应存储在rsi中,后被存储在栈中,地址为rsp-32。由于指针占8字节,所以分别是rbp-32中存储的首地址+8,+16,+24。
变量:argc
开始时存储在rdi寄存器中,后存储在栈中rbp-20的位置。
3.3.2赋值
变量赋值一般通过修改存储变量的寄存器内部的值或者改变变量地址中存储的值来实现。如图是给变量i赋值:
可以看到将0赋值给了rbp寄存器中存储地址-4处地址内存储的值。也就是改变了i的值。
3.3.3类型转换
本代码中没有类型转换。
3.3.4sizeof
实验给出的C代码中不存在sizeof函数。
3.3.5算术操作
加减操作在汇编语言中用助记符addq和subq等表示。比如subl a,b就是只利用b中的数据减a中的数据并且将值存入b中。具体实现如图:
(减法)
(加法,执行i++操作)
3.3.6关系操作
C代码中出现了一处关系操作,即!=
高级语言代码本意是用来做if函数的一个判断条件若argc不等于4则执行printf函数输出。所以,在汇编语言中,编译器利用argc的值(这个值目前存储在rbp-20中)与4进行比较,利用je指令(相等即跳转)来实现条件控制。如图:
(若其中存储的值与4相同,则跳转到.L2处,对应不执行if中的值)
3.3.7数组、指针操作
1)对于数组的操作
由于数组存储数据具有连贯性,即数组申请的地址是一块连贯的地址。所以编译器对于数组的处理也是只将数组对应的首地址(即argv[0]的地址)存储在栈中,后续的地址根据对应变量的类型进行寻址。(比如int型占4字节则在首地址+4得到第二个数据的地址)本代码中数组的类型是指针类型的,占8字节,所以在数组首地址的位置上(rbp-32)加8、16、24得到argv[1],argv[2],argv[3]的值。
如图所示:、
(取argv[2]的值到rdx,取argv[1]的值到rax)
2)指针操作
本代码中对于指针部分的操作只有指针数据类型占八个字节。无实际的指针操作。
3.3.8控制转移
1)if条件判断
之前在3.3.6中提到过此部分,编译器采用比较——跳转指令来实现对于执行if括号内的内容还是外面内容的筛选。如图:
(此时在比较argc与4哪个更大,若相等则跳转.L2,即不执行if内的内容,否则执行if内的内容,即将存放的printf函数调用结果输出之后调用exit函数结束进程,否则,跳转到.L2位置,继续执行代码。)
2)for循环
C语言中,对于for循环的循环条件判断是否成立是利用i与9的关系来判别的。而在汇编器处理过的汇编语言中,首先对i进行了3.3.2所示的赋值操作,将0赋值给i,此处对应C代码的i=0;之后跳转到.L3执行判断操作,如图所示:
(若i小于等于8,则跳转到.L4执行循环内部的代码,否则跳出循环,执行getchar()函数,再用ret结束程序。)
由此可见,编译器处理后生成的编译语言也是完全按照C程序的流程进行的,之后进入循环,执行循环内代码后,编译器有没有正确地给i加1让其进入下一次循环呢?答案是有!观察如下汇编代码发现:
在执行完for循环内的一系列操作之后,执行了addl $1, -4(%rbp) 指令,即给存储i的地址中的内容进行加一操作,完成了i++。同时我们可以看到,下一条指令又是比较其与8的大小,若小于等于则进入循环,完成了for循环的构建!
3.3.9函数操作
经过上面的说明,我们已经知道了函数的前两个变量会存储在rdi和rsi寄存器中,而返回值会存储在rax中,调用时采用call指令进行函数调用。程序调用了很多函数,比如printf,getchar等。这里举一个清晰的例子,具体如下图所示:
汇编 C
在这个片段中,编译器调用了atoi函数,参数在rdi中传入,同时在调用结束时将eax中数据传递给edi中便于下次调用,这是因为sleep函数的参数即使atoi函数的返回值,所以更加确定了上述的结论。
3.4 本章小结
本章介绍了汇编器是如何对一个C语言进行编译操作的,并着重解释了编译器完成各种操作与C语言的对应关系,了解了编译器生成.s文件的指令和方法。
第4章 汇编
4.1 汇编的概念与作用
概念:汇编器as将hello.c翻译成为机器语言指令,打包成一种叫做可重定位目标程序的格式并将结果保存在hello.o中。它是一个二进制文件。
作用:将汇编语言转化为机器语言,变成二进制文件,为下一步链接做准备。
4.2 在Ubuntu下汇编的命令
gcc hello.s -o hello.o 或者as hello.s -o hello.o
(指令)
(汇编成功后获得的.o文件)
4.3 可重定位目标elf格式
可重定位目标文件一般包含一下几个节:
.text:已编译程序的机器代码
.rodata: 只读数据
.data: 已初始化的全局和静态C变量
.bss: 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量
.symtab:一个符号表,其存放在程序中定义和引用的函数和全局变量信息
.rel.text: 一个.text节中位置的列表
.rel.data: 被模块引用或定义的所有全局变量的重定位信息
.debug:调试符号表
.line: 原始C源程序中的行号和.text节中机器指令之间的映射
.strlab: 一个字符串表,其中包括.symtab和.debug节中的符号表
查看ELF头获得信息。内部存在着头的大小等信息。
此图为可重定位目标文件的头表。其中程序头显示了13个程序头/段,显示各段在虚拟地址空间中的大小、位置、标志和访问授权等信息。Section to Segment mapping显示的是节到段的映射(哪些节需要载入到哪些段),一个段可以包含多个节。
上图利用readelf -S hello.o命令列出了节头表的信息。每个节的具体含义前面已经解释过。
(重定位节文件的内容)
4.4 Hello.o的结果解析
(上图是通过反汇编命令反汇编得到的汇编代码)
与之前hello.s 中的代码作比较,不难发现,该代码采用十六操作数进行地址计算并采用相对寻址(即采用rip寄存器中存储的PC值加当前形势地址的寻址方法)而原代码采用的是十进制操作数以及jump,.L指令进行寻址。而且,这样通过反汇编得到的代码有一段机器代码,与每一个操作是一一对应的关系,这大概与汇编语言是一一对应的。
除此之外,在调用函数的方法上二者也有不同。汇编语言采用call指令来对函数进行调用,而反汇编得到的代码却是利用相对寻址来调用函数。原因是hello.s中调用的函数都是共享库中的函数,故需要通过等待调用动态链接将重定位的函数目标地址链接到共享库程序中,最终需通过动态链接器确定函数的运行时地址。
4.5 本章小结
本章主要介绍了汇编的概念以及作用,分析了可重定位目标文件中各节的位置和作用,并在终端上利用指令有所体现,之后又对比了由二进制文件反汇编得到的汇编代码与hello.s代码的不同之处和大多数相同之处,证明了汇编语言与机器语言具有一一映射的关系。
第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的格式
(ELF程序头部表)
Hello的ELF 格式为:从上至下依次为:ELF头、段头部表、.init段、.text段、.rodata段、.data段、.bss段、.symtab段、.debug段、.line段、.strtab段、节头部表。
各段的基本信息见上图
(段节)
(重定位节)
当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。
(符号表)
存放在程序中定义和引用的函数和全局变量信息。每个符号表是一个条目的数组,每个条目包含value、size、type、bind等信息与hello.o不同,hello可执行文件有49个符号,多出了一些产生的库函数和必要启动函数。
5.4 hello的虚拟地址空间
如图所示,hello的虚拟地址从0x401000开始,在0x401fff结束。
与上述反汇编程序对照,发现机器代码相同,更加确认从0x00401000开始的。
(在edb上面的代码上也可看到)
同时,.text节的首地址在0x401090,如下图:
此地址下存储的是汇编代码,符合.text节定义。
最后,代码在4011e4地址返回,在此结束。
这些地址也可以在5.3的截图中体现:
显示.text节大小为145,偏移量为1090,而hello程序在edb看到的虚拟内存中从400000处就开始存储,401090加载.text节,至4011d4结束,符合大小为145.综上,二者显示信息相同。
5.5 链接的重定位过程分析
利用objdump -d -r hello指令得到的反汇编文件如上图。
(hello.o反汇编得到的文本)
经分析可得,hello.o的反汇编文件的虚拟地址是从0开始的,而hello文件经过反汇编得到的虚拟地址是从0x401000开始的,所以,经过链接以后,可以得知hello文件的虚拟内存地址是已经确定的,而hello.o的虚拟地址是等待确定的。
同时,二者还在重定位问题上有不同,hello.o反汇编得到的代码中有很多重定位条目,而这些在以hello为源文件进行反汇编得到的代码中并不存在,可见经过链接这一步骤,已经完成了使每一个指令和全局变量都有唯一内存地址的目标,已经使每个符号指向了正确的运行地址。
最后,我们发现hello反汇编成的文件中多出来好多其他节,而hello.o反汇编得到的文本文件中只存在main函数的汇编指令。这是因为在链接中引入了很多其他库和函数,使反汇编文件中多出了许多其他的函数和指令。
关于hello对hello.o的重定位
重定位概念:main函数中涉及重定位的指令的二进制代码被修改。在之前汇编的过程中,汇编器遇到对最终位置未知的目标引用,会产生一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。因此在链接的过程中,链接器会根据重定位条目以及已知的最终位置对修改指令的二进制码,这个过程就是重定位的过程。
那么,关于hello.o函数我们观察其反汇编代码可以发现,其分为两种形式的重定位:R_X86_64_PC32和R_X86_64_32。二者看似只有一点差距,实则有很大不同。第一种被称作重定位PC相对引用,就是指在.text节的位置开始偏移xx地址。而第二种绝对引用就是引用到常量的绝对地址(因为要传数据,但是最终定位未知,所以要重定位,改为绝对地址)
5.6 hello的执行流程
载入:
_dl_start
_dl_init
开始执行
_start
_libc_start_main
_init
执行main:
_main
_printf
_exit
_sleep
_getcha
_dl_runtime_resolve_xsave
_dl_fixup
_dl_lookup_symbol_x
退出:
exit
5.7 Hello的动态链接分析
在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据没有被合并到hello中。加载hello时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。
动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射。使用偏移量表GOT+过程链接表PLT实现函数的动态链接。GOT中存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用。
(dl_init前)
(dl_init后)
执行dl_init前可见上个图,有很多地址为空,执行之后发现地址发生了变化,有了相应的填充。
5.8 本章小结
本章概括了链接的概念与作用,并且详细分析了hello.o与hello的elf格式区别,分析了hello的虚拟地址空间,重定位、链接流程,解析了程序运行的全过程,最后解析了动态链接及其实现方法。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个运行种的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
6.2 简述壳Shell-bash的作用与处理流程
Shell:Shell是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行
Shell的功能:实际上Shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,Shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。Shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的Shell程序与其他应用程序具有同样的效果
Shell的处理流程:shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。
6.3 Hello的fork进程创建过程
函数通过调用fork()函数通过父进程创建一个子进程,这个子进程与父进程具有相同的存储单元的备份但是操作互不干扰。子进程可以打开父进程的任何文件。如图是在linux下创建子进程的过程命令:
6.4 Hello的execve过程
Execve,即加载,一个新的进程创建完成之后要经过加载才可以启动运行。执行加载程序时,会依次像栈内压入环境变量envp全局变量、以空指针结尾的argv数组(存储着输入的命令,如输入 gcc -S hello.i -o hello.s则会从低到高依次存储gcc、-S、hello.i、-o、hello.s),再接着是libc_start_main的栈帧、main的未来的栈帧。从总体上来说,execve包含着四个主要步骤,即:删除已经存在的用户区域、映射私有区域、映射共享区域、设置程序计数器。
6.5 Hello的进程执行
上下文:上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器内容、程序计数器、环境变量以及打开文件描述符的集合。
在进程执行的过程中 ,内核为每一个进程维持一个上下文,在进程执行的某些时刻,内核可以决定抢占目前的进程,重新开始一个先前被抢占的进程,这就是进程上下文切换。
所以,在进程执行的过程中,开始时在用户模式运行当前程序,某一时刻操作系统内核发送中断信号,使用户模式的进程终止,执行内核模式中的程序,执行完毕后返回继续执行;或者是进行上下文切换,执行先前被中断的进程……由于进程上下文的不断切换构成了进程的时间片,运行的整个过程被切分为时间片,各个进程交替利用CPU资源,共同完成进程的执行。
6.6 hello的异常与信号处理
(以下格式自行编排,编辑时删除)
可能会遇到ctrl-c,ctrl-z等异常命令,执行过程中可能遇到中断、陷阱和系统调用、故障、终止四种异常。会产生的信号如下:
进程运行过程中,乱按键盘会产生一些奇妙的操作,如图:
在进程的运行过程中输入ctrl+z,进程会进入中止状态,注意,是中止而非结束。因为在输入ps命令后可以发现进程没有被回收而是在后台被挂起。如图:
输入fg可将该进程重新载入前台,如图:
该进程又在继续运行。
那么,在运行的时候在键盘上打出ctrl+c,又当如何呢?
如图:
可以看到,进程直接停止了,并且用ps命令查看后台也没有发现挂起,说明进程真的不存在了。
(运行pstree的结果)
那么,kill函数也是杀死进程,如果用kill函数杀死进程,其是否还会在后台中出现呢?如图:
可以看到,kill函数并不能终止后台程序,而fg 1可以。这说明了kill命令并不会使父进程使用waitpid函数等待子进程终止并回收,回收操作是命令fg 1触发的。
关于乱按键盘的后果:
可以看到,在执行过程中乱按键盘并不会使程序产生错误,只是会显示出一些字符罢了。
6.7本章小结
本章首先对进程的概念和作用做出了说明,接着解析了加载运行程序函数的工作原理,最后经过实践探究了不同的信号和输入对正在执行的进程的影响,分析了相应的处理机制。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址指的是在汇编代码中通过偏移量+段基址得到的地址,与物理地址不同。在hello反汇编代码中我们能够看到的就是逻辑地址。
线性地址:线性地址就是虚拟地址。
虚拟地址:虚拟地址是逻辑地址计算后的结果,同样不能直接用来访存,需要通过MMU翻译得到物理地址来访存。在hello反汇编代码计算后就能得到虚拟地址。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有一个唯一的物理地址。第一个字节的地址为0,写下来的字节地址为1,再下一个为2,以此类推。虚拟地址通过MMU翻译后得到物理地址。在hello中通过翻译得到的物理地址来得到我们需要的数据
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段标识符:段内偏移量组成。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。
所有的段由段描述符描述,而多个段描述符能组成一个数组,我们称成功数组为段描述表。段描述符中的BASE字段对我们翻译线性地址至关重要的。
BASE字段,表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。
为了得到BASE字段,我们利用索引号从GDT(全局段描述表)或LDT(局部段描述符表)中得到段描述符。选择GDT还是LDT取决于段选择符中的T1,若T1等于0则选择GDT,反之选择LDT。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址即hello程序虚拟地址空间中的虚拟地址,虚拟内存空间与物理内存空间都被划分为页,与页号相对应。虚拟地址由虚拟页号 + 虚拟页偏移量组成。页表是建立虚拟页号与物理页号映射关系的表结构,页表项包含有效位、物理页号、磁盘地址等信息。虚拟页号 + 页表起始地址能找到相对应的页表项,页表起始地址存储在页表基址寄存器中,页表项存储的页表状态有三种:未分配,已缓存,未缓存。当对应状态为已缓存时,说明虚拟页所对应的物理页已经存储在内存中,此时页表项存储的物理页号 + 物理页偏移量即为物理地址,而物理页偏移量与虚拟页偏移量相同,可以从虚拟地址中直接得出。当页表项中状态为未缓存时,若要读取该页,会引发缺页中断异常,缺页异常处理程序根据页置换算法,选择出一个牺牲页,如果这个页面已经被修改了,则写出到磁盘上,最后将这个牺牲页的页表项有效位设置为0,存入磁盘地址。缺页异常程序处理程序调入新的页面,如果该虚拟页尚未分配磁盘空间,则分配磁盘空间,然后磁盘空间的页数据拷贝到空闲的物理页上,并更新页表项状态为已缓存,更新物理页号,缺页异常处理程序返回后,再回到发生缺页中断的指令处,重新按照页表项命中的步骤执行。
7.4 TLB与四级页表支持下的VA到PA的变换
多级页表可以减小翻译地址时的时间开销。多级页表中,页表基址寄存器存储一级页表的地址,1到3的页表的每一项存储的下一级页表的起始地址,4级页表的每一项存储的是物理页号或磁盘地址。解析VA时,其前m位vpn1寻找一级页表中的页表项,接着一次重复k次,在第k级页表获得了页表条目,将PPN与VPO组合获得物理地址PA。
7.5 三级Cache支持下的物理内存访问
三级Cache是介于CPU和主存储器间的高速小容量存储器,由静态存储芯片SRAM组成,容量较小但比主存DRAM技术更加昂贵而快速,接近于CPU的速度。
通过以上对逻辑地址到线性地址到物理地址的转换,现在我们已经拥有了我们需要访问的物理地址,接下来就是去找到他。Cache的访问是按照分块策略来进行的。总的来说,对于某一级Cache的查找,思路是这样的:如果我们需要的数据块当前已经缓存在Cache之中,那么在当前级直接取出,也就是Cache命中,否则就前往下一级寻找,也就是缓存不命中。并且,在不命中时会发生Cache中数据的替换,替换策略则有很多种,与Cache的相连方式以及写入方式都有关
7.6 hello进程fork时的内存映射
当 fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给 它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只 读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
加载并运行可执行程序hello,要执行以下四个步骤:
1)删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存 在的区域结构。
2)映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结 构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名 文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长 度为零。
3)映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链 接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4)设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程 上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障:指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生缺页故障。
缺页中断处理:缺页处理程序从磁盘中加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,响应的物理页面已经驻留在内存中,指令就可以没有故障地完成了。
7.9动态存储分配管理
动态内存管理一般由动态内存分配器维护,分配器有两种风格,显示和隐式,两者的区别在于前者要求应用显式的释放任何已分配块,而后者则自动检测一个已分配块何时不再被程序使用。不过两者都要求显式地分配空闲块。
我们可以将堆组织为一个连续的已分配块和空闲块的序列,这就是隐式空闲链表,如图:
分配器可以通过遍历堆中的所有块,从而间接地遍历整个空闲块的集合。注意,我们需要以某种特殊标记结束的块,在这个小例中,就是一个设置了已分配位而大小为零的终止头部。
关于策略:内存分配器在malloc时需要寻找合适的空闲块,这时就有不同的策略。可以有首次适配,即从头开始搜索,选择第一次遇到的合适的块;下一次适配,略有不同,这种方法从上一次查询结束的地方开始搜索,而不是每次都从头开始搜索。
7.10本章小结
本章主要介绍了一个程序是如何组织存储器的,包括从程序所使用的地址开始,一步步了解虚拟地址、物理地址和逻辑地址三者之间的映射,了解了intel的段式管理,掌握了程序利用各级cache的原理,如何处理缺页故障以及缺页中断方式,最后阐述了对于动态内存管理的看法。
结论
源程序编写:程序员用高级语言写下程序,命名为hello.c。
预处理:预处理器根据以字符‘#’开头的命令修改源程序后,生成hello.i文件向下传递。
编译:编译器将文本文件hello.i翻译成文本文件hello.s,完成了从高级语言到汇编语言的转化。
汇编:汇编器将hello.s翻译成机器语言指令,生成可重定位目标程序hello.o
链接:链接器进行符号解析、重定位等操作后生成的二进制文件hello,这是一个可执行目标文件。
Fork进程创建:生成了可执行程序后,如果想执行hello程序,首先要给他一个发挥的舞台——进程。输入运行程序的命令后,shell会利用fork建立一个新进程供hello发挥。
Execve:加载可执行目标文件,进入hello程序入口点,准备开始执行。
运行:在这个阶段,程序终于在进程中运行起来,这个过程由OS进行进程管理,在不同的进程和系统内核之间来回切换,并且对其他进程或者进程运行本身产生的中断信、异常信号等进行处理,同时协调各内存和缓存、磁盘等存储器完成调用指令和执行的操作。
结束进程:hello运行结束,shell回收进程,内核删除hello在内存中留下的所有痕迹,hello的光辉一生就结束了。
感悟:
通过本次实验和对于hello一生各部分内容的精细体验与关注,竟发现即使是在高级语言范畴上看起来如此简单的一个程序在计算机进行编译执行的时候也是如此困难。要经历各个硬件、操作系统、其他程序的共同努力才能完成程序的执行,最终将结果显示在屏幕上。所以,对于一个如此简单的程序尚且要这么冗余的过程,那么对于那种在高级语言上就复杂的程序,软硬件协调配合的难度就更加巨大了。站在一个程序员的角度上看,这更加要求我们了解计算机的内部配置,编写出更适合计算机运行的代码,而不是更容易写出的代码。只有不断对代码进行优化,才能在复杂冗余的加载执行过程中最大限度地发挥计算机的性能。
附件
Hello.c:hello程序的高级语言源代码(文本文件)
Hello.i:经过预处理修改了的源程序(文本文件)
Hello.s:编译器翻译成的汇编程序(文本文件)
Hello.o:汇编器翻译汇编程序为二进制机器语言指令,这个文件是可重定位的目标程序(二进制文件)
Hello:调用Printf.o和其他库函数链接后得到的可执行文件(二进制文件)
ans.txt: hello.o反汇编代码
asm.txt:hello 反汇编代码。
参考文献
[1] http://t.csdn.cn/mfqfN 简单的exceve流程
[2] http://t.csdn.cn/Kj2Cd 可执行文件的运行
[3] http://t.csdn.cn/xxxvI 计算机系统大作业
[4] 深入理解计算机系统(原书第三版)
[5] http://t.csdn.cn/QRW3r gcc编译报错
[6] http://t.csdn.cn/sp5Fn 深入理解计算机系统:虚拟存储器(5)动态内存分配