HIT-ICS大作业

 

 

 

 

计算机系统

 

大作业

 

题     目  程序人生-Hello’s P2P 

专       业     计算机类            

扫描二维码关注公众号,回复: 8431716 查看本文章

学     号    1180300213           

班     级     11803002            

学       生      陶新昊           

指 导 教 师       史先俊           

 

 

 

 

 

 

计算机科学与技术学院

2019年12月

摘  要

本文以一个简单的hello.c程序为例,通过它的P2P,O2O过程,深层次的挖掘hello.c的运行流程,一步步详细介绍了程序从被键盘和鼠标的合作来保存到磁盘,直到最后程序运行结束,进程变为僵尸进程的全过程;同时介绍了各个步骤中计算机系统的硬件和软件是如何协同或独立地去尽量快速又正确的加载并运行程序的。

关键词:P2P,O2O,Hello,计算机系统

 

 

 

 

 

 


 

目  录

 

第1章 概述................................................................................... - 4 -

1.1 Hello简介............................................................................ - 4 -

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

1.3 中间结果............................................................................... - 4 -

1.4 本章小结............................................................................... - 5 -

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

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

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

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

2.4 本章小结............................................................................... - 8 -

第3章 编译................................................................................... - 9 -

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

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

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

3.4 本章小结............................................................................. - 16 -

第4章 汇编................................................................................. - 17 -

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

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

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

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

4.5 本章小结............................................................................. - 21 -

第5章 链接................................................................................. - 22 -

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

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

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

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

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

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

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

5.8 本章小结............................................................................. - 28 -

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

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

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

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

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

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的线性地址到物理地址的变换-页式管理............. - 38 -

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

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

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

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

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

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

7.10本章小结............................................................................ - 44 -

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

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

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

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

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

8.5本章小结.............................................................................. - 47 -

结论............................................................................................... - 48 -

附件............................................................................................... - 50 -

参考文献....................................................................................... - 51 -

 


第1章 概述

1.1 Hello简介

     P2P:From Program To Process

          Hello.c从一个.c文件,经过预处理,编译,汇编,链接;再通过系统创建一个新进程并且把程序的内容加载,从而实现了由程序到进程的转化。

     O2O:From Zero-0 to Zero-0

           Hello从最开始躺在外存的一个文件,被execve函数所调用,从而将对应的上下文信息通过内存映射放入了虚拟内存中,成为了进程。当CPU为hello进程分配时间片后,由缺页处理一页一页的把虚拟内存中的页放入主存,又一级级的将主存中对应的信息放入缓存,经常用的页表项放入TLB…从而hello的指令和数据都被一条条的加载到CPU内部去执行,终端页面输出了Hello这个字符串。最后的最后,hello的代码段执行完毕,程序结束,进程变为僵尸进程,等待着被回收。Hello的运行正式结束。

1.2 环境与工具

硬件环境:X64 CPU;2GHz;4G RAM;256GHD Disk。

软件环境:Windows10,VMware15,Ubuntu18.04.2

使用工具:CodeBlocks17.12,gdb,edb,readelf,gedit,objdump,gprof

1.3 中间结果

     hello.i:hello.c预处理后的文件。

     hello.s:hello.i编译后的文件。

     hello.o:hello.s汇编后的文件。

     hello.o_obj:hello.o的反汇编文件。

     hello.o_elf:hello.o用readelf -a hello.o指令生成的文件。

     hello_obj:hello的反汇编文件。

     hello_elf:hello用readelf -a hello指令生成的文件。

     test1.c:测试编译中编码转换问题的程序。

     test1.i:test1.c预处理后的文件。

     test1.s:test1.i编译后的文件。

     test1.o:test1.s汇编后的文件。

     test1.o_obj:test1.o的反汇编文件。

1.4 本章小结

      本章概括性的说明了P2P和O2O的过程。不得不说,一个简简单单的hello,也让计算机系统的各个部件协同作战,只求快速,正确的运行它。

(第1章0.5分)

 


第2章 预处理

2.1 预处理的概念与作用

预处理是从源程序变为可执行程序的第一步,它会进行如下操作:

  1. 删除#define并展开对应的宏
  2. 处理所有条件预编译指令,例如#if #endif #ifdef #ifndef等等
  3. 添加对应的头文件到#include处
  4. 删除所有的注释
  5. 添加行号和文件名标识,以便编译时编译器产生调试时用的调试信息
  6. 保留所有#progma指令(编译器需要用)

在hello.c程序中(见图2.1.1),即为去掉初始的注释,并且讲对应的三个头文件加入

并且添加对应的标识。

                  (图:2.1.1)

作用:使得编译器在进行后面编译的操作的时候更加方便。

2.2在Ubuntu下预处理的命令

只需在终端中打出如下语句:gcc -E xxxx.c -o xxxx.o或cpp xxxx.c > xxxx.o                      

这样即可生成对应的.o后缀的文件,即为预处理过后的文件,效果如下

 

                 (图:2.2.1)

2.3 Hello的预处理结果解析

                             (图:2.3.1)

        打开hello.i(图:2.3.1),我们发现该程序竟然有3113行之多,其中main函数只占几行,其他的绝大部分都是对应的头文件中的代码;同时我们也可以看到.c文件中本来的注释已经全部消失了。

        当然,对应的还有文件名标识,如下图所示。

 

                       (图:2.3.2)

        还有一些描述使用的运行库在计算机中的位置的语句,如下图所示。

 

                       (图:2.3.3)

2.4 本章小结

本章节简单介绍了c语言程序在编译前的预处理过程,通过对预处理过程进行演示,简单介绍了预处理过程的概念和作用,并举例说明预处理的结果还有解析预处理的过程。

(第2章0.5分)


第3章 编译

3.1 编译的概念与作用

 概念:将一个由高级语言程序格式的文本文件通过词法分析,语法分析,语义分析并优化等步骤,翻译成一个执行完全相同操作的汇编语言程序格式的文本文件的过程,称作程序的编译。

     作用:主要作用是使程序更加的抽象,使得机器相同更容易理解,为后续的汇编做准备。

3.2 在Ubuntu下编译的命令

只需在终端中打出如下语句:gcc -S xxxx.o -o xxxx.i 

或者:gcc -S xxxx.c -o xxxx.i                

这样即可从.o的预处理文件或.c文件生成对应的.s后缀的文件,即为预处理过后的文件,如下图所示。

            (图:3.2.1)

3.3 Hello的编译结果解析

 首先我们先整体看一下生成的.s文件,如下两张图所示。

 

                      (图:3.3.1)

           (图:3.3.2)

可以看到程序已经转化为汇编代码,且程序并没有对库函数的部分进行编译,生成的代码除了main函数对应的汇编代码以外,这段文本的前几行和最后几行也描述了一些关于这个文件的信息,以供后续链接阶段使用。

接下来对hello.c出现的编译器对各个数据类型和各类操作做简要的说明。理论上,编译器要经过词法分析语法分析等,此处忽略,我们假设编译器已经能完美的识别每条语句中的每个单词。

3.3.1对局部变量的操作

         由于局部变量都是在函数中产生且随着函数的结束而消亡,因此局部变量是存储在栈中的,如下面两张图显示,画红框的地方说明了栈帧指针-4的位置即储存着局部变量i(对应的循环结构的操作将在后文讲解)

  

         (图:3.3.3)                         

         (图:3.3.4)

 3.3.2对字符串常量的操作

          字符串常量是不可以被改变的,在.o文件和可执行文件中,都处于.rodata段。而由于编译后还并没有.rodata段,因此就是先放在了.s文件的开头部分,然后标注上.rodata,.text等标识信息,如图3.3.5所示

 

                           (图:3.3.5)

          在程序需要使用该字符串时,也是与正常字符串类似,得到字符串的首地址,之后进行操作,如下面两张图所示。

     

               (图:3.3.6)    

               

               (图:3.3.7)

           根据图3.3.7我们也可以看到就是把对应位置的首地址传递给rdi寄存器,不过同时在括号中写出寄存器rip的意思是链接的时候是通过%rip间接寻址来找到对应的地址。

 3.3.3对赋值语句的操作

            赋值语句简单粗暴,用一句movq a b即可把内容a传给b地址处。其中对于常数的赋值,用movq $a b即可把立即数a传给b地址处。对于hello.c中对变量i的赋值即如此,如下图。

                    

                (图:3.3.8)        

     

                (图:3.3.9)

3.3.4对算数运算语句的操作

           四则运算和自增自减运算等都各有自己的指令。在hello.c文件中,出现了i++这样的语句,由于i的地址为-4(%rbp),因此我们可以用对应的add $1 -4(%rbp)或者inc -4(%rbp)等操作即可完成;对于该程序中没出现的其他算术操作,情况类似。

                  

                 (图:3.3.10)   

                 (图:3.3.11)

  3.3.5对关系语句的操作

           对于各种判断语句,其实大多是用cmp语句进行比较,本质上也是进行两个数的相减,但是不会影响两个数的值,仅仅是设置条件码,然后再根据条件码的不同,通过利用JX指令来进行不同的操作(进入分支结构)

           JX指令的跳转条件如图3.3.12所示

         

                             (图:3.3.12)

          在hello.c中共有两句关系语句,如下面三张图所示,都是采用了cmp+JX的组合。

              

         (图:3.3.13) 

         (图:3.3.14) 

         (图:3.3.15)

  3.3.6对数组/指针/结构的操作

           在hello.c中,出现了对数组argv的引用。对于这种带有偏移量的操作,都是找到对应的初始值,然后加上一个偏移量即可。

           例如,如果int数组的首地址在rax中,那取出第k个元素给寄存器rbx的语句即为mov (%rax,k,4) %rbx。关于指针的操作很类似,因为数组名也可以看作指针;结构体内部的话,只要直到结构体中的每一个元素在结构体中的偏移,即可找到每个元素对应的地址。

           在hello.c中的引用,由于argv数组中每个元素都是地址,因此在64位机器中都是占8个字节,因此每个元素地址差为8,而argv是作为参数,在栈中传递到主函数中的,因此默认目前argv的首地址是-32(%rbp),因此argv[1], argv[2] ,argv[3]的地址就是对应的地址加上8,16,24。如下面几张图所示(颜色一一对应)。

            

                  (图:3.3.16)  

                  (图:3.3.17)

  3.3.7控制转移操作

           控制转移操作一般都是紧接着关系语句,一般是用JX语句和对应的组合来实现,上文中已经提到,我们在这里以for循环为例,找到对应的语句来分析汇编是如何实现像for循环这种循环语句的。

           如下图所示,我们分别看这四条语句对应的汇编代码

              

                           (图:3.3.18)

                   

                            (图:3.3.19)

  3.3.8函数操作

           函数操作是最复杂的操作了…(想起以前被递归函数的反汇编虐爆的场景)。首先我们先看一看栈的结构(如题3.3.20),对于函数的调用,即是在之前的栈帧的上部再构建一块属于这个函数的栈帧,返回后再离开这部分栈帧,回到上一个函数的栈帧。

                

                          (图:3.3.20)

           其中函数的前六个参数分别由寄存器传参,返回值存在%rax寄存器中,而由于每个函数是共用一套寄存器的,因此对于一些寄存器的值,应该先保存起来,再在新的函数中去使用,防止数据丢失。对应的传参规则和寄存器保存规则如图3.3.21,其中调用者保存代表着调用函数的函数应该在调用新的函数前先保存在栈中;被调用者保存代表着被调用函数在使用这个寄存器前应该把以前的值保存在栈中。(注意到因此这两种情况下旧址所在的地方在不同的栈帧中)

      

                             (图:3.3.21)

           因此虽然函数的调用和返回仅仅分别是call指令和ret指令,但是对很多寄存器都要进行处理,下面以hello.c中框出的printf语句进行分析。

      

                    (图:3.3.22)

           对应的解析如下:

          

                     (图:3.3.23)

3.4 本章小结

         本章简要介绍了编译的概念,意义和过程。之后对于C语言的各种出现在hello.c中的操作所对应的C语言和汇编语言之间的翻译都做了简要分析;但不得不说编译其实是一个很复杂的与语言处理有关的步骤,也很期待着以后能更多的去接触这些,理解编译器如何去识别出对应的语句。

(第32分)


第4章 汇编

4.1 汇编的概念与作用

     概念:将汇编语言程序转化为二进制的机器语言程序,并将每个.s文件打包成可以被重定位的可重定位目标文件。此时的可重定位文件无法直接打开。

     作用:生成了机器可以直接开始分析的机器指令。

4.2 在Ubuntu下汇编的命令

只需在终端中打出如下语句:gcc -c xxxx.s -o xxxx.o                 

这样即可从.s的汇编文件生成对应的.o后缀的文件,即为汇编过后的二进制文件,如下图所示。

             (图:4.2.1)

4.3 可重定位目标elf格式

(1)可重定位目标ELF格式如下:

         (图:4.3.1)

ELF头:包含文件结构说明信息

.text节:目标代码部分。

.rodata节:只读数据部分。

.data节:已初始化的全局变量。

.bss节:未初始化的全局变量。

.symtab节:符号表。

.rel.text节:.text节相关的可重定位信息。

.rel.data节:.data节相关的可重定位信息。

.debug节:调试用符号表。

.line节:C源程序中的行号和.text节中机器指令之间的映射。

.strtab节:字符串表。

节头表:其中的表项用来描述相应的一个节的节名、在文件中的偏移、大小、访问属性、对齐方式等。

(2)下面的图用readelf -S指令查看节头表,来查看各节的基本信息。其中offset为起始地址,size为大小。

 

                   (图:4.3.2)

(3)通过图4.3.3可以看到hello.o文件中并没有rel.data段,即.data节不需要额外的重定位信息,因此我们下文分析.rel.text节的内容。由于当链接器将目标文件和其他目标文件(库函数也算)组合时,.text节中的代码会被合并,因此一些指令中引用的操作数地址或者是跳转到某个目标指令位置的信息都可能要被修改,因此我们应记录对应的绑定的信息。

  

                      (图:4.3.3)

上图中Offset为需要重定位的地址与该段首地址的偏移;Info的高24位说明了所引用的符号索引,低8位为对应的重定位类型,Type即为对于类型的具体表示;重定位前值为0;Sym.Name即为绑定的符号名,Addend即为偏移。

这里可以看出我们的程序用PLT(过程链接表)对共享库函数进行了动态链接,而对常量字符串的引用采用了PC相对引用的方式。注意到由于PC相对引用和动态链接的特殊性(即运行这条指令时,PC的值其实为下一条指令的地址,因此PC真正的值和我们目前想进行重定位的值有4个字节的偏移),因此我们在Addend的位置都减去了4(其实第四行本来是+26),这样实现了抵消这四个字节所带来的偏差。因此我们心里应该明白,第一行其实指向了.rodata段的首地址,第四行其实指向了.rodata+0x26的地址,其他行都指向了共享库函数对应的PLT表项的首地址。

    我们对hello.o的代码反汇编,分析对应的重定位信息。

   

                     (图:4.3.4)

最后我们再看一下.rodata段的信息。可以看到字符串确实为0x0处和0x26处(中文在右面显示为乱码)

 

                      (图:4.3.5)

4.4 Hello.o的结果解析

Hello.o的反汇编见图4.3.4;hello.s见图3.3.1和图3.3.2,主要区别如下图。

 

              (图:4.4.1)

Hello.o的反汇编都是按照一节一节进行反汇编,信息看起来规整了很多,也加入了对应的elf格式信息;同时每一条汇编代码都对应着一条由01序列所表示的机器代码(此处以16进制显示,为了看着方便),同时最左方也表明了相对地址。

其中跳转指令和函数调用等指令,在汇编的注释中都表示为对应的偏移,而不是在.s中的跳转到机器自己生成的符号处(类似于goto语句)。

另外,在.o文件中整数已经转化为补码表示,下面用一个小程序来举例。

                

                      (图:4.4.2)

我们分别查看对应的.s文件(图:4.3.8)和.o文件的反汇编结果(图:4.3.9),可见负数在后者已经被处理成补码的机器级表示了。至于浮点数?当然被转化为IEEE表示了,不过如何去运算就交给浮点寄存器和浮点运算指令了,不管了(눈_눈)

    

       (图:4.4.3)        

       (图:4.4.4)

4.5 本章小结

          本章主要介绍了程序的汇编过程,经过一系列的操作,生成了elf的可重定位目标文件格式:按节分好了内容,保存好了重定位信息,同时转换了编码。现在程序终于是机器直接可以理解的二进制文件了。

(第41分)


第5章 链接

5.1 链接的概念与作用

(1)概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。

(2)作用:由于链接可以将所有关联到的目标代码文件组合到一起,因此我们实现分离编译,我们可以独立地修改和编译一些小的模块,并且只是简单的对它进行重新编译,再重新链接即可,而不必重新编译其他文件。

5.2 在Ubuntu下链接的命令

    如下图所示,该指令确实比较冗长…(所以gcc大法好啊)

 

                         (图:5.2.1)

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

    ELF头:包含文件结构说明信息

段头表:描述具有相同访问属性的代码和数据段映射到存储空间的映射关系。

.init节:用于可执行目标文件开始执行时的初始化工作。

.text节:目标代码部分。

.rodata节:只读数据部分。

.data节:已初始化的全局变量。

.bss节:未初始化的全局变量。

.symtab节:符号表。

.debug节:调试用符号表。

.line节:C源程序中的行号和.text节中机器指令之间的映射。

.strtab节:字符串表。

     

                       (图:5.3.1)   

     然后我们看一下对应的节头表

 

                         (图:5.3.2)

     不得不说除了上文提到的以外,还有不少其他节…它们各司其职,这里就不一个个讲述了。Offset为各节的起始地址,Size为大小。虽然节确实很多,但是我们可以再看看到底哪些被装入了内存去执行。

    

                         (图:5.3.3)

     可见真正被装入虚拟内存的只有两个段:数据段和代码段,各自里面的内容见图5.3.1,不过以2M对齐有点硬核…虽然正常来说是按照页大小4K对齐,但是这个程序非要以2M对齐,原因是本来应该是四级页表,这两个段为相邻的页表项;但是这个程序中直接把这两个段放在了由相邻的第三级链表所索引到的页表的头部,导致他们的地址差为2^12(页大小)*2^9(每个页目录表索引9位地址)=2M。

5.4 hello的虚拟地址空间

 

           (图:5.4.1)  

           

           (图:5.4.2)

    如上面两张图,分别是代码段和数据段的起始部分。(注意数据段初始是有偏移的)

5.5 链接的重定位过程分析

     我们可以观察一下这两个文件对应反汇编代码的不同。

     首先,hello的反汇编代码多了很多节;同时每条指令和数据都也已经确定好了虚拟地址,而不是偏移量;同时由于经过了链接,也含有了库函数的代码标识信息。(其实为动态链接+延迟绑定技术去访问位置无关代码)见下图

 

                     (图:5.5.1)

     同时,我们可以对比着看一下重定位信息。左图为hello.o的反汇编代码,右图为hello的反汇编代码。可见对应的重定位信息已经填好,对应的汇编语句中也填入了正确的提示信息。

    

                            (图:5.5.2)

下面我以0x400689处的call语句来举例说明链接过程,其余同理。

(1)  首先是通过图4.3.3的信息来得知此处应该绑定第0xb也就是第11个符号,同时让链接器得知此处为PC相对寻址。

(2)  其次查看.o文件对应的符号表,我们找到第11个符号位puts,因此,此处将绑定puts的地址。

 

                      (图:5.5.3)

(3)  之后我们在hello中,找到符号puts的地址为0x4004f0

 

        (图:5.5.4)

(4)  而我们PC目前的值是call的这条指令的下一条语句的地址,也就是0x40068e,和我们要跳转到的函数的地址差为0x19e,因此我们PC的值要减去0x19e,也就是加上0xfffffe62,再加上小端法,因此重定位目标处应该填入62 fe ff ff,如下图所示。

 

               (图:5.5.5)

5.6 hello的执行流程

          用edb单步执行太费时了,不如用gdb跟着符号表对所有函数名设断点,然后不断地运行-继续来跟踪函数的调用过程。

 

            (图:5.6.1)

          我们可以得到如下运行顺序(其中图5.6.2和图5.6.3为部分截图)

          Main前:

  1. _start ()
  2. __libc_csu_init ()
  3. _init ()
  4. frame_dummy ()
  5. register_tm_clones ()
  6. main()

 

                   (图:5.6.2)

Main后:

  1. printf()
  2. atoi()
  3. sleep()
  4. getchar()
  5. exit()
  6. __do_global_dtors_aux ()
  7. deregister_tm_clones ()
  8. _fini ()

 

                 (图:5.6.3)

5.7 Hello的动态链接分析

     我们分两步来查看动态链接的全过程。

     (1)首先,是GOT[1]和GOT[2]的初始化过程,也就是绑定动态链接器的位置。

            GOT全称global offset table,是.data段的一部分。程序采用了延迟绑定技术,因此GOT[0]为.dynamic节首地址;GOT[1]为动态链接器的标识信息;GOT[2]为动态链接器延迟绑定代码的入口地址,此外的项即与调用外部函数在代码段的位置相关。

            下图为dl_init之前的GOT的信息,蓝色框为GOT[1]和GOT[2],红色框即为对应的绑定的外部函数在.text节PLT表项对应的位置。

              

                              (图:5.7.1)

            下图为运行完dl_init之后GOT的信息,发现对应蓝色框的内容发生了变化,位置定位成功。

              

                              (图:5.7.2)

     (2)其次就是对应的库函数的链接过程了,动态链接的过程图5.7.3。

 

                                 (图:5.7.3)

5.8 本章小结

本章较为详细的介绍了链接的过程,尤其是重定位的过程。不得不说,链接是一个十分严谨且有趣的过程。由于只有一个hello.c文件,也不存在全局变量,因此本文没有提到很多关于符号解析和绝对地址寻址重定位的相关内容,不管内容大同小异。总之经过链接,终于形成了直接就可以运行的文件。到这里,hello的前半生就算走完了,它从最初的机器完全不理解的.c文件一步步成为了机器直接就可以执行的可执行文件。

(第51分)

第6章 hello进程管理

6.1 进程的概念与作用

(1)概念:进程是计算机中的程序关于某数据集合上的一次运行活动,是系

          统进行资源分配和调度的基本单位,是操作系统结构的基础。

  (2)作用:提供给应用程序的关键抽象:

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

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

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

      (1)作用:是操作系统(内核)与用户之间的桥梁,充当命令解释器的作用,将用户输入的命令翻译给系统执行。

      (2)处理流程:

         1.Shell首先从命令行中找出特殊字符(元字符),在将元字符翻译成间隔符号。元字符将命令行划分成小块tokens。Shell中的元字符如下所示:
SPACE , TAB , NEWLINE , & , ; , ( , ) ,< , > , |

2.程序块tokens被处理,检查看他们是否是shell中所引用到的关键字。

3.当程序块tokens被确定以后,shell根据aliases文件中的列表来检查命令的第一个单词。如果这个单词出现在aliases表中,执行替换操作并且处理过程回到第一步重新分割程序块tokens。

4.Shell对~符号进行替换。

 5.Shell对所有前面带有$符号的变量进行替换。

6. Shell将命令行中的内嵌命令表达式替换成命令;他们一般都采用$(command)标记法。

7.Shell计算采用$(expression)标记的算术表达式。

8.Shell将命令字符串重新划分为新的块tokens。这次划分的依据是栏位分割符号,称为IFS。缺省的IFS变量包含有:SPACE , TAB 和换行符号。

9.Shell执行通配符* ? [ ]的替换。

10.Shell把所有从處理的結果中用到的注释删除,并且按照下面的顺序实行命令的检查:
A. 内建的命令
B. shell函数(由用户自己定义的)
C. 可执行的脚本文件(需要寻找文件和PATH路径)

11.在执行前的最后一步是初始化所有的输入输出重定向。

12.最后,执行命令。

6.3 Hello的fork进程创建过程

     进程的创建采用fork函数:pid_t fork(void);

     创建的子进程得到父进程用户级虚拟地址空间相同(但是独立的)一份副本,也可以共享文件。他们的最大区别是PID不同。函数会被返回两次,子进程中返回0,父进程中返回子进程的PID,两者并发执行。

     在这里,我们的父进程是shell,经过fork函数创建了子进程,子进程为后续execve函数和hello的运行做准备。

 

                 (图:6.3.1)

6.4 Hello的execve过程

          execve函数在当前进程的上下文中加载并运行一个新程序。

          execve函数调用一次从不返回。

         

                        (图:6.4.1)

          fork函数与execve函数合作,可以实现在shell中创建子进程,并且在子进程中运行我们的程序。下面的例子为实验7中要求实现的shell,实现了上述功能。

                

                             (图:6.4.2)

6.5 Hello的进程执行

首先我们看一下上下文这个概念,其实上下文就是进程自身的虚拟地址空间,分为用户级上下文和系统及上下文。每个进程的虚拟地址空间和进程本身一一对应(因此和PID一一对应)

 

      (图:6.5.1)

由于每个CPU只能同时处理一个进程,而很多时候系统中有很多进程都要去运行,因此处理器只能一段时间就要切换新的进程去运行,而实现不同进程中指令交替执行的机制称为进程的上下文切换。如下图所示,为进程A与进程B之间的相互切换。

 

          (图:6.5.2)

其中用户模式我们好理解,就是运行对应进程的代码段的内容,此时进程不允许运行很多特权指令(毕竟一个应用程序,管好自己的东西就行,总不能没事像操作系统一样管理其他进程吧);而内核模式中,进程可以运行任何指令,因此我们需要以下的步骤运行一些特权指令,来进行以下操作:

1.将当前处理器的寄存器上下文保存到当前进程的系统级上下文的现场信息中。

2.将新进程系统级上下文中的现场信息作为新的寄存器上下文恢复到处理器的各个寄存器中。

3.将控制转移到新进程去执行。

大家有一点可能会疑惑:明明每个进程虚拟内存相同,处理器如何判断当前寄存器的值是哪个进程的上下文呢?实际上是通过PID,来找到各自的页表,因此运行的内容一定属于该进程

在我们的hello程序运行过程中,shell调用了fork函数,新增加了一个进程,我们把父进程叫为shell1进程,子进程叫做shell2进程的话,那shell1和shell2就是两个不同的进程(虽然他们虚拟空间内容一样,但是相互独立),它们之间的切换就出现了上下文切换。

与内存映射相关的更详细内容会在第七章说明。

6.6 hello的异常与信号处理

 (1)程序自身运行过程中的异常。

       hello程序自身代码段和数据段都只占一页,因此当读取的时候,数据段和代码段都会产生一次缺页异常,具体处理方式见7.9节,为“故障类异常”的一种,处理过程见图6.6.1;同时由于程序中调用了sleep函数等,对系统函数的调用,为“陷阱类异常”,处理过程见图6.6.2,它们异常处理后返回的语句是不相同的。

       

                   (图:6.6.1)

    

                  (图:6.6.2)

 (2)运行时输入回车,空格等。

进程发生了中断,来处理来自IO的命令(此时并没有信号)

   

                   (图:6.6.3)

    处理方式见图6.6.4。

   

                   (图:6.6.4)

(3)Ctrl+C

   进程收到SIGINT信号,默认直接终止。

          

                      (图:6.6.5)

(4)Ctrl+Z

   进程收到SIGTSTP信号,系统默认停止,直到收到SIGCONT

        

                         (图:6.6.6)

   运行ps命令:监视后台进程。

                 

                       (图:6.6.7)

   运行jobs命令:显示当前暂停的进程。

        

                        (图:6.6.8)

       运行pstree命令:以树状图形式显示所有进程。这里太多了,只截了一小   部分。

  

                          (图:6.6.9)

   运行fg命令:使停止的进程收到SIGCONT信号,重新在前台运行。

         

                         (图:6.6.10)

   运行kill命令:利用kill可以给进程发送信号。

       例如下图中的kill -9 1931即为给1931号进程发送9号信号,即SIGKILL,杀死进程。

  

                   (图:6.6.11)

6.7本章小结

     本章主要介绍了程序由可执行目标文件到一个进程的过程。通过fork和execve的调用,程序有了对应的上下文信息,对应的段也被送到主存,寄存器内也有了相关信息,终于开始成功运行了。进程运行过程中,异常控制流也时刻去处理各种异常,实现应用与操作系统的交互。

(第61分)


第7章 hello的存储管理

7.1 hello的存储器地址空间

Linux一般在保护模式下工作,此时的存储空间采用逻辑地址,线性地址和物理地址用来描述。

在7.1,7.2,7.3三个小节中,我们以IA-32+linux为例,来介绍对应的概念和转换方式。

逻辑地址:由48位组成,包含16位的段选择符和段内偏移量(即有效地址)。机器语言中一般未逻辑地址。在7.2中我们可以看到在linux下其实逻辑地址和线性地址完全相同[3]。

线性地址:由32位构成,实际上,线性地址就是虚拟地址。

物理地址:由32位构成,为真实的内存地址。

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

(1)首先我们先看一下逻辑地址的格式,后32位偏移量先忽略,我们看一下前16位的段选择符。段选择符存放在段寄存器:CS,SS,DS,ES,FS,GS中。

  CS:指向代码段。

  SS:指向栈段。

  DS:指向数据段。

  其他三个段寄存器可以指向任意段。

              

                          (图:7.2.1)

      其中RPL代表着特权级,11为用户态,00为内核态。

      TI表示段选择符选择哪个段描述符表,0为全局描述符表(GDT),1为局部描述符表(LDT)。

      而索引说明了段描述符在对应描述符表的偏移。

(2)接下来我们再看一下描述符表中的一个个表项。

  

                            (图:7.2.2)

      图的内容较多,但是可以忽略,反正就是每个表项中,都存放着对应的基地址,如果选中该表项,最后就是把对应的基地址取出即可。

(3)因此我们可以得到下图的流程。

  

                           (图:7.2.3)

      可见逻辑地址的高16位用来去相应的描述符表在找到对应的段描述符,然后取出相应的32位基地址,然后再把基地址和逻辑地址的低32位相加即可获得32位线性地址。

(4)不过有意思的是,linux下所有段的逻辑地址都是选择GDT,并且基地址也都是0(见图7.2.4),因此虽然看起来进行了分段处理,然而实际上并没有真正进行多少操作,虽然转换过程要访问段表项(和访问页表项异曲同工),要访存,不过由于段基地址的特殊性,也不需要类似TLB的内容来加速访问段表项的过程了。

 

                            (图:7.2.4)

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

     由于访问外存耗费的时间巨大,因此我们用cache的思想,也希望内存能作为外存的缓存一样。为了提高命中率,因此我们采取全相联的策略,并且希望每“行”的字节数多一些,我们把对应的一行称作一页,一页大小一般为4k。

     正如同在cache中寻找内容也需要索引,从虚拟内存到物理内存也需要索引。因此在内存中,我们额外存储一个叫做页表的数据结构,作为对应的索引。因此,我们可以让每个进程都有一个页表,页表中的每一项都记录着该进程中对应的一页所投影到的物理地址、是否有效、还有一些其他信息等。

     然而由于页的大小为2^12个字节,而虚拟内存有2^32个字节,导致页表项会有2^20项,占用空间确实太大了,而且很多页表项应该其实都是空的,毕竟进程普遍没有占用很大的地址空间。因此系统采用了多级页表的结构来进行索引。

我们假设有两级页表,第一级页表能索引到2^10个二级页表。如果一个一级页表项对应的二级页表映射到的都是虚拟内存的为分配的页,那实际上这个一级页表项没有任何有用的信息,那就不需要存储它所引导的二级页表项了(如下图)。更多级页表也如此,如果第k级的一个页表项发现对应的下级页表所对应的虚拟内存的内容都是未分配页,那这个页表项直接设为空,不存储指向下一级也页表的指针即可。用这样的方式,就可以实现页表项的项数与虚拟内存中已分配的页数线性相关,而不是恒定有2^20项。

 

                      (图:7.3.1)

(至于为什么不用一级页表,然后未分配页的页表项就不存储呢?这样虽然存储空间能再少一些,但找一个页对应的页表信息,即使用红黑树,创建页表时插入页表项和查找页表项也需要logn次,而访存是很浪费时间的,而全部存储的时候直接用偏移就可以一次找到信息,因此这种方法显然不行)

因此我们可以通过以下的方式,来构造线性地址与物理地址的映射(见图7.3.2)。其中线性地址的高10位为页目录(一级页表)的偏移,从而找到对应的二级页表的基地址,再通过这个基地址和这个线性地址中间的10位偏移,索引到二级页表中的对应项,之后取出项中所存储的物理基地址,再加上线性地址的后12位偏移,即可得到真正的物理地址。

 

                        (图:7.3.2)

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

     我们可以看到,页表技术虽然让我们在给出虚拟地址的时候,能够很大概率的通过查找页表来找到内存地址,但查页表也是访问内存的过程,很浪费时间。由于局部性原理,我们很多时候会经常访问同一个页表项,因此我们想,那也可以按照缓存的原理,将最近使用的页表项专门缓存起来。因此TLB(后备转换缓冲器)就此诞生,TLB也称快表。之后我们在去找页表项的时候,先从快表查找,找不到再访问内存中的页表项即可。

至于四级页表…仅仅是由于虚拟内存空间更大了,因此需要更多级的页表来保证页表项的数量能少一些了,原理相同。

下图大致说明了TLB+四级页表+Cache的地址翻译。

 

                        (图:7.4.1)

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

        Cache的原理和TLB类似,也是利用局部性原理,采用了组相联的方式,存储一段时间内所加载的地址附近的内容。这样当CPU得到物理地址时,可以先从L1cache中找,如果没有,再进入L2cache,然后L3cache,然后才是主存。上面的图7.4.1显示了访问L1cache的相关步骤,访问L2和L3的步骤也类似。这里对具体的如何找到正确索引、判定是否击中等不再举例。

7.6 hello进程fork时的内存映射

       (1)首先我们来看一下内存映射的概念:将一个虚拟内存区域与一个磁盘上的对象联系起来,以初始化这个虚拟内存的内容,就叫做内存映射。(所以说内存映射与内存毫无关系…)配合上后面的虚拟地址向内存中物理地址的翻译,整个流程就实现了外存到内存的映射,整个流程如下图[4]。

    

                               (图:7.6.1)

       (2)其次是写时复制的技术:对于两个进程,如果他们有相同的私有对象,那么内存中也是只有一份副本的,当且仅当有进程改变了这个私有对象,内核就会在物理内存中建立对应页面的一个新副本并标记为可写(这个过程引发了一个保护异常),之后对该页内容的写操作都会在这个新副本执行了。具体方式见下图。

 

                        (图:7.6.2)

      (3)接下来我们可以看fork函数了。在fork函数被调用时,内核给新进程创建了对应的PID,并且用内存映射创建了虚拟内存,对应的虚拟内存和父进程相同,但他们的非共享库页面都标记为写时复制。因此虽然他们的内容最初全相同,但是他们之间相互独立,对于一个进程对页中内容的修改,不会影响到另一个进程。

7.7 hello进程execve时的内存映射

Execve函数经过以下几个流程,来实现加载并执行一个新程序:

(1).删除已存在的用户区域。也可以说删除了用户级上下文。

(2).映射私有区域,为新程序的代码、数据、bss和栈区域创建新的数据结构,所有这些新的区域都是私有的、写时复制的。

(3).映射共享区域。

(4).设置程序计数器,使之指向代码区域的入口点。

总之,execve函数通过不停的内存映射,把对应的磁盘上的关于hello的需要被加载的内容与它的虚拟内存建立联系。

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

     缺页其实就是DRAM缓存未命中。当我们的指令中对取出一个虚拟地址时,若我们发现对该页的内存访问是合法的,而找对应的页表项式发现有效位为0,则说明该页并没有保存在主存中,出现了缺页故障(图6.6.1概括了故障的处理),如下图。

    

                     (图:7.8.1)

     此时进程暂停执行,内核会选择一个主存中的一个牺牲页面,如果该页面是其他进程或者这个进程本身页表项,则将这个页表对应的有效位改为0,同时把需要的页存入主存中的一个位置,并在该页表项储存相应的信息,将有效位置为1。然后进程重新执行这条语句,此时MMU就可以正常翻译这个虚拟地址了。

7.9动态存储分配管理

        动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆是为一组大小不同的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的;空闲块可用于分配,已分配的块显式地保留为供应用程序使用。

        分配器有两种基本风格:

        (1)显式分配器:要求应用显式地释放任何分配的块,例如C标准库提供的malloc程序包。

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

        分配器简单来说有以下几种实现方式。

 

                              (图:7.9.1)

        本人自己写的分配器与malloc包的实现方式类似,都是采用了分类适配的方法来实现。当然每个空闲链表内部的结构可能还很复杂,比如采用双向链表或者BST甚至红黑树等等…这里就不详细讨论了。

                    

7.10本章小结

        本章详细介绍了本来在硬盘中储存的hello可执行文件是如何一步步从硬盘被加载到CPU中去执行的,也说明了存储器每一级都是下一级存储器的缓存。首先他的上下文信息被加载到了一个进程之中,从而对应的内容通过内存映射,被映射到了虚拟内存之中;之后,当CPU运行这个进程时,这个进程通过缺页异常处理,又不断地把对应的内容存入了主存之中。之后CPU为了读取地址中的内容,先是在TLB中试图直接找到存储地址,找不到就去找四级页表;找到地址后又不断的访问一级,二级,三级缓存以至主存,竭尽全力的试图更快的找到相应的信息,让我们的小hello开始真正的被运行,指令和数据一串串的被CPU所处理。到此,hello终于如愿所偿,迎来了最光辉的时刻。

(第7 2分)


第8章 hello的IO管理

8.1 Linux的IO设备管理方法

设备的模型化:文件,所有I/O设备都被模型化为文件。

              其中文件分为普通文件、目录、套接字、命名通道、符号链接以及字符和块设备。

设备管理:unix io接口,所有的输入输出都能以一种统一且一致的被当作对相应文件的读和写来执行。

8.2 简述Unix IO接口及其函数

     (1)open函数

       用法:用来打开一个已经存在的文件或者创建一个新的文件。

       原型:int open(char *filename,int flags,mode_t node)

       说明: 函数将filename转换为一个文本描述符,并且返回描述符数字;flags说明文件是否可读或可写;mode参数指定了新文件的访问权限位。其中标准读入的描述符是0,标准输出是1,标准错误是2。

     (2)close函数

       用法:用来关闭文件。

       原型:int close(int fd);

       说明:关闭已关闭的描述符会出错。

     (3)read函数

       用法:从文件中读取数据。

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

       说明:从描述符为fd的当前文件位置复制最多n个字节到内存buf处;返回值为-1代表出错,否则为传送字节数量。

     (4)write函数

       用法:向文件中写数据。

       原型:ssize_t write(int fd,const void *buf,size_t n)

       说明:从内存位置buf复制至多n个字节到描述符fd的当前位置。

     (5)lseek函数

       用法:移动文件读取指针[5]。

       原型:off_t lseek(int fd, off_t offset, int whence);

       说明:fd 表示要操作的文件描述符;offset是相对于whence(基准)的偏移量;whence 可以是SEEK_SET(文件指针开始),SEEK_CUR(文件指针当前位置),SEEK_END为文件指针尾。返回值为文件读写指针距文件开头的字节大小,若出错则返回-1。

     (6)stat函数

       用法:检索关于文件的元数据。

       原型:int stat(const char *filename,struct stat *buf)

       说明:以文件名作为输入,并填入一个stat数据结构的各个成员。

8.3 printf的实现分析

我们先来看一下printf函数

 

                        (图:8.3.1)

画红框的三句话中,博客中[6]指出第一句话是让argv指向一个字符串;第二句的作用就是格式化,第三句就是调用了unix I/O。至于更详细的流程,下面给出了官方解答:

从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

我们还是先来看一下getchar函数。

 

                 (图:8.4.1)

    getchar函数内部调用了read函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。不过read函数每次会把所有的内容读进缓冲区,如果缓冲区本来非空,则不会调用read函数,而是简简单单的返回缓冲区中最前面的元素

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

8.5本章小结

    本章主要介绍了系统级I/O,以及简要分析了getchar函数和printf函数。正是由于这些函数的配合,我们敲敲键盘动动鼠标,我们就可以写出我们的hello.c的代码,也可以让运行的结果显示在终端。我们终于看到了hello的运行结果。

(第81分)

 

 

 

结论

我,只是一个可怜的hello。

有一天,一位被尊称为“程序猿”的生物通过I/O设备,将我出生时的样子,记录在了庞大的硬盘之中。

我等啊等啊,等待着被执行,梦想着大放光彩的那天。终于有一天,系统把我进行了预处理:我几乎还是原来的样子,只是加入了一些笨重的库中的代码…然而CPU还是根本无法理解我…

接下来,我终于被编译了。现在一些萌新程序员已经读不懂我了。我成长了,等待着下一步的成熟。

后来,我被汇编器转换成了二进制代码。研究现在的我的人,真的是少之又少,谁会对一串串01感兴趣呢?只有CPU,已经能懂我的内容了,我离梦想已经近了很多。

正如同人需要团队合作,我也需要团队合作,我必须和其他的可重定位或动态库文件链接,现在的我,才终于被CPU所接受。现在的我,终于有能力与它越来越近了。

又过了好久,某个程序猿在终端输入了./hello,我就知道,我的梦想即将实现。

什么事情都安排得很完美的操作系统,指挥了它的一个进程,让它创建一个和他一模一样的子进程,创建好了对应的虚拟地址空间。之后我的信息,通过execve的调用,被映射到了这块虚拟内存之中。

当CPU的时间片到我这个进程的时候,我就像你们人类买车中摇号一样开心:系统中这么多进程,这次终于轮到我啦!CPU开始读取我对应的虚拟内存地址,通过缺页异常将我的信息放入主存。之后系统不惜读四级页表来找到要操作的地址,又将经常读取的页面放入了TLB,以便更快的得到相应的地址;同时又把经常读取的地址附近的内容放入一层一层的缓存,终于,我的代码,被加载到了处理器内部,进入流水线化的世界。

现在的我已经站在舞台中央,CPU为了我而快速运作。每一个时钟周期,我的一条指令都会被加载,拆解成十几个步骤,像工厂一样一步步被加工,被执行。这里的精妙程度简直不可想象…

我最最自豪的输出,也通过系统级I/O包装的I/O函数,被输出在了终端之中。

然而我只是个小程序,不到1微秒,我就被CPU运行完了,从此,我这个进程变成了让人听着很不开心的“僵尸进程”,等待着被父进程回收…

自从被回收,我又重新躺在了硬盘里。我知道,终有一天,硬盘这种存储器会损坏,我也可能因此损坏;甚至完全等不到这一天 ,程序猿觉得我太简单了,轻轻松松对我的图标,点击了右键…删除…

从此,我就仿佛没有出现过一样。虽然我的一生很短暂,站在舞台中央的时间更是转瞬即逝,但是我看到如此庞大而复杂的计算机系统为了我做了这么多操作,我这一生也是值得了。

不得不说,这学期计算机这门课是非常有挑战性,也非常有意思的。不得不感叹,计算机真的很神奇(当然这都是人类智慧的结晶),它真正的结构也远远比现在我所了解的还要复杂复杂得多。三四五的拆弹和六七八的缓存模拟,shell模拟,malloc实现,都让我更多的窥探到了计算机系统的奥秘。初窥linux系统,学的过程中被迫要自己在网上了解很多东西,我也很大程度的提升了自己的学习能力。

总之,我很喜欢这门课。唯一希望的是,以后这门课中能有关于第四章处理器体系结构的实验,想体验一下用verilog或者其他硬件语言,实现一个简单CPU模拟。

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


附件

     所有附件在同一文件夹下。

     hello.i:hello.c预处理后的文件。

     hello.s:hello.i编译后的文件。

     hello.o:hello.s汇编后的文件。

     hello.o_obj:hello.o的反汇编文件。

     hello.o_elf:hello.o用readelf -a hello.o指令生成的文件。

     hello_obj:hello的反汇编文件。

     hello_elf:hello用readelf -a hello指令生成的文件。

     test1.c:测试编译中编码转换问题的程序。

     test1.i:test1.c预处理后的文件。

     test1.s:test1.i编译后的文件。

     test1.o:test1.s汇编后的文件。

     test1.o_obj:test1.o的反汇编文件。

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


参考文献

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

[1] 袁春风. 计算机系统基础. 北京:机械工业出版社,2018.7(2019.8重印)

[2] Randal E. Bryant;David R. O’Hallaron. 深入理解计算机系统. 北京:机械工业出版社,2016.7(2019.3重印)

[3]  https://www.cnblogs.com/alantu2018/p/9002441.html

[4]  https://blog.csdn.net/mengxingyuanlove/article/details/50986092

[5]  https://blog.csdn.net/huangshanchun/article/details/46731401

[6]  https://www.cnblogs.com/pianist/p/3315801.html

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

猜你喜欢

转载自www.cnblogs.com/taotao15/p/12153835.html
今日推荐