赞
踩
题 目 程序人生-Hello’s P2P
专 业 计算机类_________
学 号 _____
班 级 _______
学 生 ________
指 导 教 师 __________
计算机科学与技术学院
2022年5月
摘 要
本文对hello程序的整个生命周期进行了系统的分析,一开始在键盘上手动编写hello.c源程序,之后运行C预处理器(cpp)将其进行预处理生成hello.i文件,运行C编译器(ccl)将其进行翻译生成汇编语言文件hello.s,然后运行汇编器(as)将其翻译成一个可重定位目标文件hello.o,最后运行链接器程序ld将hello.o和系统目标文件组合起来,创建了一个可执行目标文件hello。当shell接收到./hello的指令后开始调用fork函数创建进程,execve加载hello进入内存,由CPU控制程序逻辑流的运行,中断,上下文切换和异常的处理,最后结束进程并由父进程进行回收,hello走向“生命”的尽头。
关键词:预处理;编译;汇编;链接;进程;存储;IO管理;
目 录
第1章 概述 - 4 -
1.1 Hello简介 - 4 -
1.2 环境与工具 - 4 -
1.3 中间结果 - 4 -
1.4 本章小结 - 5 - 第2章 预处理 - 6 -
2.1 预处理的概念与作用 - 6 -
2.2在Ubuntu下预处理的命令 - 6 -
2.3 Hello的预处理结果解析 - 6 -
2.4 本章小结 - 7 - 第3章 编译 - 8 -
3.1 编译的概念与作用 - 8 -
3.2 在Ubuntu下编译的命令 - 8 -
3.3 Hello的编译结果解析 - 8 -
3.4 本章小结 - 11 - 第4章 汇编 - 12 -
4.2 在Ubuntu下汇编的命令 - 12 -
4.3 可重定位目标elf格式 - 12 -
4.4 Hello.o的结果解析 - 14 -
4.5 本章小结 - 16 - 第5章 链接 - 17 -
5.1 链接的概念与作用 - 17 -
5.2 在Ubuntu下链接的命令 - 17 -
5.3 可执行目标文件hello的格式 - 17 -
5.4 hello的虚拟地址空间 - 19 -
5.5 链接的重定位过程分析 - 20 -
5.6 hello的执行流程 - 22 -
5.7 Hello的动态链接分析 - 22 -
5.8 本章小结 - 23 - 第6章 hello进程管理 - 24 -
6.1 进程的概念与作用 - 24 -
6.2 简述壳Shell-bash的作用与处理流程 - 24 -
6.3 Hello的fork进程创建过程 - 24 -
6.4 Hello的execve过程 - 24 -
6.5 Hello的进程执行 - 25 -
6.6 hello的异常与信号处理 - 26 -
6.7本章小结 - 27 - 第7章 hello的存储管理 - 29 -
7.1 hello的存储器地址空间 - 29 -
7.2 Intel逻辑地址到线性地址的变换-段式管理 - 29 -
7.3 Hello的线性地址到物理地址的变换-页式管理 - 30 -
7.4 TLB与四级页表支持下的VA到PA的变换 - 31 -
7.5 三级Cache支持下的物理内存访问 - 31 -
7.6 hello进程fork时的内存映射 - 31 -
7.7 hello进程execve时的内存映射 - 32 -
7.8 缺页故障与缺页中断处理 - 32 -
7.9动态存储分配管理 - 33 -
7.10本章小结 - 34 - 第8章 hello的IO管理 - 36 -
8.1 Linux的IO设备管理方法 - 36 -
8.2 简述Unix IO接口及其函数 - 36 -
8.3 printf的实现分析 - 36 -
8.4 getchar的实现分析 - 39 -
8.5本章小结 - 39 - 结论 - 39 - 附件 - 41 - 参考文献 - 42 -
1、准备阶段:用户使用文本编辑器编写程序,并且保存为hello.c文件。
2、预处理阶段:hello.c经过预处理后变为hello.i文本文件。
3、编译阶段:编译器(ccl)将文本文件hello.i翻译成文本文件hello.s。
4、汇编阶段:接下来,汇编器(as)将hello.s翻译成机器语言指令,把这些 指令打包成一种叫做可重定位目标程序(relocatable object program)的 格式,并将结果保存在目标文件hello.o中。
5、链接阶段:将程序需要用到的库文件链接到可重定位目标程序hello.o 中,得到链接后的可执行程序hello。
全过程如下图所示:
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
1.硬件环境:X64 CPU;2.3GHz;16GRAM;512Disk
2.软件环境:Windows11 64位;Vmware 16;CentOS8 64位
3.工具:codeblocks;gdb;edb;
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
hello.c 源程序
hello.i 预处理后文件
hello.s 编译后的汇编文件
hello.o 汇编后的可重定位目标执行文件
hello 链接后的可执行文件
本章总体介绍了hello程序“一生”的过程,以及进行实验时的软硬件环境及开发与调试工具等基本信息。
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如
hello.c中第1行的#include <stdio.h>命令告诉预处理器读取系统头文件
stdio.h的内容,并把它直接插人程序文本中。结果就得到了另一个C程序,通常
是以.i作为文件扩展名。
用vim打开hello.i后,如下图:
程序的仍然保留了c程序的一些特征,但是内容大大增加。值得注意的一点 是,原先c程序以#开头的语句消失了,转而用实际的头文件中的程序代替。
本章介绍了预处理的相关概念和作用,经过预处理后的程序大大加长,将原先的”#”引用的头文件替换到源程序中。
编译器(ccl)将文本文件hello.i翻译成文本文件hello.s,它包含一
个汇编语言程序。该程序包含函数main的定义,如下图所示:
其中每条语旬都以一种文本格式描述了一条低级机器语言指令。汇编语言是 非常有用的,因为它为不同高级语言的不同编译器提供了通用的输出语句。
例如, C编译器和Fortran编译器产生的输出文件用的都是一样的汇编语言。
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
.file 声明源文件
.text 代码节
.section.rodata 只读数据段
.globl 声明全局变量
.type 声明一个符号是函数类型还是数据类型
.size 声明大小
.string 声明一个字符串
.align 声明对指令或者数据的存放地址进行对齐的方式
这两个.string表示的是main函数两次调用printf()函数输出的字符串。
首先获取c程序源代码,方便与汇编代码进行比较。hello.c的main() 函数如下图所示:
我们首先来看main()函数的前几行代码,如下图:
1、因为接下来要用到%rbp寄存器,所以先将%rbp保存起来。之后便将%rbp的值设置为栈的地址(mov %rsp, %rbp),然后将%rsp的值减去32,这里的subq指令,相当于c语言中的减操作。
2、然后使用cmpl比较4和argc的大小,如果相等就将程序跳转到符号为.L2的地方执行,这里的操作相当于c语言中的if(argc == 4)语句。
3、执行完if语句后,就面临两个分支。如果二者相等的话就跳到.L2,如果不相等的话就继续往下照常执行。我们看下面的代码如下图所示:
如果二者不相等,按程序的意思就是应该打印异常语句,然后调用exit()函数退出。就应该调用printf()函数打印”用法: Hello 学号 姓名 秒数!\n”语句了。在汇编中的实现是先使用movl指令把%.LC0(也就是我们之前说的.string字符串常量)放入%edi寄存器中作为第一个参数,这一步对应于c语言中的参数传递(地址/值)。
4..之后我们调用puts函数向控制台输出这段字符串,这里的call指令相当于c语言中的函数调用()指令。
5..L2下的第一行先把0存储到-4(%rbp)中,这一步操作相当于c语言中定义局部变量i,并且给i赋初值为0。
6..如果一切正常,那么我们程序接下来就会跳转到.L3标号处继续执行。接下来的代码如下图所示:
这一部分的内容基本上也是对应于使用printf()函数输出字符串”Hello %s %s\n”,值得注意的两个点是这里调用call printf之前导入了两个参数,分别存储在%rdi和%rsi寄存器中。并且存在一个循环结构。每次.L4代码执行完以后,都调用addl %1,-4(%rbp)使-4(%rbp)存储的值加1,然后与7进行比较,如果小于等于7的话就再次跳入.L4中循环一次,这段语句对应于c语言中的for(i=0;i<8;i++)循环。
7..最后一部分如下图所示:
这一部分没什么好说的,值得注意的是汇编指令最后一行的ret,代表main函数的正常退出,以%eax中存储的0作为返回值。这里的ret相当于c语言中的函数返回。
本章主要介绍了编译器处理c语言程序的基本过程,函数从源代码变为等价的汇编代码,编译器分别从c语言的数据,赋值语句,类型转换,算术操作,逻辑/位操作,关系操作,控制转移与函数操作这几点进行分析,通过理解了这些编译器编译的机制,我们可以很容易的将汇编语言翻译成c语言,提高了反向工程的能力。
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序(relocatable object program)的格式,并将结果保存在目标文件hello.o中o hello.o文件是一个二进制文件,它包含的字节是函数main的指令编码。如果我们在文本编辑器中打开hello.o文件,将看到一堆乱码。
典型的ELF可重定位目标文件如下图所示:
使用readelf -a hello.o > hello.elf指令生成elf格式的文件,如下图所示:
我们用vim打开hello.elf,然后进行分析。
首先如下图所示:
这一部分是ELF头部分,该部分用来获取文件的字长、字节顺序、文件类型(.o,exec,.so),机器类型等基本信息。
然后是section Header部分,如下图所示:
该部分用于记录ELF文件中各部分的偏移地址和各部分的占用的大小。
然后是重定向.rel部分,如下图所示:
这部分记录了所有需要重定向的条目,由图上可知他们基本上都需要一个四字节的地址用来替换原来的地址。由于我们生成可重定向文件时,系统并不知道这些部分在内存中的真实地址,所以也就无法直接用它们在内存中的地址进行赋值。这时我们就随便先赋一个值,并用一个重定向条目进行标记,等到编译器运行程序时,编译器会自己给每个重定向条目赋值。程序便可正常运行了。
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
用指令导出反汇编文件如下图:
通过与原来的汇编代码的比较后,发现程序主体部分几乎完全一致,一个主要的不同是每当要进行函数调用时(call),在汇编文件中是直接采用跳转到函数标号进行的,如下图所示:
这里是直接跳转到puts标号继续执行。
而在可重定向文件中,每个call指令之后不是直接调用偏移地址,而是采用了一个重定向标记,这个标记会在程序编译时被替换为实际的内存地址。如下图所示:
这里调用了atoi这个重定向标号。
还有一个值得注意的小细节是,在汇编文件中数字都以十进制数字表示,而在目标文件中数字都以十六进制表示(具体截图略)。
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
本章对hello.s进行了汇编,生成了hello.o可重定位目标文件,并且分析了可重定位文件的ELF头、节头部表、符号表和可重定位节,比较了hello.s和hello.o反汇编代码的不同之处,分析了汇编语言与机器语言的对应关系。
1.链接的概念:
链接是将各种代码和数据片段收集并合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
2.链接的作用:
链接器在软件开发过程中扮演着一个关键的角色,因为它们使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。
注意:这儿的链接是指从 hello.o 到hello生成过程。
首先调用ld命令生成hello可执行文件。如下图所示:
获得hello文件,如下图所示:
这里我们用到了动态链接器,它在程序运行时才进行链接,从共享库中获取代码并且合并到源程序中。
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
使用readelf指令,如下图所示:
用vim打开helloElf.elf,分析各部分信息。
首先是ELF头的部分,和汇编一样没什么区别,如下图所示:
然后是sectionHeader部分,依旧没什么区别,如下图所示:
然后是program headers,如下图所示:
program header 是一个 array of structures,每一个描述了 a segment 或者是系统需要的程序执行的信息。一个 object 文件的 segment 包含一个或几个 sections。program header 只有在 executable 和 shared object file 中才有意义。ELF header 中的 e_phentsize 和 e_phnum 说明了一个文件它自己的 program header 的大小。ELF program header 的结构体是 Elf32_Phdr 或者是 Elf64_Phdr。
接下来是Section to Segment mapping,表示节到段的映射,如下图所示:
然后是Dynamic section,这部分表示与动态链接有关的一些信息,如下图所示:
然后是重定向部分,如下图所示:
这部分把之前所有重定向文件的重定向部分都合并起来,生成了所有需要用到的重定向片段,方便编译器统一对这些地址进行替换。(注意:这些地址不是在linking阶段赋值的,而是在编译运行时由编译器进行赋值的,因为这个时候程序才会变成一个真正的进程,拥有独立的内存地址)。
最后一部分是符号表,如下图所示:
这一部分包括了所有的符号序号和符号引用之间的关系,负责解析符号,这一部分是在linking的resolve阶段进行的。这个有点类似于java的常量池,都是生成一个符号引用列表。
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
分析程序头LOAD可加载的程序段的地址为0x400000
使用edb打开hello从Data Dump窗口观察hello加载到虚拟地址的状况,并查看各段信息。如下图所示:
在0x400000~0x401000段中,程序被载入,虚拟地址0x400000开始,到0x400fff结束,根据5.3中的节头部表,可以通过edb找到各个节的信息,比如.txt节,虚拟地址开始于0x4010f0,大小为0x145。如下图所示:
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
使用指令后查看文件,发现程序中多了很多函数,说明此时连接过程已经把库函数中我们用到的函数都链接到了主程序中,如下图所示:
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
①开始执行:_start、_libc_start_main
②执行main:_main、_printf、_exit、_sleep、_getchar
③退出:exit
程序名 程序地址
_start 0x4010f0
_libc_start_main 0x2f12271d
main 0x401125
_printf 0x401040
_exit 0x401070
_sleep 0x401080
_getchar 0x401050
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。在调用共享库函数时,编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。正常的方法是为该引用生成一条重定位记录,然后动态链接器在程序加载的时候再解析它。
延迟绑定是通过GOT和PLT实现的,根据hello ELF文件可知,GOT起始表位置为0x404000如图:
GOT表位置在调用dl_init之前0x404008后的16个字节均为0:如下图所示:
调用dl_init之后的.got.plt如下图所示:
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
本章主要介绍了链接的概念与作用,链接可分为符号定义和重定位,了解了可执行文件的ELF格式,分析了hello的虚拟地址空间,重定位过程,执行过程,动态连接过程,对链接有了更深的理解。
经典定义就是一个执行中程序的实例。广义定义是进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元(现代操作系统的基本执行单元基本都是线程,但是进程依然作为资源分配的基本单位)。
在计算机科学中,Shell俗称壳(用来区别于核),是指"为使用者提供操作界面"的软件(命令解析器)。它类似于DOS下的command.com和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。
shell负责确保用户在命令提示符后输入的命令被正确执行。其功能包括:
(1) 读取输入并解析命令行
(2) 替换特别字符,比如通配符和历史命令符
(3) 设置管道、重定向和后台处理
(4) 处理信号
(5) 程式执行的相关设置
替换成命令;他们一般都采用$(command) 标记法。
7.Shell计算采用$(expression)标记的算术表达式。
8.Shell将命令字符串重新划分为新的块tokens。这次划分的依据是栏位分割 符号,称为IFS。缺省的IFS变量包含有:SPACE , TAB 和换行符号。
9.Shell执行通配符* ? [ ]的替换。
10.shell把所有从处理的结果中用到的注释删除,並且按照下面的顺序实行命 令的检查:
A.内建的命令
B. shell函数(由用户自己定义的)
C.可执行的脚本文件(需要寻找文件和PATH路径)
11.在执行前的最后一步是初始化所有的输入输出重定向。
12.最后,执行命令。
首先输入指令,如下图所示:
在输入命令行后,bash首先会调用eval解析命令行,然后调用builtIn()判断出该指令不是内置命令。
所以系统在当前路径中寻找名为hello的可执行文件,找到后,在init进程(即主进程)下创建一个子进程作为运行hello的进程,代码如下图所示:
运行后生成新的进程,我们可以调用ps命令来查看,结果如下图所示:
我们可以看到hello的当前进程号是5985,它的父进程是2881,即init进程。
在hello进程创建出来后,系统在这个进程中调用execve()函数,执行hello文件的内容,如下图所示:
hello程序的执行是依赖于进程所提供的抽象的基础上,进程提供给应用程序的抽象有:
1.一个独立的逻辑控制流,它提供一个假象,好像我们的进程独占的使用处理器
2. 一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用CPU内存。
操作系统所提供的进程抽象:
①逻辑控制流:如果想用调试器单步执行程序,我们会看到一系列的程序计数器(PC)的值,这些值唯一地对应于包含在程序的可执行目标文件中的指令,或是包含在运行时动态链接到程序的共享对象中的指令。这个PC值的序列叫做逻辑控制流,或者简称为逻辑流。
②上下文切换:如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程,上下文就是内核重新启动一个被抢占的进程所需要的状态,是一种比较高层次的异常控制流。
③时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
④用户模式和内核模式:shell使得用户可以有机会修改内核,所以需要设置一些防护措施来保护内核,如限制指令的类型和可以作用的范围。
⑤上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由 通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内 核数据结构等对象的值构成。
hello进程的执行:在进程调用execve函数之后,进程已经为hello程序分配了新的虚拟的地址空间,最初hello运行在用户模式下,输出hello 1190201016 石衍,然后调用sleep函数进程进入内核模式,运行信号处理程序,之后再返回用户模式。运行过程中,cpu不断切换上下文,使运行过程被切分成时间片,与其他进程交替占用cpu,实现进程的调度。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
1.异常和信号异常种类
类别 原因 异步/同步 返回行为
中断 来自I/O设备的信号 异步 总是返回到下一条指令
陷阱 有意的异常 同步 总是返回到下一条指令
故障 潜在可恢复的错误 同步 可能返回到当前指令或终止
终止 不可恢复的错误 同步 不会返回
2.运行结果
①正常运行,如下图所示:
②按下 ctrl-z,如下图所示:
Ctrl+z默认的操作是使进程暂停,我们使用ps应该还能看到hello程序对应的进程,如下图:
③按下 ctrl-c,如下图所示:
Ctrl+c默认的操作是使进程终止,我们使用ps应该已经看不到hello进程了,如下图:
可以看到结果与我们预期的一致。
④当我们乱按键盘时,如下图所示:
可以看到我们输入的字符串随着hello程序一起输出到了屏幕上。
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
本章介绍了进程的概念和作用、shell-bash的处理过程与作用并且着重分析了调用fork创建新进程,调用execve函数执行hello,hello的进程执行过程,以及hello在运行时遇到的异常与信号处理。
1.逻辑地址:程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 段标识符:段内偏移量。
2.线性地址:逻辑地址向物理地址转化过程中的一步,逻辑地址经过段机制后转化为线性地址,为描述符:偏移量的组合形式,分页机制中线性地址作为输入。
3.虚拟地址:就是线性地址。
4.物理地址:CPU通过地址总线的寻址,找到真实的物理内存对应地址。CPU对内存的访问是通过连接着CPU和北桥芯片的前端总线来完成的。在前端总线上传输的内存地址都是物理内存地址。
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图所示:
索引号就是“段描述符”的索引,段描述符具体地址描述了一个段。很多个段描述符,就组了一个数组,叫“段描述符表”,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符。每一个段描述符由8个字节组成,如图所示:
Base字段,它描述了一个段的开始位置的线性地址。Intel设计的本意是,一些全局的段描述符,就放在“全局段描述符表”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表”中,用段选择符中的T1字段来判断用全局段描述符表还是局部段描述符表,=0,表示用全局段描述符表,=1,表示用局部段描述符表。如图所示:
先给定一个完整的逻辑地址 段标识符:段内偏移量
1.看段选择符的T1=0还是1,知道当前要转换是全局段描述符表中的段还是局部段描述符表中的段。
2.取出段选择符的前13位查找到对应的段描述符,确定了Base基地址。
3.将Base+offset,就是线性地址。
线性地址即虚拟地址(VA)到物理地址(PA)之间的转换通过分页机制完成,而分页机制是对虚拟地址内存空间进行分页。
系统将虚拟页作为进行数据传输的单元。Linux下每个虚拟页大小为4KB。物理内存也被分割为物理页, MMU(内存管理单元)负责地址翻译,MMU使用页表将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。如下图所示:
n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。
Core i7采用四级页表的层次结构。CPU产生虚拟地址VA,虚拟地址VA传送给MMU,MMU使用VPN高位作为TLBT和TLBI,向TLB中寻找匹配。如果命中,则得到物理地址PA。如果TLB中没有命中,MMU查询页表,CR3确定第一级页表的起始地址,VPN1确定在第一级页表中的偏移量,查询出PTE,以此类推,最终在第四级页表中找到PPN,与VPO组合成物理地址PA,添加到PLT。如下图所示:
获得物理地址之后,先取出组索引对应位,在L1中寻找对应组。如果存在,则比较标志位,相等后检查有效位是否为1.如果都满足则命中取出值传给CPU,否则按顺序对L2cache、L3cache、内存进行相同操作,直到出现命中。然后再一级一级向上传,如果有空闲块则将目标块放置到空闲块中,否则将缓存中的某个块驱逐,将目标块放到被驱逐块的位置。
在shell输入命令行后,内核调用fork创建子进程,为hello程序的运行创建上下文,并分配一个与父进程不同的PID。通过fork创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。
execve函数调用驻留在内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要以下几个步骤:
①删除已存在的用户区域
删除当前进程虚拟地址的用户部分中的已存在的区域结构。
②映射私有区域
为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制零的,初始长度为零。
③映射共享区域
hello程序与共享对象libc.so链接,libc.so是动态链接到这个程序中的,然后再映射到用户虚拟地址空间中的共享区域内。
④设置程序计数器(PC)
execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。如图所示:
如果程序执行过程中发生了缺页故障,则内核调用缺页处理程序。
处理程序执行如下步骤:
1.检查虚拟地址是否合法,如果不合法则触发一个段错误,程序终止。
2.检查进程是否有读、写或执行该区域页面的权限,如果不具有则触发保护异常,
程序终止。
3.两步检查都无误后,内核选择一个牺牲页面,如果该页面被修改过则将其交换
出去,换入新的页面并更新页表。然后将控制转移给hello进程,再次执行触发缺页故障的指令。如下图所示:
动态内存管理的基本方法与策略:
动态内存分配器维护着一个进程的虚拟内存区域,称为堆,系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高地址)。对于每个进程,内核维护着一个变量brk,它指向对的顶部。如下图所示:
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显示地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显示执行的。要么是内存分配器自身隐式执行的。分配器有两种基本风格。两种风格都要求应用显示地分配块。他们的不同之处在于由哪个实体来负责释放已分配的块。
1.显示分配器:要求应用显示的释放任何已分配的块。例如C标准库提供一个叫
做malloc程序包的显示分配器。
2.隐式分配器:要求分配器检测一个已分配块何时不再被程序使用,那么就释放
这个块。隐式分配器也叫垃圾收集器。
①隐式空闲链表的堆块格式如下图所示:
②隐式空闲链表的带边界标记的堆块格式:
使用边界标记的堆块的格式其中头部和脚部分别存放了当前内存块的大小与是否已分配的信息。通过这种结构,隐式动态内存分配器会对堆进行扫描,通过头部和脚部的结构实现查找。如下图所示:
③显示空闲链表:
显示空闲链表是将空闲块组织为某种形式的显示数据结构。如图所示。堆被组织为一个双向空闲链表,在每个空闲块中,都包含一个前驱和后继的指针。如下图所示:
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。维护链表的顺序有:后进先出(LIFO),将新释放的块放置在链表的开始处,使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理,以intel Core7在指定环境下介绍了虚拟地址VA到物理地址PA的转换、物理内存访问,分析了hello进程fork时的内存映射,hello进程execve时的内存映射、缺页故障与缺页中断处理和动态存储分配管理。
设备的模型化:所有IO设备都被模型化为文件,所有的输入和输出都能被当做相应文件的读和写来执行。
设备管理:Linux内核有一个简单、低级的接口,成为Unix I/O,是的所有的输入和输出都能以一种统一且一致的方式来执行。
设备的模型化:文件
设备管理:unix io接口
Unix IO接口:
1.打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2.Linux Shell创建的每个进程都有三个打开的文件:标准输入(描述符为0),标准输出(描述符为1),标准错误(描述符为2)。
3.改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
4.读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
5.关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件,作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放他们的内存资源。
Unix I/O函数:
①int open(char* filename,int flags,mode_t mode)
进程通过调用open函数来打开一个存在的文件或是创建一个新文件的。open函数将filename转换为一个文件描述符,并且返回描述符数字,返回的描述符总是在进程中当前没有打开的最小描述符,flags参数指明了进程打算如何访问这个文件,mode参数指定了新文件的访问权限位。
②int close(fd)
进程通过调用close函数关闭一个打开的文件,fd是需要关闭的文件的描述符。
③ssize_t read(int fd,void *buf,size_t n)
read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
④ssize_t wirte(int fd,const void *buf,size_t n)
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置。
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生成格式化之后的字符串,并返回字串的长度。
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':
itoa(tmp, *((int*)p_next_arg));
strcpy(p, tmp);
p_next_arg += 4;
p += strlen(tmp);
break;
case 's':
break;
default:
break;
}
}
return (p - buf);
}
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
在write函数中,将栈中参数放入寄存器,ecx是字符个数,ebx存放第一个字符地址,int INT_VECTOR_SYS_CALLA代表通过系统调用syscall
syscall实现:
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中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量),于是我们的打印字符串就显示在了屏幕上。
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
本章介绍了Linux的IO设备管理方法,Unix IO接口及其函数,分析了printf函数和getchar函数的实现。
1.输入:将hello.c代码从键盘输入。
2.预处理(cpp):将hello.c进行预处理,将c文件调用的所有外部的库展开合并,
生成hello.i文件。
3.编译(ccl):将hello.i文件进行翻译生成汇编语言文件hello.s。
4.汇编(as):将hello.s翻译成一个可重定位目标文件hello.o。
5.链接(ld):将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程
序hello,至此可执行hello程序正式诞生。
6.运行:在shell中输入./hello 1190201016 石衍 1
7.创建子进程:由于终端输入的不是一个内置的shell命令,因此shell调用fork ()
函数创建一个子进程。
8.加载程序:shell调用execve函数,启动加载器,映射虚拟内存,进入程序入口
后程序开始载入物理内存,然后进入main函数。
9.执行指令:CPU为进程分配时间片,在一个时间片中,hello享有CPU资源, 顺
序执行自己的控制逻辑流。
10.访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址。
11.动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存。
12.信号管理:当程序在运行的时候我们输入Ctrl+c,内核会发送SIGINT信号给进
程并终止前台作业。当输入Ctrl+z时,内核会发送SIGTSTP信号给进程,并将前台作业停止挂起。
13终止:当子进程执行完成时,内核安排父进程回收子进程,将子进程的退出状态传递给父进程。内核删除为这个进程创建的所有数据结构。
对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法:
抽象对计算机系统是很重要的概念,底层信息用二进制来抽象表示,进程是对处
理器、主存和I/O设备的抽象,虚拟内存是对主存和磁盘设备的抽象,文件是对I/O
设备的抽象,等等。另外,存储器的“过渡”策略也十分精妙,由于CPU的处理
速度比主存快得多,为了减少“供不应求”的现象,在CPU和主存之间增加了一
级,二级,三级cache大大提高了CPU访问主存的速度。
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
文件名称 功能
hello.c 源程序
hello.i 预处理后文件
hello.s 编译后的汇编文件
hello.o 汇编后的可重定位目标执行文件
hello 链接后的可执行文件
hello.elf hello.o的ELF格式
helloo.txt hello.o的反汇编
hello.txt hello的反汇编代码
helloElf.elf hello的ELF格式
列出所有的中间产物的文件名,并予以说明起作用。
为完成本次大作业你翻阅的书籍与网站等
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。