ICode9

精准搜索请尝试: 精确搜索
首页 > 编程语言> 文章详细

哈工大计算机系统大作业——程序人生-Hello’s P2P

2021-06-13 14:31:09  阅读:216  来源: 互联网

标签:... 文件 指令 Hello 地址 哈工大 P2P 进程 hello


计算机系统

大作业

题     目  程序人生-Hello’s P2P 

专       业       计算机类        

学     号       1190200817       

班     级        1936602         

学       生        刘小川       

指 导 教 师         刘宏伟         

计算机科学与技术学院

2021年6月

摘  要

本文介绍了hello的整个生命过程。利用gcc,gdb,edb,readelf,HexEdit等工具具体分析了hello从源程序开始,历经预处理、编译、汇编、链接的一系列步骤变为可执行文件的过程,即P2P的过程。同时还具体分析了hello在运行过程中涉及的进程管理、内存管理、IO管理到最后hello被回收,即020的过程。通过对hello这个简单程序的详细分析,我们能够更加深入地理解计算机系统。

关键词:Hello’s P2P;进程管理;内存管理;I/O管理                           

 

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分

 

 

 

 

 

 

 

 

 

目  录

 

第1章 概述... - 5 -

1.1 Hello简介... - 5 -

1.2 环境与工具... - 5 -

1.3 中间结果... - 5 -

1.4 本章小结... - 6 -

第2章 预处理... - 7 -

2.1 预处理的概念与作用... - 7 -

2.2在Ubuntu下预处理的命令... - 8 -

2.3 Hello的预处理结果解析... - 9 -

2.4 本章小结... - 9 -

第3章 编译... - 10 -

3.1 编译的概念与作用... - 10 -

3.2 在Ubuntu下编译的命令... - 10 -

3.3 Hello的编译结果解析... - 11 -

3.3.1数据... - 11 -

3.3.2赋值操作... - 13 -

3.3.3类型转换... - 13 -

3.3.4算术操作... - 13 -

3.3.5关系操作... - 14 -

3.3.6数组操作... - 14 -

3.3.7控制转移... - 15 -

3.3.8函数操作... - 15 -

3.4 本章小结... - 16 -

第4章 汇编... - 17 -

4.1 汇编的概念与作用... - 17 -

4.2 在Ubuntu下汇编的命令... - 17 -

4.3 可重定位目标elf格式... - 17 -

4.3.1 ELF头... - 17 -

4.3.2节头部表... - 18 -

4.4.3符号表... - 19 -

4.3.4重定位节... - 20 -

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

4.5 本章小结... - 23 -

第5章 链接... - 24 -

5.1 链接的概念与作用... - 24 -

5.2 在Ubuntu下链接的命令... - 24 -

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

5.3.1 ELF头... - 25 -

5.3.2 节头部表... - 26 -

5.3.3 程序头部表... - 27 -

5.3.4 符号表... - 27 -

5.3.5 重定位节... - 29 -

5.4 hello的虚拟地址空间... - 29 -

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

5.6 hello的执行流程... - 33 -

5.7 Hello的动态链接分析... - 33 -

5.8 本章小结... - 35 -

第6章 hello进程管理... - 36 -

6.1 进程的概念与作用... - 36 -

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

6.3 Hello的fork进程创建过程... - 36 -

6.4 Hello的execve过程... - 37 -

6.5 Hello的进程执行... - 37 -

6.6 hello的异常与信号处理... - 38 -

6.6.1可能出现的异常及处理方法... - 38 -

6.6.2可能产生的信号及处理方法... - 39 -

6.7本章小结... - 41 -

第7章 hello的存储管理... - 42 -

7.1 hello的存储器地址空间... - 42 -

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

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

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

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

7.6 hello进程fork时的内存映射... - 46 -

7.7 hello进程execve时的内存映射... - 46 -

7.8 缺页故障与缺页中断处理... - 47 -

7.9动态存储分配管理... - 48 -

7.10本章小结... - 51 -

第8章 hello的IO管理... - 52 -

8.1 Linux的IO设备管理方法... - 52 -

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

8.3 printf的实现分析... - 53 -

8.4 getchar的实现分析... - 54 -

8.5本章小结... - 54 -

结论... - 55 -

附件... - 56 -

参考文献... - 57 -

 

 

第1章 概述

1.1 Hello简介

简述Hello的P2P,020的整个过程。

Hello的P2P(From Program to Process)过程:在文本编辑器或IDE中编写C语言代码,得到最初的hello.c程序,即最初的Program。编译器驱动程序代表用户在需要时调用语言预处理器、编译器、汇编器和链接器。驱动程序首先运行C预处理器(cpp),将C的源程序hello.c翻译成一个ASCII码的中间文件;然后运行C编译器(cc1)将中间文件翻译成一个ASCII汇编语言文件;之后运行汇编器(as)将汇编语言文件翻译成可重定位目标文件;最后运行链接器(ld)创建一个可执行目标文件hello。在shell中输入执行hello的命令,shell解析命令行,通过fork新建一个子进程来执行hello,这时Hello已经从Program转换为Process了。

Hello的020(From Zero-0 to Zero-0)过程:子进程调用execve,重新为hello进行内存映射,设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。进入程序入口后通过存储管理机制将指令和数据载入内存,CPU以流水线形式读取并执行指令,执行逻辑控制流。操作系统负责进程调度,为进程分时间片。执行过程中通过L1、L2、L3高速缓存、TLB、多级页表等进行存储管理,通过I/O系统进行输入输出。当程序运行结束后,shell回收hello进程,删除和该进程相关的内容,这时hello进程就不存在了。hello从开始的未被内存映射到运行再到回收后不再存在,就是020的过程。

1.2 环境与工具

硬件环境:X64 CPU;2.6GHz;16G RAM;256GHD Disk

软件环境:Windows10 64位;Vmware 16;Ubuntu 16.04 LTS 64位

开发与调试工具:gcc,gdb,edb,readelf,HexEdit

1.3 中间结果

hello.i:C预处理器产生的一个ASCII码的中间文件,用于分析预处理过程。

hello.s:C编译器产生的一个ASCII汇编语言文件,用于分析编译的过程。

hello.o:汇编器产生的可重定位目标程序,用于分析汇编的过程。

hello:链接器产生的可执行目标文件,用于分析链接的过程。

hello.txt:hello.o的反汇编文件,用于分析可重定位目标文件hello.o。

hellold.txt:hello的反汇编文件,用于分析可执行目标文件hello。

helloelf.txt:hello.o的ELF格式,用于分析可重定位目标文件hello.o。

helloldelf.txt:hello的ELF格式,用于分析可执行目标文件hello。

1.4 本章小结

本章简述了Hello的P2P、020的整个过程并介绍了实验的基本信息:环境、工具以及实验的中间结果。

(第1章0.5分)

 

 

 

第2章 预处理

2.1 预处理的概念与作用

预处理是指在进行编译的第一遍扫描之前所做的工作,是C语言的一个重要功能,由预处理程序负责完成。预处理在源代码编译之前对其进行的一些文本性质的处理,生成扩展的C源程序。C语言提供了多种预处理功能,包括宏定义、文件包含、条件编译等。

预处理指令是以‘#’开头的代码行。‘#’必须是该行除了空白字符外的第一个字符。‘#’后面是指令关键字,整行语句构成一条预处理指令,该指令将在编译器进行编译之前对源代码做某些转换。下图是ANSI标准定义的C语言预处理指令。

 

图2-1 C语言预处理指令

宏定义(#define)使用宏名来表示一个字符串,宏展开时以该字符串取代宏名。这是一种简单的文本替换,预处理程序对它不做任何检查。如有错误,只能在后续编译源程序时发现。文件包含指令(#include)把指定头文件插入到该指令行的位置取代该指令行,从而把指定的文件和当前的源程序文件连成一个源文件。条件编译指令(#ifdef,#ifndef,#else,#elif,#endif等)对源程序中一部分内容只在满足一定条件时才进行编译,即指定编译的条件。可以按不同的条件去编译不同的程序部分,从而产生不同的目标代码文件。

预处理程序还可以识别一些特殊的符号。__FILE__:包含当前程序文件名的字符串;__LINE__:表示当前行号的整数;__DATE__:包含当前日期的字符串;__STDC__:如果编译器遵循ANSI C标准,则是非零值;__TIME__:包含当前时间的字符串。预处理程序对于在源程序中出现的这些串将用合适的值进行替换。

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

2.2在Ubuntu下预处理的命令

预处理的命令:gcc -E hello.c -o hello.i

预处理过程如图所示:

图2-2 预处理命令

图2-3 预处理结果

2.3 Hello的预处理结果解析

查看预处理产生的hello.i文件,可以发现main函数以及定义全局变量的代码没有任何改变,而原来前面的#include语句被替换成了大量的头文件中的内容,包括外部函数的声明、结构体等数据结构的定义、数据类型的定义等内容。源程序开头的注释也被删除了。同时,如果有#define的话,还会进行相应的符号替换。但是可以看出,预处理的结果仍然是可以阅读的C语言程序,预处理只是对源程序进行了一些文本性质的处理,生成的是扩展的C源程序。

图2-4 hello.i的部分结果

2.4 本章小结

       本章介绍了预处理的概念和作用,结合实际程序分析了预处理的过程,包括宏替换、头文件引入、删除注释、条件编译等。

(第2章0.5分)

 

 

第3章 编译

3.1 编译的概念与作用

编译的过程将预处理产生的ASCII码中间文件hello.i翻译成一个ASCII汇编语言文件hello.s。编译会对预处理文件进行词法分析、语法分析、优化等操作,将C语言这种高级语言转换为成更低级、更底层、机器更好理解的汇编语言程序。

词法分析对由字符组成的单词进行处理,从左至右逐个字符地对源程序进行扫描,产生一个个的单词符号,把作为字符串的源程序改造为单词符号串的中间程序。语法分析以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按语言的语法规则分析检查每条语句是否有正确的逻辑结构(语法规则可用上下文无关文法来刻画)。代码优化对程序进行等价的变换,使得变换后的程序能产生更有效的目标代码。这种等价的变换不改变程序的运行结果,同时使得程序运行时间更短,占用的存储空间更小。如果在编译的过程中发现源程序有错误,会报告错误的性质和发生位置。但一般情况下,编译器只做语法检查和最简单的语义检查,而不检查程序的逻辑。

汇编语言程序比源程序的层次更低,但是与机器代码相比程序员更容易理解,汇编语言相当于高级语言和机器语言之间的过渡,是从源程序转换到机器代码的关键中间环节。

3.2 在Ubuntu下编译的命令

编译的命令:gcc -S hello.i -o hello.s

编译过程如图所示:

图3-1 编译命令

 

图3-2 hello.s的部分结果

3.3 Hello的编译结果解析

3.3.1数据

(1)常量:hello.c源程序中的两个printf的参数是字符串常量,分别为"Usage: Hello 学号 姓名!\n"和"Hello %s %s\n"。

 

图3-3 hello.c中的字符串常量

在编译生成的hello.s中可以看到,这两个字符串常量分别由.LC0和.LC1指示,均存放在只读数据段.rodata中。

 

图3-4 hello.s中的字符串常量

(2)全局变量:hello.c源程序中的sleepsecs是全局变量,且已被赋初值。

 

在编译生成的hello.s中可以看到,使用.global将sleepsecs标记为全局变量。.data表明全局变量sleepsecs存放在数据段.data中;.align要求4字节对齐;.size表明变量为4字节;最后.long给出了变量的初值为2。

 

图3-5 hello.s中的全局变量

(3)局部变量:hello.c源程序中的局部变量包括i,用于循环的计数。

 

图3-6 hello.c中的局部变量

分析hello.s中为for循环产生的汇编代码,可以看出i被存储在%rbp-4的内存地址处。其中movl为i赋初值0,addl在每次循环时对i增加1,cmpl比较i和9的大小来决定什么时候结束循环。因此局部变量i是存放在栈上的,并通过相对栈顶(%rsp)的偏移量来访问。

 

图3-7 hello.s中的局部变量

hello.c中的其他局部变量还包括argc和argv,同样地,它们都存放在栈上的,并通过相对栈顶(%rsp)的偏移量来访问。

 

(4)关于数据的类型:在编译过程中,编译器会根据源程序中数据的类型来选取不同的寄存器以及不同的指令,比如浮点数会选择XMM寄存器,整数或指针会选择通用目的寄存器,同时也会根据数据的字节大小选择寄存器的不同部分以及指令的后缀。但在编译完成后,所有的类型信息都不复存在了,无法根据产生的汇编代码推断某个数据的类型。

3.3.2赋值操作

hello.c源程序中一共包括两次赋值操作,分别是对全局变量sleepsecs赋初值和对循环变量i赋初值。

 

图3-8 hello.c中的赋值操作

对于全局变量赋初值,这个值直接存储在数据段.data中;而如果不对全局变量赋初值的话,变量会存放在.bss段。而对于其他情况,在不考虑优化的前提下,所有的赋值操作都转化成mov类的数据传送指令。指令的后缀取决于操作数据的字节大小,movb:一个字节;movw:两个字节;movl:四个字节;movq:八个字节。以对i赋值为例,由于i为四字节,因此使用指令movl.

 

图3-9 hello.s中对应赋值操作的指令

3.3.3类型转换

       hello.c源程序中只包含一次隐式的类型转换,出现在全局变量赋初值的时候。

对于隐式类型转换,编译器会自己直接进行转换,在这个例子中,2.5被隐式类型转换为int型,编译器直接将转换后的值2放在了相应的数据段中。

图3-10 编译时的隐式类型转换

3.3.4算术操作

       hello.c源程序中只包含一次算术操作,出现在循环变量i每次增加1的时候。算术操作为++。

算术操作++代表自增1的运算,编译时转化成add类的加法指令,使用立即数1来实现每次增加1.

                      

图3-11 hello.s中的++操作    

其他和算术操作相关的指令还包括inc,dec,neg,sub,imul等等。

3.3.5关系操作

hello.c源程序中出现了两次关系操作。

(1)在if中判断argc的取值是否不等于3.

编译时使用cmpl指令将argc和3进行比较,并设置条件码。跳转指令je根据条件码决定是否跳转。对于关系操作!=来说,可以选择je或者jne跳转指令。

图3-12 hello.s中的关系操作

(2)在for循环中判断结束条件,即判断i是否小于10。

 

类似地,编译时使用cmpl指令将i和9进行比较,并设置条件码。跳转指令jle根据条件码决定是否跳转。这里进行比较的值是9而不是10,与编译的过程中进行了优化有关。

 

图3-13 hello.s中的关系操作

3.3.6数组操作

hello.c源程序中有关数组的操作出现在访问argv元素的时候,通过argv[1]和argv[2]访问了字符指针数组中的元素。

汇编代码中使用首地址+偏移量的方式来访问数组元素,数组首地址存储在%rbp-32的位置,通过将首地址加8获得argv[1]的地址,将首地址加16获得argv[2]的地址。值得注意的是,编译器会根据引用的数据类型的大小进行伸缩而不用程序员操心。由于这里的数组是指针数据,因此伸缩因子为8.

图3-14 hello.s中的数组操作

3.3.7控制转移

hello.c源程序中出现了两次控制转移。

(1)if判断argc的取值后的控制转移。

编译时使用cmpl指令将argc和3进行比较,并设置条件码。跳转指令je根据条件码决定是否跳转。控制转移由指令je完成。

图3-15 hello.s中的控制转移

(2)每次for循环结束时的控制转移。

 

类似地,编译时使用cmpl指令将i和9进行比较,并设置条件码。跳转指令jle根据条件码决定是否跳转。控制转移由指令jle完成。

 

图3-16 hello.s中的控制转移

3.3.8函数操作

(1)函数的调用:hello.c源程序中一共出现了五次函数调用。

 

图3-17 hello.c中的函数调用

编译时,所有的函数调用都转换成了指令call,后面跟着调用函数的名字。

 

 

 

 

 

 

图3-18 hello.s中的函数调用

(2)参数的传递:大部分的参数传递通过寄存器实现,通过寄存器最多传递6个参数,按照顺序依次为%rdi、%rsi、%rdx、%rcx、%r8、%r9。多余的参数通过栈来传递。在hello.c这个例子中,对于第一个函数printf,只有一个参数,通过寄存其%edi传递。

 

图3-19 hello.s中的参数传递

对于第二个函数exit,只有一个参数,通过寄存器%edi传递。

 

图3-20 hello.s中的参数传递

对于第三个函数printf,有三个参数,分别通过寄存器%edi、%rsi、%rdx传递。

 

图3-21 hello.s中的参数传递

对于第四个函数sleep,只有一个参数,通过寄存器%edi传递。

 

图3-22 hello.s中的参数传递

最后一个函数getchar没有参数,无需传递。

(3)函数的返回:编译时,在函数的最后添加指令ret来实现函数的返回。在hello.c这个例子中,只能看到main函数的返回。

图3-23 hello.s中的函数返回

3.4 本章小结

本章介绍了编译的概念和作用,并针对具体的例子hello.s,详细地分析了编译器如何处理C语言的各种数据以及各类操作。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

       汇编的过程将编译生成的ASCII汇编语言文件hello.s翻译成一个可重定位目标文件hello.o。可重定位目标文件包含指令对应的二进制机器语言,这种二进制代码能够被计算机理解并执行。因此汇编是将汇编语言转换成最底层的、机器可理解的机器语言的过程。

4.2 在Ubuntu下汇编的命令

汇编的命令:gcc -c -m64 -no-pie -fno-PIC hello.s -o hello.o

汇编过程如图所示:

 

图4-1 汇编命令

4.3 可重定位目标elf格式

使用readelf命令readelf -a hello.o > helloelf.txt查看hello.o的ELF格式,并将结果重定向到helloelf.txt便于查看分析。

 

图4-2 查看hello.o ELF格式的命令

4.3.1 ELF

ELF头以一个16字节的目标序列开始,如图中Magic所示,这个序列描述了生成该文件的系统的字的大小和字节顺序。以hello.o为例,这个16字节序列为7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00,描述了系统的字的大小为8字节,字节顺序为小端序。

ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。以hello.o为例,ELF头中包含了ELF头的大小:64字节;目标文件的类型:REL(可重定位文件);机器类型:Advanced Micro Devices X86-64;节头部表的文件偏移:1112bytes;节头部表中条目的数量:13.

图4-3 hello.o的ELF头

4.3.2节头部表

       节头部表描述不同节的位置和大小,目标文件中的每个节都有一个固定大小的节头部表条目。

       以hello.s为例,节头部表一共描述了13个不同节的位置、大小等信息。依次为:

[1].text节:已编译程序的机器代码,大小为0x7d字节,类型为PROGBITS,偏移量为0x40,标志为AX(表明该节的数据只读并且可执行)。

[2] .rela.text节:一个.text节中位置的列表,大小为0xc0字节,类型为RELA,偏移量为0x318,标志为I。

[3].data节:已初始化的全局和静态C变量,大小为0x4字节,类型为PROGBITS,偏移量为0xc0,标志为WA(表明该节的数据可读可写)。

[4].bss节:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。大小为0x0字节,类型为NOBITS,偏移量为0xc4,标志为WA(表明该节的数据可读可写)。

[5].rodata节:只读数据,大小为0x2b字节,类型为PROGBITS,偏移量为0xc4,标志为A(表明该节的数据只读)。

[6].comment节:包含版本控制信息,大小为0x36字节,类型为PROGBITS,偏移量为0xef,标志为MS。

[7].note.GNU_stack节:标记可执行堆栈,大小为0x0字节,类型为PROGBITS,偏移量为0x125。

[8].eh_frame节:处理异常,大小为0x38字节,类型为PROGBITS,偏移量为0x128,标志为A(表明该节的数据只读)。

[9].rela.eh_frame节:.eh_frame节的重定位信息,大小为0x18字节,类型为RELA,偏移量为0x3d8,标志为I。

[10].shstrtab节:包含节区名称,大小为0x61字节,类型为STRTAB,偏移量为0x3f0。

[11].symtab节:一个符号表,存放在程序中定义和引用的函数和全局变量的信息。大小为0x180字节,类型为SYMTAB,偏移量为0x160。

[12].strtab节:一个字符串表,包括.symtab和.debug节中的符号表,以及节头部中的节名字。大小为0x37字节,类型为STRTAB,偏移量为0x2e0。

 

图4-4 hello.o的节头部表

4.4.3符号表

       符号表存放程序中定义和引用的函数和全局变量的信息,每个符号表是一个条目的数组,每个条目包括value:距定义目标的节的起始位置的偏移;size:目标的大小;type:指明数据还是函数;bind:表示符号是本地的还是全局的等等。

       hello.s为例,符号表一共描述了16个符号。比如全局变量sleepsecs,Ndx=3表明它在.data节,value=0表明它在.data节中偏移量为0的地方,size=4表明大小为4字节,bind=GLOBAL表明它是全局符号,type=OBJECT:表明它是数据。而对于函数main,Ndx=1表明它在.text节,value=0表明它在.text节中偏移量为0的地方,size=125表明大小为125字节,bind=GLOBAL表明它是全局符号,type=FUNC:表明它是函数。其他的符号如puts、exit、printf、sleep和getchar都是外部的库函数,需要在链接后才能确定。

 

图4-5 hello.o的符号表

4.3.4重定位节

       汇编器遇到对最终位置未知的目标引用,会产生一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位信息就放在重定位节.rel.text中,已初始化数据的重定位条目放在.rel.data中。

       每个重定位条目包括offset:需要被修改的引用的节偏移;symbol:标识被修改引用应该指向的符号;type:重定位类型,告知链接器如何修改新的引用;attend:一些重定位要使用它对被修改引用的值做偏移调整。ELF定义了32种不同的重定位类型,两种最基本的重定位类型包括R_X86_64_PC32(重定位使用32位PC相对地址的引用)和R_X86_64_32(重定位使用32位绝对地址的引用)。

       以hello.s为例,重定位节.rela.text一共描述了8个重定位条目。重定位节.rela.eh_frame描述了1个重定位条目。

图4-6 hello.o的重定位节

4.4 Hello.o的结果解析

使用命令objdump -d -r hello.o对hello.o进行反汇编,得到结果如图。

 

图4-7 hello.o的反汇编结果

 

图4-8 hello.s

与第3章的 hello.s对比可以发现,hello.s中的汇编指令被映射到二进制的机器语言。机器语言完全是二进制代码构成的,机器可以直接根据二进制代码执行对应的操作。不同的汇编指令被映射到不同的二进制功能码,而汇编指令的操作数也被映射成二进制的操作数。因此每一条汇编语言的指令都可以映射到一条机器语言指令,而给出任何一条合法的机器语言指令也可以得知它对应的汇编指令。从汇编语言转换成机器语言的过程中,一些操作数会出现不一致的情况:

       (1)立即数的变化:hello.s中的立即数都是用10进制数表示的。

但是在机器语言中,由于转换成了二进制代码,因此立即数都是用16进制数表示的。

 

       (2)分支转移的不一致:hello.s中的分支转移(即跳转指令)直接通过像.LC0,.LC1这样的助记符进行跳转,会直接跳转到相应符号声明的位置。

助记符只是帮助程序员理解的,从汇编语言转换成机器语言之后,助记符就不再存在了,因此机器语言中的跳转使用的是确定的地址。下图中的main+0x29就表明要跳转到距main函数偏移量为0x29的位置。

(3)函数调用的不一致:hello.s中的函数调用直接在call指令后面加上要调用的函数名。

但是在机器语言中,call指令后是被调函数的PC相对地址。在这里,由于调用的函数都是库函数,需要在动态链接后才能确定被调函数的确切位置,因此call指令后的二进制码为全0,同时需要在重定位节中添加重定位条目,在链接时确定最终的相对地址。

4.5 本章小结

本章介绍了汇编的概念和作用,通过对比hello.s和hello.o分析了汇编的过程,同时分析了可重定位目标文件的ELF格式。

(第41分)

 

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

使用readelf命令readelf -a hello > helloldelf.txt查看可执行目标文件hello的ELF格式,并将结果重定向到helloldelf.txt便于查看分析。

图5-2 查看hello ELF格式的命令

5.3.1 ELF

ELF头以一个16字节的目标序列开始,如图中Magic所示,这个序列描述了生成该文件的系统的字的大小和字节顺序。以hello为例,这个16字节序列为7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00,描述了系统的字的大小为8字节,字节顺序为小端序。

ELF头剩下的部分包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。以hello为例,ELF头中包含了ELF头的大小:64字节;目标文件的类型:EXEC(可执行文件);机器类型:Advanced Micro Devices X86-64;节头部表的文件偏移:4032bytes;节头部表中条目的数量:25。同时,ELF头中还包括程序的入口点(偏移量64字节),即程序运行时要执行的第一条指令的地址。

图5-3 hello的ELF头

 

5.3.2 节头部表

节头部表描述不同节的位置和大小,目标文件中的每个节都有一个固定大小的节头部表条目。

       与hello.o相比,hello的节头部表一共描述了25个不同节的位置、大小等信息,比hello.o多出12个节。各节的起始地址由偏移量给出,同时也给出了大小等信息。

 

图5-4 hello的节头部表

5.3.3 程序头部表

       程序头部表描述了可执行文件的连续的片映射到连续的内存段的映射关系。包括目标文件的偏移、段的读写/执行权限、内存的开始地址、对齐要求、段的大小、内存中的段大小等。

       以hello中的第一个LOAD为例,Offset说明段的偏移量为0;VirtAddr说明映射到的虚拟内存段的开始地址是0x400000;FileSiz说明段的大小为0x720字节;Memsiz说明内存中的段大小也是0x720字节;Flags为R E,标志段的权限为只读且可执行;Align说明段的对齐要求为200000。

图5-5 hello的程序头部表

5.3.4 符号表

符号表存放程序中定义和引用的函数和全局变量的信息,每个符号表是一个条目的数组,每个条目包括value:距定义目标的节的起始位置的偏移;size:目标的大小;type:指明数据还是函数;bind:表示符号是本地的还是全局的等等。

       hello的符号表一共描述了48符号,比hello.o多出32个符号。多出的符号都是链接后产生的库中的函数以及一些必要的启动函数。

图5-6 hello的符号表

       hello中还多出了一个动态符号表,表中的符号都是共享库中的函数,需要动态链接。

 

图5-7 hello的动态符号表

5.3.5 重定位节

重定位条目包括offset:需要被修改的引用的节偏移;symbol:标识被修改引用应该指向的符号;type:重定位类型,告知链接器如何修改新的引用;attend:一些重定位要使用它对被修改引用的值做偏移调整。ELF定义了32种不同的重定位类型,两种最基本的重定位类型包括R_X86_64_PC32(重定位使用32位PC相对地址的引用)和R_X86_64_32(重定位使用32位绝对地址的引用)。

在hello中,原来的.rela.text节已经没有了,说明链接的过程已经完成了对.rela.text的重定位操作。Hello中出现了6个新的重定位条目。这些重定位条目都和共享库中的函数有关,因为此时还没有进行动态链接,共享库中函数的确切地址仍是未知的,因此仍然需要重定位节,在动态链接后才能确定地址。

图5-8 hello的重定位节

5.4 hello的虚拟地址空间

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

图5-9 edb查看hello的虚拟地址空间

       由5.3中的节头部表可以获得各个节的偏移量信息,从而得知各节在虚拟地址空间中的地址。

       例如,对于.interp节,节头部表中给出了它的偏移量为0x1c8,大小为0x1c字节。

 

因此它的虚拟地址空间就从0x4001c8开始,在edb中查看该虚拟内存地址,可以看出,.interp节确实在这个位置。

图5-10 edb查看.interp的虚拟地址

类似地,对于.rodata节,节头部表中给出了它的偏移量为0x600,大小为0x2f字节。

 

因此它的虚拟地址空间就从0x400600开始,在edb中查看该虚拟内存地址,可以看出,.rodata节确实在这个位置,程序中的两个字符串常量就存储在这里。

图5-11 edb查看.rodata的虚拟地址

对于.data节,节头部表中给出了它的偏移量为0x900,大小为0x8字节。

 

因此它的虚拟地址空间就从0x400900开始,在edb中查看该虚拟内存地址,可以看出,.data节确实在这个位置,程序中的全局变量sleepsecs就存储在这里,并且值为2。

图5-12 edb查看.data的虚拟地址

对于.text节,节头部表中给出了它的偏移量为0x4d0,大小为0x122字节。

 

因此它的虚拟地址空间就从0x4004d0开始,在edb中查看该虚拟内存地址,可以看出,.text节确实在这个位置,第一条指令的二进制机器码的第一个字节为0x31。

图5-13 edb查看.text的虚拟地址

       对于其他的节同理,不再赘述。

5.5 链接的重定位过程分析

使用命令objdump -d -r hello > hellold.txt对hello进行反汇编,并将结果重定向到hellold.txt中便于查看分析。hello与hello.o的不同之处在于以下几个方面:

(1) hello中的汇编代码已经使用虚拟内存地址来标记了,从0x400000开始;而hello.o中的汇编代码是从0开始的,还没有涉及到虚拟内存地址。

(2)在hello.o中,只存在main函数的汇编指令;而在hello中,由于链接过程中发生重定位,引入了其他库的各种数据和函数,以及一些必需的启动/终止函数,因此hello中除了main函数的汇编指令外,还包括大量其他的指令。

(3)main函数中涉及重定位的指令的二进制代码被修改。在之前汇编的过程中,汇编器遇到对最终位置未知的目标引用,会产生一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。因此在链接的过程中,链接器会根据重定位条目以及已知的最终位置对修改指令的二进制码,这个过程就是重定位的过程。下面以hello.o为例,说明hello如何进行重定位。

查看hello.o中的重定位条目,重定位条目给出了需要被修改的引用的节偏移、重定位类型、偏移调整等信息。

图5-14 hello.o的重定位节

这里涉及到两种不同的重定位类型,分别是R_X86_64_PC32(重定位使用32位PC相对地址的引用)和R_X86_64_32(重定位使用32位绝对地址的引用)。对于第一种重定位类型,以第一个条目为例,第一个条目的信息说明需要重定位的位置在.text中偏移量为0x1b的地方。在hello.o中找到相应的位置:

图5-15 条目1的重定位位置

这条指令的目的是将某一个数传送到%edi中,使其作为printf的参数。由源程序可知,这个指令对应与语句为 printf("Usage: Hello 学号 姓名!\n"); 因此参数应该是字符串常量"Usage: Hello 学号 姓名!\n"的地址。由于字符串常量的最终位置未知,因此产生了一个重定位条目。而重定位的目的就是修改这个数据,使得传入%edi的是"Usage: Hello 学号 姓名!\n"的最终地址。同时,重定位类型为R_X86_64_32,因此地址为绝对地址。由5.4可知,该字符串常量的地址为0x400604,因此重定位会将这条指令的最后四个字节改为04 06 40 00(小端形式的地址)。查看hello的反汇编结果,确实是这样的。

图5-16 条目1的重定位结果

对于第二种重定位类型,以第二个条目为例,第二个条目的信息说明需要重定位的位置在.text中偏移量为0x1b的地方。在hello.o中找到相应的位置:

图5-17 条目2的重定位位置

这条指令的目的是调用函数puts。由于函数puts的最终位置未知,因此产生了一个重定位条目。而重定位的目的就是修改这个数据,使得call指令的地址为puts函数的起始地址。同时,重定位类型为R_X86_64_PC32,因此地址为相对地址。从hello的反汇编结果可以获得puts函数的地址为0x400460。

图5-18 puts函数的位置

而这条call指令的地址为0x400514,它的下一条指令的地址为0x400519.

图5-19 call指令的位置

因此相对地址为0x400460 – 0x400519 = 0xffffff47。因此重定位会将这条指令的最后四个字节改为47 ff ff ff(小端形式的地址)。查看hello的反汇编结果,确实是这样的。

图5-20 条目2的重定位结果

5.6 hello的执行流程

从加载hello到_start,到call main,以及程序终止的所有过程中调用的子程序名以及程序地址(调用顺序为从上到下):

名称

地址

ld-2.23.so!_dl_start

0x7f7c8a4619b0

ld-2.23.so! dl_init

0x7f7c8a470780

hello!_start

0x4004d0

hello!__libc_start_main

0x400480

libc-2.23.so!__libc_start_main

0x7f7c8a0b6750

libc-2.23.so! cxa_atexit

0x7f7c8a0d0290

hello!__libc_csu_init

0x400580

hello!_init

0x400430

libc-2.23.so!_setjmp

0x7f7c8a0cb260

libc-2.23.so!_sigsetjmp

0x7f7c8a0cb1c0

hello!main

0x4004fa

hello!puts@plt

0x400460

hello!exit@plt

0x4004a0

hello!printf@plt

0x400470

hello!sleep@plt

0x4004b0

hello!getchar@plt

0x400490

ld-2.23.so!_dl_runtime_resolve_avx

0x7f7c8a477870

libc-2.23.so!exit

0x7f4ea0c8d5b0

5.7 Hello的动态链接分析

当程序调用一个由共享库定义的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。但这需要链接器修改调用模块的代码段,GNU编译系统使用一种称为延迟绑定的技术将过程地址的绑定推迟到第一次调用该过程时。

延迟绑定是通过两个数据结构之间的交互来实现的,分别是GOT和PLT,GOT是数据段的一部分,而PLT是代码段的一部分。PLT与GOT的协作可以在运行时解析函数的地址,实现函数的动态链接。

过程链接表PLT是一个数组,每个条目是16字节代码。PLT[0]是一个特殊条目,跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。由5.3.2中的节头部表知,存储PLT的.plt节的开始地址为0x400450.

在hello的反汇编结果中可以查看到每个PLT条目。PLT[0]是一个特殊条目,跳转到动态链接器中。接下来每个条目对应一个调用的库函数,例如PLT[1]对应的是puts函数;PLT[2]对应的是printf函数……

图5-21 hello的PLT条目

全局偏移量表GOT是一个数组,每个条目为8字节地址,和PLT联合使用。GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息,GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。由5.3.2中的节头部表知,存储GOT的.got.plt节的开始地址为0x6008b8.

在edb中查看初始时的GOT条目(如图5-22)。除了PLT[0]外,每个PLT对应的GOT条目初始时都指向这个PLT的第二条指令。例如:如图5-21,PLT[1]对应地址0x6008d0处的GOT[3],而0x6008d0处的值为0x400466,恰好指向PLT[1]的第二条指令。在函数第一次被调用时,动态链接器会修改相应的GOT条目。

图5-22 hello的初始GOT条目

同时也可以看到,GOT[1]和GOT[2]这两个条目初始时均为0。而GOT[1]应该包含动态链接器在解析函数地址时会使用的信息,GOT[2]应该为动态链接器在1d-linux.so模块中的入口点。使用edb调试,当dl_start函数返回后,发现这两个条目被修改为正确的值。

图5-23 dl_start后的GOT条目

在函数第一次被调用时,动态链接器会修改相应的GOT条目。以puts函数为例,puts函数对应的是PLT[1],PLT[1]对应地址0x6008d0处的GOT[3],而GOT[3]的初始值为0x400466,指向PLT[1]的第二条指令。当第一次调用puts时,动态链接器确定puts的运行时位置,用这个地址重写GOT[3]。这时,puts函数才真正完成动态链接,后续对puts的调用就可以直接根据GOT[3]的值进行跳转。

图5-24 第一次调用puts后的GOT条目

5.8 本章小结

本章介绍了链接的概念与作用,简要分析了可执行文件的ELF格式,hello的虚拟地址空间和执行流程,同时详细地分析了静态链接的重定位过程以及动态链接的过程。至此,一个完美的生命——hello诞生了。

(第51分)

第6章 hello进程管理

6.1 进程的概念与作用

进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。通过进程的概念提供给我们一个假象,就好像我们的程序是系统中运行的唯一的程序;程序好像独占地使用处理器和内存;处理器好像是无间断地一条接一条地执行程序中的指令;程序的代码和数据好像是系统内存中唯一的对象。

其中上下文是由程序正确运行所需的状态组成的,包括存放在内存中的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。

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

shell是指为使用者提供操作界面的软件,是一个交互型应用级程序,它接收用户命令,然后调用相应的应用程序。shell是系统的用户界面,提供了用户与内核进行交互操作的接口。

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

shell的处理流程:从终端读入输入的命令行->解析输入的命令行,获得命令行指定的参数->检查命令是否是内置命令,如果是内置命令则立即执行,否则在搜索路径里寻找相应的程序,找到该程序就执行它。

6.3 Hello的fork进程创建过程

当在shell中输入命令“./hello 1190200817 刘小川”时,shell解析输入的命令行,获得命令行指定的参数。由于./hello不是shell内置的命令,因此shell将hello看作一个可执行目标文件,在相应路径里寻找hello程序,找到该程序就执行它。shell会通过调用fork()函数创建一个子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同但独立的一个副本,包括代码段、数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,子进程可以读写父进程中打开的任何文件。父进程和子进程之间最大的区别在于它们的PID不同。hello程序之后就会运行在这个新创建的子进程的上下文中。

6.4 Hello的execve过程

shell创建一个子进程之后,这个子进程仍然是父进程的一个副本,因此需要在子进程中调用exceve()函数在当前进程的上下文中加载并运行我们需要的hello程序。execve函数加载并运行可执行文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。

execve函数用hello程序有效替代当前程序,需要以下几个步骤:

(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。

(2)映射私有区域。为新程序(即hello)的代码、数据、bss和栈区域等创建新的区域结构。所有这些区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。

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

(4)设置程序计数器。最后设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

当内核调度这个进程时,它就将从这个入口点开始执行。Linux根据需要换入代码和数据页面。

6.5 Hello的进程执行

当子进程调用exceve()函数在上下文中加载并运行hello程序后,hello程序不会立即运行,需要内核调度它。进程调度是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行时,就说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,使用一种称为上下文切换的机制来将控制转移到新的进程。上下文切换包括:保存当前进程的上下文;恢复某个先前被抢占的进程被保存的上下文;将控制传递给这个新恢复的进程。其中上下文指的是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构(页表、进程表、文件表等)。

处理器提供了一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。通常用某个控制寄存器的一个模式位来提供这种机制,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程运行在内核模式中,进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置;没有设置模式位时,进程运行在用户模式中,进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据,否则会导致保护故障。运行应用程序代码的进程初始时在用户模式中,进程需要通过中断、故障或者陷入系统调用这样的异常才能从用户模式变为内核模式。

由于负责进程调度的是内核,因此内核调度需要运行在内核模式下。当内核代表用户执行系统调用时,可能会发生上下文切换,中断也可能引发上下文切换。同时,系统通过某种产生周期性定时器中断的机制判断当前进程已经运行了足够长的时间,并切换到一个新的进程。

以hello的进程执行为例。当子进程调用exceve()函数在上下文中加载并运行hello程序后,hello进程等待内核调度它。当内核决定调度hello进程时,它就抢占当前进程,进行上下文切换,将控制转移到hello进程,并从内核模式变为用户模式,这时hello进程开始运行应用程序代码。当hello进程调用sleep时,由于sleep是系统调用,进程陷入内核模式。这时hello进程被挂起,内核会选择调度其他进程,通过上下文切换保存hello进程的上下文,将控制传递给新调度的进程。定时器的时间到了后会发送中断信号,进入内核模式,将挂起的hello进程变成运行状态,这时hello进程就可以等待内核调度它。当内核再次调度hello进程时,恢复保存的hello进程的上下文,就可以从刚才停止的地方继续执行了。当hello调用getchar的时候同样会陷入内核模式,由于getchar需要来自键盘的DMA传输,时间很长,因此内核不会等待DMA完成,而是去调度其他进程。当DMA完成后,会向处理器发送中断信号,进入内核模式,内核知道DMA完成了,就可以再次调度hello进程了。

6.6 hello的异常与信号处理

6.6.1可能出现的异常及处理方法

hello执行过程中,四类异常都可能会出现,四类异常分别为:

类别

原因

异步/同步

返回行为

中断

来自I/O设备的信号

异步

总是返回到下一条指令

陷阱

有意的异常

同步

总是返回到下一条指令

故障

潜在可恢复的错误

同步

可能返回到当前指令

终止

不可恢复的错误

同步

不会返回

hello执行过程中发生中断:如果其他进程使用了外部I/O设备,那么在hello进程运行时可能会出现外部I/O设备引起的中断。中断的处理:将控制传递给适当的中断处理程序,处理程序返回时,就将控制返回给下一条指令,程序继续执行,好像没有发生过中断一样。

hello执行过程中发生陷阱:hello中调用了系统调用sleep,产生陷阱。陷阱的处理:将控制传递给适当的异常处理程序,处理程序解析参数,调用适当的内核程序。处理程序返回时,将控制返回给下一条指令。

hello执行过程中发生故障:当hello进程刚从入口点开始执行时,会发生缺页故障。hello进程运行的过程中,也可能发生缺页故障。故障的处理:将控制传递给故障处理程序,如果处理程序能够修正这个错误情况,就将控制返回到引起故障的指令并重新执行它;否则终止引起故障的应用程序。

hello执行过程中发生错误:hello执行过程中,DRAM或者SRAM可能发生位损坏,产生奇偶错误。发生错误时会将控制传递给终止处理程序,终止引起错误的应用程序。

6.6.2可能产生的信号及处理方法

hello执行过程中,可能产生的信号如:SIGINT,SIGTSTP,SIGCHLD,SIGKILL,SIGALRM等等。进程接受到信号时,会触发控制传递到信号处理程序,信号处理程序运行,信号处理程序返回后,将控制返回给被中断的程序。每个信号类型有相关联的默认行为,使用signal函数可以修改和信号相关联的行为。

下面以hello的运行过程为例,简要说明异常与信号的处理。

(1)程序运行过程中不停乱按键盘,包括回车。如果乱按不包括回车,输入的字符串会缓存到缓冲区;如果输入的最后是回车,则getchar会读进回车,把回车前的字符串作为输入shell的命令,

图6-1 程序运行过程中不停乱按键盘,包括回车

(2)程序运行过程中键入Ctrl-Z。键入Ctrl-Z会发送SIGTSTP信号给前台进程组的每个进程,结果是停止前台作业,也就是停止hello进程。

图6-2 键入Ctrl-Z

使用jobs命令可以查看当前的作业,可以看出当前的作业是hello进程,且状态是已停止

图6-3 jobs命令

使用ps命令可以查看当前所有进程以及它们的PID,进程包括bash,hello以及ps。

图6-4 ps命令

使用pstree命令将所有进程以树状图形式显示。

图6-5 pstree命令

使用fg命令可以使停止的hello进程继续在前台运行。也可以再次键入Ctrl-Z停止hello的运行。

图6-6 fg命令

使用kill命令可以给指定进程发送信号。比如 kill -9 8329 是指向PID为8329的进程(即hello)发送SIGKILL信号。这个命令会杀死hello进程,当再次使用ps时可以发现hello进程已经被杀死,使用jobs指令也看不到当前的作业了。

图6-7 kill命令

       (3)程序运行过程中键入Ctrl-C。键入Ctrl-C会发送SIGINT信号给前台进程组的每个进程,结果是终止前台进程,即终止hello进程。

图6-8 键入Ctrl-C

使用ps命令可以发现,hello进程已经终止并被回收,不再存在了。使用jobs指令也看不到当前的作业了。

图6-9 ps,jobs命令

6.7本章小结

本章介绍了进程的概念和作用,简述shell的工作过程,并分析了使用fork+execve加载运行hello,执行hello进程以及hello进程运行时的异常/信号处理过程。

(第61分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:逻辑地址是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以使用&操作读取指针变量的值,这个值就是逻辑地址,是相对于当前进程数据段的地址。一个逻辑地址由两部份组成:段标识符和段内偏移量。

线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址生成了一个线性地址。如果启用了页式管理,那么线性地址可以再变换产生物理地址。若没有启用页式管理,那么线性地址直接就是物理地址。

虚拟地址:因为虚拟内存空间的概念与逻辑地址类似,因此虚拟地址和逻辑地址实际上是一样的,都与实际物理内存容量无关。

物理地址:存储器中的每一个字节单元都给以一个唯一的存储器地址,用来正确地存放或取得信息,这个存储器地址称为物理地址,又叫实际地址或绝对地址。

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

逻辑地址由段标识符和段内偏移量两部分组成。段标识符由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号,是对段描述符表的索引,每个段描述符由8个字节组成,具体描述了一个段。后3位包含一些硬件细节,表示具体是代码段寄存器还是栈段寄存器还是数据段寄存器等。通过段标识符的前13位,可以直接在段描述符表中索引到具体的段描述符。每个段描述符中包含一个Base字段,它描述了一个段的开始位置的线性地址。将Base字段和逻辑地址中的段内偏移量连接起来就得到转换后的线性地址。

对于全局的段描述符,放在全局段描述符表中,局部的(每个进程自己的)段描述符,放在局部段描述符表中。全局段描述符表的地址和大小存放在gdtr控制寄存器中,而局部段描述符表存放在ldtr寄存器中。

给定逻辑地址,看段选择符的最后一位是0还是1,用于判断选择全局段描述符表还是局部段描述符表。再根据相应寄存器,得到其地址和大小。通过段标识符的前13位,可以在相应段描述符表中索引到具体的段描述符,得到Base字段,和段内偏移量连接起来最终得到转换后的线性地址。

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

页表是一个页表条目(PTE)的数组,虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。每个PTE由一个有效位和一个n位地址字段组成,有效位表明该虚拟页是否被缓存在DRAM中。如果设置了有效位,那么地址字段表示相应的物理页的起始位置;如果没有设置有效位,那么空地址表示虚拟页还未被分配,否则这个地址指向该虚拟页在磁盘的起始位置。

MMU利用页表实现从虚拟地址到物理地址的变换。CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含一个p位的虚拟页面偏移VPO和一个n-p位的虚拟页号VPN。MMU利用VPN选择适当的PTE,如果这个PTE设置了有效位,则页命中,将页表条目中的物理页号和虚拟地址中的VPO连接起来就得到相应的物理地址。否则会触发缺页异常,控制传递给内核中的缺页异常处理程序。缺页处理程序确定物理内存中的牺牲页,调入新的页面,并更新内存中相应PTE。处理程序返回到原来的进程,再次执行导致缺页的指令,MMU重新进行地址翻译,此时和页命中的情况一样。同时,也可以利用TLB缓存PTE加速地址的翻译。

图7-1 线性地址到物理地址的变换

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

TLB的支持:在MMU中包括一个关于PTE的缓存,称为翻译后备缓冲器(TLB)。TLB是一个小的、虚拟寻址的缓存,每一行保存着一个由单个PTE组成的块。由于VA到PA的转换过程中,需要使用VPN确定相应的页表条目,因此TLB需要通过VPN来寻找PTE。和其他缓存一样,需要进行组索引和行匹配。如果TLB有2t个组,那么TLB的索引TLBI由VPN的t个最低位组成,TLB标记TLBT由VPN中剩余的位组成。

图7-2 TLB

当MMU进行地址翻译时,会先将VPN传给TLB,看TLB中是否已经缓存了需要的PTE,如果TLB命中,可以直接从TLB中获取PTE,将PTE中的物理页号和虚拟地址中的VPO连接起来就得到相应的物理地址。这时所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。如果TLB不命中,那和7.3中描述的过程类似,需要从cache或者内存中取出相应的PTE。

图7-3 TLB支持下的线性地址到物理地址的变换

四级页表的支持:多级页表可以用来压缩页表,对于k级页表层次结构,虚拟地址的VPN被分为k个,每个VPNi是一个到第i级页表的索引。当1≤j≤k-1时,第j级页表中的每个PTE指向某个第j+1级页表的基址。第k级页表中的每个PTE和未使用多级页表时一样,包含某个物理页面的PPN或者一个磁盘块的地址。对于Intel Core i7,使用了4级页表,每个VPNi有9位。当TLB未命中时,36位的VPN被分为VPN1、VPN2、VPN3、VPN4,每个VPNi被用作到一个页表的偏移量。CR3寄存器包含L1 页表的物理地址,VPN1提供到一个L1 PTE的偏移量,这个PTE包含某个L2页表的基址。VPN2提供到这个L2页表中某个PTE的偏移量,以此类推。最后得到的L4 PTE包含了需要的物理页号,和虚拟地址中的VPO连接起来就得到相应的物理地址。

图7-4 四级页表支持下的线性地址到物理地址的变换

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

当MMU完成了从虚拟地址到物理地址的转换后,就可以使用物理地址进行内存访问了。Intel Core i7使用了三级cache来加速物理内存访问,L1级cache作为L2级cache的缓存,L2级cache作为L3级cache的缓存,而L3级cache作为内存(DRAM)的缓存。

进行物理内存访问时,会首先将物理地址发送给L1级cache,看L1级cache中是否缓存了需要的数据。L1级cache共64组,每组8行,块大小64B。因此将物理地址分为三部分,块偏移6位,组索引6位,剩下的为标记位40位。首先利用组索引位找到相应的组;然后在组中进行行匹配,对于组中的8个行,分别查看有效位并将行的标记位与物理地址的标记位匹配,当标记位匹配且有效位是1时,缓存命中,根据块偏移位可以直接将cache中缓存的数据传送给CPU。如果缓存不命中,需要继续从存储层次结构中的下一层中取出被请求的块,将新块存储在相应组的某个行中,可能会替换某个缓存行。

L1级cache不命中时,会继续向L2级cache发送数据请求。和L1级cache的过程一样,需要进行组索引、行匹配和字选择,将数据传送给L1级cache。同样L2级cache不命中时,会继续向L3级cache发送数据请求。最后,L3级cache不命中时,只能从内存中请求数据了。

值得注意的是,三级cache不仅仅支持数据指令的访问,也支持页表条目的访问,在MMU进行虚拟地址到物理地址的翻译过程中,三级cache也会起作用。

图7-5 三级Cache下的物理内存访问

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。内核给新进程创建虚拟内存,创建当前进程的mm_struct、区域结构和页表的原样副本,将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,为每个进程保持了私有地址空间的抽象概念。同时延迟私有对象中的副本直到最后可能的时刻,充分利用了稀有的物理内存。

7.7 hello进程execve时的内存映射

exceve()函数在当前进程的上下文中加载并运行我们需要的hello程序。execve函数加载并运行可执行文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。

execve函数用hello程序有效替代当前程序,需要以下几个步骤:

(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。

(2)映射私有区域。为新程序(即hello)的代码、数据、bss和栈区域等创建新的区域结构。所有这些区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。

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

(4)设置程序计数器。最后设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。

当内核调度这个进程时,它就将从这个入口点开始执行。Linux根据需要换入代码和数据页面。

图7-6 execve时的内存映射

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

缺页故障的产生:CPU产生一个虚拟地址给MMU,MMU经过一系列步骤获得了相应的PTE,当PTE的有效位未设置时,说明虚拟地址对应的内容还没有缓存在内存中,这时MMU会触发缺页故障。

缺页故障的处理:缺页异常导致控制转移到内核的缺页处理程序。处理程序随后执行以下步骤:(1)判断虚拟地址是否合法。缺页处理程序搜索区域结构的链表,把虚拟地址和每个区域结构中的vm_start和vm_end做比较。如果指令不合法,缺页处理程序会触发一个段错误,从而终止这个进程。(2)判断内存访问是否合法。比如缺页是否由一条试图对只读页面进行写操作的指令造成的。如果访问不合法,缺页处理程序会触发一个保护异常,从而终止这个进程。(3)这时,内核知道缺页是由合法的操作造成的。内核会选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。处理程序返回时,CPU重新执行引起缺页的指令,这条指令将再次发送给MMU。这次,MMU能正常地进行地址翻译,不会再产生缺页中断了。

图7-7 缺页中断处理

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但不失通用性,假设堆是一个请求二进制零的区域,紧接在未初始化数据区域后开始,向上生长。对每个进程,内核维护一个全局变量brk指向堆顶。分配器将堆视为一组不同大小的块的集合来维护。每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留,供应用程序使用;空闲块可用来分配。空闲块保持空闲,直到空闲块显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的(即显式分配器),要么是内存分配器自身隐式执行的(即隐式分配器)。显式分配器和隐式分配器是动态内存分配器的两种基本风格。两种风格都要求应用显式地分配块,不同之处在于由哪个实体来负责释放已分配的块。显式分配器要求应用显式地释放任何已分配的块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。

图7-8 动态内存分配的区域——堆

显式分配器必须在一些约束条件下工作:处理任意请求序列;立即响应请求;只使用堆;对齐要求;不修改已分配的块。在这些限制条件下,分配器试图实现吞吐率最大化和内存使用率最大化,但这两个性能目标通常是相互冲突的。

分配器的具体操作过程以及相应策略:

(1)放置已分配块:当一个应用请求一个k字节的块时,分配器搜索空闲链表。查找一个足够大可以放置所请求的空闲块。执行这种搜索的常见策略包括首次适配、下一次适配和最佳适配等。

(2)分割空闲块:一旦分配器找到了匹配的空闲块,需要决定分配这个空闲块中多少空间。可以选择用整个块,但会造成额外的内部碎片;也可以选择将空闲块分割为两部分,第一部分变成已分配块,剩下的变成新的空闲块。

(3)获取额外的堆内存:如果分配器不能为请求块找到空闲块,分配器通过调用sbrk函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插到空闲链表中,然后被请求的块放在这个新的空闲块中。

(4)合并空闲块:分配器释放一个已分配块时,要合并相邻的空闲块。分配器决定何时执行合并,可以选择立即合并或者推迟合并。合并时需要合并当前块和前面以及后面的空闲块。

组织空闲块的形式有很多,包括隐式空闲链表、显式空闲链表、分离的空闲链表等等。

带边界标签的隐式空闲链表分配器:一个块由一个字的头部、有效载荷、可能的一些额外的填充以及一个脚部。头部位于块的开始,编码了这个块的大小(包括头部、脚部和所有的填充)以及这个块是已分配的还是空闲的。由于对齐要求,头部的高位可以编码块的大小,而剩余的几位(取决于对齐要求)总是零,可以编码其他信息。使用最低位作为已分配位,指明这个块是已分配的还是空闲的。脚部位于每个块的结尾,是头部的一个副本,是为了方便释放块时的合并操作。头部后面就是调用分配器时请求的有效载荷,有效载荷后面是一片不使用的填充块,其大小可以是任意的。填充的原因取决于分配器的策略。如果块的格式是如上所述,就可以将堆组织成一个连续的已分配块和空闲块的序列,这种结构为隐式空闲链表。空闲块通过头部的大小字段隐含地连接,可以通过遍历堆中所有的块间接遍历整个空闲块的集合。同时,需要一个特殊标记的结束块(设置分配位而大小为零的头部),这种设置简化了空闲块合并。

图7-9 隐式链表的块结构

显式空间链表:已分配块的块结构和隐式链表的相同,由一个字的头部、有效载荷、可能的一些额外的填充以及一个脚部组成。而在每个空闲块中,增加了一个前驱指针和后继指针。通过这些指针,可以将空闲块组织成一个双向链表。空闲链表中块的排序策略包括后进先出顺序、按照地址顺序维护、按照块的大小顺序维护等。显式空闲链表降低了放置已分配块的时间,但空闲块必须足够大,以包含所需要的指针、头部和脚部,这导致了更大的最小块大小,潜在提高内部碎片程度。

图7-10 显式链表的块结构

而malloc采用的是分离的空闲链表。分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小升序排列,当分配器需要一个大小为n的块时,就搜索相应大小类对应的空闲链表。如果不能找到合适的块,就搜索下一个链表,以此例推。

7.10本章小结

本章总结了hello运行过程中有关内存管理的内容。简述了TLB、多级页表支持下的地址翻译、cache支持下的内存访问、缺页的处理、fork+execve过程的内存映射以及动态存储分配的过程。

(第7 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

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

B0,B1 ,B2……Bm-1

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

设备的模型化:文件

设备管理:unix io接口

8.2 简述Unix IO接口及其函数

Unix IO 接口,使得所有的输入和输出都能以一种统一且一致的方式来执行:

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

(2)Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件中的常量可以代替显式的描述符值。

(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)进程通过调用open函数打开一个存在的文件或者创建一个新文件。

int open(char* filename,int flags,mode_t mode);

open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。

(2)进程通过调用close函数关闭一个打开的文件。

int closefd;

fd是需要关闭的文件描述符,成功返回0,错误返回-1。关闭一个已关闭的描述符会出错。

(3)应用程序通过分别调用read和write函数来执行输入和输出。

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

ssize_t wirte(int fd,const void *buf,size_t n);

read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则返回值表示的是实际传送的字节数量。write函数从内存位置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;

}

printf函数是格式化输出函数, 一般用于向标准输出设备按规定格式输出信息。printf中调用了两个函数,分别为vsprintf和write。

vsprintf函数根据格式串fmt,并结合args参数产生格式化之后的字符串结果保存在buf中,并返回结果字符串的长度。

write函数将buf中的i个字符写到终端,由于i保存的是结果字符串的长度,因此write将格式化后的字符串结果写到终端。

字符显示驱动子程序:从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函数会从stdin输入流中读入一个字符。调用getchar时,会等待用户输入,输入回车后,输入的字符会存放在缓冲区中。第一次调用getchar时,需要从键盘输入,但如果输入了多个字符,之后的getchar会直接从缓冲区中读取字符。getchar的返回值是读取字符的ASCII码,若出错则返回-1。

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

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

8.5本章小结

本章介绍了Linux的IO设备管理方法和Unix IO接口及其函数,并分析了printf和getchar函数的实现。

(第81分)

 

结论

hello所经历的过程:

源程序:在文本编辑器或IDE中编写C语言代码,得到最初的hello.c源程序。

预处理:预处理器解析宏定义、文件包含、条件编译等,生成ASCII码的中间文件hello.i。

编译:编译器将C语言代码翻译成汇编指令,生成一个ASCII汇编语言文件hello.s。

汇编:汇编器将汇编指令翻译成机器语言,并生成重定位信息,生成可重定位目标文件hello.o。

链接:链接器进行符号解析、重定位、动态链接等创建一个可执行目标文件hello。此时,hello才真正地可以被执行。

fork创建进程:在shell中运行hello程序时,shell会调用fork函数创建子进程,供之后hello程序的运行。

execve加载程序:子进程中调用execve函数,加载hello程序,进入hello的程序入口点,hello终于要开始运行了。

运行阶段:内核负责调度进程,并对可能产生的异常及信号进行处理。MMU、TLB、多级页表、cache、DRAM内存、动态内存分配器相互协作,共同完成内存的管理。Unix I/O使得程序与文件进行交互。

终止:hello进程运行结束,shell负责回收终止的hello进程,内核删除为hello进程创建的所有数据结构。hello的一生到此结束,没有留下一丝痕迹。

对计算机系统的设计与实现的深切感悟:

hello从诞生到结束,经历了千辛万苦,在硬件、操作系统、软件的相互协作配合下,终于完美地完成了它的使命。这让我认识到,一个复杂的系统需要多方面的协作配合才能更好地实现功能。同时,计算机系统提供的一系列抽象使得实际应用与具体实现相互分离,可以很好地隐藏实现的复杂性,降低了程序员的负担,使得程序更加容易地编写、分析、运行。这让我认识到抽象是十分重要的,是计算机科学中最为重要的概念之一。

(结论0分,缺失 -1分,根据内容酌情加分)

 

附件

hello.i:C预处理器产生的一个ASCII码的中间文件,用于分析预处理过程。

hello.s:C编译器产生的一个ASCII汇编语言文件,用于分析编译的过程。

hello.o:汇编器产生的可重定位目标程序,用于分析汇编的过程。

hello:链接器产生的可执行目标文件,用于分析链接的过程。

hello.txt:hello.o的反汇编文件,用于分析可重定位目标文件hello.o。

hellold.txt:hello的反汇编文件,用于分析可执行目标文件hello。

helloelf.txt:hello.o的ELF格式,用于分析可重定位目标文件hello.o。

helloldelf.txt:hello的ELF格式,用于分析可执行目标文件hello。

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

 

参考文献

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

[1]  RANDALE.BRYANT, DAVIDR.O‘HALLARON. 深入理解计算机系统[M]. 机械工业出版社, 2011.

[2]  https://www.cnblogs.com/clover-toeic/p/3851102.html

[3]  https://www.runoob.com/linux/linux-comm-pstree.html

[4]  https://www.runoob.com/cprogramming/c-function-vsprintf.html

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

(参考文献0分,缺失 -1分)

标签:...,文件,指令,Hello,地址,哈工大,P2P,进程,hello
来源: https://blog.csdn.net/lxcshax/article/details/117875520

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有