计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 航天学院
学 号 120L012012
班 级 2036015
学 生 崔耕硕
指 导 教 师 史先俊
计算机科学与技术学院
2022年5月
本文通过对一个示例程序解释计算机系统各个模块之间的联系与功能,主要介绍hello程序在linux下是如何从一个.c文件一步步变成可执行文件的。本文包括对示例程序的预处理、编译、汇编、链接、进程管理、存储管理、IO管理等的探究。
关键词:操作系统;进程;汇编;程序执行
目 录
第1章 概述
1.1 Hello简介
P2P过程:将hello.c经过预处理->编译->汇编->链接四步生成hello的二进制可执行文件,在shell中为其fork出进程并执行。
020过程:shell为其映射出虚拟内存,开始运行进程时分配并载入物理内存,执行hello程序,将output显示到屏幕,hello进程结束,shell回收内存空间。
1.2 环境与工具
1.2.1 硬件环境
X64 CPU;2GHz;2G RAM;256GHD Disk 以上
1.2.2 软件环境
Windows7/10 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上;
1.2.3 开发工具
Visual Studio 2010 64位以上;CodeBlocks 64位;vi/vim/gedit+gcc;as,ld,vim,edb,readelf,VS
1.3 中间结果
预处理后的文件 hello.i;汇编文件 hello.s;可重定位目标文件 hello.o;可执行目标文件Hello;Hello.o的ELF格式elf.txt;Hello.o 的反汇编代码 disassemble_hello.s;hello的ELF 格式 helloELF.elf;hello 的反汇编代码 disassemble_hello_o.s。
1.4 本章小结
本章主要介绍了Hello的P2P、020的整个过程并介绍了实验的基本信息:环境、工具以及实验的中间结果。
第2章 预处理
2.1 预处理的概念与作用
预处理是计算机在处理一个程序时所进行的第一步,他直接对.c文件进行初步处理将处理后的结果保存在.i文件中,随后计算机再利用其它部分接着对.i文件进行处理。预处理以展开的 # 开头,试图解释为预处理指令。其中 ISO C/C++要求支持的包括#if、#ifdef、#ifndef、#else、#elif、#endif(条件编译)、#define(宏定义)、#include(源文件包含)、#line(行控制)、#error(错误指令)、#pragma(和实现相关的杂注)以及单独的#(空指令)。预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或编译。
作用:(1)头文件的展开:将程序中所用的头文件用其内容来替换头文件名。
(2)宏替换:扫描程序中的符号,将其替换成宏所定义的内容。
(3)去掉注释:去掉程序中的注释。
(4)条件编译:防止文件重复引用。
2.2在Ubuntu下预处理的命令
图2.1 Ubuntu下预处理命令
2.3 Hello的预处理结果解析
在预处理前程序中包含开始的注释内容、头文件、全局变量和主函数。而左侧是预处理过后的文件,从全局变量的定义开始,与预处理之前的文件完全相同,这与2.1相符。
图2.2 预处理结果解析
hello.i程序中并没有注释部分。预处理阶段,预处理器将需要用到的库的地址和库中的函数加入到了文本中,与原来不需预处理的代码一同构成了hello.i文件,用来被编译器继续编译。
图2.3 预处理结果截图
图2.4 预处理结果截图
2.4 本章小结
本章主要介绍了预处理的概念和应用功能及Ubuntu下预处理的指令,同时具体到我们的hello.c文件的预处理结果hello.i文本文件解析,详细了解了预处理的内容。
第3章 编译
3.1 编译的概念与作用
概念:编译阶段将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。汇编语言程序中的每条语句都以一种标准的文本格式确切地描述了一条低级程序机器语言指令。
词法分析对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生单词符号,把作为字符串的源程序改造为单词符号串的中间程序。语法分析以单词符号为输入,分析单词符号串是否形成符合语法规则的语法单位,最后看是否构成一个符合要求的程序,按语言的语法规则分析检查每条语句是否有正确的逻辑结构。代码优化对程序进行等价的变换,使得变换后的程序能产生更有效的目标代码。这种等价的变换不改变程序的运行结果,同时使得程序运行时间更短,占用的存储空间更小。如果在编译的过程中发现源程序有错误,会报告错误的性质和发生位置。但一般情况下,编译器只做语法检查和最简单的语义检查,而不检查程序的逻辑。
3.2 在Ubuntu下编译的命令
图3.1 Ubuntu下编译命令
3.3 Hello的编译结果解析
3.3.1汇编指令
汇编指令是汇编语言中使用的一些操作符和助记符,还包括一些伪指令(如assume,end)。用于告诉汇编程序如何进行汇编的指令,它既不控制机器的操作也不被汇编成机器代码,只能为汇编程序所识别并指导汇编如何进行。包含数据传输、算术运算、逻辑运算、串指令程序转移等。一般以.开头。
图3.2 hello.s部分指令
3.3.2.数据
关于数据的定义,hello.c中定义了一个“用法:Hello 学号 姓名 秒数”的字符串。对应到hello.s文件中,就是图3.3。类似的字符串、整数i、数组argc等均可见下图。我们可以看到定义的过程中用.string声明了这是一个字符串。需要注意的一点是main函数中定义的i变量、argc变量由于是局部变量,所以汇编器并没有单独的对他进行处理,而是直接将在这个变量放到了寄存器中。
图3.3 字符串1
图3.4 字符串2
图3.5 整数i
-4(%rbp)可见main函数中定义的整数i直接在寄存器中。
图3.6 整数argc与数组argv[]
可见数组argv的首地址在-32(%rbp)中。
3.3.3赋值
在计算机程序设计语言中,用一定的赋值语句去实现变量的赋值。可以注意到图3.5中,hello.s的36行中有一个对寄存器的寻址操作,这个操作给地址赋值0。由于i是main定义的局部变量,所以直接可以在栈中用一个单元保存这个值。
3.3.4 算数运算
图3.7 算术运算
此处为for循环中的i++。用add指令进行加1操作,由于int占4字节,在add后加上后缀l。
3.3.5 关系运算
图3.8 if语句的判断条件argc!=0
将数字4和argc进行比较,并用je指令进行跳转判断。je为相等则跳转,不相等则不跳转。
图3.9 for语句的循环判断条件i<8
将7和i进行比较并用jle指令跳转。如果jle为小于等于则跳转,否则不跳转。
3.3.6 控制转移
图3.10 if语句控制转移
用cmpl指令比较,用je指令跳转。若不等于4则不跳转而进入if中。
对for循环,用cmpl指令比较,用jle指令跳转。若小于等于7,对int类型也就是小于8,跳转进入for循环体内部。见图3.9.
3.3.7 函数操作
Hello调用函数操作,编译时所有的函数调用都转换成了指令call,后面接调用函数的名字。
图3.11 hello.s中的函数调用
大部分的参数传递通过寄存器实现,通过寄存器最多传递6个参数,按照顺序依次为%rdi、%rsi、%rdx、%rcx、%r8、%r9。多余的参数通过栈来传递。调用main函数时,函数被系统启动函数__libc_start_main调用,call将下一条指令地址dest压栈,然后跳转至main函数。
对于exit函数:
图3.12 exit函数
图3.13 main函数返回
这里返回值为0。将%eax设置为0,然后返回。
图3.14 printf函数1
图3.15 printf函数2
其中,printf函数1通过把.LC0的首地址传入%rdi中进行参数传递,使用call puts@PLT进行函数调用。Printf函数2通过把argv[2]的地址传入%rdx中,把argv[1]的地址传入%rsi中,把将.LC1的首地址传入%rdi中进行3次参数传递,而后调用printf。
图3.16 atoi函数
该函数参数传递为把argv[3]数组地址传入%rdi中,而后进行函数调用,从atoi中返回值。
图3.17 sleep函数
该函数将atoi存储在寄存器%eax的返回值传入%rdi中,使用call sleep@PLT进行函数调用,值从sleep中返回。
图3.18 getchar函数
使用call getchar@PLT进行控制传递。
3.4 本章小结
在第3章中,我们了解C语言提供的抽象层下面的东西以了解机器级编程。通过让编译器产生机器级程序的汇编代码表示,了解了编译器和优化能力,以及机器、数据类型和指令集。
第4章 汇编
4.1 汇编的概念与作用
汇编器将hello.s文件翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式即可重定位文件,并将结果保存在hello.o中。hello.o是一个二进制文件。
4.1.2作用
汇编的作用是将在hello.s中保存的汇编代码翻译成可供机器执行的二进制代码,这样机器可以根据这些二进制代码真正的开始执行程序。
4.2 在Ubuntu下汇编的命令
图4.1 Ubuntu下汇编命令
4.3 可重定位目标elf格式
首先在文件夹路径下得到hello_o_elf.txt文件。
图4.2 readelf命令
分析elf头:
图4.3 ELF头
本虚拟机已汉化。由图可见ELF头以一个16字节的序列Magic开始,描述了生成该文件的系统的字的大小和字节顺序。剩下部分包含帮助链接器语法分析和解释目标文件的信息,如ELF 头的大小、程序头的大小、文件类型为可重定位文件等信息。
节头:节头记录了各节名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐等。由于是可重定位目标文件,每个节都从0开始,用于重定位。
图4.4节头
重定位节:包含了需要被修改的引用节的偏移量、信息、重定位的类型、符号值和重定位需要对被引用值的偏移调整量等。.rela.text中保存了代码的重定位信息,也就是.text节中的信息的重定位信息。可以看到这里面有.rodata,puts等很多代码的重定位信息。我们就拿第一条的信息来做分析。偏移量中保存了这个重定位信息在当前重定位节中的偏移量。第二个信息里面,前面的2个字节的信息保存了这个代码在汇编代码中被引用时的地址相对于所有汇编代码的偏移量,后面4个字节保存了重定位类型,一个是绝对引用,另一个是相对引用。
图4.5 重定位节
符号表存储了程序中定义和引用的函数和全局变量的信息。
图4.6 符号表
4.4 Hello.o的结果解析
输入objdump -d -r hello.o > hello_o.objdump,得到hello.o的反汇编代码文件hello_o.objdump文件,对比分析hello_o.objdump和hello.s:
图4.7 结果
图4.8 hello.o.objdump
hello.s对比可以发现,hello.s中的汇编指令被映射到二进制的机器语言。机器语言完全是二进制代码构成的。不同的汇编指令被映射到不同的二进制功能码,而汇编指令的操作数也被映射成二进制的操作数。从汇编语言转换成机器语言的过程中,一些操作数会出现不一致的情况:
(1)hello.s中的立即数都是用10进制数表示的。机器语言中,由于转换成了二进制代码,立即数都是用16进制数表示的。
(2)hello.s中的分支转移即跳转指令直接通过像.LC0,.LC1这样的助记符进行跳转,会直接跳转到相应符号声明的位置。从汇编语言转换成机器语言之后,助记符就不再存在了,因此机器语言中的跳转使用的是确定的地址。
(3)hello.s中的函数调用直接在call指令后面加上要调用的函数名。机器语言中,call指令后是被调函数的PC相对地址。由于调用的函数都是库函数,需要在动态链接后才能确定被调函数的确切位置,因此call指令后的二进制码为全0,同时需要在重定位节中添加重定位条目,在链接时确定最终的相对地址。
4.5 本章小结
本章介绍了汇编的概念和作用,通过对比hello.s和hello.o,分析了汇编的过程,同时分析了ELF格式下的可重定位目标文件。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据的片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。链接由链接器程序自动执行。链接包括两个主要任务:符号解析和重定位。
5.2 在Ubuntu下链接的命令
图5.1 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
图5.2 ELF头
图5.3 节头增加了数量
图5.4 程序头
描述了可执行文件中的节与虚拟空间中的存储段之间的映射关系。程序头是可执行文件hello特有的。
图5.5 dynamic动态节
图5.6 重定位节
图5.7 符号表
5.4 hello的虚拟地址空间
图5.8 程序头
图5.9 部分hello文件(edb打开)
可以看出段的虚拟空间从0x400000开始,到0x400ff0结束。在 0x400000~0x403e50段中程序被载入。这之间每个节的排列顺序与Section Headers中声明的顺序相同。由节头部表可以获得各个节的偏移量信息,从而得知各节在虚拟地址空间中的地址。例如,对于.rodata节,节头部表中给出了它的偏移量为0x600,大小为0x2f字节。它的虚拟地址空间就从0x400600开始。对于.text节,节头部表中给出了它的偏移量为0x4d0,大小为0x122字节。它的虚拟地址空间就从0x4004d0开始,第一条指令的二进制机器码的第一个字节为0x31。
5.5 链接的重定位过程分析
进行反汇编处理:
图5.10 反汇编指令处理
hello与hello.o的不同之处在于,hello中的汇编代码从0x400000开始,而hello.o中的汇编代码从0开始,还没有涉及到虚拟内存地址。在hello.o中,只存在main函数的汇编指令;而在hello中,由于链接过程中发生重定位,引入了其他库的各种数据和函数,以及一些必需的启动/终止函数,还包括其他指令。对于hello,main函数中涉及重定位的指令的二进制代码被修改。在之前汇编的过程中,汇编器遇到对最终位置未知的目标引用,会产生一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。在链接过程中,链接器会根据重定位条目以及已知的最终位置对修改指令的二进制码。查看hello.o中的重定位条目,重定位条目给出了需要被修改的引用的节偏移、重定位类型、偏移调整等信息。
图5.11 新增其他函数指令
5.6 hello的执行流程
使用edb执行hello,调用顺序为从上到下。
程序名 | 地址 |
ld-2.27.so!_dl_start | 0x7fce8cc38ea0 |
ld-2.27.so!_dl_init | 0x7fce8cc47630 |
hello!_start | 0x400500 |
libc-2.27.so!__libc_start_main | 0x7fce8c867ab0 |
-libc-2.27.so!__cxa_atexit | 0x7fce8c889430 |
-libc-2.27.so!__libc_csu_init | 0x4005c0 |
hello!_init | 0x400488 |
libc-2.27.so!_setjmp | 0x7fce8c884c10 |
-libc-2.27.so!_sigsetjmp | 0x7fce8c884b70 |
--libc-2.27.so!__sigjmp_save | 0x7fce8c884bd0 |
hello!main | 0x400532 |
hello!puts@plt | 0x4004b0 |
hello!exit@plt | 0x4004e0 |
ld-2.27.so!_dl_runtime_resolve_xsave | 0x7fce8cc4e680 |
-ld-2.27.so!_dl_fixup | 0x7fce8cc46df0 |
--ld-2.27.so!_dl_lookup_symbol_x | 0x7fce8cc420b0 |
libc-2.27.so!exit | 0x7fce8c889128 |
表1 hello调用顺序表
5.7 Hello的动态链接分析
由于编译器没有办法知道函数运行时的地址而需要链接器进行连接处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数,加载时动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。dl_init调用之后,0x601008和0x601010处的两个8B数据分别发生改变,其中变化便是GOT[1]指向重定位表,用来确定调用的函数地址,然后在调用函数时,先跳转到PLT执行.plt中逻辑,然后访问动态链接器确定函数地址,重写GOT(以便再次访问该函数时可直接跳转),将控制传递给目标函数。
图5.12 hello_elf.txt中关于GOT的起始位置描述
图5.13 edb中查看
5.8 本章小结
本章主要介绍了链接的概念与作用与可执目标文件的格式,分析了hello的虚拟地址空间和重定位的过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程的定义是一个执行中程序的实例,拥有一个独立的逻辑控制流和私有的地址空间。进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。在计算机中进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
shell是指为使用者提供操作界面的软件,是一个交互型应用级程序,它接收用户命令然后调用相应的应用程序。shell提供了用户与内核进行交互操作的接口,最重要的功能是命令解释。Linux系统上的所有可执行文件都可以作为shell命令来执行,同时它也提供一些内置命令。此外shell还包括通配符、命令补全、命令历史、重定向、管道、命令替换等功能。
处理流程:先从终端读入输入的命令行,然后解析输入的命令行,获得命令行指定的参数,最后检查命令是否是内置命令,如果是内置命令则立即执行,否则在搜索路径里寻找相应的程序,找到该程序则执行。
6.3 Hello的fork进程创建过程
当在shell中输入命令“./hello 1190200817 刘小川”时,shell解析输入的命令行,获得命令行指定的参数。由于./hello不是shell内置的命令,因此shell将hello看作一个可执行目标文件,在相应路径里寻找hello程序,找到该程序就执行它。shell会通过调用fork()函数创建一个子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同但独立的一个副本,包括代码段、数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,子进程可以读写父进程中打开的任何文件。父进程和子进程之间最大的区别在于它们的PID不同。hello程序之后就会运行在这个新创建的子进程的上下文中。
6.4 Hello的execve过程
shell创建一个子进程之后,这个子进程仍然是父进程的一个副本,因此需要在子进程中调用exceve()函数在当前进程的上下文中加载并运行我们需要的hello程序。execve函数加载并运行可执行文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。execve函数用hello程序有效替代当前程序,需要先删除当前进程虚拟地址的用户部分中的已存在的区域结构;然后映射私有区域,为新程序(即hello)的代码、数据、bss和栈区域等创建新的区域结构。所有这些区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零;然后映射共享区域。如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内;最后设置程序计数器。最后设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。当内核调度这个进程时它将从这个入口点开始执行。
6.5 Hello的进程执行
当子进程调用exceve()函数在上下文中加载并运行hello程序后,hello程序需要内核调度它而不立刻运行。进程调度是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,就说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,使用一种称为上下文切换的机制来将控制转移到新的进程。处理器提供了一种机制限制一个应用可以执行的指令以及它可以访问的地址空间范围。通常用某个控制寄存器的一个模式位来提供这种机制,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程运行在内核模式中,进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置;没有设置模式位时,进程运行在用户模式中,进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据,否则会导致保护故障。运行应用程序代码的进程初始时在用户模式中,进程需要通过中断、故障或者陷入系统调用这样的异常才能从用户模式变为内核模式。由于负责进程调度的是内核,因此内核调度需要运行在内核模式下。当内核代表用户执行系统调用时,可能会发生上下文切换,中断也可能引发上下文切换。同时系统通过某种产生周期性定时器中断的机制判断当前进程已经运行了足够长的时间,并切换到一个新的进程。
6.6 hello的异常与信号处理
hello执行过程中,四类异常都可能会出现,四类异常分别为中断、陷阱、故障和终止。中断,原因为来自I/O设备的信号问题。它是异步的,总是返回到下一条指令。陷阱是有意的异常,是同步行为,也总是返回到下一条指令。故障原因是潜在可恢复的错误,是同步的,有可能返回到当前指令。终止原因为出现了不可恢复的错误,是同步的,不会返回。在hello执行过程中可能发生,如果其他进程使用了外部I/O设备,那么在hello进程运行时可能会出现外部I/O设备引起的中断。将控制传递给适当的中断处理程序,处理程序返回时,就将控制返回给下一条指令,程序继续执行。hello中调用了系统调用sleep会产生陷阱。处理陷阱,将控制传递给适当的异常处理程序,处理程序解析参数,调用适当的内核程序。处理程序返回时,将控制返回给下一条指令。当hello进程刚从入口点开始执行时,会发生缺页故障。hello进程运行的过程中,也可能发生缺页故障。故障的处理:将控制传递给故障处理程序,如果处理程序能够修正这个错误情况,就将控制返回到引起故障的指令并重新执行它;否则终止引起故障的应用程序。hello执行过程中,DRAM或者SRAM可能发生位损坏,产生奇偶错误。发生错误时会将控制传递给终止处理程序,终止引起错误的应用程序。
对于信号处理,本文通过几个信号进行分析。
图6.1 空格信号
图6.2 回车信号
图6.3 Ctrl+C信号
图6.4 Ctrl+Z信号
由图可见空格不会打断信号处理,回车会正常结束程序。输入Ctrl+C,父进程收到SIGINT信号,终止并回收hello进程。输入Ctrl+Z,可见程序处于停止状态。输入pstree显示进程间联系。
图6.5 pstree进程树
6.7本章小结
本章介绍了进程的概念和作用,简述shell的工作过程,并分析了使用fork+execve加载运行hello,执行hello进程以及hello进程运行时的异常/信号处理过程。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以使用&操作读取指针变量的值,这个值就是逻辑地址,是相对于当前进程数据段的地址。一个逻辑地址由两部份组成:段标识符和段内偏移量。线性地址是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址生成了一个线性地址。如果启用了页式管理,那么线性地址可以再变换产生物理地址。若没有启用页式管理,那么线性地址直接就是物理地址。虚拟地址和逻辑地址近似,因为虚拟内存空间的概念与逻辑地址类似,因此虚拟地址和逻辑地址实际上是一样的,都与实际物理内存容量无关。物理地址指存储器中的每一个字节单元都给以一个用来正确地存放或取得信息的唯一的存储器地址,又叫实际地址或绝对地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段标识符和段内偏移量两部分组成。段标识符由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号,是对段描述符表的索引,每个段描述符由8个字节组成,具体描述了一个段。后3位包含一些硬件细节,表示具体是代码段寄存器还是栈段寄存器还是数据段寄存器等。通过段标识符的前13位,可以直接在段描述符表中索引到具体的段描述符。每个段描述符中包含一个Base字段,它描述了一个段的开始位置的线性地址。将Base字段和逻辑地址中的段内偏移量连接起来就得到转换后的线性地址。对于全局的段描述符放在全局段描述符表中,局部的段描述符放在局部段描述符表中。全局段描述符表的地址和大小存放在gdtr控制寄存器中,而局部段描述符表存放在ldtr寄存器中。给定逻辑地址,看段选择符的最后一位是0还是1用于判断选择全局段描述符表还是局部段描述符表。再根据相应寄存器,得到其地址和大小。通过段标识符的前13位,可以在相应段描述符表中索引到具体的段描述符,得到Base字段,和段内偏移量连接起来最终得到转换后的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元,负责把一个线性地址,转换为物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页,这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址。 物理页是分页单元把所有的物理内存也划分为固定长度的管理单位,长度一般与内存页是一一对应的。total_page数组有2^20个成员,每个成员是一个地址。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。分页单元中页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。每个活动的进程都有其独立的对应的虚似内存也对应了一个独立的页目录地址。运行一个进程,需要将它的页目录地址放到cr3寄存器中,将别个的保存下来。每个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位) 依据以下步骤进行转换:
(1)从cr3中取出进程的页目录地址;
(2)根据线性地址前十位,在数组中找到对应的索引项,因为引入了二级管理模式,页目录中的项是页表的地址。页的地址被放到页表中。
(3)根据线性地址的中间十位在页表中找到页的起始地址;
(4)将页的起始地址与线性地址中最后12位相加,得到最终的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
36位的虚拟地址被分割成4个9位的片。CR3寄存器包含L1页表的物理地址。VPN1有一个到L1 PTE的偏移量,找到PTE以后会包含到L2页表的基础地址;VPN2包含一个到L2PTE的偏移量,找到这个PTE以后会包含到L3页表的基础地址;VPN3同理;VPN4包含一个到L4PTE的偏移量,找到PTE以后是相应的PPN。
7.5 三级Cache支持下的物理内存访问
通过7.4 Core i7 MMU使用四级页表来将虚拟地址翻译成物理地址,我们得到了物理地址PA。现在分析三级cache支持下的物理内存访问。如图7-6,以L1 d-cache的介绍为例,L2、L3同理。
L1 Cache是8路64组相联,块大小为64B,因此CO和CI都是6位,CT是40位。根据物理地址(PA),首先使用CI组索引,每组8路,分别匹配标记CT。如果匹配成功且块的有效位是1,则命中,根据块偏移CO返回数据。如果没有匹配成功或者匹配成功但是标志位是1,则不命中,向下一级缓存中取出被请求的块,然后将新的块存储在组索引指示的组中的一个高速缓存行中。一般而言,如果映射到的组内有空闲块,则直接放置,否则必须驱逐出一个现存的块,一般采用最近最少被使用策略LRU进行替换。
7.6 hello进程fork时的内存映射
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为给这个新进程创建虚拟内存,它创建了当前进程的 mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只 读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要先删除当前进程虚拟地址的用户部分中的已存在的区域结构,然后映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,然后映射共享区域,hello程序与共享对象libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内,最后设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
当指令引用一个相应的虚拟地址,而与改地址相应的物理页面不再内存中,会触发缺页故障。通过查询页表PTE可以知道虚拟页在磁盘的位置。缺页处理程序从指定的位置加载页面到物理内存中,并更新PTE。然后控制返回给引起缺页故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中,因此指令可以没有故障的运行完成。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但不失通用性,假设堆是一个请求二进制零的区域,紧接在未初始化数据区域后开始,向上生长。对每个进程,内核维护一个全局变量brk指向堆顶。分配器将堆视为一组不同大小的块的集合来维护。每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留,供应用程序使用;空闲块可用来分配。空闲块保持空闲,直到空闲块显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的(即显式分配器),要么是内存分配器自身隐式执行的(即隐式分配器)。显式分配器和隐式分配器是动态内存分配器的两种基本风格。两种风格都要求应用显式地分配块,不同之处在于由哪个实体来负责释放已分配的块。显式分配器要求应用显式地释放任何已分配的块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。显式分配器必须在一些约束条件下工作:处理任意请求序列;立即响应请求;只使用堆;对齐要求;不修改已分配的块。在这些限制条件下,分配器试图实现吞吐率最大化和内存使用率最大化,但这两个性能目标通常是相互冲突的。分配器的具体操作过程以及相应策略为:
(1)放置已分配块:当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。
(2)分割空闲块:当分配器找到了匹配的空闲块需要决定分配这个空闲块中多少空间。
(3)获取额外的堆内存:如果分配器不能为请求块找到空闲块,分配器通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块将这个块插到空闲链表中,然后被请求的块放在这个新的空闲块中。
(4)合并空闲块:分配器释放一个已分配块时,要合并相邻的空闲块。分配器决定何时执行合并。合并时需要合并当前块和前面以及后面的空闲块。
带边界标签的隐式空闲链表分配器是一个块由一个字的头部、有效载荷、可能的一些额外的填充以及一个脚部。头部位于块的开始,编码了这个块的大小以及这个块是已分配的还是空闲的。由于对齐要求,头部的高位可以编码块的大小,而剩余的几位总是零,可以编码其他信息。使用最低位作为已分配位,指明这个块是已分配的还是空闲的。脚部位于每个块的结尾,是头部的一个副本,是为了方便释放块时的合并操作。头部后面就是调用分配器时请求的有效载荷,有效载荷后面是一片不使用的填充块,大小任意。填充的原因取决于分配器的策略。如果块的格式是如上所述,就可以将堆组织成一个连续的已分配块和空闲块的序列,这种结构为隐式空闲链表。空闲块通过头部的大小字段隐含地连接,可以通过遍历堆中所有的块间接遍历整个空闲块的集合。同时需要一个特殊标记的结束块,这种设置简化了空闲块合并。已分配块的块结构和隐式链表的相同,由一个字的头部、有效载荷、可能的一些额外的填充以及一个脚部组成。而在每个空闲块中增加了一个前驱指针和后继指针。通过这些指针,可将空闲块组织成一个双向链表。空闲链表中块的排序策略包括后进先出顺序、按照地址顺序维护、按照块的大小顺序维护等。而malloc采用的是分离的空闲链表。分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小升序排列,当分配器需要一个大小为n的块时,就搜索相应大小类对应的空闲链表。如果不能找到合适的块,就搜索下一个链表。
7.10本章小结
本章就hello的地址管理展开了一系列讨论。首先介绍了各类地址的概念以及在程序运行中充当的角色,接着进一步分析了从逻辑地址到线性地址的变化(段式管理),以及从线性地址到物理地址的变化(页式管理)。然后借TLB与四级页表支持下的VA到PA的变换详细分析了地址翻译的过程。紧接着分析了三级Cache支持下的物理内存访问,以及hello进程fork和execve时的内存映射,还有缺页故障与缺页中断处理的操作过程。最后通过动态存储分配管理这一节对之前的内容进行了一个整体的梳理,较为完整地阐明了动态内存分配的过程。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件。
设备管理为unix io接口,所有的 IO 设备(如网路、磁盘、终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O。操作为:打开文件;改变当前的文件位置;读写文件;关闭文件。
8.2 简述Unix IO接口及其函数
8.2.1 IO接口
(1)打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数即描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息,应用程序只需要记住这个描述符。
(2)linux shell:创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件<unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
(3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用 程序能够通过执行 seek,显式地将改变当前文件位置 k。
(4)读写文件:一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m字节的文件,当k~m 时执行读操作会触发一个称为end-of-file的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的end-of-file符号。类似地,写操作是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
(5)关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 函数
(1)打开文件
函数原型:int open(char *filename, int flags, mode_t mode);
open函数将filename转换为一个文件描述符并返回描述符数字。flags参数指明了进程打算如何访问这个文件,mode参数则指定了新文件的访问权限位。
(2)关闭文件
函数原型:int close(int fd);
关闭描述符为fd的文件,关闭一个已关闭的描述符会出错。
(3)读和写文件
函数原型:
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf, size_t n);
read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。返回值:成功则返回写的字节数,出错则为-1。
8.3 printf的实现分析
图8.1 printf函数
printf函数会接受字符串指针数组fmt,然后将匹配到的参数按照fmt输出。而vsprintf函数会接受确定输出格式的格式字符串fnt,然后用格式字符串对个数变化的参数进行格式化,产生格式化输出,并返回要打印的字符串的长度。write函数先传递寄存器参数,然后通过系统调用sys_call。syscall函数将字符串中的字节从寄存器中通过总线复制到显卡的显存中。字符显示驱动子程序会从ASCII到字模库到显示vram。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点即输出RGB分量。
图8.2 VSprintf函数
8.4 getchar的实现分析
图8.3 getchar函数
getchar由宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止。当用户键入回车后getchar开始从stdio流中每次读入一个字符,返回值是用户输入的字符的ASCII码,若文件结尾则返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。异步异常-键盘中断时,键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux的IO设备管理方法和Unix IO接口及其函数,并分析了printf和getchar函数的实现。
结论
hello所经历的过程:
源程序:在文本编辑器或IDE中编写C语言代码,得到最初的hello.c。
预处理:预处理器解析宏定义、文件包含、条件编译等,生成ASCII码中间文件hello.i。
编译:编译器将C语言代码翻译成汇编指令,生成ASCII汇编语言文件hello.s。
汇编:汇编器将汇编指令翻译成机器语言并生成重定位信息,生成可重定位目标文件hello.o。
链接:链接器进行符号解析、重定位、动态链接等创建一个可执行目标文件hello。
fork创建进程:在shell中运行hello程序时,shell会调用fork函数创建子进程,供之后hello程序的运行。
execve加载程序:子进程中调用execve函数,加载hello程序,进入hello的程序入口点。
运行阶段:内核负责调度进程,并对可能产生的异常及信号进行处理。MMU、TLB、多级页表、cache、DRAM内存、动态内存分配器相互协作,共同完成内存的管理。Unix I/O使得程序与文件进行交互。
终止:hello进程运行结束,shell负责回收终止的hello进程,内核删除为hello进程创建的所有数据结构。
对计算机系统的设计与实现的感悟:
hello在硬件、操作系统、软件的相互协作配合下,终于完美地完成了它的使命。一个复杂的系统需要多方面的协作配合才能更好地实现功能。底层问题优化可以极大地提升代码运行性能,对这方面的深入理解有助于我们进行代码优化处理。
另:冯诺依曼(此处省略100字)
附件
hello.c:源文件
hello.i:hello预处理后的文件
hello.s:hello编译后的文件
hello.o:hello汇编后的文件
hello_o_elf.txt:hello.o的ELF格式
hello_o.objdump:hello.o的反汇编
Hello:hello链接后的文件
hello_elf.txt:hello的ELF格式
为完成本次大作业你翻阅的书籍与网站等
[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.
(参考文献0分,缺失 -1分)