本文主要阐述hello程序在Linux系统的生命周期,借助edb、gcc等工具探讨hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件的全过程。同时比较全方面的涉及了Hello程序在其生命周期中可能出现的特殊情况以及处理方法等。
关键词:程序;进程;计算机系统;shell;预处理;编译;汇编;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述
1.1 Hello简介
Hello的P2P(From Program to Process)过程:在文本编辑器或IDE中编写C语言代码,得到最初的hello.c程序,即最初的Program。编译器驱动程序代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。驱动程序首先运行C预处理器(cpp),将C的源程序hello.c翻译成一个ASCII码的中间文件;然后运行C编译器(cc1)将中间文件翻译成一个ASCII汇编语言文件;之后运行汇编器(as)将汇编语言文件翻译成可重定位目标文件;最后运行链接器(ld)创建一个可执行目标文件hello。在shell中输入执行hello的命令,shell解析命令行,通过fork新建一个子进程来执行hello,这时Hello已经从Program转换为Process了。
Hello的020(From Zero-0 to Zero-0)过程:子进程调用execve,重新为hello进行内存映射,设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。进入程序入口后通过存储管理机制将指令和数据载入内存,CPU以流水线形式读取并执行指令,执行逻辑控制流。操作系统负责进程调度,为进程分时间片。执行过程中通过L1、L2、L3高速缓存、TLB、多级页表等进行存储管理,通过I/O系统进行输入输出。当程序运行结束后,shell回收hello进程,删除和该进程相关的内容,这时hello进程就不存在了。hello从开始的未被内存映射到运行再到回收后不再存在,就是020的过程。
2 环境与工具
X64 CPU;2.60GHz;16G RAM;256GHD Disk
软件环境
Windows10 64位
VM VirtualBox 6.1;Ubuntu 20.04 LTS 64位;
Visual Studio 2022 64位;CodeBlocks 17.12 64位;vi/vim/gedit+gcc
1.3 中间结果
hello.c 源程序
hello.o 汇编后的可重定位目标执行文件
hello1.txt hello.o的反汇编代码
hello.i 预处理后文件
hello 链接后的可执行文件
hello2.txt hello的反汇编代码
hello.s 编译后的汇编文件
hello.elf hello.o的ELF格式
hello1.elf hello的ELF格式
1.4 本章小结
本章简述了Hello的P2P、020的整个过程并介绍了实验的基本信息:环境、工具以及实验的中间结果。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:
预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。典型地,由预处理器(preprocessor) 对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译,预处理中会展开以#起始的行,修改原始的C程序。将所引用的所有库展开,处理所有的条件编译,并执行所有的宏定义,得到另一个通常是以.i作为文件扩展名的C程序。
预处理的作用:
1.宏的替换。将宏名(#define定义的字符串)替换为实际值(可以是字符串、代码等)。
2.文件包含。将c程序中所有#include声明的头文件复制到新的程序中。将头文件的内容插入到该命令所在的位置,从而把头文件和当前源文件连接成一个源文件。
3.条件编译。根据#if以及#endif和#ifdef以及#ifndef来判断是否处理之后的代码。
2.2在Ubuntu下预处理的命令
预处理的命令:gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
hello.i文件部分截图如下:
结果分析:
经过预处理后,hello.c被处理成为hello.i文件。打开文件后发现hello.i文件中文件内容大大增加,且仍为可阅读的c语言程序文本文件。hello.i文件对hello.c程序中的宏进行了宏展开,该文件包含了头文件中的内容。如果代码中有#define命令还会对相应符号进行替换。
2.4 本章小结
本章主要介绍了预处理的概念及其作用,给出了在Linux下预处理的指令,接着给出了hello.i文件的分析,了解了实际上一个预处理文件是怎样的,有了更加深入的理解
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译的概念
编译就是将源语言经过词法分析、语法分析、语义分析以及经过一系列优化后生成汇编代码的过程。这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
编译的作用
编译程序把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。
1.词法分析的任务是对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造成为单词符号串的中间程序。
2.编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位。编译程序的语法规则可用上下文无关文法来刻画。
3.中间代码是源程序的一种内部表示,或称中间语言。中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码,即为中间语言程序,中间语言的复杂性介于源程序语言和机器语言之间。
4.代码优化是指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。所谓等价,是指不改变程序的运行结果。所谓有效,主要指目标代码运行时间较短,以及占用的存储空间较小。这种变换称为优化。
5.目标代码生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。
3.2 在Ubuntu下编译的命令
编译的命令:gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1汇编初始部分
节名称 作用
.file 声明源文件
.text 代码节
.section.rodata 只读数据段
.globl 声明全局变量
.type 声明一个符号是函数类型还是数据类型
.size 声明大小
.string 声明一个字符串
.align 声明对指令或者数据的存放地址进行对齐的方式
3.3.2 数据
1)字符串
程序中有两个字符串,这两个字符串都在只读数据段中,如图所示:
hello.c中唯一的数组是作为main函数的第二个参数,数组的每个元素都是一个指向字符类型的指针。数组的起点存放在栈中-32(%rbp)的位置,被两次调用找参数传给printf函数。
如图所示,这两个字符串作为printf的参数。
2)局部变量
main函数声明了一个局部变量i,编译器进行编译的时候将局部变量i放入堆栈中。i被放置在栈中-4(%rbp)的位置,如下图所示。
3)参数argc
参数argc是作为用户传给main函数的参数。同样被放置到堆栈之中了。
4)数组char*argv[]
char*argv[]是main函数的第二个参数,数组的起始地址存放在-32(%rbp)的位置,数组中的每一个元素都是一个指向字符类型的指针,在内存中被两次调用穿给printf函数。
5)立即数
立即数直接体现在汇编代码中。
3.3.3全局函数
hello.c声明了一个函数int main(int argc,char *argv[]),通过阅读汇编代码我们发现此函数是一个全局函数,如图所示。
3.3.4赋值操作
hello中的赋值操作主要有:i=0,而这个操作在.s文件中汇编代码主要使用mov指令来实现。mov指令根据操作数的字节大小分为:
movb:一个字节赋值,movw:两个字节赋值,
movl:四个字节赋值,movq:八个字节赋值。
3.3.5算术操作
hello.c中的算数操作主要有i++,i是int类型,在汇编代码中用addl实现此操作。
3.3.6关系操作
1)hello.c中 “if(argc!=4)”,这是一个条件判断语句,在进行编译时,被编译为:cmpl$4, -20(%rbp)。比较后设置条件码,根据条件码判断是否需要跳转。
- hello.c源程序中的for循环条件是for(i=0;i<8;i++),该条指令被编译为cmpl$7,-4(%rbp)。同样在判断后设置条件码,为下一步的jle利用条件码跳转做准备。
3.3.7控制转移指令
汇编语言中设置了条件码,然后根据条件码来进行控制程序的跳转。通过阅读hello.c 的汇编代码,我们发现有如下控制转移指令。
1)判断argc是否等于4。如果等于4,则不执行if语句;反之if不等于4,则执行后续的语句,对应的汇编代码为:
2 ) for循环中,每次都要判断i是否小于8来决定是否继续循环。先对i进行赋初值,然后无条件跳转至判断条件的.L3中,然后判断i是否符合循环的条件,若符合则直接跳转到.L4中。这一部分的汇编代码为:
3.3.8函数操作
hello.c中涉及的函数操作主要有以下几个:main,printf,exit,sleep,和getchar函数。main函数的参数是argc和*argv,printf函数的参数是字符串,exit函数的参数是1,sleep函数的参数是atoi(argv[3])。所有函数的返回值都会存储在%eax寄存器中。函数的调用与传参的过程是给函数传递参数需要先设定一个寄存器,将参数传给这个设定的寄存器后,再通过call来跳转到调用的函数开头的地址。
3.3.9类型转换
hello.c中的atoi(argv[3])将字符串类型转换为整形。int、float、double、short、char之间可以进行相互转化。
3.4 本章小结
本章主要介绍了编译的概念及其作用,以及在linux下编译的指令,最后我们根据hello.c文件的编译文件hello.s文件中的汇编代码详细的解析了数据类型,各类操作是如何实现的。经过这一章,最初的hello.c已经被转换成了更底层的汇编程序。(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
1.汇编的概念
汇编器as,将.s文件翻译成机器指令,也即.o文件,这一过程称为汇编,同时这个.o文件也是可重定位目标文件。
2.汇编的作用
将编译器产生的汇编语言进一步翻译为计算机可以理解的机器语言,生成.o文件。
4.2 在Ubuntu下汇编的命令
命令为:gcc -c -o hello.o hello.s
4.3 可重定位目标elf格式
典型的ELF可重定位目标文件
在linux下生成hello.o文件的elf格式命令:readelf -a hello.o > hello.elf
分析hello.elf中的内容:
- ELF头 :ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
- 节头:记录各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
3)重定位节:重定位节保存的是.text节中需要被修正的信息(任何调用外部函数或者引用全局变量的指令都需要被修正),调用外部函数的指令和引用全局变量的指令需要重定位,调用局部函数的指令不需要重定位。Hello程序中需要被重定位的有printf、puts、exit、sleep、sleepseces、getchar和.rodata中的.L0和.L1。
.rela.eh_frame节是.eh_frame节的重定位信息。
4 ) 符号表:.symtab,一个符号表,它存放在程序中定义和引用的函数和全局变量的信息,一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上每个可重定位目标文件在.symtab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。
4.4 Hello.o的结果解析
命令:objdump -d -r hello.o>hello1.txt
结果解析:与hello.s的差异
1)分支转移
hello.s
hello1.txt
反汇编代码跳转指令的操作数使用的不是段名称,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
2)对函数的调用与重定位条目对应
hello.s
hello1.txt
在可重定位文件中call后面不再是函数的具体名称,而是一条重定位条目指引的信息。而在反汇编文件中可以看到,call后面直接加的是偏移量。
3)立即数变为十六进制格式
hello.s
hello1.txt
在编译文件中,立即数全部是以16进制表示的,因为16进制与2进制之间的转换比十进制更加方便,所以都转换成了16进制。
4.5 本章小结
本章主要介绍了汇编文件hello.o,以及汇编的概念与作用,如何得到汇编文件的操作命令。同时对elf文件做了详细的分析,也比对了hello.o的反汇编文件与之前得到的hello.s文件,使得我们对这部分内容有了更加深入的理解。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件 可被加载到内存并执行。
2.链接的作用:
链接器使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨 大的源文件,更便于我们维护管理,可以独立的修改和编译我们需要修改的小 的模块。
5.2 在Ubuntu下链接的命令
在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
生成hello的反汇编代码
5.3 可执行目标文件hello的格式
命令:readelf -a hello > hello1.elf
- ELF头:hello的文件头和hello.o文件头的不同之处如下图标记所示,hello是一个可执行目标文件,有27个节。
2)节头:对 hello中所有的节信息进行了声明,包括大小和偏移量。
3) 重定位节.rela.text:
4 )符号表.symtab:
5.4 hello的虚拟地址空间
分析程序头LOAD可加载的程序段的地址为0x400000
通过edb加载hello程序,打开Data Dump查看hello加载到虚拟地址的状况,并查看各段信息。
在0x401000~0x402000段中,程序被载入,虚拟地址0x401000开始,到0x401ff0结束,根据5.3中的节头部表,可以通过edb找到各个节的信息,比如.txt节,虚拟地址开始于0x4010f0,大小为0x145。
5.5 链接的重定位过程分析
命令: objdump -d -r hello > hello2.txt
与hello.o的反汇编文件对比发现,hello2.txt中多了许多节。hello1.txt中只有一个.text节,而且只有一个main函数,函数地址也是默认的0x000000。hello2.txt中有.init,.plt,.text三个节,而且每个节中有很多函数。库函数的代码都已经链接到了程序中,程序各个节变的更加完整,跳转的地址也具有参考性。
hello的重定位过程:
1)重定位节和符号定义链接器将所有类型相同的节合并在一起后,这个节就作为可执行目标文件的节。然后链接器把运行时的内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号,当这一步完成时,程序中每条指令和全局变量都有唯一运行时的地址。
2)重定位节中的符号引用这一步中,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。
3)重定位条目当编译器遇到对最终位置未知的目标引用时,它会生成一个重定位条目。代码的重定位条目放在.rel.txt中。
5.6 hello的执行流程
1.开始执行:_start、_libc_start_main
2.执行main:_main、_printf、_exit、_sleep、_getchar
3.退出:exit
程序名 程序地址
_start 0x4010f0
_libc_start_main 0x2f12271d
main 0x401125
_printf 0x401040
_exit 0x401070
_sleep 0x401080
_getchar 0x401050
5.7 Hello的动态链接分析
对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
.got.plt起始表的位置为0x404000。
GOT表调用dl_init前0x404008后的16个字节均为0;
调用dl_init后的.got.plt:
从图中可以看到.got.plt的条目已经发生变化。
5.8 本章小结
本章主要介绍了链接的概念及作用,以及生成链接的命令,对hello的elf格式文件进行了深入的分析,同时也分析了hello的虚拟地址空间,重定位过程,遍历了整个hello的执行过程,并且比较了hello.o的反汇编和hello的反汇编。对于链接有了更加深入的理解
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
进程的作用:
它提供一个假象,好像我们的程序独占地使用内存系统,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
shell-bash的作用:是一种交互型的应用级程序,是Linux的外壳,提供了一个界面,用户可以通过这界面访问操作系统内核。
shell-bash的处理流程:
(1)读取用户输入的命令行。
(2)分析命令行字符串,若是内置命令,则立即执行
(3)如果不是内置命令,则调用fork()创建新子进程,再调用execve()执行指定程序
6.3 Hello的fork进程创建过程
在终端中输入./hello 学号 姓名 1命令后,shell会处理该命令,判断出不是内置命令,则会调用fork函数创建一个新的子进程,子进程几乎但不完全与父进程相同。通过fork函数,子进程得到与父进程用户级虚拟地址空间相同的但是虚拟地址独立、PID也不相同的一份副本。
6.4 Hello的execve过程
调用fork函数之后,子进程将会调用execve函数,来运行hello程序,如果成功调用则不再返回,若未成功调用则返回-1。
完整的加载运行hello程序需要以下几个步骤
1.首先加载器会删除当前子进程虚拟地址端。,然后创建一组新的代码、数据、堆端,并初始化为0。
2.接着映射私有区域和共享区域,将新的代码和数据段初始化为可执行文件中的内容
3.最后设置程序计数器,使其指向代码区的入口,下一次调度这个进程时,将直接从入口点开始执行
6.5 Hello的进程执行
6.5.1 逻辑控制流和时间片:
进程的运行本质上是CPU不断从程序计数器 PC 指示的地址处取出指令并执行,值的序列叫做逻辑控制流。操作系统会对进程的运行进行调度,执行进程A->上下文切换->执行进程B->上下文切换->执行进程A->… 如此循环往复。 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。在一个程序被调运行开始到被另一个进程打断,中间的时间就是运行的时间片。
6.5.2用户模式和内核模式:
Shell使得用户可以有机会修改内核,所以需要设置一些防护措施来保护内核,如限制指令的类型和可以作用的范围。
6.5.3上下文切换
如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程,上下文就是内核重新启动一个被抢占的进程所需要的状态,是一种比较高层次的异常控制流。
6.5.4调度
在对进程进行调度的过程,操作系统主要做了两件事:加载保存的寄存器,切换虚拟地址空间。
6.5.5用户态与核心态转换
为了能让处理器安全运行,需要限制应用程序可执行指令所能访问的地址范围。因此划分了用户态与核心态。
核心态可以说是拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。
6.6 hello的异常与信号处理
1. 可能出现的异常
(1) 中断:来自I/O设备的信号。比如输入CTRL -C或者CTRL-Z
(2) 陷阱:有意的异常,是执行一条指令的结果,调用后也会返回到下一条指令,用来调用内核的服务进行操作。
(3)故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成功,则将控制返回到引起故障的指令,否则将终止程序。
(4)终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程序会将控制返回给一个abort例程,该例程会终止这个应用程序。
2. 可能产生的信号
SIGINT,SIGSTP,SIGCONT,SIGWINCH等等
3.各种处理的分析
(1)正常运行,程序结束后,被正常回收
2.按下ctrl-c,此举会给进程发送SIGINT信号,程序将被终止回收
3.运行过程中按下CTRL-Z,此举会给进程发送SIGSTP信号,hello程序将被挂起,用ps命令可以看到hello进程并没有回收
4.不停乱按
6.7本章小结
本章主要介绍了进程的概念及其作用,对shell的功能和处理流程也进行了介绍,然后详细分析了hello程序从fork进程的创建,到execve函数执行,最后具体执行过程以及出现异常的处理。对于整个进程管理有了更加深入的理解。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
1)逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 段标识符:段内偏移量。
2)线性地址:逻辑地址向物理地址转化过程中的一步,逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式,分页机制中线性地址作为输入。
3)虚拟地址:就是线性地址。
4)物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段标识符和段内偏移量两部分组成。段标识符由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号,是对段描述符表的索引,每个段描述符由8个字节组成,具体描述了一个段。后3位包含一些硬件细节,表示具体是代码段寄存器还是栈段寄存器还是数据段寄存器等。通过段标识符的前13位,可以直接在段描述符表中索引到具体的段描述符。每个段描述符中包含一个Base字段,它描述了一个段的开始位置的线性地址。将Base字段和逻辑地址中的段内偏移量连接起来就得到转换后的线性地址。
对于全局的段描述符,放在全局段描述符表中,局部的(每个进程自己的)段描述符,放在局部段描述符表中。全局段描述符表的地址和大小存放在gdtr控制寄存器中,而局部段描述符表存放在ldtr寄存器中。
给定逻辑地址,看段选择符的最后一位是0还是1,用于判断选择全局段描述符表还是局部段描述符表。再根据相应寄存器,得到其地址和大小。通过段标识符的前13位,可以在相应段描述符表中索引到具体的段描述符,得到Base字段,和段内偏移量连接起来最终得到转换后的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址即虚拟地址(VA)到物理地址(PA)之间的转换通过分页机制完成,而分页机制是对虚拟地址内存空间进行分页。
系统将虚拟页作为进行数据传输的单元。Linux下每个虚拟页大小为4KB。物理内存也被分割为物理页, MMU(内存管理单元)负责地址翻译,MMU使用页表将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。
7.4 TLB与四级页表支持下的VA到PA的变换
为了减少页表过大导致的空间浪费,我们采用多级页表来压缩大小
图7.4.1多级页表
在四级页表层次结构的地址翻译中,虚拟地址被划分为4个VPN和1个VPO。每个第i个VPN都是一个到第i级页表的索引,第j级页表中的每个PTE都指向第j+1级某个页表的基址,第四级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问四个PTE。
图7.4.2 四级页表翻译
7.5 三级Cache支持下的物理内存访问
当MMU完成了从虚拟地址到物理地址的转换后,就可以使用物理地址进行内存访问了。Intel Core i7使用了三级cache来加速物理内存访问,L1级cache作为L2级cache的缓存,L2级cache作为L3级cache的缓存,而L3级cache作为内存(DRAM)的缓存。
进行物理内存访问时,会首先将物理地址发送给L1级cache,看L1级cache中是否缓存了需要的数据。L1级cache共64组,每组8行,块大小64B。因此将物理地址分为三部分,块偏移6位,组索引6位,剩下的为标记位40位。首先利用组索引位找到相应的组;然后在组中进行行匹配,对于组中的8个行,分别查看有效位并将行的标记位与物理地址的标记位匹配,当标记位匹配且有效位是1时,缓存命中,根据块偏移位可以直接将cache中缓存的数据传送给CPU。如果缓存不命中,需要继续从存储层次结构中的下一层中取出被请求的块,将新块存储在相应组的某个行中,可能会替换某个缓存行。
L1级cache不命中时,会继续向L2级cache发送数据请求。和L1级cache的过程一样,需要进行组索引、行匹配和字选择,将数据传送给L1级cache。同样L2级cache不命中时,会继续向L3级cache发送数据请求。最后,L3级cache不命中时,只能从内存中请求数据了。
值得注意的是,三级cache不仅仅支持数据指令的访问,也支持页表条目的访问,在MMU进行虚拟地址到物理地址的翻译过程中,三级cache也会起作用。
图7.5 三级Cache下的物理内存访问
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。内核给新进程创建虚拟内存,创建当前进程的mm_struct、区域结构和页表的原样副本,将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,为每个进程保持了私有地址空间的抽象概念。同时延迟私有对象中的副本直到最后可能的时刻,充分利用了稀有的物理内存。
7.7 hello进程execve时的内存映射
exceve()函数在当前进程的上下文中加载并运行我们需要的hello程序。execve函数加载并运行可执行文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。
execve函数用hello程序有效替代当前程序,需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为新程序(即hello)的代码、数据、bss和栈区域等创建新的区域结构。所有这些区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
(3)映射共享区域。如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器。最后设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
当内核调度这个进程时,它就将从这个入口点开始执行。Linux根据需要换入代码和数据页面。
图7-7 excecve时的内存映射
7.8 缺页故障与缺页中断处理
指令引用一个虚拟地址,传给MMU,MMU在查找页表时,发现对应的物理地址并不在内存中,此时便发生了异常,内核中的缺页异常处理程序会先选择一个牺牲页面,如果这个页面已经牺牲过了则会把它换到磁盘,换入新的页面并更新页表。之后返回原来的进程,再次执行引起缺页的命令,此时已经可以正常运作了
图7-8 缺页异常处理
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但不失通用性,假设堆是一个请求二进制零的区域,紧接在未初始化数据区域后开始,向上生长。对每个进程,内核维护一个全局变量brk指向堆顶。分配器将堆视为一组不同大小的块的集合来维护。每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留,供应用程序使用;空闲块可用来分配。空闲块保持空闲,直到空闲块显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的(即显式分配器),要么是内存分配器自身隐式执行的(即隐式分配器)。显式分配器和隐式分配器是动态内存分配器的两种基本风格。两种风格都要求应用显式地分配块,不同之处在于由哪个实体来负责释放已分配的块。显式分配器要求应用显式地释放任何已分配的块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。
图7-9.1 动态内存分配的区域-堆
显式分配器必须在一些约束条件下工作:处理任意请求序列;立即响应请求;只使用堆;对齐要求;不修改已分配的块。在这些限制条件下,分配器试图实现吞吐率最大化和内存使用率最大化,但这两个性能目标通常是相互冲突的。
分配器的具体操作过程以及相应策略:
(1)放置已分配块:当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。执行这种搜索的常见策略包括首次适配、下一次适配和最佳适配等。
(2)分割空闲块:一旦分配器找到了匹配的空闲块,需要决定分配这个空闲块中多少空间。可以选择用整个块,但会造成额外的内部碎片;也可以选择将空闲块分割为两部分,第一部分变成已分配块,剩下的变成新的空闲块。
(3)获取额外的堆内存:如果分配器不能为请求块找到空闲块,分配器通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插到空闲链表中,然后被请求的块放在这个新的空闲块中。
(4)合并空闲块:分配器释放一个已分配块时,要合并相邻的空闲块。分配器决定何时执行合并,可以选择立即合并或者推迟合并。合并时需要合并当前块和前面以及后面的空闲块。
组织空闲块的形式有很多,包括隐式空闲链表、显式空闲链表、分离的空闲链表等等。
带边界标签的隐式空闲链表分配器:一个块由一个字的头部、有效载荷、可能的一些额外的填充以及一个脚部。头部位于块的开始,编码了这个块的大小(包括头部、脚部和所有的填充)以及这个块是已分配的还是空闲的。由于对齐要求,头部的高位可以编码块的大小,而剩余的几位(取决于对齐要求)总是零,可以编码其他信息。使用最低位作为已分配位,指明这个块是已分配的还是空闲的。脚部位于每个块的结尾,是头部的一个副本,是为了方便释放块时的合并操作。头部后面就是调用分配器时请求的有效载荷,有效载荷后面是一片不使用的填充块,其大小可以是任意的。填充的原因取决于分配器的策略。如果块的格式是如上所述,就可以将堆组织成一个连续的已分配块和空闲块的序列,这种结构为隐式空闲链表。空闲块通过头部的大小字段隐含地连接,可以通过遍历堆中所有的块间接遍历整个空闲块的集合。同时,需要一个特殊标记的结束块(设置分配位而大小为零的头部),这种设置简化了空闲块合并。
图7-9.2 隐式链表的块结构
显式空间链表:已分配块的块结构和隐式链表的相同,由一个字的头部、有效载荷、可能的一些额外的填充以及一个脚部组成。而在每个空闲块中,增加了一个前驱指针和后继指针。通过这些指针,可以将空闲块组织成一个双向链表。空闲链表中块的排序策略包括后进先出顺序、按照地址顺序维护、按照块的大小顺序维护等。显式空闲链表降低了放置已分配块的时间,但空闲块必须足够大,以包含所需要的指针、头部和脚部,这导致了更大的最小块大小,潜在提高内部碎片程度。
图7-9.3 显式链表的块结构
而malloc采用的是分离的空闲链表。分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小升序排列,当分配器需要一个大小为n的块时,就搜索相应大小类对应的空闲链表。如果不能找到合适的块,就搜索下一个链表,以此例推。
7.10本章小结
本章主要介绍了hello的存储地址空间,段式管理,页表管理,TLB与四级页表支持下的VA到PA的变换,三级cache支持下的物理内存访问,hello进程fork和execve时的内存映射,缺页故障与缺页中断处理和动态存储分配管理等内容。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
Linux将文件所有的I/O设备都模型化为文件,甚至内核也被映射为文件。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。Linux就是基于Unix I/O实现对设备的管理。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
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函数:
1.int open(char* filename,int flags,mode_t mode)
进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
2.int close(fd)
进程通过调用close函数关闭一个打开的文件,fd是需要关闭的文件的描述符。
3.ssize_t read(int fd,void *buf,size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
4.ssize_t wirte(int fd,const void *buf,size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
8.3 printf的实现分析
printf函数:
引用的vsprintf函数:
vsprintf函数将所有的参数内容格式化之后存入buf,返回格式化数组的长度。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
1.getchar函数运行时,控制权会交给os,用户按键,输入的内容便会显示在屏幕上。按下回车键表示输入完成,这时控制权将被交还给程序。
2.异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
3.getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux的IO设备管理方法和Unix IO接口及其函数,并分析了printf和getchar函数的实现。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
1.预处理:hello.c预处理到hello.i文本文件;
2.编译:hello.i编译到hello.s汇编文件;
3.汇编:hello.s汇编到二进制可重定位目标文件hello.o;
4.链接:hello.o链接生成可执行文件hello;
5.创建子进程:bash进程调用fork函数,生成子进程;
6.加载程序:execve函数加载运行当前进程的上下文中加载并运行新程序hello;
7.访问内存:hello的运行需要地址的概念,虚拟地址是计算机系统最伟大的抽象;
8.交互:hello的输入输出与外界交互,与linux I/O息息相关;
9.终止:hello最终被shell父进程回收,内核会收回为其创建的所有信息。
感悟:
一个简简单单的hello程序背后,是设计者庞大而缜密的设计实现。一个个的步骤是如此的精密而准确,不得不让人去感叹计算机的精巧,同时通过这次作业也更加加深了我对计算机系统的理解,让我大受震撼。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
hello.c 源程序
hello.i 预处理后文件
hello.s 编译后的汇编文件
hello.o 汇编后的可重定位目标执行文件
hello 链接后的可执行文件
hello.elf hello.o的ELF格式
hello1.txt hello.o的反汇编
hello2.txt hello的反汇编代码
hello1.elf hello的ELF格式
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] RANDALE.BRYANT, DAVIDR.O‘HALLARON. 深入理解计算机系统[M]. 机械工业出版社, 2011.
[2] https://www.runoob.com/linux/linux-comm-pstree.html
[3] https://www.runoob.com/cprogramming/c-function-vsprintf.html
[4] https://www.cnblogs.com/diaohaiwei/p/5094959.html
[5] https://blog.csdn.net/yueyansheng2/article/details/78860040