程序人生-Hello’s P2P

摘  要

本文以hello.c为例,介绍了整个P2P的流程。从最初的源文件开始编译成可执行文件,再到可执行文件在shell中的运行,利用了多种强大的工具详细分析了hello程序的预处理,编译,汇编,链接,进程管理,存储管理,I/O管理等一系列过程。从本质和底层展现了hello是如何运行的。同时,也分析了程序运行过程中可能出现的问题以及优化措施。

关键词:P2P;进程管理;计算机系统:编译;hello程序                     

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

目  录

第1章 概述... - 5 -

1.1 Hello简介... - 5 -

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

1.3 中间结果... - 5 -

1.4 本章小结... - 5 -

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

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

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

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

2.4 本章小结... - 9 -

第3章 编译... - 10 -

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

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

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

3.3.1 数据类型... - 11 -

3.3.2        数据操作... - 12 -

3.3.3        控制转移... - 13 -

3.3.4        函数调用... - 15 -

3.4 本章小结... - 15 -

第4章 汇编... - 16 -

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

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

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

4.3.1 ELF头... - 17 -

4.3.2 节头部表... - 18 -

4.3.3 重定位节... - 19 -

4.3.4 符号表... - 20 -

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

4.5 本章小结... - 21 -

第5章 链接... - 22 -

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

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

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

5.3.1 ELF头... - 23 -

5.3.2 节头部表... - 23 -

5.3.3 重定位节... - 24 -

5.3.4 符号表... - 25 -

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

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

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

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

5.8 本章小结... - 30 -

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

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

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

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

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

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

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

6.7本章小结... - 36 -

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

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

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

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

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

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

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

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

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

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

7.10本章小结... - 43 -

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

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

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

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

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

8.5本章小结... - 46 -

结论... - 47 -

附件... - 48 -

参考文献... - 49 -

第1章 概述

1.1 Hello简介

先在文本编辑器上写出C源码,然后经过预处理器(cpp)对源程序进行预处理,对预处理后的文件调用编译器(ccl)翻译成汇编文件,再用汇编器(as)将其转化为机器码,形成可重定位目标程序,最终经过链接器,形成一个真正的可执行目标程序。在shell中使用命令行来启动这个可执行的二进制目标程序,shell会为其fork一个子进程,经过execve将其加载到上下文中,形成一个执行的进程,Hello最终从Program变成了Process,即P2P。

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

1.3 中间结果

hello.c:程序源代码

hello.i:预处理后的源程序

hello.s:汇编程序

hello.o:可重定位目标程序

hello.asm:hello.o的反汇编代码

hello.elf:hello.o的格式文件

hello:可执行目标程序

helloexe.elf:hello的格式文件

helloexe.asm:hello的反汇编代码

1.4 本章小结

         主要介绍了P2P,列举了中间文件,简单介绍环境及流程。

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

概念:

预处理是指在C语言程序在被编译器处理编译前,由预处理器进行的一系列操作,预处理器在预处理的过程中,根据以字符#开头的命令,修改原始的C程序,得到一个新的展开的C语言程序,并生成一个以.i作为文件扩展名的文件。预处理本质是一个文本替换。

作用:

主要作用有三点:

  1. 宏定义处理

当使用#define定义一个宏变量后,如:#define Pi 3.14,预处理器在预处理的过程中会将C文本程序中出现的Pi全部替换为3.14。

  1. 文件包含处理

    当使用#include<>读取头文件后,如#include<stdio.h>,预处理器在预处理的过程中会读取系统头文件stdio.h的内容,并把它直接插入到文本程序中。

  1. 条件编译处理

    当使用了#if以及其配套语法时,预处理器在预处理的过程中根据条件是否成立来判断#if内语句是否参与到编译过程:当不成立时,#if内的语句不参与到编译器编译的过程中;反之则参与。

2.2在Ubuntu下预处理的命令

在Ubuntu下,预处理命令为:gcc -E hello.c -o hello.i

该指令对hello.c进行预处理并输出结果到hello.i文件中。

 

图 1 Ubuntu下预处理指令及结果

2.3 Hello的预处理结果解析

经过预处理后的hello.i文件共3060行,相比原hello.c文件(23行)长了3037行。对比主函数发现二者主函数相同,多出来的部分就是对于头文件stdio.h,unistd.h以及stdlib.h的预处理,预处理器将他们从系统文件中读取,并直接插入到文本程序中。同时,预处理的过程中还删除了原C文本程序的注释。

 

图 2 源文件中需要预处理的头文件

   显然,预处理后生成的.i文件同样是一个可读的C文件。

 

图 3 预处理后main函数

    

2.4 本章小结

本章程序的预处理开始,开启程序编译的准备环节。了解了预处理过程中预处理器的作用及任务:删除注释,对宏定义,文件包含以及条件编译进行处理。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

我们通常所接触的编程语言,如C,C++,Java等等,是一种符合人类语法,方便编程人员阅读和编写的高级语言。然而在机器程序运作的过程中,并不能直接识别这些语句。机器代码只能是一系列二进制数,对应于物理层面的高低电压序列。汇编语言可以将易于编程人员读写的高级语言与机器对二进制输入的低级操作相关联起来,使机器按照编程人员的想法运作。在Linux下,GCC编译器使用编译器(ccl)将经过预处理的.i文件翻译成更接近于机器代码,且便于人类阅读的汇编语言,并生成.s文件。因此,编译就是指编译器基于编程语言的规则,目标机器的指令集和操作系统遵循的惯例,将高级语言程序文本文件翻译传化成汇编程序的过程。显然,编译作用的主体是经过预处理后的.i文件,生成.s汇编程序文本文件。

       编译使得高级语言编写的程序文本更加贴近与机器代码(一系列二进制串)。了解编译过程以及汇编语言,可以更好的理解编译器的优化能力,以及高级语言所编写的代码在编译的过程中隐性存在的低效率。相比于高度抽象的高级语言,汇编语言更加接近机器运转的本质,因此深入了解编译过程以及汇编语言能极大的提高对计算机底层实现的本质,更有助于编程人员优化程序性能。

3.2 在Ubuntu下编译的命令

在Ubuntu下,编译命令为:gcc -S hello.i -o hello.s

该指令对hello.i进行编译,并将结果输出到hello.s中。

 

 

图 4 Ubuntu下编译指令及结果

3.3 Hello的编译结果解析

3.3.1 数据类型

         观察hello.c源文件,可以发现hello.c中一共包含三种数据类型,int型(红色框),char型指针(绿色框)以及字符串(紫色框)。逐一分析他们在汇编中的行为:

 

图 5 hello.c中出现的数据类型

  1. int型变量i与argc:

i是在主函数中声明的局部变量,可以看到在汇编代码中,i被申请放在了栈中。i是一个整型数,占四字节,因此,在编译的过程中,编译器在最低端开辟了四字节的空间用来存放i。

 

图 6 局部变量i在编译过程中的处理

                argc是作为参数传递给main函数的,因此存储在寄存器%edi中,随后编译器在编译的过程中将其同样放入到栈中。

 

图 7 argc在编译过程中的处理

  1. char指针型数组*argv[]:

同理argc,*argv[]作为main函数的第二个参数,数组的首地址被存储在寄存器%rsi中,编译器在编译的过程中也将其首地址压入到栈中。

 

图 8 *argv[]在编译过程中的处理

  1. 字符串

两个字符串在编译过程中被作为只读字符串,并存储在内存中,由汇编的伪指令声明指出。

 

图 9 只读字符串

      1. 数据操作

在C源文件中,分别对两个字符串,argc,argv以及i进行了数据操作。对

于两个字符串,分别在printf函数中调用。在汇编中,将字符串的首地址放入到%rdi中,并传递给puts函数和printf函数,完成对字符串的输出,汇编对于C语言的赋值操作采取mov指令实现,在数据类型和数据操作中均有体现。

 

图 10 对字符串的操作

对argc的操作主要用来判断用户输入是否符合输入格式,及是否包含了四个字符串,如果恰为四个字符串,则输出Hello,否则提示用法,详细的跳转过程会在3.3.3中详细阐述。这里我们只关注对于argc的不等式判断,在汇编文件中,表现如下:

 

图 11 argc的不等式比较

该语句对应于C语言中的argc!=4:比较4和argc,若argc==4则跳转到.L2,不等于则继续按顺序执行。

argv[1],argv[2]作为第二个,第三个参数传递给printf函数,argv[3]作为参数传递给atoi函数。

i作为循环判断的条件,在循环开始时赋值为0,每循环一次+1,大于7时停止。

 

图 12 i的初始赋值

 

图 13 循环后+1

 

图 14 判断跳转,小于等于7则跳转

      1. 控制转移

         hello.c主要包含两个控制转移,一个是if判断语句,直接体现了控制转移,一个是for循环,循环中包括了控制转移,他们使得程序不再是按顺序执行,而是发送了种种跳转。

 

图 15 源程序中的控制转移

在汇编中,使用cmp来进行对比,并设置条件码,用je,jle等语句来根据条件码进行跳转。如if语句,先比对4与argc的值,相等则跳转到.L2,对i进行初始化,跳转到.L3,并开始for循环;不相等,则继续执行,调用puts函数输出用法提示,随后调用exit函数,退出程序。

 

图 16 if控制

for循环的运转如下,红线表示成立时跳转,蓝线表不成立时跳转,没有跳转时就顺序执行。

 

图 17 for循环示意

      1. 函数调用

对于函数的调用,汇编使用call指令来实现传递控制。在调用一个函数Q时,将当前PC压栈,然后将PC更改为Q函数的首地址,函数Q在执行结束后,使用ret指令将PC压入栈的值弹出,使得PC重新指向调用函数前本该执行的代码地址。

对于函数的参数传递,可以使用%rdi,%rsi,%rdx,%rcx,%r8,%r9留歌寄存器来传递参数,当多于六个时,则会使用压栈的方式通过栈来进行传递。

对于函数的局部变量,可以采用寄存器存储,当出现如下情况时,需要利用栈来储存数据:

1. 当寄存器不够时需要将数据储存到栈上。

2. 对该变量使用了&(取值符),因此必须在内存中给该变量开辟一个空间。

3. 数组或结构体。

3.4 本章小结

本章主要分析了在汇编过程中,编译器对经过预处理后的.i文件的操作。建立起汇编语言与高级语言的联系。体会到机器底层代码的实现。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

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

        汇编代码经过汇编之后,成为真正能够执行的机器指令。在经过链接后能被机器识别并执行。

4.2 在Ubuntu下汇编的命令

在Ubuntu下,编译命令为:gcc -c hello.s -o hello.o

该指令对hello.s进行汇编,并将结果输出到hello.o中。

 

 

图 18 Ubuntu下汇编指令及结果

       hello.o是一个二进制文件,因此使用文本编辑器打开后会出现乱码。

 

图 19 用文本编辑器打开hello.o

4.3 可重定位目标elf格式

        使用readelf -a hello.o > hello.elf查看hello.o的elf格式,并将其输出到hello.elf中。

 

图 20 readelf命令查看elf格式

4.3.1 ELF

ELF头(ELF header)以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含了帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是有节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。

 

 

图 21 ELF头

4.3.2 节头部表

        节头部表包含目标文件各节的语义,包括节的名称、大小、类型、地址、偏移量、是否链接、读写权限等信息。

 

图 22 节头部表

4.3.3 重定位节

当汇编器生成一个目标模块时,它并不知道数据和代码最终将放在内存中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。代码的重定位条目放在.rel.text中。已初始化数据的重定位条目放在.rel.data中。

 

图 23 重定位节

4.3.4 符号表

在节中称为.symtab,它存放在程序中定义和引用的函数和全局变量的信息。每个可重定位目标文件在.symtab中都有一张符号表。和编译器中的符号表不同,.symtab符号表不包含局部变量的条目。

 

图 24 符号表

4.4 Hello.o的结果解析

使用objdump -d hello.o > hello.asm命令行将hello.o文件生成为反汇编文件,并将结果保存在hello.asm中。对比并分析hello.s和hello.asm之间的区别:

  1. 立即数的存储方式不同

在汇编文件中,立即数是用十进制表示的,而在反汇编中立即数以十六进制的方式表示。在机器码中,只能用二进制表示数据,十六进制表达比十进制到二进制的转化更为方便,因此在反汇编中使用十六进制来表示立即数。

 

图 25 立即数在汇编(下)与反汇编(上)中的表示

  1. 分支转移的方式不同

在汇编文件中,使用标签来指向跳转的目标,而在反汇编文件中,采用了指向地址的方法。如图:

 

图 26 转移在反汇编(上)与汇编(下)中的表示

      

  1. 函数调用不同

在函数调用的过程中和分支转移的情况类似,在反汇编中采取指向地址的方式,而在汇编中则直接地址调用。

 

图 27 函数调用在反汇编(上)和汇编(下)中的表示

  1. 是否存在机器码

在反汇编中右机器码而在汇编中不包含机器码。

4.5 本章小结

本章对应的主要是hello.s汇编到hello.o的过程。查看了hello.o的可重定位目标文件的格式,使用反汇编查看hello.o经过反汇编过程生成的代码并且把它与hello.s进行比较,分析从汇编语言进一步翻译成为机器语言的汇编过程。

(第41分)

5章 链接

5.1 链接的概念与作用

链接是将各种代码和数据片段收集并合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。

链接器在软件开发过程中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。

5.2 在Ubuntu下链接的命令

在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

 

图 28 在Ubuntu下的链接命令及结果

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

   5.3.1 ELF

            可以看到在hello的格式中的ELF头有27节,同时与hello.o的类型不同,hello.o的类型是REL(可重定位文件),而hello的类型是EXEC(可执行文件)。

 

图 29 hello的ELF头

5.3.2 节头部表

         节头部表对hello中所有信息进行了声明,包括了大小、偏移量、起始地址以及数据对齐方式等信息。

 

图 30 hello的节头部表

5.3.3 重定位节

 

图 31 hello的重定位节

5.3.4 符号表

 

图 32 hello的符号表

5.4 hello的虚拟地址空间

使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。 

打开edb,通过 data dump 查看加载到虚拟地址的程序代码。得知虚拟空间从0x00400000开始

 

图 33 hello的虚拟内存空间

以节头.rodata为例,在5.3中查到.rodata的位置起始于0x00402000,在edb中搜索该地址。

 

图 34 .rodata在虚拟内存的位置

同理.rodata可以在edb中找到所有节点的信息。

5.5 链接的重定位过程分析

使用objdump -d hello > helloexe.asm反汇编hello并将结果保存在helloexe.asm中。

 

图 35 hello的反汇编结果

hello与hello.o反汇编的不同:

  1. 地址不同

hello的起始地址为0x400000,而hello.o的起始为0。

 

图 36 hello与hello.o的反汇编对比

  1. 包含函数数量不同。

hello.o中只包含了main函数,而hello反汇编经过链接后包含了全部函数。

 

图 37 hello与hello.o反汇编的函数对比

  1. 跳转方式出现差异

在链接过程中,链接器解析了重定位条目,并计算相对距离,使用PC相对寻址,提高程序的可移动性。

 

图 38 PC相对寻址

5.6 hello的执行流程

使用edb的Analyzer here功能,在edb中进行两次分析,一次在运行前,一次运行链接后,其结果如下:

 

图 39 hello函数的执行流程

从上图中可以得到函数运行顺序以及地址等信息。

5.7 Hello的动态链接分析

  在hello的格式文件中找到.got,以及.got.plt,查看其地址。

 

图 40 .got以及.got.plt

在edb中查看:

 

图 41 在edb中查看.got

GOT表位置在调用dl_init之前,其后的字节均为0,当调用dl_init之后,发生了变化:

 

图 42 调用后的.got

对比发现,表的内容发生了变化。这是因为plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用pl时,指向的就是正确的内存地址。plt就能跳转到正确的区域。

5.8 本章小结

本章主要分析了链接过程以及链接行为。对比分析了链接形成的可执行目标文件和未经链接的可重定位文件。还重点分析了静态链接与动态链接,详细解释了其功能与不同。

(第51分)

6章 hello进程管理

6.1 进程的概念与作用

进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。进程就是程序的一次执行,进程是程序及其数据在CPU下顺序执行时所发生的活动,进程是具有独立功能的程序在数据集上运行的过程,它是系统进行资源分配和调度的一个独立单位。

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

进程的作用:给应用程序提供两个关键抽象:

  1. 一个独立的逻辑控制流,提供一个假象,好像程序独占地使用处理器

2.   一个私有地址空间,提供一个假象,好像程序独占地使用内存系统。

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

shell是一个命令解释器。shell位于操作系统和应用程序之间,是他们二者的接口,负责:把应用程序的输入命令信息解释给操作系统,将操作系统指令处理后的结果解释给应用程序。

处理流程:

1)从终端读入输入的命令。

2)将输入字符串切分获得所有的参数

3)如果是内置命令则立即执行

4)否则调用相应的程序执行

5)shell 应该接受键盘输入信号,并对这些信号进行相应处理

以hello程序为具体实例,shell进行如下流程:

  1. 在shell中输入./hello 学号 姓名 循环延迟
  2. shell接收输入,解释并对参数argc和argv进行赋值
  3. 创建子进程,判断命令不是内置命令,调用执行外部hello程序
  4. 调用hello的main函数,并将argc和argv传递给main函数,使得main在上下文中运行
  5. 等待信号或键盘输入

 

图 43 hello在shell中的运行

6.3 Hello的fork进程创建过程

父进程可以采取调用fork()函数来建立子进程,子进程与父进程用户级虚拟地址空间相同的一份副本,但子进程与父进程相互独立。fork()函数在子进程中返回的值为0,在调用的父进程中返回值为子进程的PID。

在shell中输入命令 ./hello 学号 姓名 循环延迟 ,因为hello并不是一个内置命令,shell接受命令后便fork一个子进程,hello采用前台运行的方式,因此shell会等待hello这个子进程结束后才能接受新的命令。

6.4 Hello的execve过程

Shell接受hello并fork一个子进程后,需要在加载并在上下文中切换后才能执行。execve函数加载并运行可执行文件filename,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。与fork不同,execve只会返回一次。

对于hello的execve过程,主要为以下内容:

  1. 删除已存在的用户区域。
  2. 映射私有区域。
  3. 映射共享区域。
  4. 设置程序计数器。

6.5 Hello的进程执行

逻辑控制流:一系列程序计数器PC的值的序列叫做逻辑控制流,进程是轮流使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占(暂时挂起),然后轮到其他进程。

时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。

用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。

上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构等对象的值构成。

在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度(scheduling),是由内核中称为调度器(scheduler)的代码处理的。当内核选择一个新的进程运行时,我们说内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换1)保存当前进程的上下文,2)恢复某个先前被抢占的进程被保存的上下文,3)将控制传递给这个新恢复的进程。当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程。比如,如果一个read系统调用需要访问磁盘,内核可以选择执行上下文切换,运行另外一个进程,而不是等待数据从磁盘到达。另一个示例是sleep系统调用,它显式地请求让调用进程休眠。般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。中断也可能引发上下文切换。比如,所有的系统都有某种产生周期性定时器中断的机制,通常为每1毫秒或每10毫秒。每次发生定时器中断时,内核就能判定当前进程已经运行了足够长的时间,并切换到一个新的进程。

6.6 hello的异常与信号处理

异常一共可分为四种:

1. 中断。这种异常来自于I/O设备的信号,是一种异步异常。该异常总是返回到下一条指令

2. 陷阱。有意的异常,通常用来陷入内核,是一种同步异常。该异常总是返回到下一条指令

3. 故障。这种异常来自潜在可恢复的错误,是一种同步异常。如果错误被恢复可返回到当前指令。

4. 终止。来自不可恢复的错误,是一种同步异常。该异常总是不可返回的。

hello在执行的过程中会发送陷阱,故障。hello在调用sleep函数的时候,就是采取陷进异常来陷入内核。当hello进程刚从入口点开始执行时,会发生缺页故障。故障的处理:将控制传递给故障处理程序,如果处理程序能够修正这个错误情况,就将控制返回到引起故障的指令并重新执行它;否则终止引起故障的应用程序。

在hello的运行过程中尝试如下行为:

  1. 按下Ctrl+z

按下Ctrl+z后,内核会发送一个SIGSTOP使得进程挂起。

 

图 44 hello运行中使用Ctrl+z

        但是hello进程并没有被回收,只是被暂时挂起,如果使用jobs命令仍可以看见处于停止状态的进程。

 

图 45 jobs查看暂停的进程

         如果此时调用fg,仍能是hello进入到前台继续执行。

 

图 46 fg使hello继续执行

  1. 按下Ctrl+c

运行时按ctrl+c,内核会发送一个SIGINT信号,使得hello进程终止。

 

图 47 在hello中使用Ctrl+c

        用jobs查看,发现进程确实终止了。

  1. 不停乱按

乱按会将输入的字符存入缓冲区中,如果在乱按的过程中,按下一次回车,会使得缓冲区的内容被getchar读走,程序运行完成后不在等待键盘输入而直接结束。按下回车前的内容被读走,而之后的内容仍然留在缓存区,如果此时再按下回车,则剩下的字符被视为新的命令输入到shell中。

 

图 48 在hello运行的过程中乱按

6.7本章小结

本章具体展现了hello在shell中的运行。分析了hello在软硬件层面下的实现与处理。并且分析了一些异常和信号对hello进程的影响。

(第61分)

7章 hello的存储管理

7.1 hello的存储器地址空间

(1)逻辑地址:

在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。

(2)物理地址:

在存储器里以字节为单位存储信息,为正确地存放或取得信息,每一个字节单元给以一个唯一的存储器地址,称为物理地址(Physical Address),又叫实际地址或绝对地址。

(3)虚拟地址:

 CPU启动保护模式后,程序运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。

(4)线性地址:

 线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,加上基地址就是线性地址。

汇编语言程序hello.s中的地址就是逻辑地址,hello.o文件反汇编出来的地址就是线性地址,hello文件反汇编出来的地址是虚拟地址,访问主存时,使用的是物理地址。

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

使用段选择符中的偏移值(段索引)在GDT或LDT表中定位相应的段描述符。(仅当一个新的段选择符加载到段寄存器中是才需要这一步)。利用段选择符检验段的访问权限和范围,以确保该段可访问。把段描述符中取到的段基地址加到偏移量(也就是上述汇编语言汇中直接出现的操作地址)上,最后形成一个线性地址。

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

CPU中的一个控制寄存器,页表基址寄存器(Page Table Base Register, PTBR)指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(Virtual Page Offsetm, VPO)和一个(n-p)位的虚拟页号(Virtual Page Number, VPN)。MMU利用VPN来选择适当的PTE。例如,VPN 0选择PTE 0,VPN 1选择PTE 1,以此类推。将页表条目中物理页号(Physical Page Number, PPN) 和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P字节的,所以物理页面偏移( Physical Page Offset,PPO)和VPO是相同的。

 

图 49 页式管理

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

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

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

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

第4步:MMU构造物理地址,并把它传送给高速缓存/主存。

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

当缺页时,需要硬件和操作系统协作完成:

        第1~3步:同上。

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

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

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

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

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

Core i7采用四级页表的层次结构。CPU产生虚拟地址VA,虚拟地址VA传送给MMU,MMU使用VPN高位作为TLBT和TLBI,向TLB中寻找匹配。如果命中,则得到物理地址PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,以此类推,最终在第四级页表中找到PPN,与VPO组合成物理地址PA,添加到PLT。

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

存储器的结构如下:

 

图 50 存储器结构

CPU访问物理内存时,是逐层向下访问的。L1是L2的高速缓存,L2是L3的高速缓存,L3是主存的高速缓存。当程序需要某个数据时先访问L0,如果没有则需要去L1寻找,以此类推。当程序需要第k+1层的某个数据对象d时,他首先在当前存储在第k层的一个块中查找d。如果d刚好缓存在第k层中,则缓存命中,直接读取d。如果k层没有d,则缓存不命中,当缓存不命中时,k层缓存会从k+1层取出那个块,如果k层已满,则覆盖现存的一个块,决定覆盖哪个块时由替换策略来决定,然后像缓存命中一样读取该数据块。

7.6 hello进程fork时的内存映射

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

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,这种方式也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

execve函数在当前进程中加载并运行可执行目标文件hello,用hello来作为shell的前台作业。execve函数加载并运行hello需要以下几个步骤:

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

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

3)映射共享区域。若hello程序与共享对象(或目标)链接,如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。                                   

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

 

图 51 映射

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

假设MMU在试图翻译某个虚拟地址A时,触发了一个缺页。这个异常导致控制转移到内核的缺页处理程序,处理程序随后就执行下面的步骤:

1.判断虚拟地址A是否合法,就是判断A是否在某个区域结构定义的区域内。缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_ start 和vm_ end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误,从而终止这个进程。

2.判断试图进行的内存访问是否合法。就是判断进程是否有读、写或者执行这个区域内页面的权限。例如,这个缺页是不是由一条试图对这个代码段里的只读页面进行写操作的存储指令造成的?这个缺页是不是因为一个运行在用户模式中的进程试图从内核虚拟内存中读取字造成的?如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。

3.如果以上两点均不成立,那么内核知道这个缺页是由于对合法的虚拟地址进行合法的操作造成的。它是这样来处理这个缺页的:选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。

 

图 52 缺页处理

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆(heap)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程,内核维护着一个变量brk(读做“break"), 它指向堆的顶部。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

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

显式分配器(explicit allocator), 要求应用显式地释放任何已分配的块。例如,C标准库提供一种叫做malloc程序包的显式分配器。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。C++中的new和delete操作符与C中的malloc和free相当。

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

7.10本章小结

本章详细解决和分析了hello的内存管理。详细分析了地址转换,fork和execve在进程中的映射,还对缺页的处理进行了分析。最后总结动态内存管理的原理和本质。

(第7 2分)

8章 hello的IO管理

8.1 Linux的IO设备管理方法

一个Linux文件就是一个m个字节的序列:

B0,B1,……Bk.,……,Bm-1

所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输人和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输人和输出都能以一种统一,一致的方式来执行。

8.2 简述Unix IO接口及其函数

Unix I/O接口:

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

Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件< unistd.h>定义了常量STDIN_FILENO、STDOUT_ FILENO 和STDERR_ FILENO,它们可用来代替显式的描述符值。

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

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

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

IO函数:

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

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

  1. int close(fd)

fd是需要关闭的文件的描述符,close返回操作结果。

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

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

  1. ssize_t write(int fd, const void *buf,size_t)

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接受一个fmt的格式的字符串,然后将匹配到的参数按照fmt格式输出。

vsprintf函数在printf函数中被调用,其作用是接受确定输出格式的格式字符串fmt(输入)。用格式字符串对个数变化的参数进行格式化,产生格式化输出。

write函数也在printf函数中被调用,他将buf中的i个元素写到终端。

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.

字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

显示芯片按照刷新频率逐行读取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函数会从stdin输入流中读入一个字符。调用getchar时,会等待用户输入,输入回车后,输入的字符会存放在缓冲区中。第一次调用getchar时,需要从键盘输入,但如果输入了多个字符,之后的getchar会直接从缓冲区中读取字符。getchar的返回值是读取字符的ASCII码,若出错则返回-1。

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

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

8.5本章小结

本章分析了Linux下的文件输入与输出。深入分析了printf等函数的根本实现。了解Unix I/O接口及其函数。

(第81分)

结论

完成C源码,然后经过预处理器(cpp)对源程序进行预处理,对预处理后的文件调用编译器(ccl)翻译成汇编文件,再用汇编器(as)将其转化为机器码,形成可重定位目标程序,最终经过链接器,形成一个真正的可执行目标程序。在shell中使用命令行来启动这个可执行的二进制目标程序,shell会为其fork一个子进程,经过execve将其加载到上下文中,形成一个执行的进程,Hello最终从Program变成了Process。

从这次作业中可以感受到,即使最简单的一个用高级语言完成的程序,其背后的实现历程都是十分复杂的。计算机科学与技术的发展,是数代人不断努力,不断完善挖掘探索出来的。这种复杂的流程,并不是天才的灵光乍现,而是无数人辛苦劳作,不断实验积攒出来的经验一步一步形成的。要想在这个领域有所作为,离不开三件事:1.实践干出来的经验。2.对事物本质的理论探索。3.永不安于现状的上进心。

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

附件

hello.c:程序源代码

hello.i:预处理后的源程序

hello.s:汇编程序

hello.o:可重定位目标程序

hello.asm:hello.o的反汇编代码

hello.elf:hello.o的格式文件

hello:可执行目标程序

helloexe.elf:hello的格式文件

helloexe.asm:hello的反汇编代码

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

参考文献

为完成本次大作业你翻阅的书籍与网站等

[1] RANDALE.BRYANT, DAVIDR.O‘HALLARON. 深入理解计算机系统[M]. 机械工业出版社, 2011.

[2]  (44条消息) Linux下 可视化 反汇编工具 EDB 基本操作知识_hahalidaxin的博客-CSDN博客_edb使用

[3]  (44条消息) 分页管理机制(线性地址转换到物理地址)_tttttttt222的博客-CSDN博客

[4]  (44条消息) 5.execve()到底干了啥?_chengonghao的博客-CSDN博客_execve

[5] [转]printf 函数实现的深入剖析 - Pianistx - 博客园 (cnblogs.com)

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值