计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机
学 号 120L021022
班 级 2003009
学 生 管宇栋
指 导 教 师 郑贵滨
计算机科学与技术学院
2021年5月
摘要是论文内容的高度概括,应具有独立性和自含性,即不阅读论文的全文,就能获得必要的信息。摘要应包括本论文的目的、主要内容、方法、成果及其理论与实际意义。摘要中不宜使用公式、结构式、图表和非公知公用的符号与术语,不标注引用文献编号,同时避免将摘要写成目录式的内容介绍。
本文通过阐述hello程序在Linux系统的生命周期,深入理解hello程序从hello.c经过预处理、编译、汇编、链接生成可执行文件的全过程。并结合相关理论详细阐述计算机系统对hello程序进行进程管理、存储管理和系统I/O管理的基本方法与策略,通过对hello程序生命周期的探索,深化对计算机系统领域知识的理解与认识。
关键词:计算机系统;hello程序;预处理;编译;汇编;链接;进程;存储;I/O
目 录
第1章 概述
1.1 Hello简介
P2P:progtam to process指从程序到进程在Linux中,hello.c经过cpp的预处理、ccl的编译、as的汇编、ld的链接最终成为可执行目标程序hello,在shell中键入启动命令后,shell为其fork产生一个子进程,然后hello便从程序变为了进程。
020: From Zero-0 to Zero-0从零到零是指shell为此子进程execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序运行结束后,shell父进程负责回收hello进程,内核删除相关数据结构。
1.2 环境与工具
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发与调试工具:gcc,vim,edb,readelf,HexEdit
1.3 中间结果
1、hello.i:经过预处理的文件,进行了宏替换以及将#include的内容添加了进去。
2、hello.s:经过编译生成的汇编文件。
3、hello.o:汇编生成的可重定位的文件。
4、hello:链接生成的可执行文件。
5、helloobjdump:将hello反汇编生成的文件。
6、hellooobjdump:将hello.o反汇编生成的文件。
7、helloo.elf:hello的elf文件。
8、hellooo.elf:hello.o的elf文件。
————————————————
111.4 本章小结
本章是对hello程序P2P和020的过程简介,介绍了预处理、编译、汇编、链接等的大致过程。以及进程在执行时shell和操作系统的行为,概括了一个程序的“一生”。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
预处理概念:预处理器cpp根据以字符#开头的命令(宏定义、条件编译),修改原始的C程序,将引用的所有库展开合并成为一个完整的文本文件。
预处理阶段作用:
1.处理宏定义指令预处理器根据#if和#ifdef等编译命令及其后的条件,将源程序中的某部分包含进来或排除在外,通常把排除在外的语句转换成空行。
2. 处理条件编译指令
条件编译指令如#ifdef,#ifndef,#else,#elif,#endif等。 这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉。
3.处理头文件包含指令头文件包含指令如#include "FileName"或者#include 等。 该指令将头文件中的定义统统都加入到它所产生的输出文件中,以供编译程序对之进行处理。
4.处理特殊符号
预编译程序可以识别一些特殊的符号。 例如在源程序中出现的LINE标识将被解释为当前行号(十进制数),FILE则被解释为当前被编译的C源程序的名称。预编译程序对于在源程序中出现的这些串将用合适的值进行替换。
2.2在Ubuntu下预处理的命令
gcc hello.c -E -o hello.i
图2.2
2.3 Hello的预处理结果解析
图2.3
经过预处理之后,hello.c转化为hello.i文件,我们用cat指令发现文件中头文件的几行代码已经变成了许多的具体代码。这是因为预处理后,原文件中的宏进行了宏展开,头文件中的内容被包含进该文件中。例如声明函数、定义结构体、定义变量、定义宏等内容。另外,如果代码中有#define命令还会对相应的符号进行替换。
2.4 本章小结
本章通过介绍预处理阶段的概念以及在C语言程序中的作用,并在Linux环境里面对hello.c程序进行了预处理,初步探究了预处理的执行情况。并对预处理的结果进行了解析
第3章 编译
3.1 编译的概念与作用
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
概念:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。每条语句都是以文本形式描述的低级机器指令
作用:把源程序(高级语言)翻译成目标程序。除了基本功能之外,编译程序还具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人际联系等重要功能。
3.2 在Ubuntu下编译的命令
gcc -S hello.i -o hello.s
图3.2
3.3 Hello的编译结果解析
3.3.1数据
1.常量:直接体现在汇编代码中,例如其中的$7
图3.3.1
2.变量:main函数声明了一个局部变量i,编译器进行编译的时候局部变量i会放在堆栈中。如图所示,局部变量i放在栈上-4(%rbp)的位置
图3.3.2
- 表达式
原代码中表达式有i
图3.3.3
在汇编代码中发现其-4(%rsp)与0进行比较,得知该处存储的值为i
- 类型
以字符串类型举例,程序中有两个字符串,如图
图3.3.4
- 宏
以宏定义的数组作为举例:char *argv[]
图3.3.5
3.3.2赋值
汇编指令的赋值操作通过movb、movw、movl和movq来完成,例如
图3.3.6
3.3.3类型转换
原代码中的类型转换有atoi(argv[3]),这条语句的意思是将字符串类型转换为整数类型其他的类型转换还有int、float、double、short、char之间的转换
图3.3.7
3.3.4sizeof
原程序中无sizeof
3.3.5算术操作
hello.c中的算数操作有:i++,汇编代码中用addl来实现其加1的操作
图3.3.8
3.3.6逻辑/位操作
原文件中无逻辑位操作
3.3.7关系操作
原代码中的循环语句中有关于i<8的关系大小比较,反应到汇编代码中变成了用cmpl对i<=7进行比较
图3.3.9
3.3.8数组/指针/结构操作
原代码中的数组是argv[]数组,数组的每个元素都指向字符类型的指针。数组的起始地址存放在栈中-32(%rbp)的位置。
图3.3.10
3.3.9控制转移
控制转移指令是汇编语言中通过条件码来控制转移,而条件码的变化依赖于cmp等指令。
图3.3.11
图3.3.12
例如。汇编代码中通过jmp类指令来进行函数循环的进行
3.3.10函数操作
原代码中的函数有main,printf,exit,sleep ,getchar
main函数的参数是argc和argv
两次printf函数的参数恰好是那两个字符串
exit参数是1,sleep函数参数是atoi(argv[3])
函数的返回值存储在%eax寄存器中。
3.4 本章小结
本章介绍了编译的概念和作用,并且分析了编译后的汇编代码的数据类型和各类操作在汇编语言中的存在和展示。对编译的过程有了更详细的认识。
第4章 汇编
4.1 汇编的概念与作用
概念:将汇编语言翻译为机器语言。(一般而言,汇编生成的是目标代码,需要经链接器(Linker)生成可执行代码才可以执行)。
作用:将.s 汇编程序翻译成机器语言指令,把这些指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中,.o 文件是一个二进制文件,它包含程序的指令编码。
4.2 在Ubuntu下汇编的命令
gcc hello.s -c -o hello.o
图4.2
4.3 可重定位目标elf格式
4.3.1 ELF头
ELF头内的type、machine等见下图所。readelf -h hello.o
图4.3.1
4.3.2 节头表
节头表,如图所示。readelf -S hello.o
图4.3.2
图4.3.3
4.3.2 符号表
查看符号表.symtab :命令readelf -s hello.o 如图所示
图4.3.4
4.4 Hello.o的结果解析
用objdump -d -r hello.o 分析hello.o的反汇编,与第3章的 hello.s进行对照分析。
图4.4.1反汇编代码前半部分
图4.4.2反汇编代码后半部分
图4.4.3hello.s第一部分
图4.4.5hello.s第三部分
对反汇编代码和hello.s进行比较发现汇编语言略有不同。1、原来的十进制数已经被翻译成了16进制数.2、原来引用的一些全局变量,已经由<符号>(%rip)变成了0x0(%rip),原来的符号蕴含的重定位信息被放到了表里面.(右方的信息是Objdump自己给我们辅助生成的)3、原来引用的一些函数,也是由<符号>(%rip)变成了 0x0(%rip),原来的符号蕴含的重定位信息被放到了表里面.(右方的信息是Objdump自己给我们辅助生成的)4、控制跳转指令的跳转位置,从jmp符号直接被转换为jmp指令族,其后方的二进制数代表了相对位置。
机器语言程序的是二进制机器指令的集合,是纯粹的二进制数据表示的语言,是电脑可以真正识别的语言。
分支转移:反汇编的跳转指令用的不是段名称比如.L3,而是用的确定的地址,因为,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编程 序中,call的目标地址是当前下一条指令。这是因为 hello.c 中调用的函数 都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执 行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。
4.5 本章小结
通过对汇编代码进行汇编,生成机器代码的可重定位文件,然后使用readelf进行阅读对ELF文件有了更深入的了解。再对反汇编文件和原汇编语言文件进行对比发现了机器代码和汇编代码的关系。
第5章 链接
5.1 链接的概念与作用
概念:这儿的链链接是将各种代码和数据片段收集并组合成为一个但一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行与编译时,也可以执行于加载时,甚至是运行时。由链接器来执行。
作用:将可重定位文件链接成可执行文件。链接可以帮助构造大型程序。链接还可以帮助使用共享库。接是指从 hello.o 到hello生成过程。
5.2 在Ubuntu下链接的命令
ld-ohello-dynamic-linker/lib64/ld-linux-x86-64.so.2/usr/lib/x86_64-linux-gnu/crt1.o/usr/lib/x86_64-linux-gnu/crti.ohello.o/usr/lib/x86_64-linux-gnu/libc.so/usr/lib/x86_64-linux-gnu/crtn.o
5.3 可执行目标文件hello的格式
(1)ELF Header:hello的文件头和hello.o文件头的不同之处如下图标记所示,Type类型为EXEC表明hello是一个可执行目标文件
图5.3.1
- 节头部表Section Headers:Section Headers 对 hello中所有的节信息进行了声明,其 中包括大小 Size 以及在程序中的偏移量 Offset,因此根据 Section Headers 中的信息我们就可以用 HexEdit 定位各个节所占的区间(起始位置,大小)。其中 Address 是程序被载入到虚拟地址的起始地址。
图5.3.2
(3)符号表.symtab
图5.3.3
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
通过查看edb发现虚拟地址空间开始于0x401000结束于0x402000如下图所示
图5.4.1
根据5.3.2的节头部表,我们可以通过edb找到各个节的信息,例如下面的
text节开始于00000000004010f0
图5.4.2
5.5 链接的重定位过程分析
(1)hello反汇编的代码有确定的虚拟地址,也就是说已经完成了重定位,而hello.o反汇编代码中代码的虚拟地址均为0,未完成可重定位的过程
- hello反汇编的代码中多了很多的节以及很多函数的汇编代码,这些节都具有一定的功能和含义。
- hello.o文件里面只有一个函数即main函数,而右方的对hello进行反汇编的结果里面有一整套的函数。比如:.init段的_init函数
- 代码段增多
- 增加了外部的共享库函数
- 重定位PC相对引用
图5.5.1hello.out
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
ld -2.27.so_dl_start 7efb ff4d8ea0
ld -2.27.so_dl_start 7efb ff4d8ea0
ld-2.27.so_dl_init 7efb ff4e7630
hello_start 400500
libc-2.27.so__libc_start_main 7efb ff100ab0
Hello_printf@plt 4004c0
Hello_sleep@plt 4004f0
hello!getchar@plt 4004d0
libc-2.27.so!exit 7efbff122120
5.7 Hello的动态链接分析
(以下格式自行编排,编辑时删除)
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。虽然动态链接把链接过程推迟到了程序运行时,但是在形成可执行文件时(注意形成可执行文件和执行程序是两个概念),还是需要用到动态链接库。比如我们在形成可执行程序时,发现引用了一个外部的函数,此时会检查动态链接库,发现这个函数名是一个动态链接符号,此时可执行程序就不对这个符号进行重定位,而把这个过程留到装载时再进行。
在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。GNU编译系统使用延迟绑定(lazybinding),将过程地址的绑定推迟到第一次调用该过程时。
延迟绑定是通过GOT和PLT实现的。GOT是数据段的一部分,而PLT是代码段的一部分。两表内容分别为:
PLT:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
GOT:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
根据hello ELF文件可知(图5.7.1),GOT起始表位置为0x403ff0,如图:
图5.7.1
之后去edb中查看相应地址的汇编代码.
所以在dl_init前,如图所示都为0。
图5.7.2
然后dl_init后现在都有了值。如图所示。
图5.7.3
相应的,调用的各个函数也都从0变成有了地址。
5.8 本章小结
本章主要讲了链接的过程。一个可重定位文件经过过静态链接以及动态链接后成为了可执行的程序。链接的出现很大程度上减少了代码的体积。通过对比与.0文件的elf文件以及反汇编文件,对链接时文件的变化有了更深入的认识。
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
作用:
(1) 我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存。
(2) 处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
shell是你(用户)和Linux(或者更准确的说,是你和Linux内核)之间的接口程序。你在提示符下输入的每个命令都由shell先解释然后传给Linux内核。
shell 是一个命令语言解释器(command-language interpreter)。拥有自己内建的 shell 命令集。此外,shell也能被系统中其他有效的Linux 实用程序和应用程序(utilities and application programs)所调用。
处理流程:
(1)终端进程读取用户由键盘输入的命令行。
(2)分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量
(3)检查第一个(首个、第0个)命令行参数是否是一个内置的shell命令
(4)如果不是内部命令,调用fork( )创建新进程/子进程
(5)在子进程中,用步骤2获取的参数,调用execve( )执行指定程序。
(6)如果用户没要求后台运行(命令末尾没有&号)否则shell使用waitpid(或wait等待作业终止后返回。
(7)如果用户要求后台运行(如果命令末尾有&号),则shell返回;
6.3 Hello的fork进程创建过程
终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的 逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
6.4 Hello的execve过程
当创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,加载并运行需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
(2)映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。如图6.4
(3)映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
(4)设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据 复制。直到 CPU 引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用 它的页面调度机制自动将页面从磁盘传送到内存。
6.5 Hello的进程执行
图6.5进程执行
进程提供给应用程序的抽象:
(1) 一个独立的逻辑控制流,它提供一个假象,好像我们的进程独占的使用处理器
(2) 一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用CPU内存。
hello进程的执行是依赖于进程所提供的抽象的基础上,下面阐述操作系统所提供的的进程抽象:
①逻辑控制流::一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流 使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占 (暂时挂起),然后轮到其他进程。
②并发流:一个逻辑流的执行时间与另一个流重叠,成为并发流,这两个流成为并发的运行。多个流并发的执行的一般现象成为并发。
③时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
④私有地址空间:进程为每个流都提供一种假象,好像它是独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,在这个意义上,这个地址空间是私有的。
⑤用户模式和内核模式::处理器通常使用一个寄存器提供两种模式的区分,该寄 存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的 代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任 何命令,并且可以访问系统中的任何内存位置。
⑥上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由 通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内 核数据结构等对象的值构成。
⑦上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:
1) 保存以前进程的上下文
2)恢复新恢复进程被保存的上下文,
3)将控制传递给这 个新恢复的进程 ,来完成上下文切换。
现在我们再来看一下hello进程执行,再进程调用execve函数之后,由上面分析可知,进程已经为hello程序分配了新的虚拟的地址空间,并且已经将hello的.txt和.data节分配虚拟地址空间的代码区和数据区。最初hello运行在用户模式下,输出hello 1180800811 张瑞豪,然后hello调用sleep函数之后进程陷入内核模式,内核不会选择什么都不做等待sleep函数调用结束,而是处理休眠请求主动释放当前进程,并将hello进程从运行队列中移出加入等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时发送一个中断信号,此时进入内核状态执行中断处理,将hello进程从等待队列中移出重新加入到运行队列,成为就绪状态,hello进程就可以继续进行自己的控制逻辑流了。
当hello调用getchar的时候,实际落脚到执行输入流是stdin的系统调用read,hello之前运行在用户模式,在进行read调用之后陷入内核,内核中的陷阱处理程序请求来自键盘缓冲区的DMA传输,并且安排在完成从键盘缓冲区到内存的数据传输后,中断处理器。此时进入内核模式,内核执行上下文切换,切换到其他进程。当完成键盘缓冲区到内存的数据传输时,引发一个中断信号,此时内核从其他进程进行上下文切换回hello进程。
6.6 hello的异常与信号处理
图6.6常见的产生的信号
异常和信号异常可以分为四类:中断、陷阱、故障、终止
中断:总是返回到下一条指令
陷阱:总是返回到下一条指令
故障:可能返回到当前指令
终止:不会返回
- 正常执行hello程序的结果
图6.6.1
2.图6.6.2按下 ctrl-z 的结果,输入ctrl-z默认结果是挂起前台的作业,hello进程并没有回收,而是运行在后台下,如图6.6.3所示用ps命令可以看到,hello进程并没有被回收。此时他的后台 job 号是 1,调用 fg 1 将其调到前台,此时 shell 程序首先打印 hello 的命令行命令, hello 继续运行打印剩下的 8 条 info,之后输入字串,程序结束,同时进程被回收。如图6.6.4.
图6.6.2
图6.6.3
图6.6.4
- 图6.6.5是按下Ctrl+c的结果,在键盘上输入Ctrl+c会导致内核发送一个SIGINT信号到前台进程组的每个进程,默认情况是终止前台作业,如图6.6.6,用ps查看前台进程组发现没有hello进程。
图6.6.5
图6.6.6
- 程序运行过程中按键盘,不停乱按,结果如图6.6.7,可以发现,乱按只是将屏幕的输入缓存到 stdin,当 getchar 的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做 shell 命令行输入。
图6.6.7
6.7本章小结
在本章中,阐述进程的定义与作用,同时介绍了 Shell 的一般处理流程和作用,并且着重分析了调用 fork 创建新进程,调用 execve函数 执行 hello,hello的进程执行,以及hello 的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:包含在机器语言指令中用来指定一个操作数或一条指令的地址,每个逻辑地址都由一个段和偏移量组成,偏移量指明了从段开始的地方到实际地址之间的距离。
线性地址:也就是虚拟地址。是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。
物理地址:是指出目前CPU外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。
结合hello来说的话,比如i的逻辑地址就是&i得到的,这时只是他在进程中的当前数据段的一个地址,然后和段的基地址结合就有了虚拟地址,这个虚拟地址再映射到一个物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
如图所示,就是逻辑地址段标识符: 段内偏移量组成,段标识符找到段表内的该段的首地址,再通过段内地址偏移量组合形成线性地址。
也就是说,段地址+偏移地址=线性地址。
段寄存器(16位),用于存放段选择符。CS(代码段):程序代码所在段。SS(栈段):栈区所在段。DS(数据段):全局静态数据区所在段。其他3个段寄存器ES、GS和FS可指向任意数据段。
段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中。用GDT或是LDT由段选择符中的T1字段表示的,=0,表示用GDT,=1表示用LDT。
图7.2
7.3 Hello的线性地址到物理地址的变换-页式管理
页式管理是一种内存空间存储管理的技术,页式管理分为静态页式管理和动态页式管理。将各进程的虚拟空间划分成若干个长度相等的页(page),页式管理把内存空间按页的大小划分成片或者页面(page frame),然后把页式虚拟地址与内存地址建立一一对应页表,并用相应的硬件地址变换机构,来解决离散地址变换问题。页式管理采用请求调页或预调页技术实现了内外存存储器的统一管理。
磁盘中的字节都有唯一的虚拟地址,然后虚拟内存被分割成虚拟也。虚拟页的大小P=2p字节。然后有个页表条目PTE的数组,虚拟地址空间中的每个页在页表中都有一个固定偏移量。
图7.3页式管理
如图所示,每个虚拟地址都映射到磁盘的一个页。然后页表中有效位为1的。可以映射到DRAM中,也就是物理内存。有效位为0,物理页号为null的就是说还未分配,物理页号不为空那就是没有存储在物理内存中。
图7.4虚拟地址到物理地址的翻译
如图所示是虚拟地址到物理地址的翻译。简单来说,虚拟地址分为两部分:前一部分为虚拟页号,可以索引到当前进程的的物理页表地址,后一部分为虚拟页偏移量,将来可以直接作为物理页偏移量,页表是一个存放在物理内存中的数据结构,页表将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。如果不命中,则需要从下级存储单元往上级取。
虚拟地址分为VPN和VPO。VPN又分为TLBT和TLBI,后者是TLB的组号,前者是标记。找到后就取出物理地址的PPN(物理页号),然后VPO变成PPO,CI是组号,CO是偏移,CT是标记。
7.4 TLB与四级页表支持下的VA到PA的变换
图 7.4 四级页表的VA到PA的转换
如图所示为四级页表的VA到PA的转换。CR3控制寄存器指向第一级页表(L1)的起始位置。CR3的值是每个进程上下文的一部分,每次上下文切换时,C3的值都会被恢复。
36位VON被划分为了9位的片,每个片被用做到一个页表的偏移量。CR3寄存器包含L1也标的物理地址。VPN1提供到一个L1PET的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个L2PTE的偏移量,以此类推。
而VPO可以直接转化为VPO从而和PPN结合然后转化为物理地址。
7.5 三级Cache支持下的物理内存访问
I7处理器封装包括四个核、一个大的所有核共享的L3高速缓存,以及一个DDR3内存控制器。每个核包括一个层次结构的TLB、一个层次结构的数据和指令高速缓存,以及一组快速的点到点链路,这种链路基于quickpath技术,是为了让一个核和外部I/O桥直接通信。TLB是虚拟寻址的,是四路组相联的。
图7.5高速缓存
每一级高速缓存都是类似的。比如如图7-5所示,一个地址前t位为标记,s位为组索引,b位为块偏移。然后就可以去高速缓存中寻找。如果命中则取出。如果不命中则需要逐级往下寻找并取出然后放在相应索引的组的一个行中。如果有空闲则直接放入,如果没有则需要寻找牺牲块,可以用LFU方法去寻找。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建了各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的人一个后来进行写操作时,写时复制机制就回创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
程序执行了execve调用:execve(“hello”,NULL,NULL);
1、删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
2、映射私有区域。为新的程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.test和.data区。Bss区域是请求二进制零的,映射到匿名文件,其大小包括在hello中。栈和堆区域也是请求二进制零的,初始长度为0.
3、映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C库Libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
4、设置程序计数器(PC)。Execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
图7.7execve
7.8 缺页故障与缺页中断处理
图7.7缺页故障与缺页中断处理
当指令引用一个虚拟地址,而与改地址相对应的物理页面不在内存中,因此必须从磁盘中取出时,就会发生故障。就像我们将在第九章中看到的那样,一个页面就是虚拟内存的一个连续的块(典型的是4KB)。缺页处理程序从磁盘加载适当的页面,然后将控制返回给引起故障的指令。当指令再次执行时,相应的物理页面已经驻留在内存中了,指令就可以没有故障地运行完成了。
7.9动态存储分配管理
动态内存分配器维护着堆,堆顶指针是brk。有两种风格,一种叫显式分配器,使用malloc和free等;一种叫隐式分配器,也叫垃圾收集器。
显式分配器必须要满足以下条件:1、处理任意请求序列;2、立即响应请求;3、只使用堆;4、对齐块;5、不修改已分配的块。在这些限制条件下,分配器试图实现吞吐率最大化和使用率最大化。吞吐率就是每个单位时间里完成的请求数。内存利用率可以用峰值利用率来衡量,也就是有效载荷占已堆当前当前大小的比值。
造成堆利用率的一个原因是碎片现象。碎片分为内部碎片和外部碎片。你不水平是分配一个已分配块比有效载荷大时发生的。外部碎片是当空闲内存合计一起来满足一个分配请求但没有一个单独的空闲块足够大时发生的。
为了实现一个分配器,必须考虑:1、空闲块的组织;2、放置;3、分割;4、合并。
可以设计一个隐式空间链表,如图所示。在这种情况下,一个块是由字的头部和有效载荷组成的。
图7.9.1
放置已分配的块的策略有首次适配、下一次适配和最佳适配。首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配是从上一次查询家属的地方开始检查空闲块。最简式配检查每个空闲块,选择所需请求的最小空闲块。
分割空闲块通常是把第一部分变成分配块,剩下的变成新的空闲块。
当合适的空闲块不够的时候将申请额外的堆内存,插入空闲链表中。
合并空闲块,当分配器释放一个分配块时,可能有其他空闲块与这个新释放的空闲块相邻,就必须合并。与下一块合并很简单,但是和链表中的上一块合并很困难,所以提出了边界标记,就是在结尾处增加一个脚部,如下图所示。
图 7.9.2 边界标记
图7.9.3 显式空闲链表
还可以使用显式空闲链表。也就是堆可以组织成一个双向空闲链表。使得首次适配的分配时间从块综述的线性时间减少到了空闲块的线性时间。对于释放可以使用后进先出的顺序对链表进行维护。
对于一个使用单向空闲块链表的分配器为了减少分配时间还可以使用分离存储。就是维护多个空闲链表,一般是将所有可能的块大小分成一些等价类,也叫做大小类。有两种基本方法:1、简单分离存储;2、分离适配。还有一种特例叫伙伴系统。
程序应当使用free来释放堆块。也有一种动态内存分配器叫垃圾收集器,可以自动释放程序不再需要的已分配块。基本想法就是,将内存视为一张可达图,对于不可达的点那么就是垃圾就可以回收。C语言可以使用Mark&Sweep垃圾收集器,但是是保守的,也就是说平衡树方法会保证标记所有根节点可达的节点,但可能不正确地标记实际上不可达的块。
7.10本章小结
本章主要讲了hello的存储管理,主要讲了hello的存储器地址空间,Intel逻辑地址到线性地址的变换-段式管理,Hello的线性地址到物理地址的变换-页式管理,TLB与四级页表支持下的VA到PA的变换,三级Cache支持下的物理内存访问,hello进程fork时的内存映射,hello进程execve时的内存映射,缺页故障与缺页中断处理以及动态存储分配管理。对整个hello程序的存储管理有了更深入的认识。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
一个Linux文件就是一个m字节的序列:
B0,B1,B2……Bm
所有的 IO 设备(如网路、磁盘、终端)都被模型化为文件,而所有的输入和输出都被 当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都被当做相应文件的读和写来执行:
设备的模型化:文件
设备管理:unix io接口
8.2 简述Unix IO接口及其函数
Unix I/O 接口:
(1)打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想 要访问一个 I/O 设备,内核返回一个小的非负整数,叫做描述符,它在 后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文 件的所有信息。
(2)Shell 创建的每个进程都有三个打开的文件:标准输入,标准输出,标 准错误。 (3)改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位 置 k,初始为 0,这个文件位置是从文件开头起始的字节偏移量,应用 程序能够通过执行 seek,显式地将改变当前文件位置 k。
(4)读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文 件位置 k 开始,然后将 k 增加到 k+n,给定一个大小为 m 字节的而文 件,当 k>=m 时,触发 EOF。类似一个写操作就是从内存中复制 n>0 个字节到一个文件,从当前文件位置 k 开始,然后更新 k。
(5)关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢 复到可用的描述符池中去。
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的实现,首先来看看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;
}
printf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
然后让我们追踪下write:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
这里是给几个寄存器传递了几个参数,然后一个int结束。将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址。
再来看看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
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
完成了printf。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
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时,程序发生陷阱的异常。当按键盘时会产生中断。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章主要讲了 Linux 的 IO 设备管理方法、Unix IO 接口及其函数,分析了 printf 函数和 getchar 函数的实现。通过对这些知识点讲解,对系统的IO有了更深刻地理解。
结论
从hello.c到之后的各个中间产物的变化,了解到hello程序的一生,具体深刻地理解了程序是怎么执行的,从预处理开始,编译、汇编、链接等过程hello完成了从一个c语言程序文件到可执行目标文件的华丽蜕变。
预处理阶段,完成对hello.c中带#的指令解析,将声明的头文件包含进来,将宏定义展开,进行条件编译、行控制等操作,生成hello.i文件。
编译阶段,编译器根据C语言程序到汇编指令的翻译规则将hello.i文件中的语句翻译为汇编代码,得到汇编文件hello.s。
汇编阶段,汇编器将hello.s中的汇编指令一一翻译为对应的二进制机器级指令,为各个符号引用生成所需的重定向信息,得到可重定向目标hello.o文件。
链接阶段,链接器解析hello.o中引用的内部、外部符号,处理重定位信息,为hello.o找到它需要的文件模块和外部链接库,生成可执行目标文件hello。
作为父进程,shell-bash进程调用fork函数,为hello创建进程——这将是它接下来的绽放舞台。随后在这个创建出的“舞台“进程里,execve函数被调用,操作系统删除原来的进程内容,加载器将hello文件中的代码和数据从磁盘复制到内存中,hello进程得到自己的虚拟内存空间,然后通过跳转到程序的第一条指令或入口点来运行该程序。
运行hello时,内存管理单元MMU、翻译后备缓冲器TLB、多级页表机制、三级cache同舟共济,完成对地址的请求。
异常处理机制保证了hello对异常信号的处理,使程序平稳运行;Unix I/O让程序能够与文件进行交互。
结束阶段,当hello运行完毕,shell父进程回收hello进程,内核删除为hello进程创建的所有数据结构,hello最终结束了它的演出。
Hello程序的运行历程,既是一个Program-to-process的过程,也是一个Zero-to-Zero的过程。而这正如计算机系统的发展历史:从无到有,从0到1。如果没有计算机低层硬件系统,软件层面的逻辑和设计就无从谈起;如果没有操作系统联合低层硬件提供的抽象,程序员在开发软件的时候就必须陷入复杂的硬件实现细节。这将是一件可怕的事情,而且大量的精力花费在这个重复的、没有创造性的工作上也使得程序员无法集中精力在更具有创造性的程序设计工作上去。操作系统将硬件细节与程序员隔离开来,使得计算机成为一种简单的,高度抽象的可以与之打交道的设备。
通过对计算机系统的初步学习,我深刻体会到计算机系统设计之精巧,考虑之全面。为了解决速度快的设备存储小、存储大的设备慢的不平衡,计算机系统的设计者们设计了高速缓存来作为更底层的存储设备的缓存,大大提高了CPU访问主存的速度;为了应对一切可能出现的实际情况,工程师们设计出一系列的满足不同情况的策略,比如写回和直写,写分配和非写分配,直接映射高速缓存和组相连高速缓存等等。计算机系统的设计与实现凝聚着无数聪明大脑的智慧,其中的奥秘值得我们每个人深入地探寻。
列出所有的中间产物的文件名,并予以说明起作用。
hello.i hello.c预处理后的文本文件
hello.s hello.i编译后的汇编文件
hello.o hello.s汇编后的可重定位目标文件
hello hello.o与其他组件链接后的可执行目标文件
hello.out hello反汇编后的可重定位文件
hello_2_elf ELF 格式下的hello文件
参考文献
- Randal E.Bryant. David R.O’Hallaron. 深入理解计算机系统. 北京:机械工业出版社,2021-10.
[2] https://blog.csdn.net/u011210147/article/details/54092405
[3] https://segmentfault.com/a/1190000016664025