> 文档中心 > HIT计算机系统大作业

HIT计算机系统大作业

计算机系统

大作业

题     目  程序人生-Hellos P2P  

专       业        计算机类          

计算机科学与技术学院

2022年5月

摘  要

        本文主要阐述hello程序在Linux系统里的生命周期,探讨hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件的全过程,并结合课本的知识详细阐述计算机系统是如何对hello进行进程管理、存储管理和I/O管理,通过运用一些工具,如gdb、edb、readelf等,观察hello程序从开始到结束的生命历程。通过对hello一生周期的探索,让我们对计算机系统有更深的了解。

关键词:预处理;编译;汇编;链接;进程;存储;虚拟内存;I/O        

        由于整份报告里边图片数目太多,故而没有能正常上传配图。文末将附图片版报告(如果有机会我会上传到github)

目录

第1章 概述

1.1 Hello简介

1.2 环境与工具

1.3 中间结果

1.4 本章小结

第2章 预处理

2.1 预处理的概念与作用

2.2在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析

2.4 本章小结

第3章 编译

3.1 编译的概念与作用

3.2 在Ubuntu下编译的命令

3.3 Hello的编译结果解析

3.4 本章小结

第4章 汇编

4.1 汇编的概念与作用

4.2 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式

4.4 Hello.o的结果解析

4.5 本章小结

第5章 链接

5.1 链接的概念与作用

5.2 在Ubuntu下链接的命令

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

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

5.6 hello的执行流程

5.7 Hello的动态链接分析

5.8 本章小结

第6章 hello进程管理

6.1 进程的概念与作用

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

6.3 Hello的fork进程创建过程

6.4 Hello的execve过程

6.5 Hello的进程执行

6.6 hello的异常与信号处理

6.7本章小结

第7章 hello的存储管理

7.1 hello的存储器地址空间

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

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

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

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

7.6 hello进程fork时的内存映射

7.7 hello进程execve时的内存映射

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

7.9动态存储分配管理

7.10本章小结

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

8.2 简述Unix IO接口及其函数

8.3 printf的实现分析

8.4 getchar的实现分析

8.5本章小结

结论

附件

参考文献

大作业图片版

感谢各位不知名学长的火炬使我苟完了大作业!!!


第1章 概述

1.1 Hello简介

(1)P2P(From Program to Process):

        从源文件到目标文件的转化是由编译器驱动程序(compiler driver)完成的。

        驱动程序首先运行C预处理器(cpp),它将C的源程序hello.c翻译成一个ASCII码的中间文件hello.i;接下来,驱动程序运行C编译器(ccl),将hello.i翻译成一个ASCII码的汇编语言文件hello.s;然后,驱动程序运行汇编器(as),将hello.s翻译成一个可重定位目标文件hello.o;最后运行链接程序ld,将hello.o以及一些必要的系统目标文件结合起来,创建一个可执行目标文件hello。

        Linux系统中通过内置命令行解释器shell加载运行hello程序,为hello程序fork进程,再调用execve把程序加载到进程中,开始运行。至此,hello.c完成了P2P的过程。

(2)O2O(From Zero-0 to Zero-0):

        在shell中输入相关命令后,shell将调用fork函数为这一程序创建进程,之后将通过exceve在进程的上下文中加载并运行hello,将进程映射到虚拟内存空间,并加载需要的相关代码和数据。执行时,在CPU的分配下,指令进入CPU流水线执行。当执行结束后父进程将回收这一进程,内核将清除这一进程的相关信息,删除相关数据,所以进程从0开始,最后又回到了0。

1.2 环境与工具

  1. 硬件环境 / 软件环境

AMD Ryzen7 4800H with Radeon Graphics 2.90GHz

Windows10 ;VirtualBox;Ubuntu 20.04

  1. 开发工具

Visual Studio 2022;CodeBlocks ;vi/vim/gedit+gcc

1.3 中间结果

hello.c

源代码

hello.i

hello.c预处理生成的文本文件

hello.s

hello.i经过编译器翻译成的文本文件hello.s,含汇编语言程序

hello.o

hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件

hello_elf

hello.o的ELF格式

hello_disa

hello.o反汇编生成的代码

hello

经过hello.o链接生成的可执行目标文件

hello.elf

hello的ELF格式

hello_odjdump

hello反汇编生成的代码

1.4 本章小结

        本章简要介绍了hello程序运行的整个过程,解释了P2P以及O2O的含义,并提供了实验过程中的使用的环境与工具以及中间文件。


第2章 预处理

2.1 预处理的概念与作用

(1)概念

        预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序并得到以.i为文件扩展名的新的C程序,例如宏定义,文件包含,条件编译等。

(2)作用

1. 将所有的 "#define" 删除,并且展开所有的宏定义

2. 处理所有条件预编译指令, 比如 "#if" 、"#ifdef" 、"#elif" 、 "#else" 、"#endif"

3. 处理 "#include" 预编译指令,将被包含的文件插入到该预编译指令的位置。注意,这个过程是递归进行的,也就是说被包含的文件可能还包含其他文件

4. 删除所有的注释 "//" 和 "/* */"

5. 添加行号和文件名标识,比如#2 "hello.c" 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号

6. 保留所有的 #pragma 编译器指令,因为编译器须要使用它们

2.2在Ubuntu下预处理的命令

cpp hello.c > hello.i

2.3 Hello的预处理结果解析

        可以发现,原本的源代码文件只有24行,预处理后的文件为3061行,原本的main函数部分在3046行之后

        在这之前是hello引用的所有的头文件stdio.h, unistd.h , stdlib.h内容的展开。而很显然我们发现插入的部分不止有这三个头文件的内容,还出现了其他的头文件,这是因为这三个头文件中同样使用#include命令引入了其他的头文件,而这些头文件同样出现在了hello.i文件中。插入的库文件的具体信息如下图所示:

        而hello.c开头的注释内容则完全被删去,并不体现在hello.i中,这一点就印证了我们上面说的在预处理过程中预处理器将删除源代码中的注释部分。

2.4 本章小结

        本章介绍了预处理的概念定义以及在该过程中预处理器的工作(头文件展开,宏替换,删除注释,条件替换等),同时在ubuntu中通过命令" cpp hello.c > hello.i " 生成hello.c预处理后的文件hello.i,展示了对于hello.c文件的预处理过程,分析了预处理结果。

第3章 编译

3.1 编译的概念与作用

(1)概念

        编译是指将一个经过预处理的高级语言程序文本(.i文件)翻译成能执行相同操作的等价ASII码形式汇编语言文件(.s文件)的过程。

(2)作用

1. 扫描(词义分析):将源代码程序输入扫描器,将源代码中的字符序列分割为一系列c语言中的符合语法要求的字符单元,这一部分可以分为自上而下的分析和自下而上的分析两种方式。

2. 语法分析:基于词法分析得到的字符单元生成语法分析树。

3. 语义分析:在语法分析完成之后由语义分析妻进行语义分析,主要就是为了判断指令是否是合法的c语言指令,这一部分也可以叫做静态语义分析,并不判断一些在执行时可能出现的错误,例如如果不存在IDE优化,这一步对于1/0这种只有在动态类型检查的时候才会发现的错误,代码将不会报错。

4. 中间代码:中间代码的作用是可使使得编译程序的逻辑更加明确,主要是为了下一步代码优化的时候优化的效果更好。

5. 代码优化:根据用户指定的不同优化等级对代码进行安全的、等价的优化,这一行为的目的主要是为了提升代码在执行时的性能。

6. 生成代码:生成是编译的最后一个阶段。在经过上面的所有过程后,在这一过程中将会生成一个汇编语言代码文件,也就是我们最后得到的hello.s文件,这一文件中的源代码将以汇编语言的格式呈现。        

3.2 在Ubuntu下编译的命令

gcc -S hello.i -o hello.s

3.3 Hello的编译结果解析

3.3.1数据

(1)常量

①数字常量:立即数直接和指令编码放在一起,放在.text(代码区)中,如hello.s中的0、1、4、7、8、16、24、32

②字符串常量:程序中涉及的字符串常量为:"用法: Hello 学号 姓名 秒数!\n"和"Hello %s %s\n",存储在.rodata中。.LCO中声明的字符串汉字编码格式是UTF-8,一个汉字占三个字节,一个\代表一个字节。

(2)变量——局部变量(hello.s中无全局变量)

局部变量是储存在栈中的某一个位置的或是直接储存在寄存器中的,对于源代码中的每一个局部变量可以进行逐一分析。局部变量共有三个:一个是循环变量i,以及argc和argv

①局部变量i:i是main函数中的局部变量,通过对源程序代码及.s文件的分析可知其存储在栈中地址为-4(%rbp)的空间上,程序运行时才对其进行赋值

②局部变量argc:局部变量argc,标志的是在程序运行的时候输入的变量的个数,存储在%edi中,在程序运行时被放入栈中地址为-20(%rbp)的位置,对于它的操作主要是与4比较之后确定printf("用法: Hello 学号 姓名 秒数!\n")是否执行

③局部变量argv:局部变量argv,是一个保存着输入变量的数组,存储在%rsi中,在程序运行时被放入栈中地址为-32(%rbp)的位置使用

3.3.2 赋值

程序中出现了三次对于变量的赋值:循环变量i,exit(1)的参数和return 0

①循环变量i:保存在栈中地址为-4(%rbp)的位置,初始赋值为0

②exit(1)的参数:该参数用寄存器%edi保存,调用exit之前,赋值为1

③return 0:在退出main函数前,将%eax赋值为0

3.3.3 类型转换

        程序中并没有发生隐式的类型转换,但是调用了atoi函数将字符串转换为整型数,对应的汇编如下:先取出argv[3],将其存入%rdi,接着调用atoi,转化结果保存在%rax中

3.3.4 算数操作

①+:对于局部变量i,存储在栈中地址为-4(%rbp)的位置,由于其是循环变量,因此在每一轮的循环中都要修改这个值,每次加1:

②+:在获取argv数组里的值时,利用add指令计算对应元素的地址,以获取argv[2]为例,初始时,%rax存储的是argv[0]的地址,已知地址在内存中占用8个字节,若欲获取argv[2]的地址,则需要 argv[0]的地址+16

③-:在初始化栈时,程序将栈顶指针%rsp-32,腾出32个字节的空间用以存储新的局部变量的值

3.3.5 关系操作&控制转移

程序中一共出现了两处关系操作

①对于argc的判断:当argc=4的时候将进行条件跳转至L2,略去部分代码的执行

②对于循环变量i的判断:当i≤7的时候将进行条件跳转,重复循环中的代码

3.3.6 数组

        程序中只有一个数组argv,其中-32(%rbp)这个地址里存储的是argv[0]的地址,而argv[n](1≤n≤3)的地址 = argv[0]的地址+8n ,在汇编代码中,依次取出的是argv[2],argv[1]和argv[3]

3.3.7函数调用

        在这一段代码中出现了几个函数调用的情况,首先明确在X86系统中函数参数储存的规则,第1~6个参数依次储存在%rdi、%rsi、%rdx、%rcx、%r8、%r9这六个寄存器中,其余的参数保存在栈中的某些位置

①main函数:

-参数:传入参数argc和argv,其中argc储存在%edi,argv首地址储存在%rsi

-返回:在源代码中最后的返回语句是return 0,因此在汇编代码中最后是将%eax设置为0并返回这一寄存器。汇编代码如下:

②puts函数:

-参数:传入参数%rdi中保存待输出的字符串的地址,.LC0(%rip)中存储的就是字符串 "用法: Hello 学号 姓名 秒数!\n" 的存储地址

③exit函数:

-参数:传入的参数为%edi,被赋值为1,执行退出命令

④sleep函数:

-参数:传入参数%edi,保存程序执行挂起的时间间隔

⑤printf函数:

-参数:传入参数%rdi中保存待输出的字符串的地址,.LC1(%rip)中存储的就是字符串 "Hello %s %s\n" 的存储地址

⑥atoi函数:

-参数:传入参数%rdi中保存待转化的字符串地址

-返回:返回值是转化的整型数结果,保存在%rax中

⑦getchar函数:

3.4 本章小结

        本章简述了编译的概念与作用,对hello.s中的汇编代码进行了简单解析,梳理了源程序中常量、变量、类型转换、赋值操作、算术运算、数组运算、函数调用等汇编语言中的解释。

第4章 汇编

4.1 汇编的概念与作用

(1)概念

        汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中的过程称为汇编

(2)作用

        将汇编代码转换为计算机能够理解并执行的二进制机器代码,这个二进制机器代码是程序在本机器上的机器语言的表示

4.2 在Ubuntu下汇编的命令

gcc -c hello.s -o hello.o

4.3 可重定位目标elf格式

4.3.1 命令

使用命令 readelf -a hello.o > hello_elf 导出elf的文件

4.3.2 ELF头(ELF header)

        ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件系统下的字的大小以及一些其他信息。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如 x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。具体ELF头的代码如下:

4.3.3 节头部表(section header table)

        节头部表(section header table)描述了.o文件中每一个节出现的位置,大小,目标文件中的每一个节都有一个固定大小的条目(entry)。具体内容如下图所示:

(1).text:已编译程序的机器代码

(2).rodata: 只读数据,比如printf语句中的格式串和开关语句的跳转表。

(3).data: 已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,既不出现在.data节中,也不出现在.bss节中。

(4).bss:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。目标文件格式区分已初始化和未初始化变量是为了空间效率:在目标文件中,未初始化变量不需要占据任何实际的磁盘空间。运行时,在内存中分配这些变量,初始值为0。

(5).symtab:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。一些程序员错误地认为必须通过-g选项来编译一个程序,才能得到符号表信息。实际上,每个可重定位目标文件在. syntab中都有一张符号表(除非程序员特意用STRIP命令去掉它)。然而,和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。

(6).rel.text: 一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。注意,可执行目标文件中并不需要重定位信息,因此通常省略,除非用户显式地指示链接器包含这些信息。

(7).rel.data: 被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。

(8).debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有以-g选项调用编译器驱动程序时,才会得到这张表。

(9).line: 原始C源程序中的行号和.text节中机器指令之间的映射。只有以-g选项调用编译器驱动程序时,才会得到这张表。

(10).strtab:一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串的序列。

4.3.4 重定位节
        重定位节中包含了在代码中使用的一些外部变量等信息,在链接的时候需要根据重定位节的信息对这些变量符号进行修改。链接的时候链接器会根据重定位节的信息对外部变量符号决定选择何种方法计算正确的地址,通过偏移量等信息计算出正确的地址
        本程序需要重定位的信息有:.rodata中的模式串、puts、exit、printf、atoi、sleep、getchar这些符号同样需要与相应的地址进行重定位。具体重定位节的信息如下图所示:

重定位条目常见共2种:

(1)R_X86_64_32:重定位绝对引用。重定位时使用一个32位的绝对地址的引用,通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改。

(2)R_X86_64_PC32:重定位PC相对引用。重定位时使用一个32位PC相对地址的引用。一个PC相对地址就是据程序计数器的当前运行值的偏移量。

可以看出,对于字符串的都是绝对引用。每个重定位条目包含如下信息:该节包括的内容是:偏移量、信息、类型,符号值、符名称和加数。其结构如下:

(3)重定位PC相对引用重定位算法如下:

refaddr = ADDR(s) + r.offset;

*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr);

(4)重定位绝对引用重定位算法如下:

*refptr = (unsigned) (ADDR(r.symbol) + r.addend);

4.3.5 符号表

        .symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。例如本程序中的puts、exit、printf、atoi、sleep、getchar等函数名都需要在这一部分体现,具体信息如下图所示:

4.4 Hello.o的结果解析

命令:objdump -d -r hello.o > hello_disa

分析hello.o的反汇编,并与第3章的 hello.s进行对照分析,可以发现有如下几点不同:

  • 进制不同:hello.s反汇编之后数字的表示是十进制的,而hello.o反汇编之后数字的表示是十六进制的
  • 分支转移:对于条件跳转,hello.s反汇编中给出的是段的名字,例如.L2等来表示跳转的地址;而hello.o由于已经是可重定位文件,对于每一行都已经分配了相应的地址,因此跳转命令后跟着的是需要跳转部分的目标地址 
  • 函数调用:hello.s中,call指令后跟的是需要调用的函数的名称,而hello.o反汇编代码中call指令使用的是main函数的相对偏移地址。同时可以发现在hello.o反汇编代码中调用函数的操作数都为0,即函数的相对地址为0,因为再链接生成可执行文件后才会生成其确定的地址,所以这里的相对地址都用0代替。

4.5 本章小结

        本章对汇编过程进行了一个简单但是完整的叙述。经过汇编器之后,生成了一个可重定位的文件,为下一步链接做好了准备。通过与hello.s的反汇编代码的比较,更加深入的理解了在汇编过程中发生的变化,这些变化都是为了链接做准备的。

第5章 链接

5.1 链接的概念与作用

(1)概念

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

(2)作用

        把预编译好了的若干目标文件合并成为一个可执行目标文件。使得分离编译称为可能,不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为可独立修改和编译的模块。当改变这些模块中的一个时,只需简单重新编译它并重新链接即可,不必重新编译其他文件。

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的格式

readelf -a hello > hello.elf

5.3.1 ELF头(ELF header)

包含内容与汇编中4.3.2节展示的类似,详细内容截图如下:

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息

5.3.2 节头部表(section header table)

        描述了各个节的大小、偏移量和其他属性。链接器链接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址,详细内容如下:

5.3.3 用于共享库的符号

5.3.4 符号表(部分)

可以观察到,符号表中有许多共享库的符号

5.4 hello的虚拟地址空间

(1)可以看到程序起始虚拟地址是0x400000

(2)5.3部分readelf得到程序的入门.init节的地址是0x401000,edb与之相对应:

(3)5.3部分readelf得到程序的代码段.text的地址是0x4010f0,观察edb:

5.5 链接的重定位过程分析

objdump -d -r hello > hello_objdump

5.5.1 hello与hello.o的不同

(1)在链接过程中,hello中加入了代码中调用的一些库函数,例如getchar,puts,printf,等,同时每一个函数都有了相应的虚拟地址。例如exit函数的虚拟地址如下图:

(2)对于全局变量的引用,由于hello.o中还未对全局变量进行定位,因此hello.o中用0加上%rip的值来表示全局变量的位置,而在hello中,由于已经进行了定位,因此全局变量的的值使用一个确切的值加上%rip表示全局变量的位置

(3)hello中增加了.init和.plt节,和一些节中定义的函数。

(4)hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。这是由于hello.o中对于函数还未进行定位,只是在.rel.text中添加了重定位条目,而hello进行定位之后自然不需要重定位条目。

(5)地址访问:在链接完成之后,hello中的所有对于地址的访问或是引用都调用的是虚拟地址地址。例如下图中条件跳转代码所示:

5.5.2链接的过程

链接主要分为两个过程:符号解析和重定位

(1)符号解析:目标文件定义和引用符号,符号解析将每个符号引用和一个符号定义关联起来

(2)重定位:编译器和汇编器生成从0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位

5.6 hello的执行流程

执行函数

虚拟内存地址

_init

0x401000

puts@plt

0x401090

printf@plt

0x4010a0

getchar@plt

0x4010b0

atoi@plt

0x4010c0

exit@plt

0x4010d0

sleep@plt

0x4010e0

_start

0x4010f0

_dl_relocate_static_pie

0x401120

main

0x401125

__libc_csu_init

0x4011c0

__libc_csu_fini

0x401230

_fini

0x401238

5.7 Hello的动态链接分析

因为编译器没有办法知道函数运行时的地址,需要链接器进行连接处理。动态链接器使用过程链接表(PLT)和全局偏移量表(GOT)实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。

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

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

因为编译器没办法预测地址,所以需要进行重定位,等待链接器进行处理。

首先查看与动态链接相关的.plt段和.got段

dl_init调用之前:

正在上传…重新上传取消正在上传…重新上传取消

dl_init调用之后:

可以观察到,这之间的一段数据发生了变化。此变化便是由GOT表中加载了共享库的内容而引起的。

5.8 本章小结

本章简述了链接的概念与作用,分析了我们经过链接生成的hello 文件的结构以及与之前经过链接的hello.o文件的异同,以及hello文件的运行流程,使用edb探索了动态链接的过程。

第6章 hello进程管理

6.1 进程的概念与作用

(1)概念

        在狭义上,进程是正在运行的程序的实例;而在广义上,进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。

        在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。

(2)作用

        进程提供给应用程序两个关键抽象:

[1] 逻辑控制流 (Logical control flow):每个程序似乎独占地使用CPU

——由OS内核通过上下文切换机制实现

[2] 私有地址空间 (Private address space):每个程序似乎独占地使用内存系统

——由OS内核的虚拟内存机制实现

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

(1)作用

        shell是命令行界面的解析器,能够为用户提供操作界面,提供内核服务。shell能执行一系列的读/求值操作,然后终止。读操作:读取来自用户的一个命令行。求值操作:解析命令并代表用户运行程序。

(2)处理流程

①将用户输入的命令行进行解析,分析是否是内置命令

②若是内置命令,直接执行;若不是内置命令,则 bash在初始子进程的上下文中加载和运行它(fork子进程,在子进程中execve执行相关命令)

③shell执行时同时可以接受来自终端的命令输入

④运行时,shell还可以处理异常

⑤运行结束后,shell可以回收僵尸子进程

6.3 Hello的fork进程创建过程

函数原型 pid_t fork(void):

        执行中的进程调用fork()函数,就创建了一个子进程。对于返回值,若成功调用一次则返回两个值,子进程返回0,父进程返回子进程ID;否则,出错返回-1。

        父进程通过调用fork函数创建一个新的运行的子进程,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的PID。对于返回值,若成功调用一次则返回两个值,子进程返回0,父进程返回子进程PID;否则,出错返回-1

以输入“./hello 120L021222 syh 5 ”为例:

(1)首先对于hello进程,我们终端的输入被判断为非内置命令,然后shell试图在硬盘上查找该命令(即hello可执行程序),并将其调入内存,解释为系统功能调用并转交给内核执行。

(2)Shell 创建一个子进程,使得hello开始运行,它获得了父进程的数据空间副本但并不共享,却可以读取父进程的打开的文件。此外,它们拥有不同的pid。

6.4 Hello的execve过程

函数原型:int execve(const char*filename,const char*argv[],const char*envp[])

        当fork之后,子进程调用execve函数(传入命令行参数)在当前进程的上下文中加载并运行一个新程序即hello程序。为执行hello程序加载器、删除子进程现有的虚拟内存段,execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序。加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段

        新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。最后加载器设置PC指向_start地址,_start最终调用hello中的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用它的页面调度机制自动将页面从磁盘传送到内存

        execve 函数加载并运行可执行目标文件filename, 且带参数列表argv 和环境变量列表envp 。只有当出现错误时,例如找不到filename, execve 才会返回到调用程序。所以与fork 一次调用返回两次不同,execve 调用一次并从不返回

6.5 Hello的进程执行

        系统中每个程序都运行在某个进程的上下文中。上下文是程序正确运行所需要的状态,由内核进行维持。

        一个运行多个进程的系统,进程逻辑流的执行可能是交错的。每个进程执行它的流的一部分,然后被抢占,然后轮到其他进程。一个逻辑流在时间上与另一个重叠,成为并发流。一个进程执行它的控制流的一部分时间叫做时间片。

        控制寄存器利用模式位描述了当前进程享有的特权:当设置了模式位时,进程运行在内核模式中,可以执行任何命令,访问任何内存;当没有设置模式位时,进程为用户模式,不允许执行特权指令,不允许直接引用内核区的代码、数据。

        在进程执行时,内核可以抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策称为调度。当进程调度一个新的进程运行后,会使用上下文切换来将控制转移到新的进程。上下文切换会:1.保存当前进程的上下文。2.恢复某个先前被抢占进程的被保存的上下文。3.将控制传递给新进程。系统调用、中断可能引起上下文切换。

下图反映了进程上下文切换的过程:(源于csapp)

        刚开始的时候,系统处于内核态。当用户在shell中键入命令,系统保存上下文,进行拷贝,fork子进程,开始在用户模式里面执行hello,期间如果遇到sleep,触发陷阱进程休眠,切换到内核态进行处理,并将hello加入等待队列,等到计时器完成计时,内核进行中断处理,重新切换上下文到hello进程。当执行getchar函数时,会使用read系统调用,产生上下文切换。

6.6 hello的异常与信号处理

6.6.1 异常类型及其处理方法

  • 中断处理方式
  • 陷阱处理方式
  • 故障处理方式
  • 终止处理方式

6.6.2 信号处理

(1)正常执行

(2)不停乱按:将屏幕的输入缓存到缓冲区,乱码被认为是命令,不影响当前进程的执行

(3)Ctrl+C:程序运行时按Ctrl+C发出SIGINT信号,进程收到 SIGINT 信号,结束 hello。在ps中查询不到其PID,在job中也没有显示,可以看出hello已经被彻底结束

(4)Ctrl+Z:程序运行时按Ctrl+Z,这时,产生中断异常,它的父进程会接收到SIGSTP信号并运行信号处理程序,程序在这时被挂起了,并打印相关挂起信息

(5)ps:Ctrl+Z后运行ps,打印出了各进程的pid,可以看到之前挂起的进程hello

(6)jobs:Ctrl+Z后运行jobs,打印出了被挂起进程组的jid,可以看到之前被挂起的hello,以被挂起的标识Stopped

(7)pstree:Ctrl+Z后运行pstree,可看到它打印出的信息:进程树

(8)fg:Ctrl+Z后运行fg,因为之前运行jobs是得知hello的jid为1,那么运行fg 1可以把之前挂起在后台的hello重新调到前台来执行,打印出剩余部分,然后输入hello回车,程序运行结束,进程被回收

(9)kill:Ctrl+Z后运行Kill,重新执行进程,可以发现hello的进程号为37940,那么便可通过kill -9 37940发送信号SIGKILL给进程37940,它会导致该进程被杀死。然后再运行ps,可发现已被杀死的进程hello

6.7本章小结

        本章主要介绍了hello可执行文件的执行过程,包括进程创建、加载和终止,以及通过键盘输入等过程。从创建进程到进程并回收进程,这一整个过程中需要各种各样的异常和中断等信息。程序的高效运行离不开异常、信号、进程等概念,正是这些机制支持hello能够顺利地在计算机上运行。

第7章 hello的存储管理

7.1 hello的存储器地址空间

(1)逻辑地址:

逻辑地址指由程序产生的与段相关的偏移地址部分,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。从hello的反汇编代码中看到的地址,便是hello中的逻辑地址。

(2)线性地址:

线性地址是逻辑地址到物理地址变换之间的中间层。程序hello的代码会产生逻辑地址,hello的反汇编文件中看到的地址(即逻辑地址)中的偏移量,加上对应段的基地址,便得到了hello中内容对应的线性地址。

(3)虚拟地址:

虚拟地址是Windows程序时运行在386保护模式下,这样程序访问存储器所使用的逻辑地址称为虚拟地址,与实地址模式下的分段地址类似,虚拟地址也可以写为"段:偏移量"的形式,这里的段是指段选择器。

(4)物理地址:

物理地址是出现在CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果,用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。本例中是hello的实际地址。

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

        段式管理就是把虚拟地址空间中的虚拟内存组织成一些长度可变的称为段的内存单元。每个段有三个参数定义:段基地址,指定段在线性地址空间中的开始地址。段偏移量:是虚拟地址空间中段内最大可用偏移地址。段属性:指定段的特性。如该段是否可读、可写或可作为一个程序执行,段的特权级等。在此基础上,处理器有两种寻址模式:实模式与保护模式。

  1. 保护模式

        保护模式是现代计算机常用的寻址模式。保护模式下,将一个段地址进行分段,使用索引在描述符表中读取及地址。段标识符由16位长的字段组成,称为段选择符。

转化过程如下:

(1)给定一个逻辑地址。

(2)将逻辑地址进行划分得到索引、TL、RPL信息。

(3)选择是GDT还是LDT中的段,再根据相应的寄存器得到地址。

(4)寻找段描述符得到基地址

(5)线性地址=基地址+偏移量

2. 实地址模式

实地址模式下,逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,给出32位地址偏移量,则可以访问真实物理内存。

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

        计算机利用页表,通过MMU来完成从虚拟地址到物理地址的转换。       

        虚拟地址被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),VPN用来在页表中寻找相应的对应页表条目PTE,然后读取页表中存储的物理页号(PPN),作为物理地址的PPN。然后,由于虚拟内存与物理内存的页大小相同,因此VPO与PPO(物理页偏移量)一致,如此便可得到物理地址。

如果PTE的有效位为1,则页命中,符合上述步骤。

如果PTE的有效位为0,则页不命中,没有缓存到物理内存,引发一个缺页异常,调入新的页并写入PTE,然后回到刚才导致缺页的程序处重新调用。

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

        我们具体分析运行Linux的Intel Core i7,它使用了TLB以及四级页表。

        Core i7支持48位的虚拟地址空间以及52位的物理地址空间。

        首先,我们分析各级页表中条目的格式。

第一、二、三级页表条目格式如下:

每个条目引用一个 4KB子页表:

P: (1)子页表在物理内存中 (0)不在

R/W: 对于所有可访问页,只读或者读写访问权限

U/S: 对于所有可访问页,用户或超级用户 (内核)模式访问权限

WT: 子页表的直写或写回缓存策略

A: 引用位 (由MMU 在读或写时设置,由软件清除)

PS: 页大小为4 KB 或 4 MB (只对第一层PTE定义)

Page table physical base address: 子页表的物理基地址的最高40位 (强制页表4KB 对齐)

第四级页表条目格式:

每个条目引用一个 4KB子页表:

P: (1)子页表在物理内存中 (0)不在

R/W: 对于所有可访问页,只读或者读写访问权限

U/S: 对于所有可访问页,用户或超级用户 (内核)模式访问权限

WT: 子页表的直写或写回缓存策略

A: 引用位 (由MMU 在读或写时设置,由软件清除)

D: 修改位 (由MMU 在读和写时设置,由软件清除)

Page table physical base address: 子页表的物理基地址的最高40位 (强制页表4KB 对齐)

XD: 能/不能从这个PTE可访问的所有页中取指令

        进行翻译时,虚拟地址中的VPN被划分为VPN1,VPN2,VPN3,VPN4。CR3寄存器中有L1页表的地址,根据VPN1能够在L1页表找到相应PTE,得到L2页表的基地址,一次类推,最终我们得到物理地址。并进行之后访问。具体流程如下:

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

        CPU发出一个虚拟地址再TLB里搜索,如果命中,直接发送到L1 cache里,如果没有命中,就现在也表里加载到之后再发送过去,到了L1中,寻找物理地址又要检测是否命中,如果没有命中,就向L2/L3中查找。这就用到了CPU高速缓存,这种机制加上TLB可以是的机器再翻译地址的时候性能得以充分发挥

7.6 hello进程fork时的内存映射

        当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的pid。

        为了给这个新进程创建虚拟内存,系统创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。

        当fork从新进程返回,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

        execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要:

(1)删除已存在的用户区域

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

(3) 映射共享区域:如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。

(4) 设置程序计数器(PC) ,指向代码的入口点。

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

缺页:引用虚拟内存中的字,不在物理内存中 (DRAM 缓存不命中)。

缺页后,执行如下处理步骤

(1)判断是否为合法的地址

(2)确认是否有读、写、或者执行这个区域内页面的权限。

(3)正常缺页下,选择一个牺牲页,缺页异常处理程序调入新的页面,更新PTE,返回到原来的进程执行导致缺页的指令

7.9动态存储分配管理

        动态内存分配器维护着进程的虚拟内存区域,成为堆。分配器将堆视为一组不同大小块的集合。各个块是已分配的或者是空闲的。分配器有两种基本风格:显式分配器,隐式分配器。

        C语言中,可以使用malloc和free函数来动态申请、释放内存。

7.9.1 隐式空闲链表

        空闲块可以通过头部中的大小字段隐含地连接着。我们可以通过遍历堆中所有的块来间接遍历整个空闲块集合。

隐式空闲链表块的格式:

隐式空闲链表的整体形式:(注意到序言块和结尾块的存在)

7.9.2 带边界标记的合并

        通过双界标记,我们可以在常数时间内完成空闲块的合并,具体结构如下:

7.9.3 显式空闲链表

        将空闲链表组织成一种显式的数据结构。在空闲块主体中放入指针。这样,我们可以快速定位到前、后的空闲链表,使首次适配的时间减少为空闲块的个数的线性时间,具体格式如下:

7.9.4 分离的空闲链表

        维护多个空闲链表,将所有可能的块大小划分为大小类。

基本方法有:1.简单分离存储。2.分离适配。3.伙伴系统。

7.10本章小结

        本章简述了系统对于hello的存储管理,介绍了intel段式、页式管理,分析了程序的虚拟地址逐步翻译为物理地址的过程,分析程序运行过程中forkexecve函数进行的内存映射,说明了系统对于缺页异常的处理以及动态存储的分配。

8hello的IO管理

8.1 Linux的IO设备管理方法

(1)设备的模型化:文件

        所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入输出都被当做对相应文件的读和写来执行。

(2)设备管理:unix io接口

        这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。

8.2 简述Unix IO接口及其函数

8.2.1 Unix IO接口

(1)打开文件:

        一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。

        Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。

        改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。

(2)读写文件,读操作:

        从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k。

(3)关闭文件:

        当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中

8.2.2 Unix IO函数

(1)打开文件:int open(char *filename, int flags, mode_t mode);

        Open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程当中没有打开的最小描述符。Flags参数指明了进程打算如何访问这个文件,同时也可以是一个或者更多为掩码的或,为写提供给一些额外的指示。Mode参数指定了新文件的访问权限位。

(2)关闭文件:int close(int fd);

        调用close函数,通知内核结束访问一个文件,关闭打开的一个文件。成功返回0,出错返回-1。

(3)读文件:ssize_t read(int fd, void *buf, size_t n);

        调用read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示错误,返回值0表示EOF,否则返回值表示的是实际传送的字节数量。

(4)写文件:ssize_t write(int fd, const void *buf, size_t n);

        调用从内存位置buf复制至多n个字节到描述符fd的当前文件位置。返回值-1表示出错,否则,返回值表示内存向文件fd输出的字节的数量。

8.3 printf的实现分析

printf函数:

        可以发现printf的输入参数是fmt,但是后面是不定长的参数,同时在printf内存调用了两个函数,一个是vsprintf,一个是write

Printf执行流程:

        vsprintf函数将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

        getchar是读入函数的一种。它从标准输入里读取下一个字符,相当于getc(stdin)。返回类型为int型,为用户输入的ASCII码或EOF。getchar可用宏实现:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时.程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中。直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(End-Of-File)则返回-1(EOF),且将用户输入的字符回显到屏幕。

        异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

        getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

        本章主要介绍了linux的IO设备管理方法和及其接口和函数,了解了printf的函数和getchar函数的底层实现。

结论

(1)预处理:hello.c文本翻译为hello.i文本,预处理器cpp替换掉源码中的头文件和宏。

(2)编译:将 hello.i 编译成为汇编文件 hello.s

(3)汇编:将 hello.s 会变成为可重定位目标文件 hello.o

(4)链接:

        静态链接,把外部函数的代码(通常是后缀名为.lib和.a的文件),添加到可执行文件中;动态链接的做法正好相反,它会设置过程链接表PLT和全局偏移量表GOT等,只在运行时动态引用相关代码。最后生成了hello可执行文件。 加载运行:shell中输入,终端为其新建进程(fork),把代码和数据加载入虚拟内存空间(execve),程序开始执行;

(5)执行每一步指令:

CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流。

(6)访存:

        MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。printf会调用malloc向动态内存分配器申请堆中的内存。

(7)信号处理:

        如果运行途中键入中断,则调用shell的信号处理函数分别停止、挂起。

(8)终止并被回收:shell父进程等待并回收子进程。

附件

hello.c

源代码

hello.i

hello.c预处理生成的文本文件

hello.s

hello.i经过编译器翻译成的文本文件hello.s,含汇编语言程序

hello.o

hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件

hello_elf

hello.o的ELF格式

hello_disa

hello.o反汇编生成的代码

hello

经过hello.o链接生成的可执行目标文件

hello.elf

hello的ELF格式

hello_odjdump

hello反汇编生成的代码

参考文献

[1]  https://blog.csdn.net/tianying1/article/details/100654905

[2]  https://www.cnblogs.com/zhcpku/p/14437940.html#_label0

[3]  https://www.cnblogs.com/pianist/p/3315801.html

[4]  https://blog.csdn.net/make_1998/article/details/118728696

[5]  Hello的一生_Lzyevermmm的博客-CSDN博客_hello的一生

[6]  https://www.cnblogs.com/Zhengsh123/p/15857501.html

[7] 《深入理解计算机系统》(csapp)

大作业图片版

感谢各位不知名学长的火炬使我苟完了大作业!!!