哈工大CSAPP大作业:程序人生-Hello’s P2P

 

计算机系统

大作业

题     目  程序人生-Hellos P2P  

专       业 未来技术学院计算机科学与技术  

学     号     2021111124   

班   级    21W0312     

学       生     米亚鹏    

指 导 教 师      史先俊    

   

计算机科学与技术学院

2022年5月

摘  要

Hello往往是程序员接触的第一个程序,分析hello程序是深入理解计算机系统的一个很好的方式。本报告以计算机系统的视角分析一个hello程序在运行时的各方面过程,从hello.c预处理,汇编,编译等方面描述,一步步地展现了计算机系统是如何生成一个可执行目标文件的详细过程。并且还从进程管理,存储管理和IO管理方面描述了更为详细的处理过程与功能。本次大作业帮助我更好的理解了计算机系统,完成报告的过程中锻炼了解决问题的能力。

关键词:hello程序;计算机系统                            

目  录

第1章 概述 - 4 -

1.1 Hello简介 - 4 -

1.2 环境与工具 - 4 -

1.3 中间结果 - 4 -

1.4 本章小结 - 4 -

第2章 预处理 - 5 -

2.1 预处理的概念与作用 - 5 -

2.2在Ubuntu下预处理的命令 - 5 -

2.3 Hello的预处理结果解析 - 5 -

2.4 本章小结 - 5 -

第3章 编译 - 6 -

3.1 编译的概念与作用 - 6 -

3.2 在Ubuntu下编译的命令 - 6 -

3.3 Hello的编译结果解析 - 6 -

3.4 本章小结 - 6 -

第4章 汇编 - 7 -

4.1 汇编的概念与作用 - 7 -

4.2 在Ubuntu下汇编的命令 - 7 -

4.3 可重定位目标elf格式 - 7 -

4.4 Hello.o的结果解析 - 7 -

4.5 本章小结 - 7 -

第5章 链接 - 8 -

5.1 链接的概念与作用 - 8 -

5.2 在Ubuntu下链接的命令 - 8 -

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

5.4 hello的虚拟地址空间 - 8 -

5.5 链接的重定位过程分析 - 8 -

5.6 hello的执行流程 - 8 -

5.7 Hello的动态链接分析 - 8 -

5.8 本章小结 - 9 -

第6章 hello进程管理 - 10 -

6.1 进程的概念与作用 - 10 -

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

6.3 Hello的fork进程创建过程 - 10 -

6.4 Hello的execve过程 - 10 -

6.5 Hello的进程执行 - 10 -

6.6 hello的异常与信号处理 - 10 -

6.7本章小结 - 10 -

第7章 hello的存储管理 - 11 -

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 -

7.9动态存储分配管理 - 11 -

7.10本章小结 - 12 -

第8章 hello的IO管理 - 13 -

8.1 Linux的IO设备管理方法 - 13 -

8.2 简述Unix IO接口及其函数 - 13 -

8.3 printf的实现分析 - 13 -

8.4 getchar的实现分析 - 13 -

8.5本章小结 - 13 -

结论 - 14 -

附件 - 15 -

参考文献 - 16 -


第1章 概述

1.1 Hello简介

P2P(From program to process):从程序到进程。在计算机系统中该过程需要经过预处理、编译、汇编、链接等一系列的复杂动作才可以生成一个可执行目标文件。图1.1(a)展示了相应的过程。

                           图1.1(a)

在运行时,我们打开Shell,等待我们输入指令。通过输入./hello,使Shell创建新的进程用来执行hello。操作系统会使用fork产生子进程,然后通过execve将其加载,不断进行访存、内存申请等操作。最后,在程序结束返回后,由父进程或祖先进程进行回收,程序结束。

020(From Zero-0 to Zero-0):操作系统调用了execve来加载进程,首先映射虚拟内存空间,删除当前的虚拟地址,为hello重新进行一次内存映射,执行结束之后,hello进程被回收,hello进程从开始没有运行到运行后空间被彻底回收,就是020的整个过程了。

1.2 环境与工具

1.2.1硬件环境

处理器:AMD Ryzen 7 5800H with Radeon Graphics

系统类型:X64 CPU; 3.20GHz; 16G RAM; 256G HD Disk

1.2.2软件环境

Windows11家庭和学生版

VMware Workstation pro2022

Ubuntu20.04

1.2.3开发与调试工具

Visual Stdio 2019;ClodeBlocks; gedit+gcc;VSCode

1.3 中间结果

文件名称

作用

hello.c

储存hello程序源代码

hello.i

源代码经过预处理产生的文件(包含头文件等工作)

hello.s

hello程序对应的汇编语言文件

hello.o

可重定位目标文件

hello_o.txt

hello.o的反汇编语言文件

hello1.elf

hello.o的ELF文件格式

hello

二进制可执行文件

hello2.elf

可执行文件的ELF文件格式

hello.txt

可执行文件的汇编语言文件

1.4 本章小结

本章简述了Hello程序的一生,概括了从P2P到020的整个过程,可以发现整个计算机系统的学习与Hello的生命轨迹相重合。本章还简要说明了实验的软、硬件环境以及编译处理工具,最后说明了中间过程中所用的文件。


第2章 预处理

2.1 预处理的概念与作用

预处理的概念

程序设计领域中,预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。

预处理器的作用

一般由预处理器(preprocessor)对程序源代码文本进行处理,这个过程并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位。

大多数预处理器指令属于下面3种类型:

1、宏定义:#define 指令定义一个宏,#undef 指令删除一个宏定义。

2、文件包含:#include指令导致一个指定文件的内容被包含到程序中。

3、条件编译:#if,#ifdef,#ifndef,,#elif,#else 和#endif 指令可以根据编译器可以测试的条件来将一段文本包含到程序中或排除在程序之外。

合理地使用预处理功能编写的程序便于阅读、修改、移植和调试,也有利于模块化程序设计。

2.2在Ubuntu下预处理的命令

Linux中hello.c文件进行预处理的命令是:gcc hello.c -E -o hello.i。当使用这个文件后,产生相应的预处理文件hello.i

                 

                                                                   图一 产生预处理文件

2.3 Hello的预处理结果解析

 

                                                                     图二 查看hello.i

用vscode打开预处理文件hello.i,可以看到该程序在代码量上变得较大,代码行数达到3060行,源语句出现在3047行。这之前是头文件stdio.h、unistd.h以及stdlib.h依次展开。其中以stdio.h展开为例: stdio.h是标准库文件,预处理cpp需要在Ubuntu中默认的环境变量下寻找stdio.h,打开文件/usr/include/stdio.h,可以发现其中依然使用了#define宏定义语句,cpp对stdio中的宏定义是递归展开的,所以最终的.i文件中是没有#define的,但是其中却使用了大量的#ifdef 以及#ifndef 条件编译语句,cpp会对条件值进行判断来决定是否执行包含在其中的条件逻辑。同时之前提到过,预编译程序可识别一些特殊符号,预编译程序对于在源程序中出现的这些特殊符号串会用合适值进行替换。

2.4 本章小结

本章主要是对预处理的概念和作用进行介绍,除此之外还有Ubuntu下预处理的指令,同时也具体到了我的hello.c文件预处理结果hello.i文本文件的详细解析,深刻理解了预处理的重要内涵。


第3章 编译

3.1 编译的概念与作用

编译的概念

编译程序是通过词法分析和语法分析,在确认所有的指令都符合语法规则后,将其翻译成等价的中间代码或汇编代码来表示。编译器(cc1)将预处理过的文本文件hello.i 翻译转换成汇编文本文件hello.s。

编译的作用:

编译起到翻译的作用,以下为编译基本流程:

1.语法分析:编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成了符合语法规则的语法单位,方法有自上而下分析法和自下而上分析法两种;

2.中间代码:源程序的一种内部表示,或称之为中间语言。中间代码的作用是使编译程序的结构在逻辑上更为简单明确,特别是使目标代码的优化比较容易实现;

3.代码优化:指对程序进行多种等价变换,使得从变换后的程序出发时能生成更有效的目标代码;

4.目标代码:生成目标代码是编译的最后阶段。目标代码生成器把语法分析或优化后生成的中间代码转换成目标代码。此处目标代码指汇编语言代码,即不同种类的语言提供相同的形式,其指令与处理器的指令集类似,更贴近底层,便于汇编器将其转换为可执行机器语言代码供机器执行。        

3.2 在Ubuntu下编译的命令

           

                  图一 编译指令

采用指令gcc hello.i -S -o hello.s来产生汇编程序文本。

3.3 Hello的编译结果解析

打开hello.s文件如下图,下面对其进行详细的说明。

3.3.1工作伪指令

 

                                                          图二 产生预处理文件

在hello.s文件中,第一部分的汇编代码有一部分是以.作为开头的代码段。这些代码段是指导汇编器和连接器工作的伪指令。这段代码对我们来说没有什么意义,通常可以忽略这些代码,但对汇编器和连接器却是十分重要的。这些指导伪代码的含义如下表

3.3.2数据格式和寄存器结构

在解析下面的汇编代码之前,我们需要先了解数据存储的格式以及寄存器的存储结构,各种数据类型的大小一级寄存器的结构如下所示:

    CPU中有16个通用寄存器。其为%rax,%rdx,%rdi,%rsi等(这里不一一列出,可参考书)。

3.3.3数据

立即数:

立即数在汇编代码中的呈现形式最为简单易辨认。即数顾名思义,是直接显式使用的一类数据,在汇编代码中通常以$美元符号作为标识。例如下图中的例子,表示的含义是比较(cmp compare)寄存器中的值和4,根据结果设置条件码。

                       

寄存器变量:

在汇编代码中,指令后面出现过许多的形如-32(%rbp)形式的代码声明,其实这些就是寄存器存储的变量,通过特定的寻址方式进行引用。例如下图中的例子,表示的就是将寄存器%edi中存储的值,加载到以现在栈指针%rbp指向的位置基础上,减去20所对应的地址中去。类似的加载使用的例子在这里面不胜枚举,就不一一赘述了。

                       

字符串:

.LC0和.LC1作为两个字符串变量被声明。而在.LC0中出现的\347\224等是因为中文字符没有对应的ASCII码值无法直接显式显示,所以这样的字符方式显示。而且这两个字符串都在.rodata下面,所以是只读数据。随后有两句leaq指令,这个指令为加载有效地址,相当于转移操作。

                  

  

3.3.4数据传送指令

数据传送指令是整个程序运行过程中使用的最频繁的指令。汇编代码中数据移动使用MOV类,这些指令把数据从源位置复制到目的位置,不做任何变化。MOV类中最常用的有四条指令:movb、movw、movl、movq,这些指令执行相同的操作,区别在于他们所移动的数据大小不同,如下表所示。指令的最后一个限制字符必须和寄存器所对应的数据大小保持一致。

 比如下面这几条汇编代码,用了较多的moveq指令来做数据传输。

                          

除此之外,还有一些指令会先将数据进行零扩展或者符号扩展之后再进行传送。典型实例就是MOVZ(零扩展)和MOVS(符号扩展),因为比较少见并且在hello.s中没有相应的体现,就不展开说明了。

3.3.5算术操作

算术运算也是十分常用的一些指令类,同样的,每种算术运算指令的末尾也有b、w、l、q(例如addb)来限制数据的大小。比如在hello.s中就有相应的语句,将16加到%rax寄存器中。

                        

  

3.3.6条件控制

汇编语言中,一些指令会改变条件码,例如cmpl指令和setl指令。这种指令一般不会单独使用,会根据比较结果进行后续操作。例如下图,将寄存器中存储的值和立即数4进行比较,设置条件码,然后进行跳转或者其他操作。

                       

 3.3.7压入和弹出栈数据

压入数据使用指令pushq,弹出数据使用指令popq,其实可以看做一个由两句指令组成的结合体。我们拿popq指令作为例子来说明。

popq %rax 等价于 addq $8 %rsp和movq %rbp, (%rsp)两个语句的结合。

在书中可以找到相应的语句说明如下。

 

3.3.8跳转语句

跳转指令会根据条件码当前的值来进行相应的跳转。比较常见的是直接跳转,在hello.s中也有体现,如下图所示。cmpl指令判断寄存器中的值和立即数4的大小关系,设置条件码,再进行je。je的含义是jump if equal,也就是说,如果此时的条件码所表示含义为相等,则会跳转到相应的.L2指令行。因此,跳转指令用来实现条件分支。

  

图三 跳转相关指令说明

3.3.9函数调用

call指令用来进行函数的调用。如下图所示的示例,call调用了puts函数。它会先将函数的返回地址压入运行时栈中,之后跳转到相应的函数代码段进行执行。执行结束通过ret指令返回。

                       

 通过查看可知main函数中调用了puts,exit,getchar,print,atoi,sleep等函数。

3.3.10判断与循环语句

判断语句经常是与cmpl命令和je等相应指令联系在一起发挥作用的。比如下图中将%rbp-4(第一个参数)与8比较,若小于等于8则跳到.L4,否则执行getchar,该功能类似if语句中的判断i<=8。

  

再看这段代码的结构,发现跳到.L4后执行相应操作并且执行addl $1,-4(%rbp)后继续回到.L3进行比较,说明这是一个for(i=0;i<=8;i++)的循环。

3.4 本章小结

本章对编译指令做了以下简单的介绍,以及查看了Hello的机器级实现。经过简单的思考,我们便可以发现这些汇编指令和C语言代码语句之间的对应关系。同时,根据一个程序的汇编代码我们也可以翻译出相应的C语言程序的大致样貌。


第4章 汇编

4.1 汇编的概念与作用

汇编的概念: 汇编程序是指把汇编语言书写的程序翻译成与之等价的机器语言程序的翻译程序。汇编程序输入的是用汇编语言书写的源程序,输出的是用机器语言表示的目标程序。也就是说,汇编器会把输入的汇编指令文件重新打包成可重定位目标文件,并将结果保存成.o文件。它是一个二进制文件,包含程序的指令编码。

汇编的作用:完成从汇编语言文件到可重定位目标文件的转化过程。

4.2 在Ubuntu下汇编的命令

Linux系统中使用如下命令进行汇编

gcc hello.s -c -o hello.o

          

                                                                  图一 汇编指令

4.3 可重定位目标elf格式

4.3.1 ELF头

.o文件为目标文件,相当于Windows中的.obj后缀文件,因此直接使用Vim或者其他文本编辑器查看会出现一大堆乱码。那么我们选择查看可重定位目标文件的elf形式,使用命令readelf -h hello.o查看ELF头,结果如下图所示:

ELF头以一个16字节的序列(Magic)开始,这个序列描述了生成文件的系统的字的大小和字节顺序。ELF头剩下部分的信息包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型和机器类型等。例如下图中,Data表示了系统采用小端法,文件类型Type为REL(可重定位文件),节头数量Number of section headers为13个等信息。

                                                               图二  ELF头

4.3.2 Section头

使用命令readelf -S hello.o查看节头,结果如下:

                                                            图三 Section头

夹在ELF头和节头部表之间的都为节,包含了文件中出现的各个节的语义,包括节的类型、位置和大小等信息。各部分含义如下:

      

 4.3.3符号表

使用命令readelf -s hello.o查看符号表,结果如下:                                                                               图三 符号表

这其中,Num为某个符号的编号,Name是符号的名称。Size表示他是一个位于.text节中偏移量为0处的146字节函数。Bind表示这个符号是本地的还是全局的,由上图可知main函数名称这个符号变量是本地的。

4.3.4可重定位段信息

使用readelf -r hello.o查看可重定位段信息,结果如下

   

 

                                                     图四 可重定位段信息

offset是需要被修改的引用的节偏移,Sym.标识被修改引用应该指向的符号。Type告知连接器如何修改新的引用,Addend是一个有符号常数,一些类型的重定位要使用它对被修改的引用的值做偏移调整。

4.4 Hello.o的结果解析

使用objdump -d -r hello.o命令对hello.o可重定位文件进行反汇编,得到的反汇编结果如下图:

 

                                                            图五 Hello结果

看到hello.o的反汇编文件我们很是熟悉,它使用的汇编代码和hello.s汇编文件的汇编代码是一样的,但是在这反汇编文件中,多了一些机器代码。

这些机器代码是二进制机器指令的集合,每一条机器代码都对应一条机器指令,机器只能识别正确的机器语言。每一条汇编语言都可以用机器二进制数据来表示,汇编语言中的操作码和操作数以一种相当于映射的方式和机器语言进行对应,从而让机器能够真正理解代码的含义并且执行相应的功能。机器代码与汇编代码不同的地方在于:

1.分支跳转方面:汇编语言中的分支跳转语句使用的是标识符(例如je .L2)来决定跳转到哪里,而机器语言中经过翻译则直接使用对应的地址来实现跳转。

 

2.函数调用方面:在汇编语言.s文件中,函数调用直接写上函数名。而在.o反汇编文件中,call目标地址是当前指令的下一条指令地址。这是因为hello.c中调用的函数都是共享库中的函数,需要等待链接之后才能确定响应函数的地址。因此,机器语言中,对于这种不确定地址的调用,会先将下一条指令的相对地址设置为0,然后在.rela.text节中为其添加重定位条目,等待链接时确定地址。

4.5 本章小结

本章介绍了Hello从hello.s到hello.o的过程。这一节中对hello.o的ELF头,Section头以及符号表进行了分析,可以看到Hello的更深处的信息。本节还对hello.o的反汇编文件进行了解析,比较了相对于hello.s文件.o文件是怎么让机器加工理解的。


5链接

5.1 链接的概念与作用

链接概念:链接(linking)是将各种代码和数据片段收集并合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时,也就是在由应用程序来执行。

链接作用:链接器在软件开发过程中扮演着一个关键的角色,因为它们使得分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。

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.3 可执行目标文件hello的格式

同上一章一样,我们分别查看hello文件的ELF头,节头部表,符号表。

5.3.1 ELF头

使用命令readelf -h hello查看hello的ELF头    

                

 

                            图二  ELF头

可以看到文件的Type发生了变化,从REL变成了EXEC(Executable file可执行文件),节头部数量也发生了变化,变为了27个。

5.3.2 Section头

使用命令readelf -S hello查看节头部表信息

                     

 

                             图三  Section头

节头部表对hello中所有信息进行了声明,包括了大小(Size)、偏移量(Offset)、起始地址(Address)以及数据对齐方式(Align)等信息。根据始地址和大小,我们就可以计算节头部表中的每个节所在的区域。

5.3.3符号表

使用命令readelf -s hello查看符号表信息

                

                                                                 图四 符号表

可以发现经过链接之后符号表的符号数量增多很多,说明经过连接之后引入了许多其他库函数的符号,一并加入到了符号表中。

5.3.4可重定位段信息

使用命令readelf -r hello查看可重定位段信息

     

                                         图五 可重定位段信息

5.4 hello的虚拟地址空间

使用edb加载hello,可以看到进程的虚拟地址空间各段信息。可以看出,段的虚拟空间从0x401000开始,到0x401ff0结束。

    

根据5.3.2节里面的Section头部表,我们可以找到对应的节的其实空间对应位置,例如.init初始化节,起始位置地址为0x401000在edb中有其对应位置

    

5.5 链接的重定位过程分析

使用命令objdump -d -r hello查看hello可执行文件的反汇编条目

      

 

                                             图六 反汇编条目

可以观察到hello的反汇编代码与hello.s的反汇编代码在结构和语法上是基本完全相同的,只不过hello的反汇编代码多了非常多的内容,我们通过比较不同来看一下区别:

  1. hello反汇编代码中函数调用时不再仅仅储存call当前指令的下一条指令,而是已经完成了重定位,调用的相应函数已经有对应的明确的虚拟地址空间。比如下面这条语句。

   

  1. hello反汇编代码中肉眼可见的多了很多节,这些陌生的节都是经过链接之后加入进来的。例如.init节就是程序初始化需要执行的代码所在的节,.dynamic节是存放被ld.so调用过的 动态链接信息的节等等。

 

 

重定位的过程分为两大步:

1.重定位节和符号定义:在这一步中,连接器将所有相同类型的节合并成为同一类型的新的聚合节。例如,来自所有输入模块的.data节全部被合并成一个节,这个节成为输出的可执行目标文件的.data节。

2. 重定位节中的符号引用:在这一步中,连接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的运行时地址。要执行这一步,连接器依赖于可重定位条目,及5.3节中分析的那些数据。

5.6 hello的执行流程

用edb打开hello可执行文件进行分析如下图,在程序中看到call后面的相关地址,找到相应地址后读出便可得到程序地址(列表格如下)。

图七 edb指令

 

程序名称

程序地址

_start

0x4010f0

_libc_start_main

0x7ffff7de2f90

__GI___cxa_atexit

0x7ffff7e05de0

__new_exitfn

0x7ffff7e05b80

__libc_csu_init

0x4011c0

_init

0x401000

_sigsetjump

0x7ffff7e01bb0

main

0x401125

do_lookup_x

0x7ffff7fda4c9

dl_runtime_resolve_xsavec

0x7ffff7fe7bc0

_dl_fixup

0x7ffff7fe00c0

_dl_lookup_symbol_x

0x7ffff7fdb0d0

check_match

0x7ffff7fda318

strcmp

0x7ffff7fee600

5.7 Hello的动态链接分析

动态共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个程序链接起来,这个过程就是动态链接。

把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。   

这里经查阅资料得到两个关于动态链接的数组如下:

.plt:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。

.got:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。

hello在动态连接器加载前后的重定位是不一样的,在加载之后才进行重定位。

    

 

重定位后的.init

5.8 本章小结

本章介绍了连接的过程。解释了程序是如何进行重定位的操作,把相同类型的数据放在同一个节的过程,同时也说明了链接的工作原理。


6hello进程管理

6.1 进程的概念与作用

概念:进程的经典定义就是一个执行中的程序的实例。系统中的每个程序都运行在某个进程的上下文中。每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。

 作用:进程作为一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中,上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令。

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

首先我们需要了解什么是Shell,Shell是命令语言解释器。他的本质是一个应用程序,它连接了用户和 Linux 内核,让用户能够更加高效、安全、低成本地使用 Linux 内核。它在操作系统的最外层,负责直接与用户进行对话,把用户的输入解释给操作系统,并处理各种各样的操作系统的输出结果,输出到屏幕反馈给用户。例如我们经常使用的Windows下的cmd命令行和Bash以及Linux中的Shell。

shell的作用:shell最重要的功能是命令解释,可以说shell是一个命令解释器。Linux系统上的所有可执行文件都可以作为shell命令来执行,同时它也提供一些内置命令。此外,shell还包括通配符、命令补全、命令历史、重定向、管道、命令替换等很多功能。

处理流程:从终端读入输入的命令。将输入字符串切分获得所有的参数,如果是内置命令则立即执行,否则调用相应的程序为其分配子进程并运行,shell应该接受键盘输入信号,并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

在Linux系统中,用户可以通过 ./ 指令来执行一个可执行目标文件。在程序运行时,Shell就会创建一个新的进程,并且新创建的进程更新上下文,在这个新建进程的上下文中便可以运行这个可执行目标文件。fork()函数拥有一个int型的返回值。子进程中fork返回0,在父进程中fork返回子进程的Pid。新创建的进程与父进程几乎相同但有细微的差别。子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本(代码、数据段、堆、共享库以及用户栈),并且子进程拥有与父进程不同的Pid。

6.4 Hello的execve过程

当Hellol的进程被创建之后,他会调用execve函数加载并调用程序。exevce函数在被调用时会在当前进程的上下文中加载并运行一个新程序。它被调用一次从不返回,执行过程如下:

1.删除已存在的用户区域

2.映射私有区:为 hello 的代码、数据、.bss 和栈区域创建新的区域结构,所有这些区域都是私有的、写时才复制的

3.映射共享区:比如 hello 程序与共享库 libc.so 链接

4.设置 PC:exceve() 做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点

5.execve() 在调用成功的情况下不会返回,只有当出现错误时,例如找不到需要执行的程序时,execve() 才会返回到调用程序

6.5 Hello的进程执行

上下文的概念: 上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。

上下文切换:在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程。

上下文切换(1)保存当前进程的上下文(2)恢复某个先前被抢占的进程被保存的上下文(3)将控制传递给这个新恢复的进程。

从Shell执行hello程序时,会先处于用户模式运行。在运行过程中,由内核不断进行上下文切换,配合调度器,与其他进程交替运行。如果在运行过程中收到了信号,那么就会陷入到内核中进入内核模式运行信号处理程序,之后再进行返回。运行结果如下:

 6.6 hello的异常与信号处理

6.6.1异常

异常可以分为四类:中断、陷阱、故障和终止,各类异常产生原因和一些行为总结成下表:

 

6.6.2信号

信号可以被理解为一条小消息,他通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件,它提供了一种机制,通知用户进程发生了这些异常。Linux中信号有如下种类:

               

 

6.6.3 具体处理情况

(1)Ctrl-Z中断程序

 

运用ps可查看程序状态。

(2)Ctrl-C终止程序

    

 (3)Jobs

 (4)Fg 1将程序调到前台继续执行

 6.7本章小结

本章介绍了计算机中最重要的概念之一——进程。可以说现代计算机离不开进程的概念,只有充分了解进程的创建与异常流控制,才可能成为一个优秀的程序员。本章介绍了Shell的一般处理流程,调用fork创建新进程,调用 execve 执行 hello,hello的进程执行,hello 的异常与信号处理。


7hello的存储管理

7.1 hello的存储器地址空间

在CPU中当然不是仅仅只有hello一个进程,而是许多进程共享CPU和主存资源。那么为了使内存管理更加高效,操作系统提出了一种十分重要的抽象,即虚拟内存。它有几处有点:1)可以有效使用主存;2)可以简化内存管理;3)提供独立的地址空间。下面介绍几个概念

逻辑地址:逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元、存储单元、网络主机的地址。逻辑地址往往不同于物理地址,通过地址翻译器或映射函数可以把逻辑地址转化为物理地址。

线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。

虚拟地址:CPU启动保护模式后,程序运行在虚拟地址空间中。保护模式下,hello 运行在虚拟地址空间中,它访问存储器所用的逻辑地址。

物理地址:它是在地址总线上,以电子形式存在的,使得数据总线可以访问 主存的某个特定存储单元的内存地址。

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

Intel平台下,逻辑地址的格式为段标识符:段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节。分段机制将逻辑地址转化为线性地址的步骤如下:

(1)使用段选择符中的偏移值在GDT或LDT表中定位相应的段描述符。

(2)利用段选择符检验段的访问权限和范围,以确保该段可访问。

(3)把段描述符中取到的段基地址加到偏移量上,最后形成一个线性地址。

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

通过分页机制实现线性地址(书里的虚拟地址VA)到物理地址(PA)之间的转换。分页机制是指对虚拟地址内存空间进行分页。首先Linux系统有自己的虚拟内存系统,Linux将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录,当hello运行时内核为hello进程维护一个段的任务结构(task_struct)。

虚拟页是指系统将每个段分为大小固定的块,linux下一页为4KB

物理页和虚拟页一样,他是物理内存的分割,MMU使用页表来实现二者之间的映射

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

页表是 PTE(页表条目)的数组,它将虚拟页映射到物理页,每个 PTE 都有一个有效位和一个 n 位地址字段,有效位表明该虚拟页是否被缓存在 DRAM 中。虚拟地址分为两个部分,虚拟页号(VPN)和虚拟页面偏移量(VPO)。其中VPN需要在PTE中查询对应,而VPO则直接对应物理地址偏移(PPO)。

      

 

                                                        图一 VA到PA的变化

查询过程大概如下:

CPU 产生虚拟地址 VA,VA 传送给 MMU,MMU 使用前 36 位 VPN作为 TLBT(前 32 位)+TLBI(后 4 位)向 TLB 中匹配

如果命中,则得到 PPN(40bit)与 VPO(12bit)组合成 PA(52bit)。

如果 TLB 中没有命中,MMU 向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出 PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,

以此类推,最终在第四级页表中查询到 PPN,与 VPO 组合成 PA,并且向 TLB 中添加条目。

如果查询 PTE 的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。

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

在寻找一个虚拟地址时,CPU会优先到TLB中寻找,查看VPN是否已经缓存。如果页命中的话,就直接获取PPN;如果没有命中的话就需要查询多级页表,得到物理地址PA,之后再对PA进行分解,将其分解为标记(CT)、组索引(CI)、块便宜(CO),之后再检测物理地址是否在下一级缓存中命中。若命中,则将PA对应的数据内容取出返回给CPU;若不命中,则重复上述操作,直到找到。过程图示如下:

    

                                                    图二 内存访问机制

7.6 hello进程fork时的内存映射

当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。通过 fork 创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。当 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 缺页故障与缺页中断处理

7.8.1缺页故障

首先明确一下页命中的概念:虚拟内存中的一个字存在于物理内存中,即缓存命中。那不难理解,缺页故障就是虚拟内存中的字不在物理内存中,发生页不命中。例如下图中,对VP3的访问即发生缺页故障

                              图三 缺页故障

7.8.2缺页故障处理

如果发生了缺页故障,则触发缺页故障处理程序,这个程序会选择一个牺牲页,例如下图中的P4,将其在物理内存中删除,加入所需要访问的VP3。随后返回重新执行原指令,则页命中。这种策略称为按需页面调度。

 

 

                                                   图四 缺页故障处理

7.9动态存储分配管理

首先明确动态存储分配管理的概念:在程序运行时程序员使用动态内存分配器,例如malloc获得虚拟内存。分配器将堆视为一组不同大小的 块(blocks)的集合来维护每个块要么是已分配的,要么是空闲的。

7.9.1堆

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。

                  

 

在内存中的碎片和垃圾被回收之后,内存中就会有空余的空间被闲置出来。这些空间有时会比较小,但是积少成多,操作系统不知道怎么利用这些空间,就会造成很多的浪费。为了记录这些空闲块,采用隐式空闲链表和显式空闲链表的方法实现这一操作。

7.9.2隐式空闲链表

首先了解几个概念:

首次适配 (First fit): 从头开始搜索空闲链表,选择第一个合适的空闲块: 搜索时间与总块数(包括已分配和空闲块)成线性关系。在靠近链表起始处留下小空闲块的“碎片”。

下一次适配 (Next fit): 和首次适配相似,只是从链表中上一次查询结束的地方开始。比首次适应更快: 避免重复扫描那些无用块。一些研究表明,下一次适配的内存利用率要比首次适配低得多。

最佳适配 (Best fit): 查询链表,选择一个最好的空闲块;适配,剩余最少空闲空间。保证碎片最小——提高内存利用率,通常运行速度会慢于首次适配。

在隐式空闲链表工作时,如果分配块比空闲块小,可以把空闲块分为两部分,一部分用来承装分配块,这样可以减少空闲部分无法使用而造成的浪费。隐式链表采用边界标记的方法进行双向合并。脚部与头部是相同的,均为 4 个字节,用来存储块的大小,以及表明这个块是已分配还是空闲块。同时定位头部和尾部,是为了能够以常数时间来进行块的合并。无论是与下一块还是与上一块合并,都可以通过他们的头部或尾部得知块大小,从而定位整个块,避免了从头遍历链表。但与此同时也显著的增加了额外的内存开销。他会根据每一个内存块的脚部边界标记来选择合并方式

7.9.3显式空闲链表

显式空闲链表只记录空闲块,而不是来记录所有块。它的思路是维护多个空闲链表,每个链表中的块有大致相等的大小,分配器维护着一个空闲链表数组,每个大小类一个空闲链表,当需要分配块时只需要在对应的空闲链表中搜索。

7.10本章小结

本章介绍了程序是如何组织储存器的。先从程序所使用的不同地址开始,分别介绍了逻辑地址、虚拟地址(线性地址)以及物理地址。并介绍了计算机是怎么一步步将地址从逻辑地址变化到虚拟地址再从虚拟地址变化到物理地址的。其中着重介绍了虚拟地址和物理地址之间的映射,以及进程是怎么映射到虚拟地址空间的。之后还介绍程序是怎么利用Cache来获取物理地址中所存放的数据的。最后简单介绍了虚拟地址中极为重要的概念——缺页异常,以及简单介绍了动态内存分配机制。


8hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

设备管理:unix io接口

一个Linux文件就是一个m个字节的序列:00101010011110…

所有的 IO 设备(例如网络、磁盘和终端)都被模型化为文件,所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单低级的应用接口,称为 Unix I/O。

8.2 简述Unix IO接口及其函数

8.2.1函数open()和opennat()

函数模板:

int open(const char* path, int oflag, .../*mode_t mode*/);

int openat(int fd, const char* path, int oflag, .../*mode_t mode*/);

若文件打开失败则返回-1,失败原因可以通过errno查看;若成功将返回最小的未用的文件描述符的值。其中参数path为要打开的文件的文件路径,oflag为文件打开模式。打开模式如下:

 

8.2.2creat()函数

int create(const char *path, mode_t mode);

若文件创建失败返回-1;若创建成功返回当前创建文件的文件描述符。参数与open中对应的参数含义相同。create(path, mode)函数功能为创建新文件,与open(path, O_CREATE|O_TRUNC|O_WRONLY)功能相同。

8.3.3 lseek()函数

int lseek(int fd, off_t offset, int whence);

成功则返回新的文件的偏移量;失败则返回-1。使用lseek()函数显式的为一个打开的文件设置偏移量。lseek仅将文件的偏移量记录在内核中,并不引起IO开销。

8.3.4 read()函数

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t nbytes);

若读取成功,读到文件末尾返回0,未读到文件末尾返回当前读的字节数。若读取失败,返回-1。fd为要读取文件的文件描述符。buf为读取文件数据缓冲区,nbytes为期待读取的字节数,通常为sizeof(buf)。

8.3.5 write()函数

#include <unistd.h>

ssize_t write(int fd, const void* buf, size_t ntyes);

若写入成功则返回写入的字节数;失败返回-1。buf为写入内容的缓冲区,ntyes为期待写入的字节数,通常为sizeof(buf)。一般情况下返回值与ntypes相等,否则写入失败。

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;

}

printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。printf用了两个外部函数,一个是vsprintf,还有一个是write。

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

}

vsprintf函数作用是接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.字符显示驱动子程序:从ASCII到字模库到显示vram。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点。

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;

}

异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数。在我看来Unix I/O是一个非常有趣且成功的抽象,因为它把一切输入输出都归结为对文件的操作,而且这一抽象是十分成功的,因为I/O的过程本质上就是一个对信息交换的过程,因此把所有与程序进行信息交换的主体,比如网络设备当作文件是完全没问题的。这种抽象不仅可以简化计算机的设计,还能更好的帮助我们理解学习系统级I/O。

结论

总结hello所经历的过程如下:

首先需要有一个Hello的源码,也就是说先要完成hello.c的C语言源程序的编写。这就是Hello的起点。

下一步使用命令gcc -E进行预处理,Hello完成从hello.c到hello.i的预处理。

下一步使用命令gcc -S进行编译,Hello完成从hello.i到hello.s的汇编。

下一步使用命令gcc -c进行汇编,Hello完成从hello.s到hello.o的编译。此时的Hello已经变成一个二进制文件了。

对Hello进行链接,将Hello的相关库和Hello联系起来,把它和其他可重定位二进制文件合体,变成一个可以在计算机上运行的二进制文件。

打开Shell,输入命令./hello 2021111124 米亚鹏 3来运行Hello程序。

Shell进行第六章中讲述的一系列判断:首先判断输入命令是否为内置命令。经过检查后发现其不是内置命令,则Shell将其当作程序执行。

随机Shell调用Fork()函数为Hello创建一个进程。

shell调用execve函数,execve函数会将新创建的子进程的区域结构删除,然后将其映射到hello程序的虚拟内存,然后设置当前进程上下文中的程序计数器,使其指向hello程序的入口点。

运行hello时,内存管理单元、TLB、多级页表机制、三级cache协同工作,完成对地址的翻译和请求。

当Hello运行到printf这一步时,操作系统会调用malloc函数从堆中申请内存。

当Hello执行时,可以通过IO输入等操作向进程发送信号。例如我们从键盘输入Ctrl-c,就会发送一个SIGINT信号,使当前前台进程的作业中断;同样可以使用命令jobs来查看被抢占的进程,使用命令fg %<pid>来恢复对应ID的进程。

当进程执行结束后,由父进程对子进程进行回收。至此,Hello的一生结束。


附件

文件名称

作用

hello.c

储存hello程序源代码

hello.i

源代码经过预处理产生的文件(包含头文件等工作)

hello.s

hello程序对应的汇编语言文件

hello.o

可重定位目标文件

hello_o.txt

hello.o的反汇编语言文件

hello1.elf

hello.o的ELF文件格式

hello

二进制可执行文件

hello2.elf

可执行文件的ELF文件格式

hello.txt

可执行文件的汇编语言文件


参考文献

为完成本次大作业你翻阅的书籍与网站等

  1.  2022哈工大CSAPP大作业-Hello的程序人生_Forival0420的博客-CSDN博客
  2.  哈工大计算机系统大作业-Hello的程序人生_BreadSuperman的博客-CSDN博客_哈工大计算机组成大作业
  3.  深入理解计算机系统-[美]Randal E.Bryant
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值