Hello的一生
第1章 概述
1.1 Hello简介
首先程序员通过键盘和编辑器编写hello.c的代码,保存为文本文件。之后我们的hello的生命就开始了!预处理cpp首先将其处理为hello.i,之后由编译器ccl处理为hello.s汇编语言文件,再由汇编器as处理得到hello.o可重定位二进制文件。之后链接器ld将hello.o与其他.o文件或者函数库进行连接,得到了一个可执行文目标程序hello,并将其安安稳稳放在了磁盘上。由此便是p2p的过程。
之后的阶段,将文件名输入到名为shell应用程序,shell会加载并运行我们的hello,具体为,利用fork得到一个与父进程几乎相同的子进程,并且分配相对应的内存资源,虚拟内存,CPU的使用权限,shell为他execve,调用加载器,并映射虚拟内存,之后跳转到程序入口,调用main函数,CPU通过取值、译码、执行操作来实现程序,并按照进程切换方式工作。当程序运行结束后,其处于终止状态,父进程对hello进行回收,删除相应的数据,hello从此消失,不带走一片云彩。这就是020。
1.2 环境与工具
1.2.1 硬件环境
X64CPU 2.80GHZ 8.0G RAM 1TB HD Disk 1.2.2
1.2.2 软件环境
Windows 10 X64位 Vmware14 Ubuntu16.04LTS64位
Ubuntu下GDB、edb 、readelf、vim
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件 | 作用 |
---|---|
hello.i | hello.c文件预处理后的文件 |
hello.s | hello.i文件经过编译之后得到的文件 |
hello.o | hello.s汇编之后得到的可重定位目标文件 |
helloo.elf | hello.o的elf格式文件 |
hello.c | hello.c的C语言文件 |
hello.elf | hello可执行目标文件的elf格式文件 |
hello | hello.c经过编译之后得到的可执行目标文件 |
1.4 本章小结
本章主要描述了hello从生到死传奇的一段“程”生,尽管只是笼统的介绍,但也算对他的生平有了一定的了解。同时本章还说明了本次作业的环境和中间结果的作用。
第2章 预处理
2.1 预处理的概念与作用
预处理的概念:预处理器(cpp)根据以字符“#”开头的命令,修改原始的C程序。
作用:处理代码中以#开头的预编译指令、删除注释等
例如:预处理会对下面的带#的指令进行文字描述的操作
#include 包含一个源代码文件
#define 定义宏
#undef 取消已定义的宏
#if如果给定条件为真,则编译下面代码
#ifdef 如果宏已经定义,则编译下面代码
#ifndef如果宏没有定义,则编译下面代码
#elif 如果前#if条件不为真,当前条件为真,则编译下面代码,其实就是else if的简写
#endif结束一个#if……#else条件编译块
#error停止编译并显示错误信息
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
上图是hello.c预处理之后的结果hello.i,可以看到hello.c没有了注释,已经被扩充到了3126行,,较原来的hello.c有了很多补充,也就是将一些头文件插入到了原来的代码之中,而且插入的文件可能也含有一些宏,又需要进行预处理,总之整个过程是递归进行的。整个.i文件仍然是C语言可读的。
2.4 本章小结
本章主要是对hello.c的生命开端的描述,介绍了其p2p最开始的步骤,预处理,描述了预处理的概念作用以及在linux下的指令,并将预处理之后的hello.i文件和hello.c进行了比较。
第3章 编译
3.1 编译的概念与作用
编译概念:编译的概念在这里是指编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
编译的作用:编译的作用就是将一个.i文件通过一系列的分析优化,生成相应的汇编语言程序以进行后续的操作。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
3.3 Hello的编译结果解析
3.3.1数据
首先在hello.c中有整形全局变量sleepsecs且被赋值为2.5,我们来看下在hello.s中他是如何被定义的。如下图
可以看到他被定义为全局类型.globl 并且在.data节,且4字节对齐,类型为@object 大小是4字节并且类型是long(linux下long4字节)且值为2.
hello.c中还有一个局部变量i 其应该存放在栈中在hello.s中如下描述
可知是将其放在了%rbp-4栈空间里
hello.c最后一个整形变量是argc,作为第一个参数传入其存放在%edi中后被送入-20(%rbp)中如下图
接着我们来看字符串,在hello.c中有两个字符串,分别作为printf函数的传入,在hello.s中是如下定义的
可以看到他们都被存放在.rodata节中
hello.c中还有一个数组 char *argv[] 可以看到在hello.s中他的首地址被存放在rsi后被送入-32(%rbp)中并通过如下进行访问红线是获取地址,黑线是获取值
3.3.2赋值
1、将sleepsecs赋值为2.5,直接在hello.s开头进行如下图操作
2、将i赋值为0 使用的是mov操作如下图
3.3.3 类型转换
在hello.c中隐式的将float类型的2.5转换为整型,遵从向0舍入,如下图
3.3.4算术操作
在进行对i的for循环时,有一个i++操作,在hello.s中是如下实现的
3.3.5关系操作
在hello.c的for循环中存在一个i与10的比较 判断i<10是否成立,在hello.s中如下图表示
在if中有一条比较argc和3的指令,在hello.s中如下图表示
3.3.6数组操作
在hello.c中有对第二个参数即数组的操作,在hello.s中通过如下进行访问,红线是获取地址,黑线是获取值
3.3.7控制转移
1、在比较argc和3的大小时,有一个if控制语句,在hello.s中如下操作实现
2、在for循环时也有控制转移,具体操作如下
可以看到两者都是通过比较值的大小后,利用条件跳转指令来进行跳转
3.3.8函数操作
参数传递:对于main函数第一个参数放在edi中第二个参数放在rsi寄存器
在调用puts放在edi中
调用sleep放在edi中
调用函数:
调用pus函数的指令
调用printf函数
调用sleep函数
3.4 本章小结
本章主要介绍了编译的概念和作用,并通过一个事例来看到对于C语言中不同的数据和操作在通过汇编之后究竟如何变化,我们可以看到编译并不是将可爱的hello彻底毁容了,实际上hello并没有面目全非,他仍是可读的。
第4章 汇编
4.1 汇编的概念与作用
汇编概念:汇编器将hello.s翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在目标文件hello.o中。
汇编作用:将汇编语言翻译成机器可以读懂执行的命令。
4.2 在Ubuntu下汇编的命令
gcc -c hello.s -o hello.o
4.3 可重定位目标elf格式
首先是elf头,其以一个16字节的magic序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包扩ELF头的大小,这里是64字节,目标文件的类型 这里是可重定位文件、机器类型这里是x86-64、节头部表中条目的大小和数量这里是13,以及节头部表的文件偏移。
节头表包含了文件中出现的节的类型位置和大小。
其中对于重定位.rela.text我们重点分析,他是一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,修改这些位置。
上图是.rela.text的部分,偏移量是需要进行重定向的代码在.text或.data节中的偏移位置。类型一般分为两种重定位类型 一是相对重定位,二是绝对重定位,相对重定位的地址计算方法为S+A-P;绝对重定位地址计算方法S+A;其中A代表加数值,在IA32体系结构上,由重定位位置处的内存内容隐式提供(一般为操作码后面的数值)。S是目标文件符号表中保存的符号对应地址,而P代表重定位的位置偏移量,换言之,即算出的数据写入到二进制文件中的位置偏移量(修改处的运行时地址或者偏移,对于目标文件P为修订处段内的偏移,对可执行文件P为运行时的地址)。如果加数值为0,那么绝对重定位只是将符号表中的符号的值插入在重定位位置。但在相对重定位中,需要计算符号位置和重定位位置之间的差值。
4.4 Hello.o的结果解析
经过反汇编之后的程序代码如左侧背景黑色图片显示,原汇编如白色图显示。
可以看到两者基本上是相同的,并没有特别大的差异,但是还是有一些不同的地方。
1、 在操作数上,可以看到在原来的hello.s文件中,立即数都是采用的10进制,而在反汇编代码中立即数采用的是16进制。
2、 在跳转分支的地方,在反汇编代码中,跳转后面直接接的是一个相对偏移量,而在hello.s中是接的符号
3、 在调用函数时同样是如此,在call指令后面反汇编接的是下一条指令地址,而hello.s接的是函数名。
4.5 本章小结
本章主要是介绍了hello从hello.s变成hello.o的过程,并对elf文件的格式进行介绍,其中重点介绍了rela.text节重定位的部分,并将原来的hello.s和hello.o进行反汇编的代码进行了比较,找出了其中的共性和不同。
第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
5.3 可执行目标文件hello的格式
在节头中记录了每个节的信息,包括节的名称,和大小,类型、对齐以及偏移量,在虚拟地址中的起始地址,通过这些信息就可以确定每个节的地址段。
在可执行目标文件的elf中还有一个程序头表,elf可执行文件被设计得很容易夹子道内存,其连续得片被映射到连续得内存段,程序头表描述了这种映射关系。他描述了段的访问权限,在文件偏移地址,段内存大小,映射到虚拟地址的起始地址,虚拟地址中的内存大小。
5.4 hello的虚拟地址空间
在edb的data_dump窗口中查看hello的虚拟地址空间,可以看到在0x00400000处程序开始被加载,如下图所示。
之后我们将得到的虚拟地址与上一节中的信息进行对比,对比如下图
可以看到.interp的起始地址是0x4001c8我们可以在data dump窗口中查看这个节的内容具体如上图所示,同时我们可以按照相同的方法来查看其他节
5.5 链接的重定位过程分析
通过指令得到hello的objdump与hello.o的Objdump进行比较如下图所示
可以看到两者之间的不同主要在于
1、 hello中多了一些节如.init .plt节
2、 hello中引入了一些外部函数
3、 hello.o中跳转和函数的调用在hello中被具体确定,是一个明确的虚拟内存地址
接着我们来说明链接过程,整个链接过程,首先是对符号进行解析,这个过程需要链接器从左到右按照在编译器驱动程序命令行上出现的顺序来扫描可重定位目标文件和存档文件。在完成解析的任务后,就把代码中的每个符号引用和正好一个符号定义(即它的一个输入目标模块中的一个符号表条目)关联起来。开始重定位,将合并输入模块,并为每个符号分配运行时的地址。首先进行重定位节和符号定义,链接器将所有相同类型的节合并为同一类型的新的聚合节。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址。然后是重定位节中的符号引用,链接器会修改中的代码节和数据节中对每一个符号的引用,使得他们指向正确的运行地址。
我们以hello.o中的一个需要重定位的内容进行举例。
我们对sleepsecs这个变量进行重定位分析,由重定位表中的偏移量为0x5c我们知道需要对偏移量为0x5c的变量进行重定位,在反汇编代码中我们看到0x5c部分的代码为全零,这样找到了要重定位的位置。接着我们看到sleepsecs的类型是重定位pc相对引用。我们假设链接器已经确定了.text的地址为P sleepsecs的地址为S那么我们首先计算运行时引用的地址refaddr=P+偏移量=P+0x5c 接着更新引用Refptr= S+(-4)-P-0x5c,至此重定位完成
5.6 hello的执行流程
程序名称 | 程序地址(H) |
---|---|
ld-2.27.so!_dl_start | 00007f149e5699b0 |
ld-2.27.so!_dl_init | 00007f149e578740 |
hello!_start | 00000000004004d0 |
libc-2.27.so!__libc_start_main@plt | 0000000000400480 |
libc-2.23.so!__libc_start_main | 00007f149e1be764 |
hello! __libc_csu_init | 0000000000400582 |
hello!_init | 0000000000400430 |
hello!main | 00000000004005f2 |
hello!_put@plt | 0000000000400460 |
hello!exit@plt | 00000000004004a0 |
hello! _fini () | 00000000004006e4 |
5.7 Hello的动态链接分析
在调用共享库内的函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块可以加载到任何位置。GNU采取延迟绑定的方法,将过程地址的绑定推迟到第一次调用该程序。其主要依据的原理代码段中指令和数据段中任何变量距离都已是一个运行时常量,与代码段和数据段的绝对内存位置无关。
延迟绑定通过两个数据结构 PLT和GOT来实现的。
PLT是一个数组,每个条目16字节代码,PLT[0]是一个特殊的条目,挑战到动态链接器中。PLT[1]调用系统启动函数,PLT[2]开始调用用户代码调用的函数。
GOT:是一个数组,每个条目8个字节地址,和PLT联合使用时,GOT[0]和GOT[1]包含动态链接器在解析函数地址时会用的信息。GOT[2]时动态链接器在ld-linux.so模块中入口点。其余对应一个被调用函数。初始时,每个GOT指向对应的PLT条目第二条指令。
我们来分析一个实例,在调用addvec时,
1、程序调用进入PLT[2]
2、第一条PLT指令通过GOT[4]间接跳转,跳转到PLT[2]第二条指令
3、把addvecID压入栈,PLT[2]跳转到PLT[0]
4、PLT[0]通过GOT[1]把动态链接器参数压入栈中,通过GOT[2]间接跳转进动态链接器中。动态链接器确定addvec运行位置,重写got[4]
对于我们的hello我们通过5.3小节,知道它的.got地址是0x6008b8,查看他的信息发现全是0
在调用dl_init之后
看到got[2]有了内容,进入这个地址
发现是动态链接库的入口
5.8 本章小结
本章主要介绍了链接的过程,通过对链接后hello的编译,查看,我们搞懂了链接后hello在虚拟空间中的分布,与原来的hello.o对比,我们懂得了链接器是如何进行重定位的,并直观的看到了在链接后某些节发生的变化。
第6章 hello进程管理
6.1 进程的概念与作用
进程的概念:一个执行中程序的实例。
进程的作用:使得在系统中运行程序时会得到一个假象,好像我们的程序是系统中当前运行的唯一的程序,我们的程序好像独占使用处理器和内存 。
6.2 简述壳Shell-bash的作用与处理流程
Shell:是一个交互型的应用级程序,代表用户运行其他程序。
作用:通过收到用户在stdlin的输入,根据特定输入来执行不同的操作,用户通过控制命令行的输入,控制要执行的操作。
处理流程:
1)读取用户由键盘输入的命令行。
(2)分析命令,以命令名作为文件名,并将其它参数改造为系统调用execve( )内部处理所要求的形式。
(3)终端进程调用fork( )建立一个子进程。
(4)终端进程本身用系统调用wait4( )来等待子进程完成(如果是后台命令,则不等待)。当子进程运行时调用execve( ),
子进程根据文件名(即命令名)到目录中查找有关文件(这是命令解释程序构成的文件),将它调入内存,执行这个程序(解释这条命令)。
(5)如果命令末尾有&号(后台命令符号),则终端进程不用系统调用wait4( )等待,立即发提示符,让用户输入下一个命令,转⑴。
如果命令末尾没有&号,则终端进程要一直等待,当子进程(即运行命令的进程)完成处理后终止,向父进程(终端进程)报告,
此时终端进程醒来,在做必要的判别等工作后,终端进程发提示符,让用户输入新的命令,重复上述处理过程。
6.3 Hello的fork进程创建过程
Shell通过调用fork来创建hello子进程,子进程几乎但不完全与父进程相同。子进程hello可以得到与父进程用户级虚拟地址空间相同的(但是独立)一份副本,包括代码和数据段、堆、共享库以及用户栈,子进程Hello还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以写父进程中打开的任何文件。Shell和HellO最大的区别在于有不同的PID.在运行hello时,由于这不是一个内部指令,shell进行判断发现是可执行文件,于是fork了hello子进程。具体如下图
6.4 Hello的execve过程
在shell fork了一个子进程之后,这个子进程会调用execve函数在当前进程的上下文中加载并运行hello程序。execve函数加载并运行可执行目标文件hello,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到hello,execve才会返回到调用程序。所有与fork一次调用返回两次不一样,execve调用一次从不返回。在execve加载了hello,他调用启动代码,启动代码设置栈,并将控制传递给新程序主函数。在main开始执行时,用户栈的组织结构如下图
6.5 Hello的进程执行
操作系统内核使用一种称为上下文切换的异常控制流来实现多任务,所谓上下文就是内核重新启动一个被抢占的进程所需的状态。当hello没有被别的进程抢占时,也就是他处于自己的进程时间片,此时按照hello程序顺序执行hello,当有别的进程进行抢占时,就会发生上下文切换,内核进行调度,保存hello进程上下文恢复一个之前被抢占的进程上下文,并将控制转给这个进程。具体如下图
Hello一开始在用户模式执行,当执行到sleep时,会进入内核模式切换上下文到其他进程,进入用户模式继续执行其他进程,当sleep休眠时间结束,一个中断信号发送到内核,进入内核模式内核执行处理,切换上下文,重新切换hello进程,继续hello进程的执行
6.6 hello的异常与信号处理
hello在执行可能会遇到 SIGSTP 挂起或者SIGINT终止程序 SIGKILL信号
处理方式是SIGINT信号会终止进程,SIGTSTPT停止直到下一个SIGCONT
SIGKILL杀死程序。
1、正常执行完毕之后如下图
2、在执行过程中按下Ctrl-C
可以看到发送了一个sigint信号,处理程序结束了hello并回收了hello
3、在执行过程中乱按
可以看到将乱按的一部分当作了一个命令输入
4、 在执行过程中按下Ctrl-Z,发送一个停止信号将hello挂起
运行ps指令
看到hello只是被挂起
运行fg指令,发送SIGCONT信号继续执行程序
我们看到hello继续被执行
运行jobs指令
运行pstree指令
运行kill指令,杀死hello
6.7本章小结
本章我们主要介绍了已经出生了的hello是如何被执行的,shell是如何为他fork一个子进程,子进程是如何为他execve。我们还介绍了进程之间是如何切换的,最后我们还测试了一些信号和异常情况,通过在命令行输入指令来实现了对一个进程的控制。
第7章 hello的存储管理
7.1 hello的存储器地址空间
物理地址:计算机系统的主存被组织成一个由M个连续字节大小的单元组成的数组。每字节都有一个唯一的物理地址。CPU通过地址总线寻址就是物理地址。
线性地址:地址空间是一个非负整数地址的有序集合,如果地址空间中的整数是连续的,那么我们就说他是一个线性地址空间,在这里是和hello的虚拟地址空间相同
虚拟地址:在一个带虚拟内存的系统,cpu从一个有N个地址的地址空间中生成虚拟地址,也就是虚拟内存中的地址。
逻辑地址:是在有地址变换功能的计算机中,访内指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址,也就是是机器语言指令中,用来指定一个操作数或是一条指令的地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址即物理地址。一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是个索引号,后面3位包含一些硬件细节 。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成,段标识符:段内偏移量。段标识符是由一个16位子长字段组成,称为段选择符,前13位是一个索引号,后3位包含硬件细节,如下图。
索引号是段描述符的索引,通过索引号就可以在段描述符表中找到一个段描述符。段描述符格式如下图所示
可以看到在其中有一个BASE着代表该段开始的基地址。
在intel设计,一些全局段描述符放在“全局段描述符表中(GDT)”,局部的,例如每个进程自己的,放在局部段描述符表LDT,通过段选择符中的T1字段=0表示GDT=1表示LDT。
具体变化过程为:
看段选择符T1是0还是1,知道是GDT还是LDT中的段,根据相应寄存器得到地址和大小。
通过段选择符前13位找到相应段描述符,查找BASE得到基地址
把BASE+OFFSET得到线性地址
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理将一个线性地址转为物理地址。首先我们来介绍页表
页表是一个页表条目的数组,虚拟地址空间中的每个页在页表中一个固定偏移量处有一个PTE,为了我们的目的,我们将假设每个PTE是由一个有效位和地址段组成,设置有效位,地址字段表示相应物理页起始位置。没有设置,空地址代表虚拟页未分配,否则指向虚拟页在磁盘起始位置。
而翻译地址过程是由MMU来进行的。CPU中一个控制寄存器,页表基址寄存器指向当前页表。一个n位虚拟地址包含两个部分,1个p位的虚拟页面偏移,一个N-p位的虚拟页号。MMU利用VPN也就是虚拟页号来选择适当的PTE,将页表条目中的物理页号和虚拟地址中的VPO串联起来,就得到了相应的物理地址。下图描述了整个过程。
7.4 TLB与四级页表支持下的VA到PA的变换
我们知道Intel Corei7是利用TLB和四级页表来进行这种变换的,以Intel Corei7为例子进行说明。
对于一个虚拟地址VPN为36位,VPO12位,虚拟地址48位。TLBI 4位,TLBT32位。我们利用下面的图进行说明
CPU生成一个48位的虚拟地址,根据TLBI向TLB中进行匹配,如果命中则直接将PPN和VPO组合形成物理地址。如果匹配不命中,则向页面中进行查询,CR3确定了第一级页表的起始地址,VPN1确定了第一级页表的偏移量,如此找到了PTE,如果在物理内存中符合权限,则就已经确定了第二级页表的起始地址,再利用VPN2确定第二级页表中的偏移量,找到一个PTE重复上述操作,最后会在第四级页表中找到PPN,将PPN和VPO结合,找到了物理地址。
7.5 三级Cache支持下的物理内存访问
在i7下,cache L1 是8路组相连,一共64组,块大小是64字节。
所以对于一个物理地址,用6个Bit来作为CI,因为块大小是64字节,用6位表示CO PA一共52位,剩下的40位作为CT。
在进行访存时,首先按照CI寻找对应的组,之后对于每个组中的8路进行CT的比对,如果找到CT并且块的有效位设置则命中,按照偏移值取出数据。
如果没有找到匹配或者没有设置有效位,则去下一个CACHE寻找,找到后,有空闲直接放置,如果没有采取一些放置策略放置。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面。
7.7 hello进程execve时的内存映射
加载hello程序需要以下步骤
1、 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构
2、 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
3、 映射共享区域, hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
4、 设置程序计数器(PC),execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
当CPU引用虚拟内存中的一个页的某个字时,,地址翻译硬件从内存中读取相应的PTE,从有效位推断这个页是否被缓存,如果未被缓存,则会触发一个缺页异常。缺页异常调用内核中的缺页处理程序,该程序会选择牺牲一个页,并修改牺牲页的页表条目。接下来,内核从磁盘复制这个读取页到内存中,更新相应的PTE随后返回。
当异常处理程序返回后,他会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。
具体过程如下图所示
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器分为两种基本风格。两种风格都要求应用显式地分配块。他们不同地方在于由哪个实体来负责释放已分配的块
分为显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做垃圾收集器。
隐式空闲链表:
在这种情况下,一个块是由一个字的头部、有效载荷、以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。块大小最低三位总是0,只用内存大小的29个高位表示大小,其余3位用来编码其他信息。用最低位来指明这个块是已分配的还是空闲的。空闲块通过头部中的大小字段隐含地连接着的。分配器可以通过遍历堆中所有块,遍历整个空闲块集合。我们需要某种特殊标记结束块。
寻找合适块的策略包括首次适配、下一次适配和最佳适配。首次适配会从头开始搜索空闲链表,选择第一个合适的空闲块。搜索时间与总块数(包括已分配和空闲块)成线性关系。会在靠近链表起始处留下小空闲块的“碎片”。下一次适配和首次适配相似,只是从链表中上一次查询结束的地方开始。比首次适应更快,避免重复扫描那些无用块。最佳适配会查询链表,选择一个最好的空闲块,满足适配,且剩余最少空闲空间。它可以保证碎片最小,提高内存利用率。
隐式链表的优点是简单,显著缺点是任何操作开销要对空闲链表搜索,搜索时间与堆中已分配块和空闲块总数呈线性关系。
在合并时,有四种情况,根据不同情况修改相应的头和脚就可以实现合并。
显示空闲链表:
将堆组织成一个双向空闲链表,在每个空闲块中,都包含一个pred前驱和succ后继指针。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是现行的,也可能是个常数。
一种方法是后进先出的顺序维护链表,将新释放的块放置在链表的开始处。使用LIFO的顺序和首次配适的放置策略,分配器会最先检查最近使用过的块。
另一种是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。
一般而言,显示链表的缺点是空闲块必须足够大,以包含所有需要的指针,以及头部和可能的脚部。这就导致了更大的最小块大小,也潜在地提高了内部碎片程度
7.10本章小结
本章是十分重要且复杂的,主要分析了hello的存储,对此我们介绍了一些基本地址空间的知识,同时也对一些地址间的转换方法做了介绍,分析了在得到地址后如何进行数据的读取,以及fork和execve如何进行内存映射,以及动态分配内存的方法。可以看到我们的hello真的是一个重要的角色,这么多规则都要围绕他转。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都比当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,成为Unix I/O 这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
Unix I/O接口:
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.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
Unix I/O函数:
1、通过调用open函数打开一个已存在的文件或者创建一个新文件
int open(char* filename, int flags ,mode_t mode)
open函数将文件转为文件描述符,返回描述符数字。返回的描述符总是进程中当前没有打开最小。Flag参数,指明如何访问文件也可是一个或多为掩码的或。
O_RDONLY:只读
O_WRONLY:只写
O_RDWR:可读可写
Mode参数指定了新文件的访问权限位。若返回-1则出错。
2、ssize_t read(int fd, void*buf ,size_t n)
Read函数从描述符为fd的当前文件位置复制最多n个字节到内存buf。返回值为-1表示一个错误,返回0表示EOF,否则返回值表示的是实际传送字节数。
3、ssize_t write(int fd, const void *buf, size_t n);
write 函数从内存位置buf 复制至多n 个字节到描述符fd 的当前文件位置。若成功则返回写的字节数,若出错则为-1。
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;
}
可以看到在参数部分采用了一个可变形参的写法,并且在下面这条语句va_list arg = (va_list)((char*)(&fmt) + 4); 获取了…中的第一个参数。接下来调用了vsprintf函数
我们查看这个函数的代码
int vsprintf(char *buf, const char *fmt, va_list args)
{
int len;
int i;
char * str;
char *s;
int *ip;
int flags; /* flags to number() */
int field_width; /* width of output field */
int precision; /* min. # of digits for integers; max number of chars for from string */
int qualifier; /* 'h', 'l', or 'L' for integer fields */
for (str = buf; *fmt; ++fmt) {
if (*fmt != '%') {
*str++ = *fmt;
continue;
}
/* process flags */
flags = 0;
repeat:
++fmt; /* this also skips first '%' */
switch (*fmt) {
case '-': flags |= LEFT; goto repeat;
case '+': flags |= PLUS; goto repeat;
case ' ': flags |= SPACE; goto repeat;
case '#': flags |= SPECIAL; goto repeat;
case '0': flags |= ZEROPAD; goto repeat;
}
/* get field width */
field_width = -1;
if (is_digit(*fmt))
field_width = skip_atoi(&fmt);
else if (*fmt == '*') {
/* it's the next argument */
field_width = va_arg(args, int);
if (field_width < 0) {
field_width = -field_width;
flags |= LEFT;
}
}
/* get the precision */
precision = -1;
if (*fmt == '.') {
++fmt;
if (is_digit(*fmt))
precision = skip_atoi(&fmt);
else if (*fmt == '*') {
/* it's the next argument */
precision = va_arg(args, int);
}
if (precision < 0)
precision = 0;
}
/* get the conversion qualifier */
qualifier = -1;
if (*fmt == 'h' || *fmt == 'l' || *fmt == 'L') {
qualifier = *fmt;
++fmt;
}
switch (*fmt) {
case 'c':
if (!(flags & LEFT))
while (--field_width > 0)
*str++ = ' ';
*str++ = (unsigned char)va_arg(args, int);
while (--field_width > 0)
*str++ = ' ';
break;
case 's':
s = va_arg(args, char *);
len = strlen(s);
if (precision < 0)
precision = len;
else if (len > precision)
len = precision;
if (!(flags & LEFT))
while (len < field_width--)
*str++ = ' ';
for (i = 0; i < len; ++i)
*str++ = *s++;
while (len < field_width--)
*str++ = ' ';
break;
case 'o':
str = number(str, va_arg(args, unsigned long), 8,
field_width, precision, flags);
break;
case 'p':
if (field_width == -1) {
field_width = 8;
flags |= ZEROPAD;
}
str = number(str,
(unsigned long)va_arg(args, void *), 16,
field_width, precision, flags);
break;
case 'x':
flags |= SMALL;
case 'X':
str = number(str, va_arg(args, unsigned long), 16,
field_width, precision, flags);
break;
case 'd':
case 'i':
flags |= SIGN;
case 'u':
str = number(str, va_arg(args, unsigned long), 10,
field_width, precision, flags);
break;
case 'n':
ip = va_arg(args, int *);
*ip = (str - buf);
break;
default:
if (*fmt != '%')
*str++ = '%';
if (*fmt)
*str++ = *fmt;
else
--fmt;
break;
}
}
*str = '\0';
return str - buf;
}
可以看到vsprintf的作用是格式化,他依据一个输出格式的格式字符串fmt,对一个个数变化的参数格式化,并且返回要打印字符串的长度。在函数中调用了write函数
接着我们来看下write函数,他主要是将长度为i字符串buf输出
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在最后一句可以看到,函数以一个int结尾,实际上这代表要通过系统来调用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
其主要思想是把要输出的字节的ascii码存入到显卡的显存中,之后按照ascii码访问字库模板,获取RGB信息存储到显示vram中,显示芯片按照一定的频率逐行读取vram,通过信号线向液晶显示器传输每个点(RGB分量)。然后就能成功输出字符串。
8.4 getchar的实现分析
异步异常-键盘中断的处理:用户按键后,键盘接口得到一个键盘扫描码,发送一个中断,开始执行键盘中断处理子程序,子程序接受按键扫描码将其转成ascii码,保存到系统的键盘缓冲区。
接着getchar等调用read系统函数,read函数从描述符为fd的当前文件位置复制最多n个字节到内存buf。返回值为-1表示一个错误,返回0表示EOF,否则返回值表示的是实际传送字节数,通过系统调用read从缓冲区读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章是最后一章,主要介绍了I/O方面的知识,包括I/O设备管理和接口以及常用函数,并且分析了printf和getchar两个函数的实现。可以看到我们的hello还可以接收到我们传递的信息,本次大作业快要接近尾声。
结论
Hello的一生是短暂的一生,却又是漫长的一生。在一个短短的程序背后,有着许许多多的设备为之默默服务,他们很少被使用电脑的人所熟知,大部分人都只为能编写hello而感到自豪和骄傲,而hello背后的一切需要我们来发掘!现在我们不妨来回顾一下hello的生平
出生阶段:
由人员编写代码键入hello.c -> 预处理 将hello.c中的外部库加入到hello.i中 -> 编译将hello.i变为hello.s汇编语言形式 -> 汇编将hello.s汇编为hello.o可重定位目标文件 -> 链接将hello.o与外部函数库等进行连接生成hello可执行目标文件
青春期(加载)
“父亲”shell为他fork 子进程为他execve
壮年(执行阶段)
“程”到壮年需要安身立命,于是操作系统为他分配了房子(内存)配置好周围的环境,之后CPU开始执行程序,可是CPU也是“雨露均沾”不能一直照顾hello只好给他分配了时间片,在时间片内CPU只执行hello,而中断异常信号等会打断CPU,来使得CPU去执行其他程序。在需要从硬盘中读写时,采用MMU将VA进行翻译为PA,同时采用缓存技术来提高提取的速度。在执行过程中,是不是还会有I/O来掺和一下,一起热闹热闹。
暮年
最后hello即将要执行结束,当他去世(终止)后他的父亲shell为他收尸(回收子程序),真可谓是“白发人送黑发人”。
通过本次大作业的编写,我们可以看到一个小小的程序想要成功运行,其要经历许许多多的操作。这需要计算机的软硬件协调工作才能完成。而这恰恰就是操作系统需要完成的工作,他使得软件与硬件更好的结合,实现更加高效的操作,可以看出其是整个计算机的一个核心。
在编写作业时我们看到,操作系统管理着内存资源的分配,并想尽办法来提高效率,例如在进行内存读取时为了提高效率采取了多级缓存的方式,这就大大节省了时间,对于不命中的替换策略等等也是如此。而且操作系统还控制了输入与输出装置,同时为用户与计算机交互提供了一个操作界面,例如我们通过shell来实现对hello的控制。
除此之外,可以看到操作系统还决定着资管供需的优先顺序,利用进程的概念和信号中断异常等来实现对CPU的高效利用。
而通过做大作业产生的创新想法就是在进行不命中后冲突替换方面,我们知道cache本来就是利用概率来产生的,因为局部性的原因,导致我们将概率大的资源高速缓存中,而这种替换方法是不是可以结合人工智能来进行呢,因为人工智能也是一种与概率密切相关的方面,我们是不是可以利用人工智能来计算推测当前哪块不被利用的概率较大呢?当然这只是一个不成熟的想法。
附件
文件 | 作用 |
---|---|
hello.i | hello.c文件预处理后的文件 |
hello.s | hello.i文件经过编译之后得到的文件 |
hello.o | hello.s汇编之后得到的可重定位目标文件 |
helloo.elf | hello.o的elf格式文件 |
hello.c | hello.c的C语言文件 |
hello.elf | hello可执行目标文件的elf格式文件 |
hello | hello.c经过编译之后得到的可执行目标文件 |
参考文献
为完成本次大作业你翻阅的书籍与网站等
- 兰德尔E.布莱恩特 大卫R.奥哈拉伦. 深入理解计算机系统(第3版).
机械工业出版社. 2018.4. - LINUX 逻辑地址、线性地址、物理地址、虚拟地址https://www.cnblogs.com/zengkefu/p/5452792.html
- Printf函数实现的深入剖析https://www.cnblogs.com/pianist/p/3315801.html
- Linux内存管理 段式和页式https://blog.csdn.net/hailin0716/article/details/18810755
- ELF(七)可重定位目标,部分摘自深入理解操作系统,深入理解linux内核
https://blog.csdn.net/ylcangel/article/details/18188921