摘  要

本文对hello程序从编写完成开始到最终运行结束的整个过程进行了详细的介绍.涵盖了源文件hello.c经过预处理,编译,汇编,链接一步步变成可执行文件的每一阶段.然后为了执行hello程序,我们在shell终端执行./hello的指令,shell进程开始调用fork函数创建进程,子进程调用execve加载hello程序进入内存,由CPU控制程序的运行,接受来自IO设备的中断信号,进程上下文切换和异常的处理,最后结束hello程序并由父进程回收进程资源,hello程序最终结束了它的任务.

关键词:预处理;编译;汇编;链接;异常处理;进程;系统调用;虚拟内存;shell;IO设备

(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分

目  录

第1章 概述... - 4 -

1.1 Hello简介... - 4 -

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

1.3 中间结果... - 4 -

1.4 本章小结... - 5 -

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

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

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

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

2.4 本章小结... - 8 -

第3章 编译... - 9 -

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

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

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

3.4 本章小结... - 17 -

第4章 汇编... - 18 -

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

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

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

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

4.5 本章小结... - 22 -

第5章 链接... - 23 -

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

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

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

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

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

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

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

5.8 本章小结... - 28 -

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

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

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

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

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

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

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

6.7本章小结... - 32 -

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

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

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

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

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

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

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

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

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

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

7.10本章小结... - 36 -

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

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

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

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

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

8.5本章小结... - 40 -

结论... - 41 -

附件... - 42 -

参考文献... - 43 -

第1章 概述

1.1 Hello简介

根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。

P2P:

我们用C语言编写hello.c程序,接着使用预处理器对源代码文件进行预处理得到.i文件.然后对.i文件进行编译得到hello.s文件.再通过汇编器将hello.s汇编代码文件翻译成机器语言,生成二进制可重定位文件hello.o.最后使用链接器将hello.o与外部库函数链接生成可执行文件hello.out.在shell终端中我们执行hello.out,shell进程会调用fork创建子进程,子进程调用execve函数将hello程序加载进入内存.

020:

操作系统调用execve后映射虚拟内存,先删除当前虚拟地址的数据结构并为hello创建新的区域结构,进入程序入口后载入物理内存,再进入main函数执行代码.程序执行结束之后,内核安排父进程回收hello进程的相关资源,并删除相关数据结构.

1.2 环境与工具

硬件环境:

X64 CPU;2GHz;2G RAM;256GHD Disk 以上

软件环境:

Windows7/10 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位 以上;

开发工具:

Visual Studio 2010 64位以上;CodeBlocks 64位;vi/vim/gedit+gcc

1.3 中间结果

hello.c:源代码

hello.i:预处理后的文本文件

hello.s:编译之后的汇编文件

hello.o:汇编之后的可重定位目标执行文件

hello.out:链接之后的可执行文件

objdump.s:hello.o反汇编代码

out.s:hello的反汇编代码

1.4 本章小结

这一章我们介绍了hello的P2P,020的整个过程,以及实验的软硬件环境,开发调试工具以及生成的中间文件和作用.

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

预处理是编译程序的一个阶段。所谓预处理是指在进行编译的第一遍扫描即词法扫描和语法分析之前所作的工作。预处理是C语言的一个重要的功能,它有预处理程序单独完成,当对一个源文件进行编译时,系统自动引用预处理程序。预处理将原始文件中的预处理指令,比如#include与#define等等转化为c语言代码。在c语言文件的编译过程中,预处理器根据以字符#开头的命令,修改原始的 C程序。预处理在编译之前对源代码进行的是文本处理的操作,预处理阶段主要执行的任务如下:

1. 将头文件中的内容插入到源文件中,即#include <xxx.h>语句指示的文件。

2. 进行宏替换,定义和替换由#define指令定义的符号

3. 删除掉源代码中的注释,注释不会带入到编译阶段,c语言中支持// 与/* */两种类型的注释。

4. 执行条件编译。

例如hello.c中第一行的#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果得到了另一个c程序,通常以.i作为拓展名。

2.2在Ubuntu下预处理的命令

预处理文件hello.i

ubuntu中可以使用多种方式执行预处理的命令,比如

  1. gcc -E hello.c -o hello.i 通过gcc使用预处理器生成.i格式的C语言文件
  2. cpp hello.c > hello.i 直接使用预处理器cpp处理hello.c 然后将输出重定向到hello.i文件之中

2.3 Hello的预处理结果解析

将源文件hello.c与hello.i对比.可以发现hello.i比源文件多了不少内容

通过观察比较两个文件的差异,我们清楚的发现经过预处理之后的hello.i文件行数远远多于hello.c.这是由于预处理阶段递归的将#include预处理指令引入的c文件全部拷贝到hello.i当中,因此前面近3000行的代码都是c语言的库,最后才是我们编写的hello程序.同时我们在hello.i中也没有发现注释,由此得知预处理阶段会将源文件的注释全部删除.

2.4 本章小结

这一章介绍了文件预处理的相关知识,我们知道了源文件在编译过程之前需要执行的具体的操作,同时观察预处理之后的文件,我们发现文件内容瞬间扩张,原来短短的一段hello程序经过预处理阶段也会变得很长,也需要很多的c语言的库函数支持,这些函数早在我们编写程序之前就存在,在我们需要编译执行程序的时候才被预处理拷贝到文件中.

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

编译的概念:编译器(ccl)将.i格式的文本文件翻译成.s格式的文本文件,将C语言程序翻译为汇编语言程序。

编译的作用:将源程序翻译为目标语言程序,进行词法分析与语法分析,检查可能出现的语法错误,并给出错误信息。

3.2 在Ubuntu下编译的命令

编译之后的汇编代码文件hello.s

ubuntu下编译命令:

gcc -S hello.i -o hello.s.

3.3 Hello的编译结果解析

hello.c

上图是hello.c的源文件内容,接下来我们将结合编译产生的hello.s文件来分析编译器是如何处理c语言的各个数据类型以及各类操作.

3.3.1  关系操作

1) !=

首先我们看到

!=操作截图

在hello.s中与之对应的汇编代码是这段:

对应汇编代码

很明显我们可以将cmpl 与je两个语句与源文件中if (argc != 4)进行对应,从这里我们可以看出,对于关系操作,编译器通过cmp指令比较大小,然后对源文件中的具体关系操作类型选取适当的jxx指令,从而实现分支的跳转,在这里如果argc != 4,那么je语句将不会进行跳转,继续往下执行printf()和exit(),在这里编译器使用puts@PLT和exit@PLT翻译.

2) <

然后我们关注hello.c文件中另外的关系操作的例子.

<操作以及对应汇编代码

在紧接着的for循环中,循环条件的判别使用了<操作,我们锁定汇编代码段如上,具体的锁定过程参见下面的控制转移部分.我们不难发现,在汇编代码中cmpl与jle语句同样是作为i<8循环条件的翻译语句,由于jxx指令支持多种操作,在关系操作的具体实现中也有多种等价方式.比如我们这里的例子就是通过比较循环变量是否小于等于7来进行判断的,很明显着与i<8等价,我们同样可以cmpl $8, -4(%rbp)     jl .L4实现相同的效果.

3.3.2 控制转移 for 循环

在判断argc == 4之后,我们的程序继续往下,而在hello.s文件中,如果argc == 4,那么je跳转到标签 .L2处,由此我们可以定位下面一段源代码和汇编代码:

hello.c中的for循环代码

对应的汇编代码实现

  1. 循环变量赋初始值

首先我们看到 .L2 段给 -4(%rbp) 的地址内赋值为0,对应于源码中的 i=0

2.循环条件的判断

然后跳转到 .L3进行循环条件的判断, cmpl 与 jle 两句判断是否满足 i < 8.如果满足就跳转到 .L4 即进入源码中的循环体, 如果不是那么就退出循环.

3.循环变量修改

在 .L2 段中我们发现 源码中对应的循环变量i,在汇编代码中编译器被存放于内存地址-4(%rbp)的位置,因此我们在 .L4段中搜寻修改这个内存地址的指令.即在.L3段上一句addl $1, -4(%rbp), 就是对应于源码中的i++增加循环变量.

由此我们了解了编译器处理源码中for循环结构的具体细节.这里汇编代码的结构于源码结构不同,我们可以观察到汇编代码中将for循环中循环条件的判断的相关代码放置在循环体的最后,循环体包括了循环变量的修改.在首次条件判断时需要首先跳转到整个循环部分的最后,然后再跳转回紧接着的循环体段.

3.3.3 赋值操作

我们在for循环中还能发现编译器对赋值操作的处理,即for循环中i=0循环变量的初始化.

首先判断argc == 4之后程序到达for循环语句,对应的汇编代码通过je .L2语句执行跳转到达 .L2段,然后在.L2段将循环变量存储与-4(%rbp)地址处,然后使用movl $0, -4(%rbp)对其进行赋值,这就实现了i=0循环变量的初始化.

3.3.4 算数操作 +

在for循环中我们同样能够看到编译器对算数运算的实现,具体即加法的实现,3.3.3的汇编代码贴图中.L3标签上一句addl $1, -4(%rbp)即实现的是对循环变量的递增,此时标志一次循环体的执行结束,递增循环变量并进行判断是否进行下一次循环.从这里我们可以看出,对+-*/一些简单的数值运算,存在对应的汇编指令来实现算数操作,因此编译器在翻译源码时也会比较简单直接.

3.3.5 数组操作

在hello.c中我们也能够看到编译器对源码中数组操作的实现,这部分内容的代码截图如下

调用printf之前的数组操作与参数传递

我们不难判断源码中的argv对应于汇编的地址是-32(%rbp),而汇编代码中首先将argv地址移动到%rax寄存器中,使用addq $16, %rax指令获取&argv[2],然后movq (%rax), %rdx将argv[2]的内容移动到寄存器%rdx.而addq $8, %rax就是相对应的获得了argv[1]的地址.因此我们看到编译器实际上是将源代码中的数组操作翻译为了地址的增减操作,通过增减固定长度的类型大小来实现获取数组对应索引的地址.

3.3.6 函数操作

  1. 参数传递

X86-64中,可以通过寄存器最多传递6个整型(整数和指针)参数,寄存器的使用是有特殊的顺序的.顺序为 %rdi, %rsi, %rdx, %rcx, %r8, %r9

调用printf之前的数组操作与参数传递

在源码中参数的顺序是argv[1],argv[2].而在汇编代码的实现中,实现中需要将第一个参数分别移动到%rsi, %rdi寄存器中,这样就实现了参数的传递.从这里看出编译器是通过遵循硬件架构以固定的顺序向对应的寄存器传入对应的参数来实现参数传递的.

  1. 函数调用

在1)参数传递的例子中,我们看到编译器翻译源码函数调用的操作比较简单,通过call指令即可实现函数调用,接着程序回跳转到printf的起始位置执行接下来的函数指令.

2.局部变量

X86-64架构中寄存器可以被划分为调用者保存寄存器和被调用者保存寄存器.它们的主要区别是调用者保存寄存器意味着所有的函数都能够修改它们的值.而对于被调用者保存寄存器,当过程P调用过程Q是,Q必须保存这些寄存器的值,保证它们的值在Q返回到P时与Q被调用时的原始值相同.

hello.c中main()主程定义了局部变量i.编译器会将局部变量分配在寄存器或者内存地址中,在之前的分析中我们知道i对应存储在-4(%rbp)内存地址中,或者编译器有时也会将局部变量分配寄存器中.

3.函数返回

编译器翻译源码中函数返回的方式也非常简单,这里汇编语言提供了ret语句执行函数的返回.ret指令会将当前栈顶的内容pop,然后读取先前调用函数时的返回地址,即原来过程中函数执行后第一条指令的地址,接着ret将程序计数器pc设置到那里,然后继续在原来的过程中执行命令,这样就实现了函数的返回.

3.4 本章小结

这一章我们介绍了编译的概念以及作用,同时使用gcc得到源代码编译实现的汇编代码文件.通过源代码与汇编代码的观察与比较,我们仔细的分析了编译器是怎么处理C语言的各个数据类型以及各类操作的,也因此我们对于汇编代码与源代码之间的转换与联系有了更加深刻的认识.

第4章 汇编

4.1 汇编的概念与作用

汇编的概念:汇编器(as)将.s格式的汇编程序文件翻译成机器语言指令,把这些指令打包为可重定位目标程序的格式,并将结果保存在.o格式的二进制文件.
汇编的作用:将汇编语言转化为机器语言指令,生成的文件能够在链接阶段被链接器识别与链接。

4.2 在Ubuntu下汇编的命令

Ubuntu下命令:

gcc –c hello.s –o hello.o

4.3 可重定位目标elf格式

readelf 读取hello.o段信息

分析hello.o的ELF格式

  1. ELF头:ELF头以一个16字节的魔数开始,魔数描述了生成该文件的系统字大小以及字节顺序.ELF文件头的剩余部分包含了解释目标文件的信息,包括目标文件类型(Type),这里我们可以看到目标文件类型时REL,即可重定位文件.同时还包括ELF文件头的大小,每个ELF段的大小与数量,还有字符串表段在段数组中的段索引等等重要信息.
  2. 段头部表:段头部表记录了ELF文件中每个段的名称,类型,地址,偏移量,大小,权限标记,对齐要求等等.

3.重定位段:我们可以使用objdump -r hello.o来查看重定位段.

hello.o文件重定位段信息

重定位段保存了所有在汇编过程中汇编器无法确定的地址信息,提供给链接器在链接过程中确定这些的最终地址.

在可执行文件中不会存在需要重定位的信息,每个地址都是确定的.重定位段存储了每个段中需要重定位的位置相对于段起始位置的偏移量,需要使用重定位的方式,以及具体重定位的符号值,比如printf,puts等等.

4.4 Hello.o的结果解析

hello.o反汇编得到的代码

这里我们使用objdump对hello.o进行反汇编并将其与hello.s进行对比.

  1. 分支转移

这里我们选取了在hello.s与反汇编结果产生的所有的分支转移指令

我们可以观察到在反汇编的指令中,已经将之前汇编代码的标签标记 .L2, .L3, .L4等等转换为相对于主函数的偏移量.对于汇编阶段的文件中,段标记只是汇编语言的注记符,在汇编之后是以确定的地址呈现的.

2.函数调用

我们再来观察在函数调用方面的不同.

在函数调用方面,我们可以看到反汇编文件与汇编代码文件的不同.在汇编代码之中函数调用时使用的是具体的函数名称.而在反汇编之后就被替换为主函数+偏移量的形式,同时具体的函数地址暂时被置为0,这是因为在当前的函数printf()和atoi()并不是在hello程序中定义的,作为库函数这里的函数地址需要在链接过程中才能够真正确定,这里也标记了确定函数地址所使用重定位的方式.

3.立即数表示的变化

在对比两个文件时,还有一个比较明显的区别,就是立即数的表示形式.在汇编代码文件中立即数大多以10进制表示,而在生成的二进制文件中,通过反汇编的结果,我们发现所有的立即数都已经被替换为了16进制.

​​​​​​​

4.5 本章小结

       这一章我们介绍了汇编的概念与过程.我们可以通过汇编器(as)将.s格式的汇编程序文件翻译成机器语言指令,把这些指令打包为可重定位目标程序的格式,并将结果保存在.o格式的二进制文件.我们简要的分析了ELF文件的结构,之后比较了汇编程序文件与可重定位目标程序文件的不同之处.

(第41分)

第5章 链接

5.1 链接的概念与作用

链接的概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行.链接可以执行于编译时,即在源代码被翻译成机器代码时;也可以执行于加载时甚至运行时.链接主要由链接器执行.

链接的作用:将大型的源文件分解为更小,更好管理的模块,方便独立的修改与编译这些模块.在修改模块之后,我们只需要重新编译文件并链接,而不需要将其他模块全部再次编译.

5.2 在Ubuntu下链接的命令

Ubuntu下连接指令以及生成的文件

命令:

ld  -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 /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello.out

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

1.ELF头

Hello.out的ELF文件头

通过与第四章hello.o文件的readelf读取结果进行比较,我们发现可执行文件hello.out中执行起始地址由原来的0替换为真实的地址0x4010f0,而ELF文件格式也从先前的REL可重定向文件变成了EXEC可执行文件,之前空缺的程序头的大小(size of program headers)与程序头数量(number of section headers)也经过链接过程替换成真实的数值.而且段的数量也有所增加.

2.段头部表

下面贴出了hello.out的段头部表的具体信息,与hello.o比较,我们发现主要增加了.dynsym, .dynstr, .rela.dyn, .rela.plt, .plt, .got, .got.plt等等动态链接过程中需要的段.

5.4 hello的虚拟地址空间

在0x400000~0x401000段中,程序被载入,其中每段的地址与5.3段头部表列出的段起始地址相对应.

5.5 链接的重定位过程分析

hello.out的反汇编结果中多了一些外部库函数的段,比如_init, _start, .plt, puts等等.链接器完成符号解析之后,就把代码中的每个符号引用与正好一个符号定义关联起来.在接下来的重定位步骤中,链接器将合并输入模块,并为每个符号分配运行时地址.

重定位由两步组成:

1.重定位节和符号定义.在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节.例如,来自所有输入模块的.data节被全部合并成一个节,这个节成为输出的可执行目标文件的.data节.然后,链接器将运行时内存地址赋给新的聚合节,赋给输出模块定义的每个节,以及赋给输入模块定义的每个符号.当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了.

2.重定位节中的符号引用.在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址. 重定位条目当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目,代码的重定位条目放在.rel.text中,已初始化数据的重定位条目放在.rel.data中.

我们这里从具体的例子中了解重定位项目地址的计算过程.

我们将hello.out与hello.o的反汇编结果进行对比,发现在hello.out中指令的段偏移量已经被修改为具体的地址.

hello.o中跳转指令的地址中占位符0也通过重定向替换为了相对偏移量.

这里call指令处地址使用的重定位类型是R_X86_64_PC32,即32位PC相对地址引用.即将pc程序计数器中下一条指令的地址减去当前的相对偏移量即可得到call指令的跳转地址.在hello.out的结果中我们看到对应的占位符上替换为e8 95 fe ff ff. 由于大端字节顺序,因此这里的数值是-0x16A,而hello.out中call指令的下一条指令地址是0x401120,因此减去相对偏移量我们可以得到puts函数的起始地址0x401090,因此call指令执行之后跳转到0x401090处继续执行程序.

5.6 hello的执行流程

  1. 开始执行 _start, __libc_start_main
  2. 执行main: main,  puts,  printf,  exit,  sleep,  getchar,  atoi
  3. 退出: exit

程序名                                     地址

_start                                        0x4010f0

__libc_start_main                   0x2f12271d

main                                         0x4011d6

puts                                          0x401090

printf                                        0x4010a0

getchar                                     0x4010b0

atoi                                           0x4010c0

exit                                           0x4010d0

sleep                                         0x4010e0

5.7 Hello的动态链接分析

在进行动态链接前,首先进行静态链接,生成部分链接的可执行目标文件hello.out.此时共享库中的代码和数据没有被合并到hello.out中.加载hello.out时,动态链接器对共享目标文件中的相应模块内的代码和数据进行重定位,加载共享库,生成完全链接的可执行目标文件.

动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射.使用偏移量表GOT+过程链接表PLT实现函数的动态链接.GOT中存放函数目标地址,为每个全局函数创建一个副本函数,并将对函数的调用转换成对副本函数调用.

调用dl_init前的.got.plt

调用dl_init后的.got.plt

5.8 本章小结

这一章我们介绍了链接的过程,包括静态链接与动态链接两种链接技术.重点分析了链接中重定位过程,还通过具体示例分及计算重定位之后指令地址的计算,同时介绍了动态链接的方法.

(第51分)

第6章 hello进程管理

6.1 进程的概念与作用

进程的概念: 进程是计算机程序需要进行对数据集合进行操作所运行的一次活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础.进程是操作系统在运行一个程序时创建的一个抽象,提供给程序一种独占处理器和内存的假象.进程具有一个独立的逻辑控制流和一个私有的地址空间.

进程的作用: 操作系统对每个进程都保存了必要的进程信息,比如进程id,进程组id,寄存器值,环境变量,程序计数器等等数据.借助进程的抽象以及上下文切换等机制,操作系统能够并发运行多个程序,避免单个程序过长时间占用计算资源,提高资源的利用效率.

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

Shell-bash是用c语言编写的程序,是unix/linux系统的命令解释器,用户可以通过shell使用linux.在shell中可以执行多种系统调用.

处理流程

shell接受用户输入的命令进行分析,创建子进程,由子进程实现命令所规定的功能,等子进程终止后发出提示符,在执行命令的同时shell接受并处理来自键盘输入的信号.

6.3 Hello的fork进程创建过程

Linux系统中提供了fork()系统调用创建进程,fork()返回进程的pid值,对于父进程,pid>0时表示子进程的pid,对于子进程返回0,如果pid<0则说明fork()产生错误.

在终端输入命令执行hello程序时,shell会处理对应的命令,如果判断不是内置命令,则shell调用fork创建子进程得到与父进程用户级虚拟地址空间相同的但是独立的一份副本,拥有不同的PID.

6.4 Hello的execve过程

execve()函数在当前进程的上下文中加载并运行一个程序.execve()的函数定义如下:

int execve(const char *filename, const char *argv[], const char *envp[]);

execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp.只有当出现错误时,例如找不到filename, execve才会返回到调用程序,所以,与fork一次调用返回两次不同, execve调用一次并从不返回.如果exeve成功,则不返回,如果错误,返回-1.

execve

在执行hello程序时,我们首先在shell中执行命令,shell先fork一个子进程,然后子进程执行execve函数,将参数传递给execve,并执行hello.

6.5 Hello的进程执行

在执行hello程序时,相应的进程并不是一直占有cpu资源的,在进程时间片结束之后,cpu发送时钟中断信号给操作系统,操作系统接受到时钟中断信号之后,将当前的运行环境从用户态切换为核心态. 操作系统在内核模式中进行进程的调度,通过进程调度算法选择能够运行的其他进程,接下来操作系统进行上下文切换,将正在执行hello程序的进程上下文信息,包括通用寄存器存储的值,进程的栈,程序计数器,环境变量等等进程信息保存,然后切换到接下来执行的进程上下文信息,在设置程序计数器到对应的指令,接着从核心态切换回到用户态执行其他的进程.新的进程执行固定的进程时间片之后中止,接下来再次进行进程的调度,可能会切换回职执行hello程序的进程继续运行程序.

6.6 hello的异常与信号处理

异常可以分为四类:

  1. 中断: 来自IO设备的信号,异步发生,总是返回到下一条指令
  2. 陷阱: 有意的异常,是执行一条指令的结果,同步发生,总是返回到下一条指令,陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,称为系统调用.
  3. 故障: 故障由错误情况引起,可能能够被故障处理程序修正.是潜在的可恢复错误,同步发生,可能返回到当前指令
  4. 终止: 终止是不可恢复的致命错误造成的结果.通常是一些硬件错误.

在hello执行过程中可能的情况:

1.正常运行

2.Ctrl+C终止

3.Ctrl+Z挂起

4.运行过程中键盘随机输入,无关输入缓存于stdin中,随print指令输出

6.7本章小结

这一章我们介绍了进程的概念以及作用,shell程序的作用以及执行流程,hello程序中fork,execve等系统调用的执行过程,以及hello程序中的异常与信号处理

(第61分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

1. 逻辑地址: 逻辑地址的格式是”段地址:偏移地址”,逻辑地址由CPU生成,在内部编程中使用,逻辑地址并不唯一.

2. 线性地址: 线性地址是逻辑地址到物理地址变换之间的中间层.在分段部件中逻辑地址是段中的偏移地址.然后加上基地址就是线性地址.

3. 虚拟地址: 保护模式下程序访问存储器所用的逻辑地址,虚拟地址由CPU生成.

4. 物理地址: 计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组.每个字节都有唯一的一个物理地址.

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

8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应(段寄存器主要是由mmu管理),段寄存器有一个栈、一个代码、两个数据寄存器。

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

首先线性地址划分为VPN+VPO两部分,VPN是虚拟地址页,VPO是页偏移量,物理页的偏移与虚拟页的偏移一致.首先我们通过TLB来查找对应PPN即物理页号,将VPN划分为TLBT与TLBI两部分,如果TLB缓存命中就直接将PPN+VPO组合成需要的物理地址,如果不命中,或者发生缺页的情况,则需要继续往内存中寻找PPN,最后组成PPN+VPO形式的物理地址.

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

将VA划分为VPN+VPO两部分,首先通过TLB查找对应的PPN,如果TLB中没有缓存,那么将VPN划分为四个VPNX(X=1,2,3,4)每个虚拟页号都是当前页表的索引,依次递进查找,直到第四层页表VPN4索引处值就是对应的PPN,如果发生缺页,则需要从内存中查找对应的PPN并对虚拟页进行分配.最后将得到的PPN与VPO组合得到物理地址PA

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

先将虚拟地址转换为物理地址,再对物理地址进行分析,物理地址由CT、CI、CO组成,然后在一级cache内部找.用CI位进行索引,如果匹配成功且对应组标记为有效,则称为命中,根据偏移量CO在L1cache中取数.否则称为不命中,此时需要分别到L2、L3和主存中重复上述过程.

7.6 hello进程fork时的内存映射

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

当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面.

7.7 hello进程execve时的内存映射

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

  1. 删除已存在的用户区域.删除当前进程虚拟地址的用户部分中已存在的区域结构
  2. 映射私有区域.为新程序的代码,数据,bss和栈区域创建新的区域结构.所有这些新的区域都是私有的,写时复制的.
  3. 映射共享区域.如果hello程序与共享对象链接,那么这些对象都是动态链接到hello程序的,然后再映射到用户虚拟地址空间中的共享区域内
  4. 设置程序计数器pc.execve最后设置当前进程上下文中的程序计数器,将它指向代码区域的入口处.

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

在程序执行过程中如果出现缺页故障,程序会触发缺页异常.缺页异常会调用内核中的缺页异常处理程序.异常处理程序首先检查触发故障的内存地址是否合法.如果不合法立即产生一个段错误并退出程序.如果地址合法,在进一步检查读写权限是否满足要求,如果不满足,则触发保护异常.在两步检查通过之后,内核选择一个牺牲页将其从虚拟内存中换出,如果牺牲页被修改过那么将页的内容写回对应的物理页再换出.之后换入新的页面并更新页表.随后内核将控制转移会hello程序,继续执行之前触发缺页异常的指令.

7.9动态存储分配管理

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

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

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

1.显示分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫做malloc程序包的显示分配器。

2.隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放这个块。隐式分配器也叫垃圾收集器。

显示分配器有隐式空闲链表与显示空闲链表两种格式.隐式空闲链表的块结构如图所示.这里我们主要介绍显示空闲链表.

使用堆块标记格式的隐式空闲链表

因为在实际使用中块分配与堆块的总数成线性关系.因此对于通用的分配器,隐式空闲链表是不合适的.

显式空闲链表将空闲块组织为某种显式的数据结构.因为根据定义.程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面,堆可以组织成一个双向空闲链表,在每个块中,都包含一个前驱和后继指针.

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间.维护链表的顺序有:后进先出(LIFO),将新释放的块放置在链表的开始处,使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱.平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率.

7.10本章小结

这一章我们介绍了hello程序的存储地址空间,段式管理与内存管理,简单介绍了虚拟地址转化为物理地址的过程, 分析了hello进程fork时的内存映射,hello进程execve时的内存映射、缺页故障与缺页中断处理和动态存储分配管理.

(第7 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

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

设备的模型化:文件, 所有的io设备都被模型化为文件,所有的输入与输出都能被当作相应文件的读和写进行操作.

设备管理:unix io接口

8.2 简述Unix IO接口及其函数

Unix IO接口:

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

2.Linux Shell创建的每个进程都有三个打开的文件:标准输入(描述符为0),标准输出(描述符为1),标准错误(描述符为2).

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

4.读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。

类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。

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

Unix IO函数

1.打开文件:

int open(char* filename,int flags,mode_t mode)

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

2.关闭文件:

int close(fd)

进程通过调用close函数关闭一个打开的文件,fd是需要关闭的文件的描述符.

3.读文件:

ssize_t read(int fd,void *buf,size_t n)

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

4.写文件:

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

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

若write函数成功执行返回写的字节数,若出错返回-1.

8.3 printf的实现分析

[转]printf 函数实现的深入剖析 - Pianistx - 博客园

我们首先观察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;

}

这里 函数签名中的 … 是可变形参的一种写法.当传递参数的个数不确定时,就可以用这种方式来表示.

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);

p_next_arg += 4;   //下一个参数值地址

p += strlen(tmp);    //放下一个参数值的地址

break;

case 's':

break;

default:

break;

}

}

return (p - buf);       // 生成的字符串长度

}

接下来是write函数的实现

write:

mov eax, _NR_write

mov ebx, [esp + 4]

mov ecx, [esp + 8]

int INT_VECTOR_SYS_CALL

这里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

syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码.字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中.显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量).

于是我们的打印字符串就显示在了屏幕上.

因此printf函数总体的过程包括从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.

8.4 getchar的实现分析

异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。

8.5本章小结

这一章我们介绍了linux系统中IO设备的管理方法,Unix IO接口以及相关的函数,分析了printf与getchar两个系统函数的实现.

(第81分)

结论

  1. 编写源文件hello.c, 使用键盘输入程序代码
  2. 预处理: 使用预处理器对hello.c源文件进行预处理,复制hello.c调用的外部库代码,替换所有的宏定义,并且删除注释,生成预处理之后的代码文件hello.i.
  3. 编译: 将hello.i文件进行翻译生成汇编语言文件hello.s.
  4. 汇编: 将hello.s翻译成一个可重定位目标文件hello.o.
  5. 链接: 将hello.o与可重定位目标文件和动态链接库链接成为可执行文件hello.out.
  6. 运行: 在shell终端输入./hello.out 123 xxx 1
  7. 创建子进程: 输入的命令不是shell内置命令,于是当前进程调用fork创建子进程
  8. 加载程序:子进程调用execve()将hello程序加载入内存, 进入程序入口后程序开始载入物理内存,然后进入main函数
  9. 执行指令: cpu为进程分配时间片,在进程时间片内,hello程序占有cpu,执行自己的指令
  10. 访问内存: MMU将虚拟地址通过页表转换为物理地址进行访问.
  11. 动态内存申请: hello程序中printf()函数调用malloc向堆中申请内存.
  12. 信号管理:在shell终端中输入ctrl+z或者ctrl+c可以向前台任务发送SIGTSTP或者SIGINT信号,系统在接受到信号后默认行为向前台进程组中的所有进程发送对应信号将进程挂起或终止.通过信号发送机制我们可以控制hello程序的执行.
  13. 终止: 在hello程序执行完毕之后,内核安排父进程回收子进程创建时申请的资源. 将子进程的退出状态传递给父进程,并删除为这个进程创建的所有数据结构.

深切感悟:

计算机系统中抽象是非常中要的理念,在底层的电路信号中使用二进制01进行模拟,而在程序层面,操作系统构建了进程这一抽象概念,使得程序拥有能够占有计算机所有资源的假象,同时操作系统在保证程序安全运行的同时提高了并行性.虚拟内存是对主存和磁盘的抽象,同过虚拟内存的机制,可以很好的降低主存与磁盘较慢的读写性能对计算机任务执行性能的影响.

(结论0分,缺失 -1分,根据内容酌情加分)

附件

hello.c:源代码

hello.i:预处理后的文本文件

hello.s:编译之后的汇编文件

hello.o:汇编之后的可重定位目标执行文件

hello.out:链接之后的可执行文件

objdump.s:hello.o反汇编代码

out.s:hello.out的反汇编代码

(附件0分,缺失 -1分)

参考文献

[1]  Bryant R E, David Richard O H, David Richard O H. Computer systems: a

programmer's perspective[M]. Upper Saddle River: Prentice Hall, 2003.

[2]  俞甲子. 程序员的自我修养: 链接, 装载与库[M]. 电子工业出版社, 2009.

[3]  [转]printf 函数实现的深入剖析 - Pianistx - 博客园

(参考文献0分,缺失 -1分)

哈工大2022春CSAPP大作业相关推荐

  1. 哈工大2022春CSAPP大作业-程序人生(Hello‘s P2P)

    摘  要 本论文研究了hello.c这一简单c语言文件在Linux系统下的整个生命周期,以其原始程序开始,依次深入研究了编译.链接.加载.运行.终止.回收的过程,从而了解hello.c文件的" ...

  2. 哈工大2022春计算机系统大作业:程序人生-Hello‘s P2P

    计算机系统 大作业 题     目 程序人生-Hello's P2P 专       业 计算机类 学   号 120L021305 班   级 2003002 学       生 李一凡 指 导 教 ...

  3. 哈工大2022春计算机系统大作业

    计算机系统 大作业 题     目 程序人生-Hello's P2P 专       业 计算机类 学   号 班   级 学       生 指 导 教 师 计算机科学与技术学院 2021年5月 摘 ...

  4. 哈工大2022秋计算机系统大作业——程序人生

    目录 第1章 概述 1.1 Hello简介 1.2 环境与工具 1.3 中间结果 1.4 本章小结 第2章 预处理 2.1 预处理的概念与作用 2.2在Ubuntu下预处理的命令 2.3 Hello的 ...

  5. 2022春 计算机系统大作业 程序人生-Hello’s P2P

    计算机系统 大作业 题 目 程序人生-Hello's P2P 专 业 计算学部 学 号 班 级 学 生 指 导 教 师 计算机科学与技术学院 2022年5月 摘 要 为深入理解计算机系统,本文以hel ...

  6. 哈工大2022秋计算机系统大作业-程序人生(Hello‘s P2P)

    计算机系统 大作业 题     目 程序人生-Hello's P2P 专       业 计算机科学与技术 学    号 班    级 学       生 指 导 教 师 刘宏伟 计算机科学与技术学院 ...

  7. 哈工大 2021春 计算机系统 大作业程序人生

    计算机系统 大作业 题 目 程序人生-Hello's P2P 专 业 计算机 学 号 1190200828 班 级 1936601 学 生 赵英帅 指 导 教 师 刘宏伟 计算机科学与技术学院 202 ...

  8. 哈工大2021春计算机系统大作业 程序人生-Hello’s P2P

          计算机系统 大作业 题     目 程序人生-Hello's P2P 专       业 计算机类 学     号 1190200613 班     级 1903004 学       生 ...

  9. 哈工大 2021春 计算机系统 大作业 L190201101-朴仁洪

    @ 计算机系统 大作业 题 目 程序人生-Hello's P2P 专 业 计算机类 学 号 L190201101 班 级 1903005 学 生 朴仁洪 指 导 教 师 史先俊 计算机科学与技术学院 ...

最新文章

  1. iOS经典面试题总结--内存管理
  2. tableau必知必会之如何在同一视图中进行相同分析维度图表的切换
  3. 深入浅出聊一聊Docker
  4. Linux终端乱码的解决办法
  5. 网上书店模板asp与html,一个简单的网上书城的例子(三)_asp实例
  6. 计算机突然从桌面消失了,电脑桌面突然什么都没有了,怎么处理
  7. 【洛谷 - P1507 】NASA的食物计划(二维费用背包,dp)
  8. 2022年中国功能性儿童学习用品行业发展趋势报告
  9. 早知道就好了!这些编程入门神器,赶紧用起来
  10. POST—GET—两种提交方式的区别
  11. python2 md5库_python版本坑:md5例子(python2与python3中md5区别)
  12. c语言mud文字武侠游戏,文字武侠mud游戏,纯文字武侠mud游戏手机版预约 v1.0-手游汇...
  13. 还在忍受磁力搜索网站不忍直视的广告么?18年最新最好用的bt磁力搜索网站介绍
  14. 端口扫描工具masscan常用方法和参数
  15. 移动硬盘计算机无图标,移动硬盘不显示图标的处理方法
  16. 看看这篇ARM体系结构你就都明白了
  17. dos(cmd)命令
  18. JS根据身份证号计算年龄
  19. 网站适配IE浏览器的几个注意事项
  20. Naive UI - 火热出炉!基于 Vue 3.0/TypeScript 的免费开源前端 UI 组件库

热门文章

  1. 解决dubbo中遇到HessianProtocolException: ‘xxxException‘ could not be instantiated的问题
  2. 单量子比特的布洛赫球(Bloch Sphere)分析
  3. 玩魔兽世界的朋友必看,令人感动的MM(经典)
  4. 02: DNS服务基础 特殊解析 DNS子域授权 缓存DNS 总结和答疑
  5. 2021下半年教资信息技术学科知识与教育能力——主观题
  6. HxD(十六进制编码处理工具) 1.7中文版
  7. 农村女孩奋斗在城市的艰苦
  8. java线程同步原理
  9. 基于NLP的智能问答系统核心技术
  10. java 修改ini文件_java读取和修改ini配置文件 | 学步园