计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机类
学 号 1190200416
班 级 1903005
学 生 陈睿奕
指 导 教 师 史先俊
计算机科学与技术学院
2021年5月
本篇论文探讨了一个c程序是怎么被一步步处理变成一个可执行文件,又是怎么从一个可执行文件被加载到内存中成为一个运行中的进程,最后是怎么从内存中被删去的。文章将从计算机底层的角度一步一步分析操作系统是利用了什么原理,怎么实现这些过程的。帮助读者梳理漫游计算机系统。
关键词:预处理;编译;汇编;链接;进程;异常与信号;虚拟内存;地址翻译;I/O
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
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 -
8.1 Linux的IO设备管理方法.......................................................................... - 13 -
8.2 简述Unix IO接口及其函数....................................................................... - 13 -
第1章 概述
1.1 Hello简介
P2P(Program to Process)过程:
在linux环境下,hello的 P2P的过程指的是hello的源程序,即hello.c经过cpp的预处理得到hello.i文件、经过cc1编译生成hello.s的汇编文件、经过as的处理便为可重定位目标文件hello.o、最后由ld链接生成可执行文件hello。之后用户通过shell键入./hello命令开始执行程序,shell通过fork函数创建一个子进程,再由子进程执行execve函数加载hello。以上就是hello从源程序(program)到一个被执行的进程(process)的P2P过程了。
020(Zero-0 to Zero-0)过程:
020过程指的是再execve执行hello程序后,内核为hello进程映射虚拟内存。在hello进入程序入口后,hello相关的数据就被内核加载到物理内存中,hello程序开始正式被执行。为了让hello正常执行,内核还需要为hello分配时间片、逻辑控制流。最后,当hello运行结束,终止成为僵尸进程后,由shell负责回收hello进程,删除与hello有关的数据内容。
1.2 环境与工具
硬件环境:Intel Core i5 8300H CPU、2.3GHz、8G RAM、512GHD Disk
软件环境:Windows10 64位、Vmware、Ubuntu 18.04
开发与调试工具:Codeblocks、gcc、Objdump、edb、Hexedit
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 文件作用 |
hello.c | 程序的源代码 |
hello.i | hello.c文预处理后的文本文件 |
hello.s | hello.i文件编译后的汇编文件 |
hello.o | hello.s汇编后的可重定位目标文件 |
hello | hello.s链接后的可执行文件 |
1.4 本章小结
本章概述了一个c程序从源代码到可执行文件再到运行中的进程,最后再到运行结束被清除数据的P2P和020过程。是整篇论文总起论述部分,为后续展开说明做铺垫。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:
预处理指的是cpp预处理器根据以#开头的命令修改c源程序(比如对宏#define PI 3.14,就将程序中的所有PI替换成3.14),并生成.i目标文件的过程。
作用:
- 实现条件编译,通过预处理可以实现部分代码的在某些条件下的选择性编译。
- 实现宏定义,在预处理阶段用定义的实际数值将宏替换。
- 实现头文件引用,将头文件的内容复制到源程序中以实现引用。
- 实现注释,将c文件中的注释从代码中删除。
- 实现特殊符号的使用。如处理#line、#error、#pragma以及#等。
2.2在Ubuntu下预处理的命令
使用预处理命令:gcc hello.c -E -o hello.i 对hello.c进行预处理:
图2.2.1
2.3 Hello的预处理结果解析
首先可以看到在打开shell的目录下新增了hello.i文件
图2.3.1
打开hello.i文件,我们可以看到hello.i的文本量十分要比hello.c大出了不少
图2.3.2
这是因为.c头文件中的内容被复制到了.i文件中。同时如图④,我们还可以看到.c文件中的注释被删去了:
图2.3.3
2.4 本章小结
本章介绍了在预处理的概念与作用,并展示如何在ubuntu下用gcc对.c文件进行预处理,并分析了预处理后文本的变化,为文件后续的操作打下基础。
(第2章0.5分)
3.1 编译的概念与作用
概念:对.i文件的编译指的是编译器cc1将.i文件从c语言翻译为汇编文本.s的过程
作用:编译将高级语言转化为汇编语言能够检测代码的正确性,并为接下来将汇编语言生成机器可识别的机器码做准备。
3.2 在Ubuntu下编译的命令
在ubuntu下对.i文件进行编译的指令是:gcc -S -o hello.s hello.i
图3.2.1
3.3 Hello的编译结果解析
3.3.0汇编代码:
3.3.1 数据:
字符串:如图,字符串被放在只读数据段中。
图3.3.2
局部变量i:如图,局部变量i被放在栈中,通过rbp的相对偏移来访问。
图3.3.3
主函数传递参数argc、argv:如图,这两个参数初始时分别被放在edi和rsi上,并在主函数中将其压栈,同样通过rbp的偏移量来访问。之所以这么判断的根据是64位系统函数间传递参数时使用寄存器的顺序。
图3.3.4
3.3.2 赋值:在原本c程序中的赋值操作只有i = 0这个赋值语句。在汇编中是通过mov立即数的方式体现的,如图:
图3.3.5
处理c语言中翻译过来的赋值操作,在汇编代码中还有通过以lea(地址传递)来赋值的方式,如图:
图3.3.6
算数操作:c代码中的算术操作有i++,在汇编代码中是通过add来实现的:
图3.3.7
除了c代码中的算数操作,在汇编代码中还有通过sub(减操作):
图3.3.8
3.3.3 类型转化:c程序中设计到的类型转化只有atoi,在汇编代码中同样是调用atoi函数进行,由于接下来要展开讲汇编代码中的函数操作,因此在这里就不赘述,仅展示函数的调用:
图3.3.9
3.3.4 关系操作:在c代码中有的关系操作时argc != 4以及i < 8在汇编代码中是通过cmp比较来完成的,如图:
图3.3.10
图3.3.11
3.3.5 数组:c代码中的数组访问有argv[1]、argv[2]、argv[3],在汇编代码中访问这三个量是通过数组首地址加偏移量的方式,如图:
图3.3.12
argv[2]被放在rdx中,
图3.3.13
argv[1]被放在rsi中,
图3.3.14
argv[3]被放在rdi中。
3.3.6 控制转移:在c源程序中的控制转移有if语句和for循环,在汇编代码中这两者都是通过条件跳转指令来完成的。如图:
图3.3.15
图 3.3.16
3.3.7 函数操作:在64位系统中,参数的传递首先是通过寄存器,顺序是rdi、rsi、rdx、rcx、r8、r9,其余参数压栈。
图3.3.17
如图,我们的汇编代码在调用printf函数之前就先把格式串和输出的内容分别压入rdi、rsi、rdx中。
函数的调用使用的是call语句,如果用立即数驱动call语句就必须要计算函数所在位置和rip的相对偏移量。
图3.3.18
可以看到,我们汇编代码中的函数调用用的都是call加上函数名的方法。
函数一般都通过ret指令返回,返回去往往要通过leave函数等方式进行堆栈平衡,返回值一般都存放在rax中,如图:
图3.3.19
这一张图就包含了返回值存放、堆栈平衡、函数返回。
3.4 本章小结
本章介绍了.i文件被编译为汇编代码的过程。阐述了高级语言中各个操作是如何在汇编程序中进行的,能够帮助我们理解高级语言到汇编语言之间的转化过程。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:汇编指的是汇编器ad将.s文件翻译成机器语言,并将其生成后缀名为.o的可重定位目标文件的过程。
作用:将.s文件生成机器码,使其能够被链接器ld链接生成可执行文件。
4.2 在Ubuntu下汇编的命令
在ubuntu下使用gcc -c -o hello.o hello.s指令进行汇编。
图 4.2.1
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
4.3.1 elf格式:
图 4.3.1
4.3.2 ELF头:
图 4.3.2
ELF头中的Magic魔数是一个定值,在程序执行时会检查魔数是否正确,如果不正确则拒绝加载。 ELF头告诉了我们文件的基本信息,如我呢见的类别是ELF64,文件中的数据是按照2的补码储存的,文件的类型是可重定位文件,节头大小为64字节,节头的数量为13个。
4.3.3 节头表:
图 4.3.3
节头表告诉了我们每个节的大小、名称、类型、读、写、执行权限以及对其方式。由于我们的程序还未进行链接,因此每个节的起始位置都是0,在链接后会为每个节进行重定位以获得起始位置。
4.3.4 符号表
图 4.3.4
符号表中存放着程序定义和引用的全局变量和函数。同样由于还未进行链接重定位,偏移量Value还都是0。
4.4.5 重定位节:
图4.3.5
重定位节包含了.text文件中需要重定位的信息,在链接器将目标文件与其它文件进行链接时需要修改这些信息,可执行文件中不包含重定位节。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
图4.4.1
图4.4.2
反汇编代码和汇编代码在指令格式上非常相似,但在以下几个方面存在着不同:
- 立即数的引用不同,在反汇编中立即数是十六进制的,而汇编代码则是十进制。
- 子程序的调用不同,反汇编代码中子程序的调用是通过对主函数地址的相对偏移进行的,而在汇编代码中则是通过call直接加上函数名的方法进行的。
- 分支跳转不同,在反汇编代码中,分支转移是通过跳转到以主函数地址为基址的一个偏移地址中,而在汇编代码中则是通过.L4、.L3这样分块的方式来跳转的。
综上所述,反汇编代码与汇编代码在指令上是一一对应的关系,只有在一些特殊的指令需要有引用的转化,其他地方几乎完全一致。
4.5 本章小结
本章介绍了程序的汇编过程,分析了一个可重定位目标文件中的内容,剖析了这些内容在接下来的链接过程中能起什么作用,并对比了汇编与反汇编代码有何异同。充分理解这一章的内容能够帮助我们理解链接的过程。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存中并执行。链接可以中兴于编译时、也就是源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时。也就是由应用程序来执行。
作用:链接器在软件开发过程中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。
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进行链接。
图5.2.1
5.3 可执行目标文件hello的格式
5.3.1 ELF头:
图 5.3.1
可以看到,可执行文件的ELF头与可重定位目标文件的ELF头有以下几个不同:
- 文件的类型不同,可执行文件的类型不再是REL而是EXEC。
- 程序的入口点不一样,因为连接上了库文件,使得main函数不再是从0x0开始。同理节头的开始位置也发生了变化。
- 节头的数量产了变化。
5.3.2 节头表:
图5.3.2
可以看到与hello.o不同,在可执行文件中经过重定位每个节的地址不再是0,而是根据自身大小加上对齐规则计算的偏移量。比如.hash的地址,计算方式是.note.ABI-tag的地址0x40021c加上.note.ABI-tag的大小0x20,得到0x40023c,再对.hash要求的8字节对齐进行调整,得到最终地址0x40024。
5.3.3 符号表:
图 5.3.3
观察我们可以发现,在可执行文件中多出了.dynym节。这里面存放的是通过动态链接解析出的符号,这里我们解析出的符号是程序引用的头文件中的函数。
5.3.4 重定位节:
图 5.3.4
可以看到重定位节的偏移量与hello.o已经完全不一样了。
5.4 hello的虚拟地址空间
根据节头表的信息,我们可以知道ELF是从0x400000开始的
图 5.4.1
.text节是从0x400550开始的,和ELF头中程序的入口一致
图 5.4.2
在edb反汇编代码部分查看这部分内容,确实可以看到这里是函数的入口
图 5.4.3
.rodata段从0x400690开始
图 5.4.4
.data段从0x601048开始
图5.4.5
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
首先可以看到的不同是文件中多了很多函数,这是因为链接的时候将头文件链接到了可执行文件中。
图 5.5.1
然后观察主函数,我们不难发现在hello.o反汇编代码中出现以main加上相对偏移的跳转已经全部被重写计算,这是因为在重定位后main函数有了全新的地址,使得这个计算成为可能。同时对子函数的call引用也在重定位后重写计算来了。
图 5.5.2
综上所述,重定位的大体过程是链接器ld将所有链接文件中相同的节合并,并按照要求计算新的偏移地址赋值给新的节。同时链接器按链接指令的顺序搜索符号表,查找符号引用。
5.6 hello的执行流程
如图,进入edb后第一个调用的程序是_dl_start:
图5.6.1
地址是0x7f035f630ea0
图5.6.2
通过step over单步运行程序,下一个进入的程序是_dl_init
图 5.6.3
地址是0x7f035f63f7d0
图 5.6.4
继续运行,程序通过jmp进入_start:
图 5.6.5
图 5.6.6
地址是0x400550,此时程序已经进入了函数入口。
在_start函数中通过call指令进入_libc_start_main函数
图 5.6.7
图 5.6.8
地址是0x7fd34770bb10。
之后在_libc_start_main函数中进入_cxa_atexit:
图 5.6.9
地址是0x7f03a4843b57。
之后通过rbp跳转到libc_csu_init函数:
图 5.6.10
图 5.6.11
地址是0x400610。
然后进入_setjmp函数:
图 5.6.12
地址是0x7f03a4860d30。
图 5.6.13
之后程序进入main函数:
图5.6.14
地址是0x400582。
最后从主函数返回,调用exit退出程序:
图 5.6.15
地址是0x400530。
图5.6.16
5.7 Hello的动态链接分析
在程序中动态链接是通过延迟绑定来实现的,延迟绑定的实现依赖全局偏移量表GOT和过程连接表PLT实现。GOT是数据段的一部分,PLT是代码段的一部分。
PLT数组中每个条目时16字节,PTL[0]是一个特殊的条目,他跳转到动态链接器中。每个可被执行程序调用的库函数都有自己的PLT条目。PLT[1]调用__libc_start_main函数负责初始化。
GOT数组中每个条目八个字节。GOT[0]和GOT[1]中包含动态链接器解析地址时会用的信息,GOT[2]时动态练级去在ld-linux.so模块的入口点。其余的每一个条目对应一个被调用的函数。
通过5.3.2的节头表我们可以找到.GOT.PLT的数据从0x601000开始。
图5.7.1
如图,这是未调用init前的GOT.PLT表。
图5.7.2
这是调用init后的GOT.PLT表。
经过初始化后,PLT和GOT表就可以协调工作,一同延迟解析库函数的地址了。
5.8 本章小结
本章介绍了程序链接已经加载动态库的过程。这是程序生成可执行文件的最后一步,也是将大型程序项目分解成小模块的关键所在。本章通过可执行文件的
程序头来分析重定位的过程,并解析了一个程序运行的全过程。最后简单介绍了动态链接这一现代计算机中极为重要的部分是怎么运作的。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:
进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。
作用:
它提供一个假象,好像我们的程序独占地使用处理器。
它提供一个假象,好像我们的程序独占的使用系统内存。
6.2 简述壳Shell-bash的作用与处理流程
shell的作用:shell作为UNIX的一个重要组成部分,是它的外壳.也是用户与UNIX系统的交互作用界面.Shell是一个命令解释程序.除此,它还是一个高级程序设计语言。用shell编写的程序称为shell过程。shell的一项主要功能是在交互方式下解释从命令行输入的命令。shell的另一项重要功能是制定用户环境,这通常在shell的初始化文件中完成。shell还能用作解释性的编程语言。
处理流程:
1、从终端读入输入的命令。
2、将输入字符串切分获得所有的参数
3、如果是内置命令则立即执行
4、否则调用相应的程序执行
5、shell 应该接受键盘输入信号,并对这些信号进行相应处理
6.3 Hello的fork进程创建过程
首先我们需要打开shell,使用./hello 1190200416 陈睿奕 1&命令来运行hello程序。
图6.3.1
由于我们输入的不是一条内置命令,因此为了执行我们的命令,shell会通过fork函数创建一个子进程。
这样通过shell,我们的hello子进程就被创建了。
6.4 Hello的execve过程
光有子进程是不够的,为了运行我们的hello函数,shell还必须调用execve函数。
execve函数加载并允许可执行目标文件hello,并带参数列表argv和环境变量列表envp。execve只有当出现错误时才会返回到调用程序,因此execve调用一次从不返回。
利用虚拟内存部分的知识,我们还能知道execve在运行时需要以下几个步骤:
- 删除已经存在的用户区域。
- 映射私有区域。
- 映射共享区域。
- 设置程序计数器。
6.5 Hello的进程执行
在6.1中我们说到进程给我们一种程序独占的使用处理器的假象。由于现代计算机几乎在所有时刻都有超过一个进程在运行,因此为了让多个进程在并发运行的时候还能维持这一假象,我们需要用到上下文这一概念以及上下文切换这一操作。
图6.5.1
上下文的概念: 上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
上下文切换: 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用的时可能会发生上下文切换,当调用sleep的时候内核也可也觉得执行上下文切换。上下文切换的过程如图所示:
图 6.5.2
以hello为例,hello程序在调用了sleep程序后会陷入内核状态,内核可能会进行上下文切换。到程序运行到getchar的时候,内核也会进行上下文切换,让其他进程运行。除了这些,系统还会为hello程序分配时间片,即使没有执行到getchar或者sleep函数,只要hello时间片被用完,系统就会判断当前程序以及执行够久了,从而进行上下文切换,将处理器让给其他进程。
6.6 hello的异常与信号处理
6.6.1 异常与信号概述
异常可以分为如下四类:中断、陷阱、故障和终止。异常的同步异步指的是异常的发生和程序的关系。比如从键盘输入crtl+c作为异步异常与程序的执行没有关系。而缺页异常这样的同步异常是随着程序的执行产生的。
类别 | 原因 | 异步/同步 | 返回行为 |
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 不可恢复的错误 | 同步 | 不会返回 |
在hello执行的时候第一次映射物理地址会产生缺页异常,执行sleep函数的时候会陷入系统代码,即产生陷阱异常。还可能由用户键入中断指令。
程序的异常往往时通过信号来处理的。
在hello程序执行的时候如果从键盘键入ctrl+c这样就会受到SIGINT的终止信号,其他来自键盘的信号也类似。在hello结束的时候会向shell发送SIGCHLD信号告诉shell自己运行结束了。
6.6.2 通过键盘向hello发送信号。
先来看乱按会发生什么:
图 6.6.1
可以看到,虽然乱按不会影响程序的运行,但是会在程序运行结束后对shell发送许多无效指令,不过要注意因为hello程序最后有一个getchar,因此第一个乱按的指令被getchar给读走了,不会成为发送给shell的无效指令。因为我们可以判断,我们乱按的内容被放入缓冲区,等待程序执行结束被shell当作命令读走。
然后再来看按回车会发生什么:
图 6.6.2
可以看到,输入回车后程序的行为和乱按几乎是一致的,只是回车没有被识别为无效指令而是被无视。
接下来看看输入ctrl+z会发生什么:
图 6.6.3
可以看到输入ctrl+z后程序被放入后台并暂停运行。
再来看输入ctrl+c会发生什么:
图 6.6.4
可以看到,输入ctrl+c后程序直接结束运行,回到shell等待输入下一条指令。
接下来让我们研究一个被ctrl+z暂停的程序:
首先来看ps命令:
图 6.6.5
通过ps指令我们可以看到当前在运行的进程及其pid。
然后是jobs:
图 6.6.6
通过jobs命令我们可以看到所有在执行的命令。
接下来是pstree:
图6.6.7
通过pstree我们可以看到所有进程之间的父子关系,可以看到我们的hello进程是shell(bash)创建的进程。
在之后是fg命令:
图6.6.8
通过执行fg命令我们可以让暂停的进程重新开始工作。
最后是kill命令:
图 6.6.9
kill原意只是杀死一个进程,但时至今日kill以及成为发送信号的工具,在这里我选择用9号信号杀死进程。可以看到kill成功的杀死了一个进程,fg无法将其唤醒。
6.7本章小结
本章介绍了计算机中最重要的概念之一——进程。可以说现代计算机离不开进程的概念,只有充分了解进程的创建与异常流控制,才可能成为一个优秀的程序员。这章我们讲述了一个进程是怎么在计算机中被创建的,一个程序是怎么通过子进程被执行的,这是P2P中的最后一步process。这一章我们还介绍了异常与信号,并实际对hello用各种信号进行测试,来了解常用信号的用途。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址指的是在汇编代码中通过偏移量+段基址得到的地址,与物理地址不同。在hello反汇编代码中我们能够看到的就是逻辑地址。
线性地址:线性地址就是虚拟地址,具体见下。
虚拟地址:虚拟地址是逻辑地址计算后的结果,同样不能直接用来访存,需要通过MMU翻译得到物理地址来访存。在hello反汇编代码计算后就能得到虚拟地址。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有一个唯一的物理地址。第一个字节的地址为0,写下来的字节地址为1,再下一个为2,以此类推。虚拟地址通过MMU翻译后得到物理地址。在hello中通过翻译得到的物理地址来得到我们需要的数据。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段标识符:段内偏移量组成。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。如图:
图 7.2.1
所有的段由段描述符描述,而多个段描述符能组成一个数组,我们称成功数组为段描述表。段描述符中的BASE字段对我们翻译线性地址至关重要的。
BASE字段,表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。
为了得到BASE字段,我们利用索引号从GDT(全局段描述表)或LDT(局部段描述符表)中得到段描述符。选择GDT还是LDT取决于段选择符中的T1,若T1等于0则选择GDT,反之选择LDT。
这样我们就得到了BASE。最后通过BASE加上段偏移量就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址也就是虚拟地址,我们一般通过页表来获得虚拟地址到物理地址的映射。
页表是一个关于页表条目PTE的数组。页表条目由有效位和物理页号组成。
一个虚拟页只有如下三个状态:
- 未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何内存。
- 缓存的:当前已缓存在物理内存中的已分配页。
- 未缓存在虚拟内存中的已分配页。
如图:
图 7.3.1
结合以上两点,我们就可以按下图的方法通过页表将虚拟页映射到物理页上。
图 7.3.2
接下来我们来讨论地址的翻译,由于接下来要分析多级页表,因此在这里我只论述一级页表的情况。
首先我们将n为的虚拟地址拆分成p为的虚拟页面偏移VPO和n-p位的VPN。我们通过VPN找到页表,并通过页表来获得虚拟页号,将m-p位的物理页号和p位的虚拟页面偏移组合在一起(虚拟页面偏移等价于物理页面偏移,因为物理内存映射的是虚拟内存的一整页。)就得到了m位的物理地址。如图:
图 7.3.3
7.4 TLB与四级页表支持下的VA到PA的变换
我们先来分析TLB,TLB也就是翻译后备缓冲器是抱一个包含在MMU中的小缓存,其每一行都由一个PTE组成。TLB将一个n-p位VPN分为t位的组索引和n-t-p位的标记。
图7.4.1
在访问时与cache几乎一致,先通过组索引找到所在组,在通过标记位判断是否是我们要访问的虚拟地址,如果命中则从中读取物理页号,并通与VPO组合成物理地址访问数据并将数据返回给CPU。如果不命中则必须从下一级TLB或者内存中寻找。
为了通过多级页表的VA到PA的变化我们需要重新划分虚拟地址。假设我们有一个48位的虚拟地址,我们将其划分成四个九位的VPN和一个12位的VPN,如图:
图 7.4.2
翻译地址的过程如下:首先通过CR3寄存器中存放的一级页表的地址结合VPN读取其中的数据。一到四级页表的页表条目如图所示:
图 7.4.3
图7.4.4
可以看到一到三级页表中存放的数据是指向下一级页表的首地址,而不是物理页号。逐步访问到第四级页表,第四级页表中装的就是物理页号,通过第四级页表读出的物理页号链接上虚拟地址中的VPO就可以获得物理地址了。翻译过程如图示:
图 7.4.5
要注意由于每一级页表条目都有权限,如果权限出错就会产生段错误。
7.5 三级Cache支持下的物理内存访问
Cache的访问并不复杂,对Cache的访问需要把一个物理地址分为标记、组索引、块偏移三个部分。首先我们通过组索引来找到我们的地址在Cache中所对应的组号,再通过标记和Cache的有效位来判断我们的内容是否在Cache中。若命中则通过块偏移读取我们要的数据,若不命中则从下一级Cache中寻找(下一级Cache不一定真的是Cache,比如对L3来说,它的下一级Cache就是主存)。
先来讨论一级Cahce,Core i7CPU的L1 Cache大小为32kb,每组八路,每个块大小为64字节。通过计算可以得出这个Cahce一共有64组。而我们知道,i7CPU的物理地址是52位,因此我们可以分析出这个Cache对物理地址的划分如图:
图 7.5.1
通过MMU将虚拟地址转化成物理地址后,计算机就通过提取中的组索引在L1中搜索组,再通过标记位匹配。如果匹配成功且有效位是1,则将块偏移指向的块中的内容交还给CPU,否则未命中,需要从下一级Cache中在重复上述操作。当我们找到内容后需要将内容写回我们的L1中,如果L1中没有空闲块,即有效位为0的块则需要牺牲一块内容,我们通常采用LRU算法来进行这一过程。对L2、L3的访问也是这样,因此就不再赘述。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并给他分配一个唯一的PID。为了给这个进程创建虚拟内存,它创建了mm_struct、区域结构和页表的原样副本。他将两个进程中的每个页面都标记从只读,并将两个进程中的每一个区域结构都标记位私有的写时复制。当这两个进程中的任何一个后来进行写操作时,写时复制机制就会创建新的页面。
7.7 hello进程execve时的内存映射
假设我们运行execve(“hello”, NULL, NULL);
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效的替代了当前程序。加载并运行hello需要以下结构步骤:
1、删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存 在的区域结构。
2、映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结 构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长度为零。
3、映射共享区域,hello 程序与共享对象 libc.so 链接,libc.so 是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程 上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障概念:当指令引用一个虚拟地址,而与该地址相对于的物理页面不在内存中,因此必须从磁盘中取出时,就会发送缺页故障。
缺页中断处理:假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常当值控制转移到内核的缺页处理程序,处理程序随后指向下面的步骤:
- 虚拟地址A是合法的吗?如果不合法就出发段错误。
- 试图访问的内存是否合法?如果试图进行访问的内存不合法,那么缺页处理程序会触发一个保护牙齿,从而终止这个程序。
- 此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法操作造成的。他是这样来处理这个缺页的:选择一个牺牲页,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动导致缺页的指令,这条指令将再次发送A到MMU。这次MMU就能正常的翻译A,而不会再产生缺页中断了。
图 7.8.1
7.9动态存储分配管理
7.9.1 动态分配器介绍:
虽然可以使用低级的mmap和munmap函数来创建和删除虚拟内存区域,但是C程序员还是会觉得当运行时需要额外虚拟内存时,用动态内存分配器更方便,也有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存域,称为堆。堆每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器有两种基本风格。两种风格都要求应用显示地分配块。它们的不同之处在于由哪个实体负责释放已分配的块。
显式分配器:要求应用显式地释放任何已分配的块。例如,c标准库提供一种叫做malloc程序包的显式分配器。c程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。c++中的new和delete操作符与c中的malloc和free相当。
隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集,例如Lisp,ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.9.2 带边界标签的隐式空闲链表分配器
带边界标签的隐式空闲链表与普通的空闲链表不同,一个块除了是由一个字的头部、有效载荷、可能的一些额外的填充组成外,还有一个与头部相同的脚部组成。头部和脚部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
我们称这种结构称为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。在带边界标签的隐式空闲链表中,我们的脚部就标记了一个块的结束。
合并的时候分配器就可以通过检查脚部来检查前一块的状态和大小了。
7.9.3 显式空间链表
将空闲块组织为某种形式的显示数据结构是一种更好的方法,因为根据定义,程序不需要一个空闲块的主体,所以实现空闲链表数据结构的指针可以存放在这些空闲块的主体里面。
显式空闲链表是将对组织成双向链表。在每个空闲块的主体中,都包含一个pred(前驱)和succ(后继)指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。
另一种方法是按照地址顺序来维护链表,其中链表中的每一个块的地址都小于它后一个块的地址,在这种情况下释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章介绍了程序是如何组织储存器的。先从程序所使用的不同地址开始,分别介绍了逻辑地址、虚拟地址(线性地址)以及物理地址。并介绍了计算机是怎么一步步将地址从逻辑地址变化到虚拟地址再从虚拟地址变化到物理地址的。其中着重介绍了虚拟地址和物理地址之间的映射,以及进程是怎么映射到虚拟地址空间的。之后还介绍程序是怎么利用Cache来获取物理地址中所存放的数据的。最后简单介绍了虚拟地址中极为重要的概念——缺页异常,以及简单介绍了动态内存分配机制。
充分理解这一章的内容可以帮助我们了解一个程序内部的数据是怎么组织的,可以帮助我们更好的了解程序是怎样正确运行的,是菜鸟程序员升级成高级程序的必经之路。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个linux文件就是一个m个字节的序列。所有的I/O设备(如网络、磁盘和终端)都被模型话为文件,而所有的输入和输出都被当做相应的文件的读和写来执行。这种将设备优雅地映射为文件的方式,运行linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口:
根据8.1中描述的Unix I/O接口的概念,我们可以确定I/O接口需要有如下结构功能:
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想 要访问一个 I/O 设备
- Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。
- 改变当前文件的位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。
- 读写文件。一个读操作就是从文件复制n > 0个字符到内存,从当前文件位置k开始,然后k += n。对给定一个大小为m字节的文件,当k>=m时执行读操作会出发一个称为EOF的条件。
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
8.2.2 Unix I/O函数:
1、打开文件函数:int open(char *filename, int flags, mode_t mode);
flag参数为写提供一些额外的指示,mode指定了访问权限。
2、关闭文件函数:int close(int fd);
fd是打开文件时的返回值。
3、读文件函数:ssize_t read(int fd, void *buf, size_t n);
4、写文件函数:ssize_t write(int fd, const void *buf, size_t n);
8.3 printf的实现分析
首先来看printf函数的函数体
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
其中传递参数中的...表示不确定个数。
函数中的va_list实际上就是typedef后的char*。而va_list arg = (va_list)((char*)(&fmt) + 4);这句操作实际上就是得到了...中的第一个量。
之后我们调用vsprintf函数。vsprintf函数将我们需要输出的字符串格式化并把内容存放在buf中。并返回要输出的字符个数i。然后调用系统函数write来在屏幕上打印buf中的前i个字符,也就是我们要输出的格式串。
调用write系统函数后,程序进入到陷阱,系统调用 int 0x80或syscall等,通过字符驱动子程序打印我们的线性。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
最后程序返回我们实际输出的字符数量i。
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;
}
可以看到getchar的底层实现是通过系统函数read实现的。getchar通过read函数从缓冲区中读入一行,并返回读入的第一个字符,若读入失败则返回EOF。read的具体实现如下:
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章简单的介绍了Unux的I/O操作是如何进行的,以及hello中设计到的两个I/O函数printf和getchar是怎么实现的。在我看来Unix I/O是一个非常有趣且成功的抽象,因为它把一切输入输出都归结为对文件的操作,而且这一抽象是十分成功的,因为I/O的过程本质上就是一个对信息交换的过程,因此把所有与程序进行信息交换的主体,比如网络设备当作文件是完全没问题的。这种抽象不仅可以简化计算机的设计,还能更好的帮助我们理解学习系统级I/O。
(第8章1分)
结论
hello经历的过程:
首先我们用高级语言,如c语言,为hello写下源代码,然后交由编译器进行编译成可执行文件。一般来说我们使用的都是像CodeBlock这样的集成环境来进行编译的。然而这个编译的过程实际上又分为很多步,首先使用cpp进行预处理,生成后缀名为.i的文件。然后用cc1编译器进行编译,将预处理过的.i文件翻译成.s汇编文件。之后通过as汇编器进行汇编,将汇编文件生成.o可重定位文件。最后通过链接将其与库文件链接并进行重定位生成可执行文件hello。这是一个hello成为可执行文件的过程。
接下来要让hello成为一个运行中的进程。我们通过shell键入./hello 1190200416 陈睿奕 1的命令,其中./hello是让shell运行hello,而后面的参数则是我们要通过命令行传递给hello的参数。shell接受到命令后会解析命令,当确定./hello是一条来自外部的命令而不是内置命令时会创建为hello创建一个子进程。此时内核还会为其分配一段虚拟内存。最后再通过execve来执行hello函数。这样一个hello进程就被创建出来了,当hello进入函数入口开始运行时,虚拟内存会产生缺页异常,从而将hello程序所需要使用的信息交换到主存,并为其分配物理地址。这样一个hello进程就有了鲜活的生命。
最后是hello生命的终结,当hello程序运行结束,通过return返回后会向shell发送一个SIGCHLD信号。通过这个信号,shell获悉了hello的结束,并从主存中删除与其相关的所有数据,一个hello的进程也彻底宣告消亡。
感悟:
这次大作业中我印象最深的就是抽象。计算机系统中无不充斥着抽象这一概念,进程是抽象,虚拟内存是抽象、I/O也是抽象。学习这门课以前我一直很惧怕抽象这一概念,因为抽象往往预示着复杂。但学完这门课后我才发现,并不是抽象复杂,而是一个问题本身复杂所以才需要抽象。所以抽象不仅不意味着复杂,反而意味着简化。就比如对I/O抽象成对文件的操作,这是我印象最深的抽象。如果计算机的设计者没有这一抽象工具那么他们就不得不考虑各式各样的I/O情况。而随着现代科技日新月异的发展,这样的作法是绝对跟不上时代的。同时,作为I/O设备的生产者,如果要时时刻刻地考虑操作系统是否兼容自己的设备也很难有高创新力。而有了这样一个抽象,计算机设计者只要做好对文件的操作、外接设备的设计者也只要做好将自己的设备设计成支持文件操作的形式就可以各司其职平行的进行生产。这真的是非常神奇且伟大的概念。
由此我产生思考,是否可以将世间一切的设计都用抽象的方式构建模型,并将模型留出多个接口(就像计算机对文件操作和设备支持被文件操作一样)来让所有的东西都分开设计。或者在此基础上进行多步的抽象,比如鼠标键盘这类的操作设备能否在进行一层抽象,让这层抽象去满足文件操作的接口,从而简化鼠标键盘本身的设计?
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名 | 文件作用 |
hello.c | 程序的源代码 |
hello.i | hello.c文预处理后的文本文件 |
hello.s | hello.i文件编译后的汇编文件 |
hello.o | hello.s汇编后的可重定位目标文件 |
hello | hello.s链接后的可执行文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[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分)