计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2021113243
班 级 21W0311
学 生 蔡东芮
指 导 教 师 史先俊
计算机科学与技术学院
2022年5月
本文主要阐述hello程序在Linux系统的生命周期,探讨hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件的全过程。并结合课本的知识详细阐述我们的计算机系统是如何对hello进行进程管理、存储管理和I/O管理,通过对hello一生周期的探索,让我们对计算机系统有更深的了解。
关键词:Hello程序;预处理;编译;汇编;链接;进程;存储;虚拟内存;I/O ;
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:(From Program to Process)
首先编译器编写hello.c源程序Program,然后运行C预处理器(cpp)将hello.c翻译成一个ASCII码的中间文件hello.i,而后编译器(ccl)将hello.i翻译成一个ASCII汇编语言文件hello.s,接下来汇编器(as)将hello.s翻译成一个可重定位目标文件hello.o,在通过链接器与库函数链接,创建一个可执行目标文件prog,shell执行该文件,通过fork产生子进程,调用execve函数加载运行该进程,产生Process。
020:(From Zero to Zero)
shell调用fork和execve后,程序映射到虚拟内存,删除当前虚拟地址的数据结构并创建新的区域结构,在经过程序入口后载入物理内存,再运行main函数至结束,父进程回收子进程,占据的内存也被释放。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发与调试工具:gcc,vim,edb,readelf,HexEdit
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名称 | 文件作用 |
hello.i | hello.c预处理之后文本文件 |
hello.s | hello.i编译后的汇编文件 |
hello.o | hello.s汇编之后的可重定位目标文件 |
hello | 链接之后的可执行目标文件 |
hello.out | hello反汇编之后的可重定位文件 |
1.4 本章小结
本章大致主要简单介绍了 hello 的 p2p,020 过程,列出了本次实验信息:环境、中间结果,并且大致简介了hello程序从c程序hello.c到可执行目标文件hello的大致经过的历程。
第2章 预处理
2.1 预处理的概念与作用
预处理概念:预处理器cpp根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件。
预处理作用:
1.处理宏定义指令预处理器根据#if和#ifdef等编译命令及其后的条件,将源程序中的某部分包含进来或排除在外,通常把排除在外的语句转换成空行。
2. 处理条件编译指令
条件编译指令如#ifdef,#ifndef,#else,#elif,#endif等。这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉。
3.处理头文件包含指令头文件包含指令如#include "FileName"或者#include 等。该指令将头文件中的定义统统都加入到它所产生的输出文件中,以供编译程序对之进行处理。
4.处理特殊符号
预编译程序可以识别一些特殊的符号。例如在源程序中出现的LINE标识将被解释为当前行号,FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
2.2在Ubuntu下预处理的命令
在Ubentu的命令行下输入gcc hello.c -E -o hello.i对hello.c文件进行预处理。
2.3 Hello的预处理结果解析
打开.i文件,观察到main函数主体内容没有什么变化,注释部分被删除,文件中的头文件stdio.h、 unistd.h、 stdlib.h展开成为三个头文件的源码。预编译程序可以识别一些特殊的符号,并在源程序中用合适的值进行替换。
2.4 本章小结
本章介绍了预处理的相关概念及其所进行的一些处理,例如实现将定义的宏进行符号替换、引入头文件的内容、根据指令进行选择性编译等。
第3章 编译
3.1 编译的概念与作用
编译的概念:编译器将文本文件 hello.i 翻译成文本文件 hello.s,它包含一个汇编语言程序。其以高级程序设计语言书写的源程序作为输入,而以汇编语言或机器语言表示的目标程序作为输出。 这个过程称为编译,同时也是编译的作用。
编译程序的基本功能是把源程序(高级语言)翻译成目标程序。除了基本功能之外,编译程序还具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人际联系等重要功能。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1汇编指令介绍:
.file | 声明源文件,在这里是“hello.c” |
.text | 代码节 |
.section,.rodata | 只读代码段 |
.align | 数据或者指令的地址对其方式 |
.string | 声明一个字符串(.LC0,.LC1) |
.global | 声明全局变量(main) |
.type | 声明一个符号是数据类型还是函数类型 |
3.3.2 main函数中的参数:
main函数有两个参数argc和argv,都存在栈上,从main函数的汇编语言上可以看出argc存在%rbp-20的位置,argv存在%rbp-32的位置。
3.3.3局部变量
main函数里声明了一个整型局部变量i,在编译过程中,局部变量存储在堆栈上。由图片可知,将i初始化为0。
3.3.4 for循环
对i初始化之后跳转到.L3,.L3为for循环的条件判断部分。采用cmp命令判断条件是否满足。若满足则跳转到.L4,即for循环的循环体部分。
3.3.5指令解析:
- 数据传输指令
例.mov指令:
程序中的赋值操作主要使用mov指令来实现,而根据数据的类型又有好几种不一样的后缀:
movb:一个字节
movw:两个字节
movl:四个字节
movq:八个字节
movx a , b:就是把a赋给b(Linux是这样,Inter是把b赋给a)
- 算数操作指令
3)关系操作指令
关系操作涉及到判断相等或不等,大于小于等操作。在hello.c中需要判断argc的值是否等于4:
在hello.s中if(argc!=4)被编译为:
i<8在hello.c作为判断循环条件,在汇编代码被编译为:cmpl $7,-4(%rbp),计算 i-7然后设置 条件码,为下一步 jle 利用条件码进行跳转做准备。
4)函数操作
调用函数时有以下操作:(假设函数P调用函数Q)
1.传递控制:进行过程 Q 的时候,程序计数器必须设置为 Q的代码的起始地址,然后在返回时,要把程序计数器设置为 P 中调用 Q 后面那条指令的地址。
2.传递数据:P必须能够向Q提供一个或多个参数,Q必须能够向 P中返回一个值。
3.分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些空间。
hello.c涉及的函数操作有:
main函数,printf,exit,sleep ,getchar函数,main函数的参数是argc和argv;两次printf函数的参数恰好是那两个字符串,exit参数是1,sleep函数参数是atoi(argv[3]),函数的返回值存储在%eax寄存器中。
- 类型转换
hello.c中涉及的类型转换是:atoi(argv[3]),将字符串类型转换为整数类型其他的类型转换还有int、float、double、short、char之间的转换。
3.4 本章小结
本章主要讲述了编译阶段中编译器如何处理各种数据和操作,以及c语言中各种类型和操作所对应的的汇编代码。通过理解了这些编译器编译的机制,我们可很容易的将汇编语言翻译成c语言。
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
Linux下的命令:gcc hello.s -c -o hello.o将hello.o翻译成机器语言。
4.3 可重定位目标elf格式
4.3.1ELF Header:用命令readelf -h hello.o进行查看。
前十六个字节的序列描述了生成该文件的系统的字的大小和字节顺序,剩下部分交代了目标文件类型(REL),机器类型(UNIX-System V),字节头部表的偏移以及节头部表中。根据头文件的信息,可以知道hello.o是可重定位目标文件,节头部表的文件偏移是1240bytes,节头部表有14个条目。
4.3.2节头部表,命令:readelf -S hello.o
Section Headers:节头部表,包含了文件中出现的各个节的语义,包括节 的类型、位置和大小等信息。 由于是可重定位目标文件,所以每个节都从0开始,用于重定位。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小,同时可以观察到,代码是可执行的,但是不能写;数据段和只读数据段都不可执行,而且只读数据段也不可写。
4.3.3.重定位节
.rela.text,重定位节:一个.text 节中位置的列表,包含.text 节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
一般而言,任何调用外部函数或引用全局变量的指令都需要修改。例如:printf,exit,getchar()等外部函数和.rodata中的模式串
重定位节.rela.text中各项符号的信息:
offset是需要被修改的引用节的偏移。
symbol标识被修改引用应该指向的符号。
type告知链接器应该如何修改新的应用。
attend:一个有符号常数,一些重定位要使用它对被修改引用的值做偏移调整。
4.3.4符号表
查看命令:readelf -s hello.o
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含ELF符号表。这张符号表包含一个条目的数组。其中:
1)name是符号名称,对于可重定位目标模块。
2)value是符号相对于目标节的起始位置偏移,对于可执行目标文件,该值是一个绝对运行的地址。
3)size是目标的大小,type要么是数据要么是函数。
4)bind字段表明符号是本地的还是全局的。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
通过反汇编的代码和hello.s进行比较,发现汇编语言的指令并没有什么不同的地方,只是反汇编代码所显示的不仅仅是汇编代码,还有机器代码,机器语言程序的是二进制机器指令的集合,是纯粹的二进制数据表示的语言,是电脑可以真正识别的语言。机器指令由操作码和操作数构成,汇编语言是人们比较熟悉的词句直接表述CPU动作形成的语言,是最接近CPU运行原理的语言。每一条汇编语言操作码都可以用机器二进制数据来表示,进而可以将所有的汇编语言(操作码和操作数)和二进制机器语言建立一一映射的关系,因此可以将汇编语言转化为机器语言,通过对机器代码的分析可以看出以下不同的地方。
1.分支转移:跳转指令由标签转变成了具体的相对偏移地址,我们知道标签在经过汇编过程中被删除,在实际机器指令中并不存在。
2.函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编程 序中,call的后面紧跟着的是当前下一条指令。这是因为 hello.c 中调用的函数 都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执 行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。
4.5 本章小结
本章介绍了汇编的过程,对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,分析了从汇编语言到机器语言的一一映射关系,加深了对汇编过程的理解。
第5章 链接
5.1 链接的概念与作用
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可执行于编译时,也就是源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至是运行时,也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器的程序自动执行的。
5.2 在Ubuntu下链接的命令
用命令:ld-ohello-dynamic-linker/lib64/ld-linux-x86-64.so.2/usr/lib/x86_64-linux-gnu/crt1.o/usr/lib/x86_64-linux-gnu/crti.ohello.o/usr/lib/x86_64-linux-gnu/libc.so/usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
ELF Header:hello的文件头和hello.o文件头的不同之处如下图标记所示,Type类型为EXEC表明hello是一个可执行目标文件,有27个节,节头部表的偏移为14184bytes。
节头部表Section Headers:Section Headers 对 hello中所有的节信息进行了声明,其中包括大小 size 以及在程序中的偏移量 offset,因此根据 Section Headers 中的信息我们就可以用 HexEdit 定位各个节所占的区间(起始位置,大小)。其中 Address是程序被载入到虚拟地址的起始地址。
5.4 hello的虚拟地址空间
既有可读可执行的区域,作为指令的装载地址,也有只读数据域,编码了代码中字符串常量等数据,还有运行时堆,由malloc函数管理内存分配,同时作为全局变量的数组也会保存在这一部分,最后还包涵用户栈。
可以从图看出hello开始于虚拟地址0x7bdbd000.从而该地址内容与readefl中的EFL中十六字节的序列是一样的:
5.5 链接的重定位过程分析
输入命令:objdump可以得到hello的反汇编代码
对hello和hello.o进行对比分析,不难发现,首先,hello的反汇编代码从.init节开始,而hello.o的反汇编代码从.text节开始。其次,hello的反汇编代码中导入了puts、printf、atoi、getchar、sleep等在主程序中使用过的函数,而hello.o的反汇编代码中不包含这些函数。除此之外,还存在所含汇编代码的段增多等变化。
一些外部的库函数:
这些函数是来自库中的函数,在链接器解析引用的时候会从库中复制对应的目标模块(如printf.o)与hello.o进行链接,每输入一个存档文件,链接器就会检查未定义的符号集,如果这个输入文件能够解析这个符号,那就加入到可执行文件中,一般这样的静态解析我们把存档文件放在最后链接,以防一些符号不能被解析。
hello的反汇编代码中函数的调用方法同hello.s,使用call + 运行时地址直接调用,而hello.o的反汇编代码使用call指向下一条语句,并未直接调用函数。
5.6 hello的执行流程
首先加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或者入口点来运行该程序。这里的jmp r12就是跳转到程序的入口点0x4010f0:
也就是_start函数的起始地址:
_start函数调用系统启动函数__libc_start_main,该函数定义在libc.so中,它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。这就是到调用main函数之前的准备工作。
main函数调用程序:
名称 | 地址 |
puts | 0x401090 |
exit | 0x4010d0 |
printf | 0x4010a0 |
atoi | 0x4010c0 |
sleep | 0x4010e0 |
getchar | 0x4010b0 |
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
5.7 Hello的动态链接分析
程序调用由共享库定义的PIC函数,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态连接器在程序加载的时候再解析它。不顾这种方法并不是PIC,因为它需要链接器修改调用模块的代码段,所以需要添加重定位记录,等待动态链接器处理,GNU 编译系统使用延迟绑定技术,将过程地址的绑定推迟到第一次调用该过程时。
使用延迟绑定的动机是对于一个像 libc.so 这样的共享库输出的成百上千个函数中,一个典型的应用程序只会使用其中很少的一部分。把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的内存引用。
延迟绑定是通过两个数据结构的交互来实现的,这两个数据结构是 GOT(全局偏移量表)和 PLT(过程链接表)。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的 GOT 和 PLT。GOT 是数据段的一部分,PLT 是代码段的一部分。图 5.15 展示了PLT和GOT如何协作在运行时解析函数的地址。
而其中我们关心的使GOT和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在ld-linux.so模块中的入口点。其余的每个条目对应与一个被调用的函数,其地址需要在运行时被解析。
通过readelf查看节头表中的.got.plt信息,可知.got.plt节的开始地址为0x4040000,且大小为0x40;
然后利用edb查看dl_init前后动态链接的变化,首先从edb的Data Dump打开0x403000 – 0x405000范围内的数据,找出0x404000 – 0x40403f即为dl_init的数据段:
使用edb执行到dl_init,该段发生变化,变化为
可以发现在dl_init后出现了两个地址,分别为0x7f9979380190和0x7f9979369bb0,这就是GOT[1]和GOT[2],同样利用edb查看GOT[2]内容,可以发现是动态链接函数:
5.8 本章小结
在本章中主要介绍了链接的概念与作用,并且详细阐述了hello.o是怎么链接成为一个可执行目标文件的过程,详细介绍了hello.o的ELF格式和各个节的含义,并且分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。历经艰辛我们的hello可算是诞生了。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念:
进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动 过程调用的指令和本地变量。
6.1.2进程的作用:
进程为用户提供了以下假象:
1.我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存。
2.处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 Shell-bash的作用:
Shell是一个命令行解释器,它为用户提供了一个向Linux内核发送请求以便运行程序的界面系统级程序,用户可以用Shell来启动、挂起、停止甚至时编写一些程序。Shell还是一个功能相当强大的编程语言,易编写,易调试,灵活性较强。Shell是解释执行的脚本语言,在Shell中可以直接调用Linux系统命令,能够交互性地解释和执行用户输入的命令,能够通过调用系统级的函数或功能来执行程序、建立文件、进行并行操作,还能够协调程序间的运行冲突,保证程序能够高效执行,与此同时,bash还提供了一个图形化的界面。
6.2.2 Shell-bash的处理流程:
1.终端进程读取用户由键盘输入的命令行。
2.分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。
3.检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令。
4.如果不是内部命令,调用fork( )创建新进程/子进程。
5.在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
6.如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait…等待作业终止后返回。
7.如果用户要求后台运行(如果命令末尾有&号),则shell返回。
6.3 Hello的fork进程创建过程
终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
6.4 Hello的execve过程
当创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,加载并运行需要以下几个步骤:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据 复制。直到 CPU 引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用 它的页面调度机制自动将页面从磁盘传送到内存。
6.5 Hello的进程执行
进程调用execve函数之后,由上面分析可知,进程已经为hello程序分配了新的虚拟的地址空间,并且已经将hello的.txt和.data节分配虚拟地址空间的代码区和数据区。最初hello运行在用户模式下,输出hello 2021113243 cdr,然后hello调用sleep函数之后进程陷入内核模式,内核不会选择什么都不做等待sleep函数调用结束,而是处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
操作系统内核使用上下文切换这一较高层形式的异常控制流来实现多任务。
内核为每个进程维持一个上下文,上下文就是内核重新启动一个被抢占的进程所需的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程一打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策叫做调度,是由内核中称为调度器的代码处理的,当内核选择一个新的进程运行时,就说内核调度了这个进程。当内核调度了一个新的进程运行后,它就抢占当前进程,并通过上下文切换的机制将控制转移到新的进程。
上下文切换的流程为:
1.保存当前进程的上下文。
2.恢复某个先前被抢占的进程被保存的上下文。
3.将控制传递给这个新恢复的进程。
4.当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个时间发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。如read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另一个进程;sleep系统调用显式地请求让调用进程休眠。
另外,中断也可能引发上下文切换。所有系统都有某种产生周期性定时器中断的机制,每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。
6.6 hello的异常与信号处理
6.6.1 hello的异常
异常和信号异常可以分为四类:中断、陷阱、故障、终止
hello执行过程中会出现中断,陷阱,故障,终止四种异常,具体如下:
1.中断:系统默认通过定时器的中断来进行上下文的切换,通常为1ms或者10ms,当定时器发出中断信号时,系统就会判断当前进程已经运行了足够长的时间之后进行上下文切换。
2.陷阱:hello程序调用sleep函数显示地进行系统调用,让当前进程休眠,进入内核模式将控制转移给别的进程。
3.故障:执行hello程序的时候有可能发生缺页故障.
4.终止:在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
6.6.2 hello执行过程中因为异常而接收到的信号:
1)ctrl-z:
按下ctrl-z后,shell接收到SIGTSTP信号,将hello进程暂停,通过ps命令可以看到hello进程没有被回收,此时他的后台job的号为1,调用fg 1将其调到前台,此时shell程序首先打印hello的命令行命令,hello继续运行。
2)ctrl-c:
按下ctrl-c之后,shell收到信号SIGINT,然后结束进程hello。Hello结束并且被回收。
- 乱按:
在hello执行过程中键入的信息,都会在hello的结尾处被getchar()函数读入,然后输出到终端。当键入的命令中有\n(回车)时,前一段字符会作为命令行内容被执行。
- ctrl-z加ps:
- ctrl-z加jobs:
- ctrl-z加pstree:
6.7本章小结
在本章中,阐述进程的定义与作用,同时介绍了 Shell 的一般处理流程和作用,并且着重分析了调用 fork 创建新进程,调用execve函数执行hello,hello的进程执行,以及hello的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:是指由程序产生的与段相关的偏移地址部分。逻辑地址由一个段(segment)和偏移量(offset)组成,偏移量指明了从段开始的地方到实际地址之间的距离,表示为 [段标识符:段内偏移量]。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:虚拟地址不是数据在计算机上真正的地址,它是相对于虚拟内存的一个概念。虚拟内存是磁盘上N个连续字节大小的单元组成的数组,而每一个单元对应一个虚拟地址。这是一个独立的地址,也就是说一个字节可以有一个物理地址同时也有一个虚拟地址。
物理地址:用于内存芯片级的单元寻址,与处理器和CPU链接的地址总线相对应。计算机系统的主存被组织为一个由M个连续字节大小的单元组成的数组,每一个字节都有一个唯一的物理地址。hello程序的每一个字节在DRAM中的地址,也是它真正的地址,就是它的物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
首先,一个逻辑地址由16位的段选择符(储存在段寄存器),以及16、32、64位的偏移地址(基址寄存器+变址寄存器)组成。
图7.2.1
16位的段选择符是用来确定段描述符在段描述符表中的位置的,如图前13位是用来查找段描述符的索引,TI位用来确定全局描述符表或者局部描述符表,RPL用来确定是内核态还是用户态。
整个逻辑地址到线性地址的变化就是首先从段寄存器中取出段选择符,由段选择符在段描述符表中查找到对应的段描述符获得段基址,由段基址与偏移地址进行相加得到线性地址,具体流程如下图:
7.3 Hello的线性地址到物理地址的变换-页式管理
由课本知识点可知,线性地址(也就是虚拟地址 VA)到物理地址(PA)之间的转换通过分页机制完成。而分页机制是对虚拟地址内存空间进行分页。
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘(较低层)上的数据被分割成块,这些块作为磁盘和主存(较高层)之间的传输单元。VM 系统通过将虚拟内存分割位称为虚拟页的大小固定的块来处理这个问题。每个虚拟页的大小为P = 2P字节。类似地,物理内存被分割为物理页,大小也为P字节。
在任意时刻,虚拟页面的集合都分为三个不相交的子集:
1.未分配的:VM系统还未分配的页。
2.缓存的:当前已缓存在物理内存中的已分配页。
3.未缓存的:未缓存在物理内存中的已分配页。
而页表将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
下图为一个页表的基本组织结构。页表就是一个页表条目的数组。虚拟地址空间中的每个页在页表中一个固定的偏移量处都有一个PTE,假设每个PTE是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。
下图展示了内存管理单元如何利用页表来实现这种映射。CPU中的一个控制寄存器,页表基址寄存器指向当前页表。N位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE,将页表条目中物理页号和虚拟地址中的VPO串联起来,就得到相应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
TLB:从上述地址转换过程可看出,访存时首先要到主存查页表,然后才能根据主存物理地址再访问主存以存取指令或数据。如果缺页,则还要进行页面替换、页表修改等,访问主存的次数就更多。因此,采用虚拟存储器机制,使得访存次数增加了。为了减少访存次数,往往把页表中最活跃的几个页表项复制到高速缓存中,这种在高速缓存中的页表项组成的页表称为后备转换缓冲器,通常简称为TLB或快表,相应地称主存中的页表为慢表。
TLB访问内存命中的机制与步骤:
1.CPU产生一个虚拟地址;
2.MMU从TLB中取出相应的PTE;
3.MMU将此虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存;
4.高速缓存/主存将所请求的数据字返回给CPU。
当TLB不命中时,MMU必须从L1缓存中取出相应的PTE,新取出的PTE存放在TLB中,可能会覆盖一个已经存在的条目。
四级页表:下图给出了 Core i7MMU如何使用四级的页表来将虚拟地址翻译成物理地址。36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1PET的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2PTE的偏移量,以此类推。
在Intel Core i7环境下研究 VA 到 PA 的地址翻译问题。系统的基本情况如下:虚拟地址空间 48 位,物理地址空间 52 位,页表大小4KB,4级页表。TLB 4路16组相联。CR3 指向第一级页表的起始位置(上下文一部分)。解析前提条件:由一个页表大小4KB,一个PTE条目8B,共512个条目,使用9位二进制索引,一共4个页表共使用36位二进制索引,所以VPN共36位,因为VA 48位,所以VPO 12位;因为 TLB 共16组,所以TLBI需4位,因为VPN 36位,所以TLBT 32位。
如下图,CPU 产生虚拟地址VA,VA 传送给MMU,MMU使用前36位 VPN作为 TLB+TLBI向TLB中匹配,如果命中,则得到 PPN与VPO组合成 PA。如果TLB中没有命中,MMU向页表中查询,CR3确定第一级页表的起始地 址,VPN1确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到 PPN,与 VPO 组合成 PA,并且向TLB中添加条目。如果查询 PTE 的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
7.5 三级Cache支持下的物理内存访问
虚拟地址通过地址翻译得到物理地址后,MMU发送物理地址给L1缓存,缓存从物理地址中抽取出缓存偏移CO、缓存组索引CI以及缓存标记CT,若标记匹配,缓存命中的话,则读出在偏移量CO处的数据字节,并将它返回给MMU,随后MMU将它传递给CPU,若缓存未命中的话,则从L2缓存中进行匹配,若命中则将其存储在L1缓存且返回给MMU,若不命中,则从L3缓存中进行匹配,若命中则将其存储与L2缓存和L1缓存,而后返回给MMU,若不命中,则从主存中寻找。下图为一个四级页表和三级Cache支持下的物理内存访问:
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,他创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制,当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的概念。
7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2.映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长 度为零。
3.映射共享区域, hello 程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
当指令引用一个虚拟地址,而与该地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。然后调用缺页处理程序,缺页处理程序就执行下面步骤:
1.查看虚拟地址是否合法,若不合法,那么缺页处理程序就触发一个段错误,从而终止这个进程;
2.查看试图进行的内存访问是否合法,如果试图访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程;
3.选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送虚拟地址到MMU,MMU就能正常翻译它了,而不会再产生缺页中断了。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显示地被应用所分配。一个已分配的块保持已分配状态,直到它被释放。
分配器有两种基本风格,显式的和隐式的。
显式分配器,要求应用显式地释放任何已分配的块。如C标准库提供的malloc程序包显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。
隐式分配器,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。
任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块。
一个块是由一个字的头部、有效载荷、以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0。因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息。在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
假设块的格式如下图所示:
我们将堆组织为一个连续的已分配块和空闲块的序列,如下图所示:
我们称这种结构称为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。
空闲块的组织通过以下的技术进行:
1.放置已分配的块
当应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的,一些常见的策略是首次适配、下一次适配和最佳适配。
首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配从上一次查询结束的地方开始搜索。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
2.分割空闲块
一旦分配器找到一个匹配的空闲块,就必须考虑分配这个空闲块中的多少空间。一个选择是用整个空闲块。
但如果匹配不太友好,则分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,剩下的部分变成一个新的空闲块。
3.获取额外的堆内存
如果分配器不能为请求块找到合适的空闲块,一个选择是通过合并那些在内存中物理相邻的空闲块来创建一个更大的空闲块。如果这样还是不能生成一个足够大的块,或者如果空闲块已经最大程度地合并了,那么分配器就会通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置在这个新的空闲块中。
4.合并空闲块
带边界标签的隐式空闲链表分配器允许在常数时间内进行对前面块的合并。是在每个块的结尾处添加一个脚部,其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。
使用边界标记的堆块的格式如下图:
7.10本章小结
本章主要介绍了hello的存储器的地址空间,介绍了四种地址空间的差别和地址的相互转换。同时介绍了hello的四级页表的虚拟地址空间到物理地址的转换。阐述了三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列:B0,B1,B2……Bm
所有的 IO 设备(如网路、磁盘、终端)都被模型化为文件,而所有的输入和输出都被 当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行:
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix I/O 接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文 件的所有信息。
2.Shell 创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
3.改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置 k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行 seek,显式地将改变当前文件位置k。
4.读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置 k 开始,然后将 k增加到k+n,给定一个大小为 m 字节的而文件,当 k>=m 时,触发 EOF。类似一个写操作就是从内存中复制 n>0 个字节到一个文件,从当前文件位置 k开始,然后更新k。
5.关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O 函数:
1.int open(char* filename,int flags,mode_t mode),进程通过调用 open 函 数来打开一个存在的文件或是创建一个新文件的。open函数将filename 转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访 问这个文件,mode参数指定了新文件的访问权限位。
2.int close(fd),fd是需要关闭的文件的描述符,close返回操作结果。
3.ssize_t read(int fd,void *buf,size_t n),read 函数从描述符为fd的当前文件位置赋值最多 n个字节到内存位置buf。返回值-1 表示一个错误,0 表示EOF,否则返回值表示的是实际传送的字节数量。
4.ssize_t wirte(int fd,const void *buf,size_t n),write函数从内存位置 buf复制至多n个字节到描述符为 fd 的当前文件位置。
8.3 printf的实现分析
分析首先查看printf函数的函数体:
printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
接下来是write函数:
在printf中调用系统函数write(buf,i)将长度为i的buf输出,在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,
int INT_VECTOR_SYS_CALLA代表通过系统调用syscall。
查看syscall函数体:
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是我们的打印字符串就显示在了屏幕上。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar 的源代码为:
图8.4.1
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar 函数落实到底层调用了系统函数 read,通过系统调用read读取存储在 键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装, 大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。
结论
1.用计算机系统的语言,逐条总结hello所经历的过程。
hello.c:编写c程序,hello.c诞生,它是一个二进制文本文件,hello.c中的每个字符都是用ascall编码表示。
hello.i:hello.c经过预处理阶段变为hello.i。
hello.s:hello.i经过编译阶段变为hello.s。
hello.o:hello.s经过汇编阶段变为hello.o。
hello:hello.o与可重定位目标文件和动态链接库链接成为可执行文件hello。至此可执行hello程序正式诞生。
运行:在终端输入2021113243 cdr 3。
创建子进程:由于终端输入的不是一个内置的shell命令,因此shell调用fork函数创建一个子进程。
加载::shell 调用 execve,execve 调用启动加载器,加映射虚拟内 存,进入程序入口后程序开始载入物理内存,然后进入 main 函数。
上下文切换:hello调用sleep函数之后进程陷入内核模式,处理休眠请求主动释放当前进程,内核进行上下文切换将当前进程的控制权交给其他进程,当sleep函数调用完成时,内核执行上下文切换将控制传递给当前进程。
动态申请内存:当hello程序执行printf函数是, 会调用 malloc 向动态内存分配器申请堆中的内存。
信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有 数据结构。
2.对计算机系统的设计与实现的深切感悟:计算机系统的设计思想和实现都是基于抽象实现的。从最底层的信息的表示用二进制表示抽象开始,到实现操作系统管理硬件的抽象:进程是对处理器、主存和I/O设备的抽象。虚拟内存是对主存和磁盘设备的抽象。文件是对I/O设备的抽象。
计算机系统的设计精巧:为了解决快的设备存储小、存储大的设备慢的不平衡,设计了高速缓存来作为更底层的存储设备的缓存,大大提高了CPU访问主存的速度。
计算机系统的设计考虑全面:计算机系统设计考虑一切可能的实际情况,设计出一系列的满足不同情况的策略。比如写回和直写,写分配和非写分配,直接映射高速缓存和组相连高速缓存等等。
附件
文件名称 | 文件作用 |
hello.i | hello.c预处理后文本文件 |
hello.s | hello.i编译后的文本文件 |
hello.o | hello.s汇编之后的可重定位目标文件 |
hello | 链接之后的可执行目标文件 |
hello.out | hello反汇编之后的可重定位文件 |
参考文献
[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.
[7] 深入理解计算机系统原书第3版-文字版.pdf
[8] https://www.cnblogs.com/diaohaiwei/p/5094959.html