计算机系统——大作业

第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:源程序文件hello.c文件经过预处理、编译、汇编、链接变成可执行文件hello。在shell中输入./hello (-参数…)后,shell为其fork生成一个子进程,然后用execve在子进程的上下文中加载并运行可执行文件hello,这就是hello的“From Program to Process”过程。
020:子进程调用execve,映射虚拟内存并载入物理内存,进入程序入口处开始执行,同时,CPU为运行的hello分配时间片并执行逻辑控制流。最后当hello进程终止时,父进程shell将回收hello,接着内核删除相关数据结构,这个过程叫做020。
1.2 环境与工具
硬件环境:X64 CPU;2.3GHz;8G RAM;256GHD Disk

软件环境:macOS 11.2.3;VMware Fusion 12.1.2;Ubuntu 20.04.2

开发与调试工具:gcc;objdump;readelf;as;ld;Sublime;gedit;edb;gdb
1.3 中间结果
hello.c 源文件
hello.i hello.c预处理之后文本文件
hello.s hello.i编译后的文本文件
hello.o hello.s汇编之后的可重定位目标文件
hello 链接之后的可执行目标文件
hello.out hello反汇编之后的可重定位文件

1.4 本章小结
本章大致主要简单介绍了 hello 的 p2p,020 过程,列出了本次实验所需要的环境和工具以及中间产生的一些结果。

(第1章0.5分)

第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理器(cpp)根据以字符#开头的命令,修改原始的c程序,最终得到另一个以.i作为文件扩展名的c程序。
预处理作用:
1.处理宏定义指令(#define S® (®*®)):用实际值替换宏定义的字符串。
2.文件包含(#include “文件名”):将头文件中的代码插入到新程序中。
3.条件编译(#ifdef 标识符程序段1 #else):有些语句希望在条件满足时才编译,根据if后面的条件决定需要编译的代码。
2.2在Ubuntu下预处理的命令

预处理命令:gcc -E hello.c -o hello.i

                  图1 预处理结果

2.3 Hello的预处理结果解析

经过预处理后生成hello.i文件,使用cat hello.i命令查看hello.i文件内容,可以看到程序内容增加,但是仍然为可以阅读的文本文件,程序的主函数没有改变,对原文件中的宏定义进行了宏展开,# include的头文件中的内容被包含到该文件之中,包括声明函数、定义结构体、定义变量、定义宏等,以及用#标明了程序涉及到的许多.h文件的所在目录。

图2 预处理解析

2.4 本章小结

本章介绍了预处理的相关概念以及ubuntu下预处理的命令,并对预处理后的文件进行了解析。

(第2章0.5分)

第3章 编译
3.1 编译的概念与作用

编译的概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。编译主要通过词法分析、语言分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
此处编译的作用是将源程序代码生成汇编语言代码。

3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s

图3 汇编结果
3.3 Hello的编译结果解析
3.3.0 汇编代码中伪指令介绍

由于书上没有对这些伪指令的介绍,通过查阅资料得到部分伪指令的含义:
.file:声明源文件
.text:代码节
.section .rodata: 只读数据,radata节
.align:数据或者指令的地址对其方式
.string:声明一个字符串(.LC0,.LC1)
.global:声明全局变量(main)
.type:声明一个符号是数据类型还是函数类型
为了下面内容书写方便,此处贴一张c程序的源代码:

                    图4 c程序源代码     

3.3.1 数据

1.常量:字符串常量保存在只读数据段,通过.string声明:

                     图5 字符串

程序中的立即数常量直接以立即数的方式体现在汇编代码中,如$4。
2.局部变量:程序中的局部变量通常会保存在堆栈中,隶属于各自的进程,进程结束时会被释放。其中局部变量i保存在-4(%rbp)中,函数main的第一个参数argc也被保存在堆栈中:从寄存器%edi保存到了 -20(%rbp)中。
指针数组变量char * argv[] 作为main函数的第二个参数,数组首地址由%rsi
传到了-32(%rbp),然后argv[1]存放在%rbp-24,,argv[2]存放在%rbp-16,通过偏移的引用数组内容。
3.全局变量:代码中main是全局变量且类型是函数,main在.text节中定义。.text节包含已经编译程序的机器代码。

图6 全局变量
3.3.2 赋值

赋值操作的指令是mov指令,不同后缀代表对不同的数据结构进行赋值:
movb:一个字节
movw:两个字节
movl:四个字节
movq:八个字节
本程序中的主要赋值操作就是i=0,对应的指令为movl $0, %eax

3.3.3类型转换
hello.c中涉及的类型转换是:atoi(argv[3]),将字符串类型转换为整数类型,此处是通过调用atoi函数进行的转换。

3.3.4算数操作

汇编代码中常用算数操作的指令有:

                     图7 算数操作指令

本程序中的算数操作i++通过addl指令来实现。

3.3.4关系操作
argc != 3 被翻译为cmpl指令:cmpl $4, -20(%rbp)
i < 10 也被翻译为cmpl指令:cmpl $7, -4(%rbp)
3.3.5数组操作
指针数组变量char * argv[] 作为main函数的第二个参数,数组首地址由%rsi
传到了-32(%rbp),然后argv[1]存放在%rbp-24,,argv[2]存放在%rbp-16,通过偏移的引用数组内容。
3.3.6 控制转移
指令:cmp,je,jle,jmp
用比较和跳转指令实现if语句:

图8 控制转移
实现for循环语句:首先i赋初值0,然后无条件跳转至判断条件的代码中,即.L3.
然后判断i是否符合循环的条件,符合直接跳转至.L4,也就是循环体的内部.

             图9 控制转移

3.3.7函数操作
用call进行函数调用。参数列表依次保存在%rdi,%rsi…中,call后面加函数名进行调用。
Main函数:参数传递,%edi存储着argc的值(参数变量的个数),为第一个参数,%rsi存储argv的值,作为地址,指向参数字符串数组的首地址,为第二个参数;函数调用,主函数,第一个执行的函数;函数返回,返回0

Printf函数:参数传递,%rdi中存储传递的第一个参数,如果要输出字符串,字符串在开头.LC0和.LC1处,将字符串的地址赋值给%rdi作为第一个参数,在第二个printf中有三个参数,另外两个参数通过%rax赋值给%rsi和%rdx;函数调用使用call指令调用;函数返回,将返回值存储在%rax中,使用指令ret返回

Exit函数:参数传递,将1放在%edi中作为参数传递;函数调用,使用call指令进行调用;函数返回,无返回

Sleep函数:参数传递,将sleepsecs的值作为参数,将%eax赋值给%edi,%edi中的值作为第一个参数;函数调用,使用call指令进行调用;函数返回将返回值存储在%eax,使用ret指令返回。

             图10 函数调用

此外,函数返回值通常在寄存器%rax中。
3.4 本章小结
本章主要讲述了编译阶段中编译器如何处理各种数据和操作,以及c语言中各种类型和操作所对应的的汇编代码。提高了阅读汇编代码的能力。
(第3章2分)

第4章 汇编
4.1 汇编的概念与作用

汇编概念:汇编器as将hello.s汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在hello.o目标文件中,.o文件是一个二进制文件,它包含程序的指令编码。
汇编作用:将汇编代码转为机器指令,使其在链接后能被机器识别并执行。

4.2 在Ubuntu下汇编的命令
汇编命令:gcc -c hello.s -o hello.o

                      图11 汇编命令

4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
一个典型的ELF可重定位目标文件包含下面几个节:
ELF header
.text:已经编译程序的机器代码

.rodata:只读数据

.data:初始化的全局和静态变量

.bss:未初始化的全局和静态变量

.symtab:符号表

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

.rel.data:别模块引用或定义的所有全局变量和重定位信息

.debug:调试符号表

.line:行号与.text的机器指令之间的映射

.strtab:一个字符串表

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

图12 ELF头部分

节头部表:描述目标文件的节,包含各个节的名称、类型、大小、位置等信息。由于是可重定位目标文件,所以每个节都从0开始,用于重定位。

                        图13 节头部表

重定位信息:由图可知重定位信息包含以下几个方面:
偏移量:指需要进行重定向的代码需要被修改的引用的节偏移,8个字节。
信息:前4个字节代表重定位到的目标在.symtab中的偏移量,后四个字节代表重定位到的目标的类型
类型:如何修改新的引用,即重定位到的目标的类型R_X86_64_32是重定位一个使用32位绝对地址的引用,R_X86_64_PC32是重定位一个使用32位PC相对地址的引用
符号名称:标识被修改引用应该指向的符号
加数:计算重定位位置的辅助信息,使用它对被修改引用的值做偏移调整

图14 重定位信息
4.4 Hello.o的结果解析
机器语言是由二进制码组成,每条机器指令由操作码字段和操作数字段组成,有的机器指令没有操作数。每一条汇编语言操作码都可以用机器二进制数据来表示,进而可以将所有的汇编语言(操作码和操作数)和二进制机器语言建立一一映射的关系。
反汇编结果如下:

图15 反汇编结果
反汇编之后的机器语言与汇编语言中的个别不同:
在.s文件中的跳转指令中的标号在反汇编之后使用的是确定的地址

图16 反汇编分析

在.s文件中引用.rodata和.data段中的标号被换为了数字0,表示等待链接器根据重定位条目进行重定位,对函数的调用也是为0,后面跟着的是重定位信息,等待链接时的重定位。

图17 反汇编分析
.s中的立即数使用十进制数,在.o文件中的机器指令中都用十六进制表示。
4.5 本章小结

本章介绍了汇编的概念和ELF可重定位目标文件的格式,分析比较了hello.s和hello.o反汇编代码的不同之处
(第4章1分)

第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

图18 链接命令

5.3 可执行目标文件hello的格式
一个典型的ELF可执行目标文件包含下面各节:
ELF头

段头部表 描述了如何将文件的片映射到运行时的内存段。

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

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

.rodata 只读数据,比如printf语句中的格式串和开关语句的跳转表

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

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

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

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

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

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

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

(1)ELF Header: Type类型为EXEC表明hello是一个可执行目标文件

图19 ELF Header

(2)段头部表描述了文件中各段的位置与其在内存中运行时位置的映射关系:

图20 段头部表
(3)节头部表Section Headers 对 hello中所有的节信息进行了声明,其 中包括大小 Size 以及在程序中的偏移量 Offset,因此根据 Section Headers 中的信息我们就可以用 HexEdit 定位各个节所占的区间(起始位置,大小)。其中 Address 是程序被载入到虚拟地址的起始地址。

图21 节头部表

(4)重定位节:

图22 重定位节

5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
hello的虚拟地址空间开始于0x400000,结束与0x400ff0

图23 虚拟地址总览
然后可以结合5.3中的节头部表,通过edb找到各个节的信息,例如.init节,开始于0x400468,大小时0x1a,.text节开始于0x400520,大小为0x132。

图24 hello的部分虚拟地址空间
5.5 链接的重定位过程分析
使用objdump -d -r hello > hello.out获得hello的反汇编代码.
通过分析hello与hello.o的不同可以看出以下不同的地方:
(1).init节定义了一个小函数_init,程序的初始化会调用它。

图25
(2)在.text节中,多了一个函数 _start。加载程序时,加载器会跳转到_start的地址,它会调用main函数.

图26
(3)hello反汇编的代码有确定的虚拟地址,也就是说通过链接已经完成了重定位,而hello.o反汇编代码中代码的虚拟地址均为0,未完成可重定位的过程,例如对printf函数的调用:

图27 重定位前后分析

链接的过程:(1)符号解析: 程序中所有有定义和引用的符号(包括变量和函数),编译器会讲定义的符号存放在一个符号表中。这个符号表是一个结构数组,其中每个表项包含符号名、长度和位置等信息。而链接器的工作就是将每个符号的引用都与一个确定的符号定义链接起来(2)重定位:将多个代码段与数据段分别合并为一个单独的代码段和数据段,计算每个定义符号在虚拟地址空间中的绝对地址,将可执行文件中的符号引用处的地址修改为重定位后的地址信息。
hello重定位的过程:
(1)首先合并相同节:函数<_start>就是系统代码段(.text)与hello.o中的.text节合并得到的最后的一个单独的代码段。
(2)重定位节中的符号引用,连接器修改代码节和数据节中对每个符号的引用,使他们指向正确的运行时地址。
(3)重定位条目,当编译器遇到对最终位置未知的目标引用时,它就会生成一个重定位条目。代码的重定位条目放在.rel.txt。
5.6 hello的执行流程
加载程序 ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!_libc_start_main
libc-2.27.so!_cxa_atexit
libc_2.27.so!_new_exitfn
hello!_libc_csu_init
hello!_init
libc-2.27.so!_sigsetjmp
libc-2.27.so!_sigjmp
Call main hello!main
终止 libc-2.27.so!exit
5.7 Hello的动态链接分析
动态链接库中的函数在程序执行的时候才会确定地址,所以编译器无法确定其地址,在汇编代码中也无法像静态库的函数那样体现。

hello程序对动态链接库的引用,基于数据段与代码段相对距离不变这一个事实,因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量。

GNU编译系统采用延迟绑定技术来解决动态库函数模块调用的问题,它将过程地址的绑定推迟到了第一次调用该过程时。

延迟绑定通过全局偏移量表(GOT)和过程链接表(PLT)实现。如果一个目标模块调用定义在共享库中的任何函数,那么就有自己的GOT和PLT。前者是数据段的一部分,后者是代码段的一部分。
下图为dl_init之前,GOT表的起始位置,即0x601000。在dl_init调用之前可以查看其值,发现均为0

图28 GOT表初始前内容
调用_start之后发生改变,0x601008后的两个8个字节分别变为:0x7f6f8dc46170、0x7f6f8da34750

图29 GOT表初始后内容
5.8 本章小结

本章介绍了链接的概念与作用,分析了hello.o的ELF格式和各个节的含义,并且分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。

(第5章1分)

第6章 hello进程管理
6.1 进程的概念与作用
进程概念:进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。
作用:进程给应用程序提供两个关键抽象:一个独立的逻辑控制流,一个私有的存储空间。在操作系统上运行一个程序时,就好像程序是系统中当前运行的唯一的程序,好像是在独占处理器和内存。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个交互型应用级程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程)。
流程:
(1)读取用户命令行输入
(2)分析命令行字符串,获取命令行参数,构造传递给execvedargv向量
(3)检查第一个命令行参数是否是一个内置的shell命令
(4)如果不是,用fork创建子程序
(5)子进程中,进行步骤(2)获得参数,调用exceve()执行制定程序
(6)命令行末尾没有&,代表前台作业,shell使用waitpid等待作业终止后返回
(7)命令行末尾有&,代表后台作业,shell返回
6.3 Hello的fork进程创建过程
在终端输入./hello,shell进行对命令行的解释,因为不是内置shell命令,因此调用fork()函数创建一个新的运行子进程,执行可执行程序hello。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的但独立一份副本,包括代码段、段、数据段、共享库以及用户栈子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的,fork函数在子进程中返回0。
6.4 Hello的execve过程
子进程调用execve函数,使用驻留在储存器中的称为加载器的操作系统代码来加载并运行可执行目标文件hello,并映射私有区域,为程序的代码,数据,bss,栈区域创建新的区域结构。代码和数据区域映射为hello中的.text,.data区,bss请求二进制零,映射到匿名文件。栈和堆请求二进制零,初始长度为0。
接着,映射共享区域;最后设置当前进程上下文中的程序计数器PC,指向代码区域的入口点即-start函数的地址。start函数调用系统启动函数 _libc_start_main来初始化执行环境,并调用用户层的main函数,此时构造的argv向量被传递给主函数。
6.5 Hello的进程执行
(1)进程上下文:进程的物理实体(代码和数据等)和支持进程的运行的环境合称为进程的上下文 而由进程的程序块、数据块、运行时的堆和用户栈(两者统称为用户堆栈)等组成的用户空间信息被称为用户级上下文;
由进程标识信息、进程现场信息、进程控制信息和系统内核栈等组成的内核空间信息被称为系统级上下文;处理器中各个寄存器的内容被称为寄存器上下文(也称硬件上下文),即进程的现场信息;
(2)进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
(3)在内核执行的某些时刻,内核可以决定抢占当前进程,并通过上下文切换重新开始一个先前被抢占的进程,这就是调度。
(3)用户模式和内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中,用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任何命令,并且可以访问系统中的任何内存位置。
6.6 hello的异常与信号处理
异常可以分为四类:中断、陷阱、故障、终止,hello程序执行过程中可能出现的异常有:
(1)中断:在hello程序执行的过程中可能会出现外部I/O设备引起的异常。
(2)陷阱:陷阱是有意的异常,是执行一条指令的结果,hello执行sleep 函数的时候会出现这个异常。
(3)故障:在执行hello程序的时候,可能会发生缺页故障。
(4)终止:终止时不可恢复的错误,在hello执行过程可能会出现DRAM或者SRAM位损坏的奇偶错误。
信号:
SIGINT:Ctrl-Z会产生这个信号,会中断进程,切换到别的进程中;
SIGCONT:使用fg命令,会向进程发送该信号,切换到该进程继续执行(如果停止了).
SIGTRAP:sleep函数会触发这个信号,调用陷阱异常处理程序,并跟踪陷阱.
SIGCHLD:exit函数会发送这个信号,终止进程.
kill可以给根据PID给进程发送信号.

正常运行hello的结果:

图30 正常运行

按下 ctrl-z 的结果:默认结果是挂起前台的作业,hello进程并没有回收,而是运行在后台下,用ps命令可以看到,hello进程并没有被回收。此时他的后台 job 号是 1,调用 fg 1 将其调到前台,此时 shell 程序首先打印 hello 的命令行命令, hello 继续运行打印剩下的 8 条 info。

图31
程序运行过程中按键盘,不停乱按:在乱按过程中,shell会把输入的字符存到缓冲区。当hello运行结束后,shell会把hello执行时用户输入的字符当作命令(以回车为间隔,每个回车一个命令)。

图32
ctrl+c:内核会发送一个SIGINT信号给前台进程组的每个进程,用ps命令发现hello进程已被回收。

图33

6.7本章小结
介绍了进程的概念和作用,简述了shell的作用和处理流程,以及hello的进程执行,hello的异常与信号处理。
(第6章1分)

第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:CPU所生成的地址。逻辑地址是内部和编程使用的、并不唯一。由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。
线性地址:是逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
虚拟地址:CPU启动保护模式后,程序运行在虚拟地址空间中。注意,并不是所有的“程序”都是运行在虚拟地址中。CPU在启动的时候是运行在实模式的,Bootloader以及内核在初始化页表之前并不使用虚拟地址,而是直接使用物理地址的。
物理地址:加载到内存地址寄存器中的地址,内存单元的真正地址。在前端总线上传输的内存地址都是物理内存地址,编号从0开始一直到可用物理内存的最高端。
7.2 Intel逻辑地址到线性地址的变换-段式管理
逻辑地址是程序产生的和段相关二点偏移地址,需要转换成线性地址,才能经过MMU转换成物理地址才能访问;
一个逻辑地址由2部分组成,段标识符:段内偏移量.段标识符由一个16位长的字段组成,成为段选择符.其中前13位是一个索引号.后面3位包含一些硬件细节.
索引号就是“段描述符(segment descriptor)”的索引,段描述符具体地址描述了一个段。很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。
逻辑地址转换为线性地址的一般步骤:
首先,给定一个完整的逻辑地址[段选择符:段内偏移地址]:
1、看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。我们就有了一个数组了。
2、拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,这样,它了Base,即基地址就知道了。
3、把Base + offset,就是要转换的线性地址了。
7.3 Hello的线性地址到物理地址的变换-页式管理
内存管理单元(MMU)利用页表来实现地址翻译,即从虚拟地址空间到物理地址空间的映射(线性地址就是虚拟地址,VA)。CPU中的一个控制寄存器,页表基址寄存器(PTBR)指向当前页表。n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN)。MMU利用VPN选择合适的页表条目(PTE)。将PTE中的物理页号(PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。如下图:

图34

7.4 TLB与四级页表支持下的VA到PA的变换
TLB(翻译后备缓冲器)是一个位于MMU中的小的虚拟地址的具有较高相联度的缓存,其每一行都是一组由数个PTE组成的块,TLB极大地减小了CPU访问PTE的开销,且能实现虚拟页面向物理页面的映射,同时对于页面数很少的页表可以完全包含在TLB中。
Core i7使用四级列表(L1页表、L2页表、L3页表、L4页表)来将虚拟地址翻译为物理地址。36位的VPN被划分为四个9位的片(VPN1、VPN2、VPN3、VPN4),每个片被用作到一个页表的偏移量。CR3(i7中的PTBR)寄存器包含L1页表的物理地址。VPN1提供到一个L1 PTE的偏移量,这个PTE包含一个L2页表的基地址。VPN2提供到一个L2 PTE的偏移量,以此类推。在L4页表的PTE中存了VPN对应的40位PPN,将这个PPN与VPO合并,就得到了VA对应的PA。

图35

7.5 三级Cache支持下的物理内存访问
i7处理器每个CPU有4个核,每个核有自己私有的L1 i-cache、L1 d-cache和L2高速缓存,所有核共享一个L3缓存。CPU寄存器中保存着从L1缓存中取出的字,L1缓存保存着从L2缓存中取出的缓存行,L2缓存保存着从L3缓存中取出的缓存行,L3缓存保存着从主存中取出的缓存行。
Cache分为以下三类:
(1)直接映射高速缓存
直接映射高速缓存每个组只有一行,当CPU执行一条读内存字w的指令,它会向L1高速缓存请求这个字。如果L1高速缓存中有w的一个缓存副本,那么就会得到L1高速缓存命中,高速缓存会很快抽取出w,并将它返回给CPU。否则就是缓存不命中,当L1高速缓存向主存请求包含w的块的一个副本时,CPU必须等待。当被请求块最终从内存到达时,L1高速缓存将这个块存放在它的一个高速缓存行里,从被存储的块中抽取出字w,然后将它返回给CPU。确定是否命中然后抽取的过程分为三步:1)组选择;2)行匹配;3)字抽取。
组选择即从w的地址中间抽取出s个索引位,将其解释为一个对应组号的无符号整数,从而找到对应的组;行匹配即对组内的唯一一行进行判断,当有效位为1且标记位与从地址中抽取出的标记位相同则成功匹配,否则就得到不命中;而字选择即在行匹配的基础上通过地址的后几位得到块偏移,从而在高速缓存块中索引到数据。
(2)组相联高速缓存
组相联高速缓存每个组内可以多于一个缓存行,总体逻辑类似于直接映射高速缓存,不同之处在于行匹配时每组有更多的行可以尝试匹配,遍历每一行。如果不命中,有空行时也就是冷不命中则直接存储在空行;如果没有空行也就是冲突不命中,则替换已有行,通常有LFU(最不常使用)、LRU(最近最少使用)两者替换策略。
(3)全相联高速缓存
全相联高速缓存只有一个组,且这个组包含所有的高速缓存行(即E =
C/B)。对于全相联高速缓存,因为只有一个组,组选择变的十分简单。地址中不存在索引位,地址只被划分为一个标记位和一个块偏移。行匹配和字选择同组相联高速缓存。
7.6 hello进程fork时的内存映射
当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。
它将两个进程中的每个页面都标记为只读,并将每个进程中的每个区域结构都标记为写时复制。

7.7 hello进程execve时的内存映射
execve 函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运 行包含在可执行目标文件 hello 中的程序,用 hello 程序有效地替代了当前程序。 加载并运行 hello 需要以下几个步骤:
1)删除已存在的用户区域,删除当前进程虚拟地址的用户部分中的已存 在的区域结构。
2) 映射私有区域,为新程序的代码、数据、bss 和栈区域创建新的区域结 构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射
为 hello 文件中的.text 和.data 区,bss 区域是请求二进制零的,映射到匿名 文件,其大小包含在 hello 中,栈和堆地址也是请求二进制零的,初始长 度为零。
映射共享区域, hello 程序与共享对象 libc.so 链接,libc.so 是动态链 接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4)设置程序计数器(PC),execve 做的最后一件事情就是设置当前进程 上下文的程序计数器,使之指向代码区域的入口点。

图36
7.8 缺页故障与缺页中断处理
当指令引用一个相应的虚拟地址,与该地址相应的物理页面不在DRAM中成为缺页,会触发缺页故障。
缺页故障会是进程进入缺页故障处理程序,首先判断虚拟地址A是否合法,缺页处理程序会搜索区域结构的链表,把A和每个区域结构中的vm_start和vm_end做比较。如果指令不合法则触发段错误,从而终止该进程。
然后处理程序会判断试图进行的内存访问是否合法,也就是进程是否有读写这个区域内页面的权限。如果访问不合法,那么处理程序会触发一个保护异常,终止这个进程。
最后,确保了以上两点的合法性后,根据页式管理的规则,牺牲一个页面,并赋值为需要的数据,然后更新页表并再次触发MMU的翻译过程。

图37
7.9动态存储分配管理
在程序运行时程序员使用动态内存分配器 (比如malloc) 获得虚拟内存,动态内存分配器维护着进程的一个虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护,每个块要么是已分配的,要么是空闲的。分配器分为显式分配器和隐式分配器。显示分配器要求应用显示地释放任何已分配的块。C标准库提供的malloc程序包就是显示分配器。隐式分配器会检测到一个已分配块不再被程序所使用,就会释放这个块。隐式分配器也叫垃圾收集器,自动释放未使用的已分配块的过程叫垃圾收集。
维护这些快时有不同的方式:
(1)带边界标签的隐式空闲链表分配器:

图38
一个块是由一个字的头部、有效载荷,以及可能的填充组成。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。块的头最后一位指明这个块是已分配的还是空闲的。头部后面是应用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。块的格式如图7.9.3所示,空闲块通过头部块的大小字段隐含的连接着,所以我们称这种结构就隐式空闲链表。
边界标签就是在每个块的结尾处添加一个脚部(footer,边界标签),脚部是头部的一个副本。因为每个块都包含一个脚部,所以分配器可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字(4字节)的位置。边界标签使得对前面块的合并只需要常数时间,而不需要在调用free时从头开始遍历整个堆。

图39
(2)显示空闲链表:显示空闲链表是将空闲块组织为某种形式的显示数据结构。堆被组织为一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继的指针。使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线 性时间减少到了空闲块数量的线性时间。

图40

一种方法使用后进先出的顺序维护链表,将新释放的块在链表的开始处。使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过 的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界 标记,那么合并也可以在常数时间内完成。
按照地址顺序来维护链表,其中链 表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要 线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比 LIFO 排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
(3)分离的空闲链表:一个使用单向空闲链表的分配器需要与空闲块数量呈线性关系的时间来分配块。使用分离存储技术可以减少分配的时间,即维护多个空闲链表,其中每个链表中块有大致相等的大小。一般讲所有可能大小的块大小分成一些等价类(大小类)。简单分离存储和分离适配是两种基本的分离存储的方法。伙伴系统是分离适配的一种特例。

7.10本章小结
本章介绍了存储器地址空间、段式管理、页式管理,VA到PA的变换、物理内存访问,还介绍了进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)

第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
一个Linux文件就是一个m字节的序列,所有的I/O设备都被模型化为文件,所有的输入和输出都被当作相应文件的读和写来执行。Linux文件的类型有普通文件(regular file)、目录(directory)、套接字(socket)、命名通道(named pipe)、符号链接(symbolic link)、字符(character)、块设备(block device)。
设备管理:unix io接口
将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得输入和输出都能以一种统一且一致的方式的来执行。
8.2 简述Unix IO接口及其函数
打开文件

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

关闭文件

进程通过调用close函数关闭一个文件。
int close(int fd)
fd是需要关闭的文件的描述符,close 返回操作结果。

读文件

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

写文件

ssize_t write(int fd, const void *buf, size_t n)
write 函数从内存位置 buf 复制至多 n 个字节到描述符为 fd 的当前文件位置。
8.3 printf的实现分析
首先来看看printf函数的函数体
int printf(const char fmt, …)
{
int i;
char buf[256];
va_list arg = (va_list)((char
)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
typedef char va_list这说明它是一个字符指针。其中的: (char)(&fmt) + 4) 表示的是…中的第一个参数。

vsprintf根据格式串fmt产生格式化输出。
返回值是字符串的长度,在vsprintf后write把buf中的i个元素的值输出。
write会调用syscall(陷阱)。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
于是我们的打印字符串就显示在了屏幕上。
8.4 getchar的实现分析
getchar 的源代码为:

  1. int getchar(void)
  2. {
  3. static char buf[BUFSIZ];
  4. static char *bb = buf;
  5. static int n = 0;
  6. if(n == 0)
  7. {
  8. n = read(0, buf, BUFSIZ);
  9. bb = buf;
  10. }
  11. return(–n >= 0)?(unsigned char) *bb++ : EOF;
  12. }
    异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
    getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
    在需要从键盘读入一个字符时,内核中断当前进程,控制权交给键盘中断处理程序。getchar函数中调用了read系统函数,通过系统调用读取按键ascii码,保存到系统的键盘缓冲区,直到接受到回车键才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的字符的ASCII码,若文件结尾则返回-1(EOF),且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。
    8.5本章小结
    本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数。
    (第8章1分)
    结论
    用计算机系统的语言,逐条总结hello所经历的过程。
    Hello.c文件从源文件经历预处理、编译、汇编、链接一步步编程可执行文件,然后通过shell为其提供的运行环境进行程序的执行。
    shell创建子进程,调用execve在子进程的上下文中加载并运行可执行目标文件hello。hello的运行过程也不是一帆风顺的,系统需要处理各种异常,满足其对动态内存的申请。
    在程序运行完之后,系统还需要给它一个完整的归宿——回收进程。删除其在计算机系统的痕迹——内核内核删除为这个进程创建的所有内存空间。

你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
计算机系统是一个复杂的系统,他是我们先辈智慧的结晶,计算机程序运行的每一方面都需要精巧的设计才能满足一个程序的高效运行。程序的高效运行离不开硬件和软件的密切配合,比如高速缓存的机制。计算机系统的设计离不开抽象思维:从最底层的信息的表示用二进制表示抽象开始,到实现操作系统管理硬件的抽象:进程是对处理器、主存和I/O设备的抽象。虚拟内存是对主存和磁盘设备的抽象。文件是对I/O设备的抽象。
我们也应认识到现在计算机系统的部分局限性,不断优化应该是我们永恒的追求。

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

附件
hello.c 源文件
hello.i hello.c预处理之后文本文件
hello.s hello.i编译后的文本文件
hello.o hello.s汇编之后的可重定位目标文件
hello 链接之后的可执行目标文件
hello.out hello反汇编之后的可重定位文件

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

参考文献
[1] 《深入理解计算机系统》
[2] https://baike.baidu.com/item/预处理/7833652?fr=aladdin
[3] https://www.cnblogs.com/pianist/p/3315801.html
[4] https://baike.baidu.com/item/逻辑地址/3283849?fr=aladdin
[5] https://baike.baidu.com/item/虚拟地址/1329947?fr=aladdin
[6] (15条消息) 虚拟地址、线性地址和物理地址的转换_coco_111的博客-CSDN博客
(参考文献0分,缺失 -1分)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值