报告以一个简单的hello.c文件为基础与示例,跟踪了在计算机系统中一个程序从程序员敲完的.c文件一步步被执行的过程,展现了一个代码项目文件Program如何经过计算机的预处理,编译,汇编,链接最终形成计算机的可执行文件的过程,这一部分体现在第二至第五章,至这一部分我们的hello文件已经做好了被计算机系统执行的准备工作。第六,七,八章进一步展现了操作系统是如何执行这一“准备好的”可执行文件的。具体体现在第六章介绍的操作系统对这一进程的创建,执行,信号处理等进程管理,第七章介绍的进程存储空间管理,第八章介绍的IO管理上。至此,正如大作业题目一样,hello.c程序走完了他的一生,而我们的报告也跟踪记录完毕了hello程序这一生的完整过程。
关键词:hello;计算机系统;预处理;编译;汇编;链接;进程;进程管理;存储空间;IO;
目 录
第1章 概述
1.1 Hello简介
P2P:P2P,即From Program to Process,由项目程序到进程的过程。当我们程序员敲完了一个hello代码并将其保存为.c文件时,Hello作为一个C程序就诞生了。如果我们要执行这个C程序,让Hello实现她的人生价值,就要对其经过一系列的处理使其由项目文件转变为一个进程来等待计算机的执行,这一过程就是P2P 。在P2P过程中,hello.c文件先进行预处理,这一环节处理了文件中的宏,以#include开头为代表的头文件等等,这一环节结束后hello.c就摇身一变变成了hello.i。接下来编译器对hello.i进行编译,将其由方便我们程序员阅读的高级语言编译成更接近计算机逻辑的汇编语言,这一环节结束后hello.i就变成了hello.s,然而汇编语言依然不是计算机语言,难以被计算机理解,所以接下来hello.s需要进行一步汇编过程,这一环节中hello.s将由汇编语言转换为二进制代码的机器语言,这一语言是可以被计算机解读的,生成的文件为hello.o,即可重定位文件。虽然转变为了机器语言,但离可以直接被计算机执行还缺少一步,hello内的一些函数,数据等可能不存在与hello本身的文件当中,最后还需要进行一步链接,将符号进行解析和重定位,生成可执行文件hello。
020:020,即From Zero-0 to Zero-0。从零开始以令结束,即程序从无开始戴胜至完全被系统回收结束不会产生“残留”。这一环节依靠于计算机系统的进程管理设计。在上一步我们最后生成的可执行文件hello等待被系统执行,在我们冲控制台shell输入./hello命令后,系统开始执行这一可执行文件。系统首先会调用Fork函数生成一个只有pid与父进程不同的子进程(父进程此时应为shell),然后调用evecve函数,这一函数会启动加载器loader,将子进程原内容丢弃并新建task_struct及其目录下包括mm_struct等的数据结构,映射私有区域和共享区域,然后设置程序计数器到代码区域的入口点,系统下一步开始执行hello程序。hello程序运行结束后会成为一个zombie进程等待父进程shell回收,父进程将其回收后hello的生命周期彻底结束。
1.2 环境与工具
硬件环境:Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz 2.21 GHz 64位操作系统
系统环境:Ubuntu 18.04 LTS64位,Vmware 11
开发与调试工具:codeblocks,gedit,gdb,objdump,gcc,readelf,HEXEdit
1.3 中间结果
Hello.c:敲完的源程序
Hello.i:经过预处理得到的文本文件
Hello.s:经过编译得到的汇编语言文件
Hello.o:经过汇编得到的二进制可重定向文件
Hello:经过链接得到的最终可执行文件
1.4 本章小结
本章主要从P2P以及020的角度概括了hello从被程序员编写完成正式诞生到转换为可执行文件,作为进程被系统执行到最终执行完毕被系统回收的一生,对接下来的报告整体内容做了简要介绍与概括并列出了所使用的环境与工具,列出了操作过程中得到的中间结果。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理器(cpp)根据以#开头的命令,修改原始的C程序。比如hello.c中第1行的#include 命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件拓展名。
作用:
1.将#include的声明放到新程序中。比如hello.c中第一行#include 命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。
2.宏定义:将符号常量替换成文本。
3.条件编译允许只编译源程序中满足条件的程序段,从而减少内存的开销,提高程序效率。
2.2在Ubuntu下预处理的命令
gcc hello.c -E -o hello.i
截图如下:
图2.1 预处理命令
图2.2 预处理结果
2.3 Hello的预处理结果解析
图2.3 hello.i文件
在上一步操作中,我们使用了gcc的编译命令生成了hello.i文件,这是一个文本文件,内部存储着预处理完毕后的hello程序。打开hello.i文件我们可以发现这一文本文件很长,相对于我们的源代码文件多了很多东西,源代码文件如下:
图2.4 hello.c文件
可以发现源代码文件只有8行,而预处理后的.i文件足足有3074行之多。对比源代码文件与预处理后的.i文件可以发现,.i文件在在最后包含了源代码文件的内容,但没有完全包含。相比于源代码文件,.i文件直接从int main开始,没有前面的#include部分,实际上,可以发现,#include部分已经被预处理并作为文本插入文件,多出来的3000多行主要就是拜#include所赐。
2.4 本章小结
这一章主要介绍了程序员编写完成后的源代码文件hello.c所要经历的人生第一步:预处理,介绍了预处理的概念和作用,介绍了在Ubuntu下预处理的命令,并利用这一命令实际操作演示了预处理这一步骤,生成了经预处理后的hello.i文件并将源代码文件与hello.i文件进行了对比分析,进一步证实了预处理保留源代码主题代码部分,对头部#预处理为文本插入的过程。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语
言程序。该程序包含函数main的定义,定义中的每条语句都以一种文本格式描述了一条低级机器语言指令。
作用:将高级语言源程序翻译成等价的目标程序,并且进行语法检查、调试措施、修改手段、覆盖处理、目标程序优化等步骤。汇编语言是非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语言。对于预处理完成的文本文件,它依然是使用高级语言来书写的,这种语言方便与人的书写与理解但要使计算机理解是很困难的。所以需要对其进行一系列操作转为机器语言。而如果我们直接将其转为机器语言会遇到许多困难,首先程序性能可能不会很好,代码可能存在语法问题等,处于多种考虑,编译器先将其编译为接近机器语言的汇编语言,即.s文件。
3.2 在Ubuntu下编译的命令
Ubuntu下编译命令为:
gcc hello.i -S -o hello.s
截图如下:
图3.1 编译命令
图3.2 编译结果
3.3 Hello的编译结果解析
汇编代码主体如下:
图3.3 hello.s文件1
图3.4 hello.s文件2
汇编指令:
指令 含义
.file 声明源文件
.text 以下是代码段
.section.rodata 以下是rodata节
.globl 声明一个全局变量
.type 指定函数类型和对象类型
.size 声明大小
.long .string 声明一个long,string类型
.align 声明对指令或者数据的存放地址进行对齐的方式
3.3.1 数据存储
1.首先在main函数之前,可以发现汇编代码中第3行声明了一个全局变量globle sleepsecs,大小size为4, 类型为long且已经被赋值,.data节存放已经初始化的全局和静态C变量,所以编译器处理时在.data节声明该变量。
2.接下来观察局部变量,局部变量在函数内部被创建并分配空间,存储在函数栈中。在主函数main的栈中,rsp向下移动了32个字节,其中就有给int i预留的空间。对L2.观察可以发现在hello.s中编译器将i存储在栈上空间-4(%rbp)中并初始化为0 。
3.字符串变量,printf中输出一个字符串变量,可以发现这一字符串被保存在了LC0.LC1.中。
4.main函数参数:int argc;argc是函数传入的第一个int型参数,存储在%edi中,argv[1]和argv[2]应声明在.rodata只能读数据段中。
5.常量:常量在汇编代码中以立即数的方式出现,如$0
3.3.2赋值操作
在汇编代码中,赋值操作主要以mol语句的形式出现,如L2中的moll $0, -4(%rbp)。
3.3.3算数操作
在hello程序中,算数操作只有循环体内的i++,汇编代码体现在L4的最后一行add; $1, -4(rbp)。
3.3.4关系操作
hello程序中的关系操作由两种,!=和<,分别出现在第一个对argc的判断以及第二个循环体条件中。在汇编代码中,!=被编译为cmp + je语句,如下:
图3.5 !=汇编代码
cmp指令首先对3和-20(%rbp)(即argc)进行比较大小并设置标记位,je通过设置的标记位进行判断,如果相等则跳转,这一步跳转相当于c语言中的跳出判断。
与!=类似,<也被编译为两条汇编语句的结合,体现为cmp + jle,如下:
图3.6 <汇编代码
同样,cmp比较9和-4(%rbp)(即i)的大小并设置标记位,jle根据标记位如果小于等于则跳转,对应于c代码的跳出循环。
3.3.5数组/指针/结构操作
在hello程序中出现了指针数组char argv[],在argv数组中,argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别表示两个字符串。在汇编代码中可已发现代码通过%rax + 16和%rax + 8,分别得到argv[1]和argc[2]两个字符串,其中%rax为-32(%rbp)。
图3.7 argv汇编代码
3.3.6控制转移
hello的控制转移主要由两个部分,其一是if条件判断的argc,其二是for循环。if条件判断主要汇编代码如下:
图3.8 if判断汇编代码
如同在关系操作中介绍的,这里cmp指令首先对3和-20(%rbp)(即argc)进行比较大小并设置标记位,je通过设置的标记位进行判断,如果相等则跳转,这一步跳转相当于c语言中的跳出判断。
循环主要汇编代码如下:
图3.9 for循环代码
其中L2对循环标记赋初值0,然后跳入L3,L3中55,56句对i进行条件判断,如果其小于等于9,则跳入L4,L4是循环主体,其最后一句实现对i的加一操作。
3.3.7函数操作
汇编代码在调用函数时主要是以call指令来调用,系统在每调用一个函数时都会为其创建一个栈,并在函数结束时收回。main函数是系统执行的第一个函数。
1.main函数:
参数传递:传入参数argc和argv,分别用寄存器%rdi和%rsi存储。
函数调用:被系统启动函数调用。
函数返回:设置%eax为0并且返回,对应return 0 。
2.printf函数:
参数传递:call puts时只传入了字符串参数首地址;for循环中call printf时传入了 argv[1]和argc[2]的地址。
函数调用:for循环中被调用
3.exit函数:
参数传递:传入的参数为1,再执行退出命令
函数调用:if判断条件满足后被调用
4.sleep函数:
参数传递:传入参数sleepsecs,传递控制call sleep
函数调用:for循环下被调用
5.getchar
传递控制:call getchar
函数调用:在main中被调用
3.4 本章小结
本章简要介绍了汇编的概念并通过ubuntu下的汇编指令生成了hello的汇编文件hello.s。随后我们从数据结构,赋值,算术操作,关系操作,数组/指针/结构操作以及控制转移,函数操作等方面对hello.s的汇编代码进行了分析,展现了汇编语言的特点。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中。Hello.o文件是一个二进制文件。前面说过,汇编语言虽然更贴近机器语言但仍然无法被机器所识别。所以需要我们进行汇编转换将其转换为机器语言,即.o文件。
4.2 在Ubuntu下汇编的命令
Ubuntu下汇编的命令为:
as hello.s -o hello.o
截图如下:
图4.1 汇编命令
图4.2 汇编结果
4.3 可重定位目标elf格式
ELF头描述了生成该文件的系统的字的大小和字节顺序,并且包含帮助链接器语法分析和解释目标文件的信息。
节头部表描述了不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。具体的描述包括节的名称、类型、地址和偏移量等。
当汇编器生成一个目标模块是,它并不知道数据和代码最终将放在内存中的什么位置,它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行目标文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。
ELF重定位条目的数据结构如下:
typedef struct{
long offset; /需要被修改的引用的节偏移/
long type:32, /重定位类型/
symbol:32; /标识被修改引用应该指向的符号/
long attend; /符号常数,对修改引用的值做偏移调整/
}Elf64_Rela;
两种最基本的重定位类型:
R_X86_64_PC32 :重定位一个使用32位PC相对地址的引用。
R_X86_64_32 :重定位一个使用32位PC绝对地址的引用。
可以看出8条重定位信息的详细情况,分别对符号.rodata,函数puts,exit等,加数也在符号名称之后。
readelf -a hello.o命令截图如下:
图4.3 elf截图
4.4 Hello.o的结果解析
objdump后文件如下:
图4.4 反汇编文件1
图4.6 反汇编文件2
对反汇编文件和汇编文件进行比对,主要可以发现以下区别:
操作数:hello.s中的操作数时十进制,因为hello.s文件格式认为文本文件。而hello.o反汇编代码中的操作数是十六进制,因为反汇编代码是从hello.o的二进制格式中逆转换来的。
在汇编文件中,main函数前是没有东西的,反汇编文件前多了函数地址。相应的,跳转语句之后,hello.s中是.L2和.L3等段名称,而反汇编代码中跳转指令之后是相对偏移的地址。汇编文件中,call指令之后直接是函数名称,而反汇编代码中call指令之后是函数的相对偏移地址。因为函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目。
反汇编文件在汇编文件基础上还增加了每一条汇编语言所对应的机器码。
汇编文件中,对于.rodata和sleepsecs等全局变量的访问,是$.LC0和sleepsecs(%rip)方式,而在反汇编代码中则变成了$0x0和0(%rip),这是因为它们的地址也在运行时被确定,因此访问也需要重定位,在汇编成机器语言时,将操作数全部置为0,并且添加重定位条目。
4.5 本章小结
本章进一步将文本文件的hello.s编译成为了可重定位文件hello.o,介绍了汇编的概念以及elf头文件。并通过将hello.o反汇编结果与汇编文件进行对比,体现了编译过程的工作与特点作用。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接(linking)是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时(compile time),也就是在源代码被翻译成机器代码时;也可以执行于加载时(load time),也就是在程序被加载器(loader)加载到内存并执行时;甚至执行于运行时(run time),也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器(linker)的程序自动执行的。
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译(separate com-pilation)成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
ubuntu链接命令:
Ld-ohello-dynamic-linker/lib64/ld-linux-x86-64.so.2/usr/lib/x86_64-linux-gnu/crt1.o/usr/lib/x86_64-linux-gnu/crti.ohello.o/usr/lib/x86_64-linux-gnu/libc.so/usr/lib/x86_64-linux-gnu/crtn.o
截图:
图5.1 链接命令
图5.2链接结果
5.3 可执行目标文件hello的格式
Elf头如下:
图5.3 elf头
从图中可以发现,elf头包含有文件的类别,数据类型,字节顺序,机器类型,程序入口地址,程序头节点以及头部表文件偏移,条目大小,数量等要素。
图5.4节头表
图5.5节头表
节头表记录了各个节的名称、大小、偏移量、地址、旗标、对齐方式等信息。可以看到因为可执行文件不需要再重定位,所以不存在.rel.data和.rel.text节。
图5.6程序头表
程序头部表是一个数组,数组中的每一个元素称为一个程序头,每一个程序头描述一个内存段或者一块用于准备执行程序的信息;内存中的一个目标文件中的段包含一个或多个节;也就是ELF文件在磁盘中的一个或多个节可能会被映射到内存中的同一个段中;程序头只对可执行文件或共享目标文件有意义,对于其它类型的目标文件,该信息可以忽略。
5.4 hello的虚拟地址空间
依据程序头地址信息,可以找到对应节虚拟地址
INTERP:
图5.6INTERP
LOAD代码段(400000开始)
图5.7代码节
LOAD数据节:
图5.8数据节1
图5.9数据节2
图5.10数据节3
5.5 链接的重定位过程分析
反汇编后文件与.o文件对比示例如下:
图5.11 反汇编文件对比1
图5.12反汇编文件对比2
可以发现,主要区别在于在原.o文件标有R_X86_64_的标记下其编码后面几位都是0,在重定位后这些位置机器码被重写定位,这是重定位寻址的结果,重定位寻址主要由两种方式,一种是相对寻址(R_X86_64_PC32),一种是绝对寻址(R_X86_64_32)。对于前者,是将下一条PC值与相对地址做差;对于后者,直接将原地址放入其中即可。
继续分析可以发现,hello与hello.o不同处在于:
1.链接增加新的函数:在hello中链接加入了在hello.c中用到的函数,如exit、printf、sleep、getchar等函数。
2.增加的节:hello中增加了.init和.plt节,和一些节中定义的函数。
3.函数调用:hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。
4.地址访问:hello.o中的相对偏移地址变成了hello中的虚拟内存地址。
5.6 hello的执行流程
_start执行
图5.13 start
调用与跳转的过程:
1._dl_start
2._dl_init
3._cax_atexit
4._new_exitfn
5._libc_start_main
6._libc_csu_init
7._main
8._printf
9._atoi
10._sleep
11._getchar
12._exit
13._dl_runtime_resolve_xsave
14._dl_fixup
15._dl_lookup_symbol_x
16.exit
5.7 Hello的动态链接分析
对于动态共享链接库中位置无关代码,编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理。编译器利用了数据段与代码段的距离是一个运行时常量的事实,在数据段开始的地方创建一个表,叫做全局偏移量表GOT,每个被这个目标模块引用的全局数据目标都有一个8字节条目,还会生成一个重定位记录,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含正确的绝对地址.
程序调用共享库函数时,只有函数第一次被用到时才进行绑定(符号解析、重定位),如果没有用到则不进行绑定。这种方案被称为延迟绑定。
定位前:
图5.14执行init前
图5.15执行init后
可以明显的发现404010地址后的区域有了明显的变化,这是动态链接的结果。
5.8 本章小结
本章简要介绍了链接的概念和作用并使用ubuntu下链接命令生成了hello可执行文件。随后运用edb对这一可执行文件进行跟踪,用readelf分析elf头表,进一步介绍了elf的内容以及静态链接动态链接的作用区别。特别是通过hello与hello.o的对比展现了连接的作用与重要性。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是计算机科学中最深刻,最成功的概念之一。在现代系统上运行一个程序时,我们会得到一个假象,就好像我们的程序是系统当中运行的唯一程序一样。我们的程序好像是独占使用处理器和内存。处理器就好像是无间断地一条接着一条的执行我们的指令。最后我们程序中的代码和数据好像是系统内存中的唯一的对象。这些假象都是通过进程的概念提供给我们的。
进程的经典定义就是一个执行中的程序的实例。系统的每一个程序都是运行在某一个进程上下文中。上下文是由程序正确运行所需要的状态构成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及上下文描述符的集合。
6.2 简述壳Shell-bash的作用与处理流程
shell概念:
指“为使用者提供操作界面”的软件(命令解析器)。它类似于DOS下的command.com和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。shell是一个交互性应用级程序,代表用户运行其他程序。
处理流程:
shell从终端读入输入的命令然后将输入字符串切分来获得所有的参数。如果输入命令是内置命令则立即执行。否则就调用相应的程序为其分配子进程并运行,运行同时shell 时刻准备接受键盘输入信号,并对这些信号进行相应处理。
6.3 Hello的fork进程创建过程
根据shell的处理流程,输入命令执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程Hello。Hello进程几乎但不完全与父进程相同,Hello进程得到与父进程用户级虚拟空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库、以及用户栈。Hello进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,Hello进程可以读写父进程中打开的任何文件。父进程和Hello进程最大的区别在于它们有不同的PID。
fork函数只被调用一次,却会返回两次。在父进程中,fork返回Hello进程的PID,在Hello进程中,fork返回0 。
6.4 Hello的execve过程
execve 函数加载并运行可执行目标文件filename, 且带参数列表argv 和环境变量列表envp 。只有当出现错误时,例如找不到filename, execve 才会返回到调用程序.所以,与fork 一次调用返回两次不同, execve 调用一次并从不返回。
执行过程:execve首先删除当前进程虚拟内存的用户部分中已存在的区域结构,随后映射私有区域,为新程序的代码,数据,.bss和栈区域创建新区域结构,接着,共享对象动态链接hello程序,将其映射至用户虚拟内存空间的共享区域中,最后,设置程序的计数器,使之指向代码区域。
6.5 Hello的进程执行
图6.1 进程执行
当设置了模式位时,进程就运行在内核模式中(有时叫做超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。
没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位,或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
运行应用程序代码的进程初始时是在用户模式中的。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷人系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式。
内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被抢占的进程所需的状态。它由一些对象的值组成, 这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。
当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是sleep系统调用,它显式地请求让调用进程休眠。一般而言, 即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
hello分析
hello开始运行在用户模式中,hello调用sleep后转入内核模式执行休眠。休眠时间到达后系统发送一个中断信号,内核中断睡眠回到用户模式。
6.6 hello的异常与信号处理
正常运行:
图6.2正常运行
按回车:
图6.3按回车
输入的回车数据会保存在缓存区中,代码最后getchar会读走一个回车然后程序结束,剩下的回车被当作控制台命令输出。
Ctrl_z:
图6.4ctrl-z
输入ctrl-z发现进程停止,输入ps查看发现进程被挂起,再输入fg进程继续运行结束。ctrl-z向shell发送了信号SIGTSTP,启用了shell的信号处理程序,将hello的进程挂起。接下来的命令fg 将后台作业改为前台运行,向hello的进程发送信号SIGCONT,让hello继续运行。
Ctrl-c
图6.5ctrl-c
输入ctrl-c发现进程停止,再输入ps发现进程被终止(kill).这是因为ctrl-c向shell发送了信号SIGINT,使shell启用了对应的信号处理程序,将hello的进程终止并让父进程shell调用waitpid函数等待子进程结束并回收。
Jobs
图6.6jobs
Ctrl-z后输入jobs命令可以输出进行的作业与状态。
Pstree
图6.7pstree
Pstree命令会以树状图显示所有进程关系(父进程,子进程)
Kill
图6.8kill
Kill + pid命令会终止(杀死)指定pid的进程
乱按
图6.9乱按
乱按中遇到第一个回车前的值都会被getchar缓冲走,剩下的值会被当作控制台命令输出
6.7本章小结
本章简要介绍了shell,进程与异常信号的概念与作用,介绍了hello作为一个进程的执行过程。并再执行hello时通过输入不同的指令来进一步体现shell的异常处理功能与作用。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:程序产生的与段相关的偏移地址,每一个逻辑地址都由一个段(segment)和偏移量(offset)组成,偏移量指明了段起始地址到实址之间的距离。
线性地址:段偏移地址加上段基址成为线性地址。
虚拟地址:CPU从一个有N=2^n个地址空间中生成虚拟地址,实际值上等同于线性地址。
物理地址:用于内存芯片级的单元寻址,放在寻址总线上的地址。放在寻址总线上。物理内存是以字节(8位)为单位编址的。
图7.1 hello elf头
在hello的elf头中,我们可以看见的virtaddr就是虚拟地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符、段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号,后第一位是表指示器,表明查询地址在全局表中还是局部表中,最后两位是请求特权级,表明这一地址请求的优先级。
图7.2 逻辑地址
描述符表是一个索引表,index表示段描述符在数组中的索引。一个段描述符的大小是8个字节。由此我们可以计算出段描述符所在的地址,即表起始地址加上由index表示的偏移量。从而我们就可以找到我们想要的段描述符,并进而从段描述符中获取段的首地址,最后将从段描述符中获得的首地址与逻辑地址的偏移量相加就得到了线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟地址分为虚拟页号VPN和虚拟页偏移量VPO。通过页表基址寄存器在页表中获得页表条目PTE,一条PTE中包含有效位、权限信息以及物理页号,如果有效位是0 NULL则代表没有在虚拟内存空间中分配该内存,如果是有效位0 非NULL则代表在虚拟内存空间中分配了但是没有被缓存到物理内存中,如果有效位是1则代表该内存已经缓存在了物理内存中,可以通过PTE得到物理页号PPN,物理页号PPN与虚拟页偏移量共同构成物理地址PA。
当页面命中时CPU硬件执行的步骤:
第1步:处理器生成一个虚拟地址,并把它传送给MMU.
第2步: MMU生成PTE地址,并从高速缓存/主存请求得到它.
第3步:高速缓存/主存向MMU返回PTE.
第4步:MMU构造物理地址,并把它传送给高速缓存/主存.
第5步:高速缓存/主存返回所请求的数据字给处理器.
7.4 TLB与四级页表支持下的VA到PA的变换
图7.3 四级页表下VA-PA
以图中示例,48位的虚拟地址被分为36位的VPN以及12位的VPO,前者按照9/9/9/9划分为四级页表的索引,而后者对应于页面中的偏移量。四级页表中每一级都对应着512个表项,其中只有第四级页表的表项中装着PPN。其余页表分别存储着下一级页表地址。CR3指向第一级页表首地址,前9位VPN是一级页表的偏移量,用来索引一级页表中的表项,其表项内容为二级页表的首地址。第10-18位VPN是二级页表的偏移量……以此类推,最后9位VPN对应着第四级页表的偏移量,最终定位到对应的PPO。得到的40位PPO再与12位的VPO链接,最终到52位的物理地址。
7.5 三级Cache支持下的物理内存访问
图7.4 cache下物理内存访问
我们继续7.4的示例介绍三级Cache支持下的物理内存访问。我们在前面得到52位的物理地址后,将其按40/6/6划分,这一划分是根据cache的格式来划分的。后六位CO是块内偏移量,一个块的大小是64B,即2^6,对应着6位索引;CI是组索引,占6位,一级cache的64组,是2^6,对应6位,最后剩下的40位是tag位,用来与cache进行比较判断是否命中。
每当得到一个目标物理地址,首先在一级cache中进行寻找,如果命中直接向CPU返回查找到的数据。如果不命中,继续向低一级的cache中寻找,如果找到了返回相应的数据并更新上面的cache,以此类推。如果在三级cache中都没有找到目标,那么就从内存中加载数据并更新上面三级cache。
7.6 hello进程fork时的内存映射
既然我们理解了虚拟内存和内存映射,那么我们可以清晰地知道fork函数是如何创建一个带有自己独立虚拟地址空间的新进程的。
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm struct、 区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
虚拟内存和内存映射在将程序加载到内存的过程中也扮演着关键的角色。既然已经理解了这些概念,我们就能够理解execve函数实际上是如何加载和执行程序的。假设运行在当前进程中的程序执行了如下的execve调用
execve("a.out",NULL, NULL):
execve函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效地替代了当前程序,加载并运行a.out需要以下步骤:
删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
映射私有区城。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为a.out文件中的.text和.data区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。 栈和堆区域也是请求二进制零的, 初始长度为零。
映射共享区域。如果a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux 将根据需要换入代码和数据页面。
图7.5 execve
7.8 缺页故障与缺页中断处理
图7.6 缺页处理
第1步:处理器生成一个虚拟地址,并把它传送给MMU.
第2步: MMU生成PTE地址,并从高速缓存/主存请求得到它.
第3步:高速缓存/主存向MMU返回PTE.
第4步:PTE中的有效位是零,所以MMU触发了一次异常,传递CPU中的控制 到操作系统内核中的缺页异常处理程序.
第5步:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了, 则把它换出到磁盘.
第6步:缺页处理程序页面调人新的页面,并更新内存中的PTE.
第7步:缺页处理程序返回到原来的进程,再次执行导致缺页的指令,CPU将地 址重新发送给MMU.因为虚拟页面现在已经缓存在物理内存中,所以会命中,主存将所请求字返回给处理器.
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆.系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) .对于每个进程,内核维护着一个变量brk, 它指向堆的顶部.
分配器将堆视为一组不同大小的块的集合来维护.每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的.已分配的块显式地保留为供应用程序使用.空闲块可用来分配.空闲块保持空闲,直到它显式地被应用所分配.一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的.
分配器有两种基本风格. 两种风格都要求应用显式地分配块.它们的不同之处在于由哪个实体来负责释放已分配的块
显式分配器(explicit allocator):
要求应用显式地释放任何已分配的块.例如,C标准库提供一种叫做malloc程序包的显式分配器.C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块.C++中的new和delete操作符与C中的malloc和free相当.
隐式分配器(implicit allocator):
要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块.隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集( garbage collection).例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
一个块是由一个字的头部,有效载荷,以及可能的一些额外的填充组成的.头部编码了这个块的大小,以及这个块是已分配的还是空闲的.如果我们强加一个双字的对齐约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是零.因此,我们只需要内存大小的29个高位,释放剩余的3位来编码其他信息.在这种情况中,我们用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的.
图7.7 块
在隐式空闲链表堆块的基础上,在每个块的结尾处添加一个脚部(footer),边界标记),其中脚部就是头部的一个副本.如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离,这样就允许在常数时间内进行对前面块的合并.
图2.8 隐式空闲链表合并块
一种更好的方法是将空闲块组织为某种形式的显式数据结构.因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面.例如,堆可以组织成一一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针.
图2.9 显示空闲链表合并块
7.10本章小结
本章简要介绍了虚拟内存,物理内存概念,虚拟内存,物理内存的关系以及数据读取存储的详细流程,并以hello程序位示例展现了hello程序在存储空间上的结构以及存储空间的读写调用流程,展现了系统内层对存储空间的巧妙管理机制。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/ O 设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行.这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口.
一个应用程序通过要求内核打开相应的文件来宣告它想访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,而文件的相关信息由内核记录,应用程序只需要记录这个描述符。
Linux shell创建的每个进程开始时都包含标准输入、标准输出、标准错误三个文件,供其执行过程中使用。
对于每个打开的文件,内核保持着一个文件位置k,初始为0,即从文件开头起始的字节偏移量,应用程序能够通过执行seek操作来显式的改变其值。
至于读操作,就是从文件复制n个字节到内存,并将文件位置k增加为k + n。当k大于等于文件大小时,触发EOF条件,即读到文件的尾部。
最后,在结束对文件的访问后,会通过内核关闭这个文件,内核将释放打开这个文件时创建的数据结构,并将描述符恢复到可用的描述符池中。
8.2 简述Unix IO接口及其函数
8.2.1 open函数——打开文件
函数原型:
#include
int open(const char *pathname,int flag, .../*mode_t mode*/);
返回值:成功则返回文件描述符,失败则返回-1;
参数说明:
pathname : 打开或创建的文件名
flag : 为常量,这些常量定义在 头文件中。
8.2.2 creat函数——创建文件
函数原型:
#include
int creat(const char * pathname,mode_t mode)
返回值:成功则返回为只写打开的文件描述符,出错则返回-1;
参数:
pathname : 创建的文件名
mode:模板
creat函数缺点是以只写的方式打开所创建的文件,如果需要写完文件后,读取文件的内容,则必须先creat文件,close文件,在open文件,最后read文件。而现在open函数也具有该功能:
open(pathname,O_RDWR|O_CREAT|O_TRUNC,mode);
8.2.3 close函数——关闭打开的文件
函数原型:
#include
int close(int filedes);
返回值:0(成功),-1(失败);
参数:fildedes 文件描述符
8.2.4 lseek函数——定位文件偏移量
函数原型:
#include
off_t lseek(int filedes,off_t offset,int whence)
返回值:成功返回新的文件偏移量,出错返回-1;
参数:
filedes:文件描述符
offset:与whence相关联。
8.2.5 read函数——读入文件内容
原型:
#include
ssize_t read(int filedes,void * buf,size_t nbytes)
返回值:成功返回读取到的字节数,若到达文件尾返回0,出错返回-1;
参数:
filedes:文件描述符
buf:存放读取的数据
nbytes:读取的字节数
8.2.6 write函数——写入文件
原型:
#include
ssize_t write(int filedes,const void * buf,size_t nbytes);
返回值:成功则返回已写的字节数,出错返回-1;
参数:
filedes:文件描述符
buf:写入文件的数据
nbytes:写入文件的字节数
8.2.7 dup、dup2——复制文件描述符
原型:
#include
int dup(int filedes);
返回值:成功返回新的文件描述符,出错 -1;
参数:
filedes:需要复制的文件描述符;
int dup2(int filedes,int filedes2);
返回值:成功返回新的文件描述符,出错 -1;
参数:
filedes:需要复制的文件描述符;
filedes2:指定返回文件描述符的值,如果指定的这个值已经打开,则将其关闭;
8.2.8 sync、fsync、fdatasync——保证磁盘实际的文件内容与高速缓冲区的内容一致
原型:
#include
int fsync(int filedes);
描述:只针对所描述的filedes指定的文件起作用,并同步更新文件的属性,且在等待写入磁盘后操作结束然后返回;
返回值:成功返回0,出错返回-1
参数:filedes 设定的同步的文件描述符
int fdatasync(int filedes)
描述:同fsync类似,只是fsync同步更新文件的属性,fdatasync只针对数据;
返回值:成功返回0,出错返回-1
参数:filedes 设定的同步的文件描述符
void sync(void);
描述:将所有修改过的块缓冲区排入写队列然后返回,它并不等待实际写磁盘操作结束
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拥有一个不定长参数,arg 获得不定长参数,即输出的时候格式化串对应的值。
vsprintf代码如下:
int vsprintf(char *buf, const char fmt, va_list args)
{
char p;
char tmp[256];
va_list p_next_arg = args;
for (p = buf; *fmt; fmt++) {
if (*fmt != ‘%’){
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case ‘x’:
itoa(tmp, ((int)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case ‘s’:
break;
default:
break;
}
}
return (p - buf);
}
vsprintf 程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字符串的长度。在printf中调用系统函数write(buf,i)将长度为 i 的 buf 输出。write函数的第一个参数为fd,也就是描述符,1代表标准输出。syscall将字符串中的字节从寄存器通过总线以ASCII码格式复制到显卡的显存中,字符显示驱动子程序将通过 ASCII 码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB 分量)来进行打印输出。
8.4 getchar的实现分析
Int getchar(void)
{
Char c;
Return (read(0,&c,1)==1)?(unsigned char)c:EOF
}
getchar返回值为int类型,是用户输入的第一个字符的ASCII码,如出错返回-1。当程序调用getchar时,程序等待用户按键输入。用户输入的字符被存放在缓冲区中,直到用户按回车为止(回车字符也放在缓冲区中)。当用户输入回车之后,getchar开始从stdin流每次读入一个字符并且显示到控制台上。如果用户在按回车之前输入了不止一个字符,那么其他的字符都会保留在键盘缓存区中,等待后续的getchar再读取。即,如果不清空缓冲区,后续getchar会直接读取不会等待输入。
8.5本章小结
本章简要介绍了系统IO的概念功能,常用的IO函数与功能并以hello为示例就hello中的printf和getchar两个IO函数进行了IO分析,进一步体现了IO的特点与作用。
(第8章1分)
结论
全文以一个hello.c文件为示例,跟踪了在计算机系统中一个程序一步步被执行的过程,展现了一个代码项目文件Program如何经过计算机的预处理,编译,汇编,链接最终形成计算机的可执行文件的过程,这一部分体现在第二至第五章,至这一部分我们的hello文件已经做好了被计算机系统执行的准备工作。第六,七,八章进一步展现了操作系统是如何执行这一“准备好的”可执行文件的。具体体现在第六章介绍的操作系统对这一进程的创建,执行,信号处理等进程管理,第七章介绍的进程存储空间管理,第八章介绍的IO管理上。通过这一些列流程,我们得以窥见计算机系统执行一个程序的基本流程与原理,并进一步深入理解了计算机系统软件,硬件的相互配合。hello虽小,却是一切代码的映射,再大的工程在执行时也不外经历这么几步,经历这些流程。对hello执行流程的深入分析对我们深入了解计算机系统有很大帮助。