程序人生-Hello‘s P2P

ICS2019大作业 程序人生-Hello‘s P2P

计算机系统

大作业

题 目 程序人生-Hello’s P2P
专 业 软件工程
学   号 1183710115
班   级 1837101
学 生 曲智圣    
指 导 教 师 史先俊

计算机科学与技术学院
2019年12月

摘 要
本文的目的是结合计算机系统,在Ubuntu系统下,并使用gcc、edb等工具,研究hello这一C程序的整个生命周期。以hello.c源程序为起点,从预处理、编译、链接再到加载、运行,最后终止和回收。更加深入地理解《深入理解计算机系统》一书的和老师课上所讲述的内容,自己亲手实践对程序进行各个步骤的操作,顺着hello的生命周期,漫游计算机系统,把计算机系统的整个体系串联在一起。

关键词:预处理;编译;汇编;链接;进程管理;存储管理;IO管理;

目 录

第1章 概述 - 4 -
1.1 HELLO简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 5 -
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 本章小结 - 14 -
第4章 汇编 - 16 -
4.1 汇编的概念与作用 - 16 -
4.2 在UBUNTU下汇编的命令 - 16 -
4.3 可重定位目标ELF格式 - 16 -
4.4 HELLO.O的结果解析 - 20 -
4.5 本章小结 - 22 -
第5章 链接 - 23 -
5.1 链接的概念与作用 - 23 -
5.2 在UBUNTU下链接的命令 - 23 -
5.3 可执行目标文件HELLO的格式 - 23 -
5.4 HELLO的虚拟地址空间 - 26 -
5.5 链接的重定位过程分析 - 27 -
5.6 HELLO的执行流程 - 30 -
5.7 HELLO的动态链接分析 - 31 -
5.8 本章小结 - 32 -
第6章 HELLO进程管理 - 33 -
6.1 进程的概念与作用 - 33 -
6.2 简述壳SHELL-BASH的作用与处理流程 - 33 -
6.3 HELLO的FORK进程创建过程 - 33 -
6.4 HELLO的EXECVE过程 - 34 -
6.5 HELLO的进程执行 - 34 -
6.6 HELLO的异常与信号处理 - 35 -
6.7本章小结 - 42 -
第7章 HELLO的存储管理 - 43 -
7.1 HELLO的存储器地址空间 - 43 -
7.2 INTEL逻辑地址到线性地址的变换-段式管理 - 43 -
7.3 HELLO的线性地址到物理地址的变换-页式管理 - 45 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 46 -
7.5 三级CACHE支持下的物理内存访问 - 47 -
7.6 HELLO进程FORK时的内存映射 - 48 -
7.7 HELLO进程EXECVE时的内存映射 - 48 -
7.8 缺页故障与缺页中断处理 - 49 -
7.9动态存储分配管理 - 51 -
7.10本章小结 - 53 -
第8章 HELLO的IO管理 - 54 -
8.1 LINUX的IO设备管理方法 - 54 -
8.2 简述UNIX IO接口及其函数 - 54 -
8.3 PRINTF的实现分析 - 55 -
8.4 GETCHAR的实现分析 - 58 -
8.5本章小结 - 58 -
结论 - 59 -
附件 - 61 -
参考文献 - 62 -

第1章 概述
1.1 Hello简介
P2P(From Program to Progress)的过程:首先得到使用高级语言编写的hello.c文件,通过I/O设备经过总线存入主存。然后GCC编译器驱动程序读取源程序文件hello.c,经过C预处理器cpp预处理得到一个ASCII码的中间文件hello.i,接下来驱动程序运行C编译器ccl将hello.i翻译成一个ASCII汇编语言文件hello.s,之后驱动程序运行汇编器as将hello.s翻译成一个可重定位目标文件hello.o,最后运行链接器ld和标准的C库进行链接生成hello(可执行目标文件)。这时的hello就是一个程序,在shell里面(bash)键入命令./hello后,shell将字符逐一读取后,调用fork函数创建一个新运行的子进程,该子进程就是父进程shell的一个复制,然后以从我们输入的命令中得到的一些变量作为参数,该调用execve函数启动加载器。加载器删除子进程现有的虚拟内存段,然后使用mmap函数创建新的内存区域,创建一组新的代码、数据、堆和栈段。新的栈和堆段被初始化为零。通过将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk),新的代码和数据段被初始化为可执行文件的内容。最后加载器跳转到_start地址,它最终会调用应用程序的main函数。然后程序从内存中读取指令,在CPU硬件单元中完成指令的实现和数据的读写修改。现代流水线化的系统中,多条指令乱序并行执行而又呈现出一种简单的顺序执行指令的表象。最后变成一个进程运行在内存中。从Program(可执行程序)变为Process(进程)。即P2P。
020的过程:开始shell的状态看作0,操作系统调用execve后映射虚拟内存,进入程序入口后程序开始载入物理内存,再进入main函数执行代码,程序执行完成后,进程会保持一种已终止状态,直到父进程shell回收hello进程,内核删除相关数据结构,shell会回到hello执行之前的状态,也就是0的状态。即020。
1.2 环境与工具
硬件环境:
X64 CPU;2.20GHz;12.0GB RAM;
软件环境:
Windows10 64位;Vmware 15;Ubuntu 19.04 LTS 64位;
开发工具:
Gcc;gedit;CodeBlocks;gdb;edb;readelf;
1.3 中间结果
hello.c:使用C语言编写的源程序文件。
hello.i:hello.c经预处理得到的ASCII码的中间文件。
hello.s:hello.i编译之后得到的一个ASCII汇编语言文件。
hello.o:hello.s汇编之后得到的一个可重定位目标文件。
hello:hello.s和标准的C库进行链接得到的可执行目标文件。
hello.o_obj:hello.o的反汇编代码。
hello_obj:hello的反汇编代码。
hello.elf:hello.o的elf格式文件。
hello_1.elf:hello的elf格式文件。
1.4 本章小结
本章简述了hello的P2P和020的整个过程过程、实验的硬件软件环境和开发工具。也列出了该篇论文完成所需要生成的一些中间文件,为接下来的环节铺垫基础。

第2章 预处理
2.1 预处理的概念与作用
2.1.1.概念:预处理发生在编译之前,根据c文件中以#开头的命令,预处理器(cpp)对c文件进行修改,典型地,由预处理器对程序源代码文本进行处理,得到的结果再由编译器核心进一步编译。这个程序并不对程序的源代码进行解析,但它把源代码分割或处理成为特定的单位,预处理的结果通常是以.i作为文件扩展名。
2.1.2.作用:预处理主要处理那些以#开头的预编译指令。
1) 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。该过程递归进行(被包含的文件可能包含其他文件);
2) 将所有的#define删除,并且执行宏替换;
3) 处理所有的条件编译指令。根据#if 、#endif后面的条件决定需要编译的代码;
4) 删除所有注释;
5) 添加行号和文件标识
6) 特殊控制:定义了特殊的预处理指令,如#error #pragma等。
2.2在Ubuntu下预处理的命令
预处理命令:cpp hello.c > hello.i
预处理过程:
在这里插入图片描述
图2.1 预处理过程截图
输入预处理命令后,文件夹中的文件由原来只有hello.c变成了hello.c与hello.i,所以hello.i即为预处理后的文件。
2.3 Hello的预处理结果解析
在这里插入图片描述
图2.2 预处理文件hello.i中部分内容截图
在这里插入图片描述
图2.3 预处理文件hello.i中部分内容截图
由上图可以看出,预处理后的文件是一个3042行的c语言文件,其中3027行之后的内容对应着hello.c中第10行之后的内容。之前的内容是头文件stdio.h、unistd.h、stdlib.h 的依次展开。而且注释都被删除了,也对原文件中的宏进行了宏展开,添加了行号,编译指令被保留。
2.4 本章小结
本章叙述了预处理的概念、作用和执行预处理的指令,展示了预处理后的结果并且进行了分析,可以加深我们对于预处理的理解。

第3章 编译
3.1 编译的概念与作用
3.1.1.概念:编译器ccl把预处理后的文本文件.i文件进行一系列语法分析及优化后生成相应的汇编语言文件.s文件的过程。.s文件中包含一个汇编语言程序。

3.1.2.作用:把代码翻译成汇编语言(过程:1.词法分析、2.语法分析、3.语义分析、4.源代码优化、5.代码生成目标代码优化)
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
编译命令:gcc -S hello.i -o hello.s
在这里插入图片描述
图3.1 编译过程截图
输入编译命令后,文件夹中文件由原来hello.c和hello.i变成了hello.c、hello.i和hello.s,所以hello.s即为编译后得到的文件。
3.3 Hello的编译结果解析
3.3.1.文件声明:
在这里插入图片描述
图3.2 hello.s文间声明部分
.file:源文件;
.text:代码段;
.section .rodata:只读代码段;
.align:对其格式;
.string:字符串;
.globl:全局变量;
.type:指定是对象类型或是函数类型;
.size:数据空间大小;

3.3.2.数据:
hello.s文件中的主要数据类型有:整型、字符串、指针数组。

  1. 整型:
    (1) main的参数argc:
    argc是函数传入的第一个int型参数,用来表示终端输入参数个数。如图3.3所示,argc作为第一个参数,存储在寄存器%edi中,然后又被存入-20(%rbp)。
    在这里插入图片描述
    图3.3 argc的声明
    (2) main函数内部的局部变量int i:
    函数内部的局部变量i一般会被编译器存储在寄存器或者程序栈中,它没有标识符,也不需要被声明,而是直接使用。如图3.4所示,由语句movl $0, -4(%rbp)可知,i被存储在-4(%rbp)中,初始值为0,占用了4字节大小的栈空间。
    在这里插入图片描述
    图3.4 局部变量i的声明

  2. 字符串:
    在这里插入图片描述
    图3.5 字符串的声明
    由图3.5可知,第一个字符串.LC0包含汉字,每个汉字再utf-8编码中被编码为三个字节,第二个字符串的两个%s为用户在终端输入的两个参数:argv[1]和argv[2]。这两个字符串都存在只读数据段中。

  3. 指针数组argv[]:
    作为main函数的参数char *argv[]出现在栈帧中。
    3.3.3.运算与操作:

  4. 赋值操作:
    1) 对局部变量i赋值:
    在循环开始时将i赋值为0,如图3.6所示,对给i赋值0是通过mov语句来实现的。
    在这里插入图片描述
    图3.6 i的赋值

  5. 算术操作:
    hello.c中的主要算术操作为循环变量的自增(i++)。
    1) i++,如图3.7所示,编译器将i++翻译成addl $1, -4(%rbp)。
    在这里插入图片描述
    图3.7 局部变量i的自增操作(i++)

  6. 关系操作:
    hello.c中的关系操作有argc!=4语句和作为循环变量的控制(i<8)。
    1) argc!=4:如图3.8所示,编译器将argc!=4翻译成cmpl $4,-20(%rbp),这样就实现了argc!=4的关系判断。
    在这里插入图片描述
    图3.8 argc!=4
    2) i<8:如图3.9所示,编译器将i<8翻译成cmpl $7,-4(%rbp)。这样就实现了i<8的比较。
    在这里插入图片描述
    图3.9 关系操作i<8

  7. 数组/指针/结构操作:
    1)指针数组(char *argv[]):
    在argv数组中,argv[0]指向输入程序的路径和名称,argv[1]和argv[2]分别指向两个用户从终端输入的字符串。如图3.10所示,通过%rax+16和%rax+24中存储的地址,(语句addq $16,%rax以及addq $8,%rax)分别得到argv[1]和argv[2]。
    在这里插入图片描述
    图 3.10

3.3.4.控制转移:
hello.c文件中的控制转移有:if语句(if(argc!=4))和for循环语句(for(i=0;i<8;i++))。
1) if(argc!=4):如图3.11所示当argc不等于3时进行跳转。语句cmpl $4,-20(%rbp)比较-20(%rbp)中的内容与4,并设置了条件码,然后根据ZF进行判断,如果最近操作得到的结果为0,则跳转到.L2中,否则顺序执行下一条语句。
在这里插入图片描述
图3.11 if(argc!=4)

2) for(i=0;i<8;i++):如图3.12所示,for循环的控制时,语句cmpl $7,-4(%rbp),比较-4(%rbp)中的内容和7,设置条件码,当i大于7时跳出循环,否则进入.L4循环体内部执行。
在这里插入图片描述
图3.12 for(i=0;i<8;i++)

3.3.5.函数调用:
hello.c文件中总共有五个函数调用,如图3.13所示,分别是:main()、printf()、exit()、sleep()、atoi()、getchar()。
在这里插入图片描述
图3.13 hello.c中调用的函数

1) main函数:
参数传递:第一个参数是argc(int型),第二个参数是argv[](char *型),分别存放在寄存器%rdi和%rsi中;
函数调用: main函数被系统启动函数 __libc_start_main调用,call指令将main函数的地址分配给%rip,随后调用main函数;
函数返回:返回值为0(return 0),保存在寄存器%eax中,由它返回;
函数作用:作为程序运行的唯一入口。
2) printf()函数:
参数传递:call puts时只传入了字符串参数首地址;for循环中call printf时传入了 argv[1]和argc[2]的地址;
函数调用:第一个printf()由call puts@PLT调用,第二个printf()由call printf@PLT调用;
函数作用:用来打印信息。
3) exit()函数:
参数传递:传入一个布尔变量;
函数调用:call exit@PLT call调用exit;
函数作用:如果传入参数为1,则执行退出命令。
4) sleep()函数:
参数传递:传入参数atoi(argv[3]);
函数调用:call sleep@PLT调用sleeep;
函数作用:使计算机程序(进程,任务或线程)进入休眠。
5) atoi()函数:
参数传递:传入argv[3](字符串);
函数返回:返回值为一个int类型,由输入字符作为数字解析而生成,如果该输入无法转换为该类型的值,则atoi的返回值为0;
函数作用:把字符串转换成整型数。
6) getchar()函数:
参数传递:无;
函数调用:call getchar@PLT调用getchar;
函数返回:返回值为一个int型数值,如果输入出错,则返回-1;
函数作用:用来读取字符串。

3.4 本章小结
本阶段完成了对hello.i的编译工作。使用Ubuntu下的编译指令可以将其转换为.s汇编语言文件。本章还通过与源文件C程序代码进行比较,分析了编译器是怎么处理C语言的各个数据类型以及各类操作的,完成了对汇编代码的解析工作。完成该阶段转换后,可以进行下一阶段的汇编处理。

第4章 汇编
4.1 汇编的概念与作用
4.1.1.概念:汇编即由汇编器将程序由汇编语言转化为计算机能够读取的二进制机器语言,文件形式由.s变为.o文件,.o文件是可重定位目标文件,包含程序的指令编码,程序又更容易被计算机所理解。

4.1.2.作用:实现将汇编代码转换为机器指令,使之在链接后能够被计算机直接执行。

注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
汇编命令:gcc -c hello.s -o hello.o
在这里插入图片描述
图4.1 汇编过程截图
输入汇编命令后,文件夹中文件由原来hello.c、hello.i和hello.s变成了hello.c、hello.i、hello.s和hello.o,所以hello.o即为汇编后得到的可重定位目标文件。
4.3 可重定位目标elf格式
4.3.1.elf文件(hello.elf)生成:
使用readelf获得hello.o的elf格式,命令readelf -a hello.o > hello.elf。得到hello.elf如图4.2所示。
在这里插入图片描述
图4.2 生成hello.elf文件的过程
典型地ELF可重定位文件格式:
名称 作用

ELF:头 描述了生成该文件的系统的大小和字节顺序以及帮助链接器语法分析和解释目标文件的信息
.text :已编译的程序的机器代码
.rodata: 只读数据
data: 已初始化的全局和静态C变量
.bss: 未初始化的全局和静态C变量
.symtab: 一个符号表,存放在程序中定义和引用的函数和全局变量的信息
.rel.text: .text节的重定位记录表
.rel.data: 被模块引用或定义的所有全局变量的重定位信息
.debug: 一个调试符号表
.line: 原始C源程序的行号和.text节中机器指令之间的映射
.strtab: 一个字符串表
节头部表: 每个节的偏移量大小

接下来分析 可重定位文件 hello.o 的 elf 格式。
4.3.2.ELF头:
如图4.3所示,ELF头描述了生成该文件的系统的字的大小和字节顺序,并且包含帮助链接器语法分析和解释目标文件的信息。数据采用补码表示,小端法。有13个节头表,每个占64bytes。
在这里插入图片描述
图4.3 ELF头

4.3.3.节头:
节头部表描述了不同节的位置和大小,其中目标文件中每个节都有一个固定大小的条目。如图4.4所示,为具体的描述包括各节的名称、类型、大小、地址和偏移量以及他们可以进行的操作等。
在这里插入图片描述
图4.4 节头
4.3.4.重定位节:
连接器在处理目标文件时,须要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置。这些重定位的信息都记录在ELF文件表里面,对于每个须要重定位的代码段和数据段,都会有一个相应的重定位表,例如 .rel.text 表对应.text段。也就是说,重定位表记录了须要被重定位的地址都在相应段的哪些地方。如图4.5所示,展示了.rela.text的详细信息。
在这里插入图片描述
图4.5 重定位节

4.3.5.符号表:
符号表用来存放在程序中定义和引用的函数和全局变量的信息,如图4.6所示为该符号表详细信息和内容。
在这里插入图片描述
图4.6 符号表
4.3.6.总结:
由于是可重定位目标文件,所以每个节都从0开始,用于重定位。在文件头中得到节头表的信息,然后再使用节头表中的字节偏移信息得到各节在文件中的起始位置,以及各节所占空间的大小。同时可以观察到,代码段是可执行的,但是不能写;数据段和只读数据段都不可执行,而且只读数据段也不可写。
4.4 Hello.o的结果解析
在这里插入图片描述
在这里插入图片描述
图4.7 objdump -d -r hello.o指令运行结果
4.4.1.hello.o的结果查看:
如图4.7所示。
4.4.2.结果与第三章的比较分析:
在未打开hello.s文件之前,我们可以发现我们本章生成的结果左侧有着一些16进制数,这些就是机械语言,对应着右侧的反汇编代码,接着我们打开第三章生成的hello.s文件进行详细的比较分析。
在这里插入图片描述
图4.8 两文件对比(前者是可重定位目标文件,后者是hello.s)
1. 操作数:
如图4.8所示,hello.o反汇编代码中的操作数是十六进制,hello.s中的操作数是十进制。
2. 分支转移:
如图4.8所示,跳转语句中,hello.o的反汇编代码中跳转指令后跟着的是相对偏移的地址,而hello.s中的跳转语句后跟着的是.L2、.L3这类的段名称。
3. 函数调用:
如图4.8所示,在hello.o的反汇编代码中call指令之后是函数的相对偏移地址,因为只有经过链接重定位得到可执行目标文件后才能确定函数运行执行的地址,因此在.rel.text节中为其添加了重定位条目。而在hello.s中,call指令后跟着的直接是函数名称。

4.5 本章小结
本章完成了对hello.s的汇编工作,使用汇编器生成hello.o的可重定位目标文件。汇编器将汇编语言转化成机器语言,机器语言是用二进制代码表示的计算机能直接识别和执行的一种机器指令的集合。它是计算机的设计者通过计算机的硬件结构赋予计算机的操作功能。机器语言具有灵活、直接执行和速度快等特点。
一条指令就是机器语言的一个语句,它是一组有意义的二进制代码,指令的基本格式如,操作码字段和地址码字段,其中操作码指明了指令的操作性质及功能,地址码则给出了操作数或操作数的地址。
通过与hello.s文件的比较,,了解二者的差别,发现各阶段所发生的变化,更加深入的理解计算机的运行原理。

第5章 链接
5.1 链接的概念与作用
5.1.1.概念:链接是将各种代码和数据片段收集并组合成为一个单一可执行目标文件(hello)的过程,这文件可被加载到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时,也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
5.1.2.作用:使得分离编译成为可能,不用将大型的应用程序组织成为一个巨大的源文件,而是可以将它分为更小,更容易管理的模块,可以独立的修改和编译这些模块。当我们改变其中的一个时,只要重新编译它,再链接上去应用就可以了。
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
在这里插入图片描述
图5.1 链接过程截图
输入汇编命令后,文件夹中文件由原来hello.c、hello.i、hello.s、hello.o和hello.elf变成了hello.c、hello.i、hello.s、hello.o、hello.elf和hello,所以hello即为链接后得到的可执行目标文件。
5.3 可执行目标文件hello的格式
使用命令:readelf -a hello > hello_1.elf,生成hello程序的ELF格式文件。
在这里插入图片描述
图5.2 生成hello_1.elf的过程

可执行文件hello的格式类似于可重定位目标文件的格式,接下来进行具体格式的分析。

5.3.1.ELF头:
如图5.3所示,ELF头描述文件的总体格式。它还包括程序的入口点,也就是当程序运行是要执行的第一条指令的地址。
在这里插入图片描述
图5.3 ELF头
5.3.2.节头:
节头部表(Section Headers)对hello的各个节的信息做了说明,包括各段的起始地址,大小,类型,偏移、对齐要求等信息,具体信息如图5.4所示。
在这里插入图片描述
图5.4 节头
5.3.3.helloELF格式中的其他节:
.text、.rodata和.data节与可重定位目标文件中的节是相似的,除了这些节已经被重定位到他们最终的运行时内存地址以外。
.init节定义了一个小函数,叫做_init,程序的初始化代码会调用它。
因为可执行文件是完全链接的(已被重定位),所以它不再需要.rel节。
5.4 hello的虚拟地址空间
在这里插入图片描述
图5.5
在这里插入图片描述
图5.6
使用edb加载hello,查看本进程的虚拟地址空间,如图5.5和5.6所示,进程中代码段总是从 0x400000 开始,数据段总是从 0x405000 开始。从图5.4中可以看到.init、.plt、.text、.fini、.rodata节的地址在0x0000000000401000——0x0000000000402000之间。图5.7是可执行文件的程序头部表,其中有程序的八个段:PHDR:程序头表;INTERP:程序执行前需要调用的解释器;LOAD:程序目标代码和常量信息;DYNAMIC:动态链接器所使用的信息;NOTE:辅助信息;GNU_EH_FRAME:保存异常信息;GNU_STACK:使用系统栈所需要的权限信息;GNU_RELRO:保存在重定位之后只读信息的位置展示了ELF可执行文件的连续的片被映射到连续的内存段的映射关系。其中展示了目标文件中的偏移,内存地址,对其要求,目标文件中的段大小,内存中的段大小,运行时访问权限等信息。
在这里插入图片描述
图 5.7 程序头表
5.5 链接的重定位过程分析
在这里插入图片描述
在这里插入图片描述
图5.8 objdump -d -r hello执行结果
5.5.1.hello与hello.o比较分析:
在这里插入图片描述
图5.9 两文件对比(前者是hello的,后者是hello.o)
如图5.9所示可知

  1. 地址的访问:
    hello.o中的相对地址到了hello中变成了虚拟内存地址。而hello.o文件中对于.rodata的访问,是$0x0和0(%rip),是因为它们的地址也是在运行时确定的,因此访问也需要重定位,在汇编成机器语言时,将操作数全置为0,并且添加重定位条目。
  2. 函数调用:
    Hello中无hello.o中的重定位条目,并且跳转和函数调用的地址在hello中都变成了虚拟内存地址。对于hello.o的反汇编代码。函数只有在链接之后才能确定运行执行的地址,因此在.rela.text节中为其添加了重定位条目
  3. 增加的节:
    hello中增加了.init和.plt节,和一些节中定义的函数。
  4. 增加了新的函数:
    链接后hello中加入了在hello.c中用到的函数,如printf、exit、sleep、getchar等函数。
    5.5.2hello的重定位过程:
    链接器在完成符号解析以后,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。然后就可以开始重定位步骤了。将合并输入模块,并为每个符号分配运行时的地址。在hello到hello.o中,首先是重定位节和符号定义,链接器将所有输入到hello中相同类型的节合并为同一类型的新的聚合节。例如,来自所有的输入模块的.data节被全部合并成一个节,这个节成为hello的.data节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每一个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。然后是重定位节中的符号引用,链接器会修改hello中的代码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。
    5.6 hello的执行流程
    子程序名:
    ld-2.27.so!_dl_start
    ld-2.27.so!_dl_setup_hash
    ld-2.27.so!_dl_sysdep_start
    ld-2.27.so!_dl_init
    libc-2.27.so!_cxa_atexit
    libc-2.27.so!_new_nextfn
    hello!_init
    hello!main
    hello!printf@plt
    hello!atoi@plt
    hello!sleepp@plt
    hello!getchar@plt
    或者当argv!=4时,
    main后面改为:
    hello!puts
    plt hello!exit@plt
    5.7 Hello的动态链接分析
    5.7.1. GOT表:
    概念:每一个外部定义的符号在全局偏移表(Global offset Table)中有相应的条目,GOT位于ELF的数据段中,叫做GOT段。
    作用:把位置无关的地址计算重定位到一个绝对地址。程序首次调用某个库函数时,运行时连接编辑器(rtld)找到相应的符号,并将它重定位到GOT之后每次调用这个函数都会将控制权直接转向那个位置,而不再调用rtld。
    5.7.2. 结果分析:
    因为动态链接要在程序加载运行时才能确定重定位符号的位置,需要动态链接时重定位的符号都存储在 GOT 表中,所以GOT表会发生变化。所以根据图5.4节头表找到.got节,发现其起始地址为0x403ff0。所以我们通过使用edb的Data Dump找到地址为0x403ff0的位置,即为GOT表。如图5.10和图5.11所示,在执行函数dl_init的前后,地址0x403ff0中的值由0发生了变化。这就说明,这个表中的信息是在程序执行的过程中动态的链接进来的。也就是说,我们在之前重定位等一系列工作中,用到的地址都是虚拟地址,而我们需要的真实的地址信息会在程序执行的过程中用动态链接的方式加入到程序中。当我们每次从PLT表中查看数据的时候,会首先根据PLT表访问GOT表,得到了真实地址之后再进行操作。
    在这里插入图片描述
    图5.10调用dl_init函数前(0x403ff0行)
    在这里插入图片描述
    图5.11调用dl_init函数后(0x403ff0行)
    5.8 本章小结
    这一章我们主要介绍了链接的概念与作用,又使用ld进行链接,分析了hello的格式,节头表,各段等信息。发现偏移量与进程的虚拟地址空间各段位置一一对应。比较hello与hello.o反汇编的不同处,发现共享库函数的地址变为了实际地址,又寻找了hello从头到尾的运行的函数。最后分析了hello的动态链接终于找到了共享库函数使用延迟绑定的方法,利用PLT和GOT帮助最终找到函数的地址。

第6章 hello进程管理
6.1 进程的概念与作用
6.1.1.概念:进程是一个执行中的程序的实例,系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
6.1.2.作用:进程提供给应用程序两个关键抽象:
1.逻辑控制流
a)每个程序似乎独占地使用CPU;
b)通过OS内核的上下文切换机制提供;
2.私有地址空间
a)每个程序似乎独占地使用内存系统
b)OS内核的虚拟内存机制提供。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1.作用:shell 是一个交互型的应用级程序,它代表用户运行其他程序。
6.2.2.处理流程:
1)打印提示信息;
2)等待用户输入;
3)接受命令;
4)解释命令;
5)找到该命令,执行命令,如果命令含有参数,输入的命令解释它;
6)执行完成,返回第一步。
6.3 Hello的fork进程创建过程
在shell中输入命令行执行可执行文件hello,shell会通过fork函数创建一个新的运行的子进程hello。Hello进程几乎都不完全与父进程(shell)相同,hello进程得到与父进程用户级虚拟空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库、以及用户栈。 这意味着hello进程可以读写父进程(shell)中打开的任何文件。父进程与子进程之间最大的区别在于它们拥有不同的PID。
6.4 Hello的execve过程
Shell创建的子进程将会调用execve函数,来调用加载器,execve函数在加载并运行可执行目标文件hello,且以列表argv和环境变量列表envp作为参数。只有当出现错误时,例如找不到hello,execve才会返回到调用程序。在execve加载了hello之后,它调用_start,_start设置栈,并将控制传递给新程序的主函数。
结合虚拟内存和内存映射过程,可以更详细地说明exceve函数实际上是如何加载和执行程序Hello,需要以下几个步骤:
1.删除已存在的用户区域。
2.映射私有区域。为Hello的代码、数据、bss和栈区域创建新的区域结构,所有这些区域都是私有的、写时复制的。
3.映射共享区域。比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
6.5 Hello的进程执行
在执行hello程序之后,hello进程最初是运行在用户模式。内核为hello维持一个上下文,它由一系列的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构,比如描述地址空间的页表、包含有关当前进程信息的进程表,以及包含进程已打开文件的信息的文件表。
hello进程与其他进程在同一时间内并发运行,这些进程的逻辑流的执行时间与hello的逻辑流重叠,称之为并发流。而一个进程和其它进程轮流运行的概念叫做多任务,一个进程执行它的控制流的一部分的每一时间段叫做时间片。因此多任务也叫做时间分片,如图6.1所示。
在这里插入图片描述
图6.1
处理器通过用某个控制寄存器中的一个模式位来限制一个应用可以执行的指令和它可以访问的地址空间范围。当设置了模式位时,进程就运行着在内核模式,可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据。
hello初始运行在用户模式,在hello进程调用sleep之后陷入内核模式,计时器开始计时,内核通过上下文切换将当前进程将当前进程的控制权交给其他进程。当sleep函数时间到达时发送一个中断信号,此时进入内核状态执行中断处理,然后内核将进程控制权交还给hello进程,hello进程继续执行自己的控制逻辑流。
6.6 hello的异常与信号处理
如图6.2所示,为hello程序正常运行的结果,接着输入命令ps后执行,程序后台并没有hello进程正在执行了,说明进程正常结束,已经被回收了。
在这里插入图片描述
图6.2 hello正常运行的结果
6.6.1.异常:
异常是异常控制流的一种形式,它一部分由硬件实现,一部分由操作系统实现。异常就是控制流中的突变,用来响应处理器状态中的某些变化。如图6.3展示了异常的基本思想。
在这里插入图片描述
图6.3 异常的基本思想
异常可以分为四类:
在这里插入图片描述
图6.4 异常的种类
1.中断是异步发生的,是来自处理器外部的I/O设备的信号的结果。硬件中断不是由任何一条专门的指令造成的,从这个意义上来说它是异步的。硬件中断的异常处理程序常常称为中断处理程序。如图6.5所示。
在这里插入图片描述
图6.5 中断处理
2.陷阱是有意的异常,是执行一条指令的结果。就像中断处理程序一样,陷阱处理程序将控制返回到下一条指令。陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。用户程序经常需要向内核请求服务,比如读一个文件(rcad)、创建一个新的进程(Fork)加载一个新的程序(execve),或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处埋器提供了一条特殊的“syscall n”指令,当用户程序想要请求服务n时,可以执行这条指令。执行syscall指令会导致一个到异常处理程序的陷阱1,这个处理程序解析参数,并调用适当的内核程序,如图6.6所示。
在这里插入图片描述
图6.6 陷阱处理
3.故障由错误情况引起,他可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果故障处理程序能够修正这个错误情况,他就将控制返回到引起故障的指令,从而重新执行它。否则处理程序返回到内核中的abort例程,abort例程会终止故障的应用程序,如图6.7所示。
在这里插入图片描述
图6.7 故障的处理
4.终止是不可恢复的致命错误造成的结果,通常是一些硬件错误,比如DRAM或者SRAM位被损坏时发生的奇偶错误。终止处理程序从不将控制返回给应用程序。如图6.8所示。
在这里插入图片描述
图6.8 终止的处理
6.6.2.hello执行过程中会出现的异常与信号:
对于hello程序来说,并不会发生终断异常,可能会发生终止异常。由于hello一定会执行exit和sleep函数,因此一定会发生系统服务的调用,也就一定会发生陷阱的异常。hello在第一次加载指令和数据时,缓存中没有相应的页表,因此一定会发生缺页,触发缺页异常处理程序也就一定会发生故障处理。
下面介绍更高层的软件形式的异常——信号。
如图6.9所示,列出了30种的不同类型信号。
在这里插入图片描述
图6.9 信号的类型
每种信号类型都对应于某种系统事件,底层的硬件异常是由内核异常处理程序处理的,正常情况下对用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常,比如,如果一个进程试图除以0,那么内核就发送给它一个SIGFPE信号。如果一个进程执行一条非法的指令,那么内核就发送给他一个SIGIKK信号,如果一个进程进行非法内存引用,内核就发送给它一个SIGSEGV信号,其他信号对应于内核或者其他用户进程中较高层的软件事件。比如,如果当进程在前它运行时,你键入CTRL+C,那么内核就会发送一个SIGINT信号给这个前台进程的每一个进程,当一个子程序终止或者停止时,内核就会发送一个SIGGHLD信号给父进程。
传送一个信号到目的进程是由两个不同的步骤组成的:
1) 发送信号:内核通过更新目的进程的上下文中的某个状态,发送一个信号给目的进程。
2) 接收信号:当目的进程被内核强迫以某种方式对信号的发送作出反应时,它就接受了信号。进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序的用户层函数捕捉这个信号。
如图6.10所示。
在这里插入图片描述
图6.10 信号处理
6.6.3.hello运行的各种结果:
1)键盘乱按:不停乱按的结果是程序运行情况和前面的相同,不同之处在于shell将我们刚刚乱输入的字符除了第一个回车按下之前的字符都当做了getchar的输入之外,其余都当作新的shell命令,在hello进程结束被回收之后,将会在命令行中尝试解释这些命令,中间没有任何对于进程产生影响的信号被产生。
在这里插入图片描述
图6.11 键盘乱按结果
2)CTRL+Z:运行中按CTRL+Z之后,将会发送一个SIGTSTP信号给shell。然后shell将转发给当前执行的前台进程组,使hello进程挂起。如图6.12所示。
在这里插入图片描述
图6.12 运行中按CTRL+Z结果
a)输入ps命令:查看当前存在的进程中还有hello,如图6.13所示。
在这里插入图片描述
图6.13 输入ps命令查看进程
b)输入jobs命令:如图6.14所示。
在这里插入图片描述
图6.14 输入jobs命令
c)输入pstree命令:以树状图显示进程间的关系。
在这里插入图片描述
图6.15 进程间的关系
d)使用fg命令:将hello程序重新运行一次,使用CTRL+Z使其挂起,使用fg指令完成剩下的执行。
在这里插入图片描述
图6.16 fg命令
e)使用kill命令:运行hello程序,将其再重新挂起一次,使用kill函数终止进程,如图6.17所示
在这里插入图片描述
图6.17 kill杀死进程
3)CTRL+C:在hello程序运行时输入CTRL+C会导致内核发送一个SIGINT信号到前台进程组的每个进程。默认情况下,结果是终止前台作业。
在这里插入图片描述
图6.18 CTRL+C
6.7本章小结
本章主要简述了一个程序通过shell输入命令执行,经过一系列过程和各种函数的调用创建新的进程的过程,并与系统中的很多很多进程共同并发进行,同时进程又会因为程序中含有系统函数而使用陷阱这种异常从用户模式进入内核模式,获得所有权限,当目的达成之后又会返回到用户模式。进程在运行过程中也会遇到许多异常,有时是因为硬件问题,有时会是软件问题,并针对不同的问题发送不同的信号调用相应的异常处理程序来解决异常。

第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1.逻辑地址:相对地址,是程序运行由CPU产生的与段相关的偏移地址部分。他描述一个程序运行段的地址。
7.1.2.线性地址:逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。是hello中的虚拟内存地址。
7.1.3.虚拟地址:CPU 启动保护模式后,程序运行在虚拟地址空间中。与物理地址相 似,虚拟内存被组织为一个存放在磁盘上的 N 个连续的字节大小的单元组成的数 组,其每个字节对应的地址成为虚拟地址。虚拟地址包括 VPO(虚拟页面偏移量)、 VPN(虚拟页号)、TLBI(TLB 索引)、TLBT(TLB 标记)。
7.1.4.物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。他是在前端总线上传输的而且是唯一的。在hello程序中,他就表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址由两部分组成:段标识符、段内偏移量
段标识符是由一个16位长的字段组成,称为段选择符。其中前13位为索引号,后面3位包含一些硬件细节。(如图7.1所示)
在这里插入图片描述
图7.1 段选择符说明
索引号是“段描述符(segment descriptor)”的索引,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,每一个段描述符由8个字节组成,如图7.2:
在这里插入图片描述
图7.2 段选择符说明
其中Base字段,它描述了一个段的开始位置的线性地址,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中,由段选择符中的T1字段表示选择使用哪个,=0,表示用GDT,=1表示用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。如图7.3所示:
在这里插入图片描述
图7.3 概念关系说明
另外分段功能在实模式和保护模式下有所不同:
1.实模式:逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
2.保护模式:线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。
首先,给定一个完整的逻辑地址段选择符:段内偏移地址,
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。得到一个数组。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。
在保护模式下,分段机制就可以描述为:通过解析段寄存器中的段选择符 在段描述符表中根据 Index 选择目标描述符条目 Segment Descriptor,从目标描述 符中提取出目标段的基地址 Base address,最后加上偏移量 offset 共同构成线性地 址 Linear Address。
7.3 Hello的线性地址到物理地址的变换-页式管理
CPU的页式内存管理单元,负责把一个线性地址,转换为物理地址。从管理和效率的角度出发,线性地址被分为以固定长度为单位的组,称为页(page),例如一个32位的机器,线性地址最大可为4G,可以用4KB为一个页来划分,这页,整个线性地址就被划分为一个tatol_page[2^20]的大数组,共有2的20个次方个页。这个大数组我们称之为页目录。目录中的每一个目录项,就是一个地址——对应的页的地址。 另一类“页”,我们称之为物理页,或者是页框(frame)、页桢的。是分页单元把所有的物理内存也划分为固定长度的管理单位,它的长度一般与内存页是一一对应的。 这里注意到,这个total_page数组有2^20个成员,每个成员是一个地址(32位机,一个地址也就是4字节),那么要单单要表示这么一个数组,就要占去4MB的内存空间。为了节省空间,引入了一个二级管理模式的机器来组织分页单元。如图7.4所示:
在这里插入图片描述
图7.4 二级管理模式
分页单元中,页目录是唯一的,它的地址放在CPU的cr3寄存器中,是进行地址转换的开始点。
每一个活动的进程,因为都有其独立的对应的虚似内存(页目录也是唯一的),那么它也对应了一个独立的页目录地址。——运行一个进程,需要将它的页目录地址放到cr3寄存器中,将别个的保存下来。
每一个32位的线性地址被划分为三部份,面目录索引(10位):页表索引(10位):偏移(12位) 依据以下步骤进行转换:
(1)从cr3中取出进程的页目录地址(操作系统负责在调度进程的时候,把这个地址装入对应寄存器);
(2)根据线性地址前十位,在数组中,找到对应的索引项,因为引入了二级管理模式,页目录中的项,不再是页的地址,而是一个页表的地址。(又引入了一个数组),页的地址被放到页表中去了;
(3)根据线性地址的中间十位,在页表(也是数组)中找到页的起始地址;
(4)将页的起始地址与线性地址中最后12位相加,得到最终的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
如图7.5所示给出了Core i7 MMU如何使用四级页表来将虚拟地址翻译成物理地址。每次cpu产生一个虚拟地址,MMU需要查询一个PTE,如果运气不好,需要从内存中取得,这需要花费很多时间,通过TLB(翻译后备缓冲器)能够消除这些开销。TLB是一个小的,虚拟寻址的缓存,在MMU里,其每一行都保存着一个单个PTE组成的块,TLB通常具有高度相联度
用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。
如果是32位系统,我们有一个32位地址空间,4KB的页面和一个4字节的PTE,我们总需要一个4MB的页表驻留在内存中,而对于64位系统,我们甚至需要8PB的空间来存放页表,这显然是不现实的。用来压缩页表的常见方式就是使用层次结构的页表。
如果是二级页表,第一级页表的每个PTE负责一个4MB的块,每个块由1024个连续的页面组成。二级页表每一个PTE负责一个4KB的虚拟地址页面。这样的好处在于,如果一级页表中有一个PTE是空,那么二级页表就不会存在,这样会有巨大的潜在节约,因为4GB的地址空间大部分都是未分配的。
现在的64位计算机采用4级页表,36位的VPN被封为4个9位的片,每个片被用作一个页面的偏移,CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1PET的偏移量,这个PTE包含L2页表的基地址,VPN2提供一个到L2PTE的偏移量,以此类推。
在这里插入图片描述
图7.5 Core i7地址翻译的概况
7.5 三级Cache支持下的物理内存访问
在上一节中我们已经获得了物理地址VA,我们接着上图7.5的右侧部分进行说明。是用CI6位进行组索引,每组8路,对8路的块分别匹配CT(前40位),如果匹配成功且块的valid标志位为1,则命中,根据数据偏移量CO(后六位),取出数据返回,如果没有匹配成功但是标志位是1,则不命中,向下一级缓存中查询数据(L2 Cache>L3 Cache>主存),查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突,则采用最近最少使用策略LFU进行替换。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制,当fork函数在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当两个进程中的任一个后来进行写操作时,写时复制就会创建新页面。因次,也就为每个进程保持了私有地址空间的概念。
在这里插入图片描述
图7.6 新进程和shell进程
在这里插入图片描述
图7.7 写时复制
7.7 hello进程execve时的内存映射
如图7.8所示,exceve函数加载和执行程序Hello,需要以下几个步骤:
1.删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
2. 映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。图7.8 概括了私有区域的不同映射。
3.映射共享区域。比如Hello程序与标准C库libc.so链接,这些对象都是动态链接到Hello的,然后再用户虚拟地址空间中的共享区域内。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。
在这里插入图片描述
图7.8 加载器映射用户地址空间的区域
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页(page fault) 。图7-8展示了在缺页之前我们的示例页表的状态。CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位推断出VP3未被缓存,并且触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果VP4已经被修改了,那么内核就会将它复制回磁盘。无论哪种情况,内核都会修改VP4的页表条目,反映出VP4不再缓存在主存中这一事实。
在这里插入图片描述
图7.9 VM缺页(之前)
缺页处理程序从磁盘上用VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送到地址翻译硬件。但是现在VP3已经缓存在主存中了,那么页命中也能有地址翻译硬件正常处理了,而不会再产生异常。
在这里插入图片描述
图7.10 VM缺页(之后)
此外,处理缺页故障与缺页中断还可以分为一下三步:
1)判断是不是一个合法的地址,即通过不断将这个地址与每个区域的vm_start&v和vm_end做比较。
2)确认是否有读、写、或者执行这个区域内页面的权限。
3)确认是否是由于对合法的虚拟地址进行合法的操作造成的。选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当却也处理程序返回时,CPU重新启动引起缺页的指令,这条指令将会再次发送A到MMU。这次,MMU就能正常地翻译A,而不会再产生缺页中断了。
在这里插入图片描述
图7.11 缺页处理
7.9动态存储分配管理
7.9.1.动态内存分配的基本原理:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆(如图7-9)。分配器将堆视为一组不同大小的块的集合,来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
1)显式分配器:要求应用显式地释放任何已分配的块。例如C程序通过调用malloc函数来分配一个块,通过调用free函数来释放一个块。其中malloc采用的总体策略是:先系统调用sbrk一次,会得到一段较大的并且是连续的空间。进程把系统内核分配给自己的这段空间留着慢慢用。之后调用malloc时就从这段空间中分配,free回收时就再还回来(而不是还给系统内核)。只有当这段空间全部被分配掉时还不够用时,才再次系统调用sbrk。当然,这一次调用sbrk后内核分配给进程的空间和刚才的那块空间一般不会是相邻的。
2)隐式分配器:也叫做垃圾收集器,例如,诸如Lisp、ML、以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
7.9.2.放置策略:
1)首次适配:从头搜索,遇到第一个合适的块就停止
2)下次适配:从头搜索,遇到下一个合适的块停止
3)最佳适配:全部搜索,选择合适的块停止。
7.9.3.两种堆的数据结构组织形式:
1.带标签的隐式空闲链表:
1)放置块时,分配器搜索空闲链表,常见有首次适配、下一次适配和最佳适配的放置策略。首次适配从头开始搜索空闲链表,下一次适配从链表的上一次查询结束的地方开始搜索,最佳适配检查所有空闲块,选择最小满足的。下一次适配运行最快,但利用率低得多;最佳适配最慢,利用率最高。
2)分配器找到匹配的空闲块后,根据情况可能分割它。如果没有合适的空闲块,合并空闲块来创建更大的空闲块。如果还是不能满足需要,分配器向内核请求额外的堆存储器,转成空闲块加入到空闲链表中。
3)分配器可以选择立即合并或推迟合并,一般为防止抖动,会采用某种形式的推迟合并。
4)合并需要在常数时间内完成,对于空闲链表来说,它是单链表,可以方便地查看后面的块是否空闲块,但前面的块则不行,一个好办法是在块的脚部使用边界标记,它是头部的副本,这样就可以在常数时间查看前后块的类型了。为了避免边界标记占用空间,可以只在空闲块中加边界标记。
在这里插入图片描述
图7.12 简单的堆块的格式和隐式空闲链表的组织
2.显示空闲链表:
对于通用的分配器,隐式空闲链表并不适合,因为它的块分配和堆块的总数呈线性关系。可以在空闲块中增加一种显式的数据结构。下面是双向空闲链表的堆块的格式。双向链表使首次适配时间从块总数的线性时间减少到了空闲块数的线性时间。
在这里插入图片描述
图7.13 双向空闲链表的堆块的格式
显式链表的缺点是空闲块必须足够大来包含结构,这增大了最小块的大小,也潜在提高了内部碎片的程度。
7.9.3.分离的空闲链表:
分离的空闲链表利用分离存储来减少分配时间。分配器维护一个空闲链表数组,每个空闲链表为一个大小类。大小类的定义方式有很多,如2的幂。有简单分离存储和分离适配方法。
简单分离存储的大小类的空闲链表包含大小相等的块,块大小为大小类中最大元素的大小。分配和释放块都是常数时间,不分割,不合并,已分配块不需要头部和脚部,空闲链表只需是单向的,因此最小块为单字大小。缺点是很容易造成内部和外部碎片。
分离适配的分配器维护一个空闲链表的数组,每个链表和一个大小类相关联,包含大小不同的块。分配块时,确定请求的大小类,对适当的空闲链表做首次适配。如果找到合适的块,可以分割它,将剩余的部分插入适当的空闲链表中;如果没找到合适的块,查找更大的大小类的空闲链表。分离适配方法比较常见,如GNU malloc包。这种方法既快、利用率也高。
7.10本章小结
通过本章的完成,可以懂得第一次学习是没有理解的知识,包括动态存储分配管理等,虚拟地址是对内存的一个抽象,掌握好它有助于了解计算机运行的更深层次的知识。

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/ O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux 内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
8.2 简述Unix IO接口及其函数
8.2.1.Unix IO接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) 、标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为K 。
4.读写文件。一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k~m 时执行读操作会触发一个称为end-of-file(EOF) 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2.Unix IO函数:
1.open()函数:
功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。
函数原型:int open(const char *pathname,int flags,int perms)
参数:pathname:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式。
返回值:成功:返回文件描述符;失败:返回-1。
2.close()函数:
功能描述:用于关闭一个被打开的的文件。
所需头文件:#include <unistd.h>
函数原型:int close(int fd)
参数:fd文件描述符。
返回值:0成功,-1出错。
3.read()函数:
功能描述:从文件读取数据。
所需头文件:#include <unistd.h>
函数原型:ssize_t read(int fd, void *buf, size_t count);
参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。
返回值:返回所读取的字节数;0(读到EOF);-1(出错)。
4.write()函数:
功能描述:向文件写入数据。
所需头文件:#include <unistd.h>
函数原型:ssize_t write(int fd, void *buf, size_t count);
返回值:写入文件的字节数(成功);-1(出错)。
5.lseek()函数:
功能描述:用于在指定的文件描述符中将将文件指针定位到相应位置。
所需头文件:#include <unistd.h>,#include <sys/types.h>
函数原型:off_t lseek(int fd, off_t offset,int whence);
参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)。
返回值:成功:返回当前位移;失败:返回-1。
8.3 printf的实现分析
首先来看一下printf()函数的函数体:
static int printf(const char *fmt, …)
{
va_list args;
int i;

 va_start(args, fmt);
 write(1,printbuf,i=vsprintf(printbuf, fmt, args));
 va_end(args);
 return i;

}

参数中明显采用了可变参数的定义,可以看到*fmt是一个char 类型的指针,指向字符串的起始位置。
再看一系列的va函数:
va_list arg_ptr;

void va_start( va_list arg_ptr, prev_param );
type va_arg( va_list arg_ptr, type );
void va_end( va_list arg_ptr );

首先在函数里定义一个va_list型的变量,这里是arg_ptr,这个变量是指向参数的指针。然后使用va_start使arg_ptr指针指向prev_param的下一位,然后使用va_args取出从arg_ptr开始的type类型长度的数据,并返回这个数据,最后使用va_end结束可变参数的获取。
printf()函数主要调用了vsprintf()和write()函数,所以让我们来看一下vsprintf()函数。
vsprintf()函数:
int vsprintf(char *buf, const char fmt, va_list args)
{
char
p;
char tmp[256];
va_list p_next_arg = args;

for (p=buf;*fmt;fmt++) { 
if (*fmt != '%') { 
*p++ = *fmt; 
continue; 
} 
fmt++; 
switch (*fmt) { 
case 'x': 
itoa(tmp, *((int*)p_next_arg)); 
strcpy(p, tmp); 
p_next_arg += 4; 
p += strlen(tmp); 
break; 
case 's': 
break; 
default: 
break; 
} 
} 
return (p - buf); 

}

它接受一个格式化的命令,并把指定的匹配的参数格式化输出,vsprintf返回的是一个长度,即返回的是要打印出来的字符串的长度。接着我们来看看write()函数。
write()函数:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL

一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。所以我们查看sys_call的实现。
sys_call的实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret

由上面几个函数的分析,我们可以得到printf()函数执行过程如下:
1.从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
2.字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
3.显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
当程序调用getchar时,程序就等着用户按键,当系统检测到键盘输入时,会触发异步异常—键盘中断的处理,运行键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar调用read系统函数,与write类似,read也通过终端程序调用内核函数sys_read,通过系统调用读取按键ASCII码。当用户键入回车之后,getchar才开始从stdin流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾(END-OF-File)则返回-1(EOF)。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取,也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完后,才等待用户按键。
8.5本章小结
本章主要介绍了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数。输入/输出(I/O) 是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从I/O 设备复制数据到主存,而输出操作是从主存复制数据到I/O 设备。所有语言的运行时系统都提供执行I/O 的较高级别的工具。了解Unix I/O 将帮助我们理解其他的系统概念。I/O 是系统操作不可或缺的一部分,因为,我们经常遇到I/O 和其他系统概念之间的循环依赖。
结论
作为程序猿的初恋,hello程序是我们最先接触到的成序之一,对于我们来说有着非比寻常的意义,了解掌握hello的所有流程更是重中之重。那么由我来简单的叙述一下hello的整个生命周期。
1.首先,我们通过I/O设备,使用高级程序语言C语言编写出hello的源程序文件hello.c,并经过总线存储在主存中。
2.接下来GCC编译器驱动程序运行预处理器(cpp)预处理hello.c生成hello.i文件。
3.驱动程序有运行编译器(ccl)将hello.i翻译成一个ASCII汇编语言文件hello.s。
4.驱动程序运行汇编器(as)将hello.s翻译成一个可重定位目标文件hello.o。
5.最后驱动程序运行链接器ld和标准的C库进行链接生成hello(可执行目标文件),这时的hello就是一个程序了。
6.通过shell里面(bash)键入命令./hello后,shell识别命令,获取参数,调用fork函数创建一个新运行的子进程,在子进程中调用execve函数启动加载器。加载器使用mmap函数创建新的内存区域,创建一组新的代码、数据、堆和栈段。
7.MMU将hello程序中的虚拟内存地址通过页表映射成物理地址,进而完成内存分配。
8.CPU开始取值,译码,执行,访存,写回,更新PC等一系列操作。也许大家认为这个过程很慢,但是我们由流水线这样的设计,使得多条指令可以乱序并行执行而又呈现出一种有序的状态。
9.之后由于要涉及到访问内存读取或写回数据,虚拟地址就要通过TLB和页表翻译成物理地址。
10.由于有了高速缓存(三级Cache),使得我们读写数据所耗费的时间大大减小,而且它们还支持hello的物理地址访问。
11.hello进程执行的时候并不孤单,因为在系统中有许多许多个进程和他同时进行着,他们之间的相互切换依赖于存储再内核的上下文和一种叫做时间片的抽象概念。
12.hello进程在用户模式下的能力十分有限,能做的事情也很少,但是一旦它进入了内核模式,它就无所不能了,没有什么是它做不到的。而它进入内核模式却要依靠一种叫做陷阱的异常。
13.hello进程在执行过程中也会遇到各种各样的异常,也会接收到各种各样的信号,它会针对不同的信号和指令做出不同的反应。同时他也会发送不同的信号像系统传递各种各样的信息。
14.当hello的生命走到尽头的时候,他会结束运行,处于终止状态,这个时候就需要他的老父亲shell回收它,释放空间消除留下的痕迹,避免堆积占用内存影响性能。这就是hello的完整的一个生命周期了。

自我感悟:计算机真的是一个十分精妙的东西,计算机系统的设计与原理更是值得我们深入探讨,不断专研的伟大创造。通过这学期学习的课程,我明白了编译器是如何编译我们所写的代码的,计算机又是如何理解我们写的代码,并快速的运行出结果的。也明白了为什么有的时候写的是相同功能的代码,别人却总是运行的比我的快,原来是我没有理解计算机执行的原理,代码不够优化。此外,CPU的设计真的很神奇,那么一个小小的芯片竟然能够做那么多的事情。
总之,我的学习之旅还没有结束,我还会继续深入的探索计算机的相关知识。

附件
在这里插入图片描述
hello.c:使用C语言编写的源程序文件。
hello.i:hello.c经预处理得到的ASCII码的中间文件。
hello.s:hello.i编译之后得到的一个ASCII汇编语言文件。
hello.o:hello.s汇编之后得到的一个可重定位目标文件。
hello:hello.s和标准的C库进行链接得到的可执行目标文件。
hello.o_obj:hello.o的反汇编代码。
hello_obj:hello的反汇编代码。
hello.elf:hello.o的elf格式文件。
hello_1.elf:hello的elf格式文件。

参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 林来兴. 空间控制技术[M]. 北京:中国宇航出版社,1992:25-42.
[2] 辛希孟. 信息技术与信息服务国际研讨会论文集:A集[C]. 北京:中国科学出版社,1999.
[3] 赵耀东. 新时代的工业工程师[M/OL]. 台北:天下文化出版社,1998 [1998-09-26]. http://www.ie.nthu.edu.tw/info/ie.newie.htm(Big5).
[4] 谌颖. 空间交会控制理论与方法研究[D]. 哈尔滨:哈尔滨工业大学,1992:8-13.
[5] KANAMORI H. Shaking Without Quaking[J]. Science,1998,279(5359):2063-2064.
[6] CHRISTINE M. Plant Physiology: Plant Biology in the Genome Era[J/OL]. Science,1998,281:331-332[1998-09-23]. http://www.sciencemag.org/cgi/ collection/anatmorp.
[7] BryantO’Hallaron, D.R.R.E.,. (2019). 深入理解计算机系统(第三版). 北京: 机械工业出版社.
[8] Pianistx. (2013 年 9 月 11 日). [转]printf 函数实现的深入剖析. 检索来源: 博客园: https://www.cnblogs.com/pianist/p/3315801.html.
[9] 码农的荒岛求生 彻底理解链接器:四,重定位.
检索来源:https://segmentfault.com/a/1190000016433947
[10] shenhuxi_yu (2017年05月8日)动态链接原理分析。 检索来源:https://blog.csdn.net/shenhuxi_yu/article/details/71437167

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值