程序人生-Hello’s P2P

摘  要

    本文通过结合本学期学习的计算机系统知识,详细地介绍了Hello文件的一生。从hello.c开始,经过预处理、编译、汇编、链接、进程管理、存储管理与IO管理等等,诞生了hello这个可执行文件。本文将对以上各个方面进行详细介绍,并进行对CSAPP相关知识的梳理。

    在壳(Bash)里,伟大的OS(进程管理)为其fork(Process),为其execve,为其mmap,分其时间片,竭尽所能让其得以在Hardware(CPU/RAM/IO)上驰骋(指取指译码执行/流水线等);

OS(存储管理)与MMU为VA到PA为其操碎了心;TLB、4级页表、3级Cache,Pagefile等等各显神通为其加速;IO管理与信号处理使尽了浑身解数,软硬结合,才使其能在键盘、主板、显卡、屏幕间游刃有余。最终,被bash所回收,完美谢幕。计算机系统-Editor+Cpp+Compiler+AS+LD + OS + CPU/RAM/IO等。

关键词:计算机系统;预处理;编译;汇编;链接;进程管理;存储管理与IO管理                             

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

目  录

第1章 概述... - 4 -

1.1 Hello简介... - 4 -

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

1.2.1 硬件环境... - 4 -

1.2.2 软件环境... - 5 -

1.2.3 开发工具... - 5 -

1.3 中间结果... - 5 -

1.4 本章小结... - 5 -

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

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

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

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

2.4 本章小结... - 7 -

第3章 编译... - 8 -

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

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

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

3.3.1 数据的处理... - 8 -

3.3.2 算术操作的处理... - 9 -

3.3.3 关系操作与控制转移... - 9 -

3.3.4 数组/指针/结构操作... - 10 -

3.3.5 函数操作... - 10 -

3.4 本章小结... - 11 -

第4章 汇编... - 12 -

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

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

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

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

4.5 本章小结... - 15 -

第5章 链接... - 16 -

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

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

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

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

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

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

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

5.8 本章小结... - 23 -

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

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

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

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

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

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

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

6.7本章小结... - 28 -

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

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

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

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

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

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

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

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

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

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

7.10本章小结... - 32 -

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

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

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

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

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

8.5本章小结... - 34 -

结论... - 36 -

附件... - 37 -

参考文献... - 38 -

第1章 概述

1.1 Hello简介

P2P:指From Program to Process。具体过程如下:

首先我们在各种编译器中写下hello的代码,就完成了hello.c,接下来我们需要运行它,也就是将program变成process

1. hello.c经过预处理、编译、汇编、连接,会生成可执行目标程序hello。这个过程是用来生成可执行文件的过程。

2.运行可执行文件:在bash中,输入./hello,应用shell会逐一读取文字,并将结果存至内存,然后OS(进程管理)为其fork,并新建子进程。

之后为其调用execve,将可执行文件装入内核的linux_binprm结构体。进程调用exec时,该进程执行的程序完全被替换,新的程序从main函数开始执行。

之后为其mmap,使进程之间通过映射同一个普通文件实现共享内存。hello被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问。

最后,加载器跳转到_start地址,它最终会调用应用程序的main 函数。然后程序从内存读取指令字节,然后再从寄存器读入最多两个数,然后在执行阶段算术/逻辑单元要么执行指令指明的操作,计算内存引用的有效地址要么增加或者减少栈指针。然后在流水线化的系统中,待执行的程序被分解成几个阶段,每个阶段完成指令执行的一部分。最后变成一个Process运行在内存中。

最后为其分时间片。时间片通常很短,用来运行hello这个进程。

以上整个过程就是P2P的整个过程。

020: From Zero-0 to Zero-0。具体过程如下:

程序从无开始,在通过P2P中的一系列操作后,Hello拥有了自己的进程、内存中地址和时间周期等。而在进程完成终止之后,又会被回收释放内存并删除有关上下文,然后shell等待下一个程序的进程分配。这个从无到有,又复归于无的过程就是020

1.2 环境与工具

1.2.1 硬件环境

AMD Ryzen 7 4800U with Radeon Graphics CPU; 400 MHz; 16G RAM; 512GHD disk;

1.2.2 软件环境

Windows10 64位; Vmware 16;Ubuntu 20.04 LTS 64位;

1.2.3 开发工具

Visual Studio 2022 64位;CodeBlocks 64位;vi/vim/gedit+gcc

1.3 中间结果

列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。

  1. hello.i:预处理后生成的文本文件,将使用的相关库链接后的文件。
  2. hello.s:编译后产生的汇编文件。
  3. hello.o:汇编后生成的可重定位文件。
  4. hello:链接后生成的可执行文件。
  5. hello.elf:可执行文件hello的elf文件。
  6. hello_obj:hello.o的反汇编文件。
  7. hello_objdump:hello的反汇编文件。
  8. hello.o_elf.txt:hello.o的elf文件。

1.4 本章小结

       本章对hello从一个c语言程序到如何成为一个可执行文件以及在计算机中是如何执行的进行了一个概括。Hello程序的一生也是其他所有代码程序的一生,因此具有很好的参考意义。本章也介绍了进行大作业的硬件环境和软件环境。

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

    预处理概念:预处理一般是指在程序源代码被翻译为目标代码的过程中,生成二进制代码之前的过程。这个过程可以放在程序编译的任何一个位置。

预处理作用:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序并得到以.i为文件扩展名的新的C程序,例如宏定义,文件包含,条件编译等。

1.将所有的#define删除,并展开所有的宏定义;

2.处理所有的预编译指令,例如:#if,#elif,#else,#endif;

3.处理#include预编译指令,将被包含的文件插入到预编译指令的位置;

4.添加行号信息文件名信息,便于调试;

5.删除所有的注释;

6.保留所有的#pragma编译指令;

7.生成.i文件。

2.2在Ubuntu下预处理的命令

       命令:gcc hello.c -E -o hello.i 或者 cpp hello.c -o hello.i

       具体写法如下:

      

       结果:

      

2.3 Hello的预处理结果解析

预处理前hello.c的头文件如下:

处理后,插入头文件的结果如下:

可以看出,预处理过后,hello.c的注释部分被消除,然后预处理程序将#include #include #include 三个文件的源码内容添加到预处理文件中。头文件中如果也包含预处理命令,则递归地预处理直到文件中不存在预处理命令。剩下部份内容照搬即可。

2.4 本章小结

本章简单介绍了预处理的概念,作用,并在ubuntu下利用hello.c程序进行了预处理操作,并给出了预处理操作后的结果及原因。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

       编译的概念:编译是指将一个经过预处理的高级语言程序文本(.i文件)翻译成能执行相同操作的等价ASII码形式汇编语言文件(.s文件)的过程。

       编译的具体过程:

1.语法分析:编译hello的语法分析器以单词符号作为输入,分析单词符号串是否形成符合语法规则的语法单位,其具体方法方法分为自上而下分析和自下而上两种。

2.中间代码:是源程序的一种内部表示,也称中间语言。中间代码的作用是使编译程序的结构在逻辑上更为简单明确,特别是可使目标代码的优化比较容易实现中间代码。

3.代码优化:这一步会对程序进行多种等价变换,使得从变换后的程序出发能生成更有效的目标代码。

4.目标代码:生成目标代码是编译的最后一个阶段。目标代码生成器把语法分析后或优化后的中间代码变换成目标代码,即汇编代码。

编译的作用:通过语法分析以及一定程度的代码优化c语言转换成更接近机器语言的汇编语言,在对程序进行初步优化的同时简化转化成可执行文件的过程。

3.2 在Ubuntu下编译的命令

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

截图如下:

结果如下:

3.3 Hello的编译结果解析

3.3.1 数据的处理

1.源代码中不存在全局变量,但存在局部变量。如图:

对应的汇编代码如下:

-4(%rbq)用来存放局部变量i。后面的赋值语句如下:

       2.字符串的存储:

对printf()的字符串进行编译:

      

将字符串保存在下图位置:

      

    int argc

传入main函数的参数,存储在%edi中,在栈中使用。

int main

定义的主函数,存储在.text节。

char* argv[]

传入main函数的参数,存储在%rsi中,在程序运行时被放入栈中使用。

数字常量:

4、0、8、1、2、3存储在.text节。

3.3.2 算术操作的处理

       循环中i++对应的汇编代码如下:

      

3.3.3 关系操作与控制转移

       对i赋值之后进行了一个条件判断:

      

对应的汇编代码如下:

je说明相等的话就跳转到L2。

对于下面的for循环代码:

对应的汇编代码如下:

jle判断-4(%rbp)的值是不是小于等于7,小于等于7的话就跳转到L4,L4中会有上面的add那行的汇编代码用于i++,i到8的时候就不跳转,继续后面。

3.3.4 数组/指针/结构操作

    主函数main的参数中有,指针数组char *argv[]

argv数组中,argv[0]指向输入程序的路径和名称,argv[1]argv[2]分别表示两个字符串。

argv[1]argv[2]对应的汇编代码如下:

-20(%rbp)-32(%rbp)分别存放argv[1]argv[2]两个字符串。

3.3.5 函数操作

       1.exit(0)函数对应的汇编代码如下:

      

       传入参数1,执行退出操作。

       2.main函数对应的汇编代码块如下:

      

    参数传递:传入参数argcargv[],两个量的值分别用寄存器%rdi%rsi存储。

函数返回:设置%eax0并且返回,对应return 0

3.printf():for循环中将argv[1]argv[2]的地址call print去了。对应的汇编代码如下:

4.sleep()函数:

传入atoi(argv[3])参数,for循环下call sleep,具体过程如下:

5.getchar()函数:读个空格,汇编代码如下:

3.4 本章小结

本章介绍了编译的原理与作用,并演示了hello.i预处理文件转换成hello.s汇编代码的过程。同时,本文也介绍了汇编代码实现局部变量,常量,传递参数,分支,循环以及函数的方法。对编译结果的文件进行分析,我们理解了c语言源程序的各种功能在汇编中是如何实现的。作为与硬件关系最为密切的一种语言,汇编语言在空间和时间上的效率也是最高的。

(第32分)

第4章 汇编

4.1 汇编的概念与作用

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

       汇编的作用:汇编器将汇编代码转换成对应的机器代码,这种代码计算机可以理解并且执行。汇编器将每一条汇编指令转换成对应的二进制代码,并将代码的集合放到可重定位目标程序中,即hello.o中。注意:这个二进制代码是程序在本机器上的机器语言对应的表示文件。

4.2 在Ubuntu下汇编的命令

       命令:gcc -c hello.s -o hello.o

       结果如下:

      

      

4.3 可重定位目标elf格式

       先用readelf命令查看目标文件:

      

1.ELF Header:以一个16字节的序列magic开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。然后剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。

2.再查看具体的elf文件内容:

打开文件查看对应的节头部表信息:

这个节头部表记录了每个节的名称、类型、属性(读写权限)、在ELF文件中占的度、对齐方式和偏移量。各个节的含义如下:

.text节:已经编译后的机器代码。

.rela.text节:一个.text节中位置的列表。

.data节:已初始化的静态和全局C变量。

.bss节:未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量。在目标文件中这个节不占据实际的空间,它仅仅是一个占位符。

.rodata节:存放只读数据,例如printf中的格式串和开关语句中的跳转表。

.comment节:包含版本控制信息。

.note节:注释节详细描述。

.eh_frame节:处理异常。

.rela.eh_frame:一个.eh_frame节中位置的列表。

.shstrtab节:包含节区名称的区域。

.symtab节:一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。

.strtab节:一个字符串表,其内容包括.symtab.debug节中的符号表,以及节头部的节名字。

.symtab节:本节用于存放符号表。

3.查看重定位节:重定位节表述了各个段引用的外部符号等,在链接时,链接器会通过重定位条目的类型判断该使用什么样的方法计算正确的地址值,通过偏移量等信息计算出正确的地址,因此链接器需要通过重定位节对这些位置的地址进行修改:

如上图可以看出,本程序需要重定位的信息有以下7条:.rodata中的模式串,putsexitprintfslepsecssleepgetchar这些符号。

4.符号表:存放在程序中定义和引用的函数和全局变量的信息:

4.4 Hello.o的结果解析

命令:odjdump -d -r hello.o > hello_obj

在ubuntu上的结果如下:

打开hello_obj文件,并与hello.s文件比较,可得:

       1.反汇编后的汇编代码由于已经生成了初始化的定位,形成了elf可重定位文件,并且每条指令都能给出对应的操作数形式,hello.s中则没有。

       2.对于数字的表示来说,hello.s的操作数是十进制,而反汇编文件的操作数是16进制。

       3.在全局变量访问中,对于hello.s,全局变量的访问方式为:段名称+%rip,而对于hello.o的反汇编为0+%rip。因为rodata的数据地址是在运行时确定的,故也需要重定位,所以反汇编后对于全局变量的访问方式是0,并添加了重定位条目,后续只要进行链接操作即可定位每条命令的绝对位置了。

       4.分支转移中,hello.s的跳转语句是跳转到某一个段位置(比如.L4等),而反汇编文件中跳转方式是跳转到某一相对地址,此地址为相对首地址的偏移地址。

       5.函数调用方式上,在hello.s中,调用地址时call后直接跟着函数名称,而在反汇编文件中,由于函数也有相对首地址的偏移地址,所以函数的调用是call函数的相对偏移地址(汇编时汇编器在.rela.text节中为函数添加了重定位条目)。

4.5 本章小结

       本章介绍了汇编器将hello.s变成hello.o,即可重定位文件的过程。并通过将可重定位文件反汇编以及查看可重定位文件elf的过程中,理解了汇编器将汇编语言转换成可重定位文件的方法,以及为后续链接所做的准备。在比较反汇编文件和hello.s 文件的过程中,我们更加深刻地理解了汇编器对文件的数字表示,全局变量,函数调用方式等进行的分析。

(第41分)

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

产生可执行文件hello:

      

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

使用readelf命令,命令如下:

读取结果如下:

对文件进行分析,首先是ELF的文件头:

可以看出,与前面的elf文件相同,以一个16字节的序列magic开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。然后剩下的部分包含帮助链接器语法分析和解释目标文件的信息,其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量等信息。

接下来查看节头信息:

节头对hello中所有节的信息进行了列举,描述了各个节的大小,相对elf节头的偏移量以及其他属性。链接器连接时,会将各个文件的相同段合并成一个大段,并且根据这个大段的大小以及偏移量重新设置各个符号的地址。

5.4 hello的虚拟地址空间

命令行输入edb,打开edb后打开hello文件,通过Data Dump窗口可以看到加载到虚拟地址的hello文件:

查看elf文件中的Program Headers,也可以查看到各节对应的虚拟地址:

可以看出,黑框的内容与上面edb的内容一一对应。这里告诉链接器运行时加载的内容,并提供了动态链接的信息。

对上图黑框进行分析:

在hello的ELF文件的“程序头”一节中,一共有八段,并且给出了每一段的偏移量,虚拟地址,物理地址,访问权限,对齐等信息。

1.PHDR:用来保存程序头表。

2.INTERP:指定程序从可行性文件映射到内存之后,必须调用的解释器,它是通过链接其他库来满足未解析的引用,用于在虚拟地址空间中插入程序运行所需的动态库。

3.LOAD:表示一个需要从二进制文件映射到虚拟地址空间的段,其中保存了常量数据(如字符串)、程序的目标代码等。

4.DYNAMIC:保存了由动态连接器(即INTERP段中指定的解释器)使用的信息。

5.NOTE:保存辅助信息。

6.GNU_STACK:权限标志,标志栈是否是可执行的。

7.GNU_RELRO:指定在重定位结束之后那些内存区域是需要设置只读。

5.5 链接的重定位过程分析

使用命令:objdump -d -r hello > hello_objdump

得到文件如下:

打开文件,分析与hello.o反汇编得到的结果不同点:

1.如上图,hello中增加了.init和.plt节,以及一些其他节定义的函数。

2.hello链接了许多新的函数,比如printf,exit,atoi,sleep,getchar等函数。

3.hello.o中用的是相对偏移地址,而hello中由于函数已经链接完成,已经执行了重定位条目,所以用的是虚拟地址。

观察上图,与hello对比可知,hello.o中,给出了函数和全局变量相对于EIF头的偏移量,所以在链接后,给定了程序首地址,然后根据偏移量,计算出函数和全局变量的绝对地址。

通过hello和hello.o的区别可以看出,链接就是链接器(ld)将各个目标文件(各种.o文件)组装在一起,文件中的各个库函数段按照一定的规则累积在一起。

5.6 hello的执行流程

不停使用edb寻找所有call的相关函数,如下图:

寻找结果如下:

子程序名                                                     程序地址

ld-2.27.so!_start                                          0x7fd7:8dfda090

ld-2.27.so!_dl_start                                     0x7fd7:8dfdaea0

ld-2.27.so!_dl_start_user                            0x7fd7:8dfda09b

ld-2.27.so!_dl_init                                      0x7fd7:8dfe9630

hello!_start                                                  0x400500

libc-2.27.so!__libc_start_main                   0x7fd7:8dc09ab0

-libc-2.27.so!__cxa_atexit                          0x7fd7:8dc2b430

-libc-2.27.so!__libc_csu_init                      0x4005c0

hello!_init                                                   0x400488

libc-2.27.so!_setjmp                                   0x7fd7:8dc26c10

-libc-2.27.so!_sigsetjmp                             0x7fd7:8dc28e2b

–libc-2.27.so!__sigjmp_save                      0x7fd7:8dc2db30

hello!main                                                   0x400532

hello!puts@plt                                            0x4004b0

hello!exit@plt                                             0x4004e0

*hello!printf@plt –

*hello!sleep@plt –

*hello!getchar@plt –

ld-2.27.so!_dl_runtime_resolve_xsave      0x7fd7:8dc324d0

-ld-2.27.so!_dl_fixup                                  0x7fd7:8dc34520

–ld-2.27.so!_dl_lookup_symbol_x              0x7fd7:8dd24dc0

libc-2.27.so!exit                                          0x7fd7:8dd32fd0

5.7 Hello的动态链接分析

共享库(shared library)是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接(dynamiclinking),是由一个叫做动态链接器(dynamiclinker)的程序来执行的。共享库也称为共享目标(shared object),在Linux系统中通常用.so后缀来表示。

共享库是以两种不同的方式来“共享”的。首先,在任何给定的文件系统中,对于一个库只有一个.so文件。所有引用该库的可执行目标文件共享这个.so文件中的代码和数据,而不是像静态库的内容那样被复制和嵌人到引用它们的可执行的文件中。其次,在内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。

在调用共享库的时候,编译器没有办法预测这个函数的运行地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。

现代系统以这样一种方式编译共享模块的代码段,使得可以把它们加载到内存的任何位置而无需链接器修改。使用这种方法,无限多个进程可以共享一个共享模块的代码段的单一副本。当然,每个进程仍然会有它自已的读/写数据块。

可以加载而无需重定位的代码称为位置无关代码( Position-Independent Code, PIC)。用户对GCC使用-fpic选项指示GNU编译系统生成PIC代码。共享库的编译必须总是使用该选项。在一个x86-64 系统中,对同一个目标模块中符号的引用是不需要特殊处理使之成为PIC。可以用PC相对寻址来编译这些引用,构造目标文件时由静态链接器重定位。

在hello中,对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,因此在链接时,对所有绝对地址的引用不做重定位,把这一步推迟到装载的时候进行;一旦模块装载地址确定,即目标地址确定,则系统对程序中所有的绝对地址的引用进行重定位。

在elf文件,我们找到:

进入edb调试,调用dl_init前的全局偏移表如下:

调用之后的全局偏移表如下:

对于变量而言,我们会利用代码段和数据段的相对位置不变的原则计算正确地址。对于库函数而言,需要pltgot合作,plt初始存的是一批代码,它们跳转到got所指示的位置,然后调用链接器。初始时got里面存的都是plt的第二条指令,随后链接器修改got,下一次再调用plt时,指向的就是正确的内存地址。plt就能跳转到正确的区域。

5.8 本章小结

本章对链接的过程进行了具体的分析。链接在程序编译的过程中有着十分重要的作用,它为程序处理好了需要的绝大多数资源,将所需要的函数,变量等信息都整理好,给出了绝对地址,使得程序完整,可以执行。

本章通过对比链接后hello的反汇编文件和链接前的hello.o的反汇编文件,更加深刻的理解了链接过程,也理解了链接过程是如何将重定位文件中的相对偏移地址翻译成对应的虚拟地址的。最后,我们学习了hello的执行过程,并对hello的动态连接过程进行了简单分析。

(第51分)

6章 hello进程管理

6.1 进程的概念与作用

       进程的概念:进程是操作系统一个正在运行的程序的一种抽象。程序在系统上运行时,操作系统会提供一种假象,就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器,主存和I/O设备。处理器看上去就像在不间断地一条接一条的执行程序中的指令。这些假象都是通过进程的概念提供给我们的。

    进程的作用:进程为用户提供了许多假象,包括:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。

       每次用户通过向shell 输入 一个可执行目标文件的名字,运行程序时,shell 就会创建一个新的进程,然后在这个新进程的上下文中运行这个可执行目标文件。应用程序也能够创建新进程,并且在这个新进程的上下文中运行它们自己的代码或其他应用程序。

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

       Shell-bash的作用:shell-bash 作为一个C语言程序,是用户使用Unix/Linux的桥梁,它交互性地接受,解释和执行用户输入的命令,能够通过调用系统级的函数或功能来执行程序、建立文件、进行并行操作等等。同时它也能够协调程序间的运行冲突,保证程序能够以并行形式高效执行。bash 还提供了一个图形化界面,提升交互的速度。

       Shell-bash的处理流程:

1.从终端或控制台获取用户输入的命令;
2.对读入的命令进行分割并重构命令参数;
3.如果是内部命令则调用内部函数来执行;
4.否则执行外部程序

5.判断程序的执行状态是前台还是后台,若为前台进程则等待进程结束;否则直接将进程放入后台执行,继续等待用户的下一次输入。

6.3 Hello的fork进程创建过程

       以hello程序为例,在shell中写入./hello 120L021509 朱俊,运行的终端程序会读入这个字符串,然后对这个字符串进行解析,因为hello不是一个内置的命令所以解析之后终端程序判断执行hello,之后终端程序首先会调用fork函数创建一个新的运行的子进程,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,父进程与子进程之间最大的区别在于它们拥有不同的PID。当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程与父进程有不同的pidfork被调用一次,返回两次。在父进程中fork返回子进程的pid,在子进程中fork返回0

父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。

6.4 Hello的execve过程

       对子进程fork结束后,我们就要对子进程进行execve,将hello程序加载到当前子进程的上下文中。execve函数加载并运行可执行文件hello,且带参数列表argv和环境变量envp。只有当出现错误时,例如找不到filenameexecve才会返回到调用程序。运行时,创建一个内存映像,在程序头部表的引导下,加载器将可执行文件的片复制到代码段和数据段,接下来,加载器跳转到程序的入口,_start函数的地址,这个函数是在系统目标文件ctrl.o中定义的,对所有的c程序都一样。_start函数调用系统启动函数,_libc_start_main,该函数定义在libc.so里,初始化环境,调用用户层的main函数,处理main函数返回值,并且在需要的时候返回给内核

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

6.5 Hello的进程执行

1.逻辑控制流:一系列程序计数器 PC 的值的序列叫做逻辑控制流。由于进程是轮流使用处理器的,同一个处理器每个进程执行它的流的一部分后被抢占,然后轮到其他进程。

2.用户模式和内核模式:处理器使用一个寄存器提供两种模式的区分。用户模式的进程不允许执行特殊指令,不允许直接引用地址空间中内核区的代码和数据;内核模式进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。

       3.上下文切换:上下文就是内核重新启动一个被抢占的进程所需要恢复的原来的状态,由寄存器、程序计数器、用户栈、内核栈和内核数据结构等对象的值构成。程在处理器中运行,处理器中有多个进程,但是它通过逻辑控制流提供一个假象,好像每个进程独占处理器,关键在于进程是轮流使用处理器,在不同的时间片上使用。以sleep为例介绍hello的调度过程(过程是一样的):

      

    始时,控制流再hello内,处于用户模式;调用系统函数sleep后,进入内核态,此时间片停止;2s后,发送中断信号,转回用户模式,继续执行指令。

       可以看出,在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。以执行sleep函数为例,sleep函数请求调用休眠进程,sleep将内核抢占,进入倒计时,当倒计时结束后,hello程序重新抢占内核,继续执行。

6.6 hello的异常与信号处理

       异常情况有:中断,陷阱,故障,终止。

       正常运行的结果如下:

       、

       乱按的结果如下:

      

      

       可以看出,乱按后,按下回车前命令行会把输入的字符串当作命令执行。

       Ctrl c的结果如下:

      

       前台进程被终止了,通过命令ps可以看出,hello进程已经被回收了。分析原因,大概是ctrl c让内核发送了一个SIGINT信号给前台进程组的每个进程。

       Ctrl z的结果如下:

      

       可以看出,进程被挂起了。输入ps和jobs可以看到被挂起的进程,然后输入fg可以继续执行被挂起的进程。输入kill会终止掉进程。

       最后查看pstree(太长,只截取部分内容):

      

       可以通过进程树查看每个进程的详细信息。

6.7本章小结

       本章介绍了hello可执行文件的具体执行过程。在shell进程下,我们介绍了hello的创建,加载,终止和挂起。然后我们阐明了进程的概念,以hello为例介绍了调用fork创建子进程,使用execve执行hello,hello具体的执行过程以及如何进行上下文切换的方法。最后,我们测试了hello执行过程中的各种异常输入情况,并用ps,jobs,fg,kill,pstree等命令查看了相关信息,理解了hello进程在shell上遇到各种异常状况的不同的处理结果。

(第61分)

7章 hello的存储管理

7.1 hello的存储器地址空间

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

线性地址:是逻辑地址到物理地址变换之间的中间层。在hello中,逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。

虚拟地址: Windows程序时运行在386保护模式下,这时程序访问存储器所使用的逻辑地址称为虚拟地址。与实地址模式下的分段地址类似,虚拟地址也可以写为":偏移量"的形式,这里的段是指段选择器。就是hello的虚拟内存。

物理地址:程序运行时加载到内存地址寄存器中的地址,用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。每字节都有一个唯一的物理地址。就是hello在运行时虚拟内存地址对应的物理地址。

以hello为例,hello中,汇编的地址都是逻辑地址;在寻址时,逻辑地址与段地址相加得到线性地址;在页式管理下,线性地址就是虚拟地址;再通过使用时查询cache和页表,将虚拟地址转换为物理地址,进行寻址访存。

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

       一个逻辑地址由两部分组成,段标识符和段内偏移量。段标识符由16位字段组成,前13位为索引号。索引号是段描述符的索引,很多个描述符,组成了一个数组,叫做段描述表,可以通过段描述标识符的前13位,再这个表中找到一个具体的段描述符,这个描述符就描述了一个段,每个段描述符由八个字节组成。

段描述符中的base字段,描述了段开始的线性地址,一些全局的段描述符,放在全局段描述符表中,一些局部的则对应放在局部段描述符表中。由T1字段决定使用哪个。

以下是具体的转化步骤:

1.给定一个完整的逻辑地址;

2.看段选择符T1,知道要转换的是GDT中的段还是LDT中的段,通过寄存器得到地址和大小;

3.取段选择符中的13位,再数组中查找对应的段描述符,得到BASE,就是基地址;

4.(计算式)线性地址等于基地址加偏移。

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

       线性地址到物理地址的变换涉及到页表和MMU概念。页表是一个页表条目的数组,虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE,PTE中有一个有效位用来表示该区域是否被已缓存在内存中。

MMU即分页内存管理单元,MMU位于处理器内核和连接高速缓存以及物理存储器的总线之间,能够将有效地址映射成对应的物理地址,以访问指令和数据。

当hello需要访问一个内存中的地址时,处理器将虚拟地址发送给MMU,MMU利用虚拟地址对应的虚拟页号生成页表项(PTE)地址,并从页表中找到对应的PTE,如果PTE中的有效位为0,则MMU会触发缺页异常。缺页处理程序选择物理内存中的牺牲页(若页面被修改,则换出到磁盘),缺页处理程序调入新的页面到内存,并更新PTE,缺页处理程序返回到原来进程,再次执行导致缺页的指令。

具体转化步骤如下:

1.从cr3中去除目录地址,操作系统再调度进程的时候,把这个地址装入对应寄存器;

2.根据线性地址前十位,在数组中,找到对应索引项,因为引入二级管理,所以页目录中的项,不再是页的地址,而是一个页表的地址,页的地址再这个页表中;

3.根据线性地址中间的十位,再页表中找到页的起始地址(基址+偏移);

4.将页的起始地址与线性地址中的后12位相加,得到物理地址。

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

       每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降12个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。

TLB命中,则从TLB中可以直接找到各级页表,然后得到PPN,与PPO结合即可得到物理地址。若TLB不命中,则需要从高速缓存中到PPN

       解析VA,利用前mvpn1寻找一级页表位置,接着一次重复k次,在第k级页表获得了页表条目,将PPNVPO组合获得PA

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

       得到物理地址之后,先将物理地址拆分成CT(标记)+CI(索引)+CO(偏移量),然后在L1cache内部找,寻找物理地址检测是否命中。如果未能寻找到标记位为有效的字节(miss)的话就去二级和三级cache中寻找对应的字节,当命中时,将数据传给CPU同时更新各级cachecacheline(如果cache已满则要采用换入换出策略)。

7.6 hello进程fork时的内存映射

       fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的pid。创建当前进程的mm_struct,vm_area_struct和页表的原样副本。Fork的子进程完全与父进程一致,有相同的虚拟内存空间。每个页面都标记为只读,两个进程的每个vm_area_struct都标记为私有,这样就只能在写入时复制。因此,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

       execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。

execve函数执行了以下几个操作:

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

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

3.映射共享区域。

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

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

       缺页:DRAM缓存不命中被称为缺页。

       缺页故障:当指令给出的虚拟内存对应的物理地址并不在主存中时,会发生缺页故障,这时会触发缺页中断处理程序。

1.段错误:首先,先判断这个缺页的虚拟地址是否合法,那么遍历所有的合法区域结构,如果这个虚拟地址对所有的区域结构都无法匹配,那么就返回一个段错误(segment fault)

2.非法访问:接着查看这个地址的权限,判断一下进程是否有读写改这个地址的权限。

3.如果不是上面两种情况那就是正常缺页,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。

7.9动态存储分配管理

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

malloc函数返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。若malloc遇到问题,就返回NULL,并设置errno。malloc不初始化它返回的内存。

基本方法有三种:首次适配,下一次适配和最佳适配。首次适配是从开始处往后搜索,下一次适配是从上一次适配发生处开始搜索,最佳适配依次检查所有块,性能要比首次适配和下一次适配都要高。

动态内存分配器有两种基本风格:显式分配器、隐式分配器。

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

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

7.10本章小结

       本章主要介绍了 hello 的存储器地址空间、 intel 的段式管理、 hello 的页式管理,在指定环境下介绍了 VA  PA 的变换、物理内存访问,以及hello 进程 fork 时的内存映射、 execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。通过上述介绍,我们理解了hello程序所需要的大部分存储管理。

(第7 2分)

8章 hello的IO管理

8.1 Linux的IO设备管理方法

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

8.2 简述Unix IO接口及其函数

       UnixIO接口:

1.打开文件。一个应用程序通过要求内核打开相应的文件,通知它想要访间一个I/O 设备。内核返回一个小的非负整数,它在后续对此文件的所有操作中标识这个文件。

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

3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。

4.读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。

5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。

       UnixIO函数:

1.进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的:int open(char *filename, int flags, mode_t mode)。若成功返回新文件描述符,若出错则返回-1.open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。mode参数指定了新文件的访问权限位。作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置成mode&~umask。最后进程通过调用close函数关闭一个打开的文件。

2.通过read和write函数进行输入与输出:ssize_t read(int fd, void *buf, size_t n)若成功返回读的字节数,若EOF则为零,出错为-1;ssize_t write(int fd, const void *buf, size_t n)成功为写的字节数,出错为-1.read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。也可以通过RIO包处理不足值。它提供了两类不同的函数:无缓冲的输入输出函数;带缓冲的输入函数。

3.应用程序通过调用stat和fstat函数检索关于文件的信息。Int stat(const char*filename,struct stat *buf);int fstat(int fd,struct stat *buf);若成功返回0,出错返回-1.stat函数以一个文件名作为输入,并填写stat数据结构中的各个成员。fstat相似,只不过是以文件描述符而不是文件名作为输入。stat数据结构中的st_mode编码了文件访问许可位和文件类型。St_size则包含了文件的字节数大小。

4.通过readdir系列函数来读取目录的内容。DIR*opendir(const char *name)若成功返回处理的指针,若出错,返回NULL,它以路径名为参数,返回指向目录流的指针。Struct dirent *readdir(DTR *dirp)若成功返回指向下一个目录的指针,若没有更多目录或出错则返回NULL。每次对readdir的调用返回都是指向流dirp中下一个目录项的指针,或者没有更多目录项则返回NULL.每个目录项都是一个结构。若出错,readdir返回NULL并设置errno。函数closedir关闭流并释放所有找资源。成功返回0,失败返回-1。

8.3 printf的实现分析

       详细内容已经在下面链接给出了:

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

简单分析的话,printf接受一个格式化的命令,并把指定的匹配的参数格式化输出。而在printf中用了vsprintfwrite两个函数。

vsprintf函数将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。write函数将buf中的i个元素写到终端。

vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80syscall。字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。

显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

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

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

分析:getchar()是stdio.h中的库函数,它的作用是从stdin流中读入一个字符。

用户通过输入设备输入字符串到内存缓冲区,操作系统执行一个异步异常-键盘中断,这个键盘信号处理子程序将接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

       本章主要阐述了hello在Linux下的IO交互方式以及一些对应的接口函数,并以printf()函数和getchar()函数作为例子进行了实现分析。

(第81分)

结论

hello.c先通过C语言的方式被我们编写出来,然后为了让这个程序能够在计算机上执行,我们经历了以下过程:

1.hello.c经过预处理,将外部库链接到文件中变为hello.i文件。

2.hello.i经过编译,得到汇编语言文件hello.s。

3.hello.s经过汇编,将汇编命令翻译为对应的二进制形式,得到二进制可重定位文件hello.o。

4.hello.o经过链接,将相关的函数可执行代码块链接进去,得到了可以执行的程序hello。

5.在shell下运行,用IO管理方式输入相关命令以及初始字符串,hello被通过fork函数创建了子进程,并被execve加载运行。

6.我们通过逻辑地址层层转换,得到了hello在本台机器上的物理地址。

7.printf函数通过malloc进行动态内存管理。

8.hello在执行过程中可能会受到各种异常信号的干扰,被挂起或者终止。

9.最终结束,hello被父进程shell回收,shell等待下一个被执行的子进程。

感悟:在计算机系统的学习中,我深刻理解了作为程序员,一个被编写出来的程序到底经历了怎样的变化,才能够在不同的机器上运行出美妙的结果。虽然hello程序很简单,但这个程序的运行与其他程序一样,都是非常复杂的。但通过这门课程的学习,我们深刻理解了程序被编译执行的背后过程,以及操作系统和软硬件的配合。同时,系统内核的内存管理和各种机制也让我以后对程序优化有了更深刻的理解,可以编写出对机器更加友好的程序。

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

附件

  1. hello.i:预处理后生成的文本文件,将使用的相关库链接后的文件。
  2. hello.s:编译后产生的汇编文件。
  3. hello.o:汇编后生成的可重定位文件。
  4. hello:链接后生成的可执行文件。
  5. hello.elf:可执行文件hello的elf文件。
  6. hello_obj:hello.o的反汇编文件。
  7. hello_objdump:hello的反汇编文件。
  8. hello.o_elf.txt:hello.o的elf文件。

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

参考文献

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

[1] 深入理解计算机系统

[2] http://docs.huihoo.com/c/linux-c-programming/C汇编Linux手册

[3] 企业开源和Linux | Ubuntu 

[4] Ubuntu中文论坛 - 首页

[5] 用 OProfile 彻底了解性能[IBM]_Blaider的博客-CSDN博客 

[6] https://www.cnblogs.com/jkkkk/p/6520381.html《Linux调优工具oprofile的演示分析》

[7] 笨办法学C 练习41:将 Cachegrind 和 Callgrind 用于性能调优 - 简书将 Cachegrind 和 Callgrind 用于性能调优

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值