计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机类
学 号 1190202028
班 级 1903010
学 生 牛天昊
指 导 教 师 史先俊
计算机科学与技术学院
2021年5月
本文主要从hello程序的一步步诞生,运行,到最后终止的过程。从预处理,编译,汇编,链接,成为进程,存储体系以及最终的IO体系来多维度诠释hello的一生。结合hello的具体实例,达到了csapp第三版中1-10章的一个综合复习的目的。
关键词:P2P;O2O;预处理;编译;汇编;链接;进程;存储体系
目 录
第1章 概述
1.1 Hello简介
Program to Process:
hello程序的生命周期从一个源程序开始,即程序员通过编辑器创建并保存的文本文件。Hello.c程序是以字节序列的方式存储在文件中的,每个字节都有一个整数值。预处理阶段是根据以字符#开头的命令,修改原始的C程序,将相关信息直接插入到程序文本中。编译阶段是编译器将hello.i翻译成文本文件hello.s。它包含一个汇编语言程序。其中包含main函数的定义。汇编阶段将hello.s翻译成机器语言指令生成可重定位目标文件,以.o进行结尾。最后,链接器链接可重定位目标文件以及一些库文件,生成hello的可执行目标文件。接下来,该文件可以被加载并运行。
图1:hello P2P过程
Zero to Zero:
父进程调用fork函数创建一个子进程,在子进程中,它调用execve函数加载hello。它删除子进程已存在的用户区域,并为新程序的代码,数据,栈等创建全新的区域结构,并且将一些共享对象动态链接到这个程序,再映射到用户虚拟地址空间中的共享区域中。最后设置程序计数器使之指向hello代码区域的入口点。这时hello便开始执行了。CPU以流水线的形式执行指令。在执行过程中,会遇到各种形式的异常控制流,必须能够处理。会访问cache,主存,磁盘等存储设备,还会通过IO接口进行输入和输出。当hello运行结束后,父进程还会回收hello,让他彻底不再存在。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:Intel®Core™ i7-9750H CPU;16G RAM;256GHD Disk
软件环境:win10 64位操作系统;Ubuntu 20.04;
开发与调试工具:CodeBlocks 64位;gcc;edb;objdump;readelf
1.3 中间结果
Hello.i:hello.c经编译预处理后的文件。
Hello.s:hello.i经编译以后的文件;
Hello.o: hello.i经汇编后的可重定位目标文件。
Hello:执行链接后的可执行目标文件。
1.4 本章小结
本章对hello的一生进行了简要的概述,给出了每一步的中间结果,介绍了本文所用的软硬件环境等信息。
第2章 预处理
2.1 预处理的概念与作用
(以下格式自行编排,编辑时删除)
概念:预处理器(cpp)通过以字符#开头的命令,修改原始的C程序。例如hello程序中的#include命令告诉预处理器读取系统头文件stdio.h中的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
预处理指令主要有以下三种:
1)包含文件:将源文件中以#include格式包含的文件复制到编译的源文件中,可以是头文件,也可以是其它的程序文件。
2)宏定义指令:#define 指令定义一个宏,#undef指令删除一个宏定义。
3)条件编译:根据#ifdef和#ifndef后面的条件决定需要编译的代码。
作用:解释完成编译预处理指令,从而把预处理指令转换成相应的C程序段,最终称为由纯粹C语句构成的程序,经编译后得到目标代码。
2.2在Ubuntu下预处理的命令
(以下格式自行编排,编辑时删除)
预处理命令:cpp hello.c>hello.i
应截图,展示预处理过程!
图2:预处理过程
如图所示,输入编译预处理命令。
最后输出结果文件hello.i
图3:预处理结果
2.3 Hello的预处理结果解析
.c文件成为一个具有3060行的一个很长的文本文件。其中加入了系统头文件stdio.h中的内容,unistd的内容以及stdlib的内容。Main函数位于其中的3047行如图所示
图4:预处理结果解析
2.4 本章小结
本章主要介绍了hello.c程序经过编译预处理命令到达hello.i的过程,加入了系统头文件中的内容。为进一步的编译做准备。
第3章 编译
3.1 编译的概念与作用
(以下格式自行编排,编辑时删除)
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
概念:是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级语言)”的过程。例如,将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
作用:编译程序以高级程序设计语言书写的源程序作为输入,而以汇编语言或机器语言表示的目标程序作为输出;编译出的目标程序通常还要经历运行阶段,以便在运行程序的支持下运行,加工初始数据,算出所需的计算结果。
3.2 在Ubuntu下编译的命令
gcc –S hello.i –o hello.s
应截图,展示编译过程!
如图所示:
图5:编译过程
编译后生成hello.s如图所示
图6:编译结果
3.3 Hello的编译结果解析
3.3.1 数据
常量:汇编代码中立即数形式存在。例如循环次数:
图9:编译结果解析
如图所示,在rodata节中还有两个字符串,分别对应于源程序中如图所示的printf的两个格式字符串
图10:编译结果解析
程序64位以寄存器传递参数,当寄存器个数不足时以堆栈传递参数。Main函数具有两个参数argc和argv。比对源程序和汇编程序,汇编程序中具有命令:
进行实现。除此之外基本的算术操作除了加、减、乘、除意外还有移位运算,移位运算分为算术移位和逻辑移位。具体的汇编指令如图所示:
图18:算术汇编指令
3.3.4 关系操作与控制转移
在hello源代码中,关系操作首先是判断main函数的第一个参数(即传递给main函数的参数个数)argc是否等于4。由3.3.1节中的分析可知argc存储于%rbp-20的位置。因此,这条操作对应于汇编语言中如图所示的语句:
图20:编译结果解析
关系操作另一个出现之处是在源代码中for循环判断累计变量i是否小于8。这对应于汇编代码中如图所示的部分:
图22:编译结果解析
3.3.5 数组操作
在hello源代码中有一个数组argv。实际传递给main函数的是数组的首地址。编译后,根据与首地址的偏移量来访问数组的元素。有关的汇编代码如图所示。
图23:编译结果解析
3.3.1节中已经提及数组argv的首地址被放在%rbp-20对应的位置。上图的第一条movq传送8字节的数据,将(%rbp-32)中8字节的内容,即数组argv的首地址传送给寄存器%rax。之后将%rax加上16.由源代码可知argv中存在的是指针,并且是64位系统,指针占据8个字节。因此第二条语句将rax中的值设置为argv中第三个元素的起始位置。(下标为2).紧接着的一条movq指令将第三个元素的值取出送给寄存器%rdx。同理,紧接着的四条语句将第二个元素的值(下标为0)送给%rsi。这恰好对应于源程序中访问argv[1]和argv[2]如图所示的代码:
图25:编译结果解析
由3.3.5节的分析可知,在调用printf之前,我们正确的将argv[2]的值送到了%rdx中,正确的将argv[1]送到了%rsi中。再由3.3.5节中的附图可知正确的将格式串的地址送到了%rdi中,由此正确的给printf调用参数。在调用atoi函数之前,已经将正确的argv[3]的内容传递给了%rdi,也就是atoi调用的第一个参数。最后,由于atoi的返回值整型四字节存储在%rax的低4字节%eax中,汇编代码将%eax送给%edi,也就是%rdi的低4字节,由此正确的给sleep函数正确的调用参数。对于exit函数的参数传递也是同理的。
函数调用:通过call指令来实现函数调用。Call指令的作用是将call指令之后的指令地址压入堆栈中,并使得程序计数器调换到函数的入口地址处开始执行函数。例如3.3.5节附图中call printf@plt语句。
函数返回:返回值被保存在寄存器%rax中。例如,汇编代码中调用atoi后直接将%eax值送给%edi,供sleep函数所使用。这是因为atoi返回的结果被保存在了寄存器%eax中。除此之外,源代码中如图所示的部分是从main函数返回的前一步所需进行的步骤。首先将%eax赋值为0,也就是在源代码中main函数将要返回0;leave指令等价于将栈帧%rbp送给%rsp,并且将当前栈顶的内容送给%rbp,也就是使得%rbp恢复到调用main函数的那个函数的栈帧位置。此时,%rsp指向的是调用main函数的那个函数的返回地址,当ret指令执行时,pop栈顶的返回地址,并且使得程序计数器指向这个返回地址,即调用main函数的那个函数的下一条指令地址。由此完成了main函数的return 0 指令。
图27:汇编过程
运行的结果如图所示
图28:汇编结果
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
1)ELF头:如图所示
图30:节头部表信息
节头部表给出了每个节的具体信息。包括名字,类型,地址(因为还没有链接,所以地址是不确定的),相对于文件的起始偏移,大小,读写信息,对齐等要求。例如,代码节.text开始于文件偏移量为0x40的位置,占据大小0x92字节。由于程序中不存在已经初始化的全局和静态C变量,以及不存在未初始化的全局和静态C变量,所以.data节和.rodata节的大小均为0.在目标文件中,.bss节不占据实际的空间,他仅仅是一个占位符。
3)重定位分析:
如图所示,仅仅在text节具有相关的重定位信息。
图31:重定位条目分析
.data节和.text节各会有一个重定位条目表.rel.data和.rel.text;包含对应节的重定位信息。每个重定位的条目有如下的结构:offset指示该重定位条目的节偏移,type:指示重定位的类型。在这里有两种类型,如图所示。同时,还有一个重定位条目的名称。Attend用于一些重定位中使用被修改引用的值作偏移调整。
根据重定位的信息,对此
下面对各部分的情况进行分析:
对于第一个和第四个重定位条目:使用PC32相对寻址的方式。对应于重定位.o文件中访问.rodata节中的字符串"用法: Hello 学号 姓名 秒数!\n"。当链接后的可执行文件生成后,字符串常量的运行时地址通过PC32相对寻址一定的转换重新填入到机器代码中。对于第四个重定位条目,同理,对应于重定位.o文件中访问.rodata中的字符串,对应于源代码中printf的格式化字符串。
对于其余的重定位条目:使用PLT32的寻址方式。对应于重定位.o文件中调用的外部库函数的部分。由于源代码中的第一个printf仅有字符串作为参数,因此被编译器优化成调用Puts函数。
具体进行重定位的情况分析留在第五章中作介绍。
4)符号表
图35:函数调用分析
在链接时的重定位步骤时,对于函数puts已经确定了运行地址后,再将运行目的地址对应的机器级二进制代码替换这些原有的0;进而生成可执行文件。重定位的细节留作第五章详细说明。
初次之外,如图所示的区域内也存在未被填写的信息:
图36:函数调用分析
链接时的重定位同样会处理类似的条目,将这些0替换成为以PC相对寻址方式访问.rodata节中的第一个字符串的机器级代码。具体实现的细节留作第五章详细说明。
C)机器语言和汇编语言对应的操作数也是不一致的。例如,如左图所示,将%rsp减少的语句,汇编语言中以十进制表示减少的数量32.机器语言中以十六进制表示减少的数量0x20;
4.5 本章小结
本章通过分析hello.o机器级代码与汇编语言代码的对应关系以及hello.o各部分的组成,诠释了hello.o的结构,机器级代码的编码。Hello.o本身是一个二进制文件,包含相对应的机器代码,但是从符号表中可以看到有许多没有定义但已经被引用的符号,并且并没有确定各部分的运行地址,只是有了一个相对的地址。最后的这些步骤留待第五章链接进行处理。
第5章 链接
5.1 链接的概念与作用
(以下格式自行编排,编辑时删除)
链接是生成可执行目标文件的最后一步。链接时将各种代码和数据片段手机并组合成为一个单一文件的过程。这个文件可以被加载到内存并执行。链接可以执行于编译时,也就是再源代码被翻译成机器代码时。也可以执行于加载时,也就是程序被加载器加载到内存并执行时。甚至执行与运行时,也就是由应用程序来执行。
链接再软件开发中扮演者一个关键的角色,因为他们使得分离编译成为可能。我们不用将一个达性的应用程序组织为一个巨大的源文件,而是可以把他分解成为更小,更好管理的模块,可以独立的修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译他,并重新链接应用, 而不必重新编译其他文件。
注意:这儿的链接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
(以下格式自行编排,编辑时删除)
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
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
输入下列命令:
图37:链接过程
执行后的结果如图所示:
图37:可执行目标文件中ELF表的信息
如图所示,ELF头表明有27个节;Type域表明文件是一个可执行目标文件。
2)section header信息:
图39:重定位信息
4)符号表:如图所示为可执行目标文件中的符号表。可以看到,链接过程中加入了更多的符号。符号表的条目结构与可重定位目标文件中的类似。
图41:程序头部表信息
可执行文件连续的片被映射到映射到连续的内存段,程序头部表描述了这种映射关系。在可执行目标文件中,ELF头,段头部表,.init节,.text节以及.rodata节共同组成一个只读内存段;.data节,.bss节以及.symtab节组成读写内存段。剩下的是不加载到内存的符号表和调试信息。为了实现对齐要求。对于每一个段s,链接器必须选择一个起始位置vaddr,使得vaddr mod align = off mod align;
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
将hello加载到内存后,注意代码段还包含ELF头和段头部表,均需要加载到内存中。代码段从虚拟地址0x400000处开始,并不是从.init开始。如图所示:
图42:代码段起始信息
5.3节的程序头部表中给出了各个节的起始位置,利用edb进行查看。例如,.text节起始于0x4010f0的位置。在edb中查看的结果如图所示:
图43
再比如,利用edb查看.rodata节。起始位置为0x402000;
图44:虚拟地址空间信息
如图所示。这里确实包含了我们源程序中定义的字符串常量。包括包含中文在edb中无法正常显示的字符。同时,格式串也在这里。
5.5 链接的重定位过程分析
(以下格式自行编排,编辑时删除)
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
如图所示,运行命令,得到的结果如图所示(左图为结果,右图为Hello.o程序)
图45-46:左图为结果,右图为可重定位目标文件
重定位首先将所有相同类型的节合并为同一个类型的新的聚合节。例如,hello中.text节的大小比hello.o的文件多了很多。然后,链接器将运行时的内存地址赋给新的聚合节,以及赋给输入模块定义的每个符号。于是,程序中的每条指令和全局变量都有了唯一的运行时内存地址。在右图的hello.o文件中地址只是相对于文件的起始位置的偏移量。而作图所示的可执行目标文件地址变化成了实际的虚拟内存地址。例如,hello.o中main函数的push指令原先地址为4,表明相对于原先.text节的偏移量为0;而在作图的hello中,main函数的push指令地址为0x401129,这表明这条指令的唯一的运行时虚拟地址。
重定位的第二步是重定位节和符号引用。每个重定位条目有着如图所示的结构:
图47:重定位条目
Offset指明了当前需要重定位信息的节偏移量。Type指明重定位的类型,symbol指明符号信息,attend指明偏移量信息。
对于每种重定位的类型,下面分别举例来说明过程。
首先看PC32的重定位类型。以重定位hello.o中指令编号为19处的信息为例。
首先需要确定hello.o中.rodata节起始位置,而不是可执行目标文件中的起始位置。如图所示:
图50:edb下的GOT表信息
执行完成dl_init后,GOT的内容如图所示:
图51:edb下的GOT表信息
GOT的每个条目为8个字节。GOT[0]和GOT[1]包含动态连接器在解析函数地址时会使用到的信息。GOT[2]是动态连接器在ld_linux.so模块中的入口点。其余的每个条目都有一个相匹配的PLT条目。使用objdump查看过程链接表PLT的内容如图所示;
图52-53:动态链接分析
如图所示,每个相同颜色方框中的内容都对应于一个PLT条目,依次类推。第一个红框标识的条目PLT[0]是一个特殊的条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。PLT的其余条目为调用用户代码的函数。可以看到,GOT[3]匹配的是PLT表中第二个黄色框中的条目。其余的以此类推。GOT[3]以后所有条目的初始值都是指向plt段的一个条目。例如,GOT[3]指向黄色框所对应的PLT条目。GOT[4]指向绿色框所对应的PLT条目;
以调用puts函数为例说明GOT表的变化。如图所示,第一次调用puts前,对应GOT表项如图所示:
图54:调用puts前GOT表的信息
第一次调用puts后,动态链接过程会更改GOT表项的内容,使之称为puts函数真正的运行时地址。跟踪进入代码:
图56:跟踪结果
第一次调用结束后,再次执行0x401094处的代码(即下面图片中黄色方框对应的位置)最终会直接跳转到puts函数的入口处,这是因为GOT[3]已经被更改为PUTS函数的实际运行时地址。
5.8 本章小结
链接可以在编译时由静态编译器来完成。也可以在加载时和运行时由动态连接器来完成。链接器处理称为二进制文件,具有3种不同的形式。本章节中,介绍了hello的链接过程,符号解析和重定位,并对hello的动态链接进行了简要的分析。至此,hello已经成为了一个可执行目标程序,它即将诞生。
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序代码和数据。他的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。
提供一种假象,好像我们的程序在独占的使用处理器。好像我们的程序独占的使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个交互性的应用程序, 代表用户运行其他程序。
Shell执行一系列的读/求值步骤,然后终止。Shell 除了能解释用户输入的命令,将它传递给内核,还可以:调用其他程序,给其他程序传递数据或参数,并获取程序的处理结果;在多个程序之间传递数据,把一个程序的输出作为另一个程序的输入;Shell 本身也可以被其他程序调用。由此可见,Shell 是将内核、程序和用户连接了起来。
读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。shell首先检查命令是否是内部命令,若不是再检查是否是一个应用程序。shell在搜索路径里寻找这些应用程序(。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核。
6.3 Hello的fork进程创建过程
父进程可以调用fork函数创建一个进程。特殊的,shell可以创建一个子进程,然后在这个子进程的上下文中加载并运行我们的可执行目标文件hello。
如图所示,我们在shell中输入如下命令:
图57:hello运行
Shell发现这个命令不是一个内置的命令,于是创建一个子进程加载并运行我们的hello可执行目标文件。
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户及虚拟地址空间相同的但是独立的一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本。子进程与父进程最大的区别在于他们有不同的PID。
fork函数调用一次返回两次。在子进程中返回0,在父进程中返回子进程的PID。
6.4 Hello的execve过程
Execve函数在当前进程的上下文中加载并运行一个新程序。函数加载并运行可执行目标文件filename,且带参数列表和环境变量envp。只有当出现错误时才会返回到调用程序。否则调用一次从不返回。
加载并运行hello程序需要如下的步骤:
1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2)映射私有区域。为新程序的代码,数据,bss和栈区域创建新的区域结构,所有这些新的区域结构都是私有的,写时复制的。代码和数据区域被映射为hello文件中的.text和.data区域。Bss区域是请求二进制零的,映射到匿名文件。栈和堆区域也是请求二进制零的。初始长度为0
3)映射共享区域。将hello链接的共享对象映射到这个区域。
4)设置程序计数器。设置当前进程上下文中程序计数器的位置,使之指向代码区域的入口点。
6.5 Hello的进程执行
(以下格式自行编排,编辑时删除)
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
1)上下文信息:上下文是内核重新启动一个被抢占的进程所需的状态,它由一些对象的值所组成。这些对象包括通用目的寄存器,浮点寄存器,程序计数器,用户栈,状态寄存器,内核栈和各种内核数据结构。调用execve函数后,hello进程需要等待内核调度。
2)hello进程调度:调度是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,我们就说内核调度了这个进程。在内核调度了一个新的进程运行时,他就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。上下文切换包括:保存当前进程上下文,恢复某个先前被抢占进程的上下文,将控制传递给这个新恢复的进程。当操作系统调度hello进程时,它首先保存前一个进程的上下文信息,然后进入内核模式,将控制转移到hello,然后切换为用户模式。
Hello本身具有sleep函数,作用是挂起当前进程一定的时间,在挂起的过程中内核通过上下文切换调度其他进程,并且hello进程不会被调度。当sleep时间到达后,定时器芯片发送一个中断信号,内核保存当前正在调度进程的上下文并恢复hello进程的上下文,从hello进程的下一条指令处重新开始执行。
Hello进程还有一个getchar函数,没有来自键盘的中断会阻塞当前进程的运行。这段时间内内核也可以去调度其他的进程。
3)用户态与内核态的转换:处理器通常使用某个控制寄存器中的模式位来提供这种功能。当设置了模式位时,进程运行在内核模式中;否则运行在用户模式中。用户模式中的进程不允许执行特权指令,任何这样的尝试都会导致致命的保护故障。Hello进程首先是处于用户模式中,想要转换位内核模式的唯一方法是通过诸如终端,故障或陷入系统调用这样的方式。
4)进程时间片:hello进程给我们的一种假象是:好像我们的程序正在独占的使用处理器。关键点在于hello和其他进程是轮流使用处理器的。Hello进程执行它控制流的一部分叫做时间片。
6.6 hello的异常与信号处理
(以下格式自行编排,编辑时删除)
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
6.6.1 异常
Hello执行过程中四种类型的异常均可能出现。
1)中断:中断是异步发生的,是来自处理器外部的IO设备的信号的结果。在hello执行过程中,定时器芯片可以通过向处理器的引脚发送信号,并将异常号放置在总线上,来触发中断。处理器注意到中断引脚的电压变高了,它就将控制返回给下一条程序。每次定时器发生中断时,内核就能够判断当前hello进程已经运行了足够长的时间,并切换到一个新的进程
2)陷阱:hello向内核请求服务,执行系统调用,例如调用sleep函数。陷阱处理程序将控制返回到下一条指令中。
3)故障:hello在执行过程中可能.rodata节的某一个部分(例如字符串)被引用但物理页面却不在内存中,必须从磁盘上取出。缺页处理程序从磁盘加载页面,然后将控制返回给引起故障的指令。当指令再次运行时,即能正常访问。
4)终止:hello在执行过程中也可能出现一些硬件错误,比如DRAM或者SRAM位被损坏时发生的就错误。终止处理程序从不将控制返回给应用程序。
6.6.2 信号
1)程序运行过程中不停的乱按键盘。
图58:hello运行过程中乱按键盘
如图所示,过程中不停的按键盘,键盘的输入内容会呈现在shell中。当8个信息打印完成后,键盘的内容会不断地呈现在shell上,直到回车的出现。
2)可能接收sigint和sigtstp信号,从键盘发送的信号
如图所示,运行程序并键入ctrl-Z
图59:hello运行过程中输入ctrl-z
如图所示,作用是使得前台进程hello停止。运行ps命令查看当前进程:
图60:调用ps命令查看
可以看到hello进程仍然处于进程列表中。
如图所示,运行jobs命令:
图61:调用jobs命令查看
可以看到当前进程处于挂起状态。
运行pstree命令可以查看当前进程树状结构:
图62:查看进程树状结构
运行fg命令将hello进程重新切换到前台运行,如图所示,hello继续运行:
图63:将进程切换到前台运行
再次使得进程挂起,可以调用kill指令发送sigkill命令使其终止,再次调用jobs看不到任何信息:
图64:kill后再次调用jobs的结果
再次运行进程,并键入ctrl+c
图65:键入ctrl+c后的结果
如图所示,发送sigint信号给前台每个进程即hello使得进程被终止。
2)hello可能发送sigchild信号,通知其父进程停止或终止。
6.7本章小结
本章描述了创建进程的方法,加载hello可执行目标文件的方法,以及hello进程的管理方法,hello运行过程中可能会产生的异常,hello可能接收的信号以及发送的信号,同时还描述了shell处理程序的一般步骤。
第7章 hello的存储管理
7.1 hello的存储器地址空间
(以下格式自行编排,编辑时删除)
逻辑地址:是指由程序产生的与段相关的偏移地址部分,也是在有地址变换功能的计算机中,访内指令给出的地址。逻辑地址由两部分组成:段地址和偏移地址。在实模式下,物理地址=段地址*16+偏移地址。在保护模式下,以段描述符作为下标,到GDT/LDT表查表获得段地址,段地址+偏移地址=线性地址。
线性地址:是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。在页式管理中,线性地址作为虚拟地址经过地址翻译MMU转换成物理地址。
虚拟地址:在Intel中,逻辑地址转换为线性地址也就是这里的虚拟地址。因此虚拟地址与这里的线性地址是等价的。
物理地址:计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组,每字节都有一个唯一的物理地址。是出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果。用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段地址(段寄存器)和偏移地址两部分组成。
段选择符存储在段寄存器中。段选择符的结构如图所示:
图69:多级页表的管理
如图所示,这是一个使用4级页表结构。虚拟地址被划分位4个VPN和1个VPO。每个VPNi是一个到第i级页表的索引,其中1<=i<=4.第j级页表中的每个PTE,j<=3都包含一个指向第j+1级某个页表的及地址。第4级页表中的每个PTE包含某个物理页面的物理页号或一个磁盘块地址。为了构造物理地址,必须访问4个PTE。这里TLB能够发生作用,可以将不同层次上的PTE缓存起来。
7.5 三级Cache支持下的物理内存访问
图70:cache访问
如图所示,由上面的分析可知,经过地址翻译后,物理地址由52位构成。L1 cache由64组组成,每一组有8行。每一行的大小为64字节。所以块偏移位CO为6位.同时因为有64组,所以组索引位也有6位。剩下的40位作为标记位。根据给定的物理地址,访问L1 cache。首先根据组索引位找到对应的组别,然后逐个搜索该组别中是否有与该请求的物理地址标记相同的行。
如果存在且该行的标记为有效,则为命中,取出该行块偏移所指示的内容,返回到CPU中。否则需要从L2 cache中利用相同的物理地址请求。如果还没命中,还需要再从L3 cache中请求,以此类推,直到从主存中请求数据。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程mm_struct,区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读。并将两个进程中的每个区域结构都标记为私有的写时复制。如图所示:
图72:虚拟地址映射
Execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效的替代了当前程序。加载并运行hello程序得到内存映像需要一下几个步骤:
1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2)映射私有区域。为新程序的代码,数据,bss和栈区域创建新的区域结构,所有这些新的区域结构都是私有的,写时复制的。代码和数据区域被映射为hello文件中的.text和.data区域。Bss区域是请求二进制零的,映射到匿名文件。栈和堆区域也是请求二进制零的。初始长度为0
3)映射共享区域。将hello链接的共享对象映射到这个区域。
4)设置程序计数器。设置当前进程上下文中程序计数器的位置,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
当MMU取出PTE后,如果PTE的有效位为0,则会触发一个缺页故障。控制转移到内核的缺页异常处理子程序,处理程序执行如下的步骤:
- 检查所请求的虚拟地址A是否合法。缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页异常处理程序就触发一个段错误从而终止这个进程。
- 检查进行的内存访问是否合法。即进程是否有读,写或者执行这个区域内页面的权限。例如,这个缺页是否由一条试图对这个代码段里的制度页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字而造成的?如果试图进行的访问是不合法的,那么缺页异常处理程序会触发一个保护异常从而终止这个进程
- 现在,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它选择一个牺牲页面,如果这个页面被修改过,那么将他交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常的翻译A。
图72:缺页中断(故障)的处理
如图所示,由上面的分析可知缺页异常有可能导致进程终止或回到下一条指令处继续执行。
7.9动态存储分配管理
(以下格式自行编排,编辑时删除)
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的区域开始,并向上生长。对于每个进程,内核维护一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片。要么是已分配的,要么是空闲的。已分配块显式地保留供应用程序使用,空闲块可用来被分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式地执行的,要么是内存分配器自身隐式的执行的。
分配器有两种基本的风格,这两种风格都要求应用显式地分配块。他们地不同之处在于由哪个实体来负责释放已经分配的块。显式分配器要求应用显式地释放任何已经分配地块。隐式分配器要求分配器检测一个已分配的块何时不再被程序所使用,那么就释放这个块。
- 带边界标签的隐式空闲链表
一个块是由一个字的头部,有效载荷以及可能的一些额外的填充组成的。头部编码了这个块的大小,以及这个块是已分配的还是空闲的。这样的话,可以将堆组织成一个连续的已分配块和空闲块的序列,称为隐式空闲链表。
同时,在每个块的结尾添加一个脚部。其中脚部是头部的一个副本,如果每个块包括一个这样的脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距离当前块开始位置一个字的距离。
分配策略:
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式有三种:首次适配,下一次适配和最佳适配。
释放与合并:
有4种情形:
1)当前面的块和后面的块都是已分配的,只是简单地将当前块的状态从已分配变为空闲
2)当前块和后面的块合并。用当前块和后面块的大小的和来更新当前块的头部和后面块的脚部;
3)前面的块和当前的块合并。用两个块的大小的和来更新前面块的头部和当前块的脚部。
4)要合并所有的三个块形成一个单独的空闲块,用三个块的大小的和来更新前面块的头部和后面块的脚部。
图73:隐式空闲链表的块结构
2)显式空闲链表
程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。例如,堆可以组织成一个双向空闲链表。在每个空闲块,都包含一个前驱和后继指针。
使用双向链表而不是隐式空闲链表,使得首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可以是一个常数。取决于所选择的空闲链表中的排序策略:
一种方法是使用后进先出的顺序维护链表。将新释放的块放置在链表的开始处。使用LIFO顺序和首次适配的策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表。其中链表中每个块的地址都小于它后继地址。在这种情况下,释放一个块所需要线性时间的搜索来定位合适的前驱。这样的方法如果采用首次适配的策略将比LIFO排序的首次适配有更高的内存利用率。并接近最佳适配的内存利用率。
图74:显式空闲链表的块结构
7.10本章小结
本章从虚拟内存基本概念,hello进程内存映射,存储器层次结构,hello动态内存分配算法等维度阐述了hello的存储管理模式。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
(以下格式自行编排,编辑时删除)
一个linux文件就是一个m字节的序列
B1,B2,B3……BK……Bm-1
所有的IO设备(包括网络,磁盘和终端)都被模型化为文件。而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅的映射为文件的方式,允许Linux内核引出一个简单,低级的应用接口,成为unix IO。这使得所有的输入和输出都能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
1)打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个IO设备。内核返回一个小的非负整数,叫做描述符。它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2)linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0),标准输出(描述符为1)和标准错误(描述符为2);头文件定义了常量STDIN_FILENO,STDOUT_FILENO和STDERR_FILENO;他们可以用来代替显式的描述符值。
3)改变当前的文件位置。对于每个打开的文件,内核保持一个文件位置k,初始为0.这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显式地设置文件的当前位置为k;
4)读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n;给定一个大小为m个字节的文件,当k>=m时执行读操作会触发一个成为EOF的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的EOF符号。
5)关闭文件:当应用完成了对文件的访问后,他就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放他们的内存资源。
函数:
1)通过调用open函数来打开一个已存在的文件或者创建一个新文件:
函数原型int open(char* filename,int flags,mode_t mode)
Open函数将filename转换为一个文件描述符,并且返回描述符数字。返回地描述符总是在当前进程中当前没有打开的最小描述符。Flags参数指明了进程打算如何访问这个文件。
2)通过调用close函数来关闭一个打开的文件。
函数原型int close(int fd)
3) 通过调用read函数来读取文件。
sSize_t read(int fd,void *buf,size_t n)
如果成功返回读取的字节数,若为EOF则返回0,若出错则返回-1;从描述符为fd的当前文件位置复制最多n个字节到内存位置buf;返回值-1表示一个错误,而返回值0表示EOF,否则表示实际传送的字节数量。
4)通过调用write函数来写文件
函数原型为:
Ssize_t write(int fd,const void* buf,size_t n)
若成功则返回写的字节数,否则返回-1;从内存位置Buf复制至多n个字节到描述符fd的当前文件位置。
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是一个字符指针。由C语言的压栈顺序可知,&fmt实际上就表示的是格式化字符串参数在栈中的地址。因此(char*)&fmt+4表示的就是可变数量参数列表…..中第一个参数的地址。
Vsprintf的作用是格式化。接收确定输出格式的格式字符串fmt并产生格式化的输出。返回值是处理后格式化字符串的长度。另一方面,Write函数的作用是将buf中的至多i个字符写到终端。因此函数的作用是将所有格式化字符串的值写到终端。
Write函数的汇编代码实现如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里的int表示要调用中断门,通过中断门实现调用一系列的系统服务。表示要通过系统来调用syscall函数。代码如下:
sys_call:
;ecx中是要打印出的元素个数
;ebx中的是要打印的buf字符数组中的第一个元素
;这个函数的功能就是不断的打印出字符,直到遇到:'\0'
;[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
xor si,si //清零si寄存器
mov ah,0Fh
mov al,[ebx+si] //al为第一个字符的内容
cmp al,'\0' //比较是否为空字符(已到达buf的末尾)
je .end
mov [gs:edi],ax //读出buf字符的值移动到特定的位置
inc si //si寄存器增加1
loop:
sys_call
.end:
ret
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
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函数首先新建一个buf数组用于存放输入的内容。
设置一个指针bb初始指向buf的第一个的位置,设置n的初值为0; 必须注意n和bb都是静态变量。仅进入getchar后第一次被赋初值。
当n为0的情况下,如果是第一次调用getchar的时候,n和bb会被赋初值。首先调用系统read函数,从标准输入STDIN最多读取BUFSIZ个字节到buf中。并且令bb指向buf的起始地址。Read返回读的字节数,并将这个字节数赋值给n。返回的时候满足—n>0才会返回buf的第一个字符(即bb指向的地址所对应的字符),否则返回EOF。
由于bb是静态变量会保存数据,此后(--n>0)的条件下每一次调用getchar会一直返回bb所指向的buf中的数据,将n减少1,并更新静态变量bb的值(指向buf中的下一个字符),而不执行if里面的语句。直到buf即缓冲区里面的内容被读取完毕后,即n=0的条件下,才会执行if里面的语句,重新调用read函数从STDIN中读入新的数据,如此往复。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章集合了unix IO的各种基本概念,基本函数。并依次为依据对printf函数和getchar函数进行了分析
结论
编译预处理:从源程序经过头文件,条件编译宏替换等过程生成hello.i文件。
编译:从hello.i文件由编译器生成hello.s文件,这是一个由汇编代码组成的文本文件。
汇编:将汇编语言翻译成机器语言,此时hello成为一个可重定位目标文件hello.o。
链接:由链接器完成。进行符号解析和重定位,将hello变成可执行目标文件。
fork子进程:父进程调用fork函数创建一个子进程。具有与父进程完全相同但是独立的地址空间,子进程继承了父进程所有的打开文件描述符。
加载:子进程调用execve加载hello可执行目标文件,hello开始运行。
执行:CPU执行指令;内核处理异常,调度程序。进程处理信号。还会访问存储器等。
回收:父进程负责回收hello进程,此后hello不再存在。
附件
Hello.i:hello.c经编译预处理后的文件。用于编译预处理阶段的分析。
Hello.s:hello.i经编译以后的文件;用于编译阶段的分析。
Hello.o: hello.i经汇编后的可重定位目标文件。用于汇编和链接阶段的分析。
Hello:执行链接后的可执行目标文件。运行时加载到子进程中,用于进程管理阶段的分析。
参考文献
- 兰德尔E.布莱恩特 大卫R.奥哈拉伦. 深入理解计算机系统(第3版).机械
工业出版社. 2018.4.
[2]https://www.cnblogs.com/pianist/p/3315801.html.printf实现的深入剖析.