ICS2019大作业——hello's p2p

计算机系统

大作业

题 目 程序人生-Hello’s P2P

专 业 计算机院计算机类

指 导 教 师 史先俊

计算机科学与技术学院

2019年12月

摘 要

本文从hello的一生出发,通过对hello从产生到执行的全过程对hello进行了系统的分析。文章重点讲述了hello的P2P过程以及020过程,通过这两个过程将hello的预处理、编译、汇编、链接生成执行程序的过程进行了阐述;同时结合虚拟内存、进程管理、IO管理的内容将hello的加载运行到程序结束进行了统一的概括和分析。

关键词:P2P;020;计算机系统;虚拟内存;进程

(摘要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 本章小结… - 10 -

第3章 编译… - 11 -

3.1 编译的概念与作用… - 11 -

3.2 在Ubuntu下编译的命令… - 11 -

3.3 Hello的编译结果解析… - 12 -

3.4 本章小结… - 16 -

第4章 汇编… - 17 -

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

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

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

4.4 Hello.o的结果解析… - 22 -

4.5 本章小结… - 23 -

第5章 链接… - 24 -

5.1 链接的概念与作用… - 24 -

5.2 在Ubuntu下链接的命令… - 24 -

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

5.4 hello的虚拟地址空间… - 27 -

5.5 链接的重定位过程分析… - 30 -

5.6 hello的执行流程… - 31 -

5.7 Hello的动态链接分析… - 32 -

5.8 本章小结… - 33 -

第6章 hello进程管理… - 34 -

6.1 进程的概念与作用… - 34 -

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

6.3 Hello的fork进程创建过程… - 34 -

6.4 Hello的execve过程… - 35 -

6.5 Hello的进程执行… - 35 -

6.6 hello的异常与信号处理… - 36 -

6.7本章小结… - 39 -

第7章 hello的存储管理… - 40 -

7.1 hello的存储器地址空间… - 40 -

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

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

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

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

7.6 hello进程fork时的内存映射… - 46 -

7.7 hello进程execve时的内存映射… - 46 -

7.8 缺页故障与缺页中断处理… - 47 -

7.9动态存储分配管理… - 48 -

7.10本章小结… - 51 -

第8章 hello的IO管理… - 52 -

8.1 Linux的IO设备管理方法… - 52 -

8.2 简述Unix
IO接口及其函数… - 52 -

8.3 printf的实现分析… - 54 -

8.4 getchar的实现分析… - 56 -

8.5本章小结… - 57 -

结论… - 58 -

附件… - 59 -

参考文献… - 60 -

第1章 概述

1.1
Hello简介

P2P:From Program to
Process,即表示Hello从编写代码到程序运行的过程。从整个程序的诞生和结束来说,Hello需要经过4个大步骤:预处理、编译、汇编、链接4个过程得到一个可在内存中被直接执行的可执行文件。然后在执行的过程中,由shell新建进程对Hello进行执行,在这个过程中,操作系统会调用fork产生子进程Hello成为进程的开始。然后通过execve将其加载,不断进行访存、内存申请等操作。最后,在程序结束返回后,由父进程或祖先进程进行回收,程序结束。

020:From Zero-0 to Zero-0,即表示在hello的整个运行的过程中,由开始的操作系统进行的存储管理,然后由内存管理单元将虚拟地址翻译成物理地址,同时进行内存访问,通过页面的调度进入内存,开始了进程的执行。最后以父进程或者祖先进程的回收作为终点,生也操作系统,死也操作系统,这是hello进程的一生的执行。

1.2
环境与工具

1.2.1 硬件环境:

处理器:Inter®
Core™ i7-7700HQ CPU @ 2.80GHz 2.80
GHz

RAM: 8.00GB

系统类型:64位操作系统,基于x64的处理器

1.2.2 软件环境

Windows10家庭中文版

VMware Workstation 15 Player

Ubuntu 18.04LTS

1.2.3 开发与调试工具

Gcc、Code::Blocks17.12、Readelf、Gedit、Vim、Gdb、Cpp、As、Ld、Edb

1.3 中间结果

文件名 文件作用

hello.c 存储C源代码

hello.i C源代码预处理之后的结果

hello.s 编译生成的汇编代码

hello.o 编译生成的可重定位文本

hello_o.s hello.o的反汇编文本

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

hello 可执行文本hello

helloexe.elf 可执行文本hello的ELF文件格式

helloexe.s 可执行文本hello的反汇编文本

1.4 本章小结

本章从hello的一生对其进行了简单的概括,对其的P2P过程、020过程进行了阐述和概括,并且对实验的环境和生成的中间产物进行了简要概括,是全文的梗概,后文的描述都是基于这两个过程进行深入分析的。

(第1章0.5分)

第2章 预处理

2.1 预处理的概念与作用

2.1.1 预处理的概念

预处理指的是程序在编译之前进行的处理,是计算机在处理一个程序时所进行的第一步处理,可以进行代码文本的替换工作,但是不做语法检查。预处理是为编译做的准备工作,能够对源程序.c文件中出现的以字符“#”开头的命令进行处理,包括宏定义#define、文件包含#include、条件编译#ifdef等,最后将修改之后的文本进行保存,生成.i文件,预处理结束。而在Hello中会进行的处理如图所示:
ICS2019大作业——hello's p2p_第1张图片
图2.1 hello.c中有关预处理的命令

2.1.2 预处理的作用

在预处理的过程中,计算机利用预处理器(cpp)进行处理。而预处理主要有3个方面的内容,分别是根据字符“#”后所跟的具体语句进行不同处理。它们分别为宏定义、文件包含、条件编译。

宏定义在预处理的过程中会进行宏替换。在具体语句中表现为#define。宏定义具体而言又分为两种,在不带参数的宏定义中,要用实际值替换用#define定义的字符或字符串;而在带参数的宏定义中,不仅仅要进行实际值的替换,还要将参数进行代换。在宏替换中,仅仅只是做替换,不做计算和表达式求值。

文件包含指的是对代码中出现的#include语句进行处理。#include指令能够告诉预处理器读取源程序中所引用的系统的源文件,并且将这一段代码直接插入到程序文件中,最终保存为.i文件中。而在Hello实例中,预处理会对#include、#include、#include三条语句进行文件包含的处理。

条件编译指的是针对#ifdef、#ifndef等语句进行的处理。条件编译能够根据#if的不同条件决定需要进行编译的代码,#endif是结束这些语句的标志。使用条件编译可以使目标程序变小,在满足条件之后才会进行编译。

2.2在Ubuntu下预处理的命令

下面在Linux下对Hello进行预处理,过程如下:

具体指令为linux> cpp hello.c > hello.i

具体指令的截图如下:

ICS2019大作业——hello's p2p_第2张图片

图2.2 对hello.c进行预处理的指令生成hello.i

ICS2019大作业——hello's p2p_第3张图片

图2.3 hello.i与hello.c的信息比较

由图可知,在预处理器对程序进行预处理的过程中,对原来的C代码进行了很大的修改以及补充,两个文本的大小相差极大,在main函数前被插入了大量的代码,这些都是进行预处理的结果。

2.3 Hello的预处理结果解析

在利用Linux下的指令对hello.c进行预处理之后,生成的预处理文件保存在hello.i文件中,在Linux下对其进行打开,使用gedit工具,打开如下:

ICS2019大作业——hello's p2p_第4张图片

图2.4 hello.i的部分文本

在打开的文本中,我们很容易看到hello.i中添加了很多代码,而原来的C代码在文本的最末端,如图:

ICS2019大作业——hello's p2p_第5张图片

图2.5 原来的hello.c代码

由于预处理只是对源代码中的以字符“#”开头的语句进行处理,因此在预处理阶段程序中定义的其他操作并不会在预处理阶段进行处理。由于在程序中有关于头文件的文件包含,因此在预处理时对这一段进行了解析。在main函数之前,预处理器就分别读取stdio.h、unistd.h、stdlib.h中的内容,并且根据读入的顺序依次进行内容的展开。如果头文件中仍然有以字符“#”开头的内容,则预处理器继续对其进行处理,最终的hello.i文件中没有宏定义、文件包含及条件解析等内容。而在hello.i文件中我们还能看到在源程序中定义的命令行参数、环境变量的内容,以及在文件包含中对头文件的处理,使用存储的绝对路径进行显示。另外,还有在头文件中使用的一些数据类型的说明、结构体定义、对引用的外部函数的声明,再包括未进行大修改的源代码,一起构成了hello.i头文件。上述信息如图所示:

ICS2019大作业——hello's p2p_第6张图片
图2.6 hello.i中的部分外部引用

2.4 本章小结

在本章中,通过对预处理的概念与作用的了解,结合具体的hello程序,对与处理中所做的处理进行了进一步分析,对预处理的指令进行了运用。同时针对预处理的操作结合hello.c进行了具体的阐述,对hello的预处理结果hello.i进行了结果解析。

(第2章0.5分)

第3章 编译

3.1 编译的概念与作用

3.1.1 编译的概念

编译是在预处理之后的下一阶段,利用编译器(ccl)将预处理得到的.i文本文件经过一系列的语法分析等过程得到.s文本文件的过程,这个.s文件就是对应的汇编语言文件。在这个过程中,编译能够生成汇编代码。这个过程将高级语言转换成低级汇编语言指令,它包含了一个汇编语言程序。在此过程中,编译器将会对程序进行优化、语法检查等。

3.1.2 编译的作用

在编译阶段,编译器能够针对.i文件进行对代码的语法检查,同时能够进行语义的分析,在程序没有错误的前提下能够生成对应的汇编文本,对应的汇编文本保存在.s文件中。在这个阶段的对代码进行的语法检查中,如果程序在语法上有错误,那么编译会失败。相反语义分析正确之后则生成汇编代码,供下一步汇编操作。

3.2 在Ubuntu下编译的命令

在Linux下进行编译的具体过程如下:

具体的编译指令为:linux>gcc -S hello.i -o hello.s

生成命令截图如下:

ICS2019大作业——hello's p2p_第7张图片

图3.1 linux下编译的命令

生成的文本为hello.s,具体文本截图如下:

ICS2019大作业——hello's p2p_第8张图片

图3.2 hello.s的部分文本内容

3.3 Hello的编译结果解析

3.3.1 hello.s文件预览及文件声明解析

首先观察hello.s的文本内容,截图如下:
ICS2019大作业——hello's p2p_第9张图片

图3.3 hello.s的文本内容

在文本中出现的文件声明为:

.file 声明hello的源文件hello.c

.text 指示代码段

.section .rodata .section作用是定义内存段,该命令后只跟一个参数,即它声明的段的类型,此处定义了只读数据段

.align 声明对指令或者数据的存放地址进行对齐的方式,此处参数为8

.string 表示声明一个字符串

.globl 全局类型的声明

.type 用来指定对象类型(函数/对象)

.size 用来声明大小

3.3.2 数据

3.3.2.1 字符串常量

在hello.c源代码中,我们在printf语句中出现了两句字符串,它们分别是"用法: Hello 学号 姓名 秒数!\n"与"Hello %s %s\n",这两个常量是被声明在只读数据段(.rodata)中。

在这里插入图片描述

图3.4 hello.s中的常量字符串

3.3.2.2 整型数据

在hello.c的源代码中,我们可以看到在其中定义了int i这个局部变量,这是一个整型数据。在main函数中,我们使用了参数int
argc,使用它来记录传入参数的个数。而在循环中对循环变量的数值改变则是将其作为立即数进行处理的,在汇编的代码中以立即数的形式给出。源程序内容参见下图:

ICS2019大作业——hello's p2p_第10张图片

图3.5 hello.c的内容

3.3.2.3 数组型数据

在main函数的参数中,有一个char
*argv[]数组,这是main函数的第二个参数,在系统中为其分配了一段连续空间来存放内容,对其的使用示例如图:

ICS2019大作业——hello's p2p_第11张图片

图3.6 argv数组

3.3.3 赋值操作

在源程序的循环控制中,我们能够看出此处对局部变量有一个赋值的操作,由于局部变量是存储在栈中的,因此在汇编语言中,是在代码中进行体现,对应C代码和汇编语言如图:

在这里插入图片描述

图3.7 赋值操作

3.3.4 关系操作与分支跳转

在C语言的源代码中出现了两处关系操作,其中第一处是判断输入参数的个数,如果参数个数不符合程序要求的时候,程序发生跳转,异常退出,返回值为1。

程序的第二处关系操作出现在循环中,和分支跳转联系在了一起。在循环的判断中,要比较循环变量i与8的值,同时也与分支结构联系在了一起,如图:

图3.8 关系操作与跳转

3.3.5 运算操作

在这个源程序中,体现了运算操作的语句仍然是在循环中,在对循环变量进行值的增加的过程中,要使用到加法操作,对应的汇编语言指令为:

在这里插入图片描述

图3.9 运算操作

同时,在汇编语言中,我们还能看到在对栈指针进行的减法操作以及地址加载操作。

3.3.6 函数操作

在C语言中进行函数的调用处理能够增强代码的逻辑性,在汇编语言中具体表现为过程调用。函数的执行大概可以分成以下几个步骤:参数的传递、栈空间的分配、程序计数器PC的变化、函数的执行、返回值的保存、空间的释放。在这个实例中,hello.c总共使用了7个函数,它们分别是:main、printf、puts、exit、sleep、atoi、getchar函数。其中每一个函数都有其对应的参数值和返回值,在调用的时候必须满足参数的条件才能够正常运行。

ICS2019大作业——hello's p2p_第12张图片

图3.10 函数调用的栈帧

3.4 本章小结

本章从编译的角度对hello.i生成hello.s的过程进行了深入的分析,通过对hello的编译的结果解析,了解了在编译过程中的一些主要操作,并对这些操作进行了结果的分析,增强了对于编译的认识。

(第3章2分)

第4章 汇编

4.1 汇编的概念与作用

4.1.1 汇编的概念

汇编就是计算机利用汇编器(as)将编译得到的.s文件进行翻译,将汇编代码翻译成机器语言,并且将得到的结果保存在hello.o文件中,hello.o又称为可重定位目标文件,得到的结果是一个二进制文件。汇编最后的结果就是将翻译成机器语言的结果保存在一个二进制文本文件hello.o的过程。

4.1.2 汇编的作用

汇编能够对编译生成的汇编语言进行翻译,使之能够被机器读懂并在进一步的基础上,对机器代码进行执行。汇编最终得到的是二进制的文本,而机器是只能识别这些二进制代码的代表含义,因此汇编能够真正生成机器能够读懂的指令代码,并将它存储成hello.o文件

4.2 在Ubuntu下汇编的命令

在Linux下进行汇编的具体过程如下:

具体的汇编命令为:linux>as hello.s -o
hello.o

生成hello.o的命令如下:

ICS2019大作业——hello's p2p_第13张图片

图4.1 Linux下生成hello.o的指令

生成的对应文本如下所示:

ICS2019大作业——hello's p2p_第14张图片

图4.2 生成的hello.s文本

4.3 可重定位目标elf格式

分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。

4.3.1 hello.o的ELF格式

首先利用指令readelf
-a hello.o > hello.elf将ELF文件生成在hello.elf文件中,生成指令及生成信息如下所示:

ICS2019大作业——hello's p2p_第15张图片

图4.3 生成hello.elf文件指令

ICS2019大作业——hello's p2p_第16张图片

图4.4 生成的ELF文件

典型的ELF可执行文件中的各类信息如下:

ELF头:描述文件的总体格式,包括程序入口点

段头部表:将连续的文件节映射到运行时内存段

.init:定义了_init函数,程序的初始化代码会调用

.text:已编译程序的机器代码

.rodata:只读数据,如printf语句中格式串,switch语句跳转表

.data:已初始化的全局和静态C变量。局部C变量在运行时被保存在栈中,
既不出现在.data节中,也不出现在.bss节中

.bss:未初始化的全局和静态C变量,以及所有初始化为0的全局或静态变量

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

.debug:一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序
中定义和引用的全局变量,以及原始的C源文件

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

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

节头部表:描述目标文件的节

其中ELF头到.rodata节为只读内存段(代码段),.data节到.bss节为读/写内 存段(数据段),.symtab节到节头部表为不加载到内存的符号表和调试信息

4.3.2 ELF头

ELF头描述了文件的总体格式,包括程序入口点等信息,还包括了ELF头大小、目标文件类型、机器类型、节头部表的文件偏移以及节头部表中条目的大小和数量。以一个16字节序列开始,截图如下:

ICS2019大作业——hello's p2p_第17张图片

图4.5 ELF头

4.3.3 节头部表

节头部表是用来描述目标文件的节,包含了名称、大小、类型、地址、偏移量等信息,截图表示如下:

ICS2019大作业——hello's p2p_第18张图片

图4.6 节头部表

4.3.4 重定位节

在重定位节中,包括了需要进行重定位的信息,当链接器链接目标文件时需要修改的内容。在本次实验中需要重定位的有以下几项,截图如下:

第一个字符串的内容、puts函数、exit函数、第二个字符串的内容、printf函数、atoi函数、sleep函数、getchar函数,它们的详细信息见图4.7的标明。

在重定位节的信息中,包含了有偏移量、重定位的类型、符号值、以及对应的信息。程序调用的本地函数指令地址属于绝对地址,不需要修改重定位后的地址信息。

ICS2019大作业——hello's p2p_第19张图片

图4.7 重定位节

4.3.5 符号表

.symtab是一个符号表,它存放在程序中定义和引用的函数和全局变量的信息,重定位中的符号类型全在该符号表中有声明,包含了大小、类型等信息。截图表示如下:

ICS2019大作业——hello's p2p_第20张图片

图4.8 符号表

4.4 Hello.o的结果解析

使用指令objdump -d -r hello.o > hello_o.s之后能够得到hello.o的反汇编,并保存在hello_o.s中,生成信息截图如下:

ICS2019大作业——hello's p2p_第21张图片

图4.9 生成的hello.o的反汇编文件hello_o.s

将其与hello.s进行比较,分析如下:

两者的操作在很大程度上是相同的,但是可能在栈操作中存在一些差别,立即数的表示由十进制变成了16进制。

增加信息:在hello.o的反汇编代码中,我们能够得到一些增加的信息。在反汇编的代码中,在左侧出现了类似地址的信息,但是这个地址并不是绝对地址,只是相对的地址。其次,在反汇编中,还出现了对应的16进制的代码,这个保存的是相对应的机器指令。同时,在一部分代码下方还出现了一些标注信息,这个是指示重定位的功能。

分支转移:在hello.s中,我们能够发现汇编代码中的分支跳转是直接采用了段名称的表示,例如.L2,.L3等的表示方法,而在机器码中,分支跳转则是明确指向了具体的地址,表示为主函数加上了段内偏移量,截图表示如下:

ICS2019大作业——hello's p2p_第22张图片

图4.10 分支跳转的差别

函数调用:在汇编的代码中,在共享函数的调用时直接书写的是函数名,而在反汇编的结果中,我们可以看到在反汇编的代码中,是以main加上地址偏移构成的,但是在此处有重定义条目的相关信息。由于汇编器无法定位共享函数的位置,因此这些函数需要进一步进行定位,如图:

ICS2019大作业——hello's p2p_第23张图片

图4.11 函数调用

4.5 本章小结

本章从汇编的过程讲述了hello.s到hello.o的生成过程,同时对hello.o的可重定位目标的ELF文件进行了解析,深入分析了在这个过程中hello.o的反汇编与hello.s的不同,对不同的地方进行了深入的分析。

(第4章1分)

第5章 链接

5.1 链接的概念与作用

5.1.1 链接的概念

链接是将各种代码和数据部分收集起来并组合成为一个单一文件的过程,最终生成的是一个可执行的文件,这个文件可被加载到存储器并执行。链接是由叫做链接器的程序执行的。通过链接器,将程序调用的外部函数(.o文件)与当前.o文件以某种方式合并,并得到可执行目标文件。

5.1.2 链接的作用

链接可以执行于编译时,也可以执行于加载时。同时也可以在运行时链接。因此,可以提高程序运行时的时间、空间利用效率。链接器使得分离编译成为可能。而这对于开发和维护大型的程序具有很重要的意义。它能够将巨大的源文件分解成更小的模块,易于管理。可以通过独立地修改或编译这些模块,并重新链接应用,不必再重新编译其他文件。

5.2 在Ubuntu下链接的命令

在Linux下进行链接的完整过程如下:

具体的链接指令为:linux>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/7/crtbegin.o hello.o -lc
/usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z
relro -o hello

对应的命令如下图:

ICS2019大作业——hello's p2p_第24张图片

图5.1 Linux下链接命令

在使用命令之后,生成了一个可执行文件hello,对应信息及执行过程如下:

ICS2019大作业——hello's p2p_第25张图片

图5.2 生成的hello文件信息

图5.3 hello的执行过程

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

使用命令行readelf -a hello > helloexe.elf将可执行文件hello的ELF格式保存在helloexe.elf文件中,对其进行分析如下:

ELF头:ELF头中给出了一个16字节序列,描述了生成该文件系统字的大小和字节顺序。还包括ELF文件的大小,节头部的起始位置,程序的入口地点,目标文件的类型,机器类型,节头部表的文件偏移,以及节头部表中条目的大小与数量等信息。对应信息如下图:

ICS2019大作业——hello's p2p_第26张图片

图5.4 hello的ELF头

节头表:节头表中给出了各节的名称、类型、大小、偏移量、地址以及对其字节

ICS2019大作业——hello's p2p_第27张图片

图5.5 hello的节头表信息

其中各段的起始地址,大小等信息如上图所示。

5.4 hello的虚拟地址空间

首先使用edb对程序进行加载,如下图所示:

ICS2019大作业——hello's p2p_第28张图片

图5.6 edb加载hello执行程序

然后在helloexe.elf文件中找到程序头部表,如图所示:

ICS2019大作业——hello's p2p_第29张图片

图5.7 hello的程序头

在程序头中,我们可以看到,程序包含了8个段,它们的段名及对应功能如下:

PHDR 保存程序头表

INTERP
程序映射到内存后,调用的解释器,包含动态链接器路径

LOAD 程序需要从二进制文件映射到虚拟地址空间的段,保存了常量数据、目标的空间代码等,是可加载的程序段

DYNAMIC
保存动态链接器使用的相关信息

NOTE
存储辅助信息

GNU_STACK 权限标志,标志栈是否可执行

GNU_RELRO 指定重定位后的哪些区域只需要设置只读

在程序头中,我们不难发现,hello执行程序有两个内存段,并且它们对应的权限信息也是不相同的。第一个只有读权限的对应的是一些只读区域;而具有读写权限的是可修改的数据段。使用edb加载之后,在内存区域,我们可以看到就是开始0x400000区域,保存着ELF头文件的16字节信息,截图表示如下:

ICS2019大作业——hello's p2p_第30张图片

图5.8 ELF头信息表示

之后根据地址的分配进行跳转分析,查看进程的虚拟地址空间段的各个信息,发现与5.3中的各个段起始信息一致,例如下图所示的text段和rodata段:

ICS2019大作业——hello's p2p_第31张图片

图5.9 text段和rodata段起始信息

5.5 链接的重定位过程分析

首先输入命令行objdump -d -r hello > helloexe.s将hello的反汇编结果保存到helloexe.s文本中,打开对应的文本截图如下:

ICS2019大作业——hello's p2p_第32张图片

图5.10 部分hello反汇编结果

通过与hello.o的比较,我们能够发现以下的不同:与hello.o的反汇编结果相比,在hello的反汇编结果中增加了.init、.plt、.plt.got、.fini段;同时,在.text代码段中,还另外增加了一些函数,例如_start 等函数;并且在hello.o的反汇编结果中,一些原来是需要重定位的内容被绝对地址取代了,如下图的函数所示:

ICS2019大作业——hello's p2p_第33张图片

图5.11 部分重定位结果

在链接的过程中,为了构造可执行文件,链接器先后完成两个主要任务:符号解析和重定位。通过符号解析,将定义与引用关联起来。链接器维护3个集合,可重定位目标文件的集合,引用了但尚未被定义的集合,在前面输入集合中已被定义的符号集合。初始时,3个集合均为空。链接器会判断命令行上的每一个输入文件,并反映符号定义与引用。一直到引用了尚未定义的集合为空为止,否则就会报错。

结合hello.o的重定位项目getchar函数的重定位,可以对hello进行的重定位进行深入的分析。首先可以由hello的反汇编文件读出getchar的运行地址为0x400510,然后由于偏移量offset在hello.o的反汇编结果中读出为0x83,因此引用时这两个地址需要相加。在hello.o的反汇编结果中,能够看到偏移调整为-0x4,因此可以计算出实际的地址用补码表示为52 fe ff ff。核对与之相符合。

5.6 hello的执行流程

在hello的执行过程中,通过使用edb对hello进行加载,可以列出对应的调用与跳转的子程序名和程序地址如下:

程序名称
程序地址

ld-2.27.so!_dl_start 0x7f5ebf56bea0

ld-2.27.so!_dl_init 0x7f5ebf57a630

hello!_start 0x400480

libc-2.27.so!__libc_start_main 0x7f5ebf19aab0

hello!__libc_csu_init 0x400580

hello!main 0x400620

hello!printf@plt 0x400500

hello!atoi@plt 0x400520

hello!sleep@plt 0x400540

hello!getchar@plt 0x400510

hello!exit@plt 0x400580

5.7 Hello的动态链接分析

程序调用一个由共享库定义的函数时,对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置,所以需要添加重定位记录,等待动态链接器处理。

GNU编译系统使用延迟绑定的技术解决这个问题,将过程地址的延迟绑定推迟到第一次调用该过程时。延迟绑定要用到全局偏移量表(GOT)和过程链接表(PLT)两个数据结构。GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。

如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。PLT是一个数组,其中每个条目是16字节代码。每个条目都负责调用一个具体的函数。GOT是一个数组,其中每个条目是8字节地址。下图表示了在dl_init前后项目的内容变化:
ICS2019大作业——hello's p2p_第34张图片

图5.12 修改之前的内容

ICS2019大作业——hello's p2p_第35张图片

图5.13 修改之后的内容

5.8 本章小结

本章从hello.o的链接过程了解了链接的原理,并且结合具体的链接过程对链接过程中涉及到的虚拟地址空间以及重定位和符号解析进行了深入分析。同时通过hello.o与hello的区别,深入对重定位的过程进行了阐述。最后根据hello的执行流程以及在执行过程中的动态链接分析对hello进行了过程分析。

(第5章1分)

第6章 hello进程管理

6.1 进程的概念与作用

6.1.1 进程的概念

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

6.1.2 进程的作用

进程提供了程序执行过程中的两个关键抽象:一个独立的逻辑控制流,提供一个假象,程序独占地使用处理器CPU;一个私有的地址空间,提供一个假象,程序在独占地使用系统内存。

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

6.2.1 作用

shell是一个交互型的应用级程序,它能够接收用户命令,代表用户运行其他程序,是用户和系统内核沟通的桥梁,它为用户访问操作系统内核提供了一个交互界面。用户可以通过shell向操作系统发出请求,操作系统选择执行命令。

6.2.2 处理流程

首先由用户在命令行对所需要执行的命令进行输入;然后shell读入之后会将读入的字符串进行分割,并且将分割得到的字符串传递给参数数组,获得命令;之后对命令进行判断,如果是内置命令则立即进行执行,如果不是内置命令则调用相应的程序fork和execve等为其分配子进程;之后shell可以接收来自I/O设备等的信号,并且做出相对应的处理。

6.3 Hello的fork进程创建过程

首先在命令行对要执行的应用程序hello进行命令输入,由于程序需要有3个参数,因此输入命令时要将参数一同输入。由于hello并不是内置命令,而是一个可执行的程序,因此会调用fork函数在当前进程中创建一个新进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程最大的区别在于他们有不同的PID。同时父进程与子进程是并发运行的独立的进程,内核以任意的的顺序执行它们的逻辑控制流当中的指令。fork函数调用一次返回两次,通过返回不同的PID提供了区分子进程父进程的一种方法。

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序。函数声明如图所示:

ICS2019大作业——hello's p2p_第36张图片

图6.1 execve的函数声明

由它的函数声明可知,execve函数加载并运行可执行目标文件filename,并且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。正常情况下,execve调用一次,但从不返回。Execve函数直接在当前的进程中删除当前进程中现有的虚拟内存段,并创建一组新的代码、数据、堆和用户栈的段。将栈和堆初始化为0,代码段与数据段初始化为可执行文件中的内容,最后将PC指向_start的地址。根据按需进行页面调度的原则,在CPU开始引用被映射的虚拟页的时候,内核才会将需要用到的数据从磁盘中放入内存中。加载器映射用户地址空间的区域可由下图表示:
在这里插入图片描述
图6.2 加载器对用户地址空间的映射

6.5 Hello的进程执行

shell设置了一些防护措施来保护内核,限制指令的类型和可以作用的范围。处理器通常会在某个控制寄存器中设置一个模式位用来描述当前进程所拥有的特权,当设置了模式位后,进程处于内核模式(超级用户模式),该进程可以执行指令集中任何指令,同时也能访问系统中任何内存位置。这样在一定程度上就限制了用户态和核心态之间的关系。

上下文切换:上下文切换是一种比较高层次的异常控制流。内核为每一个进程维护了一个上下文信息,上下文就是内核重新启动一个被抢占的进程所需要的状态信息,由一组对象的值组成,包括通用目的寄存器,浮点寄存器,程序计数器,用户栈,状态寄存器,内核栈和各种内核数据结构,比如描述地址空间的页表,包含当前进程有关信息的进程表,以及包含进程以打开文件的信息的文件表。上下文切换的过程大致可以概括为以下三部分,保存当前进程的上下文;恢复现在调度进程的上下文;将控制传给新恢复进程。

程序的执行是根据逻辑控制流来处理的,若不发生抢占,则顺序执行,若发生抢占,则当前进程被挂起,控制转移至下一个进程。由于各个进程是并发执行的,每个进程轮流在处理器上执行,一个进程执行它控制流的一部分成为时间分片。同时操作系统内核使用上下文切换的异常控制流来实现上下文切换,为用户态和核心态的切换提供了一种很好的方式。进程的上下文切换过程大致可用下图表示:

ICS2019大作业——hello's p2p_第37张图片

图6.3 进程的上下文切换

6.6 hello的异常与信号处理

异常可分为中断、陷阱、故障、终止等4类情况。在hello的执行过程中,可能会遇到外部信号的中断,如来自键盘的不断的输入。也可能会遇到系统调用的陷阱,例如在调用sleep函数的过程中出现的上下文切换。或者是在加载运行过程中遇到的缺页故障等,以及收到外部的终止信号,这些情况在hello的执行过程中都有可能会发生。而hello产生的信号,在发送出去后,会由相应的信号处理程序进行不同的处理。下面是对键盘输入的情况显示:

  1. 键盘不停乱按(包括回车),程序能够继续执行,将屏幕输入缓存到了stdin中,当读入回车时,将当作命令读入

图6.4 键盘乱按输入

  1. 从键盘按入Ctrl-C,程序终止

图6.5 键盘输入Ctrl-C

  1. 从键盘按入Ctrl-Z,程序暂停,被挂起

图6.6 键盘输入Ctrl-Z

  1. 在程序暂停之后,通过ps指令、jobs指令、pstree指令能够发现hello进程在后台存在,并且处于暂停态

图6.7 暂停之后运行ps、jobs、pstree等指令

  1. 通过运行fg指令将进程调成前台继续执行

图6.8 暂停之后调用fg指令

  1. 使用kill指令将hello进程杀死

图6.9 调用kill指令将hello杀死

6.7本章小结

在本章中,通过对hello的进程管理,具体描述了hello是如何在计算机进行执行的。在计算机中,程序的运行是以进程的形式抽象出来的,每一个进程都处在某一个进程的上下文中,同时每一个进程也有自己的进程上下文。进程之间的切换通过上下文之间的切换来具体进行实现。同时介绍了计算机如何为程序分配一个进程,以及如何对进程进行处理。在进程的运行中,可能会遇到不同种类的异常,通过不同的异常处理程序能够对其进行解决。同时运行的过程中也会发生信号的传送,通过不同的信号处理程序进行处理。通过对hello的进程管理,对进程的全过程以及异常和信号有了深刻的认识。

(第6章1分)

第7章 hello的存储管理

7.1 hello的存储器地址空间

逻辑地址:逻辑地址又称为相对地址,是内部和编程使用的,和实际的物理地址不相干,是由一个段标识符加上一个指定段内相对地址的偏移量组成,可以表示为[段标识符:段内偏移量],是由CPU产生的与段相关的偏移地址部分,是描述一个程序运行段的地址。

线性地址:线性地址是逻辑地址向物理地址转化的过程中的一步,是分页机制之前的地址。某地址空间中的地址是连续的非负整数时,该地址空间中的地址被称为线性地址。是经过段机制转化之后用于描述程序分页信息的地址。它是对程序运行区块的一个抽象映射。以hello为例,线性地址就是说明hello运行的内存块。

虚拟地址:CPU在寻址的时候,是按照虚拟地址来寻址,然后通过内存管理单元将虚拟地址转换为物理地址。也是对程序运行区块的一个映射。

物理地址:计算机主存被组织成由M个连续字节大小的内存组成的数组,每个字节都有一个唯一的地址,该地址被称为物理地址。它是程序运行时加载到内存地址寄存器中的地址,是内存单元的真正地址。它是在前端总线上传输的而且是唯一的。在hello中,表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。

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

段式管理可以分为两种模式。总体来说,一个段描述符由8个字节组成。它描述了段的特征,可以分为GDT和LDT两类。通常来说系统只定义一个GDT,而每个进程如果需要放置一些自定义的段,就可以放在自己的LDT中。在Linux系统中,每个CPU对应一个GDT。一个GDT中有18个段描述符和14个未使用或保留项。其中用户和内核各有一个代码段和数据段,然后还包含一个TSS任务段来保存寄存器的状态。在段式管理对应的两种模式下:在实模式中,逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。而在保护模式中,线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。

同时,段寄存器用于存放段选择符,通过段选择符可以得到对应段的首地址。处理器在通过段式管理寻址时,首先通过段描述符得到段基址,然后与偏移量结合得到线性地址,从而得到了虚拟地址。具体的数据段、代码段、系统段描述符如下图所示:

ICS2019大作业——hello's p2p_第38张图片

图7.1 数据段、代码段、系统段描述符

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

线性地址到物理地址的转换是通过页的这个概念完成的。线性地址被分为以固定长度为单位的组,称为页。页式管理将各进程的虚拟空间划分成若干个长度相等的页,把内存空间按页的大小划分成片或者页面,然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。页式管理分为静态页式管理和动态页式管理。其中,虚拟内存有其对应的组织结构,如下图所示。任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态。其中pgd指向第一级页表(页全局目录)的基址,而mmap指向一个vm_area_structs(区域结构)的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域。当内核运行这个进程时,就将pgd存放在CR3控制寄存器中。

ICS2019大作业——hello's p2p_第39张图片

图7.2 虚拟内存的组织

在虚拟内存中,对应的线性地址到物理地址的变换可由下图表示:

ICS2019大作业——hello's p2p_第40张图片

图7.3 使用页表的地址翻译

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

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

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

TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。TLB通常有高度的相联度。虚拟地址中访问TLB的组成部分如下图所示:

在这里插入图片描述

图7.4 虚拟地址访问TLB的组成部分

所有的地址翻译步骤都是在芯片上的MMU中执行的,因此非常快。当TLB不命中时,MMU必须从L1缓存中取出相应的PTE。新取出的PTE存放在TLB中, 可能会覆盖一个已经存在的条目。下图表示的是TLB命中和不命中的示意图:

ICS2019大作业——hello's p2p_第41张图片

图7.5 TLB命中和不命中的示意图

一级页表中的每个PTE负责映射虚拟地址空间中一个4MB的片,这里每一片都是由1024个连续的页面组成的。比如,PTE 0映射第一片,PTE 1映射接下来的一片, 以此类推。假设地址空间是4GB,1024个PTE已经足够覆盖整个空间了。二级页表中的每个PTE都负责映射一个4KB的虚拟内存页面,就像我们查看只有一级的页表一样。注意,使用4字节的PTE,每个一级和二级页表都是4KB字节,这刚好和一个页面的大小是一样的。下图表示一个两级页表层次结构:

ICS2019大作业——hello's p2p_第42张图片

图7.6 一个两级页表层次结构

使用k级页表层次结构的地址翻译。虚拟地址被划分成为k个VPN和1个VPO。每个VPN i都是一个到第i级页表的索引,其中l≤i≤k。第j级页表中的每个PTE,1≤j≤k-1,都指向第j+1级的某个页表的基址。第k级页表中的每个PTE包含某个物理页面的PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定PPN之前,MMU必须访问k个PTE。对于只有一级的页表结构,PPO和VPO是相同的。使用k级页表的地址翻译如图所示:

ICS2019大作业——hello's p2p_第43张图片

图7.7 使用k级页表的地址翻译

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

首先我们以Core i7的内存系统为例,下图表示了Core i7的内存系统:

ICS2019大作业——hello's p2p_第44张图片

图7.8 Core i7的内存系统

在CPU得到真实的物理地址之后,CPU就要将实际的物理进行地址的访存,由于采用的是三级的cache,因此地址翻译可由下图表示:

ICS2019大作业——hello's p2p_第45张图片

图7.9 地址翻译的过程

首先,将得到的物理地址按照cache的参数进行划分,S、E、B分别是多少位。之后,在得到相对应的组索引之后,去寻找是否有对应的标记,如果标记匹配并且有效的话,就从一级cache中根据偏移取出数据。如果一级cache不命中,就从L2、L3、主存中寻址。

7.6 hello进程fork时的内存映射

Linux通过将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射。虚拟内存区域可以映射到两种类型的对象中的一种:Linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行目标文件。文件区被分成页大小的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到CPU第一次引用到页面。如果区域比文件区要大,那么就用零来填充这个区域的余下部分。匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面井更新页表,将这个页面标记为是驻留在内存中的。

当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

7.7 hello进程execve时的内存映射

在使用execve加载hello进程时,需要以下几个步骤:

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

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

ICS2019大作业——hello's p2p_第46张图片

图7.10 映射用户地址空间

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

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

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

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

缺页处理程序搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果这个指令是不合法的,那么缺页处理程序就触发一个段错误, 从而终止这个进程。

如果试图进行的访问是不合法的,那么缺页处理程序会触发一个保护异常,从而终止这个进程。

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

上述过程可用如下图形表示:

ICS2019大作业——hello's p2p_第47张图片

图7.11 Linux缺页处理

7.9动态存储分配管理

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

ICS2019大作业——hello's p2p_第48张图片

图7.12 堆示意图

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

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

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

隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器,而自动释放未使用的已分配的块的过程叫做垃圾收集。

动态内存管理中对于空闲块管理的基本方法有以下几种:

隐式空闲链表:任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块。空闲块是通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有的块,从而间接地遍历整个空闲块的集合。它的堆块示意图如图所示:

ICS2019大作业——hello's p2p_第49张图片

图7.13 隐式空闲链表的组织块

放置已分配块:当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是首次适配、下一次适配和最佳适配。首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配很相似,只不过不是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。

分割空闲块:一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,那就是分配这个空闲块中多少空间。一个选择是用整个空闲块。虽然这种方式简单而快捷,但是主要的缺点就是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。然而,如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,而剩下的变成一个新的空闲块。

合并空闲块:当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲块相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片,就是有许多可用的空闲块被切割成为小的、无法使用的空闲块。实际的分配器都必须合并相邻的空闲块,这个过程称为合并。这就出现了一个重要的策略决定,那就是何时执行合并。分配器可以选择立即合并,也就是在每次一个块被释放时,就合并所有的相邻块。或者它也可以选择推迟合并,也就是等到某个稍晚的时候再合并空闲块。

显式空闲链表:将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。如下图所示:

ICS2019大作业——hello's p2p_第50张图片

图7.14 显式空闲链表结构

分离的空闲链表:分配器维护着一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为n的块时,它就搜索相应的空闲链表。如果不能找到合适的块与之匹配,它就搜索下一个链表。

7.10本章小结

在本章中,通过对hello的存储管理,深刻认识到了虚拟内存的相关知识。通过对逻辑地址、线性地址、虚拟地址、物理地址的区别,从地址转换的角度认识到了计算机中程序是如何进行执行的。通过对地址转换以及地址管理,加上内存映射,重新深入理解了fork函数和execve函数的功能。同时,通过对缺页故障的处理,以及对动态内存的分配,加深了对内存管理的认识。

(第7章 2分)

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

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

设备的模型化:文件

设备管理:unix IO接口

8.2 简述Unix
IO接口及其函数

8.2.1 Unix IO接口

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

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

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

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

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

8.2.2 Unix IO 函数

打开和关闭文件:进程是通过调用open函数来打开一个己存在的文件或者创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:O_RDONLY:只读、O_WRONLY:只写、O_RDWR:可读可写。flags参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示。mode参数指定了新文件的访问权限位。作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置为mode&~umask。

在这里插入图片描述

图8.1 open函数的声明

ICS2019大作业——hello's p2p_第51张图片

图8.2 访问位权限

最后,进程通过调用close函数关闭一个打开的文件。关闭一个已关闭的描述符会出错。close函数的声明如下图所示:

在这里插入图片描述

图8.3 close函数的声明

读和写文件:应用程序是通过分别调用read和write函数来执行输入和输出的。read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值0表示EOF。否则,返回值表示的是实际传送的字节数量。write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。通过调用lseek函数,应用程序能够显示地修改当前文件的位置。在某些情况下, read和write传送的字节比应用程序要求的要少。这些不足值不表示有错误。关于read和write函数的声明如下图所示:

在这里插入图片描述

图8.4 read和write的函数声明

8.3 printf的实现分析

printf是C语言中用来实现打印输出功能的一个函数,首先它的函数体如下图所示:

ICS2019大作业——hello's p2p_第52张图片

图8.5 printf函数的函数体

在形参列表里有一个token:…,这个是可变形参的一种写法。当传递参数的个数不确定时,就可以用这种方式来表示。C语言中,参数压栈的方向是从右往左。也就是说,当调用printf函数的时候,先是最右边的参数入栈。fmt是一个指针,这个指针指向第一个const参数(const char
*fmt)中的第一个元素。fmt也是个变量,它的位置,是在栈上分配的,它也有地址。对于一个char *类型的变量,它入栈的是指针,而不是这个char *型变量。接下来看一下vsprintf函数,如下图:

ICS2019大作业——hello's p2p_第53张图片

图8.6 vsprintf函数

vsprintf的作用就是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。write的实现如下:
ICS2019大作业——hello's p2p_第54张图片

图8.7 write函数的跟踪

这里是给几个寄存器传递了几个参数,然后以一个int结束,这样的int表示要调用中断门。通过中断门,来实现特定的系统服务。最后看一下sys_call的实现:

ICS2019大作业——hello's p2p_第55张图片

图8.8 sys_call的实现

其实在此处,sys_call的功能就是显示格式化了的字符串。总体而言,printf的实现可由以下说明进行概括:

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

8.4 getchar的实现分析

有关getchar的一个宏定义如下:#define getchar() getc(stdin)。getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键。用户输入的字符被存放在键盘缓冲区中,直到用户按回车为止(回车字符也放在缓冲区中)。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符,getchar函数的返回值是用户输入的第一个字符的ASCII码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。

在一定程度上,可以由以下概括理解。异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。

8.5本章小结

在本章中,通过对hello的IO管理,介绍了linux的IO设备管理,了解了文件是对IO设备的抽象。同时,通过对unix
IO接口的阐述,分析了unix
IO的相关函数及其如何具体实现。同时,对printf函数和getchar函数进行了深入分析,了解了它们具体的实现原理,不仅仅只是在调用层面对它们进行分析。通过对系统级IO的深入认识,提高了对底层工作的认识。

(第8章1分)

结论

  1. Hello所经历的过程

Hello从开始编写的源程序开始,需要经过4个大步骤:预处理、编译、汇编、链接4个过程得到一个可在内存中被直接执行的可执行文件。然后在执行的过程中,由shell新建进程对Hello进行执行,在这个过程中,操作系统会调用fork产生子进程Hello成为进程的开始。然后通过execve将其加载,不断进行访存、内存申请等操作。在执行的过程中,hello可以对异常进行处理,同时也可以发出信号。最后,在程序结束返回后,由父进程或祖先进程进行回收,程序结束。

  1. 深切感悟与理念

Hello的程序执行在没有深入对其进行了解时,你会觉得它就是简单的编写程序,然后在机器上输入./hello就能直接执行。但是,实际上,在hello的一生的执行过程中,它涉及到了相当多的不同的处理,通过不断地对过程简化,最后呈现出来的形式就是显示在屏幕前的那句话:Hello!

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

附件

文件名 文件作用

hello.c 存储C源代码

hello.i C源代码预处理之后的结果

hello.s 编译生成的汇编代码

hello.o 编译生成的可重定位文本

hello_o.s hello.o的反汇编文本

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

hello 可执行文本hello

helloexe.elf 可执行文本hello的ELF文件格式

helloexe.s 可执行文本hello的反汇编文本

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

参考文献

[1] Randal
E.Bryant,David
O’Hallaron.深入理解计算机系统(原书第3版)[M].机械工业出版社:北京,2016.

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

[3] https://blog.csdn.net/qq_21125183/article/details/80570585

[4] https://blog.csdn.net/scanferror/article/details/78647870

[5] 马旭东.读《深入理解计算机系统》有感[J].计算机教育,2011(11):115-116.

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

你可能感兴趣的:(ICS2019大作业——hello's p2p)