摘 要
本论文通过简单的hello.c程序为例,围绕一个程序从编写到运行的生命过程进行展开讲解。从C语言文件到可执行文件,论文详细介绍了预处理、编译、汇编、链接的过程,在Shell中创建进程并加载hello可执行程序,并对执行过程中的存储管理以及I/O管理进行了探究。以《深入理解计算机系统》的授课内容为主线,进行了知识的回顾与应用。
关键词:深入理解计算机系统;编译;汇编;链接;存储;进程;I/O
第1章 概述
1.1 Hello简介
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
Program to progress:首先将写好的C语言源程序保存为hello.c文件(program),通过预处理器cpp处理生成hello.i文件,此时仍为文本文件,通常为ASCII码表示的文本文件,然后通过编译器ccl生成汇编文件hello.s此时文件变成了机器级语言程序,经过编译器处理后生成了可重定位目标程序hello.o此时变为了二进制文件,最后hello.o以及静态库中的printf.o在链接器ld作用下生成可执行目标程序hello,接下来在bash中运行该程序,bash将为其创建一个进程,成为了progress.
0 to 0: 在bash中输入./hello运行hello程序,bash通过调用fork()创建子进程,同时在子进程中调用execve()函数,加载hello程序,CPU为运行的hello程序分配时间片并执行逻辑控制流。当程序执行结束后bash回收进程,释放内存,并删除与运行hello程序有关的数据结构,一切重新变为0,即为0 to 0.
1.2 环境与工具
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件:基于 X64 的处理器,64 位操作系统,2.9GHZ,16GRAM,512GDisk;
软件:Windows10 64 位,VMWare 16,Ubuntu 16.04;
工具:gcc, vim/vi, edb;
1.3 中间结果
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
功能 | |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
helloasm.txt | 反汇编hello.o得到的反汇编文件 |
helloout.txt | 由hello可执行文件生成的.elf文件 |
helloasm2.txt | 反汇编hello可执行文件得到的反汇编文件 |
1.4 本章小结
本章是大作业的简介性质的章节,主要介绍了此次大作业大致的流程以及所需要的硬件,软件环境以及所需要的工具,并列出了最终大作业所得到的文件。
第2章 预处理
2.1 预处理的概念与作用
概念:预处理器根据以字符#开头的命令,修改原始的C程序。比如hello.c中第一行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
作用:通过预处理命令,可以通过添加头文件和宏定义等方法,调用包含在头文件中已经编写好的函数,大大提高了编写程序的效率,增大了代码的简洁程度。通常使用预处理器实现的功能有:
- 文件包含:#include把指令所指的文件内容包含到当前文件中;
- 条件编译:#if, #endif等为进行编译时有选择的挑选,注释掉一些指定代码;
- 布局控制:#pragma设定编译器的状态或指示编译器完成一些特定的动作;
- 宏替换:#define, 可以实现定义符号常量、重新命名等功能。
2.2在Ubuntu下预处理的命令
应截图,展示预处理过程!cpp hello.c >hello.i
2.3 Hello的预处理结果解析
经过预处理后,与源程序进行对比,可以发现原本的头文件全都被替换为了其原本的文件内容,hello.c中的main函数出现在hello.i的3047行的位置。同时源文件中所有的注释都被删除了,查看添加进hello.i的代码中不再出现#include #if等预处理指令,均已通过预处理去掉。
2.4 本章小结
本章进行的是hello.c文件的预处理过程,是hello.c的一生的第一步,预处理工作虽然看起来有些繁杂,但是可以说正是艰难的第一步才保证了平稳的第二步,第三步......同时也正是预处理,解放了编程的效率,让程序员们不再需要每一次编程都要手动编写复杂的代码。
第3章 编译
3.1 编译的概念与作用
注意:这儿的编译是指从 .i 到 .s 即预处理后的文件到生成汇编语言程序
概念:编译将用某种编程语言写成的源代码,转换成另一种编程语言。通常会将便于人编写、阅读、维护的高级程序语言编写的源程序翻译为计算机能够解读并运行的低阶机器语言的程序,也是将代码所构成的文本文件重新编译为计算机可执行的二进制文件的程序。
作用:将高级程序语言翻译为低级机器语言,使计算机可以理解和执行。
3.2 在Ubuntu下编译的命令
应截图,展示编译过程!
3.3 Hello的编译结果解析
此部分是重点,说明编译器是怎么处理C语言的各个数据类型以及各类操作的。应分3.3.1~ 3.3.x等按照类型和操作进行分析,只要hello.s中出现的属于大作业PPT中P4给出的参考C数据与操作,都应解析。
经分析源文件可知,hello.c中包含的操作有:数据,赋值,算术操作,关系操作,数组/指针/结构操作,控制转移以及函数操作。
3.3.1 汇编指示
.file 源文件
.text 代码段
.align 对齐格式
.section .rodata 只读代码段
.String 字符串类型数据
.global 全局变量
.type 符号类型
3.3.2 数据
对于数据操作,hello.c中包含了常量,局部变量,全局变量三种。
1. 常量:hello.s中所有的常量均以立即数表示,记录为$x(x为数字).
下图中0和4均为常量
2. 局部变量:hello.s中的局部变量一般与赋值操作一同出现
下图中将立即数0存至%rbp-4对应的栈地址中,联系hello.c中的代码可知,此处的操作应为赋值0给局部变量i.
下图中两个赋值操作将参数保存为函数内的局部变量,并存在栈中。
3. 全局变量:本程序中全局变量只有一个,即main函数,由.global可知
3.3.3赋值
在本程序中只有=赋值过程
(1) 下图中为将传入的参数存入栈中,其中rdi保存的是源程序中的argc变量,rsi保存的是argv的地址,由cmpl一行可以印证。
(2) 下图为for()循环中,为i赋初值0的代码,其中栈地址rbp-4处保存迭代变量i.
3.3.4 算数操作
下图为for()循环中每次循环i+1的加法计算
3.3.5 关系操作
1. !=
在hello.s中,不等关系被相等关系所表达,源程序中如果参数argc参数不为4则执行操作的代码在汇编语言中变为:如果argc参数为4则跳转到循环操作,不再执行括号内的操作,与原本的语义等价。
- 2. <
在hello.s中,存在判断小于关系的操作,在源程序中表示为for()循环中的边界条件判断。在汇编语言中表示为i与立即数7的比较,如果i小于等于7则返回循环,否则进行下一步。
3.3.5 数组/指针/结构操作
源程序中并没有进行结构操作,但是参数传递过传递了指针数组,并且字符串的保存也使用了数组操作。将rsi中保存的64位指针数组的首地址存入栈中。
加载字符串指针数组首地址至寄存器rax, 并将寄存器进行+8操作--栈地址指向指针数组中的第二个指针元素,并将该指针的值放入寄存器rsi, 准备在printf函数中进行调用,对应打印局部变量argv[1],argv[2]. 同理可以得到argv[3]的访问过程。
3.3.6 控制转移
1.if判断
对于argc!=4的判断,使用cmpl语句进行判断,等于4直接进行跳转,不等于4是才进行括号内语句的运行。
2. for循环
在hello.s文件中,对于for语句的翻译,采用jmp等跳转语句来实现,当为满足i>7的跳出循环的条件时,使用jle语句,跳转到循环的开始进行新一轮的迭代。
3.3.7 函数操作
- 参数传递
- hello.c中main函数传递了int argc, 以及字符串指针数组char* argv[],在汇编代码中就是两个寄存器的值存入栈中,即前面所提到的寄存器edi和rsi, rsi保存的是字符串指针数组的首地址。
- 函数调用
- hello.c程序中使用了很多的函数,包括sleep, printf, atoi等,这些函数的调用都是通过call指令来实现的。
-
3.4 本章小结
通过对hello.c进行编译可以得到汇编语言文件hello.s,通过查看汇编指令并查阅源程序中的代码,可以了解到编译器是如何理解一个程序,并把诸多比较复杂的数据结构表达成更为低级的汇编语言。
-
第4章 汇编
4.1 汇编的概念与作用
注意:这儿的汇编是指从 .s 到 .o 即编译后的文件到生成机器语言二进制程序的过程。
概念:使用汇编器(as)把生成的汇编指令逐条翻译成机器可以识别的形式,即机器码。
作用:将文本文件hello.s翻译成机器码,并将指令打包成可重定位目标程序的二进制文件,生成hello.o
4.2 在Ubuntu下汇编的命令
gcc -m64 -no-pie -fno-PIC -o hello.o hello.s
-
-
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
1. ELF头
-
-
以16字节序列魔数开始,描述了生成该文件的系统的字的大小和字节顺序,ELF头剩下的部分包含帮助链接器进行符号解析以及解释可重定位目标程序的相关信息,其中包含了ELF头大小,目标文件类型,OS类型,节头表的偏移以及节头表中条目的大小和数量等相关信息。
-
2. 节头
-
-
包含了文件中出现的各个节的意义,包括节的类型、位置和大小等信息。
-
3. 重定位节 .rela .text
- 偏移量:表示需要进行重定向的代码在.text或.data节中的偏移位置
- 信息:包括symbol和type两部分,其中symbol占前半部分,tyoe占后半部分,symbol代表重定位到的目标在.symtab中的偏移量,type代表重定位的类型
- 类型:表示重定位的入口的类型
- 加数:计算重定位位置的辅助信息
-
-
包含信息如下:
-
-
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
- 对比生成的hello.s中的汇编代码,可以看出其中跳转语句原本的label在反汇编后变为了相对偏移地址,即目标指令地址与即将执行的下一条指令之间的差值。
- 在函数调用过程中,hello.s文件表示为直接call对应的函数名,而在反汇编的到的机器码文件中可以看到call的目标地址是当前指令的下一条指令。这是由于可重定位目标程序尚未经过链接器的处理,调用的函数并不实际存在于实际程序中,需要进一步调用共享库利用连接器才可以具体实现。对于不确定地址的函数调用,在编译过程中计算机会将地址全部设置为0,即指向当前指令的下一条指令,并将该函数名加入符号表中,并在.rela.text中添加重定位条目,等待进一步的静态链接。
- 对于变量的访问,在hello.s文件中使用rip+段名称的方式访问了printf中的字符串,而对反汇编文件中此处的调用则是使用了rip+0x0的方式,原理同函数调用一样,机器尚未对程序进行符号解析,需要先放入重定位条目中,等待之后对变量进行重新定位并写入。
-
4.5 本章小结
本章主要对由hello.s编译的hello.o--可重定位目标程序及其反汇编文件进行了分析,查阅了ELF头和各节头表信息,并查看了反汇编后的机器码,于hello.s的汇编代码进行了比较,发现了二者的差异,了解了编译过程的主要作用。
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
概念:链接时奖各种代码和数据片段手机并组合成一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被编译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。在现代系统中,链接由链接器来自动执行。连接完成后将生成一个完全链接的可执行的目标文件(windows下文件扩展名.exe,linux下一般省略后缀名)
作用:提供了一种模块化的方式,可以将程序编写为一个较小的源文件的集合,且实现了分开编译更改源文件,从而减少整体文件的复杂度与大小,增加容错性,也方便对某一模块进行针对性修改。链接器(ld)将hello.o文件于C标准库函数的可重定位文件进行合并,生成一个可执行的.out文件。
5.2 在Ubuntu下链接的命令
使用ld的链接命令,应截图,展示汇编过程! 注意不只连接hello.o文件
ld -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 /usr/lib/gcc/x86_64-linux-gnu/9/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/9/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello.out
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
1.ELF头
2. 节头表
3. 程序头
程序头部分是一个结构体数组,描述了系统准备程序执行所需要的信息,如加载类型,偏移量,虚拟地址以及物理地址等。
-
-
4. 动态段
-
5. 符号表
符号表中给保存着定位以及重定位程序时符号的定义和引用信息,所有在重定位中需要使用的symbol都需要在其中进行声明。 -
-
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
由于操作系统特性,0x000000~0x400000为逻辑地址,虚拟地址空间从0x400000开始,包括ELF头,程序头部表等载入在0x400000~0x401000中。可以观察到魔数即为第一行的数据。
-
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
- hello反汇编后,最直观的变化即每一行代码的序号不再是从0开始,而是有了具体的地址,这是由于重定位后链接器为程序指定了虚拟内存地址,而不再是原本的相对偏移量。
- 函数调用指令call的参数发生变化。在链接过程中,链接器解析了重定位条目,call之后的字节代码被链接器直接修改为目标地址与下一条指令的地址之差,指向相应的代码段,从而得到完整的反汇编代码。重定位的简单规则如下:
-
-
以调用函数puts为例,上图为可重定位目标程序中的反汇编代码,下图是可执行文件的反汇编代码。可以看出在可执行文件反汇编代码中,puts@plt函数名地址为0x401090, 由小端法可知fffffe95即-16b, 由此计算可得下一条指令0x4011fb减去相对偏移量即可得到实际函数的虚拟地址。
- 增加了.plt节和.init节。相比较原本的汇编代码,增加了puts@plt, sleep@plt等函数,这是因为动态链接器讲共享库中的hello.c用到的函数加入进了可执行文件中。
-
-
5.6 hello的执行流程
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
使用edb单步调试运行程序,观察其调用的函数,这里可以发现在调用main之前主要进行了初始化的工作调用了_init,在这个函数之后动态链接的重定位工作已经完成,我们可以看到在这个函数的调用之后是一系列在这个程序中所用到的库函数(printf,exit,atoi等等)这些函数实际上在代码段并不占用实际的空间只是一个占位的符号,实际上他们的内容在共享区(高地址)处。之后调用了_start这个就是起始的地址,准备开始执行main的内容。
下面列出了各个函数的名称与地址(部分函数)
-
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
编译器没有办法预测函数的运行时地址,所以需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,在GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数,在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。
-
-
根据节头表中给出的偏移地址可以看到.got和.got.plt段位置在0x403ff0和0x404000处, 即可在data dump中观察数据的变化。
-
观察可得:
调用dl_init前后:
下图依次为.got和.got.plt
-
-
对比调用前后,可以说明hello程序已经动态链接了共享库。
5.8 本章小结
本章介绍了链接的概念和作用,并且读取了可执行文件hello的ELF格式文本,与可重定位目标程序的ELF头等信息进行了比较,探究了链接的过程以及重定位的原理。之后又比较了可重定位目标程序以及可执行文件的反汇编代码,分析了二者的区别,进一步加深了对于重定位以及动态链接的理解。
第6章 hello进程管理
6.1 进程的概念与作用
概念:指程序的依次运行过程。更确切说,进程是具有独立功能的一个程序关于某个数据集合的依次运行活动,进而进程具有动态含义。同一个程序处理不同的数据就是不同的进程。
作用:给应用程序提供两个关键抽象——
- 一个独立的逻辑控制流,提供一个假象,好像程序独占地使用处理器
- 一个私有地址空间,提供一个假象,好像程序独占地使用内存系统
6.2 简述壳Shell-bash的作用与处理流程
shell 是一个用 C 语言编写的程序,它是用户使用 Linux 的桥梁。
- Shell 既是一种命令语言,又是一种程序设计语言。Shell 是指一种应用程序,这个应用程序提供了一个界面,用户通过这个界面访问 Linux 内核的服务。
- 作用:shell是一个命令解释器,它解释由用户输入的命令并且把它们送到内核。不仅如此,shell有自己的编程语言用于对命令的编辑,它允许用户编写由shell命令组成的程序。shell编程语言具有普通编程语言的很多特点,比如它也有循环结构和分支控制结构等,用这种编程语言编写的shell程序与其他应用程序具有同样的效果.
- 处理流程:shell首先检查命令是否是内部命令,若不是则再检查是否是一个应用程序(这里的应用程序可以是Linux本身的实用程序,如ls和rm,也可以是购买的商业程序,如xv,或者是自由软件,如emacs)。然后shell在搜索路径里寻找这些应用程序(搜索路径就是一个能找到可执行程序的目录列表)。如果键入的命令不是一个内部命令并且在路径里没有找到这个可执行文件,将会显示一条错误信息。如果能够成功找到命令,该内部命令或应用程序将被分解为系统调用并传给Linux内核
6.3 Hello的fork进程创建过程
打开Shell, 输入参数./hello 刘雨尘 120L021809,带参数执行hello程序。
fork进程创建过程:首先,带参执行当前目录下的可执行文件hello,父进程会通过fork函数创建一个新的运行的子进程hello。子进程获取了与父进程的上下文,包括栈、通用寄存器、程序计数器,环境变量和打开的文件相同的一份副本。子进程与父进程的最大区别是有着跟父进程不一样的PID,子进程可以读取父进程打开的任何文件。当子进程运行结束时,父进程如果仍然存在,则执行对子进程的回收,否则就由init进程回收子进程。
6.4 Hello的execve过程
execve函数加载并运行一个可执行目标文件,这里是hello,而且带参数列表argv[]和环境变量列表envp[],只有当找不到可执行目标文件时才会但回到调用程序,否则execve函数会将控制转移到新的可执行文件去执行,与fork函数调用一次返回两次不同,execve函数调用一次从不返回。
调用函数fork创建新的子进程之后,子进程会调用execve函数,在当前进程的上下文中加载并运行一个新程序hello。它将删除该进程的代码和地址空间内的内容并将其初始化,然后通过跳转到程序的第一条指令或入口点来运行该程序。将私有的区域映射进来,例如打开的文件,代码、数据段,然后将公共的区域映射进来。后面加载器跳转到程序的入口点,即设置PC指向_start 地址。_start函数最终调用hello中的 main 函数,这样,便完成了在子进程中的加载。
下图概括了私有区域的不同映射:
6.5 Hello的进程执行
用户向Shell输入可执行目标文件hello及其参数,运行程序时,Shell首先会调用fork函数创建一个新的子进程,然后在这个新进程的上下文中调用execve函数加载可执行目标文件hello。
hello进程将提供两个关键的抽象——
- 一个独立的逻辑控制流,好像我们的程序独占地使用处理器。
- 一个私有的地址空间,好像我们的程序独占地使用内存系统。
而时间片,则被表述为多个逻辑控制流重叠时又称并发运行时,由于多个进程之间轮流运行导致产生的逻辑流分段,也即多任务或时间分片。
对于用户模式和内核模式,设置模式位时,进程运行在内核模式,内核模式下的进程能够执行指令集中的任何指令,并且可以访问系统中任何内存位置。没有设置模式位时,进程就运行在用户模式中,用户模式下的进程不允许执行特权指令,也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。
对于上下文切换,操作系统内核使用上下文切换来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重新启动先前一个被强占的进程所需要的状态。在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫做调度,是由内核中称为调度器的代码处理的。
进程执行过程:
在程序运行时,Shell为hello fork了一个子进程,这个子进程与Shell有独立的逻辑控制流。在hello的运行过程中,若hello进程不被抢占,则正常执行;若被抢占,则进入内核模式,进行上下文切换,转入用户模式,调度其他进程。直到当hello调用sleep函数时,为了最大化利用处理器资源,sleep函数会向内核发送请求将hello挂起,并进行上下文切换,进入内核模式切换到其他进程,切换回用户模式运行抢占的进程。与此同时,将 hello 进程从运行队列加入等待队列,由用户模式变成内核模式,并开始计时。当计时结束时,sleep函数返回,触发一个中断,使得hello进程重新被调度,将其从等待队列中移出,并内核模式转为用户模式。此时 hello 进程就可以继续执行其逻辑控制流。
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
6.6 hello的异常与信号处理
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
- 正常运行:将打印8次信息输入回车结束程序。
- 乱按及回车时不影响程序运行,程序运行结束后除去一个回车会被作为结束标志,其余输入均将被作为Shell的指令输入至命令行中。
- 中途按下ctrl-Z,Shell进程中收到了SIGSTP信号,Shell提示停止信息并挂起hello进程,使用ps和jobs查看hello进程,可以验证hello确实被挂起而并没有被回收,并且作业编号为1.查看进程树,输入pstree,输入fg 1,则将hello程序恢复执行,执行尚未执行完的语句,并完成进程回收。输入kill杀死进程后再调用jobs查看,发现确实进程被杀死
- 输入ctrl+c,Shell将受到SIGINT信号,并终止进程
6.7本章小结
本章从进程的角度介绍了hello的执行过程,了解了Shell的功能和使用方法,明白了Shell是如何使用fork函数创建子进程并且调用execve函数加载可执行程序的,同时实操了使用Shell命令行管理hello进程,对进程的知识有了更深的了解。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
逻辑地址:在计算机体系结构中是指应用程序角度看到的内存单元、存储单元、网络主机的地址。由程序产生的与段相关的偏移地址部分,hello中要经过寻址方式的计算或变换才得到内存储器中的实际有效地址(物理地址)。
线性地址:是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。
虚拟地址:虚拟内存是硬件异常,硬件地址翻译,主存,磁盘文件和内核软件的完美结合,他为每个程序提供了一个大的、一致的和私有的地址空间。通过一个很清楚的机制提供了三个重要的能力:1)他将主存看成是一个存储在存盘上的地址空间的高速缓存。2)他为每个进程提供了一致的地址空间,从而简化了内存的管理。3)它保护了每个进程的地址空间不被其他进程破坏。而虚拟地址就是建立在虚拟内存之上的,在linux下进程加载到内存中时虚拟地址从0x400000开始,计算机通过MMU(地址翻译)完成对物理地址的映射。
7.2 Intel逻辑地址到线性地址的变换-段式管理
Intel处理器从逻辑地址到线性地址的变换通过段式管理的方式实现。每个程序在系统中都保存着一个段表,段表保存着该程序各段装入主存的状况信息,包括段号或段名、段起点、装入位、段的长度、主存占用区域表、主存可用区域表等,从而方便进行段式管理。
如图所示即为段选择符的结构,其中包含三个部分分别为:索引,TI以及RPL.
索引:用来确定当前使用的段描述符在描述符表中的位置
TI:根据TI的值判断选择全局描述符表或选择局部描述符表
RPL:判断重要等级。RPL=00,为第0级,位于最高级的内核,RPL=11,为第3级,位于最低级的用户状态
而段描述符则为8个字节组成:
7.3 Hello的线性地址到物理地址的变换-页式管理
Linux下,虚拟地址(VA)到物理地址(PA)的转化与翻译是依靠页式管理来实现的,虚拟内存作为内存管理的工具。概念上而言,虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组. 磁盘上数组的内容被缓存在物理内存中 (DRAM cache)这些内存块被称为页 (每个页面的大小为P = 2p字节)。
通过段式管理可以得到线性地址/虚拟地址,虚拟地址可被分为两个部分:VPN(虚拟页号)和VPO(虚拟页偏移量),根据计算机系统的特性可以确定VPN与VPO的具体位数,由于虚拟内存与物理内存的页大小相同,因此VPO与PPO(物理页偏移量)一致。而PPN(物理页号)则需通过访问页表中的页表条目(PTE)获取,如图所示。
若PTE的有效位为1,则发生页命中,可以直接获取到物理页号PPN,PPN与PPO共同组成物理地址。
若PTE的有效位为0,说明对应虚拟页没有缓存到物理内存中,产生缺页故障,调用操作系统的内核的缺页处理程序,确定牺牲页,并调入新的页面。再返回到原来的进程,再次调用导致缺页的指令。此时发生页命中,获取到PPN,与PPO共同组成物理地址。
处理过程如图所示:
7.4 TLB与四级页表支持下的VA到PA的变换
多级页表的概念:如果在计算机内部页表是将所有的项都全部表示出来并且都存在主存中,那么会出现很多问题,假设:4KB (212) 页面, 48位地址空间, 8字节 PTE 那么将需要一个大小为 512 GB 的页表!这512GB的页表如果存放在主存中,这笔开销是十分巨大的。但是事实是由于程序良好的局部性和程序的每一个段并不是连续的,如下图所示,中间会有大量的页表的映射是用不上的,即每次我们访问的页面大概率只有几个为了解决这个问题,我们可以采用页表分级的策略减少常驻内存的页表的开销,依照多级cacahe的原理,将页表进行一级一级缓存。
针对Intel Core i7 CPU研究VA到PA的变换。
Intel Core i7 CPU的基本参数如下:
- 虚拟地址空间48位(n=48)
- 物理地址空间52位(m=52)
- TLB四路十六组相连
- L1,L2,L3块大小为64字节
- L1,L2八路组相连
- L3十六路组相连
- 页表大小4KB(P=4x1024=2^12),四级页表,页表条目(PTE)大小8字节
由上述信息可以得知,VPO与PPO有p=12位,故VPN为36位,PPN为40位。单个页表大小4KB,PTE大小8字节,则单个页表有512个页表条目,需要9位二进制进行索引,而四级页表则需要36位二进制进行索引,对应着36位的VPN。TLB有16组,故TLBI有t=4位,TLBT有36-4=32位。
如图所示, CPU产生虚拟地址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支持下的物理内存访问
在完成了7.4中的VA到PA的转换后,得到了一个52位的物理地址。首先图中的L1cache有64组,每组8行,每个块的大小为64B,所以块偏移为6,s=6,所以将这52位的地址分为3部分,分别是40位的CT高速缓存标记,6位的CI高速缓存索引,6位的CO缓冲块内的字节偏移量。
- 先根据CI得到需要查找的组序号
- 查找该组的8个行,根据CT与行中的标记位进行匹配,且行的有效为为1,则该高速缓存行命中。
- 命中后,由CO得到我们需要的字节起始位置,取出返回CPU即可。
- 若不命中,就需要到L2,L3,甚至于到主存中去寻找请求的块,然后将该请求的块放置或替换到组索引所对应的那组的某一个高速缓存行中,若有一行的有效位位0,就放置在该行,若所有行的有效位都为1,就选择一个最近不使用的一行进行替换。
7.6 hello进程fork时的内存映射
子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。该子进程除了PID是最大的不同以外,其余与父进程几乎没有什么不同,shell会在这个新子进程的上下文中运行这个子进程。
当fork函数被当前进程hello调用时,内核为新进程hello创建各种数据结构,并分配给它一个唯一的PID。为了给这个新的hello创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
7.7 hello进程execve时的内存映射
execve函数调用驻留再内核区域的启动加载器代码,在当前进程中加载并运行包含在可执行目标文件hello中的程序,步骤如下:
删除已存在的用户区域,删除当前进程中虚拟地址的用户部分中的已存在的区域结构。映射私有区域,为新程序的代码,数据,.bss段和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区,bss区域是请求二进制0的,映射至匿名文件,其大小包含在hello中,栈和堆地址也是请求二进制0的,初始长度为0.映射共享区域,hello程序和共享对象libc.so动态链接,然后再映射到用户虚拟地址空间中的共享区域内。最后设置程序计数器(PC),使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页故障处理:DRAM缓存不命中称为缺页,缺页异常调用内核中缺页异常处理程序,该程序从内存选择一个牺牲页,将其复制回磁盘,随后内核将所需页面复制到内存相应位置,更新页表后返回,重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。
缺页中断处理:缺页处理程序是系统内核中的代码,选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令再次发送VA到MMU,这次MMU就能正常翻译VA了。
7.9动态存储分配管理
Printf会调用malloc,请简述动态内存管理的基本方法与策略。
动态内存分配器维护着一个称为堆的进程的虚拟内存区域。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放可以由应用程序显式执行或内存分配器自身隐式执行。
分配器基本分为两种:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
动态存储分配管理核心概念:
下面介绍动态存储分配管理中较为重要的概念:
- 隐式链表
堆中的空闲块通过头部中的大小字段隐含地连接,分配器通过遍历堆中所有的块,从而间接遍历整个空闲块的集合。
对于隐式链表,其结构如下:
- 显式链表
在每个空闲块中,都包含一个前驱(pred)与后继(succ)指针,从而减少了搜索与适配的时间。
显式链表的结构如下:
- 带边界标记的合并
采取使用边界标记的堆块的格式,在堆块的末尾为其添加一个脚部,其为头部的副本。添加脚部之后,分配器就可以通过检查前面一个块的脚部,判断前面一个块的起始位置和状态。从而实现快速合并,减小性能消耗。
- 分离存储
维护多个空闲链表,其中,每个链表的块具有相同的大小。将所有可能的块大小分成一些等价类,从而进行分离存储。
7.10本章小结
本章对于hello运行过程中的内存分配管理做了概括式的介绍,同时阐述了虚拟地址向物理地址的转换方式,借助TLB和多级页表的映射方法,同时讲解了如何对hello进程使用fork和execve函数,并对其内存的映射过程做了解释。并介绍了缺页故障、缺页中断处理等概念。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
一个Linux文件就是一个m个字节的序列:B0,B1,B2…….所有的I/O设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对文件的读写操作。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为UnixI/O,这使得所有的输入输出都能以一种统一的方式且一致的方式来执行。
8.2 简述Unix IO接口及其函数
8.2.1 Unix I/O接口:
- 打开文件
- 一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。对于Shell创建的每个进程,其都有三个打开的文件:标准输入,标准输出,标准错误。
- 改变当前的文件位置
- 对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
- 读写文件
- 一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件
- 内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
- 8.2.2 Unix I/O函数
- 打开文件
- 一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。对于Shell创建的每个进程,其都有三个打开的文件:标准输入,标准输出,标准错误。
- 改变当前的文件位置
- 对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
- 读写文件
- 一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件
- 内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
8.3 printf的实现分析
[转]printf 函数实现的深入剖析 - Pianistx - 博客园
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall等.
首先我们通过查找得到printf的c语言代码,入下图所示:
形参列表中的…是可变形参的一种写法,当传递参数的个数不确定时,用这种方式来表示。
va_list的定义:typedef char *va_list,说明它是一个字符指针,其中 (char*)(&fmt) + 4) 即arg表示的是...中的第一个参数。
再进一步查看windows系统下的vsprintf函数体:
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在printf中调用系统函数write(buf,i)将长度为i的buf输出。write函数如下:
printf函数的功能为接受一个格式化命令,并按指定的匹配的参数格式化输出,故i = vsprintf(buf, fmt, arg)是得到打印出来的字符串长度,其后的write(buf, i)是将buf中的i个元素写到终端。
因此,vsprintf的作用为接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,进而产生格式化输出。
进一步追踪write:
这里给几个寄存器传递了参数,然后以一个int INT_VECTOR_SYS_CALL结束。INT_VECTOR_SYS_CALL代表通过系统调用syscall,查看syscall的实现:
syscall将字符串中的字节从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码,符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成 ASCII 码,保存到系统的键盘缓冲区之中。
getchar 函数落实到底层调用了系统函数 read,通过系统调用 read 读取存储在 键盘缓冲区中的 ASCII 码直到读到回车符然后返回整个字串,getchar 进行封装,大体逻辑是读取字符串的第一个字符然后返回。
8.5本章小结
本章主要介绍了Linux的IO设备管理方法以及Unix IO接口及其函数还介绍了printf函数和getchar函数是怎么实现的。
结论
- 用计算机系统的语言,逐条总结hello所经历的过程。
- 你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
- Hello的一生看似很平凡,但实际上确凝结着现代计算机的心血,从使用高级语言编写出来开始,他就开始了不平凡,更不简单的人生旅程:
- 预处理
- 将hello.c中的所有以”#”开头的语句去掉,将所有include的头文件直接插入进程序中,并完成相关的宏定义的替换
- 编译
- 通过词法语法分析,编译器将hello.i文本文件中的代码翻译成等价的汇编语言代码,从而将hello.i文件转化为hello.s文本文件
- 汇编
- 将文本文件hello.s翻译成机器码,并将指令打包成可重定位目标程序的二进制文件,生成hello.o
- 链接
- 提供了一种模块化的方式,可以将程序编写为一个较小的源文件的集合,且实现了分开编译更改源文件,从而减少整体文件的复杂度与大小,增加容错性,也方便对某一模块进行针对性修改。链接器(ld)将hello.o文件于C标准库函数的可重定位文件进行合并,生成一个可执行的.out文件。
- Shell中的创建进程与运行程序
- 打开Shell运行hello程序,终端为其fork新建进程,并通过execve把代码和数据加载入虚拟内存空间,程序开始执行。在该进程被调度时,CPU为hello其分配时间片,在一个时间片中,hello享有CPU全部资源,PC寄存器一步一步地更新,CPU不断地取指,顺序执行自己的控制逻辑流。同时在Shell中输入的其他指令也会对hello的运行产生影响。
- Hello运行过程中的内存管理
- CPU上的内存管理单元MMU根据页表将CPU生成的虚拟地址翻译成物理地址,将相应的页面调度。同时在访问代码、数据、堆栈、时会产生缺页现象,经过处理程序的处理重新恢复运行。在程序中若需要即时地分配一些空间来存储数据需要用到动态内存分配器,包括分配器如何组织空闲区域、空间的申请、分割、合并、回收等具体过程。
- 系统的I/O管理
- IO就是计算机与外界设备进行交互的基础。一个Linux文件就是一个m个字节的序列,所有的I/O设备(如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对文件的读写操作。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为UnixI/O,这使得所有的输入输出都能以一种统一的方式且一致的方式来执行。
- 一些心得:本次大作业使用一个简单的hello程序,带领我们回顾了课本的教授内容,虽然C语言文件中仅仅只有寥寥几行代码,但是它经历的过程却需要上万字的描绘。经从程序的编写,到编译汇编链接,再到最后的执行,一路上我们看到的使现代计算机智慧的结晶,也是前人无数努力与摸索的结果,蕴含着大智慧,也是我们需要不断学习和理解的。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 | 功能 |
hello.i | 预处理后得到的文本文件 |
hello.s | 编译后得到的汇编语言文件 |
hello.o | 汇编后得到的可重定位目标文件 |
hello.elf | 用readelf读取hello.o得到的ELF格式信息 |
helloasm.txt | 反汇编hello.o得到的反汇编文件 |
helloout.txt | 由hello可执行文件生成的.elf文件 |
helloasm2.txt | 反汇编hello可执行文件得到的 |
- Randal E.Bryant, David O'Hallaron. 深入理解计算机系统[M]. 机械工业出版社.2018.4
- Pianistx.printf 函数实现的深入剖析[EB/OL].2013[2021-6-9].https: //www.cnblogs.com/pianist/p/3315801.html.
- 逻辑地址、线性地址、物理地址和虚拟地址 概念与区别_Camera Man的博客-CSDN博客 逻辑地址、线性地址、物理地址和虚拟地址 概念与区别
- 内存管理段式与页式管理https://www.cnblogs.com/xavierlee /p/6400230.html