赞
踩
大作业
题 目 程序人生-Hello’s P2P
专 业 计算学部
学 号 120L020731
班 级 2003005
学 生 吴少杰
指 导 教 师 吴锐
计算机科学与技术学院
2021年5月
本报告主要对计算机系统中最基础最简单的“Hello.c”的编译输出的整个过程进行了细致的处理与分析,讲述了现代计算机在IDE处理程序的幕后的操作过程。其中包括预处理、编译、汇编、重定向、链接、执行、以及执行过程中对进程的处理、回收等等……最终进程结束,Hello走向其生命的尽头,屏幕上只留下它来过的痕迹。
关键词:Hello;编译;预处理;汇编;链接;进程管理;计算机系统
目 录
6.2 简述壳Shell-bash的作用与处理流程.................................................................. - 10 -
7.2 Intel逻辑地址到线性地址的变换-段式管理..................................................... - 11 -
7.3 Hello的线性地址到物理地址的变换-页式管理.............................................. - 11 -
7.4 TLB与四级页表支持下的VA到PA的变换........................................................ - 11 -
7.5 三级Cache支持下的物理内存访问....................................................................... - 11 -
文件名称 | 功能 |
hello.c | 源文件 |
hello.i | 预处理文件 |
hello.s | 汇编文件 |
hello.o | 可重定向目标文件 |
hello | 可执行文件 |
helloo.elf | hello.o的elf格式文件 |
helloo.txt | hello.o的反汇编代码文件 |
hello.elf | hello的elf格式文件 |
hello.txt | hello的反汇编代码文件 |
简要介绍了hello一生的几个阶段,以及配置所需的软硬件操作环境、调试工具等基础信息。
(第1章0.5分)
2.1.1 预处理的概念
预处理是为编译做的准备工作,能够对源程序 . c文件中出现的以字符“#”开头的命令进行处理,包括宏定义#define、文件包含#include、条件编译#ifdef等,最后将修改之后的文本进行保存,生成.i文件,预处理结束。
2.1.2 预处理的作用
预处理可以扩展源代码,插入所有用#include命令指定的文件,并且扩展所有用#define声明指定的宏(宏展开),还能根据#if以及#endif和#ifdef以及#ifndef后面的条件决定需要编译的代码。
gcc -E -o hello.i hello.c
2.2-1
部分hello.i代码
- # 1013 "/usr/include/stdlib.h" 3 4
-
- # 1 "/usr/include/x86_64-linux-gnu/bits/stdlib-float.h" 1 3 4
-
- # 1014 "/usr/include/stdlib.h" 2 3 4
-
- # 1023 "/usr/include/stdlib.h" 3 4
-
-
- # 9 "hello.c" 2
-
-
-
- # 10 "hello.c"
-
- int main(int argc,char *argv[]){
-
- int i;
-
-
- if(argc!=4){
-
- printf("用法: Hello 学号 姓名 秒数!\n");
-
- exit(1);
-
- }
-
- for(i=0;i<8;i++){
-
- printf("Hello %s %s\n",argv[1],argv[2]);
-
- sleep(atoi(argv[3]));
-
- }
-
- getchar();
-
- return 0;
-
- }
2.3-1
如图2.3-1 预处理后,hello.c被拓展为3060行的hello.i代码,格式为txt,hello.i未改变hello.c文件中的主函数,只是将头文件加入代码,对宏进行了宏展开,对#define的符号进行代换。
介绍了文件预处理的结果,说明了文件预处理进行的操作。
(第2章0.5分)
2.1.1 编译的概念
将源语言经过词法分析、语法分析、语义分析以及一系列优化后生成汇编代码的过程。
2.1.2 编译的作用
2.1.2.1 词法分析:将源代码程序输入扫描器,将源代码的字符序列分割成一系列记号。
2.1.2.2 语法分析:基于词法分析得到一系列记号,生成语法树。
2.1.2.3 语义分析:由语义分析器完成,指示判断是否合法,不判断对错。又分静态语义(隐含浮点型到整形的转换,会报warning)和动态语义(在运行时才能确定:例1/3)。
2.1.2.4 源代码优化(中间语言生成):中间代码(语言)使得编译器分为前端和后端,前端产生与机器(或环境)无关的中间代码,编译器的后端将中间代码转换为目标机器代码,目的:一个前端对多个后端,适应不同平台。
2.1.2.5 编译器后端主要包括:代码生成器(依赖于目标机器,依赖目标机器的不同字长,寄存器,数据类型等),目标代码优化器(选择合适的寻址方式,左移右移代替乘除,删除多余指令)。
gcc -S -o hello.s hello.i
3.2-1
3.3.1 声明部分
.file | 声明源文件 |
.text | 声明代码节 |
.section .rodata | 只读数据段 |
.align | 声明对指令或者数据的存放地址对齐的方式 |
.string | 声明一个字符串 |
.global | 声明一个全局变量 |
.type | 声明一个符号是函数还是数据 |
.size | 声明一个大小 |
.long | 声明一个long数据 |
3.3.2 数据部分
(1).string 字符串
图3.3.2-1
如图3.3.2-1 汇编代码中有两个字符串,分别是指针数组和打印出的字符串。它们都在只读部分内。
LC0中存储的数组对应的每一个元素都是一个指向char的指针;它的头被存储在栈的“-32(%rbp)”的位置
(2)局部变量
3.3.2-2
如图3.3.2-2这里是在主函数内声明了一个局部变量 存储在栈的”-4(rbp)”中
(3)argc与char*argv[]
参数argc和数组char*argv[]是main函数的两个参数,argc是用户传入的参数;argv[]是main函数中存放指向char类型数据的指针的数组,它的头部也存放在栈中的-32(%rbp)中。
(4)立即数
在汇编代码中直接体现,不以变量形式出现。
3.3.3 算术操作
3.3-1
本程序中的算术运算只有循环增加i,在指令上体现为对-4(%rbp)+1之后检测是否小于等于7。
3.3.4 关系操作
3.3.4-1
3.3.4-2
本程序中用到的关系操作,只有简单的条件比较。图3.3.4-1
中的je是判断argc = 4;图3.3.4-2jle是判断局部变量i <= 7,若不满足则跳过条件判断的这一行,继续执行下面的程序。
3.3.5 控制转移
3.3.5-1
关系操作一般伴随着控制转移,根据3.3.4的关系操作,我们不难发现这段代码的循环块为L4,在L2中对循环变量i进行初始化,然后跳到L3中的判断语句,在L3中判断是否终止循环,若i <= 7,则进入L4循环,否则顺序执行。
3.3.6 数组操作
3.3.6-1
程序中对数组的操作在L4循环块中,具体操作为从-32(%rbp)中取出数组的第一个元素argv[1]和第二个元素argv[2],放入rax和rdx中。之后对rax解引用,将argv[1]放入rsi中,再将字符串常量放入rdi中,使用printf输出。
3.3.7 函数操作
本程序中的函数包括puts;exit;printf;atoi;sleep;getchar
3.3.7.1 puts
3.3.7.1-1
来源如图,由于printf中不包含需要引用的参数,只有一个字符串,所以这里使用了puts来打印输出
3.3.7.1-2
3.3.7.2 exit
3.3.7.2-1
由exit(1)而来,立即数1作为exit函数的惟一的参数。
3.3.7.2-2
3.3.7.3 printf
3.3.7.3-1
这里的printf来自于这一句,其中rax和rdx是存储的两个引用参数argv[1]和argv[2]。然后调用printf函数打印。
3.3.7.3-2
3.3.7.4 atoi &sleep
3.3.7.4-1
这一句包含了两个函数,sleep和atoi,atoi是将一个char转化为int类型,用来作为sleep的参数;而atoi的参数是argv[3],也就是-8(%rbp)处的数据,第三个参数。
3.3.7.4-2
3.3.7.6 getchar
3.3.7.4-1
这句倒是没什么特别的,调用了一下getchar函数,没有参数。
3.3.7.4-2
介绍了编译操作的过程,解读了将预处理后的hello.i文件编译为汇编代码文件的hello.s,在此过程中,编译器将会对源文件进行语法分析、词法分析,得到汇编文件hello.s。
同时,本章中详细解析了变量、相关运算,以及各类C语言的基本语句的汇编表示,更便于理解高级语言的底层表示。
(第3章2分)
4.1.1 概念:
汇编器(as)将hello.s文件翻译成二进制机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存到目标文件hello.o中。hello.o是一个二进制文件。
4.1.2 作用:
在汇编过程中,文件格式将由面向阅读友好的文本文件转化为机器可执行的二进制文件,并且将文本文件中的常量转化为对应的二进制补码,同时,汇编过程也将生成可重定位目标文件的结构信息,Linux系统使用可执行可链接格式(ELF)对目标文件进行组织。
命令:as hello.s -o hello.o
4.2-1
4.3-1
图4.3-1为通过readelf -a hello.o 来读取elf格式的hello.o
4.3.1 ELF头
ELF头描述生成该文件的系统的字的大小和字节顺序、帮助链接器语法分析和解释目标文件的信息。上图中包含的有效信息有:ELF64(ELF 64位的可执行程序);2补码表示,小端法;REL(Relocatable file即可重定位目标文件);运行机器为AMD x86-64;节头开始为文件开始处1240偏移处。
4.3.2 节头
4.3.2-1
这一段是elf中的节头部分,与CSAPP书中大致相同。表头部分是小节的名称、类型、地址、偏移量、节大小、项大小、(flags)旗标、链接、附加信息、对齐(2的align次方)。
4.3.2-2
4.3.3 符号表
4.3.3-1
这其中,Num为某个符号的编号,Name是符号的名称。Size表示它是一个位于.text节中偏移量为0处的146字节函数。Bind表示这个符号是本地的还是全局的。
4.3.4 重定位节
4.3.4-1
重定位节保存的是.text节中需要被修正的信息(调用外部函数或者引用全局变量的指令),调用外部函数的指令和引用全局变量的指令需要重定位,调用局部函数的指令不需要重定位。
4.4 Hello.o的结果解析
4.4-1
hello.o反汇编代码和汇编代码hello.s差不多,所用指令都相同,不同的是
(1)分支转移:反汇编的跳转指令用的不是段名称比如.L3,二是用的确定的地址,因为,因为段名称只是在汇编语言中便于编写的助记符,所以在汇编成机器语言之后显然不存在,而是确定的地址。
(2)函数调用:在.s 文件中,函数调用之后直接跟着函数名称,而在反汇编程 序中,call的目标地址是当前下一条指令。这是因为 hello.c 中调用的函数 都是共享库中的函数,最终需要通过动态链接器才能确定函数的运行时执 行地址,在汇编成为机器语言的时候,对于这些不确定地址的函数调用,将其call指令后的相对地址设置为全0(目标地址正是下一条指令),然后在.rela.text 节中为其添加重定位条目,等待静态链接的进一步确定。
(3)立即数:在反汇编中被自动转换为16进制。
(4)反汇编中汇编指令被详细转换为机器代码。
本章对应的主要是hello.s汇编到hello.o的过程。在本章中,我们查看了hello.o的可重定位目标文件的格式,使用反汇编查看hello.o经过反汇编过程生成的代码并且把它与hello.s进行比较,分析和阐述了从汇编语言进一步翻译成为机器语言的汇编过程。
(第4章1分)
5.1.1 概念:
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至于运行时,也就是由应用程序来执行。
5.1.2 作用:
链接使得分离编译成为可能。我们不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为更小、更好管理的模块,可以独立地修改和编译这些模块。当我们改变这些模块中的一个时,只需简单地重新编译它,并重新链接应用,而不必重新编译其它文件。
命令:
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.2-1
5.3.1 ELF头
5.3.1-1
命令:
readelf-h hello
图中说明了hello的数据是2补码、小端序;文件类型是EXEC(可执行程序);节数为27
5.3.2 节头
命令:readelf -S hello
5.3.2-1
由上图可以看出,hello中节头表的条目数多于hello.o中节头表的条目数。值得注意的是每一节都有了实际地址,而不是像在hello.o中那样地址值全为0。这说明了重定位工作已完成。同时,多出的节是为了能够实现动态链接,如.interp这一节包含动态链接器的路径名,动态链接器通过执行一系列重定位工作完成链接任务。
5.3.3 符号表
命令:
readelf -s hello
5.3.3-1
与hello.s相比,hello的elf符号表多出了很多内容,但是符号表的列没有变化,这说明在链接工作完成后,重定向文件与系统文件链接,在执行文件中加入了系统函数的机器语言符号。
5.4-1
在edb的Data Dump中,我们可以看到程序的虚拟地址空间从(0x00401000)到(0x00401ff0)
命令:
objdump -d -r hello > hello.txt
相比hello.o的反汇编中,hello的反汇编文件多出了很多节,以及很多系统自带的函数。 比如,在上图中的_init是程序初始化的代码;.plt是动态链接的过程链接表。
5.6-1
在edb中右键选择“analyze here”能够展示调用的函数
5.6-2
在edb中run过后,再次analyze here 就可以得到主函数中出现的函数
动态共享库是致力于解决静态库缺陷的一个现代创新产物。共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个程序链接起来,这个过程就是动态链接。
把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
.plt:PLT是一个数组,其中每个条目是16字节代码。PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目都负责调用一个具体的函数。
.got:GOT是一个数组,其中每个条目是8字节地址。和PLT联合使用时,GOT[O]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。GOT[2]是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数,其地址需要在运行时被解析。每个条目都有一个相匹配的PLT条目。
5.7-1
在edb中使用symbol viewer可以找到hello的.got和.plt节的内容,这就是对hello进行动态链接的过程。
本章介绍了可重定向文件和系统静态函数库链接成为可执行文件的过程,链接为程序编写以及版本管理(利用动态链接)提供了一定的便利。利用静态库,计算机可以利用同一组标准库而不需要占用大量的磁盘空间;通过动态链接共享库,多个进程可以共享一个函数的多个副本而不需要花费多份内存空间,并且可以仅仅通过更新动态链接库而不必重新编译程序来更新版本。
(第5章1分)
6.1.1 进程的概念:
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
6.1.2 进程的作用:
在运行一个进程时,我们的这个程序好像是系统当中唯一一个运行的程序,进程的作用就是提供给程序两个关键的抽象。一分别是独立的逻辑控制流和私有的地址空间。
6.3-1
在程序运行时,Shell就会创建一个新的进程,并且新创建的进程更新上下文,在这个新建进程的上下文中便可以运行这个可执行目标文件。
fork()函数拥有一个int型的返回值。子进程中fork返回0,在父进程中fork返回子进程的Pid。新创建的进程与父进程几乎相同但有细微的差别。子进程得到与父进程虚拟地址空间相同的一份副本(代码、数据段、堆、共享库以及用户栈),并且子进程拥有与父进程不同的Pid。
在shell为hello创建子进程后,shell调用execve函数。execve获取参数中的filename,将命令行作为新进程的argv,并传入环境变量。
6.5.1 逻辑控制流和时间片
进程的运行本质上是CPU不断从程序计数器 PC 指示的地址处取出指令并执行,值的序列叫做逻辑控制流。操作系统会对进程的运行进行调度,执行进程A->上下文切换->执行进程B->上下文切换->执行进程A->… 如此循环往复。 在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这种决策就叫做调度,是由内核中称为调度器的代码处理的。当内核选择一个新的进程运行,我们说内核调度了这个进程。在内核调度了一个新的进程运行了之后,它就抢占了当前进程,并使用上下文切换机制来将控制转移到新的进程。在一个程序被调运行开始到被另一个进程打断,中间的时间就是运行的时间片。
6.5.2用户模式和内核模式
Shell使得用户可以有机会修改内核,所以需要设置一些防护措施来保护内核,如限制指令的类型和可以作用的范围。
6.5.3上下文切换
如果系统调用因为等待某个事件发生而阻塞,那么内核可以让当前进程休眠,切换到另一个进程,上下文就是内核重新启动一个被抢占的进程所需要的状态,是一种比较高层次的异常控制流。
6.5.4调度
在对进程进行调度的过程,操作系统主要做了两件事:加载保存的寄存器,切换虚拟地址空间。
6.5.5用户态与核心态转换
为了能让处理器安全运行,需要限制应用程序可执行指令所能访问的地址范围。因此划分了用户态与核心态。核心态可以说是拥有最高的访问权限,处理器以一个寄存器当做模式位来描述当前进程的特权。进程只有故障、中断或陷入系统调用时才会得到内核访问权限,其他情况下始终处于用户权限之中,保证了系统的安全性。
6.6.1 脸滚键盘
6.6.1-1
在程序运行过程中脸滚键盘,乱打字。在打字的过程中,如果没有按回车,那么程序会正常一直运行,一旦输入回车(getchar读取),shell就会开始检测回车之前的行与内部命令匹配,若无匹配则会继续运行程序。
6.6.2 Ctrl + Z 挂起
6.6.2-1
在程序运行过程中按下“Ctrl + Z”的组合键,可以使进程被挂起,但是hello并没有被回收,使用ps命令调出进程可以发现hello还在后台运行,使用fg 指令可以将hello进程恢复运行,输入字符串回车后结束程序,然后回收进程。
6.6.3 Ctrl + C 终止
6.6.3-1
与Ctrl + Z不同,Ctrl + C将程序直接终止并且回收了进程,使用ps调用后台进程时发现已经没有hello,自然也无法使用fg 恢复进程。
本章介绍了hello运行的进程,进程是一个执行中程序的实例(Instance),即使操作系统中同时有多个程序执行,我们看到的也像是操作系统仅在运行前台程序一样,这是通过上下文切换实现的。操作系统根据某种特定的策略调度进程来在不同进程间快速地交错执行。
(第6章1分)
7.1.1逻辑地址:
程序经过编译后出现在汇编代码中的地址。逻辑地址用来指定一个操作数或者是一条指令的地址。是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量]。
7.1.2线性地址:
也叫虚拟地址,和逻辑地址类似,也是一个不真实的地址,如果逻辑地址是对应的硬件平台段式管理转换前地址的话,那么线性地址则对应了硬件也是内存的转换前地址。
7.1.3 虚拟地址:
也就是线性地址。
7.1.4 物理地址:
用于内存芯片级的单元寻址,与处理器和CPU链接的地址总线相对应。可以直接把物理地址理解成插在机器上那根内存本身,把内存看成一个从0字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址。
7.2-1
在 Intel 平台下,逻辑地址(logical address)是 selector:offset 这种形式,selector 是 CS 寄存器的值,offset 是 EIP 寄存器的值。如果用 selector 去 GDT( 全局描述符表 ) 里拿到 segment base address(段基址) 然后加上 offset(段内偏移),这就得到了 linear address。我们把这个过程称作段式内存管理。
一个逻辑地址由段标识符和段内偏移量组成。段标识符是一个16位长的字段(段选择符)。可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段。
全局的段描述符,放在“全局段描述符表(GDT)”中,一些局部的段描述符,放在“局部段描述符表(LDT)”中。
给定一个完整的逻辑地址段选择符+段内偏移地址,看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。拿出段选择符中前13位,可以在这个数组中,查找到对应的段描述符,就得到了其基地址。Base + offset = 线性地址。
线性地址即虚拟地址(VA)到物理地址(PA)之间的转换通过分页机制完成,而分页机制是对虚拟地址内存空间进行分页。
系统将虚拟页作为进行数据传输的单元。Linux下每个虚拟页大小为4KB。物理内存也被分割为物理页, MMU(内存管理单元)负责地址翻译,MMU使用页表将虚拟页到物理页的映射,即虚拟地址到物理地址的映射。
n位的虚拟地址包含两个部分:一个p位的虚拟页面偏移(VPO),一个n-p位的虚拟页号(VPN),MMU利用VPN选择适当的PTE,根据PTE,我们知道虚拟页的信息,如果虚拟页是已缓存的,那直接将页表条目的物理页号和虚拟地址的VPO串联起来就得到一个相应的物理地址。VPO和PPO是相同的。如果虚拟页是未缓存的,会触发一个缺页故障。调用一个缺页处理子程序将磁盘的虚拟页重新加载到内存中,然后再执行这个导致缺页的指令。
每次CPU产生一个虚拟地址,MMU(内存管理单元)就必须查阅一个PTE(页表条目),以便将虚拟地址翻译为物理地址。在最糟糕的情况下,这会从内存多取一次数据,代价是几十到几百个周期。如果PTE碰巧缓存在L1中,那么开销就会下降1或2个周期。然而,许多系统都试图消除即使是这样的开销,它们在MMU中包括了一个关于PTE的小的缓存,称为翻译后备缓存器(TLB)。
TLB是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单一PTE组成的块。TLB通常有高的相联度。
7.4-1
7.4.1 多级页表:将虚拟地址的VPN划分为相等大小的不同的部分,每个部分用于寻找由上一级确定的页表基址对应的页表条目。VPN被分为k个部分,第一级VPN结合基址寄存器得到一个页表条目,其中存放下一级页表的基址,再结合VPN2,得到第三级页表基址,继续寻找,以此类推,直到最后确定对应的物理页号,与VPO结合,得到由PPN与PPO结合成的物理地址,用于物理地址寻址。
7.4.1-1
7.4.3 VA到PA的变换
处理器生成一个虚拟地址,并将其传送给MMU。MMU用VPN向TLB请求对应的PTE,如果命中,则跳过之后的几步。MMU生成PTE地址(PTEA).,并从高速缓存/主存请求得到PTE。如果请求不成功,MMU向主存请求PTE,高速缓存/主存向MMU返回PTE。PTE的有效位为零, 因此 MMU触发缺页异常,缺页处理程序确定物理内存中的牺牲页 (若页面被修改,则换出到磁盘——写回策略)。缺页处理程序调入新的页面,并更新内存中的PTE。缺页处理程序返回到原来进程,再次执行导致缺页的指令。
获得物理地址之后,先取出组索引对应位,在L1中寻找对应组。如果存在,则比较标志位,相等后检查有效位是否为1.如果都满足则命中取出值传给CPU,否则按顺序对L2cache、L3cache、内存进行相同操作,直到出现命中。然后再一级一级向上传,如果有空闲块则将目标块放置到空闲块中,否则将缓存中的某个块驱逐,将目标块放到被驱逐块的位置。
当fork 函数被shell调用时,内核为hello进程创建各种数据结构,并分配给它一个唯一的PID。为了给hello进程创建虚拟内存,它创建了hello进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。通过 fork 创建的子进程拥有父进程相同的区域结构、页表等的一份副本,同时子进程也可以访问任何父进程已经打开的文件。当 fork 在新进程中返回时,新进程现在的虚拟内存刚好和调用 fork 时存在的虚拟内存相同,当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间。
execve的加载步骤在6.4节中已经给出,如下:
1.删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
2.映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。
3.映射共享区域。如果hello程序与共享对象链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
4.设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。
缺页(page fault)指虚拟内存中的DRAM缓存不命中。当CPU请求某个虚拟地址的数据而它恰好不在主存而在磁盘中时(通过检查有效位),就会引发缺页故障,调用内核中的缺页异常处理程序,它会选择一个牺牲页,用所请求的页替换该牺牲页。如果该主存中的牺牲页还被修改过,在替换之前内核还需要将其复制回磁盘。牺牲页的选择因系统而异,常见的替换算法有LRU(Least Recently Used)算法,它选择一个最近最久未使用的页面作为牺牲页。如果一个程序拥有良好的局部性,虚拟内存能够以较好的效率完成任务(典型的页面大小为4KB,这足够抵消从磁盘交换页面进入内存的时间)。但是,如果一个程序的工作集超出了物理内存的大小,就很可能引发抖动(thrashing)现象,这会导致页面从内存和磁盘之间频繁地换入换出,带来极大的时间开销,此时我们就应该设法减小工作集大小来提高程序速度。
在hello程序中使用的printf,而printf会使用由动态内存分配器动态内存分配机制。动态内存分配器维护进程虚拟地址空间中的的堆区域,它将堆视作一组不同大小的块的集合来维护,每个块是一段连续的虚拟内存碎片,要么是已分配的,要么是空闲的。空闲块保持空闲直至被应用程序分配,以已分配块保持已分配状态直至被释放。
分配器需要一些数据结构维护堆块来区分块边界以及区分已分配块和空闲块,这些可以被标识在块的头部,那么分配器可以将堆组织为一个连续的已分配块和未分配块的序列(称为隐式空闲链表),如下图所示:
7.9-1
这样的话,通过隐式空闲链表,分配器可以通过对于链表的操作以完成在堆上放置已分配的块、分割空闲块、获取额外内存、合并空闲块等操作。于是应用程序就可以动态地在堆上分配额外内存空间了。
本章主要介绍了有关内存管理的知识。详细阐述了hello程序是如何存储,如何经过地址翻译得到最终的物理地址。介绍了hello的四级页表的虚拟地址空间到物理地址的转换。阐述了三级cashe的物理内存访问、进程 fork 时的内存映射、execve 时的内存映射、缺页故障与缺页中断处理、动态存储分配管理。
(第7章 2分)
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。
8.2.1函数open()和opennat()
- int open(const char* path, int oflag, .../*mode_t mode*/);
-
- int openat(int fd, const char* path, int oflag, .../*mode_t mode*/);
若文件打开失败则返回-1,失败原因可以通过errno查看;若成功将返回最小的未用的文件描述符的值。其中参数path为要打开的文件的文件路径,oflag为文件打开模式。打开模式如下:
8.2.2 creat()函数
oflag | 含义 |
O_RDONLY | 只读权限 |
O_WRONLY | 只写权限 |
O_RDWR | 读写权限 |
O_EXEC | 可执行权限 |
O_SEARCH | 搜索权限(针对目录) |
O_APPEND | 每次写都追加到文件的末端 |
O_CLOEXEC | 把close_on_exec设置为文件描述符标识 |
O_CREATE | 若文件不存在,则创建它。使用此选项的时候,需要使用第三个参数指定该新文件的访问权限位 |
O_DIRECTORY | 如果path不是目录则出错 |
O_EXCL | 若同时执行了O_CREATE,若文件存在则出错,可以用此选项测试文件是否存在 |
int create(const char *path, mode_t mode);
若文件创建失败返回-1;若创建成功返回当前创建文件的文件描述符。参数与open中对应的参数含义相同。create(path, mode)函数功能为创建新文件,与open(path, O_CREATE|O_TRUNC|O_WRONLY)功能相同。
8.3.3 lseek()函数
int lseek(int fd, off_t offset, int whence);
成功则返回新的文件的偏移量;失败则返回-1。使用lseek()函数显式的为一个打开的文件设置偏移量。lseek仅将文件的偏移量记录在内核中,并不引起IO开销。
8.3.4read()函数
- #include <unistd.h>
-
- ssize_t read(int fd, void *buf, size_t nbytes);
若读取成功,读到文件末尾返回0,未读到文件末尾返回当前读的字节数。若读取失败,返回-1。fd为要读取文件的文件描述符。buf为读取文件数据缓冲区,nbytes为期待读取的字节数,通常为sizeof(buf)。
8.3.5write()函数
- #include <unistd.h>
-
- ssize_t write(int fd, const void* buf, size_t ntyes);
若写入成功则返回写入的字节数;失败返回-1。buf为写入内容的缓冲区,ntyes为期待写入的字节数,通常为sizeof(buf)。一般情况下返回值与ntypes相等,否则写入失败。
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开辟一块输出缓冲区,然后用vsprintf在输出缓冲区中生成要输出的字符串。之后通过write将这个字符串输出到屏幕上。而write会通过syscall陷阱跳到内核,内核的显示驱动程序会通过这些字符串及其字体生成要显示的像素数据,将它们传到屏幕上对应区域的显示vram中。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
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码,直到接受到回车键才返回。
本章简单介绍了Linux函数IO设备的管理方法,以及UnixIO函数的用法,参数含义和函数功能。
(第8章1分)
综上所述 hello的“出生”到“死亡”由以下的步骤构成
①用文本编辑器或者其他能够写出字的东西编写hello.c的源代码。
②预处理器通过#include和#define等对hello.c的代码进行展开。
③编译器将C语言代码转换为汇编语言代码,程序开始从人类层面进入机器层面。
④汇编器将文本形式的汇编语言代码转换为二进制形式的可重定位目标文件hello.o,程序中使用的绝对地址将暂时保留为重定位条目,程序开始进入人类读不懂的二进制形态。
⑤链接器将hello.o与其它必要的库进行链接并进行重定位,得到可执行文件hello(也是人类读不懂的二进制文件)。
⑥shell通过fork创建进程,execve加载可执行文件hello及其所需的动态链接库,通过虚拟内存机制将可执行文件中的节映射到内存空间中。
⑦在hello进程运行时,会产生诸多的异常与信号,例如键盘中断、SIGTSTP、SIGINT等。
⑧在程序hello运行时,它将使用一个属于自己的虚拟地址空间,通过分段机制和分页机制进行内存访问。
⑨在程序hello运行时,要通过中断与IO端口等与外部硬件设备交互。
⑩最终,hello正常退出,或者进程收到信号后终止,都会使得操作系统结束并回收hello的进程。
总而言之,hello,没你想的那么简单。看似easy的printf指令,它的执行牵扯到预处理、编译、重定向、shell中fork、execve等等步骤,这就是计算机系统的含金量,它不仅教会我们如何使用,还教会我们背后的原理。这是人类构建的一整套缜密的,独立于自然的语言系统,是人类智慧的结晶,也是计算思维的具象。
(结论0分,缺失 -1分,根据内容酌情加分)
文件名称 | 功能 |
hello.c | 源文件 |
hello.i | 预处理文件 |
hello.s | 汇编文件 |
hello.o | 可重定向目标文件 |
hello | 可执行文件 |
helloo.elf | hello.o的elf格式文件 |
helloo.txt | hello.o的反汇编代码文件 |
hello.elf | hello的elf格式文件 |
hello.txt | hello的反汇编代码文件 |
(附件0分,缺失 -1分)
为完成本次大作业你翻阅的书籍与网站等
[1] 深入理解计算机系统原书第3版-文字版.pdf
[2] https://blog.csdn.net/haitang_yue/article/details/52840020
[3] https://www.cnblogs.com/diaohaiwei/p/5094959.html
[4] https://blog.csdn.net/weixin_51744028/article/details/124716781
[5] https://www.jianshu.com/p/fd2611cc808e
[6] https://blog.csdn.net/Just_one_chace/article/details/108416606
(参考文献0分,缺失 -1分)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。