计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算机科学与技术
学 号 2021112016
班 级 21w0312
学 生 侯雯芮
指 导 教 师 史先俊
计算机科学与技术学院
2022年11月
本篇文章将对hello的整个生命历程进行系统的分析,从编写hello.c的源代码开始,我们将运行cpp对其进行处理生成hello.i文件,运行ccl将其汇编成hello.s文件,然后运行as将其翻译成hello.o文件,最后运行ld将系统内的所有目标文件组合起来,生成可执行文件hello。在本篇文章中,我们将详细地分析生成各个文件背后的玄机,透彻地研究hello从起源到走向尽头的整个生命历程。
关键词:预处理;编译;汇编;链接;进程管理;存储管理;I/O管理
目 录
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:
1.hello程序的生命周期是从一个源程序开始的,即程序员通过编译器创建并保存的文本文件,文件名是hello.c。
2.预处理阶段:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序,结果就得到了另外一个C程序,通常是以.i作为文件扩展名。
3.编译阶段:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。
4.汇编阶段:接下来,汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中,hello.o文件是一个二进制文件。
5.链接阶段:hello程序调用了printf函数,其存在于一个名为printf.o的单独的预编译好了的目标文件中,链接器(ld)负责将printf.o合并到我们的hello.o程序中,结果就得到hello文件,它是一个可执行目标文件(或者简称为可执行文件),可以被加载到内存中,由系统执行。
如下图所示,预处理器、编译器、汇编器和链接器一起构成了编译系统:
020:
之后,shell为其execve,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入main函数执行目标代码,CPU为运行的hello分配时间片执行逻辑控制流。当程序执行结束后,shell父进程负责回收hello进程,内核会删除相应的数据结构。
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
开发工具:gcc,vim,edb,readelf,HexEdit
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
文件名 | 作用 |
hello.c | 源程序 |
hello.i | 预处理得到的文本文件 |
hello.s | 编译得到的汇编文件 |
hello.o | 汇编得到的可重定位目标文件 |
hello | 链接时得到的可执行目标文件 |
hello.elf | hello.o的ELF格式 |
hello1.elf | hello的ELF格式 |
1.4 本章小结
本章从整体上概述了hello程序的实现过程,并简述了hello 的P2P和020的整个过程,分析了此过程需要用到的软硬件环境和开发工具,并对此程序实现过程中涉及到的中间结果的文件名和作用进行了大致的分析。
第2章 预处理
2.1 预处理的概念与作用
预处理
概念:预处理一般是指在程序源代码被翻译成目标代码的过程中,生成二进制代码之前的过程。预处理阶段,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。
作用:预处理指令一般被用来使源代码在不同的执行环境中被方便的修改或者编译。
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
hello.c文件经预处理后得到hello.i文件,预处理器(cpp)按照#内容进行预处理内容展开,最后将文件扩展成三千多行。hello.i文件为仍可以阅读的C语言程序文件,最终的hello.i文件中没有以#开头的预处理命令,其包含了#include指示的文件的所有原代码以及源程序的命令行参数、环境变量配置以及用绝对路径显示的库头文件的位置、头文件中使用的数据类型生命、结构体定义、外部函数引用等等,综合形成了hello.i文件。
2.4 本章小结
本章主要介绍了预处理的概念及作用,在ubantu下输入预处理的命令,并对预处理后生成的hello.i文件进行了简要的解析。
第3章 编译
3.1 编译的概念与作用
编译
概念:编译是指利用编译程序从预处理文本文件(.i)产生汇编程序(.s)的过程。这个过程就是把完成预处理后的文件进行一系列词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。
作用:将源程序(高级语言)翻译成目标程序。除了基本功能之外,编译程序还具备语法检查、调试措施、修改手段、覆盖处理、目标程序优化、不同语言合用以及人际联系等重要功能。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序。
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.3.0 汇编代码
.file:声明源文件
.text:代码节
.section:
.rodata:只读代码段
.align:数据或者指令的地址对其方式
.string:声明一个字符串(.LC0,.LC1)
.global:声明全局变量(main)
.type:声明一个符号是数据类型还是函数类型
3.3.1 数据
1.字符串
程序中有两个字符串,由上图可知,这两个字符串都在只读数据段中。
2.局部变量i
局部变量i被放在栈中,通过rbp的相对偏移来访问。
3. 主函数传递参数argc、argv
符号数argc和字符型数组指针argv,根据寄存器使用规则,这两个参数分别通过%edi和%esi传递。在程序最开始,为main函数建立栈帧,并完成参数传递。argc存放于%rsp-20;argv作为main函数的参数,数组的元素都是指向字符类型的指针,起始地址存放在栈中-32(%rbp)的位置,被两次调用找参数传给printf。
3.3.2全局函数
从 hello.c 可以看出 hello.c 描述并编译了全局函数 int main( intargc, char*argv[])后,将main函数中使用的字符串常量也保存到数据区域。
3.3.3赋值操作
程序中的赋值操作主要有:i=0这条赋值操作在汇编代码主要使用mov指令来实现,而根据数据的类型又有多中相应的不一样的后缀。
movb:一个字节
movw:两个字节
movl:四个字节
movq:八个字节
处理C语言中翻译过来的赋值操作,在汇编代码中还有通过以lea(地址传递)来赋值的方式。
3.3.4算术操作
C代码中的算术操作有i++,在汇编代码中是通过add来实现的。
除了C代码中的算数操作,在汇编代码中还有通过sub(减操作)。
3.3.5关系操作
(1) argc! = 3; 判断条件句子的条件: 编译 argc! = 3时,本命令语如下: cmpl $3,-20 (% rbp ) , 此命令包含代码设置,并判断是否需要分区。
(2)i<8, hello.c以判断循环为条件,在汇编代码中编译如下: 计算cmpl $7,-4(% rbp), i-7后设置条件代码,准备在下一个 jle 利用条件代码跳跃。
3.3.6控制转移指令
在C源程序中的控制转移有if语句和for循环,在汇编代码中这两者都是通过条件跳转指令来完成的。如图:
3.3.7函数操作
在64位系统中,参数的传递首先是通过寄存器,顺序是rdi、rsi、rdx、rcx、r8、r9,其余参数压栈。
函数的调用使用的是call语句,如果用立即数驱动call语句就必须要计算函数所在位置和rip的相对偏移量。
我们汇编代码中的函数调用用的都是call加上函数名的方法。函数一般都通过ret指令返回,返回去往往要通过leave函数等方式进行堆栈平衡,返回值一般都存放在rax中,如图:
3.3.8类型转换
Sleep函数的参数为int值,而argv为字符串数组,在hello.c中用atoi将字符串转化成int型,在hello.s中用call语句调用atoi函数强制处理该类型转换。
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
3.4 本章小结
本章主要介绍了编译的概念及作用,并详细地说明了编译器是如何对C语言的各个数据类型以及各类操作进行处理的,以代码加文字说明的形式从数据、全局函数等方面作了介绍,并对赋值、算数、函数、关系等操作做出了详细的说明。
第4章 汇编
4.1 汇编的概念与作用
汇编
概念:汇编程序即为将汇编语言书写的程序翻译成与之等价的机器语言程序的翻译程序。汇编时,输入的文件是汇编语言书写的源程序,输出的是机器语言表示的目标程序。它是将汇编指令文本文件打包成可重定位目标文件,结果保存在.o文件中,这是一个二进制文件。
作用:完成汇编指令后,文件完成向可重定位目标文件的转化。
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.3.1 ELF头
ELF头以一个16字节的序列(Magic,魔数)开始,它描述了系统的字的大小和字节顺序(大端序或者小端序)。ELF头剩下部分的信息包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型和机器类型等。由上图我们可以看出,Data表示了系统采用小端法,文件类型Type为REL(可重定位文件),节头数量Number of section headers为14个等信息。
4.3.2节头部表:
我们知道夹在ELF头与节头部表之间的都为节。其描述了.o文件中出现的各个节的信息,包括节的名称、类型、地址、偏移量、所占空间大小等信息。其中书上描述过的有:
名称 | 内容 |
.text | 已编译程序的机器代码 |
.rodata | 只读数据 |
.data | 已初始化的全局和静态C变量 |
.bss | 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量 |
.symtab | 一个符号表,存放一些在程序中定义和引用的函数和全局变量的信息 |
.rel.text | 一个.tex节中位置的列表 |
.rel.data | 被模块引用或定义的所有全局变量的重定位信息 |
.debug | 一个调试符号表 |
.line | 原始C程序中的行号和text节中机器指令之间的映射 |
.strtab | 一个字符串表(包括.symtab和.debug节中的符号表) |
4.3.3重定位节
.rela.text中保存了.text节中需要被修正的信息,注明了偏移量、寻址方式等信息。
.rela.eh_frame中保存了.eh_frame节重定位信息。
4.3.4 符号表
这其中,Num为某个符号的编号,Name是符号的名称。Size表示他是一个位于.text节中偏移量为0处的146字节函数。Bind表示这个符号是本地的还是全局的。
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
汇编的代码和hello.s比较的结果,汇编的命令语没有区别。只是汇编代码不仅标注了汇编代码,还标注了机器代码。机器语言软件是李镇洙机器的命令语集合,是纯粹的李镇洙数据所表现的语言,是计算机能够正确识别语言。机器指令由操作代码和操作数组成,汇编语言是直接表现CPU动作的形成,是最接近CPU运转原理的语言。每个汇编操作代码都可以用机器的二进制数据来表示,进而可以使所有的汇编(操作代码和操作数)和二进制语言建立一个个映射的关系,因此可以将汇编转换成机器语言。
(1)分支转移: 汇编的移动指令是.L3,不是短路,而是汇编时容易写出的帮助,因此汇编语言后就不存在了。
(2)函数调用: 如果函数在.s文件中呼出, 函数名称将保持原样, 反向编程程序中的 call 目标地址是当前命令。这是因为从 hello.c 呼出的函数是共享库中的函数,因此只能通过动态链接来执行,当汇编成为汇编语言时,对于这个不确定的函数呼出,call 命令的相对地址设置为0,并在下一个命令中设置下列命令。 在rela.text 栏目中再次添加静态链接,然后等待下一个链接。
4.5 本章小结
本章介绍了汇编。经过汇编器,将汇编语言转化为机器语言,hello.s文件转化为hello.o可重定位目标文件。分析了ELF的文件格式,了解了ELF头等相关概念,分析了汇编语言与机器语言的不同。
第5章 链接
5.1 链接的概念与作用
链接
概念:链接是将各种代码和数据片段收集并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代 码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。
作用:链接是由叫做链接器的程序执行的。链接器使得分离编译成为可能,极大地方便了模块化编程。
注意:这儿的链接是指从 hello.o 到hello生成过程。
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的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
5.3.1 ELF Header
可执行文件的ELF头与可重定位目标文件的ELF头有以下几个不同:
1.可执行文件的类型不再是REL而是EXEC。
2.程序的入口点不一样,因为连接上了库文件,使得main函数不再是从0x0开始。同理节头的开始位置也发生了变化。
3.节头的数量产了变化。
5.3.2节头部表Section Headers
可以看到与hello.o不同,在可执行文件中经过重定位每个节的地址不再是0,而是根据自身大小加上对齐规则计算的偏移量。
比如.hash的地址,计算方式是.note.ABI-tag的地址0x40021c加上.note.ABI-tag的大小0x20,得到0x40023c,再对.hash要求的8字节对齐进行调整,得到最终地址0x40024。
5.3.3符号表.symtab
在可执行文件中多出了.dynym节。这里面存放的是通过动态链接解析出的符号,这里我们解析出的符号是程序引用的头文件中的函数。
5.3.4重定位节
重定位节的偏移量与hello.o已经完全不一样了。
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
通过查看edb,看出hello的虚拟地址空间开始于0x400000,结束与0x400ff0。
通过5.3.2的节头部表可以找到各节信息,例如:
.text节是从0x4010f0开始的,.rodate节是从0x402000开始的。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
(1)在hello.o中call、jmp指令后紧跟着的是相对地址,而hello中紧跟的是虚拟内存的确定地址,原因在于链接器完成了重定位过程,可以确定运行时的地址
(2)在hello中增加了一些在hello.o中没有的函数,这些都是在hello.c中没有定义却直接使用的函数,这些函数定义在共享库中,在链接时完成了符号解析和重定位,如printf、sleep等。
综上所述,重定位的大体过程是链接器ld将所有链接文件中相同的节合并,并按照要求计算新的偏移地址赋值给新的节。同时链接器按链接指令的顺序搜索符号表,查找符号引用。
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
子程序名 | 地址 |
ld-2.31.so!_dl_start | 0x7f8586bea770 |
ld-2.31.so!_dl_init | 0x7f8586bea9a0 |
hello!_start | 0x4010f0 |
libc-2.31.so!_libc_start_main | 0x7f634a96ce60 |
hello!printf@plt | 0x401040 |
hello!sleep@plt | 0x401080 |
hello!getchar@plt | 0x401050 |
libc-2.31.so!exit | 0x7f634a7540d0 |
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接采用了延迟加载的策略,即在调用函数时才进行符号的映射。使用偏移量表GOT和过程链接表PLT的协同工作实现函数的动态链接。GOT中存放函数目标地址,PLT使用GOT中的地址跳转到目标函数。
程序开始后,通过执行dl_init可以修改PLT和GOT,下面是执行dl_init之前的PLT内容:
执行dl_init之后的内容:
从图中第二行可以看到变化。
5.8 本章小结
本章介绍了链接的概念和作用,linux下执行链接的命令,查看了hello的elf,使用了objdump工具得到反汇编代码,分析了hello的虚拟地址空间、重定位过程、执行流程、动态链接过程。
第6章 hello进程管理
6.1 进程的概念与作用
进程
概念:第一,进程是一个实体。这意味着每个进程都有它自己的独立地址空间。一般情况下,这个地址空间由三部分组成:文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域的功能是存储处理器执行的代码;数据区域主要存储变量和执行期间动态分配的内存;堆栈区域存储调用的指令和本地变量。第二,进程是一个“执行中的程序”。操作系统执行它时才变成一个活动的进程。
作用:提供给应用程序独立的逻辑控制流和私有地址空间。
6.2 简述壳Shell-bash的作用与处理流程
作用:微壳是一个负责连接用户和 Linux 内核的应用,作用是让用户能够更加高效安全地使用 Linux 内核。
处理过程:
1.找出命令中的特殊字符并译为间隔符号,经此将命令行划分成小块tokens。
2.检查程序块的关键字。
3.shell根据列表来检查命令的首位单词。若aliases表中也有此单词则替换它并且处理过程回到第一步重新分割程序块tokens。
4.替换部分符号。
5.替换内嵌命令表达式成命令。
6.计算$(expression)标记的算术表达式。
7.重新划分命令字符串按栏位分割符号为新的块tokens。
8.替换通配符。
9.删除注释并检查内建的命令、shell函数(由用户自己定义的)、可执行的脚本文件(需要寻找文件和PATH路径)
10.初始化所有的输入输出重定向。
11.最后,执行命令。
6.3 Hello的fork进程创建过程
我们可以看出新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程虚拟地址空间相同的但是独立的一份副本,任何打开文件描述符相同的副本,但是是子进程有不同于父进程的PID。
6.4 Hello的execve过程
内容:陷入内核->加载新的可执行文件->检查可执行性->映射可执行文件到当前进程的进程空间并覆盖原来的数据->更新EIP的值为新的可执行程序的进入地址。(如果可执行程序位静态链程序,则进入地址为其main函数地址;若可执行程序需要额外的动态链接库,则进入地址是加载器ld的进入地址)—>返回用户态,从新的EIP处执行。
特点:execve不会成功返回。(老进程被新的进程替代,但PID不变。因此新进程中没有execve调用的代码)
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
进程上下文信息:调度上下文、记账、文件表、文件系统上下文、信号处理程序表和虚拟内存上下文。
进程时间片:CPU分配给各程序的时间,每个线程被分配一个时间段。这叫做时间片。代表着进程允许运行的时间。
进程调度的过程:选择进程类型->选择算法->执行分析->切换堆栈位置->切换地址空间
用户态核心态转换指进程中断或者系统调用而陷入内核态之行时,进程所使用的堆栈也要从用户栈转到内核栈。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
四类异常:中断、陷阱、故障、终止。
分别产生信号:处理器外部IO设备的信号(返回到当前指令的下一条指令)、返回到当前指令的下一条指令、返回到当前正在执行的指令(成功)或返回内核的abort历程并终止(失败)、终止同上的失败。
6.7本章小结
在本章中,阐述进程的定义与作用,同时介绍了 Shell 的一般处理流程和作用,并且着重分析了调用 fork 创建新进程,调用 execve函数 执行 hello,hello的进程执行,以及hello 的异常与信号处理。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元(memory cell)、存储单元(storage element)、网络主机(network host)的地址。在有地址变换功能的计算机中,访内指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。
线性地址:线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
虚拟地址:虚拟地址并不真实存在于计算机中。每个进程都分配有自己的虚拟空间,而且只能访问自己被分配使用的空间。
物理地址:物理地址空间是实在的存在于计算机中的一个实体,在每一台计算机中保持唯一独立性。我们可以称它为物理内存。
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址也就是虚拟地址,我们一般通过页表来获得虚拟地址到物理地址的映射。
页表是一个关于页表条目PTE的数组。页表条目由有效位和物理页号组成。
一个虚拟页只有如下三个状态:
未分配的:VM系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何内存。
缓存的:当前已缓存在物理内存中的已分配页。
未缓存在虚拟内存中的已分配页。
结合以上两点,我们就可以按下图的方法通过页表将虚拟页映射到物理页上。
接下来我们来讨论地址的翻译,由于接下来要分析多级页表,因此在这里我只论述一级页表的情况。
我们将n为的虚拟地址拆分成p为的虚拟页面偏移VPO和n-p位的VPN。我们通过VPN找到页表,并通过页表来获得虚拟页号,将m-p位的物理页号和p位的虚拟页面偏移组合在一起(虚拟页面偏移等价于物理页面偏移,因为物理内存映射的是虚拟内存的一整页。)就得到了m位的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
在 Intel Core i7 环境下研究 VA 到 PA 的地址翻译问题。前提如下: 虚拟地址空间 48 位,物理地址空间 52 位,页表大小 4KB,4 级页表。TLB 4 路 16 组相联。CR3 指向第一级页表的起始位置(上下文一部分)。 解析前提条件:由一个页表大小 4KB,一个 PTE 条目8B,共 512 个条目,使 用 9 位二进制索引,一共 4 个页表共使用 36 位二进制索引,所以 VPN 共 36 位, 因为 VA 48 位,所以 VPO 12 位;因为 TLB 共 16 组,所以 TLBI 需 4 位,因为 VPN 36 位,所以 TLBT 32 位。
CPU 产生虚拟地址 VA,VA 传送给 MMU,MMU 使用前 36 位 VPN 作为 TLBT(前 32 位)+TLBI(后 4 位)向 TLB 中匹配,如果命中,则得到 PPN (40bit)与 VPO(12bit)组合成 PA(52bit)。 如果 TLB 中没有命中,MMU 向页表中查询,CR3 确定第一级页表的起始地 址,VPN1(9bit)确定在第一级页表中的偏移量,查询出 PTE,如果在物理内存 中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查 询到 PPN,与 VPO 组合成 PA,并且向 TLB 中添加条目。如果查询 PTE 的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。
7.5 三级Cache支持下的物理内存访问
Cache的访问并不复杂,对Cache的访问需要把一个物理地址分为标记、组索引、块偏移三个部分。首先我们通过组索引来找到我们的地址在Cache中所对应的组号,再通过标记和Cache的有效位来判断我们的内容是否在Cache中。若命中则通过块偏移读取我们要的数据,若不命中则从下一级Cache中寻找(下一级Cache不一定真的是Cache,比如对L3来说,它的下一级Cache就是主存)。
先来讨论一级Cahce,Core i7CPU的L1 Cache大小为32kb,每组八路,每个块大小为64字节。通过计算可以得出这个Cahce一共有64组。而我们知道,i7CPU的物理地址是52位。
通过MMU将虚拟地址转化成物理地址后,计算机就通过提取中的组索引在L1中搜索组,再通过标记位匹配。如果匹配成功且有效位是1,则将块偏移指向的块中的内容交还给CPU,否则未命中,需要从下一级Cache中在重复上述操作。当我们找到内容后需要将内容写回我们的L1中,如果L1中没有空闲块,即有效位为0的块则需要牺牲一块内容,我们通常采用LRU算法来进行这一过程。对L2、L3的访问也是这样,因此就不再赘述。
7.6 hello进程fork时的内存映射
虚拟内存和内存映射解释了fork函数如何为每个新进程提供私有的虚拟地址空间。
1.为新进程创建虚拟内存
2.创建当前进程的的mm_struct, vm_area_struct和页表的原样副本
3.两个进程中的每个页面都标记为只读;两个进程中的每个区域结构(vm_area_struct)都标记为私有的写时复制(COW)
4.在新进程中返回时,新进程拥有与调用fork进程相同的虚拟内存
随后的写操作通过写时复制机制创建新页面。
7.7 hello进程execve时的内存映射
首先execve函数利用参数名,调用函数hello,能取得对应文件的i节点,然后将当前进程(子进程)的i节点置换为上面操作得到的节点,释放内存页表并修改为LDT。
具体操作如下:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
当CPU执行指令希望访问一个不在内存的页面时,将产生缺页中断,系统开始运行中断处理程序。此时指令计数器(PC) 的值尚未来得及增加就被压入堆栈,因此压入的断点必然是本次被中断的指令地址,而非下一条指令的地址。
缺页中断处理过程:
(1) 保留进程上下文
(2)判断内存是否有空闲可用帧?若有,则获取一个帧号No,转(4) 启动I/O过程。若无,继续(3)
(3)腾出一个空闲帧,即:
(3)-1调用置换算法,选择一个淘汰页PTj。
(3)-2 PTj(S)=0 ; //驻留位置0
(3)-3 No= PTj (F); //取该页帧号
(3)-4 若该页曾修改过,则
(3)-4-1 请求外存交换区上一个空闲块B ;
(3)-4-2 PTj(D)=B ;//记录外存地址
(3)-4-3启动I/O管理程序,将该页写到外存上。
(4)按页表中提供的缺页外存位置,启动I/O,将缺页装入空闲帧No中。
(5)修改页表中该页的驻留位和内存地址。PTi(S)=1 ; PTi(F) =No。
(6)结束。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态存储分配的几个方式:
1)malloc 函数
分配指定字节数的存储区。此存储区的初始值不确定。
2)calloc 函数
为指定数量指定长度的对象分配存储空间。该空间中的每一位(bit)都初始化为 0。
3)realloc 函数
增加或减少以前分配区的长度。这里重点说一下 realloc 函数
(1) 减少存储区的长度,这个简单直接减少就行;
(2) 增加存储区长度,如果在该存储区后有足够的空间可供扩充,则可在原存储区上向高地址方向扩充,无需移动任何原先的内容;并返回与传给他相同的指针值;
(3) 如果原存储区后没有足够的空间,则 realloc 分配另一足够大的存储区,再将原来空间的元素搬移过去,然后释放原存储区,返回新分配区的指针。新的区域,初始值不确定。
注意:realloc 的最后一个参数是存储区的长度,不是新、旧存储区长度之差。
7.10本章小结
本章通过hello程序,帮助了解了其在存储器地址空间的表示方式 ,阐述了逻辑地址到线性地址,以及线性地址到物理地址之间的转化流程,同时了解了cache、TLB、fork、execve的工作原理。
8.1 Linux的IO设备管理方法
设备的模型化:文件,所有的输入和输出都能被当做相应文件的读和写来执行。
设备管理:unix io接口,使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_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.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
函数:
打开和关闭文件:int open(char *filename, int flags, mode_t mode);
int close(int fd);
读和写文件:ssize_t read(int fd, void *buf, size_t n);
Ssize_t write(int fd, const void *buf, size_t n);
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实际上就是typedef后的char*。而va_list arg = (va_list)((char*)(&fmt) + 4);这句操作实际上就是得到了...中的第一个量。
之后我们调用vsprintf函数。vsprintf函数将我们需要输出的字符串格式化并把内容存放在buf中。并返回要输出的字符个数i。然后调用系统函数write来在屏幕上打印buf中的前i个字符,也就是我们要输出的格式串。
调用write系统函数后,程序进入到陷阱,系统调用 int 0x80或syscall等,通过字符驱动子程序打印我们的线性。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
最后程序返回我们实际输出的字符数量i。
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;
}
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
通过本章,我们了解到了linux环境下IO设备的管理方式,IO的函数实现以及具体IO函数的内部操作。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
Hello经历了如下阶段:
- 预处理:预处理器cpp将.c文件翻译成.i的文件;
- 编译:gcc编译器将.i文件翻译成.s格式的汇编语言文件;
(3) 汇编:as汇编器将.s文件转换成十六进制机器码的.o文件;
(4) 链接:ld链接器将一系列.o文件链接起来形成最终的可执行文件hello;
(5) 进程创建:shell为hello程序fork一个子进程;
(6) 程序运行:shell调用execve函数,映射虚拟内存,载入物理内存,进入main函数;
(7) 指令执行:hello和其他进程并发地运行,CPU为其分配时间片;
(8) 进程回收:shell回收子进程,系统释放该进程的数据所占的内存空间。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 | 作用 |
hello.i | hello的预处理结果 |
hello.s | hello.i的汇编结果 |
hello.o | hello.s翻译成的可重定位文件 |
hello | 可执行文件 |
参考文献
- 深入理解计算机系统(原书第3版)/(美)兰德尔·E.布莱恩特等著;龚奕利,贺莲译.
- printf函数实现的深入剖析
https://blog.csdn.net/zhengqijun_/article/details/72454714