计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机学院
学 号 xxxxxxxxxx
班 级 xxxxxxx
学 生 xxx
指 导 教 师 xx
计算机科学与技术学院
2021年5月
摘 要
本文通过陪伴hello程序在Linux系统下走完它平凡却又伟大的一生,探讨hello源程序的预处理、编译、汇编、链接、生成可执行文件并运行的主要过程。同时说明系统是如何实现对hello程序进行进程管理,存储管理和I/O管理。
关键词:预处理;编译;汇编;链接;进程;存储;I/0;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第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:P2P指的是hello从一个源程序(program)变为一个在计算机上运行的进程(process)。
- 我们利用基本的C语言语法在编辑器中完成代码的编写,并将其保存在电脑的磁盘中,hello的program就产生了。
- 接下来,我们将hello的源程序hello.c经过cpp进行预处理得到hello.i文件,再经过cc1进行编译得到hello.s的汇编语言文件,然后经过as进行汇编变为可重定位目标文件hello.o,最后经过ld进行链接生成可执行目标文件hello。
- 然后用户通过在shell中输入./hello的命令,shell解析并执行命令,通过fork函数创建新的子进程,然后在子进程中执行execve函数加载并执行我们的hello。
图1:hello从源程序变为可执行目标程序
020:020指的是hello程序从无到有,再从有到无的过程。
- 我们从“0”开始,利用editor编写程序的源代码,再经过预处理,编译,汇编,链接成为一个可执行目标程序。
- 当我们在shell中输入./hello的命令,shell解析并执行命令,通过fork函数创建新的子进程,然后在子进程中执行execve函数加载并执行我们的hello程序:将hello目标程序中的代码和数据从磁盘“拷贝”到主存,加载器会从开始地址开始,进入主函数执行目标代码,处理器(CPU)则会为我们的程序分配时间片和逻辑控制流,我们的程序开始执行,输出的内容会从主存复制到寄存器中,再从寄存器复制到显示设备上,最终会在屏幕上呈现。
- 当我们的程序运行结束后,shell父进程会回收子进程,清理掉程序运行时所占用的内存,关闭程序打开的文件描述符,释放程序运行占用的物理资源,清理程序在内核中的数据结构等,操作系统通过上下文切换,恢复shell的上下文,将控制权重新还给shell,我们的程序有回归为“0”。
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
开发/调试工具:Visual Studio 2010 64位以上;CodeBlocks 64位;vi/vim/gedit+gcc
1.3 中间结果列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
| 文件的作用 | 文件名 |
| 预处理后的文件 | hello.i |
| 编译之后的汇编文件 | hello.s |
| 汇编之后的可重定位目标文件 | hello.o |
| 链接之后的可执行目标文件 | hello |
| Hello.o 的 ELF 格式 | hello1.txt |
| Hello.o 的反汇编代码 | hello1_objdump.txt |
| hello的ELF 格式 | hello2.txt |
| hello 的反汇编代码 | hello2_objdump.txt |
本章对hello进行了总体的介绍,分别对P2P和020的意思进行了解释,并给出了实验进行的软件环境,硬件环境,以及开发/调试工具,最后给出了在实验过程中生成的中间结果文件。
(第1章0.5分)
第2章 预处理 2.1 预处理的概念与作用
预处理的概念:
1.C语言在对源程序进行编译之前,会先对一些特殊的预处理指令作解释(比如之前使用的#include文件包含指令),产生一个新的源程序(这个过程称为编译预处理),之后再进行通常的编译。
2.为了区分预处理指令和一般的C语句,所有预处理指令都以符号"#"开头,并且结尾不用分号。
3.预处理指令可以出现在程序的任何位置,它的作用范围是从它出现的位置到文件尾。习惯上我们尽可能将预处理指令写在源程序开头,这种情况下,它的作用范围就是整个源程序文件。
4.C语言提供的预处理指令主要有:宏定义、文件包含、条件编译:
①.宏定义:#define 指令定义一个宏,#undef 指令删除一个宏定义。
②.文件包含:#include指令导致一个指定文件的内容被包含到程序中。
③.条件编译:#if,#ifdef,#ifndef,,#elif,#else 和#endif 指令可以根据编译器可以测试的条件来将一段文本包含到程序中或排除在程序之外。
剩下的#error,#line和#pragma指令更特殊的指令,较少用到。
预处理的作用:
1. 将源文件中以“include”格式包含的文件复制到编译的源文件中。
2. 用实际值替换用“#define”定义的字符串。
3. 根据“#if”后面的条件决定需要编译的代码。
2.2在Ubuntu下预处理的命令命令:gcc -E hello.c -o hello.i
图2:预处理过程
2.3 Hello的预处理结果解析可以看到在经过预处理指令后得到了预处理过后的文件hello.i
通过查看hello.i中的内容我们可以发现,整个程序文件比原来的hello.c大了很多,这是因为预处理将源文件中以“include”格式包含的文件复制到编译的源文件中,cpp到默认的环境变量下依次寻找我们指定的文件,用实际值替换用“#define”定义的字符串,并根据“#if”后面的条件决定需要编译的代码。
图3:预处理后的hello.i
图4:库中内置函数
在源程序的位置,预处理将注释删除掉,其他保存不变。
图5:hello.i中main函数
2.4 本章小结
本章主要介绍了预处理的概念与实际的作用,并且在Ubuntu环境下对hello.c源程序进行预处理得到预处理文件hello.i,对得到的预处理后的文件进行对比分析,详细地介绍了预处理的具体细节。
(第2章0.5分)
第3章 编译 3.1 编译的概念与作用
编译的概念:
编译器将文本文件 hello.i 翻译成文本文件 hello.s。
编译程序所要做的工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。
编译的作用:
编译将高级语言转化为汇编语言能够检测代码的正确性,并为接下来将汇编语言生成机器可识别的机器码做准备。
编译包括以下基本流程:
- 语法分析:编译程序的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,如表达式、赋值、循环等,最后看是否构成一个符合要求的程序,按该语言使用的语法规则分析检查每条语句是否有正确的逻辑结构,程序是最终的一个语法单位。编译程序的语法规则可用上下文无关文法来刻画。方法分为两种:自上而下分析法和自下而上分析法。
- 中间代码:源程序的一种内部表示,或称中间语言。中间代码的作用是可使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码。
- 代码优化:指对程序进行多种等价变换,使得从变换后的程序出发,能生成更有效的目标代码。有两类优化:一类是对语法分析后的中间代码进行优化,它不依赖于具体的计算机;另一类是在生成目标代码时进行的,它在很大程度上依赖于具体的计算机。对于前一类优化,根据它所涉及的程序范围可分为局部优化、循环优化和全局优化三个不同的级别。
- 目标代码:生成是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码。此处指汇编语言代码,须经过汇编程序汇编后,成为可执行的机器语言代码。
命令:gcc -S hello.i -o hello.s
图6:编译过程
3.3 Hello的编译结果解析3.3.1 文件说明:
图7:文件声明
.file 指明源文件名
.text 代码段:用来存放程序代码的区域
.section 指示将代码分成若干个段
.rodata 用于维护只读数据
.align 8 地址对齐伪指令,用来指定符号的对齐方式
.string 字符串的存储位置
.global 用来让一个符号对链接器可见,可以供其他链接对象模块使用,告诉编译器后续跟的是一个全局可见的名字
.type 指定是对象类型或者函数类型
3.3.2 数据
(1). 常量:
常量例如立即数:
立即数直接出现在代码段之中。
常量例如字符串:
我们可以看到字符串出现在了名为.LC0和.LC1的段中。
.LC0和.LC1部分是⼀个标签.它提供了⼀个符号名称,它引用符号后面出现的字节.在这种情况下,LC1表示字符串“Hello %s %sn”,GNU汇编程序使用标记开头的约定带有“L”的被认为是“本地标签”。在这种情况下,编译器使用它来引用特定目标文件专用的符号。在这种情况下,它表示⼀个字符串常量。
(2). 变量
①全局变量:
初始化的全局变量储存在.data节中,在汇编代码中一般会标注为global。
②局部变量:
局部变量存储在寄存器或栈中。
例如:
在这里我们可以看到在循环体中,局部变量i被初始化为0,经过比较后,决定进入或跳出循环,在循环体中的代码执行结束后,加1。
可以在汇编代码中找到与之相对的汇编代码如下:
可以看到局部变量i被存储在栈中-4(%rbp)的位置。
3.3.3 赋值:
(1). 通过mov来实现赋值:
在这段代码中,汇编语言通过mov立即数的方式来实现对i的赋值。
(2). 通过leal来实现赋值:
还可以通过使用leal指令进行赋值。
3.3.4 算术操作:
在hello.s中所涉及的算术操作有加与减两种,分别使用add,sub指令进行操作。
(1). add
此处将局部变量i(-4(%rbp))的值加1。
(2). sub
此处将栈顶指针减去32,实现了栈幁的增长。
3.3.5 关系操作和控制转移
关系操作与控制转移一般是一起出现的。
例如如下代码:
它所对应的关系操作与控制转移如下:
首先用cmpl指令将立即数7与i进行比较,并产生条件码,然后用jle指令判断cmpl指令产生的条件码,若后一个操作数的值小于等于前一个操作数的值则跳转到指定地址L4,否则进行往下执行下一条指令。
关系操作还出现在if语句的汇编之中,如:
它所对应的关系操作与控制转移如下:
在这里首先用cmpl指令将立即数4与argc进行比较,并产生条件码,然后用je指令判断cmpl指令产生的条件码,若后一个操作数的值等于前一个操作数的值则跳转到指定地址L2,否则进行往下执行下一条指令。
3.3.6 数组/指针/结构操作:
主函数main的参数中有指针数组char *argv[]。
在argv数组中,argv[0]指向输入程序的名称,argv[1]和argv[2]分别指向两个字符串。
C语言代码中的数组访问有argv[1],argv[2],argv[3],在汇编代码中访问这三个量都使用首地址加偏移量的方式。
如图:
3.3.7 函数操作:
利用call指令来调用函数,将函数需要的参数存放在寄存器中或栈中进行参数传递,函数调用结束之后,返回值保存在寄存器%rax中。
hello中出现的函数操作有:
调用puts
puts:将传入的参数输出在屏幕上。
调用exit
exit:从函数中退出。
调用sleep
sleep:实现程序的休眠。
调用getchar
getchar:从缓冲区中读取字符。
3.4 本章小结
本章主要介绍了编译的概念以及过程。同时在Ubuntu环境中对hello.c进行编译处理。然后详细地对汇编代码中的细节进行介绍。
(第3章2分)
第4章 汇编 4.1 汇编的概念与作用
汇编的概念:
汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程,汇编器(as)将.s汇编程序翻译成机器语言并将这些指令打包成可重定目标程序的格式存放在.o目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
汇编的作用:
将汇编语言翻译成机器语言,使其在链接后能够被机器识别并执行。
4.2 在Ubuntu下汇编的命令命令:gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式1. ELF头:
ELF头部以一个16字节的序列开始,描述生成该文件的系统的字的大小和字节顺序。剩下的部分包含帮助链接器分析语法和解释目标文件的信息,其中包含ELF头大小、目标文件的类型、及其类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
2. 节头表:
节头部表描述了不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。具体的描述包括节的名称、类型、地址和偏移量等
3. 重定位条目:
重定位是一种用符号定义连接符号引用的过程。可重定位文件必须拥有描述如何去修改它们section contents的信息,好让可执行或共享目标文件保存程序镜像的正确信息。可重定位条目就是这些数据。
4. 符号表:
存放在程序中定义和引用的函数和全局变量的信息。
4.4 Hello.o的结果解析用指令objdump -d -r hello.o对hello.o进行反汇编。
得到汇编文件如图:
hello.s文件:
对hello.o进行反汇编,与hello.s进行对比分析:
1. 数的表示:hello.s中的操作数时十进制,hello.o反汇编代码中的操作数是十六进制。
2. 分支转移:反汇编代码中跳转语句之后不是.L1等助记符,而是相对偏移的地址。
3. 函数调用:hello.s中,call指令后跟着的是函数名称,而反汇编代码中call指令的目标地址是当前下一条指令。这是因为 hello.c 中调用的函数都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执行地址。在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0,最后在链接时才填入正确的地址。
4.5 本章小结本章介绍了汇编的概念和作用,并通过对hello.s的汇编结果的分析简要讲述了可重定位文件的格式,重点讲述了可重定位文件的ELF头,节头表,符号表和.rela.text节的内容。最后比较了反汇编结果与汇编文件hello.s的区别。
(第4章1分)
第5章 链接 5.1 链接的概念与作用
链接的概念:
链接是将各种不同文件的代码和数据部分收集(符号解析和重定位)起来并组合成一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。
链接的作用:
链接使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。
5.2 在Ubuntu下链接的命令使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
命令:
5.3 可执行目标文件hello的格式1.ELF头信息
hello的ELF头中指出hello的类型为EXEC类型,即hello为可执行目标文件,同时hello的入口地址非零,说明重定位工作已完成。
2. 节头表
hello中节头表的条目数多于hello.o中节头表的条目数。并且这里的每一节都有了实际地址,而不是像在hello.o中那样地址值全为0。这说明重定位工作已完成。同时多出的节是为了能够实现动态链接。
与hello.o的节头部表作对比,可以得出:①hello的节头部表增加了若干条目,上图中一共有26个节的信息,而hello.o的节头部表中只有13个节的信息。②所有节被分配了运行时的地址。可以看到下图中某些节地址有所不同,而hello.o的节头部表中所有节的Address为全0。
3. 程序表
段头表描述了可执行目标文件的连续的片与连续的虚拟内存段之间的映射关系。从段头表中可以看到根据可执行目标文件的内容初始化为两个内存段,分别为只读内存段(代码段)和读写代码段(数据段)。
p_type:此数组元素描述的段类型或解释此数组元素的信息的方式。
p_offset:相对段的第一个字节所在文件的起始位置的偏移。
p_vaddr:段的第一个字节在内存中的虚拟地址。
p_paddr:段在与物理寻址相关的系统中的物理地址。由于此系统忽略了应用程序的物理地址,因此该成员对于可执行文件和共享目标文件具有未指定的内容。
p_filesz:段的文件映像中的字节数,可以为零。
p_memsz:段的内存映像中的字节数,可以为零。
p_flags:与段相关的标志。表 13-2 中指定了类型值及其含义。
p_align:可装入的进程段必须具有 p_vaddr 和 p_offset 的同余值(以页面大小为模数)。此成员可提供一个值,用于在内存和文件中根据该值对齐各段。值 0 和 1 表示无需对齐。另外,p_align 应为 2 的正整数幂,并且 p_vaddr 应等于 p_offset(以 p_align 为模数)。
4. 符号表
描述了程序中定义和引用的函数和全局变量的信息。
可以看到,可执行目标文件的符号表表项数目(51 entries)明显多于可重定位目标文件的表项数目(18 entries)。一方面,可执行目标文件中加入了与调试、加载、动态链接相关的节,使得表示节的符号数增多;另一方面,由于链接器对可重定位目标文件中的符号进行了进一步解析,加入了若干系统调用。
5.4 hello的虚拟地址空间从程序头中可以获取各个区域所对应的物理空间
PHDR:保存程序头表
INTERP:动态链接器的路径
LOAD:可加载的程序段
DYNAMIN:保存了由动态链接器使用的信息
NOTE保存辅助信息
GNU_STACK:标志栈是否可执行
GNU_RELRO:指定重定位后需被设置成只读的内存区域
例如:
从ELF头表中可以看出程序的入口地址为0x4010f0,对应着_start函数的地址。
那么可以在edb的Data Dump中查看具体的内容。
图 :edb下hello程序的入口地址
5.5 链接的重定位过程分析
命令:objdump -d -r hello > hello2.txt
图 :生成hello2.txt
我们可以看到文件中相较于之前的汇编代码,多了许多函数,这是因为链接的过程中将头文件链接到了可执行文件之中,如exit、printf、sleep、getchar等函数。
图 :
hello中增加了.init和.plt节,和一些节中定义的函数。
然后观察main函数,我们不难发现在hello.o反汇编代码中出现以main加上相对偏移的跳转已经全部被重写计算,这是因为在重定位后main函数有了全新的地址,使得这个计算成为可能。同时对子函数的call引用也在重定位后重写计算来了。
下面给出重定位引用的计算方法
- .重定位PC相对引用
- 首先判断寻址方式
看反汇编代码的5和7行,R_X86_64_PC32就是重定位PC相对引用,则对应重定位算法第5~9行。
- 读代码,将对应的数据附上值。
算法代码中(第5~9行):
refaddr:引用的运行时地址(需要算)
ADDR(s):符号地址(题目给出)
r.offset:偏移量(重定位入口)
*refptr:更新该引用,运行时指向sum程序(需要算)
ADDR(r.symbol):机器分配的 (题目给出)
r.addend:在反汇编代码里可读出
反汇编代码中(第7行)
r.offset = 0xf
r.symbol = sum
r.type = R_X86_64_PC32
r.addend = -4(注意符号)
3. 带入计算公式
假设链接器已经确定:
ADDR(s) = 0x4004d0
ADDR(r.symbol) = ADDR(sum) = 0x4004e8
则有
refaddr = ADDR(s) + r.offset = 0x4004d0 + 0xf = 0x4004df
*refptr = (unsigned)(ADDR(r.symbol) + r.addend - refaddr)
= (unsigned)(0x4004e8 + (-4) - 0x4004df)
= (unsigned)(0x5)
- .重定位绝对引用
- 首先判断寻址方式
看反汇编代码的5和7行,R_X86_64_32就是重定位绝对引用,则对应重定位算法第11~13行。
- 读代码,将对应的数据附上值。
算法代码中(第11~13行):
*refptr:更新该引用,运行时指向array程序(需要算)
ADDR(r.symbol):机器分配的 (题目给出)
r.addend:在反汇编代码里可读出
反汇编代码中(第5行)
r.offset = 0xa
r.symbol = array
r.type = R_X86_64_32
r.addend = 0
3. 带入计算公式
假设链接器已经确定:
ADDR(r.symbol) = ADDR(array) = 0x601018
则有
*refptr = (unsigned)(ADDR(r.symbol) + r.addend)
= (unsigned)(0x601018 + 0)
= (unsigned)(0x601018)
综上所述,重定位的大体过程是链接器ld将所有链接文件中相同的节合并,并按照要求计算新的偏移地址赋值给新的节。同时链接器按链接指令的顺序搜索符号表,查找符号引用。
5.6 hello的执行流程使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
(1) 载入:_dl_start、_dl_init
(2) 开始执行:_start、_libc_start_main
(3) 执行main:_main、_printf、_exit、_sleep、_getchar、_dl_runtime_resolve_xsave、_dl_fixup、_dl_lookup_symbol_x
(4) 退出:exit
5.7 Hello的动态链接分析在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件hello。此时共享库中的代码和数据没有被合并到hello中。加载hello时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件。
动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射。使用偏移量表GOT+过程链接表PLT实现函数的动态链接。GOT中存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用。
图 :edb执行init前
图 :edb执行init后
对于变量而言,我们利用代码段和数据段的相对位置不变的原则计算正确地址。对于库函数而言,需要plt、got合作,plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。plt就能跳转到正确的区域。
5.8 本章小结概括了链接的概念和作用,重点分析了hello程序的虚拟地址空间、重定位和执行过程。简述了动态链接的原理。
(第5章1分)
第6章 hello进程管理 6.1 进程的概念与作用
1. 进程的概念:
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。
2. 进程的作用:
进程为用户提供以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程作用:是一种交互型的应用级程序,时Linux的外壳,提供了一个界面,用户可以通过这界面访问操作系统内核。
处理流程:
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
(3)检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令
(4)如果不是内部命令,调用fork( )创建新进程/子进程
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(6)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait…等待作业终止后返回。
(7)如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程当在shell上输入./hello命令时,命令行会首先判断该命令是否为内置命令,如果是内置命令则立即对其进行解释。否则将其看成一个可执行目标文件,再调用fork创建一个新进程并在其中执行。
当shell运行一个程序时,父进程通过fork函数生成这个程序的进程。新创建的子进程几乎但不完全与父进程相同,包括代码、数据段、堆、共享库以及用户栈。父进程和新创建的子进程之间最大的区别在于他们有不同的PID。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令
execve函数加载并允许可执行目标文件hello,并带参数列表argv和环境变量列表envp。execve只有当出现错误时才会返回到调用程序,因此execve调用一次从不返回。
利用虚拟内存部分的知识,我们还能知道execve在运行时需要以下几个步骤:
- 删除已经存在的用户区域。
- 映射私有区域。
- 映射共享区域。
4. 设置程序计数器。
6.5 Hello的进程执行逻辑控制流:
一系列程序计数器 PC 的值的序列叫做逻辑控制流。由于进程是轮流使用处理器的,同一个处理器每个进程执行它的流的一部分后被抢占,然后轮到其他进程。
用户模式和内核模式:
处理器使用一个寄存器提供两种模式的区分。用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据;内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文:
上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。
示例:sleep进程的调度过程
图 : 进程上下文切换
初始时,控制流再hello内,处于用户模式
调用系统函数sleep后,进入内核态,此时间片停止。
2s后,发送中断信号,转回用户模式,继续执行指令。
调度的过程:
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
以执行sleep函数为例,sleep函数请求调用休眠进程,sleep将内核抢占,进入倒计时,当倒计时结束后,hello程序重新抢占内核,继续执行。
用户态与核心态转换:
为了能让处理器安全运行,不至于损坏操作系统,必然需要先知应用程序可执行指令所能访问的地址空间范围。因此,就存在了用户态与核心态的划分,核心态可以说是“创世模式”,拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。
6.6 hello的异常与信号处理hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
异常的类别:
- 当在中途乱按,会将输入当做是命令缓存到缓冲区中,在程序执行结束后执行命令。
图 :运行过程中乱按
- 当按下Ctrl+C时,进程会收到SIGINT信号,结束hello程序,使用ps查看进程,发现进程被彻底结束。
图 :Ctrl+C
- 当按下Ctrl+Z时,进程收到SIGSTP信号 ,hello程序被挂起,当使用ps查看进程时,可以看到进程仍然存在,通过fg加job号可以恢复执行。
图 :Ctrl+Z
- 使用Kill可以将挂起的进程被终止掉。
图 :kill
- 使用pstree指令可以看到进程树。
图 :pstree
6.7本章小结本章阐述进程的定义与作用,同时介绍了 Shell 的一般处理流程和作用,并且着重分析了调用 fork 创建新进程,调用 execve函数 执行 hello,hello的进程执行,以及hello 的异常与信号处理。
(第6章1分)
第7章 hello的存储管理 7.1 hello的存储器地址空间
1. 逻辑地址:
在hello反汇编代码中我们能够看到的就是逻辑地址,逻辑地址用来指定一个操作数或者是一条指令的地址,是由一个段标识符加上一个指定段内相对地址的偏移量。
2. 线性地址:
逻辑地址向物理地址转化过程中的一步,逻辑地址经过段机制后转化为线性地址。与实地址模式下的分段地址类似,虚拟地址也可以写为“段:偏移量”的形式,这里的段是指段选择器。Hello反汇编的地址即为虚拟地址。
3. 虚拟地址:
保护模式下程序访问存储器所用的逻辑地址。
4. 物理地址:
加载到内存地址寄存器中的地址,内存单元的真正地址。CPU通过地址总线的寻址,找到真实的物理内存对应地址。在前端总线上传输的内存地址都是物理内存地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理逻辑地址由段标识符与段内偏移量组成。段标识符是一个16位长的字段组成,称为段选择符,其中前13位是一个索引号。后面三位包含一些硬件细节。
索引号就是“段描述符(segment descriptor)”的索引,段描述符具体地址描述了一个段,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,就可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。
Base字段,表示的是包含段的首字节的线性地址,也就是一个段的开始位置的线性地址。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。什么时候该用GDT,什么时候该用LDT是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT,GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。
7.3 Hello的线性地址到物理地址的变换-页式管理线性地址到物理地址的转换是通过分页完成的。首先CPU通过生成一个虚拟地址来访问主存,这个虚拟地址被送到内存之前首先转换为物理地址,CPU上的内存管理单元(MMU)利用主存中的查询表来动态翻译虚拟地址。
虚拟地址转换为物理地址时需要检查页表中虚拟页是否缓存在物理内存中,若不缓存在物理内存,则查询该虚拟页在磁盘的位置。页表就是页表条目的数组,页表条目由有效位和地址字段组成。这样就建立了页表到虚拟内存或物理内存的映射。
线性地址即虚拟地址,用VA来表示。VA被分为虚拟页号(VPN)与虚拟页偏移量(VPO),CPU取出虚拟页号,通过页表基址寄存器来定位页表条目,在有效位为1时,从页表条目中取出信息物理页号(PPN),通过将物理页号与虚拟页偏移量(VPO)结合,得到由物理地址(PPN)和物理页偏移量(PPO)组合的物理地址。
图 :页式管理
7.4 TLB与四级页表支持下的VA到PA的变换Core i7采用四级页表的层次结构。CPU产生VA,VA传送给MMU,MMU使用VPN高位作为TLBT和TLBI向TLB中寻找匹配。如果命中,则得到PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,以此类推,最终在第四级页表中找到PPN,与VPO组合成PA,添加到PLT。
7.5 三级Cache支持下的物理内存访问1. 先选择虚拟地址的组索引位,找到对应的组。再使用行匹配将虚拟地址的标记和组中各行的标记位比较。命中的条件是高速缓存行有效且标记位匹配。
2. 高速缓存命中后,使用块偏移找到需要字节的块内偏移位置,将其取出返回给CPU。
3. 若产生了不命中的现象,那么需要从L2中寻找所请求的块,若找到就将其存储在cache的一行中。一般使用最近最少使用的替换策略进行替换。若没找到还需要到L3,主存中重复以上过程。
7.6 hello进程fork时的内存映射当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。
它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射1)在bash中的进程中执行了如下的execve调用:execve("hello",NULL,NULL);
2)execve函数在当前进程中加载并运行包含在可执行文件hello中的程序,用hello替代了当前bash中的程序。
下面是加载并运行hello的几个步骤:
3)删除已存在的用户区域。
4)映射私有区域
5)映射共享区域
6)设置程序计数器(PC)
exceve做的最后一件事是设置当前进程的上下文中的程序计数器,是指指向代码区域的入口点。而下一次调度这个进程时,他将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:
1)虚拟地址A是合法的吗?即A在某个区域结构定义的区域内吗?为了回答这个问题,缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。这个情况在下图中标识为“1”。
因为一个进程可以创建任意数量的新虚拟内存区域,所以顺序搜索区域结构的链表花销可能会很大。因此在实际中,Linux 使用某些我们没有显示出来的字段,Linux 在链表中构建了一棵树,并在这棵树上进行查找。
2)试图进行的内存访问是否合法?换句话说,进程是否有读、写或者执行这个区域内页面的权限?例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。这种情况在下图中标识为“2”。
3)此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
7.9动态存储分配管理动态内存分配器维护着一个进程的虚拟内存区域,称为堆。对于每个进程,内核维护一个变量brk,它指向堆的顶部。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。例如malloc。
隐式分配器:也叫做垃圾收集器,要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
堆中的块主要组织为两种形式:
1.隐式空闲链表(带边界标记)
对比于显式空闲链表,代表并不直接对空闲块进行链接,而是将对内存空间中的所有块组织成一个大链表,在块的首尾的四个字节分别添加header和footer,负责维护当前块的信息(大小和是否分配)。由于每个块是对齐的,所以每个块的地址低位总是0,可以用该位标注当前块是否已经分配。可以利用header和footer中存放的块大小寻找当前块两侧的邻接块,方便进行空闲块的合并操作。优点是简单,缺点是搜索所需时间与堆中以分配块和空闲块的总数成线性关系。
2.显式空闲链表
堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。分别指向前一个空闲块和后一个空闲块。采用该策略,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。将空闲块组织成链表形式的数据结构。
7.10本章小结本小节讨论了存储空间的管理,首先解释了四种地址的概念,逻辑地址、线性地址、虚拟地址、物理地址,接着描述了逻辑地址到线性地址再到物理地址的转换过程。再用TLB和四级页表的实例描述了va到pa的转变过程,说明了如何使用物理内存访问cache。在将进程使用到的fork和execve的内存映射情况加以描述。
(第7章 2分)
第8章 hello的IO管理 8.1 Linux的IO设备管理方法
(以下格式自行编排,编辑时删除)
设备的模型化:文件
设备管理:unix io接口
设备的模型化
文件(所有的I/O设备都被模型化为文件,甚至内核也被映射为文件)
设备管理
unix io接口
这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。
我们可以对文件的操作有:打开关闭操作open和close;读写操作read和write;改变当前文件位置lseek等
8.2 简述Unix IO接口及其函数(以下格式自行编排,编辑时删除)
Unix IO接口:
- 打开文件:内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。
- Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件
定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。 - 改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
- 读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k
- 关闭文件:当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中
Unix IO函数:
1. open()函数
功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。
函数原型:int open(const char *pathname,int flags,int perms)
参数:pathname:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式,
返回值:成功:返回文件描述符;失败:返回-1
2. close()函数
功能描述:用于关闭一个被打开的的文件
所需头文件: #include
函数原型:int close(int fd)
参数:fd文件描述符
函数返回值:0成功,-1出错
3. read()函数
功能描述: 从文件读取数据。
所需头文件: #include
函数原型:ssize_t read(int fd, void *buf, size_t count);
参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。
返回值:返回所读取的字节数;0(读到EOF);-1(出错)。
4. write()函数
功能描述: 向文件写入数据。
所需头文件: #include
函数原型:ssize_t write(int fd, void *buf, size_t count);
返回值:写入文件的字节数(成功);-1(出错)
5. lseek()函数
功能描述: 用于在指定的文件描述符中将将文件指针定位到相应位置。
所需头文件:#include
函数原型:off_t lseek(int fd, off_t offset,int whence);
参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)
返回值:成功:返回当前位移;失败:返回-1
8.3 printf的实现分析从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
printf函数:
int printf(const char *fmt, ...)
{
int i;
va_list arg = (va_list)((char *)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
所引用的vsprintf函数
int vsprintf(char *buf, const char *fmt, va_list args)
{
char *p;
chartmp[256];
va_listp_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函数将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
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的底层实现是通过系统函数read实现的。getchar通过read函数从缓冲区中读入一行,并返回读入的第一个字符,若读入失败则返回EOF。read的具体实现如下:
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。
8.5本章小结本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
hello所经历的过程:
- hello.c经过预编译,拓展得到hello.i文本文件
- hello.i经过编译,得到汇编代码hello.s汇编文件
- hello.s经过汇编,得到二进制可重定位目标文件hello.o
- hello.o经过链接,生成了可执行文件hello
- bash进程调用fork函数,生成子进程;并由execve函数加载运行当前进程的上下文中加载并运行新程序hello
- hello的变化过程中,会有各种地址,但最终我们真正期待的是PA物理地址。
- hello再运行时会调用一些函数,比如printf函数,这些函数与linux I/O的设备模拟化密切相关
- hello最终被shell父进程回收,内核会收回为其创建的所有信息
即使是这样一个简单的程序,也需要硬件、操作系统、软件的相互协作配合,才能最终让它完美地实现自己的功能,小小的hello却包含着计算机的浩瀚宇宙,值得我们去探索。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
列出所有的中间产物的文件名,并予以说明起作用。
(附件0分,缺失 -1分)
| 文件的作用 | 文件名 |
| 预处理后的文件 | hello.i |
| 编译之后的汇编文件 | hello.s |
| 汇编之后的可重定位目标文件 | hello.o |
| 链接之后的可执行目标文件 | hello |
| Hello.o 的 ELF 格式 | hello1.txt |
| Hello.o 的反汇编代码 | hello1_objdump.txt |
| hello的ELF 格式 | hello2.txt |
| hello 的反汇编代码 | hello2_objdump.txt |
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] https://blog.csdn.net/u013728021/article/details/92835300:C语言预处理指令简介
[2] 编译_百度百科 :百度百科:编译
[3] 在shell或bash执行一个bin文件或者脚本的流程_痒痒挠963的博客-CSDN博客_shell脚本执行bin :在shell或bash执行一个bin文件或者脚本的流程
[4] UNIX IO 简介_chadm的博客-CSDN博客 :UNIX IO简介
[5] 计算机系统基础 链接器的重定位_jiyong_的博客-CSDN博客 :链接器重定位
(参考文献0分,缺失 -1分)



