哈工大计算机系统大作业——程序人生-Hello’s P2P

计算机系统

大作业

题     目  程序人生-Hello’s P2P 

专       业   计算机类                    

学     号    1190200106                   

班     级    1936601                   

学       生                     

指 导 教 师    刘宏伟                   

计算机科学与技术学院

2021年6月

 

本文介绍了hello的整个生命过程。在Linux系统下利用gcc,gdb,edb等工具分析了hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件的全过程,即P2P的过程,还分析了hello在运行过程中涉及的进程管理、内存管理、IO管理到最后hello被回收的020的过程。通过本文的分析,可以让我们对计算机系统有更深的理解。

关键词:Linux;hello程序;计算机系统                           

 

第1章 概述... - 3 -

1.1 Hello简介... - 3 -

1.2 环境与工具... - 3 -

1.3 中间结果... - 4 -

1.4 本章小结... - 4 -

第2章 预处理... - 5 -

2.1 预处理的概念与作用... - 5 -

2.2在Ubuntu下预处理的命令... - 5 -

2.3 Hello的预处理结果解析... - 6 -

2.4 本章小结... - 7 -

第3章 编译... - 8 -

3.1 编译的概念与作用... - 8 -

3.2 在Ubuntu下编译的命令... - 8 -

3.3 Hello的编译结果解析... - 8 -

3.4 本章小结... - 12 -

第4章 汇编... - 13 -

4.1 汇编的概念与作用... - 13 -

4.2 在Ubuntu下汇编的命令... - 13 -

4.3 可重定位目标elf格式... - 13 -

4.4 Hello.o的结果解析... - 16 -

4.5 本章小结... - 19 -

第5章 链接... - 20 -

5.1 链接的概念与作用... - 20 -

5.2 在Ubuntu下链接的命令... - 20 -

5.3 可执行目标文件hello的格式... - 20 -

5.4 hello的虚拟地址空间... - 25 -

5.5 链接的重定位过程分析... - 25 -

5.6 hello的执行流程... - 28 -

5.7 Hello的动态链接分析... - 28 -

5.8 本章小结... - 29 -

第6章 hello进程管理... - 30 -

6.1 进程的概念与作用... - 30 -

6.2 简述壳Shell-bash的作用与处理流程... - 30 -

6.3 Hello的fork进程创建过程... - 30 -

6.4 Hello的execve过程... - 31 -

6.5 Hello的进程执行... - 31 -

6.6 hello的异常与信号处理... - 32 -

6.7本章小结... - 35 -

第7章 hello的存储管理... - 36 -

7.1 hello的存储器地址空间... - 36 -

7.2 Intel逻辑地址到线性地址的变换-段式管理... - 36 -

7.3 Hello的线性地址到物理地址的变换-页式管理... - 37 -

7.4 TLB与四级页表支持下的VA到PA的变换... - 38 -

7.5 三级Cache支持下的物理内存访问... - 39 -

7.6 hello进程fork时的内存映射... - 40 -

7.7 hello进程execve时的内存映射... - 41 -

7.8 缺页故障与缺页中断处理... - 41 -

7.9动态存储分配管理... - 42 -

7.10本章小结... - 44 -

第8章 hello的IO管理... - 45 -

8.1 Linux的IO设备管理方法... - 45 -

8.2 简述Unix IO接口及其函数... - 45 -

8.3 printf的实现分析... - 46 -

8.4 getchar的实现分析... - 48 -

8.5本章小结... - 48 -

结论... - 48 -

附件... - 50 -

参考文献... - 51 -

1章 概述

1.1 Hello简介

程序的生命周期是指从程序源文件,依次经过预处理器cpp的预处理、编译器cc1的编译、汇编器as的汇编、链接器ld的链接最终成为可执行目标程序并在操作系统上加载、执行、回收的全过程。程序的生命周期可以分为P2P和020两个部分,P2P指程序由源文件到进程的过程;020指程序被加载到内存执行,直到被回收的过程。

P2P:

GCC编译器驱动程序读取源程序文件并把它翻译成一个可执行目标文件。在预处理阶段,预处理器cpp读取需要的系统头文件内容,并把它直接插入程序文本中,得到hello.i。在编译阶段,编译器ccl间文本文件hello.i翻译成hello.s,这是一个汇编语言的程序。在汇编阶段,汇编器as将hello.s翻译成机器语言指令,并将结果保存在目标文件hello.o中,它以可重定位目标程序的格式存储。在链接阶段,链接器ld需要将一些库函数合并到hello.o的程序中,最终得到hello的可执行文件。用户在Ubuntu shell键入./hello启动此程序,shell调用fork函数为其产生子进程,hello便成为了进程。完整过程图示如下:

020:

操作系统的进程管理调用fork函数产生子进程并调用execve函数,进行虚拟内存映射(mmp),并为运行的hello分配时间片来执行取指译码流水线等操作。操作系统的储存管理以及MMU解决VA到PA的转换,cache、TLB、页表等的功能为加速访问过程,IO管理与信号处理综合软硬件对信号等进行处理。程序结束时,shell回收hello进程,内核将其所有痕迹从系统中清除。

1.2 环境与工具

硬件环境:Intel Core i5-9300H CPU;2.40GHz;8GB RAM

软件环境:Windows10 64位;Ubuntu 20.04.2 LTS 64位

开发与调试工具: gcc;edb;gdb;readelf;objdump

1.3 中间结果

文件名

文件作用

hello.i

预处理器修改了的源程序,分析预处理器行为

hello.s

编译器生成的编译程序,分析编译器行为

hello.o

可重定位目标程序,分析汇编器行为

hello

可执行目标程序,分析链接器行为

helf.txt

hello.o的elf格式,分析汇编器和链接器行为

h1elf.txt

可执行hello的elf格式,作用是重定位过程分析

1.4 本章小结

本章主要是本文实验的准备工作和绪论部分,主要介绍了hello程序P2P、020的过程,给出了实验中生成的中间文件,列出了实验使用的软硬件环境以及调试工具等。

2章 预处理

2.1 预处理的概念与作用

预处理指令是以‘#’开头的代码行。‘#’必须是该行除了空白字符外的第一个字符。‘#’后面是指令关键字,整行语句构成一条预处理指令,该指令将在编译器进行编译之前对源代码做某些转换。下图是ANSI标准定义的C语言预处理指令及其作用:

2.2在Ubuntu下预处理的命令

命令:cpp hello.c > hello.i

2.3 Hello的预处理结果解析

查看hello.i文件,可以发现原来的代码已经被拓展为3000多行,而main函数以及定义全局变量的代码没有任何改变,只是原来前面的#include语句被替换成了大量的头文件中的内容,包括外部函数的声明、结构体等数据结构的定义、数据类型的定义等内容。并且源程序开头的注释也被删除了。同时会对#define进行相应的符号替换。所以我们分析可以知道生成的是经过预处理扩展之后的源程序。

2.4 本章小结

本章主要介绍了预处理的概念以及作用,预处理环节是接下来编译、汇编、链接等环节的基础,并通过hello.c经过预处理生成的hello.i文件导致两者内容的不同进行了分析。

3章 编译

3.1 编译的概念与作用

概念:

编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序,该程序包含函数main的定义,这个过程称为编译。

作用:

编译的作用就是将高级语言源程序翻译成等价的目标程序,并且进行语法检查、调试措施、修改手段、覆盖处理、目标程序优化等步骤。

3.2 在Ubuntu下编译的命令

命令:gcc –S hello.i -o hello.s

3.3 Hello的编译结果解析

3.3.1 数据

全局变量:sleepsecs

在hello.s中可以看到,.global将sleepsecs标记为全局变量。.data表明全局变量sleepsecs存放在数据段.data中,.align要求4字节对齐,.size表明变量为4字节,最后.long给出了变量的初值为2。

局部变量:i,用于循环计数:

在hello.s的汇编代码中,i被存储在%rbp-4的内存地址处。其中movl为i赋初值0,addl在每次循环时对i增加1,cmpl比较i和9的大小来决定什么时候结束循环。局部变量i是存放在栈上,通过相对栈顶(%rsp)的偏移量来访问。

字符串常量:Usage: Hello 学号 姓名!\n;Hello %s %s\n:

这两个字符串常量分别由.LC0和.LC1表示,存放在只读数据段.rodata中。

int argc:

argc首先被保存在了寄存器%edi中,后续需要参与判断的时候编译器将其赋值给了-20(%rbp)。

char argv[]:

argv[]的每个元素都是一个指向字符类型的指针,起始地址存放在栈中-32(%rbp)的位置,并且被两次调用传给printf。

3.3.2 赋值

源程序中的赋值操作主要有:int sleepsecs=2.5、i=0。

对于int sleepsecs = 2.5,直接在.data节中就已经将sleepsecs 声明为值为2的long类型数据(隐式转换)。

       对于i=0。在hello.s文件中是通过汇编语句movl $0, -4(%rbp)将立即数赋值给局部变量i的。这里使用的是“movl”,这是因为指令的后缀取决于操作数据的字节大小,movb:一个字节;movw:两个字节;movl:四个字节;movq:八个字节,而局部变量i是int类型的数据,占4个字节。

3.3.3 类型转换

int sleepsecs=2.5的操作中出现了隐式类型转换,由于当 double 或 float 向 int 进行类型转换的时候,程序遵循向零舍入的原则将浮点数2.5转化为int类型的整数2,然后由于编译器缺省,int类型又被转换为了long类型。

3.3.4 算术操作

在for循环的时候出现了算术操作为i++,编译时转化成加法指令,使用立即数1来实现每次增加1。

3.3.5 关系操作

在程序中有两次关系操作:

if中判断argc的取值是否不等于3:利用cmpl将argc和3进行比较,指令je根据条件码决定是否跳转。

for循环中判断i是否小于10:我们可以看到汇编代码将它优化为了i<=9,编译器会计算-4(%rbp)-9,并设置条件码,随之jle语句通过条件码决定进行怎样的跳转处理。

3.3.6 数组/指针/结构操作

程序在访问argv[]的时候出现了数组操作,在向main函数传参时,通过movq %rsi, -32(%rbp)进行参数的传递,把argv数组的首地址保存在栈中。使用首地址+偏移量的方式来访问数组元素,数组首地址存储在%rbp-32,通过将首地址加8获得argv[1]的地址,将首地址加16获得argv[2]的地址,从而在循环中分别读取了argv[1]和argv[2]。

3.3.7 控制转移

程序中有两次控制转移:

if中判断argc的取值是否不等于3之后的控制转移:

cmpl指令将argc和3进行比较, je根据条件码决定是否跳转,控制转移也由它完成。

for循环结束时的控制转移:

编译时使用cmpl指令将i和9进行比较, jle根据条件码决定是否跳转,控制转移也由它完成。

3.3.8 函数操作

程序中一共有五次函数操作:main函数;printf函数;sleep函数;getchar函数;exit函数:

函数调用: printf函数第一次调用为在汇编代码中被优化为puts函数,第二次调用为直接调用;其他函数则直接通过call @函数名调用。

参数传递:向main函数传递的参数是argc和argv[],分别使用%rdi(%edi)和%rsi存储;printf函数第一次传递首先将rdi赋值为字符串“Usage: Hello 学号 姓名! \n”字符串的首地址,然后调用了puts函数,将字符串参数传入,第二次则传递了3个参数,%rdi保存的是“Hello %s %s\n”的首地址,%rsi保存的是argv[1],%rdx保存的是argv[2];sleep函数则是通过movl sleepsecs(%rip), %eax和movl %eax, %edi,对应sleepsecs;getchar函数没有参数传递的过程;exit通过汇编语句movl $1, %edi将内容设置为1。

函数返回:只有main函数有函数返回的过程,将%eax设置为0后通过leave退出。

3.4 本章小结

本章介绍了编译的概念以及作用,同时对hello.s的编译代码的数据、赋值、类型转换、算术操作、关系操作、数组/指针/结构操作以及控制转移和函数操作进行了分析。

4章 汇编

4.1 汇编的概念与作用

概念:

汇编器(as)将.s汇编程序翻译成机器语言,把这些机器语言指令打包成可重定位目标程序的格式,并将结果保存在.o目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码,这个过程就叫做汇编。

作用:

汇编的作用就是将高级语言转化为机器可直接识别执行的机器指令代码文件。

4.2 在Ubuntu下汇编的命令

    命令:as hello.s -o hello.o

4.3 可重定位目标elf格式

利用readelf -a hello.o > helf.txt获得hello的elf文件并重定向到helf.txt。

ELF头:

它以一个16字节的目标序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。hello.o中,这个16字节序列为7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00,并且系统的字的大小为8字节,字节顺序为小端序。剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。

节头:

节头描述不同节的位置和大小,目标文件中的每个节都有一个固定大小的节头部表条目。在hello.s中:

.text节表示已编译程序的机器代码。.rela.text节表示一个.text节中位置的列表。

.data节表示已初始化的全局和静态C变量,且该节的数据可读可写。.bss节表示未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量,且该节的数据可读可写。.rodata节:只读数据。.comment节表示版本控制信息。.note.GNU_stack节表示可执行堆栈。.eh_frame节处理异常,且数据只读。.rela.eh_frame节表示.eh_frame节的重定位信息。.shstrtab节表示节区名称。.symtab节表示一个符号表,存放在程序中定义和引用的函数和全局变量的信息。.strtab节表示一个字符串表,包括.symtab和.debug节中的符号表,以及节头部中的节名字。

符号表:符号表存放程序中定义和引用的函数和全局变量的信息,每个符号表是一个条目的数组,每个条目包括距定义目标的节的起始位置的偏移;目标的大小;指明数据还是函数;表示符号是本地的还是全局的等。

重定位节:

汇编器遇到对最终位置未知的目标引用,会产生一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。重定位信息就放在重定位节.,重定位条目放在.rel.data中。每个重定位条目包括需要被修改的引用的节偏移;标识被修改引用应该指向的符号;重定位类型。

ELF定义了32种不同的重定位类型,两种最基本的重定位类型包括R_X86_64_PC32(重定位使用32位PC相对地址的引用)和R_X86_64_32(重定位使用32位绝对地址的引用)。

4.4 Hello.o的结果解析

利用objdump -d -r hello.o进行反汇编:

将其与hello.s进行对比:

我们可以知道机器语言指的是二进制的机器指令集合,机器可以直接根据二进制代码执行对应的操作。而机器指令是由操作码和操作数构成的。汇编语言的主体是汇编指令。汇编指令和机器指令的差别在于指令的表示方法上,汇编指令是机器指令便于记忆的书写格式。

每一条汇编语言的指令都可以映射到一条机器语言指令,从汇编语言转换成机器语言的过程中,部分操作数会出现不一致:

分支转移:hello.s文件中分支转移是使用段名称进行跳转的,而hello.o文件中分支转移是通过重定位地址进行跳转的。

函数调用:hello.s文件中,调用call后的是函数名称,而在hello.o文件中,因为这些函数都是共享库函数,它们的地址是不确定的,因此call指令将相对地址全部设置为0,然后在.rela.text节中为其添加重定位条目,在链接时确定最终的相对地址

立即数:hello.s中的立即数都是用10进制数表示的,但在机器语言中,立即数都是用16进制数表示的。

4.5 本章小结

本章介绍了汇编的概念以及作用,通过readelf命令查看了hello.o可重定位目标elf格式,并对其中的ELF头、节头、符号表和重定位表进行了分析。除此之外,将hello.o的反汇编代码和hello.s的代码进行比较,理解了汇编指令与机器指令的区别,更深刻地理解了汇编这一过程。

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

5.3 可执行目标文件hello的格式

分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。

利用readelf -a hello > h1elf.txt查看可执行目标文件hello的ELF格式,并将结果重定向到h1elf.txt。其中信息相似的部分在4.3已经有过介绍,就不再赘述了,新的部分会在下面进行补充:

ELF头:

节头:

程序头:描述了可执行文件的连续的片映射到连续的内存段的映射关系。包括目标文件的偏移、段的读写/执行权限、内存的开始地址、对齐要求、段的大小、内存中的段大小等。

符号表:相比于hello.o多出的符号是链接后产生的库中的函数以及一些必要的启动函数。

同时还有动态符号表,里面的符号都是共享库中的函数,需要动态链接:

重定位节:我们可以看到原来的.rela.text节已经没有了,说明链接的过程已经完成了对.rela.text的重定位操作。并且出现了新的重定位条目,它们和共享库中的函数有关,因为此时还没有进行动态链接,共享库中函数的确切地址仍是未知的,因此仍然需要重定位节,在动态链接后才能确定地址。

5.4 hello的虚拟地址空间  

通过edb,看出hello的虚拟地址空间开始于0x400000,结束与0x400ff0:

而其中,我们分析.rodata:

可以看到该节的位置,大小都和前面的节头一致,其他不一一列出了。

5.5 链接的重定位过程分析

利用objdump -d -r hello进行hello的反汇编。

hello.o和hello反汇编的对比:

相比之下hello反汇编后多了许多文件节:

hello.o反汇编之后其中的地址大多是相对偏移地址,而hello反汇编的地址是虚拟地址。这是因为hello已经是可执行文件了,相关的重定位工作必须已经完成,所有虚拟地址也必须确定。

相比之下,hello反汇编之后增加了许多外部链接的共享库函数:

重定位分析:当汇编器生成一个目标模块时,针对最终位置未知的目标引用,它会生成一个重定位条目,告诉链接器在生成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中。在把hello.o链接为可执行文件hello的过程中,链接器就是根据.rel_data和.rel_text节中保存的重定位信息对符号或者函数进行重定位的。

重定位算法如下:

以hello.o重定位表中的第一项为例,目标符号引用出现在偏移0x1c处,其运行时地址为0x401141,目标符号定义在.rodata节中,其运行时地址为0x402008,我们记录下一条指令的运行时地址0x401145,将其与目标符合定义处的运行时地址做差得0x0ec3,将其转化为小端法表示则为c3 0e 00 00,与结果刚好相符。

5.6 hello的执行流程

程序名

程序地址

ld-2.27.so!_dl_start

0x7fce 8cc38ea0

ld-2.27.so!_dl_init

0x7fce 8cc47630

hello!_start

0x400550

hello!init

0x4004c0

hello!main

0x400582

hello!puts@plt

0x4004f0

hello!exit@plt

0x400530

hello!printf@plt

0x400500

hello!sleep@plt

0x400540

hello!getchar@plt

0x400510

sleep@plt

0x400540

5.7 Hello的动态链接分析

动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时,还是需要用到动态链接库。在调用共享库函数时生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。

延迟绑定是通过全局偏移量表(GOT)和过程连接表(PLT)实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:

PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。

GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。

接下来我们对hello的动态链接进行分析:

根据hello 可执行目标文件可知,如下图所示,GOT运行时地址为0x403ff0,PLT的运行时地址为0x404000:

在程序调用dl_init前,使用edb查看地址0x404000处的内容:

GOT表的内容在调用_start之后发生改变,其中GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是共享库模块的入口点。

5.8 本章小结

本章首先介绍了链接的概念、作用,查看了hello的格式,随后分析了可执行目标文件与可重定位目标文件的区别,利用hello详细介绍了静态链接的重定位等过程,之后分析了hello的执行流程并且对hello介绍了动态链接分析,此时hello程序就可以加载到内存中执行了。

6章 hello进程管理

6.1 进程的概念与作用

概念:进程就是一个执行中程序的实例,系统中的每个程序都运行在某个进程的上下文中。其中上下文是由程序正确运行所需的状态组成的,包括存放在内存中的程序的代码和数据,它的栈,通用目的寄存器的内容,程序计数器,环境变量以及打开文件描述符的集合。

作用:进程的作用在于通过进程可以提供给我们一个假象,就好像我们的程序是系统中运行的唯一的程序;程序好像独占地使用处理器和内存;处理器好像是无间断地一条接一条地执行程序中的指令;程序的代码和数据好像是系统内存中唯一的对象。

6.2 简述壳Shell-bash的作用与处理流程

作用:Shell是用户与操作系统之间完成交互式操作的一个接口程序,它为用户提供简化了的操作。Shell最重要的功能是命令解释,从这种意义上说,Shell是一个命令解释器。Linux系统上的所有可执行文件都可以作为Shell命令来执行。当用户提交了一个命令后,Shell首先判断它是否为内置命令,如果是就通过Shell内部的解释器将其解释为系统功能调用并转交给内核执行;若是外部命令或实用程序就试图在硬盘中查找该命令并将其调入内存,再将其解释为系统功能调用并转交给内核执行。

处理流程:

终端进程读取用户由键盘输入的命令行。

分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量。

检查首个命令行参数是否是一个内置的shell命令。

如果不是内部命令,调用fork()创建新进程/子进程。

在子进程中,用步骤2获取的参数,调用execve()执行指定程序。

如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid等待作业终止后返回。

如果用户要求后台运行(如果命令末尾有&号),则shell返回。

6.3 Hello的fork进程创建过程

以下格式自行编排,编辑时删除

当在shell中输入命令“./hello 1190200106 何炫霖”时,shell解析输入的命令行,获得命令行指定的参数。由于./hello不是shell内置的命令,因此shell将hello看作一个可执行目标文件,在相应路径里寻找hello程序,找到该程序就执行它。shell会通过调用fork()函数创建一个子进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同但独立的一个副本,包括代码段、数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,子进程可以读写父进程中打开的任何文件。父进程和子进程之间最大的区别在于它们的PID不同。hello程序之后就会运行在这个新创建的子进程的上下文中。

6.4 Hello的execve过程

当创建了一个子进程之后, exceve函数在当前子进程的上下文加载并运行一个新的程序,加载并运行需要以下几个步骤:

删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。

映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。

映射共享区域。如果hello程序与共享对象链接,然后再映射到用户虚拟地址空间中的共享区域。

设置程序计数器。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

6.5 Hello的进程执行

用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,这个模式中,硬件防止特权指令的执行,并对内存和I/O空间的访问操作进行检查。设置模式位时,进程处于内核模式,一切程序都可运行。任务可以执行特权级指令,对任何I/O设备有全部的访问权,还能够访问任何虚地址和控制虚拟内存硬件。

上下文信息:上下文程序正确运行所需要的状态,包括存放在内存中的程序的代码和数据,用户栈、用寄存器、程序计数器、环境变量和打开的文件描述符的集合构成。

Hello进程执行分析:

Hello起初在用户模式下运行,在hello进程调用sleep之后转入内核模式,内核休眠,并将hello进程从运行队列加入等待队列,定时器开始计时2s,当定时器到时,发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,hello进程继续执行。

6.6 hello的异常与信号处理

异常和信号异常可以分为四类:中断、陷阱、故障、终止:

对于hello程序:

正常运行:

随机键盘输入:

可以看到,无意义输入均被缓存到 stdin,当调用getchar时读出一个‘\n’结尾的字符串,其他字符会当做shell命令输入,并且无意义输入并不会影响到hello进程的运行。

Ctrl+c:

按下Ctrl-C后,hello进程运行终止。组合键Ctrl-C会导致内核发送一个SIGINT信号到前台进程组的每个进程,默认情况下,结果是终止前台作业。利用ps指令可以看到,hello进程已经父进程回收,进程表中无hello进程:

Ctrl+z:

按下Ctrl-Z后,hello进程运行暂停。组合键Ctrl-Z会导致内核发送一个SIGSTP信号到前台进程组的每个进程,默认情况下,结果是挂起前台作业。

ps:

可以看到,hello进程并没有被回收,此时其后台作业号为1。

jobs:

可以看出当前的作业是hello进程,且状态是已停止。

pstree(部分):将所有进程以树状图形式显示

fg:

fg命令可以使停止的hello进程继续在前台运行,可以看到hello程序继续运行了。

kill:

kill命令可以给指定进程发送信号。如图, kill -9 3647 是指向PID为3647的进程(即hello)发送SIGKILL信号。这个命令会杀死hello进程,当再次使用ps时可以发现hello进程已经被杀死。

6.7本章小结

本章介绍了进程的概念与作用,同时介绍shell的一般处理流程和作用,并且分析了调用fork 函数创建新进程和调用execve函数加载并执行hello,最后分析了hello的异常与信号处理。

7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:逻辑地址是指由程序产生的与段相关的偏移地址部分。例如,在进行C语言指针编程中,可以使用&操作读取指针变量的值,这个值就是逻辑地址,是相对于当前进程数据段的地址。一个逻辑地址由两部份组成:段标识符和段内偏移量。

线性地址:线性地址是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址生成了一个线性地址。如果启用了页式管理,那么线性地址可以再变换产生物理地址。若没有启用页式管理,那么线性地址直接就是物理地址。

虚拟地址:因为虚拟内存空间的概念与逻辑地址类似,因此虚拟地址和逻辑地址实际上是一样的,都与实际物理内存容量无关。

物理地址:存储器中的每一个字节单元都给以一个唯一的存储器地址,用来正确地存放或取得信息,这个存储器地址称为物理地址,又叫实际地址或绝对地址。

7.2 Intel逻辑地址到线性地址的变换-段式管理

逻辑地址的表示形式为[段标识符:段内偏移量],这个表示形式包含完成逻辑地址到虚拟地址(线性地址)映射的信息。

逻辑地址实际是由48位组成的,前16位是段选择符,后32位是段内偏移量。通过段选择符,我们可以获得段基地址,再与段内偏移量相加,即可获得最终的线性地址。

段标识符又名段选择符,是一个16位的字段,包括一个13位的索引字段,1位的TI字段和2位的RPL字段。

通过段标识符的前13位,可以直接在段描述符表中索引到具体的段描述符。每个段描述符中包含一个Base字段,它描述了一个段的开始位置的线性地址。将Base字段和逻辑地址中的段内偏移量连接起来就得到转换后的线性地址。全局的段描述符,放在全局段描述符表中,每个进程自己的段描述符,放在局部段描述符表中。全局段描述符表存放在gdtr控制寄存器中,而局部段描述符表存放在ldtr寄存器中。

逻辑地址到线性地址的变换过程为:给定逻辑地址,看段选择符的最后一位是0还是1,从而判断选择全局段描述符表还是局部段描述符表。通过段标识符的前13位,得到Base字段,和段内偏移量连接起来最终得到转换后的线性地址。

7.3 Hello的线性地址到物理地址的变换-页式管理

页表:

页表是一个页表条目(PTE)的数组,用于维护物理地址和虚拟地址的映射关系。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。每个PTE由一个有效位和一个n位地址字段组成,有效位表明该虚拟页是否被缓存在DRAM中。如果设置了有效位,那么地址字段表示相应的物理页的起始位置;如果没有设置有效位,那么空地址表示虚拟页还未被分配,否则这个地址指向该虚拟页在磁盘的起始位置。

如果不考虑TLB与多级页表,虚拟地址可以分为虚拟页号VPN和虚拟页偏移量VPO。其中,VPN可以作为到页表中的索引。进而,通过页表基址寄存器(PTBR)我们可以在页表中获得条目PTE。一条PTE中包含有效位和物理页号(PPN)。如果有效位是0,则代表页面不在存储器中(缺页);如果有效位是1,则代表该内存已经缓存在了物理内存中,可以得到其物理页号PPN,再与物理页偏移量(PPO)共同构成物理地址PA。

当页面命中时CPU硬件执行的步骤:

1.处理器生成一个虚拟地址,并把它传送给MMU;

2.MMU生成PTE地址,并从高速缓存/主存请求得到它;

3.高速缓存/主存向MMU返回PTE;

4.MMU构造物理地址,并把它传送给高速缓存/主存;

5.高速缓存/主存返回所请求的数据字给处理器。

7.4 TLB与四级页表支持下的VA到PA的变换

    PTE有三个权限位,控制对页的访问。R/W位确定页的内容是可以读写的还是只读的。U/S位确定是否能够在用户模式中访问该页,从而保护操作系统内核中的代码和数据不被用户程序访问。禁止执行位可以用来禁止从某些内存读取指令。当MMU翻译每一个内存地址时,它还会更新另外两个内存缺页处理程序会用到的位。每次访问一个页时,MMU都会设置引用位。内核可以用这个引用位来实现它的页替换算法。每次对一个页进行了写之后,MMU都会设置修改位或脏位。修改位告诉内核在复制替换页之前是否必须写回牺牲页。内核可以通过调用一条特殊的内核模式指令来清除引用位和修改位。

Core i7 MMU中,36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1 PET的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,以此类推。

7.5 三级Cache支持下的物理内存访问

首先使用物理地址的CI进行组索引,对8个块分别对CT进行标志位的匹配。如果匹配成功且块的有效位为1,则成功命中。然后根据数据偏移量 CO取出相应的数据并返回。这里的数据保存在一级Cache。

如果没有命中,或者没找到相匹配的标志位,那么就会在下一级Cache中寻找,只要本级Cache中没找到就要去下一级的Cache中寻找数据,然后逐级写入Cache。

在更新Cache的时候,首先需要判断是否有有效位为0的块。若有,则直接写入;若不存在,则需要驱逐一个块(LRU策略),再进行写入。

7.6 hello进程fork时的内存映射

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。内核给新进程创建虚拟内存,创建当前进程的mm_struct、区域结构和页表的原样副本,将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork 时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,为每个进程保持了私有地址空间的抽象概念。同时延迟私有对象中的副本直到最后可能的时刻,充分利用了稀有的物理内存。

7.7 hello进程execve时的内存映射

execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:

1.删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存在的区域结构。

2.映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。

3.映射共享区域,hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。

4.设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。

7.8 缺页故障与缺页中断处理

处理缺页要求硬件和操作系统内核协作完成:

       1:处理器生成一个虚拟地址,并把它传送给MMU。

       2:MMU生成PTE地址,并从高速缓存/主存请求得到它。

       3:高速缓存/主存向MMU返回PTE。

       4:PTE中的有效位是零,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。

       5:缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。

       6:缺页处理程序页面调人新的页面,并更新内存中的PTE。

       7:缺页处理程序返回到原来的进程,再次执行导致缺页的指令,CPU将地址重新发送给MMU。因为虚拟页面现在已经缓存在物理内存中,所以会命中,主存将所请求字返回给处理器。

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。

分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

分配器有两种基本风格。两种风格都要求应用显式地分配块。它们的不同之处在于由哪个实体来负责释放已分配的块:

显式分配器:要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。显式分配器必须在严格的约束条件下工作,约束有:必须处理任意请求序列;立即响应请求;只使用堆;对齐块;不修改已分配的块。分配器的编写应该实现:吞吐率最大化;内存使用率最大化(两者相互冲突)。

隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。

在分配器的具体实现中,主要有以下几种实现方法:

显式空闲链表:

堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针。这样一来,会使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。显式空闲链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片的程度。

隐式空闲链表:

隐式空闲链表的优点是简单。显著的缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索时间与堆中已分配块和空闲块的总数呈线性关系。

带边界标记的隐式空闲链表:

这种方式可以允许在常数时间进行对前面块的合并,并且它对许多不同类型的分配器和空闲链表组织都是通用的。然而它也存在一个潜在的缺陷。它要求每个块都保持一个头部和一个脚部,在应用程序操作许多个小块时,会产生显著的内存开销。

7.10本章小结

本章先介绍了各类地址的概念,然后分析了从逻辑地址到线性地址的变化(段式管理),以及从线性地址到物理地址的变化(页式管理)。然后解析了TLB与四级页表支持下的VA到PA的变换详细分析了地址翻译的过程,分析了三级Cache支持下的物理内存访问,以及hello进程fork和execve时的内存映射,还有缺页故障与缺页中断处理的操作过程。最后讲述了动态存储分配管理的基本方法和策略,从而得到了一个较为完整的动态分配内存的过程。

8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件

所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,所有的输入输出都被当作对相应文件的读和写来执行。

设备管理:unix io接口

这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这使得所有的输入和输出都能以一种统一且一致的方式来执行。

8.2 简述Unix IO接口及其函数

Unix I/O接口:

打开文件:

一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息,应用程序只需要记住这个描述符。

linux shell:

创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件中的常量可以代替显式的描述符值。

改变当前的文件位置:

对于每个打开的文件,内核保持着一个文件位置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用 程序能够通过执行 seek,显式地将改变当前文件位置 k。

读写文件:

 一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号”。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k。

关闭文件:

当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

Unix I/O 函数:

打开文件的函数:int open(char *filename, int flags, mode_t mode);

open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件;mode参数指定了新文件的访问权限位。

关闭文件的函数:int close(int fd);

fd是需要关闭的文件描述符,成功返回0,错误返回-1。关闭一个已关闭的描述符会出错。

读写文件的函数:ssize_t read(int fd, void *buf, size_t n);

               ssize_t write(int fd, const void *buf, size_t n);

read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则返回值表示的是实际传送的字节数量。

write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。

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函数是格式化输出函数, 一般用于向标准输出设备按规定格式输出信息。printf中调用了两个函数,分别为vsprintf和write:

对于vsprintf函数,它根据格式串fmt,并结合args参数产生格式化之后的字符串结果保存在buf中,并返回结果字符串的长度。

对于write函数:

mov eax, _NR_write

mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

它将buf中的i个字符写到终端,由于i保存的是结果字符串的长度,因此write将格式化后的字符串结果写到终端。

 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

它实现的功能就是把将要输出的字符串从总线复制到显卡的显存中。显存中存储的是字符的ASCII码。字符显示驱动子程序通过ASCII码在字模库中找到点阵信息将点阵信息存储到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函数会从输入流中读入一个字符,输入的字符会存放在缓冲区中,如果输入了多个字符,之后的getchar会直接从缓冲区中读取字符。

getchar的返回值是读取字符的ASCII码,若出错则返回-1。

而对于异步异常-键盘中断的处理为键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。

getchar调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

本章首先介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,最后分析了 printf 函数和 getchar 函数的实现。

结论

hello经历的过程:

源程序编写:通过编写工具(文本编辑器、IDE等)编写出hello.c;

预处理:预处理器cpp读取需要的系统头文件内容,生成ASCII码的中间文件hello.i。

编译:编译器ccl将C语言代码翻译成汇编指令,生成hello.s。

汇编:汇编器as将hello.s翻译成机器语言指令,并生成重定位信息,将结果保存在可重定位目标文件hello.o中。

链接:链接器进行符号解析、重定位、动态链接等创建一个可执行目标文件hello,此时hello可以被执行。

运行阶段:当我们在shell键入./hello启动程序的时候,shell调用fork函数为其产生子进程,子进程中调用execve函数,加载hello程序,进入hello的程序入口点。

进程运行:内核负责调度进程,并对可能产生的异常及信号进行处理。内存的管理由MMU、TLB、多级页表、cache、DRAM内存、动态内存分配器共同完成,而Unix I/O的作用则是让程序与文件进行交互。

终止:hello最终被shell父进程回收,内核删除为hello进程创建的所有数据结构。

感悟:

hello从诞生到结束,需要在硬件、操作系统、软件的相互协作配合下,才能最终让它完美地实现自己的功能。从中我看到了对于一个系统而言,需要进行多方面的协调配合才能让每个模块发挥相应的功能。同时,计算机科学家们所做的抽象让应用与具体实现相互分离,从而让我们在实际体验当中往往会忽略它背后隐藏的复杂,我认为这是十分伟大的工作。

通过本门课程的学习,我觉得更多的是给我打开了计算机领域的大门,里面蕴藏着一代代科学家毕生研究的结晶,有许多奥秘等着我去探索,我还需要在未来的学习中更深入的研究里面的知识。

附件

文件名

文件作用

hello.i

预处理器修改了的源程序,分析预处理器行为

hello.s

编译器生成的编译程序,分析编译器行为

hello.o

可重定位目标程序,分析汇编器行为

hello

可执行目标程序,分析链接器行为

helf.txt

hello.o的elf格式,分析汇编器和链接器行为

h1elf.txt

可执行hello的elf格式,用来进行重定位过程分析

参考文献

[1] ANSIC标准定义的C语言预处理指令总结https://blog.csdn.net/zxnsirius/article/details/51158895?utm_source=itdadao&utm_medium=referral

[2]  深入了解计算机系统(第三版)2016 Bryant,R.E. 机械工业出版社

[3]  GCC编译器将源程序“.c”文件翻译为可执行文件的过程https://blog.csdn.net/qq_41543757/article/details/101019828

[4]  printf函数实现的深入剖析 https://www.cnblogs.com/pianist/p/3315801.html

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值