计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机类
学 号 120L021311
班 级 2003011
学 生 郭俊诚
指 导 教 师 郑贵滨
计算机科学与技术学院
2021年5月
HelloWorld程序蕴含着无数计算机系统机制设计者的思想精华。
在编译源文件的过程中,gcc通过调用cpp/cc1/as/ld,将C语言源文件进行预处理、编译、汇编、链接,最终形成可执行目标文件hello,由存储器保存在磁盘中。运行进程时,操作系统为其分配虚拟地址空间,提供异常控制流等强大的工具,Unix I/O为其提供与程序员和系统文件交互的方式。本文通过分析Hello程序从代码编辑器到运行进程的过程,对计算机系统编译源文件、运行进程等机制进行较深入的分析和介绍。
关键词:计算机系统;操作系统;进程;Linux
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
6.2 简述壳Shell-bash的作用与处理流程... - 28 -
6.3 Hello的fork进程创建过程... - 28 -
7.2 Intel逻辑地址到线性地址的变换-段式管理... - 36 -
7.3 Hello的线性地址到物理地址的变换-页式管理... - 38 -
7.4 TLB与四级页表支持下的VA到PA的变换... - 39 -
7.5 三级Cache支持下的物理内存访问... - 40 -
7.6 hello进程fork时的内存映射... - 41 -
7.7 hello进程execve时的内存映射... - 41 -
第1章 概述
1.1 Hello简介
在编译源文件的过程中,gcc通过调用预处理器(cpp)、编译器(cc1)、汇编器(as)、链接器(ld),对hello.c源文件进行预处理、编译、汇编、链接等一系列流程,形成可执行目标文件hello。在运行时,在shell中输入启动命令后,通过fork产生子进程,hello就从程序变为了一个进程,这就是P2P的过程。
在此之后,通过execve运行进程,操作系统为其分配虚拟地址空间,载入物理内存,进入main中执行。当程序从main返回时,程序终止,shell作为父进程回收子进程,操作系统内核清除相关数据结构,释放hello占用的资源。这就是O2O的过程。
1.2 环境与工具
硬件环境:x64CPU; 2.1GHz; 8G RAM; 512GHD Disk
软件环境:Windows10; Virtualbox; Ubuntu20.04;
开发工具:Visual Studio 2022 64位; CodeBlocks 64位; vim/gedit+gcc
1.3 中间结果
文件名字 | 作用 |
hello.i | 预处理后的文件 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
helloelf.txt | hello.o的ELF格式文件 |
hellodump.txt | hello.o的反汇编代码 |
hello | 链接后生成的可执行目标文件 |
elf.txt | hello的ELF格式文件 |
dump.txt | hello的反汇编代码 |
1.4 本章小结
本章简要介绍了hello的一生,即P2P和O2O的过程。介绍了完成大作业使用计算机的硬件配置、软件环境以及所使用的开发工具,列出了完成本论文生成的中间文件和每一个文件的作用。论文接下来将详细描述本章简介中的过程。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理指的是程序在编译之前进行的处理,是计算机在处理一个程序时所进行的第一步处理,可以进行代码文本的替换工作,但是不做语法检查。预处理是为了后续编译做的准备工作,能够处理.c文件中以#开头的内容,如:宏定义#define、包含头文件#include、条件编译#ifdef等。预处理后会保存修改之后的文本,生成.i文件。
其中红线圈出的部分即为预处理阶段需要处理的语句。
2.1.2预处理的作用
在预处理的过程中,计算机利用预处理器(cpp)进行处理。而预处理主要有3个方面的内容,分别是根据字符“#”后所跟的具体语句进行不同处理。它们分别为宏定义、文件包含、条件编译。
宏定义(#define)在预处理过程中会进行宏替换,又称宏展开。其中不带参数的宏定义会在预处理阶段直接用实际值替换#define的数值或字符串;带参数的宏定义则需要同时进行实际值的代换和参数的代换。
文件包含在处理阶段可以扩展源代码,插入指定的文件。# include指令能够告诉预处理器读取源程序中所引用的系统的源文件,并且将这一段代码直接插入到程序文件中,最后存储到.i文件中。在hello.c中,预处理阶段处理的文件包含命令为在上图以红色笔迹圈出的命令。
条件编译指的是针对#ifdef等语句进行的处理。条件编译能够根据#i f ifif的不同条件决定需要进行编译的代码,#endif是结束这些语句的标志。使用条件编译可以根据后面的条件决定需要编译的代码,从而实现使目标程序变小的目标。
2.2在Ubuntu下预处理的命令
指令为Linux>cpp hello.c > hello.i
可以看到执行该指令之后生成了预处理之后的文本文件hello.i。
2.3 Hello的预处理结果解析
预处理之后我们对比观察hello.c和hello.i的属性:
可以看出hello.i的大小远远大于hello.c的源代码文件。
接下来我们观察hello.i中内部的内容:
可以看到hello.i将最初的只有二十多行的源代码文件扩展到了三千多行,添加了很多代码。main函数中主体的内容并没有很大的变化,增加的部分主要是处理stdio.h,stdlib.h和unistd.h中的内容并对其包含的头文件的内容进行展开。最后的hello.i中包含的内容有命令行参数、对头文件中使用的数据类型的说明等,但已经不再包含宏定义、文件包含、条件编译等内容。
2.4 本章小结
在本章中,了解了预处理的概念与作用,并以此为基础,结合具体的hello.c文件,尝试对其进行了预处理操作,并通过对比观察hello.c与生成的hello.i的不同,理解了预处理阶段会对程序产生什么样的变化,通过对比了解了hello.i文件中对hello.c中的哪部分进行了展开,在实践的基础上加深了对预处理阶段的理解。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译器内部可以分为分析和整合两个部分,其中分析过程把源程序分为多个结构分别对程序进行校验,同时还会收集源程序的信息存储到一个叫符号表的数据结构中;整合过程则根据分析过程传递的信息来构造用户期待的目标汇编语言程序。分析部分包括词法分析、语法分析、语义分析、中间代码生成等,并在一系列优化后生成目标汇编代码。编译是由编译器(ccl)将文本文件hello.i翻译成hello.s这一汇编语言程序的过程。
3.1.2编译的作用
将高级语言程序转化为机器可直接识别处理执行的的机器码,其中经历了词法分析、语法分析、语义分析、代码优化等一系列过程。
词法分析:词法分析是编译器的第一个步骤,它也被称为扫描(scanning)。词法分析器通过读取源程序的字符流对其进行扫描,并且把它们组成有意义的词素序列,并产生词法单元(token)作为输出,传递给语法分析。
语法分析:语法分析又称为解析(parsing)。语法分析器使用词法单元的第一个分量来创建树形的中间表示–语法树(syntax tree)。
语义分析:语义分析是由语义分析器完成的,它使用语法树和符号表中的信息来检查源程序是否和语言定义的语义一致。
3.2 在Ubuntu下编译的命令
指令为:Linux>gcc -Og -S hello.i > hello.s
可以发现在执行完编译命令后,生成了hello.s这一在编译阶段生成的目标文件。
3.3 Hello的编译结果解析
3.3.1数据
1、字符串:存储在rodata段中
字符串还可以作为参数:
2、立即数:整型常数,以$开头
3、寄存器:以%开头
4、内存操作数:指定内存地址开始的连续字节,经常运用内存寻址方式
3.3.2赋值操作
赋值操作主要通过mov指令实现,后面的l或q表示数据为几个字节:b为1字节,w为2字节,l为4字节,q为8字节。
以图中示例展现通过mov指令实现赋值操作,实际上hello.s还有很多mov指令实现的赋值操作,在此不一一展示。
3.3.3算数操作
算数操作分别通过如下指令实现:、
操作 | 指令 | 效果 |
加法操作 | add a,b | b=b+a |
减法操作 | sub a,b | b=b-a |
取数值 | leaq a,b | b=&a |
++/--/+= | add/sub和mov指令结合 | |
乘法操作 | mul a,b | |
除法操作 | div |
此处两例为hello.s中对于加法操作以及减法操作的实现。
3.3.4逻辑/位操作
逻辑操作:
操作 | 指令 |
逻辑与(&&) | and指令 |
逻辑或(||) | or指令 |
逻辑非(!) | not指令 |
异或 | xor指令 |
位操作:
分为左移和右移。左移中算数左移(sal)和逻辑左移(shl)相同,右移中分为算术右移(sar)和逻辑右移(shr),逻辑右移在最高位左侧补零,算数右移在最高位左侧复制最高有效位。
3.3.5关系操作
关系操作只要通过cmp指令实现,以cmp s1,s2为例,将会计算s2-s1的值并根据这一计算结果设置条件值寄存器的值,从而来判断s1和s2的关系。
此外还可以通过关系操作指令和跳转指令结合实现程序流程的控制,这时可以用到cmp指令和jmp指令的结合,如:
图中指令就通过关系操作指令与跳转指令的结合实现了if语句对于程序的控制。
而这两条指令通过关系操作指令与跳转指令的结合实现了for循环对于程序的控制。
3.3.6控制操作
通过je、jne、jle、jmp等指令,根据前一条cmp指令的结果决定是否跳转,实现对于程序的控制,其中:
je:相等时跳转
jne:不相等时跳转
jle:小于等于时跳转
jmp:无条件跳转
图中指令实现了if对于程序的控制。
而这两条指令实现了for循环对于程序的控制。
3.3.7函数操作
在hello.s中可以观察到调用了许多函数:
puts函数:
exit函数:
printf函数:
atoi函数:
sleep函数:
getchar函数:
3.4 本章小结
本章介绍了编译的概念以及作用,展示了ubuntu下编译程序的命令,对hello.i程序进行编译获得了hello.s汇编语言程序,并对hello.s的内容进行了解析,分析了汇编语言程序中的数据和操作等,加深了对汇编文件及汇编语言的理解。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
由汇编器as将汇编语言hello.s文件翻译成机器语言指令,并打包成的可重定位目标文件的格式,保存到hello.o中的过程称为汇编。
4.1.2汇编的作用
汇编这一步将汇编语言的代码转换成了计算机能够运行的机器语言的代码,让我们的程序可以被计算机执行。
4.2 在Ubuntu下汇编的命令
指令为:Linux>as hello.s -o hello.o
可以看到执行完上述指令后,生成了目标文件hello.o。
4.3 可重定位目标elf格式
4.3.1观察可重定位目标文件
在此阶段用readelf -a hello.o > helloelf.txt指令将可重定位目标文件中的内容读取出来存储到helloelf.txt文件中观察,下面展示hello.o的ELF格式及其各节的内容。
4.3.2 ELF头
可以看到,ELF头部分包含了机器型号、ELF头的大小、节头表的个数等信息。
4.3.3节头目表
描述了hello.o文件中出现的各个节的类型、地址、偏移量、大小等信息。
4.3.4重定位节
各个段引用的外部符号等在链接这一阶段需要通过重定位来更改这些位置的地址,重定位节中给出了偏移量,以及重定位时的寻址方式为绝对寻址还是相对寻址,此外addend这一量在后续的重定位算法中确定具体地址时也有着重要的作用。在链接时链接器会通过重定位节的重定位条目,通过课程中介绍的重定位算法来修正出正确的地址。
hello.o中需要重定位的内容:puts,exit,printf,sleep,getchar等函数调用,以及rodata段中的模式串。
4.3.5符号表
symtab段存放着符号解析阶段解析的程序中定义和引用的符号,如函数和全局变量存储在符号表中。
4.4 Hello.o的结果解析
通过objdump -d -r hello.o > hellodump.txt将hello.o反汇编出来的文件输出到hellodump.txt出对比观察。
首先,在数的表示上,hello.o的反汇编代码中操作数是用十六进制表示的,而hello.s在这一方面使用十进制,但是这两者之间仍然应当是对应的,所以在机器语言和汇编语言中间存在一个十六进制与十进制的映射关系。
其次,在控制转移上,hello.s中使用段名称进行跳转,而hello.o反汇编代码中则是根据要跳转的语句的地址进行跳转,在跳转时还需要语句与头部的偏移量信息来确定具体跳转的地址,但由于程序没有被实际加载运行,所以无法确定最终的地址,但是留下了跳转地址为0的重定位条目,这些为0的部分将在链接后补充上正确的重定位后的位置。
最后,在函数调用上,hello.s这一汇编程序文件通过直接调用函数的名称来实现函数的调用,而hello.o的反汇编代码在函数调用这一部分同样根据地址和偏移量来进行跳转,因此这一部分也留下了重定位条目,暂时跳转地址为0。
4.5 本章小结
在这一部分介绍了汇编,进行了符号解析过程,并由汇编器将符号定义存储在了hello.o的符号表中,将汇编语言程序转化成了机器语言,由hello.s生成了hello.o这一可重定位目标文件。并通过readelf观察了可重定位目标文件中的内容,对比了hello.s与hello.o的区别,观察到了可重定位目标文件的哪些部分需要被重定位,为之后的链接过程中理解重定位奠定了基础。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接是将各种代码和数据片段收集起来组合成一个单一文件的过程,且这个文件可以被加载到内存中并执行,包括符号解析和重定位两个主要的过程。其中符号解析需要解析程序定义和引用的符号(全局变量和函数)并把符号引用和符号定义关联起来,重定位则会把多个单独的代码和数据段合并为一个节,将符号从它们在.o文件中的相对位置重新定位到可执行文件中的最终绝对内存位置,并用它们的新位置更新所有对这些符号的引用。
5.1.2链接的作用
链接过程可以最终确定符号在可执行文件的内存位置,并更新在汇编阶段的可重定位条目,使程序距离可以被执行更进一步。同时使用链接器进行链接还有一些优点,如可以编写一个个较小的文件并将它们分开编译最后链接起来,方便了程序的修改;同时也有利于构建公共的函数库,可以提高空间效率。
5.2 在Ubuntu下链接的命令
命令为:Linux>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
可以看到在执行完链接命令后,生成了hello这一可执行目标文件。
5.3 可执行目标文件hello的格式
同样还是运用readelf指令,这次我们将输出文件重定向至elf.txt中,在elf.txt文件中观察可执行目标文件hello的格式:
5.3.1 ELF头
可以看到ELF头中包含了机器类型、节头表的位置、条目大小数量等信息。
5.3.2节头表
节头表中描述了各个节的类型、地址、偏移量、大小等信息。链接器链接时,会将各文件的相同段合并,并且根据大小和偏移量重新设置机器码中的地址。由于在ELF头可以观察到共有31个节,不可能全部截图说明,此处只截取前五个。
5.3.3段头表(程序头表)
程序头表中包含了页面大小、虚拟地址内存段大小、物理地址内存段等信息。
5.3.4动态链接信息表
5.3.5重定位表
5.3.6符号表
由于hello中的符号较多,全部截图展示过于占据篇幅,在此只截取符号表中的部分符号信息。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息。
在data dump中可以看到加载到虚拟地址空间的情况:
在edb中观察hello的各节位置和名称,发现其与5.3节的各部分相对应:
在edb中可以找到可执行目标文件hello的ELF头:
5.5 链接的重定位过程分析
通过objdump -d -r hello > dump.txt命令将可执行目标文件hello反汇编的代码输出重定向到dump.txt中,与hello.o的反汇编结果进行对比:
5.5.1新增节
hello.o:
hello:
可以看出hello.o是从.text节开始的,而在hello中新增了.init节和.plt节,同时增加了一些在节中定义的函数。
5.5.2新增函数
可以看出在链接阶段,加入了许多在hello.c中调用的库函数,如printf、getchar等函数。
5.5.3地址的更新
hello.o:
hello:
可以看出hello中实现了对调用的函数的重定位,使得调用函数时调用的地址已经是确切的地址而不是未重定位时的跳转地址0。
5.5.4 hello中链接的过程
链接的过程分为符号解析和重定位两个步骤。在完成符号解析阶段之后,链接器就已经将每一个符号引用和一个确定的符号定义关联起来。由此链接器就知道它的输入目标模块中的代码段和数据段的大小。
5.5.5重定位的过程
在重定位阶段,链接器将合并输入的模块,将所有相同类型类型的节合并成一个大的节,在将运行时的内存地址赋给合并后的节,以及赋给每一个符号,在这一阶段后,程序中的变量就都有唯一的运行地址,就可以进行引用的符号的重定位,更新地址,使得符号引用原来为0的部分填充上正确的地址。
5.6 hello的执行流程
利用edb执行hello获得各个过程以及各个过程地址:
ld-2.27.so!_dl_start
ld-2.27.so!_dl_inithello!_start
libc-2.27.so!_libc_start_main
libc-2.27.so!_cxa_atexit
libc_2.27.so!_new_exitfn
hello!_libc_csu_init
hello!_init
libc-2.27.so!_sigsetjmp
libc-2.27.so!_sigjmp
Call main hello!main
libc-2.27.so!exit
子函数名和地址:
截图展示:
5.7 Hello的动态链接分析
由于无法预测函数的运行时地址,对于动态共享链接库中 PIC 函数,编译器需要添加重定位记录,等待动态链接器处理。
动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
根据下图可知,GOT起始表位置为0x601000。
在调用dl_init之前0x601008后的16个字节均为0,对于每一条PIC函数调用,调用的目标地址都实际指向PLT 中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。
调用_start之后该位置发生了发生改变:
在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时,GOT 地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在 PLT[0]中将重 定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位 表确定函数运行时地址,重写 GOT,再将控制传递给目标函数。
5.8 本章小结
本章研究了链接的过程。通过edb查看hello的虚拟地址空间,对比hello与hello.o的反汇编代码,深入研究了链接的过程中重定位的过程。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程是一个正在运行的程序的实例,是一个具有一定独立功能的程序关于某个数据集合的一次运行活动,是系统进行资源分配和调度运行的基本单位。
6.1.2进程的作用
计算机可以同时运行许多进程,进程可以提供给应用程序两个关键的抽象:逻辑控制流(每个进程似乎独占地使用CPU)和私有地址空间(每个进程似乎独占地使用内存系统)。通过对进程的使用与管理,可以提高CPU的执行效率,减少因为程序等待带来的CPU空转以及其它计算机软硬件资源浪费。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1Shell-bash的作用
shell是一个交互型应用级程序,其功能是代表用户运行其他程序,解析命令并代表用户执行。在解析命令阶段shell还会判断命令是否为内置命令,如果为内置命令直接执行对应的内置命令处理程序;否则会认为是一个可执行程序,在文件系统中按路径查找文件并为其fork一个子进程来执行。
6.2.2shell的处理流程
shell执行一系列的读或者求值步骤,其中读步骤用来读取用户的命令行,求值步骤用来解析用户的命令,分析用户想要做什么,并代表用户运行。对于hello这一可执行目标程序,shell的处理流程为:①从终端读入输入的命令;②切分命令行字符串,解析命令,构造argv和envp;③检查命令行第一个参数是否为内置的命令,如果是则立即执行内置命令对应的程序,否则用fork创建一个子进程;④调用execve函数在子进程加载并运行hello程序;⑤hello开始运行,命令最后是否有&会决定hello在前台还是后台运行。
6.3 Hello的fork进程创建过程
终端通过fork函数创建一个新的、处于运行状态的子进程。新创建的子进程几乎但不完全与父进程相同:子进程与父进程之间虚拟地址空间相同但是相互独立;子进程获得与父进程任何打开文本描述符的一份副本,包括代码段、段、数据段、共享库以及用户栈。但是父子进程之间PID不同,这也是父子进程之间最大的区别。
在shell对命令行中输入的./hello解析后,发现hello不是shell的内置命令,所以按照hello为当前路径下的可执行文件来解析,之后通过fork创建一个子进程,再在创建的子进程中加载hello并执行。在子进程执行期间,父进程等待子进程执行结束并回收子进程。用简单的示意图表示:
6.4 Hello的execve过程
execve的参数包括需要执行的程序、参数argv、环境变量envp。execve过程带盖如下:①为执行hello程序加载器、删除子进程现有的虚拟内存段;②映射私有区:为hello的代码、数据、.bss、栈等区域创建新的区域结构;③新的栈和堆段通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容;④设置PC:设置当前进程的上下文中的程序计数器,使PC指向_start地址,_start最终调用main函数。
注意:execve函数在调用成功的情况下不会返回,只有出现错误如在路径下找不到要运行的程序时,execve才会返回-1,返回到调用程序。
6.5 Hello的进程执行
进程的执行不一定是完整、连续地执行完成,在执行过程中会发生进程的切换,每一个进程在执行时都会有其对应的时间片。在进程执行一段时间后需要切换进程时,会从用户态切换到核心态,保存当前进程寄存器的值切换地址空间。操作系统内核通过上下文切换,控制流从一个进程传递到另一个进程。
6.5.1逻辑控制流:
一系列程序计数器 PC 的值的序列叫做逻辑控制流,在同一个处理器核心中,每个进程执行它的流的一部分后暂时挂起,然后轮到其他进程,进程轮流使用处理器。
6.5.2时间片
一个进程执行它的控制流的一部分的每一时间段叫做时间片。在一个进程被调运行开始到被另一个进程打断,中间执行它控制流的时间就是运行的时间片。
6.5.3上下文信息切换
上下文就是内核重新启动一个被暂时挂起的进程所需要的状态。操作系统内核会对进程进行调度。在进程执行的某些时刻(如一条执行时间较长的指令),这时内核可以决定抢占当前进程,并运行另一个进程,保存寄存器的信息等,切换虚拟地址空间,同时载入接下来要运行的进程的寄存器信息等,这就是上下文信息切换的过程。
6.5.4用户模式和核心模式、用户态和核心态
设置模式位时,进程处于内核模式,该进程可以访问系统中的任何内存位置,可以执行指令集中的任何命令;当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。核心态几乎拥有最高的访问权限,进程只有故障、中断、或陷入系统调用时才会得到内核访问权限,其他情况下处于用户态,不允许执行特权指令。
6.6 hello的异常与信号处理
hello在执行过程中可能会出现四类异常:中断、陷阱、故障和终止。
①中断:属于异步异常,由处理器外部I/O设备引起,中断处理程序返回到下一条指令处,例如:时钟中断、键盘上敲击一个Ctrl+C。中断处理的流程示意图如下:
②陷阱:属于同步异常,是有意的,是执行指令产生的结果,所以发生时间可以预知,陷阱处理程序将返回到下一条指令。陷阱处理的流程示意图如下:
③故障:属于同步异常,也是执行指令产生的结果,但是不是有意的,但是出现的故障有可能被修复,如缺页故障。如果故障可以修复,那么处理程序将重新执行引起故障的指令,否则终止。
④终止:属于同步异常,不是有意为之,而且发生了不可恢复的致命错误,会直接终止当前程序。
hello的信号处理:
Ctrl+Z操作:向进程发送一个SIGTSTP信号,让hello暂时挂起。输入ps命令可以观察hello进程的状态。
可以发现hello进程还没有被关闭,我们可以通过jobs命令查看hello的作业编号,并通过fg命令再将hello放到前台运行:
Ctrl+C操作:向进程发送一个SIGINT信号,结束hello。
通过ps命令我们可以发现hello已经终止了,被彻底结束。
不停乱按:
jobs命令:可以查看当前的所有作业
pstree命令:把各个进程用树状图的形式展示,称为进程树。
由于进程树过于庞大,此处只展示进程树的一部分。
kill命令:发送信号给一个进程,如kill -9即终止一个进程。
可以看到发送信号后hello进程被终止了。
6.7本章小结
本章围绕hello执行过程中的进程管理展开,介绍了进程的概念和作用,了解了shell的处理流程,理解了hello执行过程中子进程的创建以及程序的加载与执行,并且尝试在hello执行的过程中向进程发送不同的信号,通过不同的指令查看了不同信号对进程的作用。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量,指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址,在hello中指的是hello.o中的内容。
7.1.2线性地址
线性地址是一个不真实的地址,对应了硬件页式内存的转换前地址,是逻辑地址到物理地址变换之间的中间层。先用段选择符到全局描述符表中取得段基址,再加上段内偏移量(即hello中的逻辑地址),即得到线性地址。
7.1.3虚拟地址
虚拟地址表明程序拿到的地址并不是真实的物理地址,而是一个虚拟的地址(用逻辑地址表示),需要经过到线性地址和物理地址的变换。
7.1.4物理地址
用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应,是地址变换的最终结果地址。可以把物理地址理解成把内存看成一个从0字节一直到最大空量逐字节变大的一个巨大的数组,然后把这个数组叫做物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图:
索引号对应着段描述符,段描述符的具体地址描述了一个段。很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。每一个段描述符由8个字节组成,如图所示:
在图中我们主要关心BASE字段,它描述了一个段的开始位置的线性地址。
在Intel的设计中一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中,具体选择使用哪一个是由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT,如图所示:
那么怎么由一个逻辑地址转换成一个线性地址呢?
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],步骤如下:①看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小;②拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了;③把Base + offset,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元,负责把一个线性地址,最终翻译为一个物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),分成的大数组我们称之为页目录。目录中的每一个目录项为一个页表条目(PTE),就是一个对应的页的地址。虚拟地址空间中的每个页(VP)在页表固定位置有一个PTE。虚拟页存在未分配的、缓存的、未缓存的三种状态。
另一类“页”,我们称之为物理页,是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。其中缓存的页对应于物理页。
PTE由一个有效位和一个n位的地址字段组成,其中有效位表示该虚拟页是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示DRAM中相应的物理页的起始位置,这个物理页中缓存了虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。
每次将虚拟地址转换为物理地址时,都会查询页表来判断虚拟页是否缓存在DRAM中,如果不在DRAM中,即发生缺页故障,通过查询页表条目找到虚拟页在磁盘的位置,再将虚拟页映射到物理页,此后导致缺页的的指令重新启动时,就变成了页面命中的情况。这种工作方式具有较高的工作效率主要依赖于程序的局部性,程序的时间局部性越好,工作集也就越小。如果工作集大于物理内存的大小,就会出现频繁的换入换出,性能急剧下滑。所以程序的局部性在页式管理的机制中扮演着重要的角色。
7.4 TLB与四级页表支持下的VA到PA的变换
在TLB的支持下,系统在MMU中包括了一个关于PTE的小的缓存,即TLB。TLB中每一行都保存着一个单个PTE组成的块,TLB由TLB索引(TLBI)、TLB标记(TLBT)和VPO组成,其中TLBI和TLBT共同构成了VPN。
一般情况下TLB都会命中(因为TLB中存的是页表项,一个页表项很大),这时的处理流程为:①CPU产生一个虚拟地址;②MMU从TLB中取出相应的PTE;③MMU将这个虚拟地址去页表中寻找到对应的PPN;④由于PPO与VPO相同,将PPN与VPO相拼接,就翻译出了虚拟地址对应的物理地址,并将这个物理地址发送给高速缓存/主存;⑤高速缓存/主存将所请求的数据字返回给CPU。示意图如图所示:
而在四级页表的支持下,如上图所示:36位VPN 被划分成四个9 位的片,每个片被用作到一个页表的偏移量。CR3 寄存器包含Ll页表的物理地址。VPN 1 提供到一个Ll PET 的偏移量,这个PTE 包含L2 页表的基地址。VPN 2 提供到一个L2 PTE 的偏移量,以此类推。CPU 会产生虚拟地址 VA,VA 传送给 MMU,MMU 使用前 36 位 VPN 作为 TLBT(前 32 位)+TLBI(后 4 位)向 TLB 中匹配。
大部分情况下TLB会命中,直接得到虚拟地址翻译出的物理地址。如果TLB不命中时,需要不断向下一级页表查询后再向TLB中增加条目。
7.5 三级Cache支持下的物理内存访问
当TLB与cache共同作用后,物理内存的访问机制更加复杂,大致步骤如下:首先根据TLB的条目个数、TLB组相联情况计算出VPO,TLBI、TLBT的位数,根据cache的信息计算出CO、CI和CT的位数;②当得到一个虚拟地址时,根据TLBI去TLB对应的组中寻找标记位TLBT匹配的页表条目,获得PPN;③获得PPN后将PPN与VPO拼接就得到的虚拟地址翻译出的物理地址PA;④根据物理地址中CI去对应的cache的组里寻找cache标记位CT匹配的cache缓存块,找到后根据cache的块内偏移量CI取得请求的数据字节。大致机制如图所示:
而如果出现TLB不命中的情况,则直接根据VPN去页表中寻找对应的虚拟页表条目获得PPN与VPO拼接成PA,后续步骤与上述过程一致;
而如果去cache访问数据时发生了不命中,则cache去下一级cache或主存中请求对应的数据块读入到cache中,即可实现cache的命中,只不过性能会有一些降低。
7.6 hello进程fork时的内存映射
当 fork 函数被 shell 进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的 PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct(描述一个进程的整个虚拟内存空间)、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
hello进程在execve时的内存映射大致的步骤如下:① 删除已存在的用户区域,将shell与hello都有的区域结构删除;②映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构;③映射共享区域,将一些动态链接库映射到hello的虚拟地址空间,再映射到用户虚拟地址空间中的共享区域内;④设置当前进程上下文中的程序计数器,使之指向hello程序的代码入口。
7.8 缺页故障与缺页中断处理
当MMU翻译某个虚拟地址时,触发了一个缺页。这是一个故障类型的异常,会导致跳转到内核的缺页处理程序,之后对于缺页中断处理的步骤基本如下:①首先判断虚拟地址是否是合法的,缺页处理程序会搜索区域结构的链表,把虚拟地址和每个区域结构中的vm_start和vm_end做比较,如果指令不合法则会触发一个段错误;②之后缺页处理程序会判断试图进行的内存访问是否合法,即进程是否有对这个区域执行某个操作的权限,如果试图进行的内存访问是不合法的,那么同样会触发一个段错误;③如果内核知道了这个缺页是对合法虚拟地址的合法操作造成的,那么缺页处理程序就会选择一个牺牲页面,如果牺牲页面被修改过,就执行写回操作。当缺页处理程序返回时,CPU重新启动引起缺页的指令,就像在第六章中描述的处理故障的流程,如图所示:
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap) 。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) 。对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器分为两种基本风格:显式分配器和隐式分配器。两种风格都要求应用显式地分配块。
①显式空闲链表:显式空闲链表是将堆的空闲块组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针。进行内存管理。在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
②隐式空闲列表:空闲块通过头部的大小字段隐含地连接着。分配器遍历堆中所有的块,间接地遍历整个空闲块的集合。当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配和最佳适配。分配器在面对释放一个已分配块时,可以合并相邻的空闲块,其中一种简单的方式,是利用隐式空闲链表的边界标记来进行合并。隐式空闲列表还具有分割和释放并分配机制。
③分离的空闲链表:分离存储,是一种流行的减少分配时间的方法。一般思路是将所有可能的块大小分成一些等价类/大小类。分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。
7.10本章小结
本章介绍了存储器地址空间、段式管理、页式管理,VA 到 PA 的变换、物理内存访问, hello 进程fork时和execve 时的内存映射、缺页故障与缺页中断处理、以及动态存储分配管理的内容。存储管理为进程之间独立运行提供了可能,并提供了一种内存保护的机制,在计算机系统中有着重要的地位。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O, 这使得所有的输人和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1Unix IO接口
①打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
②Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)和标准错误(描述符为 2)。头文件< unistd.h> 定义了常量 STDIN_FILENOSTDOUT_FILENO和STDERR_FILENO它们可用来代替显式的描述符值。
③改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k。
④一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
⑤关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2函数
①打开文件:
函数原型:int open(char* filename,int flags,mode_t mode)
返回值:若成功则为新文件描述符,否则返回-1;
open函数将filename转换为一个文件描述符(用数字表示)。返回的数字总是进程中没有打开的最小描述符。flags指明了打开该文件的方式,它有如下选项:
-O_RDONLY:只读;
-O_WRONLY:只写;
-O_RDWR:可读可写。
②关闭文件:
函数原型:int close(fd)
返回值:成功返回0,否则为-1
③读文件:
函数原型:ssize_t read(int fd,void *buf,size_t n)
返回值:成功则返回读的字节数,若EOF则为0,出错为-1
功能描述:从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。
④写文件:
函数原型:ssize_t wirte(int fd,const void *buf,size_t n)
返回值:成功则返回写的字节数,出错则为-1
描述:从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置
8.3 printf的实现分析
printf函数:
引用的vsprintf函数:
write函数:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
sys_call函数:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
vsprintf函数将所有的参数内容格式化之后存入buf,返回格式化数组的长度。write函数将buf中的i个元素写到终端。从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
(以下为原文档)
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
当程序调用getchar时,程序等待用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车(回车也在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。
异步异常-键盘中断的处理:当用户按键时触发键盘终端,操作系统将控制转移到键盘中断处理子程序,中断处理程序执行,接受按键扫描码转成ascii码,保存到系统的键盘缓冲区,显示在用户输入的终端内。当中断处理程序执行完毕后,返回到下一条指令运行。
8.5本章小结
本章介绍了 Linux 的 I/O 设备的基本概念和管理方法,以及Unix I/O 接口及其函数。最后分析了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的运行需要地址的概念,虚拟地址是计算机系统最伟大的抽象。
- hello的输入输出与外界交互,与linux I/O息息相关
- hello最终被shell父进程回收,内核会收回为其创建的所有信息
hello的一生经历了被编写成代码、预处理、编译、汇编、链接、运行、创建子进程、加载、执行指令、访问内存、动态内存分配、发送信号、终止。在伴随hello经历一生的过程中,我们也进一步加深了对于计算机系统的理解。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
文件名字 | 作用 |
hello.i | 预处理后的文件 |
hello.s | 编译后的汇编文件 |
hello.o | 汇编后的可重定位目标文件 |
helloelf.txt | hello.o的ELF格式文件 |
hellodump.txt | hello.o的反汇编代码 |
hello | 链接后生成的可执行目标文件 |
elf.txt | hello的ELF格式文件 |
dump.txt | hello的反汇编代码 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[2] C预处理_Leslie X徐的博客-CSDN博客_程序预处理的概念 C预处理
[3] Randal E. Bryant, David R. O'Hallaon. 深入理解计算机系统. 第三版. 北京市:机械工业出版社[M]. 2018: 1-737
[4] printf 函数实现的深入剖析. https://www.cnblogs.com/pianist/p/3315801.html.
(参考文献0分,缺失 -1分)