Hello, world!
相信这几乎是每一个新手程序员第一次看到自己编写的程序运行的样子,但当时没什么经验的我们大概是不了解这个程序的“前世今生”的,但现在就有这么一个机会。本次实验,将跟随hello这个小小的程序,探究它平凡而又伟大的一生,利用各种工具来研究它的方方面面,从而了解hello的P2P,020的全过程,并借此提升自己的能力。
关键词:P2P;020;程序的生命周期;进程;
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
2.2在Ubuntu下预处理的命令............................................................................. - 5 -
5.3 可执行目标文件hello的格式........................................................................ - 8 -
6.2 简述壳Shell-bash的作用与处理流程........................................................ - 10 -
6.3 Hello的fork进程创建过程......................................................................... - 10 -
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 -
8.2 简述Unix IO接口及其函数.......................................................................... - 13 -
第1章 概述
1.1 Hello简介
P2P过程:
gcc编译器驱动程序读取程序文件hello.c,然后由预处理器(cpp)根据以#字符开头的命令,修改该程序获得hello.i文件,通过编译器(cll)将文本文件hello.i翻译成文本文件形式的汇编程序hello.s,再通过汇编器(cs)将hello.s翻译成机器语言指令,将指令打包成可重定位的目标文件hello.o,然后通过链接器(ld)合并获得可执行目标文件hello。Linux系统中通过内置命令行解释器shell加载运行hello程序,为hello程序fork进程。
020过程:
Shell通过execve在fork产生的子进程中加载hello,先删除当前虚拟地址的用户部分已存在的数据结构,为hello的代码、数据、bss和栈区域创建新的区域结构,然后映射共享区域,设置程序计数器,使之指向代码区域的入口点,进入main函数,CPU为hello分配时间片执行逻辑控制流。hello通过Unix I/O管理来控制输出。hello执行完成后shell会回收hello进程,并且内核会从系统中删除hello所有痕迹。
1.2 环境与工具
硬件环境:Intel Core i5-9400f x64CPU;16 G RAM;512G SSD +1T HDD。
软件环境:Windows 10 64位;VMware Workstation 15Pro;Ubuntu 18.04.3 LTS。
使用工具:Visual Studio 2019 64 位;vi/vim/gpedit+gcc;readelf;objdump;Ultra Edit;Notepad++;readelf。
1.3 中间结果
文件名 | 作用 |
hello.c | hello程序的源代码 |
hello.i | hello.c预处理生成的文本文件。 |
hello.s | hello.i经过编译器翻译成的文本文件,含汇编语言程序。 |
hello.o | hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件。 |
hello.elf | hello.o的ELF格式。 |
hello.txt | hello.o反汇编生成的代码。 |
hello | hello.o经过链接生成的可执行目标文件。 |
helloout.elf | hello的ELF格式。 |
helloout.txt | hello反汇编生成的代码。 |
1.4 本章小结
本章主要介绍了P2P,020的概念及其过程,其较好的概括了hello的一生。同时列出了本次实验的环境、工具和所产生的中间产物,是本次实验的提纲。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:预处理是编译器在编译开始之前调用预处理器(cpp)根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序并将引用的所有库展开合并,生成以.i结尾的文本文件。
作用:
- 将源文件中用#include 形式声明的文件复制到新的程序中。
- 用实际值替换用#define 定义的字符串。
- 条件编译。是根据实际定义宏(某类条件)进行代码静态编译的手段。可根据表达式的值或某个特定宏是否被定义来确定编译条件。
- 布局控制。布局控制的主要功能是为编译程序提供非常规的控制流信息。
- 删除注释。
2.2在Ubuntu下预处理的命令
命令:gcc -E -o hello.i hello.c
生成预处理文本文件
2.3 Hello的预处理结果解析
文件的开头是系统头文件的寻址和解析,如下:
文件中部引入了头文件中所有typedef关键字,结构体类型、枚举类型、通过extern关键字调用并声明外部的结构体及函数定义(省略了大部分内容,见提交文件):
文件的结尾是hello.c的内容:
2.4 本章小结
通过本章了解了c语言在编译前的预处理过程,明白了预处理的概念及作用,学会了GCC下预处理对应的指令,同时解析了预处理文本文件内容,对预处理的结果有了一定的了解,更好的了解了预处理的过程。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
概念:经过预处理后,编译器(ccl)将预处理文件翻译成汇编程序(文本文件),它包含一个汇编语言程序。
作用:将经过预处理的文件hello.i翻译成hello.s,主要包括词法分析;语法分析;语义检查、中间代码生成、目标代码生成等几个阶段,方便进行下一步的汇编工作。同时,编译器会在出现语法错误时给出提示信息。
3.2 在Ubuntu下编译的命令
命令:gcc -S -o hello.s hello.i
3.3 Hello的编译结果解析
3.3.1汇编文件伪指令
汇编文件的起始段是一些汇编文件伪指令,如下图:
这部分内容主要声明了源文件的名字(.file),代码段(.text),数据段(.data),全局变量及其大小(.globl,.size),指令及数据存放地址的对齐方式(.align)等文件信息。
3.3.2数据类型
在hello.c中,主要有以下数据类型:
- 常量,主要是字符串型。汇编语言中,输出字符串作为全局变量保存。汇编文件hello.s中,共有两个字符串,均作为printf参数,如下图:
- 变量。在hello.c中,定义了全局int型变量sleepsecs和局部int型变量i,argc。其中sleepsecs在开头定义:
可以看到sleepsecs占有了4字节的空间,保存在.rodata节中。
而i作为局部变量保存在栈中(运行)时,如下(下面为给i赋初值为0):
argv同理:
在hello.c中,同时主要定义和使用了argv数组,而在hello.s中,通过以下代码可知:
分析可知,argv[1]作为printf第二个参数,应当存于寄存器%rsi中,因此可推断argv[1]地址为-0x2A(%rbp)中,argv[2]作为printf函数的第三个参数,应当存于寄存器%rdx中,因此可推断argv[2]地址为-0x16(%rbp);数组首地址位于-0x32(%rbp) ,以上所占字节数为8。
3.3.3各类操作
hello.c中,主要有以下操作(见下面注释):
int sleepsecs=2.5; //赋值操作,类型转换
int main(int argc,char *argv[])
{
int i;
if(argc!=3) //条件控制,关系操作
{
printf("Usage: Hello 学号 姓名!\n"); //函数调用
exit(1); //函数调用
}
for(i=0;i<10;i++) //for循环语句,算术操作
{
printf("Hello %s %s\n",argv[1],argv[2]); //函数调用
sleep(sleepsecs); //函数调用
}
getchar(); //函数调用
return 0;
}
下面依次分析:
- 赋值操作。如在for循环中,初始化局部变量i为0。在hello.s中,可以找
到对应的操作,主要使用了数据传送指令,如下图:
此外,在给printf传参数时,使用了加载有效地址指令,如下图:
- 类型转换操作。hello.c中,使用了隐性数据类型装换(int sleepsecs=5;),由以下指令实现:
可以看到将sleepsecs赋值为2。由2.5的二进制为10.1。
根据向零舍入和向偶数舍入,最后的1应该去除,得到舍入结果2。
- 关系操作。在hello.c中使用了if语句,其中使用了关系操作,处理方法为使用条件码和cmp指令,如下图:
使用cmpl指令比较了argv和3的大小。
- 控制转移操作。在hello.c中使用了if语句,在进行关系操作后使用条转指令即可,如在上图中,先使用cmpl指令比较argv与3的大小,并在相等时条跳到.L2处,从而实现了条件控制的操作。
- 算术操作。在hello.c中,在语句i++处使用算术操作,在hello.s中对应如下的操作:
即使用addl指令实现了所需的算术操作。
- for循环操作。在hello.c中实现了for循环,在hello.s对应于以下操作:
可知,使用了条状指令jle和关系操作指令cmpl实现了for循环操作。
- 函数操作。hello.c中调用了许多函数,如printf,exit,getchar,可以通过以下的指令实现,如下图printf的实现:
分析可知,通过过程调用指令call来实现函数操作,同时通过寄存器(如%rdi)来传递参数。
3.4 本章小结
通过本章了解了c语言的编译过程,明白了编译的概念及作用,学会了GCC下编译操作对应的指令,同时解析了hello的编译结果,对编译的过程处理有了一定的了解,同时对程序的机器级表示方法更加熟练。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
概念:经过编译后,汇编器(as)将汇编程序翻译成可重定位目标程序(二进制文件),它包含该程序的所有机器语言指令。
作用:将经过编译的文件hello.s翻译成hello.o,主要是将hello.s翻译成机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在二进制目标文件hello.o中。
4.2 在Ubuntu下汇编的命令
命令:gcc -c -o hello.o hello.s
4.3 可重定位目标elf格式
使用readelf查看hello.o:
使用Ultra Edit打开并分析,结果如下:
- ELF头
由图可知,ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的头目(entry)
- 重定位节 '.rela.text'
由图可知,节头部表描述了不同节的位置与大小,并且目标文件的每个节都有一个固定大小的条目,其中记录了各节名称、类型、地址、偏移量、大小、全体大小、旗标、连接、信息、对齐信息。
- 重定位节 '.rela.text'
由图可知'.rela.text'是一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。如图4.4,图中8条重定位信息分别是对.L0(第一个printf中的字符串)、puts函数、exit函数、.L1(第二个printf中的字符串)、printf函数、sleepsecs、sleep函数、getchar函数进行重定位声明。
其中偏移量为需要进行重定向的代码在.text或.data节中的偏移位置,信息包括symbol和type两部分,其中symbol占前4个字节,type占后4个字节,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型,类型代表重定位到的目标的类型,符号名称和加数则指出了重定向到的目标的名称计算重定位位置的辅助信息。
- 重定位节 '.rela.eh_frame'
由图可知,'.rela.eh_frame'包含了eh_frame节的重定位信息。
- 符号表
由图可知,.symtab存放着程序中定义和引用函数和全局变量的信息。且不包含局部变量的条目。重定位需要引用的符号都在其中声明。
4.4 Hello.o的结果解析
输入如下指令,反汇编hello.o:
使用Ultra Edit打开hello.txt:
可以看到机器语言与汇编语言大致上是相同的,但是也存在着以下差异:
- 操作数的不同
hello.s中:
hello.txt中:
可以看到在汇编语言中操作数为十进制,而在机器语言中为十六进制。
- 分支转移语句的不同
hello.s中:
hello.txt中:
可以看到,在汇编语言中,分支转移命令是由助记符来标识,通过用符号.L2等助记符,跳转到相应的位置。而在机器语言中,分支转移命令是直接跳转入目的地址,例如此处为2b,通过地址调用,直接进入相应的语句进行执行。
- 函数调用的不同
hello.s中:
hello.txt中:
由图可知,在汇编语言中,函数调用是对函数名的引用,而在机器语言中,函数调用是通过对在.rela.text节中的重定位条目进行解析从而得到目的函数地址。
- 数据访问方式的不同
hello.s中:
hello.txt中:
由图可知,在汇编语言中,访问rodata(printf中的字符串),使用段名称+%rip,而在机器语言中为0+%rip。因为rodata中数据地址也是在运行时确定,故访问也需要重定位。所以在汇编成为机器语言时,将操作数设置为全0并添加重定位条目。
4.5 本章小结
通过本章了解了c语言的汇编过程,明白了汇编的概念及作用,学会了GCC下汇编操作对应的指令,同时解析了hello的汇编结果,对机器代码和汇编代码的相同与不同之处有了一定的了解,最后对elf文件的构成也有了一定的了解。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
概念:经过汇编后,链接器(as)将可重定位目标程序与其他需要的各种代码和数据片段收集并组合称为一个单一文件,即可执行目标文件(二进制文件),它可被加载(复制)到内存并执行。
作用:链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。早期计算机系统中链接时手动执行的,在现代系统中,链接器由链接器自动执行。链接器使得分离编译成为可能。开发过程中无需将大型的应用程序组织委员一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。
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.3 可执行目标文件hello的格式
如图,使用readelf查看hello:
在Ultra Edit中打开,段头部表如下:
由图可知,段头部表概括了各段的基本信息,包括各段的起始地址,大小等信息。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,如下图:
同时查看elf文件中的程序头部分:
可以发现大致的对应关系,例如,对于LOAD代码段,可以看出开始于内存地址0x400000处,总共内存大小是0x76c个字节,并且被初始化为可执行目标文件的头0x76c字节,并且可以在edb中找到对应的位置:
(仅取用了一部分)
5.5 链接的重定位过程分析
执行以下操作,查看hello与hello.o的不同:
通过比较两个文件内的内容,发现hello的反汇编文件多出了许多的内容,如下图:
通过比较两个文件,发现了以下不同:
- 函数个数。
在使用ld命令链接的时候,指定了动态链接器为64的/lib64/ld-linux-x86-64.so.2,crt1.o、crti.o、crtn.o中主要定义了程序入口_start、初始化函数_init,_start程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入,如下图:
- 函数调用
链接器解析重定条目时发现对外部函数调用的类型为R_X86_64_PLT32的重定位,此时动态链接库中的函数已经加入到了PLT中,.text与.plt节相对距离已经确定,链接器计算相对距离,将对动态链接库中函数的调用值改为PLT中相应函数与下条指令的相对地址,指向对应函数。对于此类重定位链接器为其构造.plt与.got.plt,如下图:
- .rodata引用
链接器解析重定条目时发现两个类型为R_X86_64_PC32的对.rodata的重定位(printf中的两个字符串),.rodata与.text节之间的相对距离确定,因此链接器直接修改call之后的值为目标地址与下一条指令的地址之差,指向相应的字符串。
这里以计算第一条字符串相对地址为例说明计算相对地址的算法:
refptr = s + r.offset = Pointer to 0x40054A
refaddr = ADDR(s) + r.offset= ADDR(main)+r.offset=0x400532+0x18=0x40054A
*refptr=(unsigned)(ADDR(r.symbol)+ r.addend-refaddr) = ADDR(str1)+r.addend-refaddr=0x400644+(-0x4)-0x40054A=(unsigned) 0xF6,
观察反汇编验证计算:
发现相对地址的计算正确。
5.6 hello的执行流程
使用edb调试,记录调用的过程,结果如下:
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!__libc_start_main
-libc-2.27.so!__cxa_atexit
-libc-2.27.so!__libc_csu_init
hello!_init
libc-2.27.so!_setjmp
-libc-2.27.so!_sigsetjmp
--libc-2.27.so!__sigjmp_save
hello!main
hello!puts@plt
hello!exit@plt
*hello!printf@plt
*hello!sleep@plt
*hello!getchar@plt
ld-2.27.so!_dl_runtime_resolve_xsave
-ld-2.27.so!_dl_fixup
--ld-2.27.so!_dl_lookup_symbol_x
libc-2.27.so!exit
5.7 Hello的动态链接分析
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
根据hello ELF文件可知,GOT起始表位置为0x601000,如图:
同时观察虚拟地址空间,发现了以下不同:
调用dl_init之前,0x601008后的16个字节均为0:
调用dl_init之后,调用_start之后发生改变,0x601008后的两个8个字节分别变为:0x7fb06087e168、0x7fb06066e870,其中GOT[O](对应0x600e28)和GOT[1](对应0x7fb06087e168)包含动态链接器在解析函数地址时会使用的信息。GOT[2](对应0x7fb06066e870)是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,改变后的GOT表如下:
GOT[2]对应部分是共享库模块的入口点,如下:
举例puts函数在调用puts函数前对应GOT条目指向其对应的PLT条目的第二条指令,如图puts@plt指令跳转的地址:
在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数。
因为在PLT中使用的jmp,所以执行完目标函数之后的返回地址为最近call指令下一条指令地址,即在main中的调用完成地址。
5.8 本章小结
通过本章了解了c语言的链接过程,明白了链接的概念及作用,学会了GCC下链接操作对应的指令,并学会了查看hello运行时的虚拟地址空间,了解了hello的重定位过程,对hello的执行流程有了一定的了解,同时对hello的动态链接过程进行了分析。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。
作用:进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序一样, 进程给应用程序提供的关键抽象有两种:
(1) 一个独立的逻辑控制流,提供一个假象,程序独占地使用处理器。
(2) 一个私有的地址空间,提供一个假象,程序在独占地使用系统内存。
6.2 简述壳Shell-bash的作用与处理流程
作用:shell是命令行界面,是系统内核的一层壳,作用是用来保护内核同时传递入与计算机交互的信息.它只是系统的一个工具,我们可以使用它来操作计算机。
处理流程:
- 从终端读入输入的命令。
- Shell对用户输入命令进行解析,判断是否为内置命令。
- 如果是内置命令则立即调用内置命令处理函数,否则调用execve函数创建一个子进程进行运行。
- 判断是否为前台运行程序,如果是,则调用等待函数等待前台作业结束;否则将程序转入后台,直接开始下一次用户输入命令。
- shell应该接受键盘输入信号,并对这些信号进行相应处理。
6.3 Hello的fork进程创建过程
当在shell中运行hello后,父进程通过调用fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时。子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的id。
fork函数调用一次返回两次,在父进程中fork会返回子进程的PID,在子进程中fork会返回0;父进程与子进程是并发运行的独立进程。内核能够以任何方式交替执行他们逻辑控制流中的指令。系统进程创建hello子进程然后调用waitpid()函数知道hello子进程结束,程序进程图如下:
6.4 Hello的execve过程
execve函数的功能是在当前进程的上下文中加载并运行一个新的程序。
系统为hello fork子进程之后,子进程调用execve函数加载并运行可执行目标文件hello, execve调用驻留在内存中的被称为启动加载器的操作系统代码来执行hello程序,加载器删除子进程现有的虚拟内存段,并创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的片,新的代码和数据段被初始化为可执行文件中的内容。加载器创建的内存映像如下:
6.5 Hello的进程执行
首先介绍以下概念:
逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。
时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。
因此hello进程调度的过程为:
- hello初始运行在用户模式。
- hello进程调用sleep陷入内核模式,内核处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程。
- 当定时器到时时(2.5secs)发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
如下图:
6.6 hello的异常与信号处理
hello执行过程中出现的异常种类可能会有:中断、陷阱、故障、终止。可能产生的信号有:sigtstp,sigint等,下面分情况讨论:
- 正常运行
可以看到程序正常运行。
- 运行时乱按键盘或回车
可以看到在程序运行中途乱按或者按回车不会阻碍程序的运行,乱按只是将屏幕的输入缓存到stdin,当for循环结束,运行getchar函数的时候读出一个以’\n’结尾的字串(end)作为一次输入。
- 运行时输入Ctrl-Z
可以看到输入Ctrl-Z后,hello进程停止了,原因是shell父进程收到SIGSTP信号,信号处理函数的逻辑是打印屏幕回显、将hello进程挂起。
再次输入ps命令:
可以看到被挂起的hello进程,PID为2889。
输入jobs命令:
可以看到hello进程的job号为1。
输入pstree可以查看当前的进程树(下图为部分进程树):
输入fg指令后:
hello进程继续运行,因为shell将后台hello程序更改为前台,hello子进程继续刚刚的位置。
暂停后输入kill命令:
进程hello被终止,因为shell收到SIGCHLD信号,得到hello终止的信息,利用信号处理程序回收hello子进程。
- 运行时输入Ctrl-C
可以看到输入Ctrl-C,hello进程终止了,原因是当按下ctrl-c之后,shell父进程收到SIGINT信号,信号处理函数的逻辑是结束hello,并回收hello进程。
6.7本章小结
本章主要探讨了hello的进程管理,首先介绍了进程的概念与作用,并对shell做了介绍,然后研究了hello的fork、exceve过程,简单概括就是调用fork 函数创建一个新的运行的子进程,然后调用exceve加载hello。最后对hello程序执行时的进程管理和异常进行了研究。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
1.逻辑地址
在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。在hello中,有许多的相对地址引用,例如经过相对地址引用(R_X86_64_PC32)的重定位信息大多是逻辑地址:
2.线性地址
线性地址或也叫虚拟地址,跟逻辑地址类似,它也是一个不真实的地址,假设逻辑地址是相应的硬件平台段式管理转换前地址的话,那么线性地址则相应了硬件页式内存的转换前地址。
3.虚拟地址
由线性地址的定义,可以知道虚拟地址即线性地址。虚拟地址是相对于虚拟内存而言的。每个进程的虚拟地址完成对物理地址的一个映射。在hello中即可执行目标文件ELF格式中的程序头中的VirtAddr:
4.物理地址
物理地址用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相应。CPU通过地址总线的寻址,找到真实的物理内存对应地址。
他们之间的关系如下:
7.2 Intel逻辑地址到线性地址的变换-段式管理
在x86保护模式下,段的信息(段基线性地址、长度、权限等)即段描述符占8个字节,段信息无法直接存放在段寄存器中(段寄存器只有2字节)。Intel的设计是段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值(index)。
一个逻辑地址由两部份组成,段标识符: 段内偏移量。
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址],
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再依据对应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,能够在这个数组中。查找到相应的段描写叙述符,这样。它了Base。即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址即虚拟地址,用VA来表示。由图,VA被分为虚拟页号(VPN)与虚拟页偏移量(VPO),CPU取出虚拟页号,通过页表基址寄存器(PTBR)来定位页表条目,在有效位为1时,从页表条目中取出信息物理页号(PPN),通过将物理页号与虚拟页偏移量(VPO)结合,得到由物理地址(PPN)和物理页偏移量(PPO)组合的物理地址,如下图:
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块。TLB通常有高的相连度,从虚拟地址中的页号提取出组选择和行匹配的索引和标记字段,下图表示虚拟地址中用以访问TLB的组成部分:
而对于多级页表,会将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。如下图,VPN被分为k个部分,第一级VPN结合基址寄存器得到一个页表条目,其中存放下一级页表的基址,再结合VPN2,得到第三级页表基址,继续寻找,以此类推,直到最后确定对应的物理页号,与VPO结合,由线性地址到物理地址的变换,得到由PPN与PPO结合成的物理地址,用于物理地址寻址。
Core i5是四级页表进行的虚拟地址转物理地址。48位的虚拟地址的前36位被分为四级VPN区。结合存放在CR3的基址寄存器,由前面多级页表的知识,可以确定最终的PPN,与VPO结合得到物理地址,如下图:
7.5 三级Cache支持下的物理内存访问
对于一个虚拟地址请求,首先将去TLB寻找,看是否已经在TLB中缓存。如果命中的话就直接MMU获取,没有命中的话就先在结合多级页表,得到物理地址,去cache中找,到了L1里面以后,寻找物理地址又要检测是否命中,不命中则紧接着寻找下一级cache L2,接着L3。这里就是使用到CPU的高速缓存机制了,一级一级往下找,直到找到对应的内容,下图分别为Cache结构:
下图为物理地址寻找的流程:
7.6 hello进程fork时的内存映射
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID 。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct 、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork 在hello进程中返回时,hello进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念,下图为对共享对象的一种描述:
7.7 hello进程execve时的内存映射
xecve 函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
1删除已存在的用户区域。删除当前进程(shell)虚拟地址的用户部分中的已存在的区域结构。
2映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。下图概括了私有区域的不同映射。
3映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下图表述了一个通用的虚拟内存空间:
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中, DRAM 缓存不命中称为缺页(page fault) 。下图 展示了在缺页之前我们的示例页表的状态。CPU 引用了VP 3 中的一个字, VP 3 并未缓存在DRAM 中。地址翻译硬件从内存中读取PTE 3, 从有效位推断出VP 3 未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,在此例中就是存放在PP3中的VP4 。如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4 的页表条目,反映出VP 4 不再缓存在主存中这一事实。
当遇到缺页时,会进入缺页异常处理程序,如下图:
7.9动态存储分配管理
动态储存分配管理使用动态内存分配器来进行。动态内存分配器维护着一个进程的虚拟内存区域,称为堆。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可以用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配的状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,都要求应用显式地分配块,它们的不同之处在于由哪个实体来负责释放已分配的块。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。而自动释放未使用的已分配的块的过程叫做垃圾收集。
动态内存分配主要有两种基本方法与策略:
- 带边界标签的隐式空闲链表分配器管理
带边界标记的隐式空闲链表的每个块是由一个字的头部、有效载荷、可能的额外填充以及一个字的尾部组成的,如下图:
在隐式空闲链表中,因为空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。其中,一个设置了已分配的位而大小为零的终止头部将作为特殊标记的结束块,如下图:
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大的可以放置所请求块的空闲块。分配器有三种放置策略:首次适配、下一次适配合最佳适配。分配完后可以分割空闲块减少内部碎片。同时分配器在面对释放一个已分配块时,可以合并空闲块,其中便利用隐式空闲链表的边界标记来进行合并。
- 显示空间链表管理
显式空闲链表是将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如,堆可以组织成一个双向链表,在每个空闲块中,都包含一个前驱与一个后继指针,如下图:
在显式空闲链表中。可以采用后进先出的顺序维护链表,将最新释放的块放置在链表的开始处,也可以采用按照地址顺序来维护链表,其中链表中每个块的地址都小于它的后继地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
7.10本章小结
本章主要对hello的存储管理进行了研究,主要包括有逻辑地址、线性地址、虚拟地址、物理地址以及他们之间的变换,同时对虚拟内存的原理并结合hello探究了虚拟地址转化为物理地址的过程。最后,对动态内存的分配也进行了探讨,主要介绍了动态内存分配的两种方法与策略。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列。所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有输入和输出都能以一种统一且一致的方式来执行:
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。
- Linux Shell创建的每个进程开始时都有三个打开的文件:标准输入、标准输出、标准错误。
- 改变当前文件的位置。对于每个打开的文件,内核保持着一个文件位置k、初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显示地设置文件的当前位置为k。
- 读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。
8.2 简述Unix IO接口及其函数
可分为以下几个:
- open()
原型:int open(char * filename, int flags, mode_t mode);
作用:打开一个已存在的文件或者创建一个新文件的。其中,open 函数将filename 转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
- close()
原型:int close(int fd);
作用:关闭一个打开的文件
- read()
原型:ssize_t read(int fd, void * buf, size_t n);
作用:执行输入,read 函数从描述符为fd 的当前文件位置复制最多n 个字节到内存位置buf 。返回值-1表示一个错误,而返回值0 表示EOF。否则,返回值表示的是实际传送的字节数量。
- write()
原型:ssize_t write(int fd, const void * buf, size_t n);
作用:执行输出,write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。图10-3 展示了一个程序使用read 和write 调用一次一个字节地从标准输入复制到标准输出。
8.3 printf的实现分析
首先查看pringf函数:
static int printf(const char *fmt, ...)
{
va_list args;
int i;
va_start(args, fmt);
write(1,printbuf,i=vsprintf(printbuf, fmt, args));
va_end(args);
return i;
}
首先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': //只处理%x一种情况
itoa(tmp, *((int*)p_next_arg)); //将输入参数值转化为字符串保存在tmp
strcpy(p, tmp); //将tmp字符串复制到p处
p_next_arg += 4; //下一个参数值地址
p += strlen(tmp); //放下一个参数值的地址
break;
case 's':
break;
default:
break;
}
}
return (p - buf); //返回最后生成的字符串的长度
}
vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt(输入)。用格式字符串对个数变化的参数进行格式化,产生格式化输出,并返回生成的字符串的长度。
再看write:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
根据代码可知内核向寄存器传递几个参数后,中断调用syscall函数。对应ebx打印输出的buf数组中第一个元素的地址,ecx是要打印输出的个数。
再看syscall:
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
其中ecx中是要打印出的元素个数 ,ebx中的是要打印的buf字符数组中的第一个元素,sys_call的功能就是不断的打印出字符,直到遇到:’\0’ 。其中call是访问字库模板并且获取每一个点的RGB信息最后放入到eax也就是输出返回的应该是显示vram的值,然后系统显示芯片按照刷新频率逐行读取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有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要研究了hello的IO管理。首先对Linux系统下的IO折本管理方法以及Unix IO接口及其函数惊醒了分析,然后着重分析了printf函数与getchar函数的实现,对hello的IO管理有了一定的了解。
(第8章1分)
结论
hello所经历的过程:
- hello最初是一个C语言源程序hello.c,里面包含了hello程序所有的C语言代码。
- hello.c经过预处理后,得到hello.i,hello的预处理文本文件。
- hello.i经历编译后,得到hello.s,里面包含一个汇编程序。
- hello.s经历汇编后,得到hello.o,hello的可重定位目标文件。
- hello.o经历链接后,得到最终的可执行目标文件——hello。
- 在shell中运行hello,shell为hello程序fork子进程,调用execve,将hello程序加载并运行,得到子进程hello
- 通过虚拟内存映射,为hello分配虚拟内存空间,内核通过为hello划分时间片,让hello能够执行自己的逻辑流。
- 运行hello时,内存管理单元MMU、翻译后备缓冲器TLB、多级页表机制、三级cache共同完成hello对地址的请求。
- 异常处理机制保证了hello对异常信号的处理,使程序平稳运行。
- Unix I/O让程序能够与文件进行交互。
- 当hello运行完毕,shell父进程回收hello,hello的一生结束。
hello几乎是每个程序员编写的第一个程序,我也不例外。在第一次编写hello的时候,程序成功运行的喜悦是难以想象的,但当时也没有对这个小小程序实现的原理进行深究,直到完成了这次实验,才发现就算是hello这样小小的程序,其中蕴含的原理一点也不简单,让人不得不感慨hello虽小,但其中的门道一点都不少。
同时也对现代计算机的高速发展与大体量有了一定的了解,对软件与硬件的高效配合叹为观止,同时也明白了自己在这座大山之前的渺小。但是,登高必自卑,行远必自迩,就将hello做为登山的第一站,继续前进吧。
(结论0分,缺少 -1分,根据内容酌情加分)
附件
文件名 | 作用 |
hello.c | hello程序的源代码 |
hello.i | hello.c预处理生成的文本文件。 |
hello.s | hello.i经过编译器翻译成的文本文件,含汇编语言程序。 |
hello.o | hello.s经汇编器翻译成机器语言指令打包成的可重定位目标文件。 |
hello.elf | hello.o的ELF格式。 |
hello.txt | hello.o反汇编生成的代码。 |
hello | hello.o经过链接生成的可执行目标文件。 |
helloout.elf | hello的ELF格式。 |
helloout.txt | hello反汇编生成的代码。 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
(参考文献0分,缺失 -1分)