计算机系统
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L020307
班 级 2003003
学 生 王佳路
指 导 教 师 史先俊
计算机科学与技术学院
2022年5月
本文通过一个给定的程序hello.c,通过hello.c在Linux环境中被编译为可执行文件的步骤,逐步介绍在程序员视角下的计算机系统相关知识,分析hello程序的一生。
关键词:计算机系统;编译;汇编;linux;Ubuntu;进程;虚拟内存;I/O
(摘要0分,缺失-1分,根据内容精彩称都酌情加分0-1分)
目 录
第1章 概述................................................... - 4 -
1.1 Hello简介............................................ - 4 -
1.2 环境与工具........................................... - 4 -
1.3 中间结果............................................... - 5 -
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 本章小结............................................. - 12 -
第4章 汇编................................................. - 13 -
4.1 汇编的概念与作用............................. - 13 -
4.2 在Ubuntu下汇编的命令.................. - 13 -
4.3 可重定位目标elf格式...................... - 13 -
4.4 Hello.o的结果解析........................... - 15 -
4.5 本章小结............................................. - 18 -
第5章 链接................................................. - 19 -
5.1 链接的概念与作用............................. - 19 -
5.2 在Ubuntu下链接的命令.................. - 19 -
5.3 可执行目标文件hello的格式......... - 19 -
5.4 hello的虚拟地址空间....................... - 22 -
5.5 链接的重定位过程分析..................... - 23 -
5.6 hello的执行流程............................... - 24 -
5.7 Hello的动态链接分析...................... - 24 -
5.8 本章小结............................................. - 25 -
第6章 hello进程管理.......................... - 27 -
6.1 进程的概念与作用............................. - 27 -
6.2 简述壳Shell-bash的作用与处理流程.. - 27 -
6.3 Hello的fork进程创建过程............ - 27 -
6.4 Hello的execve过程........................ - 27 -
6.5 Hello的进程执行.............................. - 27 -
6.6 hello的异常与信号处理................... - 28 -
6.7本章小结.............................................. - 31 -
第7章 hello的存储管理...................... - 32 -
7.1 hello的存储器地址空间................... - 32 -
7.2 Intel逻辑地址到线性地址的变换-段式管理............................................................ - 32 -
7.3 Hello的线性地址到物理地址的变换-页式管理........................................................ - 33 -
7.4 TLB与四级页表支持下的VA到PA的变换................................................................ - 33 -
7.6 hello进程fork时的内存映射......... - 35 -
7.7 hello进程execve时的内存映射..... - 35 -
7.8 缺页故障与缺页中断处理................. - 35 -
7.9动态存储分配管理.............................. - 36 -
7.10本章小结............................................ - 37 -
第8章 hello的IO管理....................... - 39 -
8.1 Linux的IO设备管理方法................. - 39 -
8.2 简述Unix IO接口及其函数.............. - 39 -
8.3 printf的实现分析.............................. - 39 -
8.4 getchar的实现分析.......................... - 41 -
8.5本章小结.............................................. - 41 -
结论............................................................... - 42 -
附件............................................................... - 43 -
参考文献....................................................... - 44 -
第1章 概述
1.1 Hello简介
P2P,全程Program to Process,其中的Program是指在编辑器中写下的hello.c文件。process则是指写下的程序经过预处理编译等操作,变为可执行文件的过程。首先程序员用键盘输入hello.c 文件,然后经过预处理器、汇编器、编译器、链接器的一系列处理,就生成了一个hello 可执行文件(Program)。
具体过程:
- 预处理器将hello.c文件中的#开头的命令进行预处理,插入对应的系统头文件中的内容,得到hello.i文件;
- 然后编译器将其翻译为汇编语言文件hello.s然后汇编器将汇编语言翻译为机器指令代码,得到hello.o文件;
- 最后,在链接阶段,链接器ld将hello.o和其它用到的预编译好的目标文件合并到一起并且完成引用的重定位工作,就得到了一个可执行文件hello。
我们可以在Ubuntu的shell程序中输入./hello执行hello程序,此时shell程序会调用fork函数创建子进程,并且在子进程中加载该程序,到这一步,hello.c文件就成为了一个进程。
1.2 环境与工具
硬件环境:
CPU:Intel(R) Core(TM) i7-10750U
内存:8.00GB
磁盘: 512GB SSD
显卡:MX350
软件环境:
Windows10 64位;
VMware Workstation
Ubuntu 18.04 64位;
工具:
Visual Studio Code 64位;gedit,gcc, readelf, objdump, hexedit, edb
1.3 中间结果
hello.c | hello源代码 |
hello.i | 预处理之后的ASCII码文件 |
hello.s | 编译之后得到的文件 |
hello.o | 汇编之后的可重定位目标文件 |
hello | 链接之后的可执行目标文件 |
helloelf.txt | hello的ELF格式文件 |
hellooelf.txt | hello.o的ELF格式文件 |
1.4 本章小结
本章简要介绍了hello的P2P,020过程和大作业的软硬件环境及开发工具,还列出了操作过程中生成的中间文件。
(第1章0.5分)
第2章 预处理
2.1 预处理的概念与作用
概念:
预处理是指程序在编译一个c文件之前,根据以字符#开头的命令(头文件等),修改原始的c程序。
作用:
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如通过#include <stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,把它直接插人程序文本中,并拓展所有用#define声明指定的宏。
2.2在Ubuntu下预处理的命令
gcc -E hello.c -o hello.i
2.3 Hello的预处理结果解析
#include指令:在编译期间把置顶文件的内容包含进当前文件中;
#define指令:用字符序列替换一个标记;
如图所示:
2.4 本章小结
本章主要介绍了预处理的概念和功能,将hello.c文件进行预处理生成hello.i文件,并对生成的hello.i文件进行了分析。
(第2章0.5分)
第3章 编译
3.1 编译的概念与作用
编译器(ccl)将文本文件hello.i编译成文本文件hello.s,它包含一个汇编语言程序。汇编语言程序中的每条语句都以一种标准的文本格式确切的描述一条低级机器语言指令。首先,编译器对程序代码进行分析和分割,形成一些记号,同时检查代码中是否有不规范记号,如果存在就生成错误提示然后分析生成符号表,主要是为了将高级语言(C,C++等)翻译为机器语言一一对应的汇编语言。
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.3.1常量的解析
整形常量:
整形常量在hello.c文件中是以代码中的数字的形式存在,例如:
汇编代码中的位置:
字符串:
hello.c文件中的字符串常量有两处:
hello.s文件中常量字符串对应的存储位置:
其中每个汉字3字节(以UTF-8的格式进行编码)
3.3.2变量的解析
局部变量
在hello.c中出现的局部变量主要是int i
在汇编代码中可以发现其被分配在运行时栈上
3.3.3算数操作
在for循环中,int i进行了++操作
在对应的汇编代码中,是通过add指令完成的
3.3.4控制转移
Hello.c中的控制转移有对于表达式的值的判断,通过cmp指令和jle指令组合完成对于for循环体是否循环的判断,从而完成循环体的跳转。
此外,还有对于表达式的判断,通过cmpl和je组合判断完成if语句的跳转。
3.3.5数组/指针/结构操作
在main函数中传递的参数出现了指针数组
每一个数组的元素都是一个char*类型的指针,可以指向一个字符串,argv[0]指向文件名,argv[1] argv[2]分别指向命令行输入的第一个和第二个参数,在汇编代码中这个指针数组的首地址被存放在rsi中,也就是存放main函数的第二个参数的寄存器。
对于指针数组argv,其每一个元素的大小都是8字节,而且数组中的元素应是连续存放的,因此地址偏移量也应该是8的倍数
从此处的代码我们可以验证这一点。
3.3.6函数操作
Main函数
首先我们可以查看main函数的汇编代码,发现其两个参数,argc和argv分别被存放在寄存器rdi和rsi中.
继续查看后面的汇编代码,我们发现对于argv的操作,其指针数组的基地址通过寄存器存放,我们将基地址加上8的倍数的偏移量就可以访问指针数组的别的成员指针,而为了访问每一个指针成员指向的字符串,我们需要访问每一个指针指向的内存空间,也就是说,每个指针成员指向的字符串是存放在内存中的
Main函数的返回值是int类型的,存储在寄存器%rax中,在需要返回时,先将rax的值设置为0,然后返回即可,对应于hello.c文件中的return 0.
Printf函数
Hello.c文件中,printf函数被调用了2次
第一次调用printf函数实际上是调用了puts函数,因为此次不需要传递额外的参数,只需要将内存中存储的字符串复制到屏幕上即可,因此编译器做了一点等价的替换。对应的汇编代码部分如下:
第二次总共传递了三个参数,第一个参数是主体字符串的首地址,被存放在rdi中,第二、第三个参数分别是替换的字符串的首地址,通过寄存器rsi和rdx传递,对应汇编代码如下:
可以看到
Exit函数
Exit函数的源代码如下:
对应的汇编代码如下:
函数传递的参数存放在edi中,对应了exit(1),传递的整数值直接作为函数退出的状态值。
Sleep函数
Sleep函数的源代码如下:
sleep函数通过rdi传递了一个参数作为休眠时间的控制,对应的汇编代码如下:
Getchar函数
Getchar函数的汇编代码如下:
此函数没有参数,不需要通过寄存器进行参数传递。
3.4 本章小结
本章主要介绍了编译的概念以及过程,对hello进行编译转换为汇编代码。分析编译得到的文件,介绍汇编代码如何实现变量、常量、函数调用等。通过理解这些汇编代码,我们能够理解编译器的优化能力,并分析代码中隐含的低效率。
(第3章2分)
第4章 汇编
4.1 汇编的概念与作用
汇编的概念:
是通过汇编器,把汇编语言翻译成机器语言的过程。
汇编的作用:
通过汇编过程把汇编代码转换成计算机能直接识别的机器代码,把指令打包成可重定位目标程序的格式,并将结果保存在.o 目标文件中。
4.2 在Ubuntu下汇编的命令
输入如下指令:
生成的文件:
4.3 可重定位目标elf格式
4.3.1 生成elf文件
首先通过readelf指令将hello.o转换为TXT文档:
生成的文件如下:
4.3.2 ELF头信息
可以从ELF头中看到,其中包含了包含了版本和系统信息、编码方式、节的大小和数量、ELF头大小和头部索引等等一系列信息:
4.3.3节头目表
4.3.4 重定位节
重定位节中有各种引用的外部符号,给出了他们的偏移量。包括全局变量,以及调用的函数,其中.rodata中的数据是模式串。
有了这些信息之后,在下一步进行链接2,就可以通过重定位节对这些位置的地址进行重定位,使其映射到虚拟内存上。
4.3.4 符号表
4.4 Hello.o的结果解析
4.4.1首先输入如下指令,得到hello.o的反汇编代码
代码如下:
4.4.2对两份汇编代码进行对比,我们可以发现以下的不同之处:
1.hello.s中的跳转采用跳转到.L2等方式进行跳转,而反汇编文件中则是采用直接跳转,跳转位置为主函数起始地址加上偏移量。
2.hello.s中的操作数采用10进制编码,而反汇编文件中的操作数都已16进制编码
3.hello.s中的函数调用采用直接跳转到函数名的方式进行跳转,而反汇编文件中则是采用直接跳转,跳转位置为主函数起始地址加上偏移量。
4.4.3机器语言的构成与汇编语言的映射关系:
反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码;反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有些细微的差别。例如:省略了很多指令结尾的‘q’.
在函数调用和分支跳转时,二者也是有差别的。
分支跳转时,如判断argc!=4,然后跳转
hello.s中是jump到.L6,用一个标记指示;
hello.o的反汇编是跳转到地址15
函数调用时,如printf("Hello %s %s\n",argv[1],argv[2])
hello.s中是将参数传递到寄存器之后,call后紧跟的是调用函数明
而hello.o的反汇编中call后紧跟的是45 <main+0x45>,并且还有一个重定位条目,用于链接时重定位。
值得注意的是,printf的在.rodata中的格式串也不再表示为.LC1,而用mov $0x0 , %esi表示,后面也跟着一个重定位条目,用于链接时重定位。
4.5 本章小结
了解汇编的概念和作用,汇编得到.o文件,然后分析了可重定位目标elf格式,然后使用objdump进行反汇编并与.s文件进行比较,更加深入地理解机器语言与汇编语言的关系。
(第4章1分)
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念:
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载到内存并执行。链接可以执行于编译时、加载时、运行时。
5.1.1链接的作用:
链接使得分离编译成为可能。我们可以把一个大型的应用程序分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接,而不必重新其他文件。
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.3.1运行如下指令:
5.3.1 ELF文件头:
5.3.2 节头部表:
5.3.3 符号表:
5.4 hello的虚拟地址空间
使用edb加载hello,查看hello的虚拟地址空间各段信息。
查看 ELF 格式文件中的 Program Headers,它告诉链接器运行时加载的内容,并提供动态链接的信息。每一个表项提供了各段在虚拟地址空间和物理地址空间的各方面的信息。其中PHDR部分负责保存程序头表;INTERP指定了在程序映射到内存之后必须调用的解释器;LOAD则表示一个从二进制文件映射到虚拟地址空间的段,其中保存了常量数据、程序的目标代码等等数据;DYNAMIC保存了由动态链接器使用的信息;而NOTE保存辅助信息;GNU_STACK是其中的权限标志,用于标识栈是否是可执行的;GNU_RELRO则是在指定在重定位结束之后哪些内存区域需要设置为只读区域。
5.5 链接的重定位过程分析
命令:
得到的反汇编代码:
hello与hello.o的反汇编文件的比较:
在hello的反汇编文件中新增了.init和.plt节,和一些节中定义的函数。在hello.c中调用的一些库函数被链接器链接到了hello文件中,通过反汇编代码就可以查看到这些新增函数,如exit、printf、sleep、getchar等函数。Hello的跳转和函数地址都变成了虚拟内存地址,这就是链接器的重定位的功能。而在hello.o的反汇编代码中,并没有相应的虚拟地址,因此在.rela.text节中为其添加了重定位条目。hello.o中的代码段的起始地址全部为0,需要将其映射到对应的可执行文件的虚拟地址中,因此需要重定位,并且添加重定位条目。
5.6 hello的执行流程
(使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
子程序名 | 程序地址 |
_init | 0x2d9d6000 |
printf@plt | 0x2d9d6040 |
getchar@plt | 0x2d9d6050 |
atoi@plt | 0x2d9d6060 |
sleep@plt | 0x2d9d6080 |
puts@plt | 0x2d9d6030 |
exit@plt | 0x2d9d6070 |
_start | 0x2d9d6100 |
main | 0x2d9d61e9 |
__libc_csu_init | 0x2d9d6280 |
__libc_csu_fini | 0x2d9d62f0 |
5.7 Hello的动态链接分析
在elf文件中:
进入edb查看:
图 5.7 edb执行init之前的地址
图 5.8 edb执行init之后的地址
一开始地址的字节都为0,调用_init函数之后GOT内容产生变化,指向正确的内存地址,下一次调用跳转时可以跳转到正确位置。
5.8 本章小结
在本章中,我们主要更了解了在Linu系统中链接的运行机制。通过将目标文件hello.o链接为hello可执行文件,我们更加了解了关于链接器的工作细节。并且,通过Ereadelf指令得到了ELF文件,以及通过objdump指令得到了反汇编文件,通过与前几章中的反汇编文件进行比较,我们也更加清楚了关于链接和重定位的具体工作,以及动态链接的具体工作内容。
(第5章1分)
第6章 hello进程管理
6.1 进程的概念与作用
概念:进程是一个执行中的程序的实例。 作用:通过进程,我们会得到一种假象,好像我们的程序是当前唯一运行的程序,我们的程序独占处理器和内存,我们程序的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
Shell是一个交互型应用级程序,代表用户运行其他程序(是命令行解释器,以用户态方式运行的终端进程其基本功能是解释并运行用户的指令,重复如下处理过程:
首先终端进程读取用户由键盘输入的命令行。分析命令行字符串,获取命令行参数,并构造传递给execve的argv向量检查第一个命令行参数是否是一个内置的shell命令如果不是内部命令,调用fork( )创建新进程/子进程。在子进程中,用获取的参数,调用execve( )执行指定程序。
6.3 Hello的fork进程创建过程
shell判断出不是内置命令后,加载可执行文件hello,通过fork创建子进程,子进程得到一份与父进程用户级虚拟地址空间相同的副本,还获得与父进程打开的文件描述符相同的副本,子进程与父进程的PID不同。fork被调用一次,但返回两次,父进程中返回子进程的PID,子进程返回0。
6.4 Hello的execve过程
在前文提到的shell父进程创建的子进程中,子进程会调用execve函数,其参数主要是两个二级指针char **argv , char **envp,其作用主要是给出加载程序的参数。只有在加载文件出现错误的时候,例如找不到目标文件,execve函数才会返回,否则就直接执行程序,不再返回。
在execve加载了Hello之后,会为新的程序映射虚拟内存空间,并且将控制流转移到新程序的主函数中,主函数main有三个参数int argc , char **argv , char **envp,其中argv和envp分别等于传递向execve函数的参数,argc的数值等于argv指向的字符串数组的元素个数。
此时子进程还会为新的程序映射新的数据段和代码段区域,以及共享区,然后将程序计数器(PC)的值修改为新程序的代码区域入口处的地址。
6.5 Hello的进程执行
在系统中,用户进程数一般都多于处理机数、这将导致它们互相争夺处理机。另外,系统进程也同样需要使用处理机。这就要求进程调度程序按一定的策略,动态地把处理机分配给处于就绪队列中的某一个进程,以使之执行。
在进程执行的某些时刻,内核可以决定先停止当前进程的运行,然后重新启动一个先前以及暂停了的进程,这种决策就叫做进程的调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。
为了能让处理器安全运行,以不至于使得当前进程修改一些操作系统或者是内核的核心代码和数据,从而造成操作系统的损坏,我们需要对于程序对于不同区域的读写权限进行划分。我们根据权限的不同将模式划分为用户模式与核心模式,核心模式拥有最高的访问权限,何以访问和修改任意处的数据,处理器用一个寄存器来记录当前的模式。对于一个进程,只有在陷入故障,中断或者是系统调用等情况的时候才会进入内核模式,其他时候都始终处于用户模式之下,无法对系统或者是内核的核心数据和代码作出修改,从而保证了操作系统的安全性。
6.6 hello的异常与信号处理
hello 执行过程中可能出现四类异常:中断、陷阱、故障和终止。
1. 中断是来自 I/O 设备的信号,异步发生,中断处理程序对其进行处理,返 回后继续执行调用前待执行的下一条代码,就像没有发生过中断。
2. 陷阱是有意的异常,是执行一条指令的结果,调用后也会返回到下一条指 令,用来调用内核的服务进行操作。帮助程序从用户模式切换到内核模式。
3. 故障是由错误情况引起的,它可能能够被故障处理程序修正。如果修正成 功,则将控制返回到引起故障的指令,否则将终止程序。
4. 终止是不可恢复的致命错误造成的结果,通常是一些硬件的错误,处理程 序会将控制返回给一个 abort 例程,该例程会终止这个应用程序。
信号处理方式:
图 6.1 中断处理
图 6.2 陷阱处理
图 6.3 故障处理
图 6.4 终止处理
我们输入运行命令,并且按照要求输入参数,观察程序正常运行的结果如下:
可以看到,程序按照设定的间隔(1s)休眠,然后输出指定信息。
按下Ctrl+Z:
根据课本上关于信号和进程的知识,我们可以知道,按下Ctrl+Z之后,进程会收到一个SIGSTP 信号,使得当前的hello进程被挂起。用ps指令查看其进程PID,可以发现hello的PID是37740;再用jobs查看此时hello的后台 job号是1,调用指令fg 1将其调回前台。
按下Ctrl+C
根据课本上的相关知识,我们知道此时进程收到一个SIGINT 信号,一次结束 hello。我们输入ps指令,发现查询不到hello进程的PID,输入指令jobs,发现也没有对应作业,因此hello进程被彻底终止。
乱按:
此时的程序只会将其记录在输入缓冲区,不会影响程序的运行,但是如果在hello运行结束后还有乱按的指令在缓冲区的,那么就会被作为新的命令行输入。
Kill命令:
通过kill指令向所在的挂起的进程发出终止指令,在此之后,通过ps指令无法找到对应的进程,对应的jobs指令也无法找到作业,说明进程已经被终止。
6.7本章小结
本章了解了hello进程的概念和shell的作用,分析了hello进程的执行过程以及fork和execve的作用,了解信号处理和hello进程如何在内核和前端中反复跳跃运行的。(第6章1分)
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.1.1逻辑地址
逻辑地址是指在计算机体系结构中是指应用程序角度看到的内存单元(memory cell)、存储单元(storage element)、网络主机(network host)的地址。 是指由程序产生的与段相关的偏移地址部分。
7.1.2线性地址
线性地址(Linear Address)是逻辑地址到物理地址变换之间的中间层。在分段部件中逻辑地址是段中的偏移地址,然后加上基地址就是线性地址。
7.1.3虚拟地址
虚拟地址是程序运行在保护模式下,这样程序访问存储器所使用的逻辑地址称为虚拟地址。
7.1.4物理地址
放在寻址总线上的地址。放在寻址总线上,如果是读,电路根据这个地址每位的值就将相应地址的物理内存中的数据放到数据总线中传输。如果是写,电路根据这个地址每位的值就在相应地址的物理内存中放入数据总线上的内容。物理内存是以字节(8位)为单位编址的。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址的构成有两个部分:分别是段标识符和段内偏移量。其中,段标识符是一个由16位长数据组成的字段,将其称为段选择符。其中的前13位是一个索引号。而后面三位中则包含了一些硬件的细节。
段式内存管理方式就是直接将逻辑地址转换成物理地址,也就是CPU不支持分页机制。其地址的基本组成方式是段号+段内偏移地址。
在x86保护模式下,段的信息即段描述符占8个字节,段信息无法直接存放在段寄存器中。Intel的设计是段描述符集中存放在GDT或LDT中,而段寄存器存放的是段描述符在GDT或LDT内的索引值。
首先给定一个完整的逻辑地址,其次,看段选择描述符中的T1字段是0还是1,可以知道当前要转换的是GDT中的段,还是LDT中的段,再根据指定的相应的寄存器,得到其地址和大小,我们就有了一个数组了。接着,拿出段选择符中的前13位,可以在这个数组中查找到对应的段描述符,这样就有了Base,即基地址就知道了。最后,把基地址Base+Offset,就是要转换的下一个阶段的地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
首先,将线性地址划分为VPN+VPO的格式,然后将VPN拆分为TLBT+TLBI的格式,然后在TLB中寻找对应的PPN,如果有缺页的情况发生,那么就去下一级页表中寻找对应的PPN,以此类推。找到PPN知乎,将其与之前的VPO进行组合就得到了对应的物理地址。
7.4 TLB与四级页表支持下的VA到PA的变换
为了消除每次 CPU 产生一个虚拟地址,MMU 就查阅一个 PTE 带来的时间开销,许多系统都在 MMU 中包括了一个关于 PTE 的小的缓存,称为翻译后被缓冲器(TLB),TLB 的速度快于 L1 cache。
TLB 通过虚拟地址 VPN 部分进行索引,分为索引(TLBI)与标记(TLBT)两个部分。这样,MMU 在读取 PTE 时会直接通过 TLB,如果不命中再从内存中将PTE 复制到 TLB。同时,为了减少页表太大而造成的空间损失,可以使用层次结构的页表页压缩页表大小。core i7 使用的是四级页表。
在四级页表层次结构的地址翻译中,虚拟地址被划分为 4 个 VPN 和 1 个 VPO。每个 VPNi 都是一个到第 i 级页表的索引,第 j 级页表中的每个 PTE 都指向第 j+1级某个页表的基址,第四级页表中的每个 PTE 包含某个物理页面的 PPN,或者一个磁盘块的地址。为了构造物理地址,在能够确定 PPN 之前,MMU 必须访问四个PTE。
综上,在四级页表下,MMU 根据虚拟地址不同段的数字通过 TLB 快速访问得到下一级页表的索引或者得到第四级页表中的物理页表然后与 VPO 组合,得到物理地址(PA)。
7.5 三级Cache支持下的物理内存访问
根据上述步骤得到了一个物理地址PA之后,根据Cache的具体结构,我们将PA划分为CT(标记位),CS(组号)和CO(偏移量)
接下来,我们根据CS寻找到正确的组,比较每一个缓存行是否标记位有效以及CT是否相等。如果命中,就返回CO偏移量指向的数据,如果没有命中,就依次去L2,L3,主存判断是否命中,当命中时,将数据传给CPU同时更新各级cache的缓存行。
多级Cache示意图如下:
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,首先,内核会为新进程创建各种与父进程相同的数据结构,并分配给它一个唯一的PID,同时为这个新进程创建虚拟内存。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任意一个在之后进行写操作时,写时复制机制就会为其创建新页面。因此,也就为每个进程保持了私有空间地址的抽象概念。
7.7 hello进程execve时的内存映射
exceve函数加载和执行程序Hello,需要以下几个步骤:
1.删除已存在的用户区域。
2.创建新的私有区域(.malloc,.data,.bss,.text)。
3.创建新的共享区域(libc.so.data,libc.so.text)。
4.设置程序计数器PC,指向代码的入口点。
7.8 缺页故障与缺页中断处理
DRAM缓存不命中称为缺页,即虚拟内存中的字不在物理内存中。CPU引用了虚拟页的一个字,地址翻译硬件从内存中读取了该虚拟页对应的页表条目,从有效位推断出该页未被缓存,这样就触发了一个缺页异常,缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,把要缓存的页缓存到牺牲 页的位置。如果这个牺牲页被修改过,就把它交换出去。当缺页处理程序返回时, CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。
下图对VP3的引用不命中,从而触发缺页。
缺页之后,缺页处理程序选择VP4作为牺牲页,并从磁盘上用VP3的副本取代它。在缺页处理程序重新启动导致缺页的指令之后,该指令将从内存中正常地读取字,而不会再产生异常
7.9动态存储分配管理
基本方法:维护一个虚拟内存区域“堆”,分配器将堆视为一组不同大小的块的集合来维护,每个块要么是已分配的,要么是空闲的,需要时选择一个合适的内存块进行分配。
分配器分为两种基本风格:显式分配器、隐式分配器。
1. 显式分配器:要求应用显式地释放任何已分配的块。
2. 隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这 个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
带边界标签的隐式空闲链表分配器原理:
每个块增加四字节的头部和四字节的脚部保存块大小和是否分配信息,可以在 常数时间访问到每个块的下一个和前一个块,使空闲块的合并也变为常数时间,而且可以遍历整个链表。隐式空闲链表即为,利用边界标签区分已分配块和未分配块,根据不同的分配策略(首次适配、下一次适配、最佳适配),遍历整个链表,一旦找到符合要求的空闲块,就把它的已分配位设置为1,返回这个块的指针。隐式空闲链表并不是真正的链表,而是"隐式"地把空闲块连接了起来(中间夹杂着已分配块)。
显式空闲链表的基本原理:
因为隐式空闲链表每次查找空闲快都需要线性地遍历整个链表,而其中的已分配块显然是不需要遍历的,所以浪费了大量时间,一种更好的方式是把空闲块组织成一个双向链表,每个空闲块中包含一个 pred 和 succ 指针,指向它的前驱和后继,在申请空闲块时,就不需要遍历整个堆,只需要利用指针,在空闲链表中遍历空闲块即可。一旦空闲块被分配,它的前驱和后继指针就不再有效,变成了有效载荷的一部分。显式空闲链表的已分配块与隐式空闲链表的堆块的格式相同。
7.10本章小结
本章介绍了hello 的存储器地址空间、 intel 的段式管理、 hello 的页式管理,在指定环境下介绍了 VA 到 PA 的变换,以及进程fork和execve内存映射的内容,还有缺页问题和动态存储分配管理的问题。
(第7章 2分)
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.1.1设备的模型化
我们可以将所有的I/O设备都被模型化为文件,方便统一进行操作。
8.1.2设备管理
这种将设备统一模型化为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O。这样,我们就可以简单地对所有的文件执行open/close,read/write或是lseek操作等等。
8.2 简述Unix IO接口及其函数
8.2.1 Unix IO接口的几种操作:
1. 打开文件:程序要求内核打开文件,内核返回一个小的非负整数(描 述符),用于标识这个文件。程序在只要记录这个描述符便能记录打 开文件的所有信息。
2. shell 在进程的开始为其打开三个文件:标准输入、标准输出和标准错 误。
3. 改变当前文件的位置:对于每个打开的文件,内核保存着一个文件位 置 k,初始为 0。这个文件位置是从文件开头起始的字节偏移量。应用 程序能够通过执行 seek 操作显式地设置文件的当前位置为 k。
4. 读写文件:一个读操作就是从文件复制 n>0 个字节到内存,从当前文 件位置 k 开始,然后将 k 增加到 k+n。给定一个大小为 m 字节的文件, 当 k>=m 时执行读操作会出发一个称为 EOF 的条件,应用程序能检测 到这个条件,在文件结尾处并没有明确的 EOF 符号。
5. 关闭文件:内核释放打开文件时创建的数据结构以及占用的内存资源, 并将描述符恢复到可用的描述符池中。无论一个进程因为何种原因终 止时,内核都会关闭所有打开的文件并释放它们的内存资源。
8.2.2 Unix IO函数:
打开文件:open()函数
打开一个已经存在的文件,若文件不存在则创建一个新的文件。
关闭文件:close()函数
通知内核关闭这个文件,内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中。
读取文件:read()函数
从当前文件位置复制字节到内存位置,如果返回值<0则说明出现错误。
写入文件:write()函数
从内存复制字节到当前文件位置,如果返回值<0则说明出现错误。
改变文件位置:lseek()函数
文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
8.3 printf的实现分析
printf函数的代码如下:
int printf(const char *fmt, ...){
int i;
va_list arg = (va_list)((char *)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
其中引用的vsprintf函数代码如下:
int vsprintf(char *buf, const char *fmt, va_list args){
char *p;
chartmp[256];
va_listp_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);
}
}
vsprintf函数的作用是将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。
write函数是将buf中的i个元素写到终端的函数。
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成 ASCII 码,保存到系统的键盘缓冲区之中。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar 调用了 read 函数,read 函数也通过 sys_call 调用内核中的系统函数,将读取存储在键盘缓冲区中的 ASCII 码,直到读到回车符,然后返回整个字符串,getchar 函数只从中读取第一个字符,其他的字符被缓存在输入缓冲区。
8.5本章小结
本章介绍了 Linux 的 I/O 设备的基本概念和管理机制,以及Unix I/O 接口及其函数,并且分析了printf 函数和 getchar 函数的工作过程。
(第8章1分)
结论
用计算机系统的语言,逐条总结hello所经历的过程。
-
- 预处理:将hello.c调用的所有外部的库拓展到hello.i文件中;
- 编译:将hello.i编译得到汇编代码文件hello.s;
- 汇编:将hello.s汇编成为二进制可重定位目标文件hello.o;
- 链接:将hello.o与可重定位目标文件和动态链接库链接成为可执行目标程序hello;
- 运行:shell中运行hello;
- 创建子进程:shell进程调用fork函数创建子进程;
- 运行程序:shell调用execve函数,execve调用启动加载器,映射虚拟内存,进入程序入口后程序开始载入物理内存,然后进入 main函数;
- 执行指令:CPU为其分配时间片,在一个时间片中,hello享有CPU资源,顺序执行自己的控制逻辑流;
- 访问内存:MMU将程序中使用的虚拟内存地址通过页表映射成物理地址;
- 动态申请内存:printf会调用malloc向动态内存分配器申请堆中的内存;
- 信号:如果运行途中键入Ctrl-C、Ctrl-Z则调用shell的信号处理函数分别停止、挂起;
- 结束:shell父进程回收子进程,内核删除为这个进程创建的所有数据结构。
感悟:
不得不说计算机系统这门课程难度还是比较大,但是也很有意思。经过学习我们了解了一个最简单的hello程序的一生,原来需要这么多步骤,这么多“部门”协同完成。我对计算机程序的理解深入了许多,以后还会继续深入学习。最后,感谢老师的辛勤付出!
(结论0分,缺失 -1分,根据内容酌情加分)
附件
hello.c | hello源代码 |
hello.i | 预处理之后的ASCII码文件 |
hello.s | 编译之后得到的文件 |
hello.o | 汇编之后的可重定位目标文件 |
hello | 链接之后的可执行目标文件 |
helloelf.txt | hello的ELF格式文件 |
hellooelf.txt | hello.o的ELF格式文件 |
(附件0分,缺失 -1分)
参考文献
为完成本次大作业你翻阅的书籍与网站等
- 大卫R.奥哈拉伦,兰德尔E.布莱恩特. 深入理解计算机系统[M]. 机械工业出版社,2016.
(参考文献0分,缺失 -1分)