Hello’s P2P

Hello’s P2P

摘 要

本文以在Linux环境下,简单的C程序hello.c从一个program到process的过程为线索,介绍了GCC编译的四个工作环节。又以运行hello程序为核心,展开的介绍了程序的进程管理,相关数据的存储管理,和I/O管理。

关键词:

编译系统;进程;内存管理;Linux I/O管理;

第1章 概述

1.1 Hello简介

Hello的P2P(from program to process)过程,就是从编程到生成可执行文件的过程。我们在编译器中编写代码,在集成开发环境,用编译器构建(Build)成功后,就生成了一个.exe可执行程序文件。这只是P2P的表面过程,其中间的过程被IDE自动完成了。

P2P的深层过程,可以在Linux下编译代码中得到体现。假设我们在Linux环境下,用GCC编译器驱动程序来编译hello.c,有四个阶段:预处理阶段、编译阶段、汇编阶段、链接阶段,这四个阶段分别用到了是:预处理器(cpp)、编译器(ccl)、汇编器(as)和链接器(ld)。

图1.1 编译过程
经历了这四个阶段,hello.c就成为了hello.out(Linux环境),P2P的过程就结束了。

Hello的O2O(from zero-0 to zero-0)过程,就是我们在进程中调用hello, Execve函数运行起hello,在虚拟内存空间中给hello分配空间,又由地址翻译把hello的虚拟地址翻译成物理地址,硬件根据物理地址在主存中取址,形成软硬件结合的运行体系。Hello运行结束后,进程终止,内存回收,内核把关于hello的一切数据全部抹去,就像他从未存在过。正是因为O2O,计算机中的程序才得以正常运行(要不然那么点内存空间早炸了)。

1.2 环境与工具

硬件环境:Intel Core i7 7700HQ ,2.80GHz,8GB RAM,128GB SSD

软件环境:Windows 10,Ubuntu 18.04.1 LTS(VMware)

工具:Codeblocks,gedit,gcc,objdump,readelf 等

1.3 中间结果

hello.i hello.c预处理后的文本文件
hello.s 编译后的汇编语言文本文件
hello.o 汇编后的可重定位目标文件
outobj.txt hello利用objdump工具的反汇编代码
oobj.txt hello.o利用objdump工具的反汇编代码

1.4 本章小结

本章介绍了hello的P2P(from program to process)和O2O(from zero-0 to zero-0)过程,及环境工具等

第2章 预处理

2.1 预处理的概念与作用

预处理是源文件到目标文件转化的第一环节。预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。这些命令通常是.c文件开头的一些以#开头的命令。这些命令告诉预处理器读取哪些相应的文件(如头文件),之后把这些文件直接插入程序文本中,就得到了另一个C程序,通常是以.i作为文件扩展名。

2.2在Ubuntu下预处理的命令

GCC编译器的预处理命令格式为
gcc -E hello.c -o hello.i
其中,-E选项指只预处理,不编译。hello.c是源文件。-o选项指定了预处理输出文件的文件名,通常我们建议输出文件名与源文件同名,而不同后缀。

2.3 Hello的预处理结果解析

预处理过程其实是把源程序.c文件,添加头文件内容,扩展成为一个新的文本模式的.i文件。

2.4 本章小结

预处理阶段是hello的“P2P”之路的第一步,从此,hello的代码中的头文件等的引入被处理,但此时代码还是文本文件,仍属于高级语言。
在Linux中,预处理阶段由GCC编译器驱动程序的预处理器(cpp)执行,转换过程由.c文件变成.i文件。

第3章 编译

3.1 编译的概念与作用

编译是源文件到目标文件转化的第二阶段。预处理器(cpp)对.c文件预处理为.i文件后,由编译器(ccl)继续对.i文件在编译阶段中加工。
在编译阶段,编译器(ccl)将.i文件翻译成另一种文本格式的.s文件,它包含一个汇编语言程序。汇编语言是一种低级语言,它是高级程序语言和机器语言之间的桥梁。
3.2 在Ubuntu下编译的命令
GCC编译器驱动程序的编译器的编译命令格式如下:

gcc -S hello.i -o hello.s
或者

gcc -S hello.c -o hello.s
其中,-S选项只编译,不汇编和链接。-o选项指定了输出的文件。操作对象既可以是源文件.c,也可以是预处理后的.i文件,如果是对.c文件操作,则gcc默认对.c文件先进行预处理,再进行编译。
另外,-O选项给编译过程提出了指定的优化级别。Gcc编译器有几种优化模式:-O0, -O, -O1, -O2, -Os, -O3,优化级别越高优化效果越好。

3.3 Hello的编译结果解析

3.3.1 数据

hello.c中的常量都是在语句中出现的,这种都用立即数直接表示
hello.c中的变量主要有全局变量sleepsecs和局部变量i。
全局变量在hello.s文件头部声明

sleepsecs在.text声明为全局变量,存放在.data节中,对齐方式为4字节对齐, sleepsecs声明为@object类型,大小为4字节。给sleepsecs赋值为long类型的值2
源文件中,int sleepsecs = 2.5,long和int类型大小相同,编译器将int转成了long存储,并且把赋值语句右侧的2.5直接舍成2。
局部变量i要么保存至寄存器,要么保存至栈空间,从编译结果看,变量i保存在了栈空间中

3.3.2 操作
3.3.2.1 赋值

hello.c中一共有两处赋值,一个是全局变量sleepsecs的初始化赋值,一个是局部变量i的初始化赋值。全局变量的赋值是在.s的头部就已经做好了的,值在.data节中。至于局部变量的赋值,用的是MOV指令。

局部变量i保存在栈中,具体位置在-4(%rbp)。这句操作指令的含义是把立即数0,传送到栈中-4(%rbp)的位置,即对i赋值。

3.3.2.2 类型转换

hello.c中只有一个隐式的类型转换,就是在全局变量的初始化赋值中。这里的类型转换是把浮点数转换为整型。对于浮点数来说,向整数舍入,通常是找到最接近浮点数的整数来作为舍入结果。但是,如果有两个整型距离这个浮点数相同,采取向偶数舍入,把2.5舍入为2,而把3.5舍入为4。因此我们也看到了,在.data节,sleepsecs的值被声明为2。

3.3.2.3 算数操作与逻辑操作

hello.c中只有一处算术操作,就是循环语句中的循环变量i++, 没有逻辑操作,汇编语言中的算术操作和逻辑操作使用一系列指令集来完成的。

指令 操作 描述
leaq S, D D←&S 加载有效地址
INC D D←D+1 加1
DEC D D←D-1 减1
NEG D D←-D 取反
NOT D D←~D 按位取反
ADD S, D D←D+S 加法
SUB S, D D←D-S 减法
IMUL S, D D←D*S 乘法
XOR S, D D←D^S 按位异或
OR S, D D←D|S 按位或
AND S, D D←D&S 按位与
SAL k, D D←D<<k 算数左移
SHL k, D D←D<<k 逻辑左移(等同SAL)
SAR k, D D←D>>Ak 算数右移
SHR k, D D←D>>Lk 逻辑右移

3.3.2.4 关系操作

hello.c中的关系操作在判断语句中,包括循环终止条件的判断。这些关系操作基于大小比较。汇编语言中的比较指令 CMP
CMP指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外,CMP指令和SUB指令行为是一样的。
以hello.c中的第一个条件判断为例:
argc!=3
它的汇编代码是:
cmpl $3, -20(%rbp)
je .L2
这是用argc和3作差,根据结果设置条件码,再根据条件码,判断是否跳转到L2处。

3.3.2.5 数组操作

hello.c中也是有字符串和数组的。事实上这里面的字符串作为printf的参数,在主函数中是以如下形式声明的:

而数组char *argv[]是main的参数,在栈空间中,argv的首地址被存放在-32(%rbp)的位置,调用数组元素时,根据栈指针加上偏移量来寻址。

3.3.2.6 控制转移

hello.c中的控制转移是伴随着条件判断出现的。比如说,如果argc!=3,那么就执行if块的语句。如果i<10的话,那么就继续i++,并转而执行新的迭代。汇编语言中的控制转移有两种,一种是通过访问条件码,用jump指令配合条件控制来执行控制转移。另一种是用条件传送来实现条件分支。

3.3.2.7 函数操作以及参数

hello.c中共有五个函数调用,除了main函数外,还有printf、exit、sleep和getchar函数。其中,只有getchar没有参数。

函数作为一种过程,假设过程P调用过程Q,有这样的机制:
传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
传递数据。P必须能够向Q提供一个或者多个参数,Q必须能够向P返回一个值。
分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放掉这些空间。这些空间往往是运行时栈。

3.4 本章小结

编译是“hello”P2P之路的第二阶段,把源程序代码转化成了汇编语言代码。汇编语言是高级程序语言和机器语言之间的桥梁。我们写的高级语言代码朝机器语言更近了一步。

第4章 汇编

4.1 汇编的概念与作用

汇编是源文件到目标文件转化的第三阶段。经过了预处理和编译,源文件已经变成了由汇编语言编写的.s文件。接下来,由汇编器(as)将.s文件翻译成为机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并把结果保存在目标文件.o中。

4.2 在Ubuntu下汇编的命令

GCC的汇编指令格式如下:

gcc -c hello.c -o hello.o

其中,-c选项是只进行预处理、编译和汇编,而不进行链接。hello.c是操作对象,也可以是.i或.s文件。-o指定了输出文件,输出文件是.o格式文件。

4.3 可重定位目标elf格式

4.3.1 ELF头

ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型、机器类型、字节头部表的文件偏移,以及节头部表中条目的大小和数量等信息。

4.3.2 节头部表

节头部表描述了不同节的位置和大小,目标文件中每个节都有一个固定大小的条目。

4.3.3 重定位节

重定位节.rela.text :一个.text节中位置的列表,包含.text节中需要进行重定位的信息,当链接器把这个目标文件和其他文件组合时,需要修改这些位置。图中8条重定位信息分别是对.L0(第一个printf中的字符串)、puts函数、exit函数、.L1(第二个printf中的字符串)、printf函数、sleepsecs、sleep函数、getchar函数进行重定位声明。

4.3.4 符号表

符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。

4.4 Hello.o的结果解析

hello.o是由hello.s汇编得到的,而hello.o的反汇编却和hello.s不一样了。汇编之后,hello.o比hello.s更加具体,比如说函数调用的地址,从函数名称变成了主函数首地址加上偏移量,而条件跳转也从跳转到段名称变成了跳转到指定偏移地址。还有对全局变量的引用,之前是.LC0(%rip), 而现在是$0x0, 至于这个全局变量的地址到底在哪,这些信息都保存在重定位信息里了。还有一些细节,立即数的表示由十进制变成了十六进制。

4.5 本章小结

汇编之后的hello变得更加丰满。hello.o作为可重定位的目标文件,即将和其它目标文件一起链接成最后的可执行目标文件到此,我们的hello已经拥有了汇编语言解释,ELF格式的诸多信息,即将达成最后的可执行文件了。

第5章 链接

5.1 链接的概念与作用

链接过程是hello成为可执行目标文件的最后一阶段。在经历了预处理、编译和汇编之后生成的.o文件,只需要再经过链接器(ld)和其它可重定位目标文件链接,就可以生成最终的可执行目标程序了。

5.2 在Ubuntu下链接的命令

ld -dynamic-linker /lin64/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/7/crtbegin.o /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o hello.o -lc -z relro -o hello

5.3 可执行目标文件hello的格式

5.4 hello的虚拟地址空间

5.5 链接的重定位过程分析

hello和hello.o的区别有,在链接之前,各段的地址仅仅是一个偏移量,而非“地址”,链接之后,各段有了实质的虚拟地址,每一条指令也有与之对应的虚拟地址。hello.o经过链接中的重定位后,基本上所有未确定的信息都有了确定的地址,比如在加载全局变量字符串时(printf的参数字串),hello中给出的指令是:
mov $0x4006f4, %edi
可以说,每一个条目都找到了虚拟内存地址。调用的函数,也都是call函数的虚拟地址。

5.6 hello的执行流程

子程序名 程序地址 简述
ld-2.27.so!_dl_start 0x7ffeb26105e0 开始加载
ld-2.27.so!_dl_init 0x7f768f82b630
libc-2.27.so!__libc_start_main 0x7f768f44bab0
libc-2.27.so!__cxa_atexit 0x7f768f46d430
hello!__libc_csu_init 0x400670
libc-2.27.so!_setjmp 0x7f768f468c10
hello!main 0x4005e7 主函数
hello!puts@plt 0x4004b0 hello.c中调用
hello!exit@plt 0x4004e0 hello.c中调用
hello!printf@plt 0x4004c0 hello.c中调用
hello!sleep@plt 0x4004f0 hello.c中调用
hello!getchar@plt 0x4004d0 hello.c中调用
libc-2.27.so!exit

5.7 Hello的动态链接分析

对于动态共享链接库中PIC函数,编译器没有办法预测函数的运行时地址,需要添加重定位记录,等待动态链接器处理,为避免运行时修改调用模块的代码段,链接器采用延迟绑定的策略。动态链接器使用过程链接表PLT+全局偏移量表GOT实现函数的动态链接,GOT中存放函数目标地址,PLT使用GOT中地址跳转到目标函数。
在dl_init调用之前,对于每一条PIC函数调用,调用的目标地址都实际指向PLT中的代码逻辑,GOT存放的是PLT中函数调用指令的下一条指令地址。

在之后的函数调用时,首先跳转到PLT执行.plt中逻辑,第一次访问跳转时GOT地址为下一条指令,将函数序号压栈,然后跳转到PLT[0],在PLT[0]中将重定位表地址压栈,然后访问动态链接器,在动态链接器中使用函数序号和重定位表确定函数运行时地址,重写GOT,再将控制传递给目标函数。之后如果对同样函数调用,第一次访问跳转直接跳转到目标函数

5.8 本章小结

本章介绍了hello在真正成为process之前的最后一步——链接。简单跟踪了hello的链接过程.重点分析了重定位和动态链接中的PIC调用和引用。

第6章 hello进程管理

6.1 进程的概念与作用

进程是一个执行中的程序的实例,每一个进程都有它自己的地址空间,一般情况下,包括文本区域、数据区域、和堆栈。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储区着活动过程调用的指令和本地变量。

进程为用户提供了以下假象:我们的程序好像是系统中当前运行的唯一程序一样,我们的程序好像是独占的使用处理器和内存,处理器好像是无间断的执行我们程序中的指令,我们程序中的代码和数据好像是系统内存中唯一的对象。

6.2 简述壳Shell-bash的作用与处理流程

Shell的作用:Shell是一个用C语言编写的程序,他是用户使用Linux的桥梁。Shell 是指一种应用程序,Shell应用程序提供了一个界面,用户通过这个界面访问操作系统内核的服务。
处理流程:
1)从终端读入输入的命令。
2)将输入字符串切分获得所有的参数
3)如果是内置命令则立即执行
4)否则调用相应的程序为其分配子进程并运行
5)shell应该接受键盘输入信号,并对这些信号进行相应处理

6.3 Hello的fork进程创建过程

父函数可以通过fork函数创建一个新的运行的子进程。新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的但是独立地一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的差别在于它们有不同的PID。
有几个需要注意的地方。fork函数调用一次,返回两次。父进程调用一次fork,但却有一次是返回到父进程,而另一次是返回到子进程的。父进程和子进程是并发运行的独立进程,内核可以以任意方式交替执行它们的逻辑控制流中的指令。父进程和子进程还具有相同但是独立的地址空间,从虚拟内存的角度看fork函数,子进程使用父进程的地址空间,但有写时复制的特性。父子进程还有共享的文件。
通常父进程用waitpid函数来等待子进程终止或停止。在父进程调用fork后,到waitpid子进程终止或停止这段时间里,父进程执行的操作,和子进程的操作(如果没有什么其它复杂的操作的话),在时间顺序上是拓扑排序执行的。有可能,这段时间里父子进程的逻辑控制流指令交替执行。而父进程的waitpid后的指令,只能在子进程终止或停止后,waitpid返回后才能执行。

6.4 Hello的execve过程

execve函数在当前进程的上下文中加载并运行一个新程序。execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。正常情况下,execve调用一次,但从不返回。在execve加载filename之后,调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数.

6.5 Hello的进程执行

hello在执行时,有自己的逻辑控制流。多个进程的逻辑控制流在时间上可以交错,表现为交替运行。每个进程执行它的流的一部分,然后被抢占(暂时挂起),然后轮到其它进程。一个逻辑流的执行在时间上和另一个流重叠,成为并发流,这两个流并发地运行。一个进程执行它的控制流的一部分的每一时间段叫时间片。
当hello执行到sleep函数时,会被挂起一段时间。挂起就是指进程被抢占,也就是hello会在逻辑控制流上出现一段断片。这会使当前进程挂起secs秒,如果请求的时间量到了,sleep返回0,否则返回还剩下的要休眠的秒数。正常情况下,hello正常运行,直到调用函数sleep,hello进程会临时交出控制权。进程控制权的交换涉及到上下文切换。操作系统内核使用一种成为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫调度,是由内核中成为调度器的代码处理的。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换要做到:

1.保存当前进程的上下文
2.恢复某个先前被抢占的进程被保存的上下文
3,将控制传递给这个新恢复的进程

再回到hello进程来,调用sleep后,内核中的调度器将hello进程挂起,然后进入到内核模式,由于hello调用sleep的这个过程没有显式地创建新的进程,所以,在hello被抢占了secs秒后,内核又会选择hello进程,恢复它被抢占时的上下文,并把控制交给它,这时,又回到了用户模式。

6.6 hello的异常与信号处理

hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
先看一下hello程序的内容。

argc是执行hello时的参数个数,*argv[]是执行hello时,输入的参数数组,并且这时已经被解析过的参数字串。
首先,如果参数不为3,那么会打印一条默认语句,并异常退出。如果参数是3个,那么会执行一个循环,每次循环会使hello进程休眠2.5秒,休眠后又会恢复hello。而且循环里会输出一条格式字串,其中有输入的两个参数字串。循环结束后,有一个getchar()等待一个标准输入,然后就结束了。

6.6.1 正常运行
6.6.2 Ctrl + C 信号
6.6.3 Ctrl + Z 信号
6.6.4 乱按

乱按只是将屏幕的输入缓存到stdin,当getchar的时候读出一个’\n’结尾的字串(作为一次输入),其他字串会当做shell命令行输入

6.7本章小结

进程给应用程序提供的关键抽象,使得进程可以并发地执行。信号和异常的处理,使得并发执行的过程变得井然有序,如信号处理程序,一切都按照规shell-bash的建立,给用户和进程之间提供了一个操作平台。

第7章 hello的存储管理

7.1 hello的存储器地址空间

计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。物理地址是在地址总线上,以电子形式存在的,使得数据总线可以访问主存的某个特定存储单元的内存地址。
在一个带虚拟内存的系统中,CPU从一个有N=2n个地址的地址空间中生成虚拟地址,这个地址空间成为虚拟地址空间。
地址空间是一个非负整数的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。线性地址就是线性地址空间中的地址。
在有地址变换功能的计算机中,访问指令给出的地址(操作数)叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。
edb调试中看到的hello的指令地址都是16位的虚拟地址,有些访问指令的地址也是逻辑地址,在程序中虚拟地址和逻辑地址没有明显的界限。通常来说我们是看不到程序的物理地址的。至于线性地址,只是一个地址的概念。
逻辑地址转换成线性地址,虚拟地址,是由段式管理执行的。
线性地址转换成物理地址,是由页式管理执行的。

7.2 Intel逻辑地址到线性地址的变换-段式管理

最初8086处理器的寄存器是16位的,为了能够访问更多的地址空间但不改变寄存器和指令的位宽,所以引入段寄存器,8086共设计了20位宽的地址总线,通过将段寄存器左移4位加上偏移地址得到20位地址,这个地址就是逻辑地址。将内存分为不同的段,段有段寄存器对应,段寄存器有一个栈、一个代码、两个数据寄存器。
分段功能在实模式和保护模式下有所不同。
实模式,即不设防,也就是说逻辑地址=线性地址=实际的物理地址。段寄存器存放真实段基址,同时给出32位地址偏移量,则可以访问真实物理内存。
在保护模式下,线性地址还需要经过分页机制才能够得到物理地址,线性地址也需要逻辑地址通过段机制来得到。段寄存器无法放下32位段基址,所以它们被称作选择符,用于引用段描述符表中的表项来获得描述符。描述符表中的一个条目描述一个段

所有的段描述符被保存在两个表中:全局描述符表GDT和局部描述符表LDT。gdtr寄存器指向GDT表基址。所以在保护模式下,分段机制就可以描述为:通过解析段寄存器中的段选择符在段描述符表中根据Index选择目标描述符条目Segment Descriptor,从目标描述符中提取出目标段的基地址Base address,最后加上偏移量offset共同构成线性地址Linear Address。保护模式时分段机制图示如下:

7.3 Hello的线性地址到物理地址的变换-页式管理

虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。虚拟页是带虚拟内存系统将虚拟内存分割为大小固定的块,作为磁盘和主存(较高层)之间的传输单元。任何时刻,虚拟页面只有三种情况,要么是未分配的,要么是缓存的,要么是未缓存的。

页表是一个存放在物理内存中的数据结构,将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
页表就是一个页表条目(PTE)的数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。可以假设每个PTE是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示物理内存中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,一个非空地址指向的是该虚拟页在磁盘上的起始位置。

形式上来说,地址翻译是一个N元素的虚拟地址空间中的元素到一个M元素的物理地址空间中元素的映射。

CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含两个部分,一个p位的虚拟页面偏移(VPO)和一个n-p位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE。将页表条目中的物理页号(PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。

7.4 TLB与四级页表支持下的VA到PA的变换

CPU产生虚拟地址VA,VA传送给MMU,MMU使用前36位VPN作为TLBT(前32位)+TLBI(后4位)向TLB中匹配,如果命中,则得到PPN(40bit)与VP(12bit)组合成PA(52bit)。
如果TLB中没有命中,MMU向页表中查询,CR3确定第一级页表的起始地址,VPN1(9bit)确定在第一级页表中的偏移量,查询出PTE,如果在物理内存中且权限符合,确定第二级页表的起始地址,以此类推,最终在第四级页表中查询到PPN,与VPO组合成PA,并且向TLB中添加条目。
如果查询PTE的时候发现不在物理内存中,则引发缺页故障。如果发现权限不够,则引发段错误。

7.5 三级Cache支持下的物理内存访问

L2,L3类似,所以只讨论L1
L1 Cache是8路64组相联。块大小为64B。因为共64组,所以需要6bit CI进行组寻址,因为共有8路,因为块大小为64B所以需要6bit CO表示数据偏移位置,因为VA共52bit,所以CT共40bit。
在上一步中已经获得了物理地址VA,使用CI(后六位再后六位)进行组索引,每组8路,对8路的块分别匹配CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。
如果没有匹配成功或者匹配成功但是标志位是1,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略LFU进行替换。

7.6 hello进程fork时的内存映射

当fork函数被shell进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将这两个进程的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时复制

7.7 hello进程execve时的内存映射

hello调用execve后,execve在当前进程中加载并运行包含在可执行目标文件hello.out中的程序,用hello.out程序有效地替代了当前程序。加载并运行hello.out需要以下几个步骤:

  1. 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
  2. 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello.out文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello.out中,栈和堆地址也是请求二进制零的,初始长度为零。图7.9概括了私有区域的不同映射。
  3. 映射共享区域, 如果hello.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
  4. 设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
    下一次调度hello进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和页面。

7.8 缺页故障与缺页中断处理

物理内存(DRAM)缓存不命中成为缺页。假设CPU引用了磁盘上的一个字,而这个字所属的虚拟页并未缓存在DRAM中。地址翻译硬件会从内存中读取虚拟页对应的页表,推断出这个虚拟页未被缓存,然后触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页。如果被牺牲的页面被修改了,那么内核会把它复制回磁盘。总之,内核会修改被牺牲页的页表条目,表示它不再缓存在DRAM中了。
之后,内核从磁盘把本来要读取的那个虚拟页,复制到内存中牺牲页的那个位置,更新它的页表条目,随后返回。当异常处理程序返回时,会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。于是,地址翻译硬件可以正常处理现在的页命中了。

7.9动态存储分配管理

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。

分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块,自动释放未使用的已经分配的块的过程叫做垃圾收集。
1.带便捷标签的隐式空闲链表
a.堆
在内存块中增加4B的Header和4B的Footer,其中Header用于寻找下一个blcok,Footer用于寻找上一个block。Footer的设计是专门为了合并空闲块方便的。因为Header和Footer大小已知,所以我们利用Header和Footer中存放的块大小就可以寻找上下block

b.隐式链表
所谓隐式空闲链表,对比于显式空闲链表,代表并不直接对空闲块进行链接,而是将对内存空间中的所有块组织成一个大链表,其中Header和Footer中的block大小间接起到了前驱、后继指针的作用。

c.空闲块合并
因为有了Footer,所以我们可以方便的对前面的空闲块进行合并。合并的情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四种情况分别进行空闲块合并,我们只需要通过改变Header和Footer中的值就可以完成这一操作。
2.显示空间链表
将空闲块组织成链表形式的数据结构。堆可以组织成一个双向空闲链表,在每个空闲块中,都包含一个pred(前驱)和succ(后继)指针,如下图:

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。

维护链表的顺序:后进先出(LIFO),将新释放的块放置在链表的开始处,使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率

7.10本章小结

本章主要介绍了hello的存储器地址空间、intel的段式管理、hello的页式管理、VA到PA的变换、物理内存访问,还介绍了hello进程fork时的内存映射、execve时的内存映射、缺页故障与缺页中断处理、动态存储分配管理

第8章 hello的IO管理

8.1 Linux的IO设备管理方法

一个Linux文件就是一个m个字节的序列:

B0, B1, …, Bk, …, Bm-1

所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行,这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单低级的应用接口,称为Unix I/O。

8.2 简述Unix IO接口及其函数

(以下格式自行编排,编辑时删除)
设备可以通过Unix I/O接口被映射为文件,这使得所有的输入和输出都能以一种统一且一致的方式来执行:

  1. 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
  2. Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为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. 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
    在Unix I/O接口中,进程是通过调用open函数来打开一个存在的文件或者创建一个新文件的,函数声明如下:

int open(char *filename, int flags, mode_t mode);

open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件。mode参数指定了新文件的访问权限位。作为上下文的一部分,每个进程都有一个umask,它是通过调用umask函数来设置的。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置成mode&~umask。

进程通过调用close函数关闭一个打开的文件。函数声明如下:

int close(int fd);

关闭一个已关闭的描述符会出错。关闭成功返回0,若出错则返回-1.

应用程序是通过分别调用read和write函数来执行输入和输出的。函数声明如下:

ssize_t read(int fd, void *buf, size_t n);

ssize_t write(int fd, const void *buf, size_t n);

read函数从描述符为fd的当前文件位置赋值最多n个字节到内存位置buf。返回值-1表示一个错误,0表示EOF,否则返回值表示的是实际传送的字节数量。
write函数从内存位置buf复制至多n个字节到描述符为fd的当前文件位置
通过调用lseek函数,应用程序能够显式第修改当前文件的位置。

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;
}

首先arg获得第二个不定长参数,即输出的时候格式化串对应的值。
查看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’: //只处理%x一种情况
itoa(tmp, ((int)p_next_arg)); //将输入参数值转化为字符串保存在tmp
strcpy(p, tmp); //将tmp字符串复制到p处
p_next_arg += 4; //下一个参数值地址
p += strlen(tmp); //放下一个参数值的地址
break;
case ‘s’:
break;
default:
break;
}
}
return (p - buf); //返回最后生成的字符串的长度
}
则知道vsprintf程序按照格式fmt结合参数args生成格式化之后的字符串,并返回字串的长度。
在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的实现
sys_call:
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将字符串中的字节“Hello 1170301003 wangyi”从寄存器中通过总线复制到显卡的显存中,显存中存储的是字符的ASCII码。
字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。
显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。

8.4 getchar的实现分析

异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码转换成ASCII码,保存到系统的键盘缓冲区之中。
getchar函数落实到底层调用了系统函数read,通过系统调用read读取存储在键盘缓冲区中的ASCII码直到读到回车符然后返回整个字串,getchar进行封装,大体逻辑是读取字符串的第一个字符然后返回。

8.5本章小结

本章主要介绍了Linux的IO设备管理方法、Unix IO接口及其函数,分析了printf函数和getchar函数

结论

本文围绕着hello在计算机系统中的一生展开,到此已落下帷幕。hello的一生,也是其它程序的一生,计算机系统的学习,就是程序在计算机中的日记,我们和程序一起成长。

hello在计算机系统中的一生概括如下:

  1. 编辑(诞生)。用codeblocks之类的编译器将正确的程序写用高级语言出来。
  2. 预处理。GCC中的预处理器将源代码中的预处理指令拓展,成为新的文本文件。
  3. 编译。GCC中的编译器将拓展代码文件按照规则,编译为汇编代码文本文件。
  4. 汇编。GCC中的汇编器将汇编代码翻译成机器指令格式,打包成可重定位目标文件。
  5. 链接。GCC中的链接器将可重定位目标文件和其它必要的可重定位目标文件一切链接,生成可执行目标文件。
  6. 运行。在shell中运行hello,shell为hello创建一个子进程,并调用execve执行hello。
  7. 内存管理。在shell中运行hello的同时,为hello分配了虚拟地址空间。
  8. 内存访问。在hello的运行过程中,任何指令语句的执行,都调动着系统和硬件的配合,将虚拟地址翻译成物理地址,并在主存中取址。是CPU和主存之间的交互。
  9. 信号与异常。信号与异常是hello运行过程中的协奏曲,很多信号与异常是必要的。但如果有不必要的信号与异常发生,会对hello造成一些影响。
  10. 终止。hello由于某种原因(正常或非正常)而终止成为僵死进程,shell回收hello,同时内核删除hello的数据相关,为hello善后。仿佛他从未存在过
    在一个学期里学完计算机系统这么多知识,学期结束后,感觉自己仍有很多迷惑之处。简单的一个程序的诞生到终止就包含了如此之多的幕后过程,现在觉得深入理解计算机系统的必要性,自己在计算机科学的学习之路上仍是任重而道远。

附件
hello.c 源程序
hello.i hello.c预处理后的文本文件
hello.s 编译后的汇编语言文本文件
hello.o 汇编后的可重定位目标文件
outobj.txt hello利用objdump工具的反汇编代码
oobj.txt hello.o利用objdump工具的反汇编代码

参考文献
[1] 《深入理解计算机系统》第三版,兰德尔 E.布莱恩特,大卫 R.奥哈拉伦
[2] https://www.cnblogs.com/pianist/p/3315801.html
[3] https://www.cnblogs.com/clover-toeic/p/3737129.html
[4] 物理地址https://baike.baidu.com/item/%E7%89%A9%E7%90%86%E5%9C%B0%E5%9D%80/2901583?fr=aladdin
[5] GCC编译命令常用选项,https://www.cnblogs.com/clover-toeic/p/3737129.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值