计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机类
学 号 1190202428
班 级 1903005
学 生 许家辉
指 导 教 师 史先俊
计算机科学与技术学院
2021年5月
程序从最初的源代码到最终的可执行可链接文件,需要经历预处理、编译、汇编、链接等过程;而可执行文件执行的过程中,又需要shell为其创建子进程、加载到虚拟内存、输入输出信息。本文将以gcc、edb、objdump等编译工具,以一个简单程序为例,研究程序从源代码到运行的整个过程,深入剖析现代计算机系统的结构与工作原理。
关键词:编译;进程;虚拟内存;系统级I/O;计算机系统
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式....................................................................... - 8 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 10 -
6.3 Hello的fork进程创建过程........................................................................ - 10 -
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 -
8.2 简述Unix IO接口及其函数.......................................................................... - 13 -
第1章 概述
1.1 Hello简介
程序的生命周期是指从程序源文件,依次经过预处理器cpp的预处理、编译器cc1的编译、汇编器as的汇编、链接器ld的链接最终成为可执行目标程序并在操作系统上加载、执行、回收的全过程。程序的生命周期可以分为P2P和O2O两个部分,P2P即From program to process,指程序由源文件到进程的过程;O2O即From zero to zero,指程序被加载到内存执行,直到被回收的过程。
注:本文中,术语“编译”根据不同的上下文有不同的含义:在与加载、执行、回收等术语并列时,编译指的是程序由源文件到可执行目标文件的过程;在与汇编、链接等术语并列时,编译指的是程序由C代码转化为汇编代码的过程。
1.1.1 hello的P2P过程
hello的P2P过程包括预处理、编译、汇编、链接以及shell1的fork子进程五个步骤。首先,我们使用C代码编写hello程序,这时形成了源文件hello.c,编译过程中,预处理器(cpp)在hello.c中加入头文件的宏定义、函数声明等形成hello.i文件;编译器将hello.i的高级语言翻译为汇编语言并进行有限的保守的优化得到hello.s文件,此时,hello文件依然以文本文件存储;汇编器将hello.s文件中的汇编代码翻译为机器可执行的二进制机器码,得到hello.o文件;最后链接器将hello.o文件进行链接,得到hello可执行目标文件。编译的四步如下图所示:
在编译后,当我们在shell中输入字符串.\hello并输入回车后,shell通过一系列指令的调用将输入的字符读入到缓冲区中,之后将Hello目标文件中的代码和数据从磁盘复制到主存。此时shell会调用fork函数创建一个新的进程,并通过加载保存上下文,将控制权交给这个新的进程,这就完成了hello的P2P过程。
1.1.2 hello的O2O过程
O2O是一个很形象的描述,最初内存中没有hello程序的内容(0),在经过shell的fork和加载过程后,hello被映射到虚拟内存中,而当hello执行后,shell又要对其进行进程回收,回收后hello程序的内容又被从内存中移除(0)。下面本文将介绍hello执行这一“我挥挥衣袖,不带走一片云彩”的O2O过程。
在hello加载完成后,处理器就开始执行这个程序,新的代码段和数据段被初始化为hello目标文件的内容。然后,加载器会从_start的地址开始,来到main函数的入口地址,之后进入main函数执行目标代码,CPU为运行的hello分配时间片,执行逻辑控制流。
执行阶段把这个执行的程序分解成几个阶段,分别执行对应的指令,最后输出字符串。之后输出的字符串从主存复制到寄存器文件,再从寄存器文件复制到显示设备,最终显示到屏幕上。
这标志着进程的终止,shell的父进程回收这个进程操作系统恢复shell的上下文,控制权重回shell,由shell等待接受下一个指令的输入。
1.2 环境与工具
本节中将介绍在撰写论文期间测试机的硬件环境、软件环境,以及撰写论文过程中所使用的调试、监测工具。
1.2.1 硬件环境
本实验中使用的测试机型号为Dell G3 3590,其处理器、RAM、Cache等详细信息如下所示,该组信息由工具CPU-Z获取。
处理器:Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
高速缓存:三级Cache RAM: 2R×8 DDR4 SODIMM 2666MHz
1.2.2 软件环境
本实验hello.c程序的调试均在虚拟机中完成,实体机仅进行论文的撰写工作,二者操作系统具体信息如下表所示:
| 实体机 | 虚拟机 |
操作系统名称 | Windows 10 家庭中文版 | Ubuntu 20.04.2 LTS |
操作系统类型 | 64位 | 64位 |
内部版本 | 19042.1052 | 3.36.8 |
1.2.3 调试工具
CPU-Z:用于查看实体机的硬件配置
gcc:编译hello文件
edb:查看hello的虚拟内存映射、反汇编代码等
objdump:查看hello的各节内容
readelf:查看ELF文件格式
1.3 中间结果
hello.c 源文件,本实验的开始
hello.i 经预处理器预处理的源文件,用于研究预处理器功能
hello.s 汇编代码文件,用于研究编译器功能
hello.o 可重定位目标文件,用于研究汇编器功能
hello 可执行目标文件,用于研究链接器功能
1.4 本章小结
本章主要简要介绍了hello的程序生命周期,包括其P2P、O2O的过程,并详细介绍了本文撰写时的软件环境、硬件环境以及所使用工具、中间结果等。
本章主要是本文实验的准备工作和绪论部分,后续章节将会对本章所述内容详细展开介绍。
第2章 预处理
2.1 预处理的概念与作用
预处理是指预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,宏定义和加载文件使其最常见的用法,其余用法如条件编译等在调试时也有较大作用,ANSI标准定义的C语言预处理指令及其作用如下所示:
2.2在Ubuntu下预处理的命令
Ubuntu下的预处理有两种方式:一是直接调用预处理器cpp进行预处理操作;二是通过gcc -E选项对hello.c进行预处理。两种方式处理结果等价。
两种命令格式如下所示:
①:cpp xx.c>xx.i ②:gcc-E xx.c>xx.i 其中,xx为文件名
2.3 Hello的预处理结果解析
在本文中,我们分别使用两种方式进行预处理,hello.i为方法①预处理结果,hello1.i为方法②预处理结果,预处理过程以及预处理结果如下所示:
注:由于hello.i文件代码过长,本处仅展示部分内容,详细内容请见附件
方法①预处理过程及部分结果:
方法②预处理过程及部分结果:
首先,上述两种方式得出的预处理结果是完全等价的。
观察上述结果我们不难发现,初始只有十几行的代码已经被拓展为3060行,主要拓展内容为宏定义、函数定义等。其效果为将stdio.h、unisd.h、stdlib.h三个头文件的内容复制到hello.c文件中。
2.4 本章小结
本次实验中,预处理仅用于加载并复制头文件到hello.c文件中。事实上,预处理指令的作用远不止如此,例如在条件编译时需要用到#ifdef、#if等预处理指令;在需要运行效率时则需要利用一部分宏定义代替函数调用以提高程序运行效率。
预处理环节是接下来编译、汇编、链接等环节的基础,同时预处理机制的存在也使得程序员的编程更加便捷,程序封装性更好。
经过本章预处理器的预处理操作后,源程序hello.c变为带有头文件中宏定义与函数声明的hello.i,下一步将由编译器对其进行编译生成汇编代码。
第3章 编译
3.1 编译的概念与作用
编译在不同环境下有不同的定义,此处的编译是指将C代码由编译器翻译为汇编代码的过程,即由.i文件到.s文件的过程,其具体流程如下所示:
编译的作用一方面是将C代码翻译为更符合机器行为并更容易被汇编为机器码的汇编语言(其实汇编语言本质上讲就是机器语言的助记符);另一方面,在编译过程中还会进行编译器级的优化操作,使其拥有更好的运行时效率,该编译操作对源代码的改变以及优化等级由参数-Og、-O1、-O2决定。但由于编译器优化具有一定的局限性,编写面向编译器友好的代码就显得很重要。
3.2 在Ubuntu下编译的命令
与预处理指令相同,编译指令也有两种等价的方式,其命令格式如下所示:
①gcc -S xx.i -o xx.s ②cc1 xx.i -o xx.s 其中,xx为文件名
注:由于cc1并不位于当前目录下,方式②应在cc1前加入cc1所在文件目录名称
方式①编译如下所示:
方式②编译如下所示:
3.3 Hello的编译结果解析
本节将说明编译器怎么处理C语言的各个数据类型以及各类操作。
3.3.1 数据操作
本节中数据主要有以下几类:①局部变量:整型i ②字符串常量:两个printf中的格式串 ③main函数参数:整型argc、字符指针argv ④整型常量:代码中的0、1、2、4、8等整型常量
<1>局部变量
本次实验中存在的局部变量为int i,主要作为计数器使用。
该局部变量存储在运行时栈中,地址为%rbp-4,如下图所示:
两图分别对应于源代码第17行for循环中赋初值操作i=0和循环终止条件判断i<8。
<2>字符串常量
本次实验中存在两个字符串常量作为printf的格式串,分别为“用法:Hello 学号 姓名 秒数!\n”和“Hello %s %s\n”
字符串常量以uft-8格式编码并存储在.rodata段,如下图所示:
由于该文件还未进行汇编,此处仅使用符号.LC0和.LC1代表两个字符串的首地址。
由于字符串存储在只读数据区(.rodata节),故对字符串的访问只有读操作,没有写操作。在访问该字符串常量时,使用PC相对寻址的方式对其进行访问,汇编代码将在本节讨论函数调用时给出。
<3>main函数参数
main函数参数主要有int argc和字符型指针数组首地址argv
对于整型变量argc,如下汇编代码于C代码相对应,分析C代码可知,31-32行汇编代码的行为是将main函数参数argc与4比较,故argc应存储于内存中%rbp-20地址处。
对于字符型指针数组首地址argv,如下汇编代码与C代码相对应,根据其语义分析可知,argv存储于%rbp-32地址处。
<4>代码中的整型常量
代码段中的整型常量被改写为汇编语言中的立即数,作为代码段的一部分存储,上文中的0、1、2、4、8等均在此列。
3.3.2 赋值操作
本实验C代码中显示的赋值操作只在for循环初始化时出现,如下图所示,C语言中的赋值操作在汇编语言中表现为MOV类指令。
3.3.3 算术操作
本实验中唯一的算术运算是for循环对i的自增运算i++,在编译过程中被编译器翻译为ADD类指令,C代码与汇编代码如下所示:
事实上,本处应采用编码更为紧凑的INC类指令,但很遗憾,出于某种未知原因编译器并未将其翻译为INC类指令。对于未使用INC类指令的原因,可能是由于编译器并未进行此方面的优化或使用ADD类指令更具统一性。
3.3.4 数组操作
本实验中的数组操作是对argv数组进行读操作,并将其作为参数传递,C代码如下图所示:
为了理解更好的理解汇编代码行为,此处将重申C语言指针的概念,指针是存储着一段虚拟内存地址的变量。也就是说,指针是存储在内存中的内存地址,理解这个概念后,对汇编代码的解读将变得容易很多。
数组操作的汇编代码如下图所示:
其中,%rbp-32地址处存储着指针数组argv的首地址。34行代码将指针数组的首地址传递给%rax,此时%rax的值为指针数组argv的首地址。本文将以argv[2]为例,介绍编译器进行数组操作的方法。
注意到:本实验在64位系统下编译,指针大小为8字节。
第35行汇编代码将数组首地址加16字节,得到argv[2],并将其传递给寄存器%rdx作为函数调用的第三个参数。由于本实验中C代码直接引用argv[2],汇编代码的内存引用方式为基址+偏移量寻址;若使用argv[i]进行数组访问,汇编代码将使用比例变址寻址进行内存访问。
3.3.5 控制转移与关系操作
由于关系操作往往作为控制转移的判定表达式,故本文中将二者放在一起进行讨论;本节内容与函数调用也有部分重复,本节中将着重介绍控制转移与关系操作部分,函数调用部分将在下文中详细介绍。
本实验C代码中共有2处控制转移操作:if分支和for循环,分别对应两次关系操作,下面将对两者进行详细介绍。
<1>if分支
本实验中的if分支主要用途是作为防御性代码检测传入参数个数是否符合要求,其C代码与汇编代码如下所示:
可以看到,编译器采用如下C语言伪代码描述的控制流进行分支跳转
该方法的优势在于可以很好地处理如本实验中只有if而没有else的情况,相对于if(t)型的控制流而言可减少一次跳转操作。
<2>for循环
本实验中的for循环用于循环输出8次同样的内容,虽说行为十分迷惑但本小节还要对其控制流结果进行详细解释,其C代码与汇编代码如下图所示:
分析其控制流不难发现,该处编译器将for循环转化为guarded-do型控制流,即在循环开始前先进行表达式检查,若不符合条件直接退出。
<3>关系操作
在介绍控制流后,我们将对其中的关系操作进行详细讲解。C语言中的关系操作返回的是一个布尔值(整型数),if根据其真值进行跳转,也就是说,在C语言中,关系操作和控制转移是相对分离的;而在汇编语言中,关系操作将以条件控制和条件传送两种方式直接反应在汇编代码之中,下表中列出了关系操作与条件控制的关系(条件传送类比即可)
3.3.6 函数操作
本实验中的函数操作主要涉及以下函数:①printf ②exit ③sleep ④getchar。本节中将忽略系统调用与用户函数的区别,简单介绍函数调用时参数传递的方式。
由于函数调用过程大同小异,本节将以C代码18行的printf函数调用为例进行介绍,其C代码与汇编代码如下所示:
在x86-64中,函数传参前6个参数使用寄存器传参,使用寄存器顺序依次为%rdi,%rsi,%rdx,%rcx,%r8,%r9,其余参数使用栈传参,本实验中仅有3个参数,%rdi中存储printf输出的格式串首地址,%rsi中存储argv[1],%rdx中存储argv[2],不使用栈传参。
注:事实上,本实验中main函数参数传递方式为栈传参,该部分已在3.3.1数据操作一节中介绍。
3.4 本章小结
编译操作作为一个程序由源代码文件到可执行文件的关键一环,主要将预处理后的hello.i文件编译为汇编代码文件hello.s,在此过程中,编译器将会对源文件进行语法分析、词法分析,得到汇编文件hello.s,此时文件的控制流将会由易于人类理解的模式变为机器可执行的模式。同时,编译器还会对源代码进行保守的、有限的优化,故编写面向编译器友好的代码仍具有很重要的意义。
在本章编译操作后,经过预处理操作的hello.i文件由C代码文件变为汇编代码文件hello.s,后续将由汇编器将汇编代码转化为机器可以直接执行的二进制码即机器码。
第4章 汇编
4.1 汇编的概念与作用
汇编指的是在由源文件生成可执行文件的过程中,汇编器(as)将编译器(cc1)生成的汇编代码.s文件,按照汇编指令编码为二进制可重定位目标文件的过程。简而言之,汇编就是将汇编代码翻译为机器码的过程。
在汇编过程中,文件格式将由面向阅读友好的文本文件转化为机器可执行的二进制文件,并且将文本文件中的常量转化为对应的二进制补码。同时,汇编过程也将生成可重定位目标文件的结构信息,Linux系统使用可执行可链接格式(ELF)对目标文件进行组织,其具体结构及其内容如下所示:
.text:已编译程序的机器代码
.rodata:只读数据
.data:已初始化的全局和静态C变量
.bss:未初始化的全局和静态C变量
.symtab:符号表
.rel.text:代码段重定位信息表
.rel.data:数据段重定位信息表
.debug:调试符号表
.line:C代码行号与机器码行号映射表
.strtab:字符串表
4.2 在Ubuntu下汇编的命令
汇编指令命令格式如下所示:
as xx.s -o xx.o 其中xx为文件名
汇编过程如下所示:
4.3 可重定位目标elf格式
ELF文件基本格式在上文中已经介绍,本节中将使用readelf工具对hello.o文件进行解析。
在terminal中输入指令readelf -a hello.o > hello_o_elf.txt,将hello.o的ELF文件格式输出在hello_elf.txt文件中,以下各小节将对ELF文件的主要结构进行介绍。
4.3.1 ELF头
ELF头部分如下图所示:
ELF头以一个16字节的序列Magic开始,该序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(可重定位/可执行/共享)、机器类型、节头部表的文件偏移,以及节头部表条目的大小和数量。
由hello.o文件的ELF头部分可知:该文件为可重定位目标文件;数据为小端法存储;共14个节。
4.3.2 节头部表
节头部表部分如下图所示:
节头部表(section header table)包含目标文件各节的语义,包括节的名称、大小、类型、地址、偏移量、是否链接、读写权限等信息。第53-57行是对节头部表中出现的各个符号的含义。
本小节以.text节为例分析节头部表条目的含义。.text节存储着已编译程序的机器代码。其大小为0x92字节,即十进制下146字节;虚拟内存地址为0x0000000000000000,这是由于hello.o是可重定位目标文件,所以每个节都从0开始,用于重定位;读写权限为AX,即可执行;相对于文件头的偏移量为0x40字节,即十进制下64字节。
4.3.3 .symtab节
.symtab节部分如下图所示:
.symtab节存储ELF符号表。符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含一个ELF符号表,这张符号表包含一个条目的数组。符号表每个条目都对应一个符号的语义,具体包括:
①符号名称name:以整型变量存放在符号表中,是字符串表.strtab中的字节偏移,指向符号的以null结尾的字符串名字
②符号位置value:距定义目标的节的起始位置的偏移,对于可执行目标文件来说是一个绝对的运行时地址
③符号类型type:表明符号的类型,通常要么是数据,要么是函数
④符号范围bind:该字段表明符号是本地的还是全局的
⑤分配目标section:该字段是一个到节头部表的索引,表明对应符号被分配至目标文件的某个节;
有三个特殊的伪节:ABS代表不该被重定位的符号;UNDEF代表未定义的符号,即本模块中引用的外部符号;COMMON表示还未被分配位置的未初始化的数据目标
⑤目标大小size
本小节以符号main为例分析ELF符号表条目。符号main定义在.text节中;是全局符号,代表一个函数;大小为146字节。
4.3.4 .rela.text节
.rela.text节如下所示:
.rela.text节是一个.text节的重定位条目数组,当连接器把这个目标文件和其他文件链接时,需要修改这些位置。
重定位条目包含以下内容:
①offset:需要被修改的引用的字节偏移
②symbol:表示被修改引用应该指向的符号
③type:告知连接器如何修改新的引用
④addend:一些重定位要使用它对被修改引用的值做偏移调整
本小节以.rela.text节中第一个条目为例分析重定位条目。该符号在代码段的偏移量为0x1c处被引用,使用objdump查看hello.o在0x1c地址附近的汇编代码如下所示:
分析其汇编代码结构不难发现,0x1c处内容对应于C代码中第一个格式串的地址偏移,由于该文件为可重定位目标文件,各段的运行时地址未分配。注意到:该处使用PC相对寻址对格式串进行访问,其原因在于重定位条目中type为R_X86_64_PC_32,该类型代表重定位时使用32位PC相对地址的引用。
再观察其addend属性,可以得知该符号在.rodata节中存储,使用objdump查看hello.o的.rodata节如下所示:
.rodata相应位置存储的确实为C代码中第一个格式串,由此可见,上述对0x1c处内容的判断是正确的。
4.4 Hello.o的结果解析
本小节将对比hello.s以及hello.o的反汇编文件,比较汇编前后代码发生的变化,并简单介绍机器码与汇编代码的映射关系。
4.4.1 反汇编结果对比
在terminal中输入objdump -d -r hello.o > hello_o_dis.txt,得到hello.o的反汇编代码,hello.s汇编代码与hello_dis.txt内容如下图所示:
注:本小节中将hello.s文件中的汇编代码成为汇编代码,hello_dis.txt文件中的汇编代码称为反汇编代码
通过对比hello.s与hello_dis.txt内容,可以发现汇编代码与反汇编代码内容与结构大致相同,主要存在以下几个细微差别:
①反汇编代码中的数字使用十六进制,汇编代码中使用十进制,如图中蓝色方框所示。该差别主要是由于编码的显示方式不同,底层汇编代码并无差别。
②反汇编代码中的分支跳转指令使用相对地址,汇编代码中使用注记符,如图中红色方框所示。该处区别是由于hello.s文件仍然是文本文件,汇编代码并未对代码段分配相应的运行时内存,故无法使用反汇编代码中使用的寻址方式。
③反汇编代码中函数跳转指令call的目标地址是当前下一条指令,汇编代码中的函数跳转指令call后面跟着函数名。这样的差别是由于反汇编代码已经完成了符号解析,函数作为printf作为外部符号已经被存储在符号表中,并且.rela.text节中也记录了该符号的重定位信息,只需链接器对其进行链接即可;而汇编代码并未完成上述步骤,只能使用函数名。
4.4.2 汇编代码与机器代码映射
对于汇编代码与机器代码的映射,本文仅介绍x86-64的简单子集Y86-64的汇编代码与机器代码映射关系,x86-64的详细内容请见附件。
如上图所示,Y86-64的指令按照其功能被分为13类,具体指令编码规则如下所示:每条指令的第一个字节表明指令的类型。这个字节分为两个部分,每部分4位,高4位是代码部分(icode),低4位是功能部分(ifun)。有的指令可能存在附加的寄存器指示符字节,有的指令需要附加一个8字节常数字。最终形成一个有汇编代码到机器代码的映射。
4.5 本章小结
本章首先介绍了汇编的基本概念、作用以及Ubuntu下汇编操作的指令;接下来本章以hello.o文件为例,详细介绍了可重定位目标文件中的结构以及各节的内容及其含义;最后,通过对比汇编代码与反汇编代码,解释了汇编过程汇编器对文件进行的一系列操作并以x86-64的简单子集Y86-64简要介绍了汇编代码与机器代码的映射方式。
在本章汇编操作后,经过编译操作的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文件基本格式在上文中已经介绍,本节中将使用readelf工具对hello.out文件进行解析。
在terminal中输入指令readelf -a hello.out > hello_out_elf.txt,将hello.out的ELF文件格式输出在hello_out_elf.txt文件中,以下各小节将对ELF文件的主要结构进行介绍,主要对比hello的ELF文件结构与hello.o的ELF文件结构中的不同之处。
5.3.1 ELF头
ELF头如下图所示:
hello的ELF头与hello.o的ELF头差异项已在图中进行标注
由上图可知hello是一个可执行目标文件,有25个节
5.3.2 节头部表
节头部表如下图所示:
对比hello.o的节头部表,hello文件的节头部表与其存在以下两点差异:①hello文件中所有节已经被分配了运行时地址 ②hello中还加入了若干个额外的节
由于hello是可执行目标文件,需要分配内存地址进行加载,故节头部表中每个节都需要分配地址;下面我们将介绍新加入的若干节的功能,额外加入的节按其功能,大致可划分为以下三类:
编译器的调试功能相关:.note.gnu.propert节、.note.ABI-tag节、.hash节、.gnu.hash节、.gnu.version_r节、.gnu.version节
动态链接相关:.dynsym节、.dynstr节、.rela.dyn节、.rela.plt节、.plt.plt节、.sec.fini节、.eh_frame节、.dynamic节、.got节、.got.plt节
加载相关:.init节、.fini节
5.3.3 .symtab节
符号表如下图所示:
可以看到,可执行目标文件的符号表表项数目明显多于可重定位目标文件的表项数目。一方面,可执行目标文件中加入了与调试、加载、动态链接相关的节,使得表示节的符号数增多;另一方面,由于链接器对可重定位目标文件中的符号进行了进一步解析,加入了若干系统调用,这也使得符号数增多。
5.4 hello的虚拟地址空间
本小节将使用edb调试工具并根据5.3中的节头部表介绍hello的虚拟地址空间。
首先,使用edb调试hello,可以看到,hello的虚拟内存地址空间是从0x400000开始,到0x404ff0结束,如下图所示,这段地址空间内存储着hello的全部信息。
由于.rodata节中存储着printf函数的格式串,可读性相对于其他节更好,故本小节中将以.rodata节为例,介绍hello的虚拟地址空间。
根据节头部表的表项可知,.rodata节起始于内存地址0x402000处,截止于内存地址0x40203b处
使用edb查看内存地址0x402000处的内容,如下所示,我们可以内存地址的内容中看到两个以Hello开头的格式串的位置。
5.5 链接的重定位过程分析
本小节将首先介绍静态链接重定位的方法,然后以hello中第一个printf及其格式串为例,介绍hello中重定位的具体步骤。
5.5.1 重定位
链接器完成符号解析后,代码中的每个符号引用都与一个符号定义唯一的关联起来,此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小,可以开始重定位步骤,重定位步骤分为以下两步:
重定位节和符号定义:在这一步中,链接器将所有同类型的节合并为同一类型的聚合节,然后将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及输入模块定义的每个符号。当这一步完成时,程序中每条指令和全局变量都有唯一的运行时内存地址。
重定位节中的符号引用:在这一步中,链接器将修改代码节和数据节中对每个符号的引用,使其指向正确的运行时地址。执行此步骤,要依靠我们上文中介绍的重定位表。
简化的重定位算法的伪代码如下图所示,该简化算法仅支持PC相对引用和绝对引用
5.5.2 hello案例
本小节中,我们将以hello程序的部分代码为例,介绍重定位的具体步骤,hello案例的源文件部分C代码、可重定位目标文件部分汇编代码、部分重定位条目、可执行文件部分汇编代码如下所示:
源文件部分C代码:
可重定位目标文件部分汇编代码:
部分重定位条目:
可执行文件部分汇编代码:
从C代码中我们可以看到,案例程序需要重定位的符号有两处:printf函数与其格式串参数。
在可重定位目标文件中,leaq指令的代码部分和功能部分已被编码为机器码,但数据部分仍为0x00000000,在其后跟随着该地址的重定位信息;callq指令也是同样的模式。
在可执行目标文件中,这些指令的数据部分已经被编码为机器码。
首先,根据算法的伪代码,我们首先观察hello.o重定位表中的第一项,目标符号引用出现在偏移0x1c处,其运行时地址为0x401141,即leaq的数据部分;目标符号定义在.rodata节中,其运行时地址为0x402008。
由于使用PC相对寻址,我们记录下一条指令的运行时地址0x401145,将其与目标符合定义处的运行时地址做差得0x0ec3,将其转化为小端法表示则为c3 0e 00 00,与结果刚好相符。
5.6 hello的执行流程
本小节中使用edb执行hello,下面列出了hello从加载到程序终止的所有调用与跳转的函数名及其运行时地址
函数名 | 运行时地址 |
ld-2.27.so!_dl_start | -- |
ld-2.27.so!_dl_init | -- |
hello!_start | 0x400550 |
hello!init | 0x4004c0 |
hello!main | 0x400582 |
hello!puts@plt | 0x4004f0 |
hello!exit@plt | 0x400530 |
hello!printf@plt | 0x400500 |
hello!sleep@plt | 0x400540 |
hello!getchar@plt | 0x400510 |
sleep@plt | 0x400540 |
5.7 Hello的动态链接分析
本小节首先将介绍动态链接的相关概念及其实现方法,后续将以hello程序为例深入讲解动态链接的具体过程。
5.7.1 动态链接概念及其作用
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过全局偏移量表(GOT)和过程连接表(PLT)实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
5.7.2 hello案例
根据hello 可执行目标文件可知,如下图所示,GOT运行时地址为0x403ff0,PLT的运行时地址为0x404000。
在程序调用dl_init前,使用edb查看地址0x404000处的内容,如下所示:
GOT表的内容在调用_start之后发生改变,0x404008后的两个8个字节分别变为:0x7fe444212190、0x7fe4441fbbb0,其中GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,改变后的GOT表如下,GOT[2]对应部分是共享库模块的入口点。
以puts函数为例,在调用puts函数前对应GOT条目指向其对应的PLT条目的第二条指令,puts@plt指令跳转的地址如下所示:
调用puts函数前的PLT函数如下所示,可以看出其对应GOT条目初始时指向其PLT条目的第二条指令的地址。
调用puts函数后在查看此处地址:
可以看出其已经动态链接,GOT条目已经改变。
5.8 本章小结
本章首先介绍了链接的概念、作用,随后通过对比可执行目标文件与可重定位目标文件的区别,引出了静态链接这一概念,用hello例程详细介绍了静态链接的重定位等过程。在介绍静态链接后,本章总结静态链接的不足,仍通过hello例程介绍了更为先进的动态链接方法。
在本章结束后,我们的hello程序就可以加载到内存中执行了,后续章节将会对其执行过程的细节进行详细介绍。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中的程序的实例,是操作系统对一个正在运行的程序的一种抽象。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的代码和数据,它的栈、通用目的寄存器、程序计数器、环境变量以及打开文件描述符的集合。
进程的作用在于为用户提供一种对处理器、内存、I/O设备的抽象,并向应用程序提供以下关键抽象:
①一个独立的逻辑控制流:它提供一个假象,好像我们的程序独占地使用处理器
②一个私有的地址空间:它提供一个假象,好像我们的程序独占地使用内存系统
6.2 简述壳Shell-bash的作用与处理流程
Linux系统中,Shell是一个交互型应用级程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程)
其基本功能是解释并运行用户的指令,重复如下处理过程:
①终端进程读取用户由键盘输入的命令行。
②分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
③检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令
④如果不是内部命令,调用fork( )创建新进程/子进程
⑤在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
⑥如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait…等待作业终止后返回。
⑦如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
以我们的hello为例,当我们输入 ./hello 1190202428 许家辉 1 的时候,首先shell对我们输入的命令进行解析,由于我们输入的命令不是一个内置的shell命令,因此shell会调用fork()创建一个子进程。
6.4 Hello的execve过程
当创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,加载并运行需要以下几个步骤:
①删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构。
②映射私有区域:为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。
虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区;bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中;栈和堆区域也是请求二进制零的,初始长度为零。具体虚拟内存空间如下图所示:
③映射共享区域:如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
④设置程序计数器(PC):exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
此时,除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
6.5 Hello的进程执行
上文中我们提到过进程提供给应用程序的两个抽象:①独立的逻辑控制流 ②私有的地址空间
hello进程的执行依赖于操作系统所提供的抽象,下面阐述操作系统所提供的进程抽象:
①逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占 (暂时挂起),然后轮到其他进程。
②并发流:一个逻辑流的执行时间与另一个流重叠,成为并发流,这两个流成为并发的运行。多个流并发的执行的一般现象成为并发。
③时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
④私有地址空间:进程为每个流都提供一种假象,好像它是独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,在这个意义上,这个地址空间是私有的。
⑤用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的 代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
⑥上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内 核数据结构等对象的值构成。
⑦上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。
了解上述概念后,我们将继续介绍hello进程的执行:
在进程调用execve函数之后,由上述分析可知,进程已经为hello程序分配了新的虚拟的地址空间,并且已经将hello的.txt和.data节分配虚拟地址空间的代码区和数据区。
进程的控制流图如下所示,最初hello运行在用户模式下,输出hello 1190202428 许家辉,然后hello调用sleep函数之后进程陷入内核模式,内核不会选择什么都不做等待sleep函数调用结束,而是处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
当hello调用getchar时,实际执行的输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。
hello的执行效果如下图所示:
6.6 hello的异常与信号处理
hello执行过程中会出现的异常有中断、陷阱、故障和终止,处理方式为暂停当前进程的执行,调用异常处理子程序,根据异常种类以及处理方式不同采取返回当前指令、返回下一条指令、不返回三种方式,具体处理方式如下表所示:
由于hello程序较为简单,产生的信号种类较为单一,可能产生的信号种类、默认行为及相应事件在下表中列出:
ID | 名称 | 默认行为 | 相应事件 |
2 | SIGINT | 终止 | 来自键盘的中断 |
9 | SIGKILL | 终止 | 杀死程序(该信号不能被捕获不能被忽略) |
11 | SIGSEGV | 终止 | 无效的内存引用(段故障) |
14 | SIGALRM | 终止 | 来自alarm函数的定时器信号 |
17 | SIGCHLD | 忽略 | 一个子进程停止或者终止 |
18 | SIGCONT | 忽略 | 继续进程如果该进程停止 |
19 | SIGSTOP | 停止 | 不是来自终端的停止信号 |
20 | SIGTSTP | 停止 | 来自终端的停止信号 |
以下将会介绍hello程序处理来自键盘的信号
6.6.1 随机无意义键盘输入
在本小节中,hello进程运行过程中从键盘输入包括回车在内的无意义符号,运行结果如下图所示:
由运行结果可以看到,无意义输入均被缓存到 stdin,当调用getchar时读出一个‘\n’结尾的字串(作为一次输入),其他字符会当做shell命令输入,无意义输入并不会影响到hello进程的运行。
6.6.2 Ctrl-Z键盘输入
在本小节中,hello进程运行过程中从键盘Ctrl-Z,运行结果如下图所示:
按下Ctrl-Z后,hello进程运行暂停。组合键Ctrl-Z会导致内核发送一个SIGSTP信号到前台进程组的每个进程,默认情况下,结果是挂起前台作业。
使用ps指令可以看到,hello进程并没有被回收,此时其后台作业号为1
使用命令fg 1将其变为前台进程,此时shell继续执行hello进程
hello进程执行完毕,其被父进程shell回收,再次使用ps指令hello已不在进程表中
6.6.2 Ctrl-C键盘输入
在本小节中,hello进程运行过程中从键盘Ctrl-C,运行结果如下图所示:
按下Ctrl-C后,hello进程运行终止。组合键Ctrl-C会导致内核发送一个SIGINT信号到前台进程组的每个进程,默认情况下,结果是终止前台作业。
使用ps指令可以看到,hello进程已经父进程回收,进程表中无hello进程
6.7本章小结
本章在hello可执行目标文件基础上,基于hello被加载、执行的过程,介绍进程的定义与作用,同时介绍shell的一般处理流程和作用,并且着重分析了调用fork 函数创建新进程,调用execve函数加载并执行hello,以及hello的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
内存中地址的概念主要有四个:逻辑地址、线性地址、虚拟地址、物理地址。
逻辑地址是指程序经过编译后出现在汇编代码中的地址,用来指定一个操作数或者是一条指令的地址。逻辑地址是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量],《深入理解计算机系统》第三章3.10.4金丝雀变量寻址方式即采用如下所示的逻辑地址寻址。
虚拟地址:虚拟内存将整个内存抽象为一个字节数组,而虚拟地址可以视为这个字节数组的下标,程序使用虚拟地址对数据进行访问。但事实上,内存的组织方式并不是线性存储的,虚拟地址在使用时需要MMU将其转化为物理地址对物理内存进行读写。
线性地址:目前的计算机体系结构下与虚拟地址概念系统,不做区分。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU链接的地址总线相对应。
注:本章中对线性地址与虚拟地址不做区分,统一称为虚拟地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址的表示形式为[段标识符:段内偏移量],这个表示形式包含完成逻辑地址到虚拟地址(线性地址)映射的信息。
7.2.1 段标识符
段标识符又名段选择符,是一个16位的字段,如下图所示,其包括一个13位的索引字段,1位的TI字段和2位的RPL字段。
索引字段用于确定当前使用的段描述符在描述符表中的位置,即段选。
RPL字段用于表示CPU的当前特权级,RPL=00为第0级,位于最高级的内核态;RPL=11为第3级,位于最低级的用户态。
TI字段用于选择描述符表,TI-0,选择全局描述符表;TI=1,选择局部描述符表。
7.2.2 段描述符与段描述符表
段描述符是一种数据结构,实际上就是段表项,分为两类:
①用户的代码段和数据段描述符
②系统控制段描述符,又分两种:特殊系统控制段描述符,包括:局部描述符表(LDT)描述符和任务状态段(TSS)描述符;控制转移类描述符,包括:调用门描述符、任务门描述符、中断门描述符和陷阱门描述符
描述符表实际上就是段表,由段描述符(段表项)组成。有三种类型
①全局描述符表GDT:只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段
②局部描述符表LDT:存放某任务(即用户进程)专用的描述符
③中断描述符表IDT:包含256个中断门、陷阱门和任务门描述符
7.2.3 逻辑地址到线性地址的变换
如下图所示,逻辑地址到虚拟地址的转换主要分为以下步骤
①逻辑地址被分割为16位的段选择符与32位的段内偏移量
②根据段选择符的TI字段,选择全局描述符表或局部描述符表
③根据段选择符的索引字段,选择描述符表中的段描述符,其中包含被选段的基地址
④将被选段的基地址与段内偏移量相结合,得到32位虚拟地址
7.3 Hello的线性地址到物理地址的变换-页式管理
地址翻译是指将一个虚拟地址转化为物理地址的任务,本小节将越过Cache与多级页表等复杂结构来介绍地址翻译的基本步骤和基本思想,后续小节将在本小节基础上对Cache与多级页表的访存模式进行介绍。
本小节首先将介绍页表这一用于地址翻译的重要数据结构,随后将介绍地址翻译的具体步骤。
7.3.1 页表
页表是一个存放于物理内存的页表条目(PTE)数组,用于维护物理地址和虚拟地址的映射关系。
下图展示了一个页表的基本组织结构,虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。PTE是由一个有效位和一个n位地址字段组成的。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置;如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向虚拟页在磁盘上的起始位置。
7.3.2 地址翻译
形式上,地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)中元素的映射:
MAP:VAS→PAS∪∅
如下图所示,MMU利用页表来实现上述映射关系:CPU中一个控制寄存器,页表基址寄存器(PTBR)指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE,将PTE中的物理页号(PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。
上述内容简要描述了地址翻译的全过程,而事实上,在地址翻译最初进行时,MMU硬件会首先检查PTE有效位,进而得到命中和不命中两种结果,以下将介绍页面命中和页面不命中时CPU的不同行为。
<1>页面命中
页面命中时,如上图所示,CPU将执行以下步骤:
①处理器生产一个虚拟地址,并把它传送给MMU
②MMU生成PTE地址,并从高速缓存/主存请求得到它
③高速缓存/主存向MMU返回PTE
④MMU构造物理地址,并把它传送给高速缓存/主存
⑤高速缓存/主存返回所请求的数据字给处理器
<2>页面不命中
页面不命中时,如上图所示,CPU执行以下步骤:
①②③与页面命中的前三步相同
④PTE有效位是0,MMU出发缺页异常,传递CPU中的控制流到操作系统内核中的缺页异常处理子程序
⑤缺页处理子程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘
⑥缺页处理自行页面调入新的页面,并更新内存中的PTE
⑦缺页处理子程序返回到原来的进程,再次执行导致缺页的命令
7.4 TLB与四级页表支持下的VA到PA的变换
本节首先将介绍多级页表的动机,随后将简要介绍TLB的基本思想和实现方法,最后以Intel Core i7为例介绍四级页表支持下VA到PA的变换
7.4.1 单独页表的局限性
在32位系统中,地址空间有32位,假设每个页面大小为4KB,每个PTE大小为4字节,那么即使所引用的只是虚拟地址空间中很小的一部分,也总是需要一个4MB的页表驻留在内存中,对于地址空间为64位的系统而言,问题将变得更加复杂。
为解决上述问题,我们使用层次结构的页表来对其空间进行压缩,其主要思想为:将页表构建出层次结构,高级页表中存储低级页表的低质,最底层页表存储相应的物理内存地址。
这种方法从两个方面减少了内存要求:①如果一级页表中的一个PTE是空的,那么相应的二级页表就根本不会存在 ②只有一级页表才需要总是存储在主存中;虚拟内存系统可以在需要时创建、调入、调出二级页表
7.4.2 TLB
《深入理解计算机系统》第六章介绍了存储器的层次结构,低速器件与高速器件的配合达到了容量与速度的平衡。TLB正是基于这一思路对页表访问的优化。
TLB称为翻译后备缓冲器,是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块,虚拟地址中用以访问TLB的组成部分如下所示:
下图展示了TLB命中与不命中时所包括的步骤:
7.4.3 四级页表
本节将以Intel i7处理器的页表层次结构为例,介绍多级页表工作方式,其处理器结构如下所示:
其一级、二级、三级PTE格式如下所示:
四级PTE格式如下所示:
PTE有三个权限位,控制对页的访问。R/W位确定页的内容是可以读写的还是只读的。U/S位确定是否能够在用户模式中访问该页,从而保护操作系统内核中的代码和数据不被用户程序访问。XD(禁止执行)位是在64位系统中引入的,可以用来禁止从某些内存读取指令。
当MMU翻译每一个内存地址时,它还会更新另外两个内存缺页处理程序会用到的位。每次访问一个页时,MMU都会设置A位,称为引用位。内核可以用这个引用位来实现它的页替换算法。每次对一个页进行了写之后,MMU都会设置D位,又称修改位或脏位。修改位告诉内核在复制替换页之前是否必须写回牺牲页。内核可以通过调用一条特殊的内核模式指令来清除引用位和修改位。
如上图所示,在进行地址翻译时,36位的VPN被划分位四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供一个到L1PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供一个到L2PTE的偏移量,以此类推。
注意到:各级页表的VPNi位数均相同,这样做也是为了优化页表性能
7.5 三级Cache支持下的物理内存访问
Intel i7的地址翻译概况如下所示,本节中将介绍以L1缓存为例,介绍Cache支持下的物理内存访问。
Cache支持的物理内存访问大致分为以下几步:
①VA被分割为VPN和VPO,VPN用于查询页表得到PPN,VPO直接作为PPO传递,PPN与PPO组合得到PA,具体细节已在上节中介绍,此处不再赘述。
②L1 Cache对PA进行分解,将其分解为标记(CT)、组索引(CI)、块偏移(CO)
③L1 Cache根据CI选择L1 Cache中的组,根据组valid位与CT判断是否命中,若命中则根据CO选择组中的块,并将其返回到CPU;否则在下一级缓存中重复上述步骤
上述步骤即Cache支持物理内存访问的全过程,但注意到:PPO的位数与CI和CO的位数是相等的,这是一种优化策略。由于查询页表得到PPN的时间需要比得到PPO的时间要长很多,这样的优化策略使得在查询页表的同时,L1 Cache中就可以进行行匹配和组选择,提高了指令执行的并行性。
7.6 hello进程fork时的内存映射
当 fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只 读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存系统。当这两个进程中任一个后来进行写操作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,
加载并运行 hello 需要以下几个步骤:
①删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
②映射私有区域,为新程序的代码、数据、bss节和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text节和.data节,bss节是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
③映射共享区域,hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
④设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障:当指令引用一个相应的虚拟地址,而与改地址相应的物理页面不再内存中,会触发缺页故障。通过查询页表PTE可以知道虚拟页在磁盘的位置。缺页处理程序从指定的位置加载页面到物理内存中,并更新PTE。然后控制返回给引起缺页故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中,因此指令可以没有故障的运行完成。其处理流程如下所示:
7.9动态存储分配管理
本节首先介绍动态内存分配的基本概念,随后将会介绍动态内存分配过程中的原则以及存在的问题,并着重介绍隐式空闲链表这一显示分配器的实现方式,最后会对其他的显示分配器进行介绍。
7.9.1 基本概念
动态内存分配器维护着一个进程的虚拟内存区域,称为堆,内核中维护着一个变量brk,指向堆的顶部。分配器将堆视为一组不同的大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。
分配器有以下两种基本风格,两种风格都是要求显示的释放分配块。
①显示分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。
②隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。
在维护动态内存的过程中,碎片现象是造成堆利用率很低的主要原因,当存在未使用的内存,但不能用来满足分配请求时,就会发送这种现象。碎片也有两种形式:
内部碎片:已分配块比有效载荷大。
外部碎片:空闲内存合计起来足够满足一个分配请求,但没有一个单独的空闲块足够大可以处理这个请求。
7.9.2 分配器的要求和目标
显示分配器必须在以下约束条件下工作:
①处理任意请求序列:分配器不可以假设分配和释放请求的顺序
②立即响应请求:不允许分配器为了提高性能重新排列或缓冲请求
③只是用堆:分配器使用的任何非标量数据结构必须保存在堆中
④对齐块:分配器必须对齐块
⑤不修改已分配的块:分配器只能操作空闲块
在这些约束条件下,分配器的目标则是实现吞吐率最大化与内存使用率最大化
7.9.3 隐式空闲链表
隐式空闲链表区别块的边界、已分配块和空闲块的方法如下图所示:
这种情况下,一个块是由一个字的头部、有效载荷,以及可能的填充组成。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。块的头最后一位指明这个块是已分配的还是空闲的。
头部后面是应用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。块的格式如下图所示,空闲块通过头部块的大小字段隐含的连接着,所以我们称这种结构就隐式空闲链表。
隐式空闲链表在动态内存分配中,有以下四个基本操作:放置已分配块、分割空闲块、获取额外堆内存、合并空闲块,下文中会对其一一介绍。
①放置已分配的块:当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。分配器搜索方式的常见策略是首次适配、下一次适配和最佳适配。
②分割空闲块:一旦分配器找到一个匹配的空闲块,就必须做一个另策决定,那就是分配这个块多少空间。分配器通常将空闲块分割为两部分。第一部分变为了已分配块,第二部分变为了空闲块。
③获取额外堆内存如果分配器不能为请求块找到空闲块,一个选择是合并那些在物理内存上相邻的空闲块,如果这样还不能生成一个足够大的块,分配器会调用sbrk函数,向内核请求额外的内存。
④合并空闲块合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变头部的信息就能完成合并空闲块。Knuth提出了一种采用边界标记的技术快速完成空闲块的合并。
7.9.4 显式空闲链表
显示空闲链表是将空闲块组织为某种形式的显示数据结构。如下图所示。堆被组织为一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继的指针。
显示空闲链表的优势在于其使用双向链表的结构,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。
7.9.5 分离空闲链表
分离空闲链表的核心思想是分离存储,即维护多个空闲链表,其中每个链表的块有大致相等的大小,实现有两种基本方法:简单分离存储和分离适配。C语言的malloc函数实现方法介绍显示空闲链表加分离适配。
在分离空闲链表的基础上,我们还可以进一步将其维护成AVL树或红黑树的结构,使其效率达到最优。
7.10本章小结
本章主要以例程hello为例介绍了四种地址空间,同时以Intel i7处理器为例,介绍了四级页表的虚拟地址空间到物理地址的转换,阐述了三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列:
B0,B1,B2……Bm
所有的IO设备(如网络、磁盘、终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O 接口
Unix I/O接口由以下部分组成:
①打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息
②Shell创建的每个进程都有三个打开的文件:标准输入、标准输出、标准错误
③改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k
④读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k
⑤关闭文件:内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去
8.2.2 Unix I/O函数
Unix I/O 函数有以下函数:
①int open(char* filename, int flags, mode_t mode),进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位
②int close(fd),fd是需要关闭的文件的描述符,close返回操作结果
③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的当前文件位置
8.3 printf的实现分析
首先查看printf函数的函数体:
static int printf(const char *fmt, ...)
{
va_list args;
int i;
va_start(args, fmt);
write(1,printbuf,i=vsprintf(printbuf, fmt, args));
va_end(args);
return i;
}
可以看到,printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度
接下来是write函数:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在printf中调用系统函数write(buf, i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
查看syscall函数体:
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
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是我们的打印字符串就显示在了屏幕上。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用int 0x80或syscall。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
经过上述步骤后,字符串“Hello 1190202428 许家辉”便显示在了屏幕上。
8.4 getchar的实现分析
getchar 的源代码如下所示:
int getchar(void)
{
static char buf[BUFSIZ];
static char *bb = buf;
static int n = 0;
if(n == 0)
{
n = read(0, buf, BUFSIZ);
bb = buf;
}
return(--n >= 0)?(unsigned char) *bb++ : EOF;
}
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在 键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装, 大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。
结论
程序hello的生命周期开始于程序员将其以高级语言的形式编写形成hello.c源文件,该文件本质上还是文本文件。随后,经过预处理器的预处理过程,hello.c变为了hello.i,该文件与hello.c文件并无本质差别,只是在其中加入了头文件的函数声明、宏定义等;预处理后,编译器开始将hello程序由hello.i这一阅读友好的文件翻译为hello.s的汇编代码文件,这一过程同时进行着编译器级代码优化;经过编译器编译的文件仍是文本文件,需要汇编器将其由汇编语言文件hello.s转化为机器语言二进制文件hello.o,这一步骤创建了可重定位目标文件的一系列结构,为链接做准备;编译的最后一个阶段就是链接,链接器将hello.o引用的符号进行解析和重定位,最终成为可执行文件hello。
编译结束后,我们在terminal中运行hello程序,shell首先使用fork和execve函数加载映射虚拟内存,为hello创建新的代码数据堆栈段,CPU为hello分配一个时间片,将程序计数器指向hello代码,开始执行hello程序。在此过程中,还有动态链接的延迟绑定和系统级I/O的问题需要解决。最后,hello运行完毕,shell回收子进程,内核会删除这个进程使用所需要创建的一系列数据结构。至此,hello程序运行结束。
学习计算机系统这门课后,我对计算机的底层设计有了更深入的理解,对之前使用的一些小技巧的底层原理有了深入理解,并且通过几次实验实现了很多有意思的小功能,比如二进制炸弹。
参考文献
[1] [美]兰德尔E.布莱恩特. 深入理解计算机系统.3版 [M] 龚奕利,贺莲译 北京:机械工业出版社,2016.7
https://blog.csdn.net/weixin_45406155/article/details/103775420
文件名 | 作用 |
hello.c | 源文件,本实验的开始 |
hello.i | 经预处理器预处理的源文件,用于研究预处理器功能 |
hello1.i | 经预处理器预处理的源文件,采用与hello.i不同的指令 |
hello.s | 汇编代码文件,用于研究编译器功能 |
hello.o | 可重定位目标文件,用于研究汇编器功能 |
hello.out | 可执行目标文件,用于研究链接器功能 |
hello_o_dis.txt | objdump生成的hello.o的反汇编文件 |
hello_o_elf.txt | readelf读取的hello.o的elf文件内容 |
hello_out_dis.txt | objdump生成的hello.out的反汇编文件 |
hello_out_elf.txt | readelf读取的hello.out的elf文件内容 |
Intel指令集.pdf | X86-64指令集的详细机器指令映射 |