目录
第1章 概述
1.1 Hello简介
P2P:首先从一个写好的源程序程序文件(Program)开始,gcc编译器读取它,依次通过预处理器cpp将其预处理,通过编译器ccl把它变成汇编程序,汇编器as把它转化为可重定位的目标程序,最后再由链接器ld将其变为可执行的目标程序。这时,在shell中运行它,OS会通过fork来为其创建一个新的进程(Process)。
020:首先,编写出一个源程序hello.c,然后通过gcc编译器读取它,并依次经过预处理,编译,汇编,链接来生成一个可执行文件,然后再shell中执行hello文件,shell通过fork为其创建新的子进程,然后通过exceve在进程的上下文中加载并运行hello,把它映射到对应虚拟内存区域,并依需求载入物理内存,在CPU的帮助下,它的指令被一步步执行,实现它拥有的功能,在程序运行结束后,父进程会对其进行回收,内核把它从系统中清除,hello完成了它的一生,这就是020的过程。
1.2 环境与工具
1.2.1硬件环境
Intel(R) Core(TM) i5-10300H CPU @ 2.50GHz
16G RAM 256G SSD + 1T GHD
1.2.2 软件环境
Ubuntu - 20.04.4
1.2.3 开发与调试工具
gcc,edb等
1.3 中间结果
hello.i 预处理得到的文本文件
hello.s 编译得到的文本文件
hello.o 汇编之后得到的可重定位的目标文件
hello/hello.out 链接后得到的可执行文件
elf.txt hello.o的elf文件
helloelf.txt hello的elf文件
helloasm.txt hello.o的反汇编文件
helloa.txt hello的反汇编文件
1.4 本章小结
本章先了解P2P,020实现的过程,然后准备好执行hello文件的软硬件环境。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理器根据#标识的命令(头文件、宏定义、条件编译等),修改原始c代码,将包含的头文件插入到c代码中,并将宏定义进行替换,去除注释等,形成一个.i文本文件
作用:
(1)将所有的#define删除,并且展开所有的宏定义。
(2)处理所有的条件编译指令,#ifdef #ifndef #endif等。
(3)处理#include,将#include指向的文件插入到该行处。
(4)删除所有注释。
(5)添加行号和文件标示。
(6)保留#pragma编译器指令。
2.2在Ubuntu下预处理的命令
预处理指令如图所示:
图2.2.1 预处理指令
图2.2.2 预处理得到hello.i文件
2.3 Hello的预处理结果解析
图2.3.1 hello.i的开头部分
在此部分中,hello.c的注释消失了,变成了对头文件的解析
图2.3.2 hello.i的中间某部分
如图,在预处理后的文件中,将头文件读取展开插入到hello.i文件中。
图2.3.3 hello.i中最后部分
如图,main函数在hello.i的最后部分。
图2.3.4 hello.i的规模
经过预处理后,由最开始的十几行变成了三千多行。
而在hello.i中,找不到在hello.c中的文件开头的注释部分,说明在预处理中,注释部分被消除掉了,而且将头文件所包含的内容都插入进来等。
2.4 本章小结
预处理是hello程序人生的第一次转变,在这次处理中,将hello.c中的注释部分清除掉,并将头文件展开插入到文件当中,同时将宏定义替换,得到了hello.i文件,准备进行程序人生的下一步转变。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译的概念:编译是指将一个经过预处理的程序文本(.i文件)翻译成能执行相同操作的等价ASII码形式汇编语言文件(.s文件)的过程。
编译包括以下基本流程:词法分析、语法分析、中间代码、代码优化、目标代码。
作用:编译器做一些语法分析、词法分析、语义分析等,若检查无错误,便将高级程序设计语言(C语言)书写的程序转变为与其等价的汇编语言程序。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
3.2 在Ubuntu下编译的命令
图3.2.1 编译指令
图3.2.2 编译得到hello.s文件
3.3 Hello的编译结果解析
3.3.1 以.开头的汇编指令
图3.3.1.1 hello.s的开头部分
其中的以.开头的指令如下解释含义
.file 声明源文件
.text 代码段
.global 声明一个全局可见的名字(可能是变量,也可能是函数)
.align 对指令或数据的存放地址进行对齐
.type 用来指定一个符号的类型是函数类型或是对象类型
.size 指定一个符号的大小
3.3.2 数据部分
3.3.2.1 传入的形参数组
图3.3.2.1 hello.c中传入的形参数组
图3.3.2.2 hello.s中%edi中存argv存入栈,%rsi存argv[]存入栈
3.3.2.2 局部变量
图3.3.2.3 在main函数中声明局部变量i
图3.3.2.4 在hello.s中声明变量i的指令
在hello.s中,先在栈中开辟一块空间,地址为%rbp-4,赋值为0,此处村的就是变量i。
3.3.2.3 字符串
图3.3.2.5 main函数中字符串
图3.3.2.6 在hello.s中字符串部分
3.3.3 赋值操作
图3.3.3.1 数据传送的指令
图3.3.3.2 算数和逻辑操作
图3.3.3.3 hello.s中部分赋值指令
在汇编指令中,如上图使用mov指令进行赋值,如图便是把1存到%edi寄存器中。
图3.3.3.4 hello.s中的push指令进行压栈
图3.3.3.5 hello.s中减法指令
3.3.5 控制跳转
图3.3.5.1 比较指令
图3.3.5.2 跳转指令
图3.3.5.3 hello.s中的控制跳转
如图,将%rbp-4地址保存的值与7比较,若小于等于,则跳转到.L4
而其中call指令,直接调用后面的函数
3.3.6 函数操作
图3.3.6.1 puts()和exit()函数的调用
图3.3.6.2 printf()函数的调用
图3.3.6.3 atoi()和sleep()函数的调用
图3.3.6.4 getchar()函数的调用
3.4 本章小结
在本章中,我们看到了hello.i转变为了hello.s,在汇编文件中,我们了解到了一些汇编指令,看到了一些数据类型,控制跳转,函数操作等实现,汇编语言是非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语言。接下来便是hello程序人生的下一次转变。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:把汇编语言翻译成机器语言的过程称为汇编。
汇编的作用:汇编器(as)将hello.s翻译成机器语言指令,并把这些指令打包成一种叫做可重定位目标程序的格式,将结果存在目标文件hello.o中。
4.2 在Ubuntu下汇编的命令
图4.2.1 汇编的命令
图4.2.2 生成的hello.o文件
4.3 可重定位目标elf格式
图4.3.1 elf的生成
图4.3.2 elf头
我们可以在elf头中看到包含如下信息:Magic(生成该文件的系统的字的大小和字节顺序)、目标文件类型、数据、版本、系统架构、程序头起点、程序头大小,节头大小和数量等。
图4.3.3 elf节头
我们可以在这部分中看到如下信息:各节的名称,大小,类型,全体大小,地址,旗标,链接,信息,对齐,偏移量。
图4.3.4 重定位节
如图中包含信息:Main函数调用的puts,exit,printf,sleep,getchar函数以及全局变量sleepsecs和.rodata节,除此之外,还有.radata字节的偏移量,信息,类型,符号值,符号名称,加数。
在这里出现的R X86_64PC32是重定位一个使用32位PC的相对地址引用;R X86_64_32是重定位一个使用32位PC的绝对地址的引用。
链接过程中链接器通过公式:
refptr = s + r.offset
refaddr = ADDR(s) + r.offset
refptr = (unsigned)(ADDR(r.symbol)+r.addend-refaddr)
可以得出refptr。
因为是PC相对的寻址方式,所以需要计算出运行时需要被重定位的位置和要重定位到符号的位置,然后计算两者的差,然后用这个差来修改当前.text中该位置的值,使其能够在运行时能够指向正确的位置。
addend的作用:寻址全局变量时,使用下一条指令地址来进行寻址,所以此处addend = -4,就是为了调整上面计算出来的差变为符号与下一条指令地址的差。
图4.3.5 Symbol table
符号表,用来存放程序中定义和引用的函数和全局变量的信息。重定位需要引用的符号都在其中声明。
4.4 Hello.o的结果解析
图4.4.1 反汇编指令
图4.4.2 反汇编生成的汇编代码
1.反汇编.o文件得到的汇编代码和汇编.i文件得到的汇编代码的不同之处:
(1)汇编中mov、push、sub等等的指令后都有表示操作数大小的后缀,比如q、l、w、b等,反汇编得到的代码中没有。
(2)汇编代码中有很多以“.”开头的伪指令,反汇编得到的代码中没有。
(3)汇编代码中调用函数是用“call 函数名”来表示,而反汇编代码中用“call 数字”表示,且相对应的机器代码中有PC相对引用的占位符。
(4)汇编代码中跳转“jmp 标志”的形式,在反汇编代码中直接为“jmp地址”的形式。
(5)汇编代码每行开头直接是指令,而反汇编得到的代码由一串数字构成的机器指令开头。
2.机器语言的构成:
机器语言由二进制的机器指令序列集合构成,机器指令由操作码和操作数组成。
与汇编语言的映射关系:一条汇编语言对应一条机器指令,其中每条机器指令的长度不一定一致。
4.5 本章小结
本章介绍了从hello.o到hello.s的汇编过程。查看了hello.o的ELF,通过用objdump查看其反汇编代码与hello.s比较,了解了机器语言的构成,及其与汇编语言的映射关系,特别是机器语言中的操作数与汇编语言不一致,还有分支转移和函数调用等的不同。完成了汇编的转变,接下来hello就要进入下一步的转变了。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
链接的概念:链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行与加载时,也就是在源代码被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
链接的作用:链接使得分离编译成为可能,即可以将一个大项目分解为较小的、更好管理的模块,可以单独对其进行修改和变异,最后再将其链接到一起。
5.2 在Ubuntu下链接的命令
图5.2.1 链接命令
图5.2.2 生成的hello文件
5.3 可执行目标文件hello的格式
图5.3.1 elf头部
图5.3.2 elf的节头
图5.3.3 elf的程序头
节头部表包含了hello中的每一个节,包含了名称、类型、地址、偏移量、大小、全体大小、旗标、链接、信息、对齐。
图5.3.4 elf的重定位节
5.4 hello的虚拟地址空间
图5.4.1 虚拟地址空间
图5.4.2 elf程序头
通过分析我们可以知道,在 0x401000~0x402000 段中,hello程序被载入.
查看 ELF 格式文件中的程序头表,它告诉链接器运行时加载的内容并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的大小、位置、标志、访问权限和对齐方面的信息。在上图可以看出,程序包含如下的段:
PHDR:保存程序头表。
INTERP:指定在程序已经从可执行文件映射到内存之后必须调用的解释器。
GNU_STACK:权限标志,标志栈是否是可执行的。
GNU_RELRO:指定在重定位结束之后需要设置把哪些内存区域设置为只读的。
LOAD:表示一个需要从二进制文件映射到虚拟地址空间的段。其中保存了常量数据、程序的目标代码等。
DYNAMIC:保存由动态链接器使用的信息。
NOTE:保存辅助信息。
5.5 链接的重定位过程分析
图5.5.1 由hello文件反汇编生成汇编文件
图5.5.2 链接后文件中增加的函数对应的汇编代码
图5.5.3 main函数的汇编代码
在使用ld命令链接的时候,指定了动态链接器为/lib64/ld-linux-x86-64.so.2,程序调用hello.c中的main函数,libc.so是动态链接共享库,其中定义了hello.c中用到的printf、sleep、getchar、exit函数和_start中调用的__libc_csu_init,__libc_csu_fini,__libc_start_main。链接器将上述函数加入。
重定位的方式:
首先计算需要被重定位的位置: refptr = .text + r.offset
然后链接器计算出运行时需要重定位的位置::refaddr = ADDR(.text) + r.offset
然后更新该位置:*refptr = (unsigned) (ADDR(r.symbol) + r.addend-refaddr)
5.6 hello的执行流程
ld-2.27.so!_dl_start
ld-2.27.so!_dl_init
hello!_start
libc-2.27.so!__libc_start_main
puts@plt
exit@plt
printf@plt
sleep@plt
getchar@plt
libc-2.27.so!exit
5.7 Hello的动态链接分析
图5.7.1 节头部表
动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
函数调用时,首先跳转到 PLT 执行.plt 中逻辑,第一次访问跳转时GOT 地址为下一条指令,将函数序号压栈,然后跳转到 PLT[0],在 PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写 GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数。
5.8 本章小结
本章介绍了链接的概念、作用、可执行目标文件hello的格式、hello的虚拟地址空间、链接的重定位过程分析、hello的执行流程以及hello的动态链接分析。
链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。至此,hello的程序人生已经来到了可执行的时候。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
进程是一个执行中程序的实例,系统中每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程为程序提供了一个假象:好像它是系统当前唯一运行的程序,好像是在独占的使用处理器和内存,处理器好像在无间断的执行该程序中的指令,好像它的代码和数据是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个交互型应用级程序,代表用户运行其他程序。是系统的用户界面,提供了用户与内核进行交互操作的一种接口。它接收用户输入的命令并把它送入内核去执行。
其基本功能是解释并运行用户的指令,重复如下处理过程:
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
(3)检查第一个命令行参数是否是一个内置的shell命令
(4)如果不是内部命令,调用fork( )创建子进程
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(6)如果用户没要求后台运行则shell使用waitpid(或wait)等待作业终止后返回。
(7)如果用户要求后台运行,则shell返回。
6.3 Hello的fork进程创建过程
在终端中输入./hello 120L022102 xs
图6.3.1 执行hello文件
shell首先对命令进行解析,判断它是否是一个内置的shell命令。然后发现这并不是一个内置命令,而是当前目录下的一个可执行目标文件。然后调用fork函数为hello创建一个子进程。
新创建的子进程几乎但不完全与父进程相同:子进程得到与父进程虚拟地址空间相同的(但是独立的)一份副本,子进程获得与父进程任何打开文件描述符相同的副本,子进程有不同于父进程的PID。
图6.3.2 hello进程图
6.4 Hello的execve过程
execve函数原型:
int execve(const char* filename,const char* argv[],const char* envp[]);
execve 函数在当前进程的上下文中加载并运行可执行目标文件filename=hello,且带参数列表argv和环境变量列表envp,只有当出现错误时,例如找不到hello,execve 才会返回到调用程序,所以,与fork一次调用返回两次不同,execve 调用一次并从不返回。
argv变量指向一个以NULL结尾的指针数组,其中每个指针都指向一个参数字符串。按照惯例argv[0]是可执行目标文件的名字。环境变量列表也是由一个类似的数据结构表示的,envp变量指向一个以NULL结尾的指针数组,其中每个指针指向一个环境变量字符串,每个字符串都是形如”name=value”的“名字-值”对。
6.5 Hello的进程执行
处理器提供了一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常用某个控制寄存器中的一个模式位来提供这种功能,该寄存器描述了进程当前享有的特权。当设置了模式位时,进程就运行在内核模式中。一个运行在内核模式中的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个I/O操作。进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中成为调度器的代码处理的。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种成为上下文切换的机制来控制转移到新的进程:
1)保存当前进程的上下文
2)恢复某个先前被抢占的进程被保存的上下文
3)将控制传递给这个新恢复发进程
当hello调用sleep函数时,它显示地请求了让进程休眠。此时,hello进程被挂起,从运行队列加入等待队列,定时器开始计时2s。调度器通过上下文切换,重新开始一个之前被抢占了的进程。当sleep调用完毕的时候,会发送一个中断信号,此时内核将当前进程挂起(或终止)并返回到hello进程。
图6.5.1 上下文切换
6.6 hello的异常与信号处理
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
图6.6.1 不停乱按
图6.6.2 不停乱按回车
在运行过程中按下回车不影响进程输出,不会让进程终止或挂起。
图6.6.3 按CTRL+Z和CTRL+C
运行过程中按下Ctrl-Z后,显示进程已停止。
图6.6.4 ps命令
发现hello进程并没有结束。
图6.6.5 jobs命令
图6.6.6 pstree命令
6.7本章小结
一个系统中有成百上千个程序在同时运行,那么如何管理它们,让它们既能互不影响的运行,又能在必要的时候进行通信就是一个很重要的问题。一个系统要能够有效的运行,它必须建立一个简单但有效的模型。计算机系统为了解决这个问题,提供了两个抽象:进程让每个程序都以为只有它自己在运行,虚拟内存让每个程序都以为它自己在独占整个内存空间。这两个抽象使得计算机系统能够对每个程序都够以一致的方式去管理。多任务就通过进程之间快速的切换来实现,程序之间的影响就通过进程之间的通信——信号来实现。
(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:又称相对地址。由段地址加上偏移地址构成,它是描述一个程序运行段的地址。
线性地址:是经过段机制转化之后用于描述程序分页信息的地址。它是对程序运行区块的一个抽象映射。
虚拟地址:虚拟地址跟线性地址相同,都是对程序运行区块的相对映射。
物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。它是在前端总线上传输的而且是唯一的。物理地址中很大一部分是留给内存条中的内存的,但也常被映射到其他存储器上。
在没有使用虚拟存储器的机器上,虚拟地址被直接送到内存总线上,使具有相同地址的物理存储器被读写;而在使用了虚拟存储器的情况下,虚拟地址不是被直接送到内存地址总线上,而是送到存储器管理单元MMU,把虚拟地址映射为物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
段式管理就是把虚拟地址空间中的虚拟内存组织成一些长度可变的称为段的内存单元。段是虚拟地址到线性地址转化的基础。每个段有三个参数定义:
段基地址:指定段在线性地址空间中的开始地址。基地址是线性地址对应于段中偏移0处。
段限长:是虚拟地址空间中段内最大可用偏移地址。定义了段的长度。
段属性:指定段的特性。如该段是否可读、可写或可作为一个程序执行,段的特权级等。
这三个参数存储在一个称为段描述符的结构项中。在逻辑地址到线性地址的转换映射过程中会使用这个段描述符。段描述符保存在内存中的段描述符表中。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址通过页表查找来对应物理地址。
分页的基本原理是把内存划分成大小固定的若干单元,每个单元称为一页,每页包含4k字节的地址空间。每一页的起始地址都是4k字节对齐的。为了能转换成物理地址,需要给CPU提供当前任务的线性地址转物理地址的查找表,即页表。为了实现每个任务的平坦的虚拟内存,每个任务都有自己的页目录表和页表。
7.4 TLB与四级页表支持下的VA到PA的变换
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块。TLB通常有高的相联度,从虚拟地址中的页号提取出组选择和行匹配的索引和标记字段。
因为所有的地址翻译都是在芯片上的MMU中进行的,因此非常快。
多级页表:将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。如下图,VPN被分为k个部分,第一级VPN结合基址寄存器得到一个页表条目,其中存放下一级页表的基址,再结合VPN2,得到第三级页表基址,继续寻找,以此类推,直到最后确定对应的物理页号,与VPO结合,由图7.3,得到由PPN与PPO结合成的物理地址,用于物理地址寻址。
图7.4.1 虚拟地址访问TLB
如果是二级页表,第一级页表的每个PTE负责一个4MB的块,每个块由1024个连续的页面组成。二级页表每一个PTE负责一个4KB的虚拟地址页面。这样的好处在于,如果一级页表中有一个PTE是空,那么二级页表就不会存在,这样会有巨大的潜在节约,因为4GB的地址空间大部分都是未分配的。
7.5 三级Cache支持下的物理内存访问
CPU发送一条虚拟地址,随后MMU按照上述操作获得了物理地址PA。根据cache大小组数的要求,将PA分为CT(标记位)CS(组号),CO(偏移量)。根据CS寻找到正确的组,比较每一个cacheline是否标记位有效以及CT是否相等。如果命中就直接返回想要的数据,如果不命中,就依次去L2,L3,主存判断是否命中,当命中时,将数据传给CPU同时更新各级cache的cacheline(如果cache已满则要采用换入换出策略)。
7.6 hello进程fork时的内存映射
当shell调用fork函数时,内核为新进程hello创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在进程hello中返回时,进程hello现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数调用内核区域的启动加载器加载运行可执行目标文件hello。
加载运行hello有以下四个步骤:
①删除已存在的用户区域
②映射私有区域。为新进程的代码、数据、bss和栈区域创建新的区域结构。
③映射共享区域。hello程序和共享对象libc.so链接。
④设置程序计数器PC。
图7.7.1 映射地址空间
7.8 缺页故障与缺页中断处理
处理缺页要求硬件和操作系统内核协作完成:
1、处理器生成一个虚拟地址,并把它传送给MMU。
2、MMU生成PTE地址,并从高速缓存/主存请求得到它。
3、高速缓存/主存向MMU返回PTE。
4、PTE中的有效位是0,所以MMU触发了一次异常,传递CPU中的控制到操作系统内核中的缺页异常处理程序。
5、缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。
6、缺页处理程序页面调入新的页面,并更新内存中的PTE。
7、缺页处理程序返回到原来的进程,再次执行导致缺页的指令。CPU将引起缺页的虚拟地址重新发送给MMU。因为虚拟页面现在缓存在物理内存中,所以就会命中。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。对每个进程,内核维护着一个变量brk,它指向堆的顶部。分配器将堆视为一组不同大小的块(block)的集合来维护。每个块是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式的保留为供应程序使用。空闲块保持空闲,直到它被应用所分配。已分配的块保持已分配,直到它被释放。
显示分配器要求应用显示地释放任何已分配的块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个快。
malloc函数返回一个指针,指向大小为至少size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。若malloc遇到问题,就返回NULL,并设置errno。malloc不初始化它返回的内存。
隐式空闲链表:
空闲块通过头部中的大小字段隐含的链接着,分配器可以可以通过遍历堆中所有的块,从而间接的遍历整个空闲块的集合。当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个大小足够放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是首次适配、下一次适配和最佳适配。
一旦分配器找到一个匹配的块,他就必须做另外一个决定,那就是分配这个空闲块中多少的空间。
若找不到合适的空闲块,一个选择是合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块。,如果还不够,就会通过sbrk函数,向内核请求额外的堆内存。至于合并策略,分配器可以选择立即合并或推迟合并。
带边界标记的合并:
在每个块的结尾处添加一个脚部(头部的一个副本),分配器可以通过检查他的脚部,判断前一个块的起始位置和状态。这个脚部总是在距离当前快开始位置一个字的距离。
图7.9.1 隐式空闲链表
显式空闲链表:
将空闲块组织为某种形式的显式数据结构。因为根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以存放在这些空闲块的主体里面。
分离的空闲链表:维护多个空闲链表,其中每个链表中的块有大致相等的大小。以下是几种基本的分离存储方法:
1、简单分离存储:每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。
2、分离适配:分配器维护着一个空闲链表的数组。每个空闲链表是和一个大小类相关联的,并被组织成一种类型的显式或隐式链表。每个链表包含潜在的大小不同的块,这些块的大小是大小类的成员。
图7.9.2 显式空闲链表
7.10本章小结
本章主要介绍了hello的存储器地址空间、Intel的段式管理与页式管理、TLB与四级页表支持下的VA到PA的变换、三级Cache支持下的物理内存访问、hello进程fork时的内存映射、hello进程execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理等内容。
理解虚拟内存将帮助程序员更好地理解系统是如何工作的,理解虚拟内存将帮助程序员利用虚拟内存的强大功能在应用程序中添加动力,理解虚拟内存以及诸如malloc之类的管理虚拟内存的分配程序,可以帮助程序员避免很多错误。
理解存储器层次结构,因为它对应用程序的性能有着巨大的影响。如果我们的程序需要的数据是存储在CPU 寄存器中的,那么在指令的执行期间,在0个周期内就能访问到它们。如果存储在高速缓存中,需要4~75个周期。如果存储在主存中,需要上百个周期。而如果存储在磁盘上,需要大约几千万个周期。理解存储器层次结构,对于我们以后编写一些运行速度更快的代码十分重要。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2 简述Unix IO接口及其函数
Unix I/O接口统一操作:
设备可以通过Unix I/O接口被映射为文件,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
打开文件:
一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
Linux shell创建的每个进程开始时都有三个打开的文件:
标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件定义了常量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。
关闭文件:
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O接口函数:
(1) int open(char* filename,int flags,mode_t mode) ,进程通过调用 open 函数来打开一个存在的文件或是创建一个新文件的。open 函数将 filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags 参数指明了进程打算如何访问这个文件,mode 参数指定了新文件的访问权限位。
(2) int close(fd),fd 是需要关闭的文件的描述符,close 返回操作结果。
(3) ssize_t read(int fd,void *buf,size_t n),read 函数从描述符为 fd 的当前文件位置赋值最多 n 个字节到内存位置 buf。返回值-1 表示一个错误,0表示 EOF,否则返回值表示的是实际传送的字节数量。
(4)ssize_t wirte(int fd,const void *buf,size_t n),write 函数从内存位置 buf复制至多 n 个字节到描述符为 fd 的当前文件位置。
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;
}
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’: //只处理%x一种情况
itoa(tmp, ((int)p_next_arg)); //将输入参数值转化为字符串保存在tmp
strcpy(p, tmp); //将tmp字符串复制到p处
p_next_arg += 4; //下一个参数值地址
p += strlen(tmp); //放下一个参数值的地址
break;
case ‘s’:
break;
Default:
break;
}
}
return (p - buf); //返回最后生成的字符串的长度
}
作用是产生格式化输出。
write函数:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL;
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_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
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
getchar原函数:
int getchar(void)
{
static char buf[BUFSIZ];
static char* bb=buf;
static int n=0;
if(n==0)
{
n=read(0,buf,BUFSIZ);
bb=buf;
}
return (--n>=0)?(unsigned char)*bb++:EOF;
}
getchar有一个int型的返回值。当程序调用getchar时,程序就等着用户按键,用户输入的字符被存放在键盘缓冲区中直到用户按回车为止(回车字符也放在缓冲区中)。
当用户键入回车之后,getchar才开始从stdio流中每次读入一个字符。getchar函数的返回值是用户输入的第一个字符的ascii码,如出错返回-1,且将用户输入的字符回显到屏幕。如用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取。也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux的IO设备管理方法、Unix IO接口及其函数,重点分析了printf函数和getchar函数。
(第8章1分)
结论
1、编写C语言程序。
2、预处理:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
3、编译:在编译阶段,编译器将高级语言编译成汇编语言。汇编语言不具有可移植性,是直接面向处理器的语言,是机器指令的一种符号表示,不同类型的计算机系统有不同的机器指令系统,也就有不同的汇编语言。
4、汇编:在汇编阶段,汇编器将汇编语言指令翻译成机器可执行的机器语言指令。
5、链接:链接器在软件开发中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其他文件。
6、进程管理:进程是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。进程给应用程序提供两个关键的抽象:一个独立的逻辑控制流,一个私有的地址空间。
7、存储管理:CPU上的内存管理单元MMU根据页表将CPU生成的虚拟地址翻译成物理地址,进行相应的页面调度。在这个过程中,TLB,三级cache结构的使用加快了访存速度。
8、I/O管理:Linux提供了少量的基于Unix I/O模型的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行I/O重定向。Linux的读和写操作会出现不足值,应用程序必须能正确的预计和处理这种情况。应用程序不应直接调用Unix I/O函数,而应该使用RIO包,RIO包通过反复执行读写操作,直到传送完所有的请求数据,自动处理不足值。
感想:感受到了计算机系统的复杂。并且懂得了要想构建一个行之有效的系统,对每个部分进行抽象是非常重要的,它能让系统使用一些一致的操作来对系统中的每个任务进行有效的管理。子系统的局部优化也很重要,它能够让整个系统运行的更快。学好计算机系统十分重要,能够让我们作为一个程序员更好的深入的了解我们编写的每一个程序的执行方式、情况等,理解计算机系统,能够让我们编写出一个优秀的程序,无论是从性能、优化程度等方面来讲,都有些和巨大的帮助,所以当我们计算机系统这门课结课以后,我们也应当继续学习计算机系统,不断去理解掌握它的知识、内涵,帮我我们打造一个优秀的程序员。
(结论0分,缺失 -1分,根据内容酌情加分)
附件
附图:生成的文件
hello.c hello的c源代码文件
hello.i 预处理得到的文本文件
hello.s 编译得到的文本文件
hello.o 汇编之后得到的可重定位的目标文件
hello/hello.out 链接后得到的可执行文件
elf.txt hello.o的elf文件
helloelf.txt hello的elf文件
helloasm.txt hello.o的反汇编文件
helloa.txt hello的反汇编文件
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
[1] 虚拟地址、逻辑地址、线性地址、物理地址:
https://blog.csdn.net/rabbit_in_android/article/details/49976101
[2] Randal E.Bryant,David R. O’Hallaron, 深入了解计算机系统:工业出版社,2016.
[3]https://www.cnblogs.com/lxgeek/archive/2011/01/01/1923738.html汇编指令
[4]C语言真正的编译过程:
https://blog.csdn.net/qq_29924041/article/details/54917521
(参考文献0分,缺失 -1分)