程序人生-Hello’s P2P

摘 要

摘要是论文内容的高度概括,应具有独立性和自含性,即不阅读论文的全文,就能获得必要的信息。摘要应包括本论文的目的、主要内容、方法、成果及其理论与实际意义。摘要中不宜使用公式、结构式、图表和非公知公用的符号与术语,不标注引用文献编号,同时避免将摘要写成目录式的内容介绍。

关键词:

hello;预处理;编译;汇编;链接;进程;存储;IO

摘要:

本论文通过讨论一个简单的hello程序在整个计算机系统中的生命流程来贯穿本学期学到的计算机系统知识,使我们对庞大而精细的计算机系统有一个更深入的了解。hello的一生如下:编写并保存源码,预处理,编译,汇编,链接,shell创建新进程,使用加载器加载hello进程并建立虚拟内存映射,可执行目标文件从外部存储经过IO桥进入内存,在运行过程中接收并处理信号,通过MMU访存,最后调用Unix IO函数输出,之后程序返回,进程被回收。过程中我们通过gdb,edb,gcc等一系列工具查看hello的一生中的具体某个过程,并对某些过程进行一些比较和验证。最终我们达到了目的,对上述整个过程有了较为精细的理解,对辅助我们的计算机系统课程学习起了很大作用。
(摘要0分,缺失-1分,根据内容精彩程度酌情加分0-1分)

第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P过程:

  1. 编写并保存源程序.c文件。
  2. 预处理器cpp根据include语句把头文件内容添加到这个文件,根据define语句做宏替换等等,最终形成.i文件。
  3. 编译器cc1将.i文件编译成.s汇编程序文件。
  4. 汇编器as将.s文件中的汇编程序翻译成机器语言,生成一个.o可重定位目标文件。
  5. 链接器ld将我们生成的.o文件和程序中用到的printf等库函数对应的.o文件进行链接,生成一个可执行目标文件hello
  6. 我们在Bash里输入一行命令,告诉壳我们要运行hello程序。
  7. Bash通过fork创建一个子进程;通过execve将hello文件载入到这个进程中;通过mmap建立虚拟内存映射;设置当前进程的上下文中的程序计数器,使之指向程序入口处。CPU分给这个进程时间片,使它得以在硬件上流水线化执行。
  8. 程序运行起来需要访存时,CPU上的内存管理单元MMU会先将虚拟内存地址翻译成物理内存地址:首先去页表的缓存TLB里查,查不到的话再去物理内存上的4级页表查,如果不命中要触发缺页异常更新页表,最终页表命中,根据页表项将虚拟内存地址翻译成物理内存地址。然后根据物理地址先去CPU上的高速缓存查,不命中就需要再到内存里去查。
  9. 程序执行途中可能有程序调用kill函数或者键盘按下Ctrl+C或Ctrl+Z,使内核发送信号,hello所在进程可能会对这些信号做出反应,比如终止或停止。
  10. 程序终止后,向父进程发送SIGCHLD信号,hello所在进程被回收。

020过程:执行程序开始时,一直到建立虚拟内存映射,hello文件内容都还没有进入物理内存,我们可以认为这时都还是“0”。通过一次缺页中断,hello文件被写入物理内存,这时变成了“1”,然后经过上面P2P的一系列过程,hello完成了它的使命,被父进程回收,后面会被从物理内存中换出去,又变成了“0”。

1.2环境与工具

列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发工具:gedit,cpp,gcc,as,readelf,objdump,ld,edb

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.i:预处理之后的源程序。
hello.s:编译之后得到的汇编语言程序。
hello.o:汇编hello.o生成的可重定位目标文件。
hello:链接后生成的可执行目标文件。

1.4 本章小结

本章简述了hello的P2P,O2O的整个过程,并列出了编写本论文使用的环境与工具和生成的中间结果文件,是对本论文的一个整体概述。
(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

概念:预处理又叫预编译,是为编译做准备工作的阶段。预处理器在此阶段对一些代码进行预处理,包括:处理头文件包含(#include),宏定义(#define),条件编译(#if #else #ifdef #ifndef #elif #endif),去掉注释。
作用:
#include指令指示预处理器将头文件的全部内容在此处展开。如果头文件用<>括起,则在系统指定的目录中寻找文件,若用””括起则在当前目录中寻找文件。
#define指令指示预处理器进行原封不动的宏替换,分3种情况:第一种是定义标识,标识有效范围为整个程序,形如#define XXX,常与#ifdef配合使用;第二种是定义常数,形如#define max 100;第3种是定义“函数”,如#define get_max(a,b) ((a)>(b)?(a):(b)),这样我们就好像获得了一个get_max()函数。
#if,#ifdef等条件编译命令:可以实现某些语句在某些标识被定义或某些条件满足时才进行编译,可以使目标程序变小,运行时间变短。
预编译使问题或算法的解决方案增多,有助于我们选择合适的解决方案;也使库的实现成为可能,我们可以调用库函数,不用所有函数都自己实现;宏替换也使我们的代码可读性和易修改性增强,降低了我们的编码难度。

2.2在Ubuntu下预处理的命令

cpp hello.c>hello.i:预处理hello.c将结果输出到hello.i程序

在这里插入图片描述

图2.1:预处理命令和得到的.i文件的内容

2.3 Hello的预处理结果解析

.i文件根据内容可以划分为几个部分,下面分部分讨论内容:
第一部分是源程序文件名,命令行参数和环境变量:
在这里插入图片描述图2.2:预处理结果.i文件第一部分
第二部分是包含的.h文件的绝对路径:

在这里插入图片描述
图2.3:预处理结果.i文件第二部分
第三部分是标准C库中一些数据类型的声明:
在这里插入图片描述
图2.4:预处理结果.i文件的第三部分
第四部分是标准C库中一些结构体的声明:
在这里插入图片描述
图2.5:预处理结果.i文件的第四部分
第五部分是对引用的外部变量和函数的声明:
在这里插入图片描述
图2.6:预处理结果.i文件的第五部分
第六部分是对一些枚举类型的声明:
在这里插入图片描述
图2.7:预处理结果.i文件的第六部分
第七部分是对一些本地函数的声明:
在这里插入图片描述
图2.8:预处理结果.i文件的第七部分
第八部分是原来.c文件中的内容,基本没有变化:
在这里插入图片描述
图2.9:预处理结果.i文件的第八部分

2.4 本章小结

预处理进行一些代码文本的替换,为编译做好预备工作。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

概念:通过编译器,将.i文件变成.s文件,内容由高级语言变为汇编指令,不同的高级语言,如果逻辑相同,编译完的结果也是相同的。
作用:首先是得到汇编语言文件,为汇编做准备,其次编译器还会对程序进行优化和语法检查。

3.2 在Ubuntu下编译的命令

gcc -v -S hello.c -o hello.s:加-v可以输出详细过程。
在这里插入图片描述
图3.1:编译命令,生成.s文件

3.3 Hello的编译结果解析

3.3.1: argc和argv两个参数的处理:

这两个参数是通过寄存器传入的,并且被作为局部变量存在了栈空间中:
在这里插入图片描述
图3.2:argc和argv两个参数的处理

3.3.2:两个printf格式串的处理:

两个格式串被放在了只读代码段,在运行时通过rip相对寻址得到:
在这里插入图片描述
在这里插入图片描述

图3.3:两个printf格式串的处理

3.3.3:局部变量i的处理:

被放在了栈空间中:
在这里插入图片描述
图3.4:局部变量i的处理

3.3.4:常量的处理:

程序中的0,1,4,8等常量直接在相应汇编代码命令中使用,其中8被换成了7,因为前面的小于号变成了小于等于号。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
图3.5:常量的处理

3.3.5:类型的处理:

汇编语言中是没有数据类型的概念的,char,int,char*等不同数据类型在汇编语言中的不同就体现为所占字节数的不同(浮点类型除外,他们有自己专有的寄存器和命令),在汇编代码中就体现为命令的字宽后缀不同,如图3.2中对int赋值后缀用l,对地址赋值后缀用q。

3.3.6:赋值语句的处理:

使用mov语句,如图3.2。

3.3.7:i++语句的处理:

对应汇编语言中的add语句:
在这里插入图片描述
图3.6:i++语句的处理

3.3.8:关系操作的处理:

先使用cmp语句,然后可以根据条件码判断比较结果:
在这里插入图片描述
图3.7:关系操作的处理

3.3.9:数组操作的处理:

argv是一个指针数组,对它的使用转化为汇编语言后分3步:先将数组首地址存到一个寄存器里,然后再给这个寄存器加上数组下标所需要的偏移,然后使用()就可以访问数组元素了:
在这里插入图片描述图3.8:数组操作的处理

3.3.10:条件语句的处理:

利用cmp语句更新条件码,再使用条件跳转实现分支结构:
在这里插入图片描述
图3.9:条件语句的处理

3.3.11:for循环的处理:

首先进行赋初值操作,然后一个无条件跳转到判断循环终止条件处,该处使用一个条件跳转决定是跳出循环还是进入循环主体,循环主体的末尾和判断循环终止条件是连在一起的,所以不需要额外的跳转:
在这里插入图片描述
在这里插入图片描述

图3.10:for循环的处理

3.3.12:参数传递:

第一个参数使用rdi,第二个用rsi,第三个用rdx,见图3.2和图3.10。

3.3.13:函数调用:

使用call语句,见图3.10。

3.3.14:函数返回:

进入函数时会先把原来rbp的值push进栈,然后把rbp设成当前rsp,所以函数返回前要先使用leave语句,它包含将rsp设为rbp,然后从栈中pop出原来的rbp。通过rax传递返回值,最后使用ret语句返回。
在这里插入图片描述
图3.11:函数返回的处理

3.4 本章小结

编译过程将高级语言程序变成汇编语言程序,还会进行语法检查和程序优化,为后面的汇编过程做准备。本章还详细介绍了C语言中的各种数据与操作是如何转换成相应的汇编语句的。
(第3章2分)

第4章 汇编

4.1 汇编的概念与作用

汇编器as将hello.s中的汇编语言代码变成机器语言指令,生成一个可重定位目标文件.o文件。

4.2 在Ubuntu下汇编的命令

as hello.s -o hello.o:生成可重定位目标文件。
readelf -a hello.o:使用readelf文件查看可重定位目标文件的内容
在这里插入图片描述
图4.1:生成可重定位目标文件并查看

4.3 可重定位目标elf格式

分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
ELF头以一个16字节序列开始,描述生成该文件系统字的大小和字节顺序。剩下的部分是该目标文件的一些信息,可以帮助链接器进行语法分析,这些信息有:类别、数据存储方式(补码,小端机器)、版本、操作系统、ABI版本、节头部表的起始位置、本头的大小,节头部表项的大小和数量,string table节在节头部表中的表项的索引等
在这里插入图片描述
图4.2:可重定位目标文件的ELF头
节头部表的内容:包含每个节的名字,大小,地址(因为还没有重定位所以都是0),读写权限,对齐方式,相对于文件头部的偏移等
在这里插入图片描述
图4.3:可重定位目标文件的节头部表
.rela.text的内容:存储需要重定位的符号的偏移,类型,值等,链接器将该文件和其他目标文件组合时,需要修改这些位置。其中类型为R_X86_64_32的两项对应的是两个printf格式串,其他的是调用的外部函数。
在这里插入图片描述
图4.4:.rela.text的内容
.symtab的内容:存放在程序中定义和引用的函数和全局变量的信息,如它们的地址、大小、类型、名字。

在这里插入图片描述
图4.5:.symtab的内容

4.4 Hello.o的结果解析

objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
机器语言是一些二进制串,每一条汇编语言语句都对应一条长短不一的机器语言,这种映射关系可以反映在图4.6左半部分和右半部分之间:每一条汇编语言对应一条机器语言。但是注意反汇编结果并不完全等于原来的汇编语言程序,主要有以下3个方面的不同:
1.原来的汇编语言程序中跳转语句使用的是注记符,但反汇编结果中,也就是机器语言中,操作数使用的是要跳转到的地址与下一条指令的地址的差,如图4.6中的0x17处的跳转指令是要跳转到main+0x2f的位置,但操作数是0x16,是0x2f和下一条指令地址0x19的差。
2.原来的汇编语言程序中,printf格式串使用的是注记符(图4.7中的第26行),但机器语言中是.rodata+偏移量(图4.6中0x19地址处),但由于还没有链接,机器语言中的操作数还是0。
3.原来的汇编语言程序中函数调用call的是函数名(图4.7中的第27行),机器语言中操作数应是下一条指令的地址和所调函数首地址的差,但由于没有链接,目前还是0,而反汇编代码中显示的是call下一条指令的地址,如图4.6中0x20处。
在这里插入图片描述
图4.6:对.o文件的反汇编结果
在这里插入图片描述
图4.7:.s文件的内容

4.5 本章小结

本章通过汇编器as将.s文件编译成可重定位目标文件.o文件,并通过readelf工具查看elf文件的elf头和各节内容。又使用objdump反汇编工具查看机器语言和汇编语言的一些不同,如汇编语言中有注记符而机器语言中没有等。
(第4章1分)

第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.1:链接命令

5.3 可执行目标文件hello的格式

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
ELF头以一个16字节序列开始,描述生成该文件的系统的字的大小和字节顺序。剩下的部分是该目标文件的一些信息,可以帮助链接器进行语法分析,这些信息有:类别、数据存储方式(补码,小端机器)、版本、操作系统、ABI版本、节头部表的起始位置、本头的大小,节头部表项的大小和数量,string table节在节头部表中的表项的索引等。
在这里插入图片描述图5.2:hello的ELF头
节头部表中有各段的起始地址和大小等信息:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
图5.3:hello的节头部表

5.4 hello的虚拟地址空间

使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
使用edb加载hello,使用edb的SymbolViewer工具查看各段的虚拟地址起始位置,发现和5.3中的节头部表得到各段的虚拟地址一致,见图5.4。然后利用edb的Data Dump窗口查看对应位置,发现各段的大小和节头部表中描述的是一样的,见图5.5和图5.6。
在这里插入图片描述
图5.4:用edb的SymbolViewers工具查看各段信息
在这里插入图片描述
图5.5:用edb查看.interp段的内容

在这里插入图片描述
图5.6:用edb查看.rodata段的内容

5.5 链接的重定位过程分析

objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
hello和hello.o的不同:
1.hello比hello.o多了.init,.plt.sec等段,见图5.7。
2.hello.o的.text段中只包含main一个函数,而hello中包含进程调用main函数前调用的函数,如图5.8。
在这里插入图片描述
图5.7:hello中.plt.sec段的一些内容
在这里插入图片描述
图5.8:hello的.text段中除main外的其他函数
3.hello.o中跳转语句标注的偏移是相对main函数首地址的偏移,其实也是相对代码段首地址的偏移,因为.o文件的代码段就只有main,;但是hello中跳转语句标注的是相对整个代码段首地址的偏移,因为main函数前还有其他函数,所以这个标注和.o文件是不同的,但其实机器语言的内容是相同的,见图5.9中0x40113c处。
4.使用两个printf格式串的地址的语句中地址对应的操作数由原来的未处理(全都是0)变成有具体的值,即串的地址与当前rip的差,且后面的注释由# 20 <main+0x20> 1c: R_X86_64_PC32 .rodata-0x4变成了# 402008 <_IO_stdin_used+0x8>,见图5.9中0x40113e处。
5.调用函数处原来的hello.o中call后面写的是下一条语句地址相对代码段首地址的偏移量,而且机器语言中的操作数其实是0;而hello中call后面有调用的函数的真正的首地址相对代码段首地址的偏移量,同时也有调用的函数名,而机器语言中操作数由全0变成函数首地址与当前rip的差。见图5.9中0x401145处。
在这里插入图片描述
图5.9:hello中的main函数
链接过程:
为了构造可执行文件,链接器先后完成两个主要任务:符号解析和重定位。
符号解析:每个符号对应一个函数、全局变量、静态变量,通过符号解析,将定义和引用关联起来。链接器维护3个集合,可重定位目标文件集合E,引用了但尚未被定义的符号集合U,在前面输入的文件集合中已经被定义的符号集合D。初始时三个集合均为空。链接器会判断命令行上每一个输入文件f,若它是一个目标文件,链接器会把f添加到E,修改U和D来反映f中的符号定义和引用,并继续下一个输入文件。如果f是一个存档文件,那么链接器就尝试匹配U中未解析的符号和由存档文件成员定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中的所有成员目标文件都依次进行这个过程,直到U和D都不再发生变化。此时,任何不包含在E中的成员目标文件都简单的被丢弃,而链接器继续处理下一个输入文件。如果链接器完成对命令行上输入的文件的扫描后U非空,链接器会输出错误并终止,否则它会合并和重定位E中的目标文件,构建输出的可执行文件。
重定位:结合对puts函数的重定位进行分析。重定位分为重定位相对引用和重定位绝对引用,此程序涉及的是重定位相对引用。重定位条目应该有4个部分:偏移是0x56(该条目地址相对段首的偏移),符号是puts,类型是R_X86_64_PLT32,addend是-4,见图4.6。链接器会先确定代码段的运行时地址和puts函数首地址的运行时地址,根据反汇编结果可知这两个数分别为0x4010f0和0x401090。然后链接器先算出我们需要修改的操作数的地址0x4010f0+0x56得到0x401146,可在图5.9中0x401145处验证正确,然后算出要修改为的值0x401090+ addend-0x401146 =-0xba,其补码为0xffffff46。可在图5.9中0x401145处验证正确。

5.6 hello的执行流程

使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
call main前:
首先call 0x7fc703f51df0
然后跳转到0x7fc703f51e9f
然后经过一系列操作进入_start函数
_start函数调用libc-2.31.so!__libc_start_main函数
libc-2.31.so!__libc_start_main函数里先调用libc-2.31.so!__cxa_atexit函数
然后再调用hello!__libc_csu_init函数
然后再调用libc-2.31.so!_setjmp函数
最后call hello!main
进入main:
首先进行判断,如果输入参数不是4个,会将要输出的格式串传给寄存器(传参),然后call hello!.plt+0x70,在里面会跳转到puts@plt函数,puts结束后调用exit函数程序终止。
如果输入参数是4个,会进入一个循环,每次先传参,然后call 0x4010a0,在里面又会跳转到printf函数,printf结束以后回到循环再给atoi传参,之后call 0x4010c0,在里面又会跳转到atoi函数,之后再回到循环给sleep传参,之后call 0x4010e0,在里面又会跳转到sleep函数。循环8次后结束。之后call 0x4010b0,在里面跳转到getchar函数。等待键盘输入一个字符后,通过eax设置返回值为0,然后main函数return。之后回到上一层函数,调用exit程序终止。

5.7 Hello的动态链接分析

分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用延迟绑定的技术来解决这个问题,该过程地址的绑定推迟到第一次调用该过程时。
延迟绑定通过GOT(全局偏移量表)和PLT(过程链接表)的交互实现。PLT是一个数组,其中每个条目是16字节代码。PLT[0]跳转到动态链接器中,PLT[1]调用系统启动函数,PLT[2]开始的条目调用用户代码调用的函数。GOT是一个数组,每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块的入口点,其余的每个条目对应于一个被调用的函数,其地址在运行时被解析,每个条目都有一个相匹配的PLT条目。
由图5.3中的节头部表可知,hello的.got.plt段的大小是0x48,起始位置是0x404000。使用edb进行分析,先在date dump窗口查看.got.plt段的内容,发现确实是一些PLT中语句的地址,如图5.10;再在主窗口中查看对应地址处的代码,发现确实初始值是jmp语句下一条语句的地址。

在这里插入图片描述
图5.10:用edb查看hello的.got.plt段的内容
在这里插入图片描述
图5.11:用edb查看got表项对应的地址处的代码
调用dl_init后,再用edb查看.got.plt段的内容,发现一部分发生改变,已在图5.12中使用蓝色线标出。

在这里插入图片描述
图5.12:调用dl_init后发生的变化
在某函数f第一次被调用时,延迟解析它的运行时地址:不直接调用f,程序调用进入f对应的PLT条目PLT[x],该指令先通过f对应的GOT[y]进行间接跳转,这次跳转只是简单的进入PLT[x]中的下一条指令,之后f的ID被压入栈中,再跳转到PLT[0],PLT[0]通过GOT1间接的把动态链接器的一个参数压入栈中,然后通过GOT[2] (已经被dl_init更新)间接跳转进动态链接器中,动态链接器使用两个栈条目确定f的运行时位置,用这个地址重写GOT[y],再把控制传递给f。后面再调用时第2步中GOT[y]的间接跳转会将控制直接转移到f。该过程可使用edb得到验证,main函数的for循环在edb中对应如图5.13代码。发现在运行完call 0x4010a0(对应于源程序中的printf函数,需要动态链接)那一行后,.got.plt段相对图5.12中出现了变化,在图5.14中用蓝色线标出,符合上面说的重写GOT[y]。
在这里插入图片描述
图5.13:main函数中循环在edb中对应的代码
在这里插入图片描述
图5.14:第一次调用printf函数后,.got.plt段发生变化

5.8 本章小结

本章对程序用到的链接和程序执行过程进行了系统的梳理,并对hello的elf文件的内容和虚拟地址空间进行了梳理。通过符号解析和重定位,我们把一些可重定位目标文件组合成了一个可执行文件。程序执行时调用共享库函数时,因为定义它的共享模块在运行时可以加载到任意位置,所以我们需要动态链接,本章重点介绍了GNU为解决这个问题使用的延迟绑定技术。
(第5章1分)

第6章 hello进程管理

6.1 进程的概念与作用

概念:进程的经典定义就是一个执行中程序的实例。系统中每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。每次用户通过shell输入一个可执行目标文件的名字,运行程序时,shell就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。
作用:进程给应用程序提供如下关键抽象:
一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占的使用处理器。
一个私有地址空间,它提供一个假象,好像我们的程序独占的使用内存系统。

6.2 简述壳Shell-bash的作用与处理流程

作用:shell是一个交互型的应用级程序,为用户访问操作系统内核提供交互界面,它代表用户运行程序。
处理流程:

  1. 读取输入的指令。
  2. 调用parseline函数分割字符串,获取命令,如果最后一个参数是一个&字符,parseline会返回1,表示应在后台执行该程序,否则返回0。
  3. eval函数调用builtin_command函数,检查第一个命令行参数是否是内置的shell命令。若为内置命令则执行,否则如果是可执行文件名就为其分配子进程执行,否则报错。如果是后台执行,shell返回循环顶部,等待下一命令行,否则调用waitpid函数等待作业终止。作业终止后,shell开始下一轮迭代。
  4. 可以异步接收来自I/O设备或应用程序的信号并对其进行处理。

6.3 Hello的fork进程创建过程

在终端输入./hello 1190202027 李杰 1,shell发现./hello不是内置命令,而是一个可执行文件,终端调用fork函数创建一个当前进程的子进程,该子进程与父进程几乎完全相同。子进程得到与父进程用户级虚拟地址空间相同的副本,包括代码、数据、堆、共享库和用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,即子进程可读写当前父进程打开的任何文件。子进程与父进程最大的区别在于他们有不同的PID。

6.4 Hello的execve过程

通过fork创建了hello的子进程后,子进程通过execve系统调用启动加载器,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的堆和栈段被初始化为零。通过虚拟地址空间中的页映射到可执行文件的页大小的片。代码段和数据段被初始化为可执行文件中对应的内容。映射共享区域,如果程序与共享对象链接,比如标准C库libc.so,那就把这些对象映射到用户虚拟地址空间中的共享区域内。然后加载器跳转到_start地址,它最终调用main函数,Main启动时栈的结构如图6.1。除了一些头部信息,加载过程中没有任何从磁盘到内存的数据复制,直到CPU引用一个被映射的虚拟页时才会进行复制,此时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存。
在这里插入图片描述
图6.1:main启动时栈的结构

6.5 Hello的进程执行

结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
上下文信息:上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
时间片:一个进程和其他进程轮流运行的概念称为多任务。一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此,多任务也叫时间分片。
用户模式和内核模式:处理器通过某个控制寄存器中的一个模式位提供这种功能。该寄存器描述了进程当前享有的特权。设置该位时,进程运行在内核模式中,运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中,用户模式的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接的访问内核代码和数据。
进程调度过程:hello一开始在用户模式下运行,在hello进程调用sleep之后转入内核模式,内核休眠,并将hello进程从运行状态设为等待状态,定时器计时一定的秒数,本次是1秒,定时器到时后发送一个中断信号,内核态执行中断处理,将hello进程由等待状态变为运行状态,hello进程继续执行。反复重复上述过程,直到hello中的循环结束,不再调用sleep。

6.6 hello的异常与信号处理

可能出现的异常:
会出现陷阱:为了实现系统调用要使用陷阱异常,hello每次调用sleep都需要系统调用使内核休眠。陷阱处理程序返回后将控制返回到下一条指令。普通的函数运行在用户模式中,系统调用运行在内核模式中。
会出现中断:sleep调用会使用定时器,定时器到时后会发送一个信号,造成中断异常,程序运行时按Ctrl+C和Ctrl+Z等也会发送信号,造成中断异常。中断异常会调用适当的中断处理程序,处理程序返回时,它将控制返回给下一条指令。
会出现故障:CPU访问一个被映射的虚拟页时会触发缺页异常。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障的运行完成了。
会产生的信号:
SIGINT:程序运行中,键盘按下Ctrl+C会产生该信号,程序收到该信号终止。
SIGTSTP:程序运行中,键盘按下Ctrl+Z会产生该信号,程序收到该信号停止。
SIGCHLD:运行途中按下ctrl+c,前台进程终止,内核再向父进程发送一个SIGCHLD信号,通知父进程回收子进程。
定时器计时结束后,会发送信号:该信号通知hello由等待状态变为运行状态。

程序正常运行,输出8行内容,之后等待键盘输入,直到输入回车后程序结束,见图6.2:
在这里插入图片描述
图6.2:程序正常运行
运行时乱按,包括回车。发现乱按的内容会进入缓冲区,等待几个sleep执行完被处理,乱按的第一个回车前的内容用来终止hello,之后每按一个回车相当于输入了一行命令行。见图6.3,注意第二个命令fasdf被第三个输出的hello行分开了:

在这里插入图片描述
图6.3:程序运行时乱按
程序运行时按Ctrl+Z,程序会暂停:
在这里插入图片描述
图6.4:运行时按Ctrl+Z
暂停后输入ps命令,发现hello进程还在进程列表里,见图6.5:
在这里插入图片描述
图6.5:hello暂停后输入ps命令
暂停后输入jobs命令,可以看到暂停的作业,见图6.6:

在这里插入图片描述
图6.:6:hello暂停后输入jobs命令
暂停后输入pstree命令,可以看到当前各进程的树状结构,见图6.7:
在这里插入图片描述
图6.7:hello暂停后输入pstree命令
暂停后输入fg 1,hello进程又被放到前台执行,见图6.8:
在这里插入图片描述
图6.8:hello暂停后输入fg 1
暂停后输入kill命令,再用ps命令查看进程列表,发现已经没有hello进程,见图6.9:
在这里插入图片描述
图6.9:hello暂停后输入kill命令

6.7本章小结

本章先介绍了进程的概念和作用,然后介绍了shell-bash的作用和处理流程,介绍了使用fork创建子进程,使用execve执行hello,并分析了hello的执行过程的上下文切换的进程调度过程。最后讨论了hello执行过程中可能的异常和信号处理,并给出了操作过程。
(第6章1分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:程序代码经过编译后出现在汇编语言程序中的地址,逻辑地址是段地址:偏移地址的格式。逻辑地址是机器语言指令中使用的地址,用来指定一个操作数或者一条指令的地址。hello的机器代码中使用的是逻辑地址。
线性地址:一个非负整数地址的有序集合称为地址空间。如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。
虚拟地址:CPU通过由逻辑地址生成一个虚拟地址来访问主存,这个虚拟地址通过内存管理单元MMU被转换为物理地址。MMU利用存放在主存中的查询表来动态翻译虚拟地址,该表的内容由操作系统管理。ISA指令执行时段式管理机构进行地址计算,将逻辑地址转换为虚拟地址,我们使用反汇编工具查看可执行文件各段信息时看到的就是虚拟地址。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。hello中的访存用到的地址最终要转化为物理地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

首先通过段寄存器(16位)中存储的段选择符得到对应的段描述符。段选择符内容如图7.1。
在这里插入图片描述
图7.1 段选择符
段描述符有8个字节,可分为GDT和LDT两种。通常系统定义一个GDT,每个进程可以在自己的LDT中放置一些自定义的段。IA-32引入GDTR和LDTR两个寄存器,用来存放当前正在使用的GDT和LDT的首地址。Linux系统中每个CPU对应一个GDT。一个GDT中有18个段描述符和14个未使用或保留项。其中用户和内核各有一个代码段和数据段,还有一个TSS任务段保存寄存器的状态。段描述符的结构见图7.2。

在这里插入图片描述

图7.2 段描述符
逻辑地址向线性地址的转换过程可以被描述为图7.3:
在这里插入图片描述
图7.3 逻辑地址转换为线性地址的过程

  1. 首先确定要使用的段寄存器。
  2. 根据段选择符确定访问GDT还是LDT,然后根据GDTR和LDTR两个寄存器得到GDT或LDT的首地址。
  3. 将段选择符的索引值8,加上2中得到的首地址,得到段描述符的地址。因为段描述符是8对齐的,所以地址的后3位没必要存在段选择符的索引值里,所以段选择符的索引值里存的是偏移右移3位后的值,所以取出来用时要做8的还原。
  4. 得到段描述的地址后,通过段描述符的BASE得到段的首地址。
  5. 将逻辑地址中的偏移地址和段首地址相加得到线性地址。

7.3 Hello的线性地址到物理地址的变换-页式管理

我们使用术语SRAM缓存表示位于CPU和主存之间的L1、L2和L3高速缓存。使用术语DRAM缓存表示虚拟内存系统的缓存,它在主存中缓存虚拟页。页表存放在物理内存中,将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表内容,以及在磁盘与DRAM之间来回传送页。页表是一个由页表条目(PTE)组成的数组,虚拟地址空间中每个页在页表中的一个固定偏移量处都有一个PTE。我们可以假设每个PTE由一个有效位和一个n位地址字段组成。有效位表明该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,地址字段表示DRAM中相应物理页的起始位置。如果没有设置有效位,空地址表示这个虚拟页还未分配;否则,这个地址指向该虚拟页在磁盘上的起始位置。
在这里插入图片描述
图7.4 页表
下面是简化情况下内存管理单元MMU如何利用页表实现将线性地址转化为物理地址,见图7.5:
1. 虚拟地址被传送给MMU。
2. MMU根据虚拟地址得到虚拟页号VPN和虚拟页偏移VPO(同时也是物理页偏移PPO)
3. MMU根据页表基址寄存器PTBR得到页表首位置,然后根据VPN找到页表项,之后可能因为页面未缓存触发一次缺页异常。最终MMU可以得到物理页号PPN。
4. MMU将PPN和PPO拼接起来得到物理地址。

在这里插入图片描述
图7.5 使用页表的地址翻译

7.4 TLB与四级页表支持下的VA到PA的变换

TLB:因为页表是存放在内存中的,为了消除这一次访存的开销,许多系统在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓冲器TLB。TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。用于组选择和行匹配的索引和标记字段从VPN中提取出来。
带TLB的VA翻译成PA的过程:

  1. CPU产生一个虚拟地址。
  2. MMU访问TLB,如果命中,直接得到PTE;否则从高速缓存或内存中取出相应PTE并更新TLB。
  3. MMU执行7.3节中提到的剩下的过程。
    多级页表:页表其实是很大的,我们不应让它整个驻留在内存中。用来压缩页表的常用方法是使用层次结构的页表。下面讨论二级页表,四级页表的形式是类似的。
    两级页表中的第一级页表中的每个PTE负责映射虚拟地址空间中一个4MB的片,每一个片是由1024个连续的页组成的。如果片i中的每个页面都未被分配,那么一级PTEi就为空。如果在片i中至少有一个页是分配了的,一级PTEi就指向一个二级页表的基址。二级页表中的每个PTE都负责映射一个4KB的虚拟内存页面。两级页表的层次结构见图7.6。
    在这里插入图片描述
    图7.6 两级页表的层次结构
    这种方法从两个方面减少内存要求。首先,如果一级页表中的PTE是空的,那么对应的二级页表就不会存在,这其实很管用,因为对于典型的程序,虚拟空间的大部分都会是未分配的。其次,只有一级页表才需要总是在主存中,二级页表可以在需要时创建、调入或调出。
    使用k级页表时,虚拟地址被分成k个VPN和1个VPO。每个VPNi都是一个到第i级页表的索引,具体结构见图7.7。
    在这里插入图片描述
    图7.7 使用k级页表的地址翻译
    综上,TLB与四级页表支持下的VA到PA的转换:
    1.CPU产生一个虚拟地址。
    2.MMU总共访问4次PTE:每次先访问TLB,TLB里缓存了各个不同层次上的页表的PTE,如果命中,则该次直接得到了PTE;否则从高速缓存或内存中取出相应PTE,其中第2,3,4级页表有些可能不在内存中,需要页面调入。最终我们得到了PTE,并更新TLB。经过4次访问PTE,我们最终得到存储PPN或某个磁盘块地址的PTE。
    3.MMU执行7.3节中提到的剩下的过程。

7.5 三级Cache支持下的物理内存访问

CPU和主存之间有三级SRAM高速缓存存储器,第i级缓存第i+1级的内容,第三级缓存主存的内容。CPU得到物理地址后,先尝试去缓存中访问,如果未命中,才去它的高一级缓存或主存中访问。第k层没有缓存数据对象d,从k+1层缓存中取出包含d的块,如果第k层已满,可能会覆盖现存的一个块。决定替换哪个块是由缓存的替换策略控制的。最近最少被使用(LRU)替换策略的缓存会选择最后被访问的时间距现在最远的块。
三级缓存的每一级都被分成若干组,每组有E行,E称为相联度,一行又被称为一块,是最基础的单位。三级缓存的块大小都是64字节,所以物理地址的后六位(05)被解读为块偏移CO,L1缓存有64组,L2有512组,L3有8192组,所以对应的物理地址(611)、(614)、(618)位被解读为高速缓存组索引CI,剩下的位置被解读为高速缓存标记CT,该结构可见图7.8。而缓存中的结构如图7.9所示,每行由一个有效位,t个标记位和B个字节构成。
在这里插入图片描述

图7.8:缓存对物理地址的解读
在这里插入图片描述
图7.9:高速缓存的结构
上面讨论的都是从缓存中读的情况。关于向缓存中写的问题,如果我们要写一个已经缓存了的字,称为写命中,关于更新低一层的副本,有两种策略:直写,即立即写到低一层缓存中,缺点是每次写都会引起总线流量;写回,即推迟更新,只有这个更新过的块要被驱逐时,才把它写到低一层中。如果写不命中,也有两种策略:写分配,即加载第一层的块到高速缓存中,然后更新这个高速缓存块,缺点是每次不命中都要将一个块从低一层写到高速缓存;非写分配,即避开高速缓存,直接写到低一层中。直写高速缓存通常是非写分配的,写回高速缓存通常是写分配的。

7.6 hello进程fork时的内存映射

当fork函数被shell进程调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的pid。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任意一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

execve在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效的替代了当前程序。加载并运行hello分以下几个步骤:

  1. 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
  2. 映射私有区域。为hello的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
  3. 映射共享区域。hello中用到的puts,printf,atoi,sleep函数都是共享对象,他们动态链接到这个程序,然后再映射到用户虚拟地址空间中的共享区域内。
  4. 设置程序计数器PC。设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
    下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。execve的内存映射示意图可详见图7.10。
    在这里插入图片描述
    图7.10:execve的内存映射

7.8 缺页故障与缺页中断处理

在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。但实际上也可能出现我们访问的虚拟地址未分配的情况,这也会触发缺页异常。缺页异常处理程序执行下面步骤:

  1. 搜索区域结构的链表,把虚拟地址A和每个区域结构中的vm_start和vm_end作比较。如果这个指令是不合法的,缺页处理程序就会触发一个段错误。因为一个进程可以创建任意数量的新虚拟内存区域,所以顺序搜索区域结构的链表花销可能会很大。实际上Linux是用某些我们没有显示出来的字段,Linux在链表中构建了一棵树,并在这棵树上进行查找。
  2. 判断我们要进行的内存访问是否合法,即进程是否有读写或执行这个区域内页面的权限。如果访问是不合法的,缺页处理程序会触发一个保护异常,终止这个进程。
  3. 现在知道该缺页是对合法虚拟地址进行合法操作造成的。那么内核选择一个牺牲页面,如果这个牺牲页面被修改过,就将它交换出去,换入新的页面并更新页表。缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送A到MMU。这次MMU就能正常翻译A,不会再出现缺页中断。
    Linux的各种缺页情况的示意图可见图7.11。
    在这里插入图片描述
    图7.11 Linux各种缺页情况

7.9动态存储分配管理

Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配块显式的保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式的被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。分配器有两种基本风格,他们都要求应用显式的分配块,不同在于由哪个实体释放已分配块。显式分配器要求应用显式的释放任何已分配的块。隐式分配器,又叫垃圾收集器,会检测一个已分配块何时不再被程序所使用,那么就释放这个块。自动释放未使用的已分配的块的过程叫做垃圾收集。
分配器需要一些数据结构,来允许它区分块的边界,区分已分配块和空闲块。下面讨论几种结构:
隐式空闲链表:隐式空闲链表一个块的结构如图7.12所示。其中块头部是一个4字节的值,存储了块大小和块是否已分配两个信息。因为块有对齐要求(32位8对齐,64位16对齐),所以32位下块大小的二进制表示的后3位全都是0,所以前29位存块大小除以8之后的值,后3位中的最低位存该块是否已分配。有效载荷就是我们实际用malloc申请的空间,而填充可能是分配器策略的一部分,用来对付外部碎片,或者用来满足对齐要求。空闲块通过头部中的大小字段隐含的连接着。分配器可以通过遍历堆中的所有块,从而间接的遍历整个空闲块的集合,如图7.13。
在这里插入图片描述
图7.12:简单隐式空闲链表中一个堆块的结构
在这里插入图片描述
图7.13 隐式空闲链表的结构与遍历
带边界标签的隐式空闲链表:合并空闲块时,要找到某个空闲块前面的块,普通隐式空闲链表只能用线性时间扫描,带边界标签的隐式空闲链表是一种简单的改进,其一个块的结构如图7.14。其他内容和普通空闲链表一致,在每个块的结尾处添加一个脚部,内容和头部一致,这样一个块通过常数时间就可以得到它前面块的脚部,也就能得到它前面块的头部的信息。
在这里插入图片描述
图7.14 带边界标签的隐式空闲链表中一个块的结构
显式空闲链表:将空闲块组织为某种形式的显式数据结构。因为程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面,比如可以组织成一个双向空闲链表,每个空闲块中都有一个前驱和后继指针,如图7.15所示。双向空闲链表的组织方式有两种,一种是后进先出LIFO,即将新释放的块直接放到链表开始,好处是释放一个块可以在常数时间完成,缺点是内存利用率相对较低;另一种是按地址顺序维护链表,缺点是释放一个块需要线性时间来定位前驱,优点是内存利用率相对较高。
在这里插入图片描述
图7.15:使用双向空闲链表的堆块的结构
分离空闲链表:维护多个空闲链表,每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成一些等价类,也叫作大小类。分配器维护一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为n的块时,它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表,以此类推。下面描述两种基本方法:简单分离存储和分离适配。
简单分离存储,每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。为了分配一个给定大小的块,先检查相应的空闲链表,如果非空,我们分配第一个块的全部。空闲块不会分割以满足分配请求。如果链表为空,分配器就向操作系统申请固定大小的额外内存片,将这个片分成大小相等的块,将这些块连接起来形成新的空闲链表。释放一个块是简单的将这个块插入到相应的空闲链表的前部。这种方法的优点是大小类中块大小都相等,又不会有合并和分割操作使块大小发生变化,所以不必每个块都存自己的大小,另外因为没有合并操作,我们也不需要是否分配的标签,所以我们不需要头部和脚部。而我们的分配和释放操作都是在链表头操作,所以我们也不需要双向链表,只需要单向,所以最小块大小就是一个字,存succ指针(不考虑8对齐)。
分离适配:每个空闲链表和一个大小类相关联,被组织成某种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。下面描述一种最简单的版本。分配一个块时,先确定请求的大小类,然后对适当的空闲链表做首次适配,找到合适的块。如果找到了,就可选的分割它,并将剩余部分插入适当空闲链表。如果找不到,就搜索下一个更大的大小类的空闲链表,直到找到合适的块。如果空闲链表中没有合适的块,就向操作系统请求额外堆内存,从这个新的堆内存中分配出一个块,将剩余部分放入适当大小类中。释放一个块时执行合并,将结果放到相应的空闲链表中。这种方法搜索被限制在堆的某个部分,而不是整个堆,所以搜索时间减少了,内存利用率也得到了改善,因为对分离空闲链表的简单的首次适配搜索,其内存利用率近似于对整个堆的最佳适配搜索的内存利用率。

7.10本章小结

本章主要介绍了hello的存储器地址空间、intel的段式管理、页式管理,TLB与四级页表支持下的VA到PA的变换、三级cache支持下物理内存访问, hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容。通过这些内容,我们对hello进程从被创建,到运行,再到被终止过程中的访存过程有了系统的完整认识。
(第7章 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列:B0,B1,…,Bk,…,Bm-1
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅的映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

Unix IO接口:
打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个IO设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可用来代替显式的描述符值。
改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始值为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式的设置文件的当前位置为k。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k>=m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似的,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为相应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix IO函数:
进程通过调用open函数打开一个已经存在的文件或者创造一个新文件。
int open(char *filename,int flags,mode_t mode);
open函数将filename转换为一个文件描述符,并且返回描述符数字,如果出错则返回-1。返回的描述符总是在进程中当前没有打开的最小描述符。flags指明进程打算如何访问这个文件,是只读只写还是可读可写。mode指定新文件的访问权限位。
进程通过调用close函数关闭一个打开的文件。
int close(int fd)
若成功则返回0,若出错则返回-1。关闭一个已关闭的描述符会出错。
程序通过调用read和write函数来执行输入和输出。
ssize_t read(int fd,void *buf,size_t n);
若成功则返回读的字节数,若EOF返回0,若出错返回-1
ssize_t write(int fd,const void *buf,size_t n);
返回:若成功则为写的字节数,若出错则为-1
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。
通过调用lseek函数,应用程序能够显式的修改当前文件位置。

8.3 printf的实现分析

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
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;
}

…是可变形参的一种写法,可以认为是通过栈传参的,所以(char*)(&fmt) + 4是…中第一个参数的地址。注意fmt虽然是一个字符串的首地址,但是这个变量也是有自己的地址的,就是它在栈中的那个地址,所以用&得到它在栈中的地址,然后+4就是第一个参数的地址。
然后是vsprintf函数的函数体:

int vsprintf(char *buf, const char *fmt, va_list args) 
   { 
    char* p; 
    char tmp[256]; 
    va_list p_next_arg = args; 
   
    for (p=buf;*fmt;fmt++) { 
    if (*fmt != '%') { 
    *p++ = *fmt; 
    continue; 
    } 
   
    fmt++; 
   
    switch (*fmt) { 
    case 'x': 
    itoa(tmp, *((int*)p_next_arg)); 
    strcpy(p, tmp); 
    p_next_arg += 4; 
    p += strlen(tmp); 
    break; 
    case 's': 
    break; 
    default: 
    break; 
    } 
    } 
   
    return (p - buf); 
   }

可以看出是利用格式串和其他参数得到最终要输出的字符串,即格式化。返回的是要输出的字符串的长度。
然后调用write系统函数。这个函数的反汇编内容如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
可以看到使用int调用中断门,来实现特定的系统服务。它之后调用sys_call函数。sys_call一个call save,保存中断前进程的状态。sys_call将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息存储到vram(存储每一个点的RGB颜色信息)中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。于是字符串就显示在了屏幕上。

8.4 getchar的实现分析

第一次getchar()时,需要人工输入,但是如果输入了多个字符,以后的getchar()再执行时就会直接从缓冲区读取。键盘输入的字符都存到缓冲区内,一旦键入回车,getchar就进入缓冲区读取字符,一次只返回第一个字符作为getchar函数的值,如果有循环或足够多的getchar语句,就会依次读出缓冲区内的所有字符直到’\n’。
getchar函数在stdio.h中声明,代码如下(参考网上的博客,感觉代码内容有点问题,不过思想应该是没问题的):

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;
}

bb是缓冲区的开始,int变量n初始化为0,只有在n为0的情况下从缓冲区读入BUFSIZ个字节。返回时如果n大于0,那么返回缓冲区的第一个字符,否则返回EOF。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求。键盘中断服务程序先从键盘接口取得按键的扫描码,然后根据其扫描码判断用户所按的键并做相应的处理,最后通知中断控制器本次中断结束并实现中断返回。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章通过介绍Linux的IO设备管理方法、Unix IO的接口及函数、printf和getchar的实现,让我们对hello执行过程中的IO管理有了一定的认识。
(第8章1分)

结论

用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello程序经历的过程:

  1. 编写并保存源程序.c文件。
  2. 预处理器cpp根据include语句把头文件内容添加到这个文件,根据define语句做宏替换等等,最终形成.i文件。
  3. 编译器cc1将.i文件编译成.s汇编程序文件。
  4. 汇编器as将.s文件中的汇编程序翻译成机器语言,生成一个.o可重定位目标文件。
  5. 链接器ld对我们生成的.o文件中引用的printf等库函数和两个pritnf格式串做符号解析,并对非共享函数和两个格式串做重定位,生成一个可执行目标文件hello
  6. 我们在Bash里输入一行命令,告诉壳我们要运行hello程序。Bash通过IO管理得到我们的命令。
  7. Bash通过fork创建一个子进程;通过execve将hello文件载入到这个进程中;通过mmap建立虚拟内存映射;设置当前进程的上下文中的程序计数器,使之指向程序入口处。CPU分给这个进程时间片,使它得以在硬件上流水线化执行。
  8. 程序运行起来需要访存时,CPU中的段式管理机制先将机器指令中的逻辑地址翻译成虚拟地址,CPU上的内存管理单元MMU会先将虚拟内存地址翻译成物理内存地址:首先去页表的缓存TLB里查,查不到的话再去高速缓存或物理内存上的4级页表查,如果不命中要触发缺页异常更新页表,最终页表命中,根据页表项将虚拟内存地址翻译成物理内存地址。然后根据物理地址先去CPU上的高速缓存查,不命中就需要再到内存里去查。
  9. 程序运行时需要调用共享库函数时,需要动态链接,即第一次调用该函数时通过PLT和GOT两个数据结构和动态链接器的配合,得到函数的运行时地址并更新GOT。之后再调用时,因为已经进行过动态链接,就可以直接通过对应的GOT条目得到运行时地址。
  10. 程序执行途中可能有程序调用kill函数或者键盘按下Ctrl+C或Ctrl+Z,使内核发送信号,hello所在进程可能会对这些信号做出反应,比如终止或停止。如果停止可以使用shell命令恢复运行。
  11. 输出和getchar过程:printf调用malloc进行动态内存分配,在堆中申请所需的内存。printf调用系统write函数,通过显卡上的显存,向液晶显示器传输每一个点,使我们的输出最终显示在屏幕上。程序的最后调用getchar函数,它调用系统read函数,等待第一个回车符后返回。
  12. 程序终止后,向父进程发送SIGCHLD信号,hello所在进程被回收。

感悟:计算机系统是一个复杂而精细的系统,几乎它的每个部分都体现着设计者的智慧:比如链接时的库的提出,动态链接共享库函数的实现,使库函数不用在每个程序中都存储,大大节约了内存空间;又比如高速缓存,利用程序的局部性,综合了CPU的速度优势和内存的容量优势;再比如虚拟内存作为内存管理和内存保护的工具,将进程管理、链接、内存共享等一系列问题简化。所有的这些优化汇聚到一起才有了今天的快速而安全的计算机系统。
(结论0分,缺失 -1分,根据内容酌情加分)

附件

列出所有的中间产物的文件名,并予以说明起作用。
hello.i:预处理之后的源程序。
hello.s:编译之后得到的汇编语言程序。
hello.o:汇编hello.o生成的可重定位目标文件。
hello:链接后生成的可执行目标文件。

(附件0分,缺失 -1分)

参考文献

为完成本次大作业你翻阅的书籍与网站等
[1] https://blog.csdn.net/qq_40363447/article/details/85478752
[2] https://blog.csdn.net/lll_90/article/details/85427841
[3] 深入了解计算机系统(第三版)2016 Bryant,R.E. 机械工业出版社
[4] https://www.cnblogs.com/pianist/p/3315801.html
[5] https://blog.csdn.net/weixin_44551646/article/details/98076863
[6] https://www.cnblogs.com/dugudashen/p/12122337.html#_Toc532238449
(参考文献0分,缺失 -1分)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值