hello的一生

第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:首先从一个写好的源程序程序文件(Program)开始,GCC编译器的驱动程序读取它,依次通过预处理器cpp将其预处理为一个修改了的源程序,通过编译器ccl把它变成汇编程序,汇编器as把它转化为可重定位的目标程序,最后再由链接器ld将其变为可执行的目标程序。这时,在shell中运行它,OS(进程管理)会通过fork来为其创建一个新的进程(Process)。这便是P2P的过程。
020:首先,通过Editor编写出一个源程序hello.c,然后通过GCC编译器的驱动程序读取它,并依次经过预处理,编译,汇编,链接来生成一个可执行文件,然后再shell中输入执行它的指令,shell通过fork为其创建新的子进程,然后通过exceve在进程的上下文中加载并运行hello,把它映射到对应虚拟内存区域,并依需求载入物理内存,在CPU的帮助下,它的指令被一步步执行,实现它拥有的功能,在程序运行结束后,父进程会对其进行回收,内核把它从系统中清除,hello完成了它的一生,这便是020的过程。

1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发工具:GDB/OBJDUMP;EDB;gedit+gcc;CodeBlocks 64位等。

1.3 中间结果
hello.i 预处理后修改了的源程序
hello.s 汇编生成的hello的汇编程序
hello.o 编译生成的hello的可重定位目标程序
hello 链接生成的hello的可执行目标程序
asm.txt hello.o的反汇编文件

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
1.4 本章小结
hello从被编写为源程序文件开始,经过预处理,编译,汇编,链接等一步步的蜕变,生成了一个可执行目标文件,这时hello便正式有了生命,可以在shell中运行,shell通过fork创建进程,exceve加载并运行程序,并通过内存系统和CPU一系列的配合,hello实现了它的功能,当程序执行结束后,shell又会通过父进程来回收它,删掉他在系统中的痕迹,这便是hello的程序人生。

第2章 预处理
2.1 预处理的概念与作用
概念:预处理器cpp根据以字符#开头的命令,修改原始的C程序。结果是得到了另一个C程序,通常以.i为文件扩展名。

作用:
1)处理文件包含:对于C源程序中的#include指令,如#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它们直接插入到程序文本中。
2)处理宏定义:对于#define指令,进行宏替换,用所定义的实际值去替换代表它的符号。
3)处理注释:删除C源程序中的注释内容。
4)处理条件编译:根据条件编译指令(如#if、#elif、#else等),按条件选择符合的代码送至编译器编译,从而有选择地执行相应操作。
5)处理一些特殊控制指令,如#error等。

2.2在Ubuntu下预处理的命令
cpp hello.c > hello.i

hello的一生
图2-1 在Ubuntu下预处理的命令

2.3 Hello的预处理结果解析
可见hello.i与hello.c相比,代码大大增多,而源程序hello.c中除注释和头文件部分位于hello.i的最后。
hello的一生
图2-2 hello.i中与源程序相同部分

然后往上看,可以发现hello.c中的三个头文件stdio.h , unistd.h , stdlib.h都被从系统中读取并展开插入到了程序文本中。

hello的一生hello的一生hello的一生
图2-3 hello.i中插入的头文件

而在hello.i中,却找不到与hello.c中相同注释,说明在预处理过程中,所有的注释都被删除掉了。
hello的一生
图2-4 hello.i中出现的其他文件
而仔细观察hello.i中展开的头文件部分,发现还出现了许多在hello.o中没有见过的其他头文件。因为对于直接引用的头文件stdio.h , unistd.h , stdlib.h来说,它们内部还可能包含其他的多种头文件,其包含的头文件又可能包含另一些头文件,所以在预处理时,对头文件时递归展开并插入的,会插入所用要用到的头文件。
2.4 本章小结
预处理是hello程序生命周期的中的第一次转变。在这个过程中,预处理器cpp为hello去掉hello.c中无用的包袱(代码中的注释),将引用的头文件展开插入到程序文本中并替换宏定义,为hello程序人生的下一步转变提供充分的准备。

第3章 编译
3.1 编译的概念与作用
概念:编译器ccl将文本文件hello.i翻译成文本文件hello.s。它包含一个汇编语言程序。
作用:编译器做一些语法分析、词法分析、语义分析等,若检查无错误,便将高级程序设计语言(C语言)书写的程序转变为与其等价的汇编语言程序(可能依情况做一些优化),汇编语言是介于高级语言和机器语言之间的中间代码,它为不同高级语言的不同编译器提供了通用的输出语言,使他们产生的输出文件都是用的一样的输出语言。

3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s

hello的一生
图3-1 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.3.1 数据
1)常量
数字常量:hello.s中出现的常量有以下几个地方:对全局变量赋值2,将argc与3比较,循环中每次给i加1,循环中止条件i<10的判断。
hello的一生
hello的一生
hello的一生
hello的一生
图3-2 hello.s中出现的数字常量

字符串常量:字符串常量为源程序中printf函数中直接输出的部分。可见它被放在了.rodata节中。
hello的一生
图3-3 hello.s中出现的字符串常量

2)变量
全局变量:有一个全局变量int sleepsecs,可见它作为全局变量被放在.data节中,设置了大小为4字节,并初始化为2。
hello的一生
图3-4 hello.s中出现的全局变量

局部变量:其中局部变量int argc存放在栈中-20(%rbp)的位置,通过与3的比较操作可以找到它;局部变量int i存放在栈中-4(%rbp)的位置,从与9的比较和循环中每次加1的操作可以找到它;还有一个局部变量数组char argv[],可以通过L4(循环部分)中输出函数前的两次取值找到argv[1],argv[2]的位置。
hello的一生
hello的一生
③在这里插入图片描述hello的一生
图3-5 hello.s中出现的局部变量

3.3.2 赋值
赋值操作共有两个,一个是对全局变量sleepsecs的赋值,源程序里令
int sleepsecs = 2.5。而因为sleepsecs为整型变量,所以编译时直接对其赋值为2。
另一个是对局部变量i赋值,之前已经得知i存在栈中-4(%rbp)的位置,可见它的赋值操作如3-6②所示。
hello的一生

hello的一生
图3-6 hello.s中的赋值
3.3.3 类型转换
对全局变量sleepsecs的赋值存在一个隐式类型转换。int sleepsecs = 2.5因为它把一个浮点数赋给整型变量,所以它会把浮点数2.5强制转换为2(浮点数转整数时向零舍入),如下图所示:
hello的一生
图3-7 hello.s中的类型转换

3.3.4 算术操作
存在一个算术操作i++,即在每次循环中对变量i加1,之前已经得知i存在栈中-4(%rbp)的位置,那么通过add每次对-4(%rbp)中内容加1即可。
hello的一生
图3-8 hello.s中的算术操作

3.3.5 关系操作
存在两个关系操作,第一个是判断argc!=3,即将argc(栈中-20(%rbp)的内容)与3通过cmp进行比较。第二个是判断i<10,即将i(栈中-4(%rbp)的内容)与9通过cmp进行比较(即判断i<=9)。
hello的一生
hello的一生
图3-9 hello.s中的关系操作

3.3.6 数组/指针/结构操作
存在一个对数组argv的操作,在printf函数中引用了数组argv的两个元素argv[1],argv[2], 可以通过L4(循环部分)中输出函数前的两次取值找到argv[1],argv[2]的位置。

hello的一生
图3-10 hello.s中的数组操作

3.3.7 控制转移
第一处是判断argv是否等于3,若不等于,则继续执行,若等于,则跳转至L2处(循环前对i初始化)继续执行。
第二处是对i初始化为0后的无条件跳转,以跳到L4,即循环部分代码。
第三处是判断是否达到循环终止条件(i<10),这里用i与9进行比较,若小于等于则跳回L4重复循环,否则执行循环外的下一步。这里将i<10的比较改为了与其等价的i<=9。
hello的一生
hello的一生
hello的一生
图3-11 hello.s中的控制转移
3.3.8 函数调用
共有三次函数调用,第一次调用puts函数输出一个字符串常量,参数存在%rdi中;
第二次调用printf函数输出字符串常量以及两个局部变量数组的元素,字符串常量作为参数1存在%rdi中,两个数组元素作为参数2、3分别存在%rsi和%rdx中。
第三次调用sleep函数,以sleepsecs为参数,参数存在%edi中。
hello的一生
hello的一生
hello的一生
图3-12 hello.s中的函数调用

3.4 本章小结
在编译的过程中,hello实现了从高级语言到汇编语言的转变,它比原来更加接近于底层,更加接近于机器。只有通过这一步骤,他才能被之后的汇编器所理解,从而翻译出真正的机器语言,在计算机上自如地运转。它离拥有程序的生命更近了一步。

第4章 汇编
4.1 汇编的概念与作用
概念:汇编器as将hello.s翻译成机器语言指令,把这些指令打包成一种叫可重定位目标程序的格式,并将结果保存在目标文件hello.o中。(hello.o是一个二进制文件)。
作用:把汇编语言翻译成机器语言,用二进制码代替汇编语言中的符号,即让它成为机器可以直接识别的程序。

4.2 在Ubuntu下汇编的命令
as hello.s -o hello.o
hello的一生

                图4-1 在Ubuntu下汇编的命令

4.3 可重定位目标elf格式
通过 readelf –a 指令查看可重定位目标文件hello.o的ELF文件各节基本信息。

1)ELF可重定位目标文件中首先是ELF头,它以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、机器类型、节头部表的文件偏移,以及节头部表中条目的大小和数量。
hello的一生
图4-2 ELF头

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

hello的一生
图4-3 节头部表

3)重定位项目
其中.rela.text节是一个.text节中位置的列表。当链接器把这个目标文件和其他文件组合时,需要修改这些位置。
.rela.eh_frame节包含了对en_frame节的重定位信息。
其中,Offset是需要被修改的引用的字节偏移(在代码节或数据节的偏移),Info指示了重定位目标在.symtab中的偏移量和重定位类型,Type表示不同的重定位类型,例如图中R_X86_64_PC32就表示重定位一个使用32位PC相对地址的引用。Sym. Name表示被修改引用应该指向的符号,Append用于一些类型的重定位要使用它对被修改引用的值做偏移调整。
可见下图中,在链接时需要对.rodata中的两个字符串常量(用于printf函数中),全局变量sleepsecs,以及函数puts,exit,printf,sleep,getchar进行重定位。

hello的一生
图4-4 重定位项目
4)符号表
它存放了程序中定义和引用的函数和全局变量的信息(不包含局部变量的条目)。
hello的一生
图4-5 符号表
4.4 Hello.o的结果解析
通过objdump可以得到hello.o的反汇编代码,在汇编时从main开始(地址为0)依次为每一行指令都分配了一个地址。可以在下图中看到汇编所得到的机器语言,机器语言由二进制的操作码和操作数构成,图中给出了一个示例。每一条汇编指令能翻译成一条对应的机器指令,汇编语言可以看作是二进制机器语言的助记符。
hello的一生
图4-6 hello.o的反汇编
1)可以看出,汇编语言中操作数是十进制的,而机器语言反汇编得到的操作数是十六进制的。
hello的一生
图4-7 汇编与机器语言操作数的不同

2)对全局变量(即字符串常量)的引用,汇编语言中是用的全局变量所在的那一段的名称加上%rip的值,而hello.o中用的是0加%rip的值,因为当前为可重定位目标文件,之后还需经过重定位方可确定其具体位置,所以这里都用0来代替。
hello的一生
图4-8 汇编与机器语言引用全局变量的不同

3)分支转移
对分支转移,hello.s的汇编语言中在跳转指令后用对应段的名称(如.L3)表示跳转到的位置,而hello.o中因为每行指令都被分配了对应的地址(从main函数第一条指令地址为0开始),在跳转指令后用跳转目的的地址来表示跳转到的位置。
hello的一生
图4-9 汇编与机器语言分支转移的不同
4)函数调用
hello.s中的汇编语言在函数调用时,在call指令后用函数的名字表示对其调用,而反汇编指令在call指令后加上下一条指令的地址来表示,观察机器语言,发现其中操作数都为0,即函数的相对地址为0,因为再链接生成可执行文件后才会生成其确定的地址,所以这里的相对地址都用0代替。
hello的一生
图4-10 汇编与机器语言函数调用的不同
4.5 本章小结
在汇编过程中,hello实现了由汇编语言到机器语言的转变,hello第一次称为了机器可以读懂的代码,它的每条指令得到了一个暂时的地址,并通过在不同地址间的跳转把程序连接成了一个整体。hello也第一次由文本程序变成了二进制程序,虽然让人难以读懂,却又距在机器上运行的目标更近了一步。

第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

hello的一生
图5-1 在Ubuntu下链接的命令

5.3 可执行目标文件hello的格式
可通过readelf -a来查看可执行目标文件hello的ELF文件各节基本信息(包含在节头部表中),他描述了不同节的位置和大小等基本信息。其中,它的第一列按地址顺序列出了各段的名称及大小,第三列列出来各段的起始地址,最后一列列出来各段的偏移量。
hello的一生
hello的一生
hello的一生
图5-2 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
通过readelf查看hello的Program Headers,可发现其中列出的虚拟地址在edb的Data Dump中都能找到对应的位置。

hello的一生
图5-3 edb加载hello并查看

还可发现Data Dump是从地址0x400000开始的,并且该处有ELF的标识,可以判断从可执行文件时加载的信息(只读代码段,读/写段)是从地址0x400000处开始的。
hello的一生
图5-4 edb中查看Data Dump
在edb中查看Symbols选项,并与5.3中列出的虚拟地址各段信息做对比,可以看到都显示出了匹配的相同地址。

hello的一生
图5-5 edb中查看hello并做对比

5.5 链接的重定位过程分析
1)在hello.o中,我们看不到各函数的代码段,而在hello中,存在了各个函数的代码段并且,并且每个函数(以及其每条)指令都有了对应的虚拟地址。
hello的一生
图5-6 hello与hello.o中的函数

2)在hello.o中main函数的起始地址为0,往后依次得到每条指令的简单地址,而hello中每条指令都拥有一个虚拟地址,main函数也不是从0开始了。
hello的一生
图5-7 hello与hello.o中的地址

3)对于全局变量的引用,hello.o中用0加上%rip的值来表示全局变量的位置,因为当时并未对全局变量进行定位,而在hello中,因为全局变量都有了确定的位置,所以用实际的相对偏移加%rip的值来描述其位置。
hello的一生
图5-8 hello与hello.o中引用全局变量

4)对于函数的调用,因为hello.o中尚未对函数定位,所以在调用时都用call加下一条指令地址来表示,而hello中各函数已拥有了各自的虚拟地址,所以在call后加其虚拟地址来实现函数调用。
hello的一生
图5-9 hello与hello.o中的函数调用
5)对于跳转指令,hello.o中在其后加上目的地址,为main从0开始对每条指令分配的地址;而hello中同样加上目的地址,但这里是每条指令的虚拟地址。
hello的一生
图5-10 hello与hello.o中的跳转

链接主要分为两个过程:符号解析和重定位。
1)符号解析:目标文件定义和引用符号,符号解析将每个符号引用和一个符号定义关联起来。
2)重定位:编译器和汇编器生成从0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目的详细指令,不加甄别地执行这样的重定位。
对于hello来说,链接器把hello中的符号定义都与一个内存位置关联了起来,重定位了这些节,并在之后对符号的引用中把它们指向重定位后的地方。hello中每条指令都对应了一个虚拟地址,而且对每个函数,全局变量也都它关联到了一个虚拟地址,在函数调用,全局变量的引用,以及跳转等操作时都通过虚拟地址来进行,从而执行这些指令。
5.6 hello的执行流程
_dl_start 地址:0x7ff806de3ea0
_dl_init 地址:0x7f75c903e630
_start 地址:0x400500
_libc_start_main 地址:0x7fce59403ab0
_cxa_atexit 地址:0x7f38b81b9430
_libc_csu_init 地址:0x4005c0
_setjmp 地址:0x7f38b81b4c10
_sigsetjmp 地址:0x7efd8eb79b70
_sigjmp_save 地址:0x7efd8eb79bd0
main 地址:0x400532
(puts 地址:0x4004b0
exit 地址:0x4004e0) (argc!=3时)
print 地址:0x4004c0
sleep 地址:0x4004f0 (以上两个在循环体中执行10次)
getchar 地址:0x4004d0
_dl_runtime_resolve_xsave 地址:0x7f5852241680
_dl_fixup 地址:0x7f5852239df0
_uflow 地址:0x7f593a9a10d0
exit 地址:0x7f889f672120

5.7 Hello的动态链接分析
当程序调用一个由共享库定义的函数时,编译器无法预测这个函数运行时的地址,因为定义它的共享模块在运行时可以加载到任何位置。这时,编译系统提供了延迟绑定的方法,将过程地址的绑定推迟到第一次调用该过程时。他通过GOT和过程链接表PLT的协作来解析函数的地址。在加载时,动态链接器会重定位GOT中的每个条目,使它包含正确的绝对地址,而PLT中的每个函数负责调用不同函数。那么,通过观察edb,便可发现dl_init后.got.plt节发生的变化。
通过readelf可以发现.got.plt节在地址为0x601000的地方开始。而它后面的.data节从地址0x601040开始。那么中间部分便是.got.plt的内容。
hello的一生
图5-11 利用readelf查看.got.plt

用edb观察.got.plt,发现在dl_init前后.got.plt的第8到15个字节发生了变化。
在这里,它们对应GOT[1]和GOT[2]的位置。其中,这时GOT[1]包含动态链接器在解析函数地址时使用的信息,而GOT[2]是动态链接器ld-linux.so模块中的入口点。加载时,动态链接器重定位GOT中的这些条目,使它们包含正确的绝对地址。
hello的一生
图5-12 用edb查看.got.plt

5.8 本章小结
在链接过程中,各种代码和数据片段收集并组合为一个单一文件。利用链接器,分离编译称为可能,我们不用将应用程序组织为巨大的源文件,只是把它们分解为更小更模管理的模块,并在应用时将它们链接就可以完成一个完整的任务。
经过链接,hello.o与其他一些所需部分结合起来,生成可执行目标文件,这时,hello便可以开始在shell中运行了,正式开始它的程序人生。

第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个执行中程序的实例。系统中每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
作用:在现代系统上运行一个程序时,我们会得到一个假象,好像我们的程序是系统中唯一运行的程序一样。我们的程序好像独占处理器和内存。处理器好像无间断地一条接一条执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。这些假象是通过进程的概念提供的。进程提供给应用程序的关键抽象:1)一个独立的逻辑控制流,提供一个程序独占处理器的假象;2)一个私有的地址空间,提供一个程序独占地使用内存系统的假象。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行,求值步骤解析命令行,并代表用户运行程序。
处理流程:shell打印一个命令行提示符,等待用户在stdin上输入命令行,然后对命令行求值,即解析以空格分隔的命令行参数,第一个参数被假设为要么是一个内置的shell命令名,马上就会解释这个命令并执行相应操作;要么是一个可执行目标文件,会通过fork创建一个新的子进程,并在新的子进程的上下文中通过execve加载并运行这个文件。如果用户要求在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid函数等待作业终止并回收。当作业终止时,shell开始下一轮的迭代。
hello的一生
图6-1 Shell处理流程图
6.3 Hello的fork进程创建过程
父进程通过调用fork函数创建一个新的运行的子进程。调用fork函数后,新创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本(包括代码、数据段、堆、共享库以及用户栈),子进程获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。fork被调用一次,却返回两次,子进程返回0,父进程返回子进程的PID。子进程有不同于父进程的PID。
hello的一生
图6-2 fork创建子进程

6.4 Hello的execve过程
exceve函数在当前进程的上下文中加载并运行一个新程序。exceve函数加载并运行可执行目标文件,并带参数列表和环境变量列表。只有当出现错误时,exceve才会返回到调用程序,否则,exceve调用依一次且从不返回。在exceve加载了可执行目标文件后,他调用启动代码,启动代码设置栈,将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序,由此将控制传递给新程序的主函数。
6.5 Hello的进程执行
系统中通常有许多程序在运行,那么进程会为每个程序提供一个好像它在独占地使用处理器的假象。这时依赖于进程提供的独立的逻辑控制流(由上下文切换机制提供)。如一个系统运行着多个进程,那么处理器的一个物理控制流就被分成了多个逻辑控制流,每个进程1个。这些逻辑流的执行是交错的,它们轮流使用处理器,会存在并发执行的现象。其中,一个进程执行它的控制流的一部分的每一时间段叫做时间片。这样的机制使进程在执行时仿佛独占了处理器。
处理器用某个控制寄存器中的一个模式位来限制一个应用可以执行的指令以及它可以访问的地址空间范围。没有设置模式位时,进程运行在用户模式中,它必须通过系统调用接口才可间接访问内核代码和数据;而设置模式位时,它运行在内核模式中,可以执行指令集中的任何指令,访问系统内存的任何位置。异常发生时,控制传递到异常处理程序,由用户模式转变到内核模式,返回至应用程序代码时,又从内核模式转变到用户模式。
操作系统内核使用上下文切换来实现多任务。内核为每个进程维持一个上下文,它是内核重启被抢占的进程所需的状态,包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构的值。
进程执行到某些时刻,内核可决定抢占该进程,并重新开启一个先前被抢占了的进程,这种决策称为调度。内核调度一个新的进程运行后,通过上下文切换机制来转移控制到新的进程:1)保存当前进程上下文;2)恢复某个先前被抢占的进程被保存的上下文3)将控制转移给这个新恢复的进程。当内核代表用户执行系统调用时,可能会发生上下文切换,这时就存在着用户态与核心态的转换。
如下图所示:

hello的一生

              图6-3 进程的上下文切换

6.6 hello的异常与信号处理
hello执行过程中会出现的异常有:
中断:他由处理器外部的I/O设备的信号引起(如Ctrl-Z,Ctrl-C),可能产生信号SIGSTP,它会将程序挂起,直到有下一个SIGCONT信号;也可能产生信号SIGINT,它会将进程终止。

1)运行程序
在终端运行程序,打印十次Hello 姓名 学号后,输入hello(任意)回车,程序执行完成,进程被回收。
hello的一生
图6-4 运行程序
2)运行时不停乱按(包括回车)
发现他会把乱按的字符打印出来,按回车它会换一行,但是这些都不影响程序的正常执行,因为当程序执行时他不会受到外部输入的影响,它会阻塞这些操作产生的信号,而因为之前将大量字符(包括回车)输入到了屏幕上,所以最后不用自己再输入字符来结束程序,而是直接读取之前的输入。
hello的一生
图6-5 运行程序时乱按

3)运行程序时按Ctrl-Z
程序运行时按Ctrl-Z,这时,产生中断异常,它的父进程会接收到信号SIGSTP并运行信号处理程序,然后便发现程序在这时被挂起了,并打印了相关挂起信息。
hello的一生
图6-6 运行程序按Ctrl-Z

Ctrl-Z后运行ps,打印出了各进程的pid,可以看到之前挂起的进程hello。
hello的一生
图6-7 Ctrl-Z后运行ps

Ctrl-Z后运行jobs,打印出了被挂起进程组的jid,可以看到之前被挂起的hello,以被挂起的标识Stopped。
hello的一生
图6-8 Ctrl-Z后运行jobs

Ctrl-Z后运行pstree,可看到它打印出的信息
hello的一生
hello的一生
hello的一生
图6-9 Ctrl-Z后运行pstree

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

重新开始hello,按Ctrl-Z后通过ps得知hello的进程号为3701,那么便可通过kill -9 3701发送信号SIGKILL给进程3701,它会导致该进程被杀死。然后再运行ps,可发现已被杀死的进程hello。
hello的一生
图6-11 Ctrl-Z后运行kill

4)运行程序时按Ctrl-C
运行hello时按Ctrl-C,会导致一个中断异常,从而内核产生信号SIGINT,父进程受到它后,向子进程发生SIGKILL来强制终止子进程hello并回收它。这时在运行ps,可以发现并没有进程hello,可以说明他已经被终止并回收了。
hello的一生
图6-12 运行程序时按Ctrl-C

6.7本章小结
hello开始真正在系统上运行时,离不开shell给它提供的平台,也离不开进程机制的支持和各种信号的通知。从创建进程,到在进程中加载程序,信号以及上下文切换使其可以自如的运行在计算机中,就好像独占了整个CPU。而当hello的进程生命结束,同样需要各种信号与系统的配合来对它进行终止,回收。程序的高效运行离不开异常、信号、进程等概念,正是这些机制支持hello能够顺利地在计算机上运行。

第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:逻辑地址指由程序产生的与段相关的偏移地址部分。在有地址变换功能的计算机中,访内指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。从hello的反汇编代码中看到的地址,它们需要通过计算,即加上对应段的基地址才能得到真正的地址,这些便是hello中的逻辑地址。
线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,其偏移量加上基地址就是线性地址。hello的反汇编文件中看到的地址(即逻辑地址)中的偏移量,加上对应段的基地址,便得到了hello中内容对应的线性地址。
虚拟地址:使用虚拟寻址时,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送至内存前先转换成适当的物理地址。虚拟地址转化成物理地址的过程叫做地址翻译。在linux中,虚拟地址数值树等于线性地址,即hello中看到的地址加上对应段基地址的值。
物理地址:计算机系统的主存被组织成一个M个连续字节大小的单元组成的数组,每字节都有一个独立的物理地址。它是物理内存中实际对应的地址,在hello的运行中,在访问内存时需要通过CPU产生虚拟地址,然后通过地址翻译得到一个物理地址,并通过物理地址访问内存中的位置。

7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由段选择符和偏移量组成,线性地址为段首地址与逻辑地址中的偏移量组成。其中,段首地址存放在段描述符中。而段描述符存放在描述符表中,也就是GDT(全局描述符表)或LDT(局部描述符表)中。
hello的一生
图7-1 段选择符
段选择符的组成如上图。其中TI指示段描述符是在GDT还是LDT中,而索引指示段描述符在段描述符表中的位置。由此,便可以通过段选择符的指示在段描述符表中找到对应的段描述符,然后便可从段描述符中获得段首地址,将其与逻辑地址中的偏移量相加,就得到了线性地址。

7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(虚拟地址)由虚拟页号v*n和虚拟页偏移VPO组成。首先,MMU从线性地址中抽取出v*n,并且检查TLB,看他是否因为前面某个内存引用缓存了PTE的一个副本。TLB从v*n中抽取出TLB索引和TLB标记,查找对应组中是否有匹配的条目。若命中,将缓存的PPN返回给MMU。若不命中,MMU需从页表中的PTE中取出PPN,若得到的PTE无效或标记不匹配,就产生缺页,内核需调入所需页面,重新运行加载指令,若有效,则取出PPN。最后将线性地址中的VPO与PPN连接起来就得到了对应的物理地址。
hello的一生
图7-2 线性地址到物理地址的变换

7.4 TLB与四级页表支持下的VA到PA的变换
虚拟地址VA虚拟页号v*n和虚拟页偏移VPO组成。若TLB命中时,所做操作与7.3中相同;若TLB不命中时,v*n被划分为四个片,每个片被用作到一个页表的偏移量,CR3寄存器包含L1页表的物理地址。v*n1提供到一个L1 PTE的偏移量,这个PTE包含L2页表的基地址。v*n2提供到一个L2 PTE的偏移量,依次类推。最后在L4页表中对应的PTE中取出PPN,与VPO连接,形成物理地址PA。

hello的一生
图7-3 TLB与四级页表支持下的VA到PA的变换

7.5 三级Cache支持下的物理内存访问
MMU将物理地址发给L1缓存,缓存从物理地址中取出缓存偏移CO、缓存组索引CI以及缓存标记CT。若缓存中CI所指示的组有标记与CT匹配的条目且有效位为1,则检测到一个命中,读出在偏移量CO处的数据字节,并把它返回给MMU,随后MMU将它传递给CPU。若不命中,则需到低一级Cache(若L3 cache中找不到则到主存)中取出相应的块将其放入当前cache中,重新执行对应指令,访问要找的数据。
hello的一生
图7-4 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的pid。为了给这个新进程创建虚拟内存,他创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。当fork从新进程返回,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要:1)删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。2)映射私有区域:为新程序hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。3) 映射共享区域:如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。4) 设置程序计数器(PC) :设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

7.8 缺页故障与缺页中断处理
对虚拟内存来说,DRAM缓存不命中称为缺页。如下例所示,CPU引用了VP3中的一个字,而VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,因为有效位0,所以并未缓存,引发了缺页异常,调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,这里以存放在PP3中的VP4为例。若VP4被修改了,那么内核会将它复制回磁盘。内核会修改VP4的页表条目,反映出VP4以不在主存中。然后,内核从磁盘复制VP3到内存中PP3位置,然后处理程序返回,重新启动导致缺页的指令。这时,VP3已存在主存中,不会在导致缺页,可以正常读取。
hello的一生
hello的一生
图7-5 缺页故障与缺页中断处理

如图所示,处理故障前,PP3中存的是VP4,而PTE 3处有效位为0,故对其访问时引发一个缺页。而处理后,PP3中存的是VP3,PTE 4处有效位为0,PTE 3处有效位变为1。由此可见VP4作为牺牲页被替换出去了。

7.9动态存储分配管理
C程序当运行时需要额外虚拟内存时,用动态内存分配器是一种更加方便也有更好可移植性的方法。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间的细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放。这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
在hello中,printf函数会调用malloc函数。malloc函数返回一个指针,指向大小至少为size字节的内存块。有时,当我们运行程序时,才会直到某些数据结构的大小,这样便需要使用到动态内存分配。
当进行动态内存分配时,任何分配器都需要一些数据结构,以隐式空闲链表为例,带边界标签的隐式空闲链表中的每个块是由一个字的头部和一个字的脚部,有效载荷以及可能的额外填充组成的。头部和脚部编码了块的大小以及块是已分配还是空闲的。它们之间便是malloc时请求的有效载荷,以及为了满足8字节对齐要求的填充部分。他是通过头部脚部中的大小字段隐式连接的。在应用请求k字节的块时,分配器搜索空闲链表,查到一个足够大可以放置所请求块的空闲块。一旦其找到匹配的空闲块,就要分配空闲块的空间,若剩余部分足以形成新的空闲块,则将其分割。若分配器找不到合适的空闲块,则需要向内核额外请求堆内存,将其转化为大的空闲块,插入到空闲链表中,然后将请求块放置于此。当分配器释放一个已分配块时,可能有其他空闲块与新释放的相邻,这时需要进行合并,这时由于每个块的头部脚部记录了块是否空闲,那么便可通过检查其前面块的脚部和后面块的头部来判断是否有空闲块相邻。若是,也只需通过修改头部脚部便可进行合并。
以上便是动态内存管理的基本方法,下面来说一下动态内存管理的策略。
首先,对与堆中的块的组织,可以选择隐式、显示、分离空闲链表等。当分配器查找空闲块时,又可以采用不同的放置策略,如首次适配(从头开始搜索链表)、下一次适配(从上一次找到的空闲块的剩余块除法)以及最佳适配等。在分割空闲块时,又可以采用将剩余块分割为新的空闲块的策略。在合并时,可以采用带边界标记的合并,就像在上一段基本方法中所描述,通过边界标记来判断当前块周围是否也同样是空闲块,以此来判断是否需要合并。
这些就是动态内存管理的基本方法与策略。
7.10本章小结
在hello 的运行中,它需要与其他进程共享主存,而对于有限的主存空间,如果太多的进程需要太多的内存,这就可能会使程序没有空间可用。而虚拟内存的概念很好地解决了这一点。他是硬件异常,地址翻译,主存,磁盘和内核软件的完美交互,它为每个进程提供了大的私有的地址空间。在这个机制下,hello可以通过malloc从堆中申请内存,也可以通过free释放掉使用结束的内存。可以说,虚拟内存及其管理机制为hello提供了一个*正确运行的广阔平台。

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/ O 设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O(即unix io接口),这使得所有的输入和输出都能以一种统一且一致的方式来执行。这便是Linux的IO设备管理方法。
8.2 简述Unix IO接口及其函数
这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,它使得所有的输入和输出都能以一种统一且一致的方式来执行:
1)打开文件:一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2)Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) ,标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k 。
4)读写文件:一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k>=m 时执行读操作会触发一个称为EOF 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。
类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
5)关闭文件:当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

Unix IO接口的函数:
1)打开文件:int open(char *filename, int flags, mode_t mode);
调用open函数,通知内核你准备好访问该文件,打开一个已存在的文件或创建一个新文件。flags参数指明进程如何访问这个文件,mode参数指定新文件的访问权限位。它返回一个小的描述符数字---- 文件描述符。返回的描述符总是在进程中当前没有打开的最小描述符。若返回-1,则出错。

2)关闭文件:int close(int fd);
调用close函数,通知内核你要结束访问一个文件,关闭打开的一个文件。成功返回0,出错返回-1。

3)读文件:ssize_t read(int fd, void *buf, size_t n);
调用read函数,从当前文件位置复制字节到内存位置,然后更新文件位置。从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示错误,返回值0表示EOF,否则,返回值表示的是实际传送的字节数量。返回类型 ssize_t 是有符号整数。

4)写文件:ssize_t write(int fd, const void *buf, size_t n);
调用write函数,从内存复制字节到当前文件位置,然后更新文件位置。从内存位置buf复制至多n个字节到描述符fd的当前文件位置。返回值-1表示出错,否则,返回值表示的是从内存向文件fd实际传送的字节数量。

8.3 printf的实现分析
首先,先观察一下printf函数的实现代码:
1.int printf(const char fmt, …)
2.{
3.int i;
4.char buf[256];
5.
6. va_list arg = (va_list)((char
)(&fmt) + 4);
7. i = vsprintf(buf, fmt, arg);
8. write(buf, i);
9.
10. return i;
11. }
可见printf的第一个参数是const char类型的形参fmt,而后面的参数用…代替,因为对于printf来说传入的参数个数不确定。那么为了正确进行打印,在之后便需要设法得知传入参数的个数。
以上代码中第6行: va_list arg = (va_list)((char
)(&fmt) + 4);
首先,va_list表示一个字符指针类型。那么下面分析该类型的变量arg代表着什么。向函数传递参数时,需要把参数按从右至左的顺序依次压入栈中,因为栈是从高地址向低地址增长,所以第一个参数fmt地址最低。那么&fmt便是fmt存在栈中的地址,地址占4字节,再加上4即为下一个参数的地址。所以arg指向了…中的第一个参数。
以上代码中的第7行:i = vsprintf(buf, fmt, arg);
首先观察一下vsprintf的实现。
1.int vsprintf(char *buf, const char fmt, va_list args)
2. {
3. char
p;
4. char tmp[256];
5. va_list p_next_arg = args;
6.
7. for (p=buf;*fmt;fmt++) {
8. if (*fmt != ‘%’) {
9. *p++ = *fmt;
10. continue;
11. }
12.
13. fmt++;
14.
15. switch (*fmt) {
16. case ‘x’:
17. itoa(tmp, ((int)p_next_arg));
18. strcpy(p, tmp);
19. p_next_arg += 4;
20. p += strlen(tmp);
21. break;
22. case ‘s’:
23. break;
24. default:
25. break;
26. }
27. }
28.
29. return (p - buf);
30. }
先看一下printf中第8行代码, write(buf, i);
其中参数一个是字符数组buf,另一个便是vsprintf的返回值i。显然write是写操作,把要打印的东西写到终端。之前我们直到printf需要设法得知传入参数的个数,对个数不定的传入参数进行格式化,这样才能得到格式化的输出。那么,可以推断,vsprintf的作用便是接受确定输出格式的格式字符串fmt,并用格式字符串来格式化参数,以便在write中输出。
然后,再来观察write的实现:
1.write:
2. mov eax, _NR_write
3. mov ebx, [esp + 4]
4. mov ecx, [esp + 8]
5. int INT_VECTOR_SYS_CALL
write函数的作用便是将字符数组buf中的i个元素打印到终端。而这个步骤与硬件有关,这就需要限制程序执行的权限。而write中的最后一行int INT_VECTOR_SYS_CALL便是通过系统来调用函数sys_call来执行下一步操作。
观察函数sys_call的实现:
1.sys_call:
2. call save
3.
4. push dword [p_proc_ready]
5.
6. sti
7.
8. push ecx
9. push ebx
10. call [sys_call_table + eax * 4]
11. add esp, 4 * 3
12.
13. mov [esi + EAXREG - P_STACKBASE], eax
14.
15. cli
16.
17. ret
(其中第2行call save用来保存中断前进程状态)sys_call的功能,便是显示格式化了的字符串。
然后通过字符显示驱动子程序,从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析
getchar由宏实现:#define getchar() getc(stdin)它有一个int型的返回值。当程序调用getchar()时,它会等待用户按键来输入字符。用户输入的字符被存放在缓冲区中,直到用户按了回车键,这时getchar()才会从stdio流中读入一个字符。
getchar函数的返回值是用户输入的字符的ASCII码,若出错返回-1,且将用户输入的字符回显到屏幕.若用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
这可以看做一个异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar函数调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
hello的执行离不开程序间的交互和通信。程序通过操作系统提供的服务来语I/O设备等进行通信。正是通过这些功能,才让hello可以通过printf函数展示在终端,让getchar()读取我们输入的字符来结束hello程序人生的最后一段旅程。正是这些,让hello以它所设计的方式展现在了我们的面前,也让它能接受到我们传递的信号。

结论
当用户向计算机敲入代码形成最初的hello.c源程序时,计算机中开始有了一个叫hello的程序,但在这时,他还不算真正拥有了生命,它需要经过一系列的处理,才能变成可以在终端执行的有生命的程序。拥有生命后,它可以在系统中运行从而完成它的功能与使命,最后,在结束它的一生时,他会被回收,消除曾经存在过的痕迹。下面逐条总结hello经历的过程:
1)GCC编译器驱动程序读取源程序文件hello.c。
2)预处理器cpp将其预处理为一个修改了的源程序hello.i(如读取并插入头文件内容等)。
3)编译器ccl将其翻译成汇编语言程序hello.s。
4)汇编器as将其翻译成机器语言指令,得到可执行目标文件hello.o。
5)链接器ld将重定位目标文件链接为可执行目标文件hello。
6)在shell中输入运行hello的指令,shell通过fork为其创建新的进程。
7)通过execve将hello程序加载并运行。把它映射到对应虚拟内存区域,并依需求载入物理内存。
8)在CPU的帮助下,它的指令被一步步执行,实现它拥有的功能。
9)在程序运行结束后,父进程会对其进行回收,内核把它从系统中清除。
这样,hello便完成了它的程序人生。
计算机系统的设计复杂而严密,对内存,CPU等各个实现都有着精密的处理设计,以涵盖在系统运行时可能遇到的各种情况。计算机系统的运行需要内存,CPU,信号等机制的密切配合,来实现在系统上正确而又高效地运行程序。

附件
hello.i 预处理后修改了的源程序
hello.s 汇编生成的hello的汇编程序
hello.o 编译生成的hello的可重定位目标程序
hello 链接生成的hello的可执行目标程序
asm.txt hello.o的反汇编文件

列出所有的中间产物的文件名,并予以说明起作用。

参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 兰德尔·布莱恩特. 大卫·奥哈拉伦. 深入理解计算机系统 机械工业出版社
[2] 动态链接原理分析 https://blog.csdn.net/shenhuxi_yu/article/details/71437167
[3] printf 函数实现的深入剖析
https://www.cnblogs.com/pianist/p/3315801.html
[4] getchar函数浅谈 https://blog.csdn.net/zhuangyongkang/article/details/38943863
[5] Linux 线性地址,逻辑地址和虚拟地址的关系
https://www.zhihu.com/question/29918252#answer-17622254
[6] 从逻辑地址到线性地址的转换流程
http://www.cnblogs.com/image-eye/archive/2011/07/13/2105765.html