目 录
第1章 概述
1.1 Hello简介
P2P过程:首先先有个hello.c的c程序文本,经过预处理->编译->汇编->链接四个步骤生成一个hello的二进制可执行文件,然后由shell新建一个进程给他执行。
020过程:shell执行他,为其映射出虚拟内存,然后在开始运行进程的时候分配并载入物理内存,开始执行hello的程序,将其output的东西显示到屏幕,然后hello进程结束,shell回收内存空间。
1.2 环境与工具
硬件环境:X64 CPU Intel Core i7 10875H;2.30GHz;16G RAM; 512GHD Disk
软件环境:Microsoft Windows10 Home 64位;VirtualBox 6.1.32;Ubuntu 20.04.4
开发工具:Visual Studio 2019;CodeBlocks 64位;gedit+gcc
1.3 中间结果
hello.c 源程序
hello.i 修改了的源程序
hello.s 汇编程序
hello.o 可重定位目标程序
hello 可执行目标程序
1.4 本章小结
本章主要简单介绍了hello的P2P,020过程,列出了本次实验信息:环境、中间结果。
第2章 预处理
2.1 预处理的概念与作用
2.1.1预处理的概念
预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。比如hello.c中第一行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。除了调用库这样的操作之外,程序中的宏定义也会在预处理的时候处理[]。
2.1.2预处理的作用
删除”#define”并展开所定义的宏;处理所有条件预编译指令,如”#if”、”#ifdef”、”#endif”等;插入头文件到”include”处,可以递归方式进行处理;删除所有的注释”//”和/* */;添加行号和文件名标识,以便编译时编译器产生调用的行号信息;保留所有#pragma编译指令。
2.2在Ubuntu下预处理的命令
图2-1预处理的命令
使用gcc -E hello.c -o hello.i对hello进行预处理
2.3 Hello的预处理结果解析
如图2-2所示,可以看到hello.c的大小为527bytes,hello.i的大小为64.7kB。hello.i与hello.c相比,代码大大增多。可见预处理工作中对文本做了很大的改动和补充。
图2-2预处理前后文件大小
我们在2.1节中说到过,预处理只对以字符#开头的命令进行操作。也就是说,对于我们程序中定义的变量、写的函数等这些操作,预处理阶段是不会管的,我们首先就来对比一下这一部分。如图2-3所示,在预处理之前,程序中包含的主函数。而右侧是预处理过后的文件,这里展示了文件的最后几行,可以看到与预处理之前的文件完全相同,这与2.1中的概念相符。
图2-3预处理前后的程序文本
接下来我们回到hello.i文件的开头,从图2-4中可以看出,hello.i程序中并没有了注释部分,说明预处理删除了所有注释。最后我们再来看hello.i文本的中间部分,首先我们看到左侧的图中从第13行开始有很多的地址,还有如右侧图中的一些代码部分,右侧图中就是一个结构体变量。这说明,预处理阶段,预处理器将需要用到的库的地址和库中的函数加入到了文本中,与我们原来不需要预处理的代码一同构成了hello.i文件,用来被编译器继续进行编译。
图2-4预处理后的程序文本
2.4 本章小结
预处理过程是计算机对程序进行操作的起始过程,在这个过程中预处理器会对hello.c文件进行初步的解释,对头文件、宏定义和注释进行操作,将程序中涉及到的库中的代码补充到程序中,将注释这个对于执行没有用的部分删除,最后将初步处理完成的文本保存在hello.i中,方便以后的内核器件直接使用。
第3章 编译
3.1 编译的概念与作用
3.1.1编译的概念
编译过程就是将预处理后得到的预处理文件(如hello.i)进行词法分析、语法分析、语义分析并优化,生成汇编代码文件。
3.1.2编译的作用
编译器(cll)将文本文件hello.i翻译成文本文件hello.s,它包含一个汇编语言程序。编译器会在编译阶段对代码的语法进行检查,如果出现了语法上的错误,会在这一阶段直接反馈回来,造成编译的失败。如果在语法语义等分析过后,不存在问题,编译器会生成一个过渡的代码,也就是汇编代码,在随后的步骤中,汇编器可以继续对生成的汇编代码进行操作。
这里有一个问题,就是为什么我们在预处理的过程中生成的比较大的hello.i文件,在进行完汇编过程后生成的hello.s文件又变小了。我们可以发现,hello.s文件中只存储了头文件之后的汇编代码,至于之前加入的头文件的代码具体去了哪里,会在第五章链接进行介绍。
3.2 在Ubuntu下编译的命令
图3-1Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.3.1数据
在hello.c中定义了一个int型变量i。如图3-1,变量i在被使用的时候才进行定义。
图3-2数据定义
3.3.2赋值
在hello.c中的赋值操作:将0赋值给i。如图3-2,使用movl操作对变量进行赋值。
图3-3将0赋值给i
3.3.3算术操作
在hello.c中的算术操作:i++。
因为i保存在%rbp-4指向的地址,所以使用addl $1 -4(%rbp)完成i++操作
图3-4 i++操作
3.3.4关系操作
在hello.c中的关系操作:i<8和argc!=4。
使用cmpl和jle操作,如果i<=7则跳转,来完成判断i是否小于8的操作。
图3-5 判断i是否小于8的操作
argc作为第一个参数保存在edi寄存器中,将edi寄存器的值赋值给-20(%rbp),再使用cmpl将4和-20(%rbp)进行比较,来完成判断argc是否等于4。
图3-6 判断argc是否等于4的操作
首先cmpl是一个比较函数,这个函数中将比较的结果保存在条件码中。条件码中一共有四位,每一位都有不同的含义。如图3-7中所示。对于不同的比较结果,操作码中就保存了不同的值。
图3-7 条件码
3.3.5数组/指针/结构操作
hello.c中在输出的时候调用了argv数组中的元素。如图3-8所示,我们可以看到,在汇编中,我们已经没有了数组、结构等概念,我们有的只是地址和地址中存储的值。所以对于一个数组的保存,在汇编中我们只保存了他的起始地址,对应的也就是argv[0]的地址,对于数组的中其他元素,我们利用了数组在申请的过程中肯定是一段连续的地址这样的性质,直接用起始地址加上偏移量就得到了我们想要的元素的值。
图3-8 调用argv数组中的元素
3.3.6控制转移
使用cmpl和je完成if操作。条件跳转指令和比较指令总是同时出现。是因为在执行条件跳转的时候,我们必须利用到操作码中的值。所以在每个条件跳转之前,都肯定有一个比较指令对操作码进行设置。
图3-9 if操作
3.3.7函数操作
%eax寄存器中保存了函数的返回值。作为一个函数,我们肯定需要向函数内进行传参操作,对于参数比较少的情况来说,就直接存储在特定的寄存器中,如%rdi,%rsi,%rdx,%rcx就分别用来存储第一至四个参数。X86的及其一共为我们提供了6个寄存器来保存参数。如果参数多于6个,那么就只能放在栈中保存了。
如图3-10,我们直接利用call指令,后面加上调用函数的名称printf,就直接可以去到被调用的函数的位置。在被调用的函数执行完毕之后,程序会将函数的返回值存在%eax中,然后执行ret语句,将函数程序返回到调用的地方。这样就完成了整个的函数调用。
图3-10 调用printf函数
3.4 本章小结
本章我们主要介绍了编译器是如何将文本编译成汇编代码的。可以发现,编译器并不是死板的按照我们原来文本的顺序,逐条语句进行翻译下来的。编译器在编译的过程中,不近会对我们的代码做一些隐式的优化,而且会将原来代码中用到的跳转,循环等操作操作用控制转移等方法进行解析。最后生成我们需要的hello.s文件。
第4章 汇编
4.1 汇编的概念与作用
4.1.1汇编的概念
汇编器(as)将hello.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在hello.o中。这里的hello.o是一个二进制文件。
4.1.2汇编的作用
我们知道,汇编代码也只是我们人可以看懂的代码,而机器并不能读懂,真正机器可以读懂并执行的是机器代码,也就是二进制代码。汇编的作用就是将我们之前再hello.s中保存的汇编代码翻译成可以攻机器执行的二进制代码,这样机器就可以根据这些01代码,真正的开始执行我们写的程序了。
4.2 在Ubuntu下汇编的命令
图4-1 Ubuntu下汇编的命令
4.3 可重定位目标elf格式
根据readelf命令的结果,可以获得ELF文件的一些信息。
图4-2中展示了ELF头的信息。以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。
图4-2 ELF头
图4-3中展示了ELF可重定位文件中各个节节头的信息。偏移量这一栏中保存的信息代表在hello.o这个二进制文件中,对应的节应该保存在起始地址加偏移量的位置。
图4-3 各个节的信息
图4-4中保存了hello.o中的两个可重定位节中保存的具体信息,分别是.rela.text和.tela.eh.frame。
.rela.text中保存了代码的重定位信息,也就是.text节中的信息的重定位信息。可以看到这里面有.rodata,puts等很多代码的重定位信息。我们就拿第一条的信息来做分析。首先偏移量中保存了这个重定位信息在当前重定位节中的偏移量,也就是这个重定位信息的存储位置。然后是信息,前面的2个字节的信息保存了这个代码在汇编代码中被引用的时候的地址相对于所有汇编代码的偏移量,也就是这个代码具体在那个位置被调用了。后面4个字节保存了重定位类型,一个是绝对引用,另一个是相对引用。这也与后面一栏的类型相对应。后面的符号值和符号名称就比较好理解了,保存了代码段中具体符号的信息。
图4-4 可重定位节
4.4 Hello.o的结果解析
用objdump命令进行反汇编过后,得到了如图4-5中所示的代码:
图4-5 hello.o的反汇编代码
图4-6hello.s的汇编代码
对比hello.s的汇编代码可以发现,hello.o的反汇编程序多出来了三个部分。
首先是保存了每一条指令的运行时地址,可以看到main函数的初始地址是0,然后依次向下增加。其实在后面的章节中会讲到,这个地址只是一个虚拟地址,而不是程序真正执行的地址。
其次,是一些16进制的代码。可以发现每一个指令地址的变化量都是16进制代码的字节数。这就可以确定,这些16进制代码保存的是16进制下的机器指令。
最后,有些代码,我们看到底下多了一行信息。这一行代码声明了这个变量的具体类型。同时注意到偏移地址为20和2a的一指令,这个call操作也不是像hello.s中一样直接call对应的函数名字了,而是一个具体的相对地址,能够让程序在跑的过程中,直接跳转到的地方。
总体来说,hello.o文件的反汇编代码中最主要的就是增加了地址这个概念,将代码中的一切信息与地址联系起来。这样做的目的是因为在程序运行的过程中,都是在进行地址操作,所以hello.o文件可以说更接近了计算机可以执行的文件。
4.5 本章小结
汇编器(as)将汇编代码处理成机器可以看懂的机器码,也就是二进制代码。二进制代码较汇编代码来说,虽然可读性变得比较差,但是在执行效率方面有了非常大的提升,汇编代码虽然已经在原来的文本的基础上进行了优化,但是还是存在着一些字符等不能够直接处理的数据。但是二进制代码中,已经将所有的指令、函数名字等量变成了相应的存储地址,这样机器就可以直接读取这些代码并执行。所以总的来说hello.o已经非常接近一个机器可以执行的代码了。
第5章 链接
5.1 链接的概念与作用
5.1.1链接的概念
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。在早期的计算机系统中,链接是手动执行的。在现代系统中,链接是由叫做链接器的程序自动执行的。
5.1.2链接的作用
因为有了链接这个概念的存在,所以我们的代码才回变得比较方便和简洁,同时可移植性强,模块化程度比较高。因为链接的过程可以使我们将程序封装成很多的模块,我们在变成的过程中只用写主程序的部分,对于其他的部分我们有些可以直接调用模块,就像C中的printf一样。
作为编译的最后一步,链接就是处理当前程序调用其他模块的操作,将该调用的模块中的代码组合到相应的可执行文件中去。
5.2 在Ubuntu下链接的命令
使用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-1 链接的命令
5.3 可执行目标文件hello的格式
图5-2中展示了ELF头。可执行目标文件的ELF头类似于可重定位目标文件,它还包括程序的入口点,也就是当程序运行时要执行的第一条指令的地址。
图5-2 ELF头
图5-3中保存了可执行文件hello中的各个节的信息。可以看到hello文件中的节的数目比hello.o中多了很多,说明在链接过后有很多文件有添加了进来。下面列出每一节中各个信息条目的含义:
名称和大小这个条目中存储了每一个节的名称和这个节在重定位文件种所占的大小。
地址这个条目中,保存了各个节在重定位文件中的具体位置也就是地址。偏移量这一栏中保存的是这个节在程序里面的地址的偏移量,也就是相对地址。
图5-3 各节信息
5.4 hello的虚拟地址空间
图5-4 edb的显示
图5-5 反汇编代码
图5-6 各节信息
如图,各段的起始地址能够在edb中得到证实,这与5.3中结果一致。
5.5 链接的重定位过程分析
首先先看一下main函数中有哪些不一样的地方,如图5-7所示,图中上面的程序是hello.o文件中main函数中的的一部分代码,下面的代码是hello文件中对应部分的反汇编代码。可以发现,在链接之前,hello.o中的注释仅仅是对main函数的一个便宜量,并且相应的汇编代码中lea后对于%rip的偏移量也是0,也就是说对于hello.o来说,我们并不能准确的了解到这段代码的含义。
再看链接之后的反汇编,可以看到反汇编之后,代码注释中的内容直接变成了系统的IO库中的函数,lea后面跟的偏移量也是正确的偏移量了。接下来的call指令也是一样,在hello中准确的指明了具体调用的函数,而hello.o文件中也只有一个main函数的偏移量。
可以看到,在链接的过程中,链接器会将我们链接的库函数或者其他文件在可执行文件中准确的定位出来。
图5-7 hello.o与hello的反汇编
可以看到,在hello.o的反汇编代码中,只有一个main函数,但是对于hello的反汇编代码来说,可以看到很多如_init样子的函数。这些函数都是在链接的过程中,被加载到可执行文件中的。
通过上面的对比可以看到,在链接的过程中,链接器会进行如下几个过程:
将代码、符号、变量、函数等进行重定位,使这些元素在可执行文件中可以有明确的虚拟内存地址。具体的执行方式就是用.o文件中的重定位条目,这个条目告诉链接器应该如何修改这个引用的地址。
将调用的函数都加载到可执行文件中,使其变成一个完整的文件,在文件中涉及到的任何符号或者函数等信息在文件中都有定义。
接下来分析一下链接器是如何进行重定位的。我们就对图5-8中红框中的语句的重定位进行分析。首先左侧存储了hello.o中代码节的重定位条目。红框中的第一个信息偏移量存储的是这个符号的出现位置的偏移量,这里是0x1d,对应于右侧红框中的我们可以看到是这个call函数的位置,这段指令的起始地址是0x1c,由于call函数的机器码是e8占了一个字节,所以真正的符号出现的地址是0x1d,这两个地址相同,说明这个重定位条目对应的是这个符号。
图5-8 hello.o的重定位条目
第二个信息的前两个字节保存了这个符号在符号表中的偏移量,可以看到红框中的信息为0xc,对应在符号表中可以看到,puts在符号表中的偏移量是12,对应的十六进制的值就是0xc,说明了被重定位的符号应该是puts。后面的类型保存了是相对地址还是绝对地址。
图5-9 hello.o中的符号表
总体来说,重定位的过程就是应用重定位文件中存储的信息,在对应的符号表和汇编代码中将要重定位的符号或者函数的位置准确的放到可执行文件中。
5.6 hello的执行流程
hello在执行的过程中一共要执行三个大的过程,分别是载入、执行和退出。载入过程的作用是将程序初始化,等初始化完成后,程序才能够开始正常的执行。如图5-10所示,由于hello程序只有一个main函数,所以在程序执行的时候主要都是在main函数中。又因为main函数中调用了很多其它的库函数,所以可以看到,在main函数执行的过程中,会出现很多其他的函数。
在 hello 执行过程中,call 后面接的就是一个函数名,然后按下 F7,进入到这个函数里,第一条的地址就是这个函数的地址。
图5-10 hello的执行流程
使用 edb 跟踪程序运行记录程序名与程序地址如下:
程序名 程序地址
ld-2.23.so!_dl_start 0x00007f11037199b0
ld-2.23.so!_dl_init 0x00007f1103728740
hello!_start 0x400510
libc-2.23.so! libc_start_main 0x00007fa17c942740
ld-2.23.so!_dl_fixup 0x00007fb4dc2d39f0
libc-2.23.so! cxa_atexit 0x00007fb4dbf34280
libc-2.23.so! libc_csu_init 0x400690
libc-2.23.so!_setjmp 0x00007fb4dbf2f250
hello!main 0x400606
hello!puts@plt 0x4004a0
hello!exit@plt 0x4004e0
ld-2.23.so!_dl_fixup 0x00007fb4dc2d39f0
libc-2.23.so!exit 0x00007f2137ae8030
libc-2.23.so! run_exit_handlers 0x00007f2137ae7f10
5.7 Hello的动态链接分析
如图5-11中所示,在执行函数dl_init的前后,地址0x600ff0中的值由0发生了变化。我们可以借助图5-2中的信息,得到这个地址是.got节的开始,而got中是一个全局函数表。这就说明,这个表中的信息是在程序执行的过程中动态的链接进来的。也就是说,我们在之前重定位等一系列工作中,用到的地址都是虚拟地址,而我们需要的真实的地址信息会在程序执行的过程中用动态链接的方式加入到程序中。当我们每次从PLT表中查看数据的时候,会首先根据PLT表访问GOT表,得到了真实地址之后再进行操作。
图5-11 dl_init前后文件变化
5.8 本章小结
链接的过程,是将原来的只保存了你写的函数的代码与代码用所用的库函数合并的一个过程。在这个过程中链接器会为每个符号、函数等信息重新分配虚拟内存地址,方法就是用每个.o文件中的重定位节与其它的节想配合,算出正确的地址。同时,将你会用到的库函数加载(复制)到可执行文件中。这些信息一同构成了一个完整的计算机可以运行的文件。链接让我们的程序做到了很好的模块化,我们只需要写我们的主要代码,对于读入、IO等操作,可以直接与封装的模块相链接,这样大大的简化了代码的书写难度。
第6章 hello进程管理
6.1 进程的概念与作用
6.1.1进程的概念
进程的经典定义就是一个执行中的程序的实例。
6.1.2进程的作用
通过进程这个概念,我们在运行一个程序的过程中会得到一个假象,就好像我们的程序时系统中当前运行的唯一的程序一样。我们的程序好像是独占的使用处理器和内存。处理器好像就是无间断的一条接一条的执行我们程序中的指令。最后我们程序中的代码和数据好像是系统内存中唯一的对象。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个linux中提供的应用程序,他在操作系统中为用户与内核之间提供了一个交互的界面,用户可以通过这个界面访问操作系统的内核服务。他的处理流程如下:
从界面中读取用户的输入。
将输入的内容转化成对应的参数。
如果是内核命令就直接执行,否则就为其分配新的子进程继续运行。
在运行期间,监控shell界面内是否有键盘输入的命令,如果有需要作出相应的反应
6.3 Hello的fork进程创建过程
首先先来了解一下fork函数的机制。父进程通过调用fork函数创建一个新的子进程。新创建的子进程几乎但不完全与子进程相同。在创建子进程的过程中,内核会将父进程的代码、数据段、堆、共享库以及用户栈这些信息全部复制给子进程,同时子进程还可以读父进程打开的副本。唯一的不同就是他们的PID,这说明,虽然父进程与子进程所用到的信息几乎是完全相同的,但是这两个程序却是相互独立的,各自有自己独有的用户栈等信息。
fork函数虽然只会被调用一次,但是在返回的时候却有两次。在父进程中,fork函数返回子进程的PID;在子进程中,fork函数返回0。这就提供了一种用fork函数的返回值来区分父进程和子进程的方法。
同时fork在使用的过程中,有一个令人比较头疼的问题,就是父进程和子进程是并发执行的所以我们不能够准确的知道那个进程先执行或者先结束。这也就造成了每次执行的输出结果可能是不同的,也是不可预测的。
6.4 Hello的execve过程
execve函数的作用是在当前进程的上下文中加载并运行一个新的程序。与fork函数不同的是,fork函数创建了一个新的进程来运行另一个程序,而execve直接在当前的进程中删除当前进程中现有的虚拟内存段,并穿件一组新的代码、数据、堆和用户栈的段。将栈和堆初始化为0,代码段与数据段初始化为可执行文件中的内容,最后将PC指向_start的地址。在CPU开始引用被映射的虚拟页的时候,内核才会将需要用到的数据从磁盘中放入内存中。
图6-1系统映像
6.5 Hello的进程执行
我们在之前的小节中已经提到过了,当前的CPU中并不是只有我们一个程序在运行,这只是一个假象,实际上有很多进程需要执行。要了解具体是怎样进行的,首先先了解几个概念。
上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由 通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内 核数据结构等对象的值构成。
进程时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
用户模式与内核模式:处理器通常使用一个寄存器提供两种模式的区分,该寄 存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的 代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任 何命令,并且可以访问系统中的任何内存位置。
了解了一些基本概念之后,我们来分析一下hello程序中的具体执行情况。图6-2中展示了hello的代码中会主动引起中断的一个代码。
图6-2 hello文件的部分代码
这段代码中调用了sleep函数, 我们知道这个函数中用到的参数的值为argv[3](即我们输入的秒数),所以这个sleep函数的作用就是当运行到这一句的时候,程序会产生一个中断,内核会将这个进程挂起,然后运行其它程序,当内核中的计时器到了输入的秒数时,会传一个时间中断给CPU,这时候CPU会将之前挂起的进程放到运行队列中继续执行。
假设hello进程在sleep之前一直在顺序执行。在执行到sleep函数的时候,切换到内核模式,将hello进程挂起,然后切换到用户模式执行其它进程。当到了输入的秒数之后,发生一个中断,切换到内核模式,继续运行之前被挂起的进程。最后切换回用户模式,继续运行hello进程。
6.6 hello的异常与信号处理
图6-3 展示了进程运行时输入回车会换行、乱按只会显示按的字符,进程仍然正常运行。
图6-3 输入回车或乱按
图6-4展示了在进程运行的过程中从键盘输入Ctrl+Z命令后的结果。可以看到在执行时,从键盘输入Ctrl+Z命令,shell父进程会收到一个SIGSTP信号,这个信号的功能是将程序挂起并且放到后台。通过ps命令我们可以看到,hello命令并没有结束。接下来我们用fg命令将JID最大的放到前台,也就是刚刚挂起的hello进程,可以看到进程又继续执行。
图6-4 输入Ctrl+Z
输入Ctrl+C操作后的结果。从键盘中输入Ctrl+C后,shell父进程会收到一个SIGINT信号,这个信号的功能是直接将子进程结束。可以在ps中看到,已经没有了hello进程。说明Ctrl+C会直接将进程结束并回收掉。
图6-5 输入Ctrl+C
6.7本章小结
本章中阐述了进程的概念以及它在计算机中具体是如何在使用的。其次,还介绍了如何利用shell这个平台来对进程进行监理调用或发送信号等一系列操作。
在hello的运行过程中,当接受到不同的异常信号时,异常处理程序将对异常信号做出相应,执行相应的代码,每种信号都有不同的处理机制,对不同的异常信号,hello也有不同的处理结果。
第7章 hello的存储管理
7.1 hello的存储器地址空间
逻辑地址:又称相对地址,是程序运行由CPU产生的与段相关的偏移地址部分。他是描述一个程序运行段的地址。
物理地址:程序运行时加载到内存地址寄存器中的地址,内存单元的真正地址。他是在前端总线上传输的而且是唯一的。在hello程序中,他就表示了这个程序运行时的一条确切的指令在内存地址上的具体哪一块进行执行。
线性地址:是经过段机制转化之后用于描述程序分页信息的地址。他是对程序运行区块的一个抽象映射。
虚拟地址:其实虚拟地址跟线性地址是一个东西,都是对程序运行区块的相对映射。
就hello而言,他是在物理地址上运行的,但是对于CPU而言,CPU看到的hello运行的地址是逻辑地址,在具体操作的过程中,CPU会将逻辑地址转换成线性地址再变成物理地址。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部份组成,段标识符: 段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号。后面3位包含一些硬件细节,如图7-1所示:
图7-1 段选择符
索引号是“段描述符(segment descriptor)”的索引,很多个段描述符,就组了一个数组,叫“段描述符表”,这样,可以通过段标识符的前13位,直接在段描述符表中找到一个具体的段描述符,这个描述符就描述了一个段,每一个段描述符由8个字节组成,如图7-2:
图7-2 段选择符
其中Base字段,它描述了一个段的开始位置的线性地址,一些全局的段描述符,就放在“全局段描述符表(GDT)”中,一些局部的,例如每个进程自己的,就放在所谓的“局部段描述符表(LDT)”中,由段选择符中的T1字段表示选择使用哪个,=0,表示用GDT=1表示用LDT。GDT在内存中的地址和大小存放在CPU的gdtr控制寄存器中,而LDT则在ldtr寄存器中。如下图:
图7-3 概念关系说明
具体的转换步骤如下:
给定一个完整的逻辑地址[段选择符:段内偏移地址。
看段选择符的T1=0还是1,知道当前要转换是GDT中的段,还是LDT中的段,再根据相应寄存器,得到其地址和大小。可以得到一个数组。
取出段选择符中前13位,在数组中查找到对应的段描述符,得到Base,也就是基地址。
线性地址 = Base + offset。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址到物理地址的转换是通过页的这个概念完成的。线性地址被分为以固定长度为单位的组,称为页。
首先 Linux 系统有自己的虚拟内存系统,其虚拟内存组织形式如图 7-4所示,Linux 将虚拟内存组织成一些段的集合,段之外的虚拟内存不存在因此不需要记录。内核为hello进程维护一个段的任务结构即图中的task_struct,其中条目mm指向一个mm_struct,它描述了虚拟内存的当前状态,pgd 指向第一级页表的基地址(结合一个进程一串页表),mmap指向一个vm_area_struct 的链表,一个链表条目对应一个段,所以链表相连指出了 hello 进程虚拟内存中的所有段。
图7-4 Linux是如何组织虚拟内存的
CPU芯片上有一个专门的硬件叫做内存管理单元(MMU),这个硬件的功能就是动态的将虚拟地址翻译成物理地址的。这个表示如何工作的呢,如图7-5所示。N为的虚拟地址包含两个部分,一个p位的虚拟页面偏移(VPO)和一个(n-p)位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE(页表条目)。接下来在对应的PTE中获得PPN(物理页号),将PPN与VPO串联起来,就得到了相应的物理地址。
图7-5 使用页表的地址翻译
7.4 TLB与四级页表支持下的VA到PA的变换
首先来介绍一下TLB具体是什么东西。我们注意到,每次在进行虚拟地址翻译的过程中都会有访问PTE的操作,如果在比较极端的情况下,就会存在访存的操作,这样的效率是很低的。TLB的运用,就可以将PTE上的数据缓存在L1中,也就是TLB这样一个专用的部件,他会将不同组中的PTE缓存在不同的位置,提高地址翻译的效率。
其次我们来介绍一下多级页表的概念。在前面我们了解了一级页表是如何进行工作的。可以发现一级页表有一个弊端,就是对于每一个程序,内核都会给他分配一个固定大小的页表,这样有一些比较小的程序会用不到开出的页表的一些部分,就造成了空间的浪费,多级页表就很好的解决了这个问题。以二级页表为例,首先我们先开一个比较小的一级页表,我们将完整的页表分组,分别对应到开出来的一节页表的一个PTE中,在执行程序的过程中,如果我们用到了一个特定的页表,那么我们就在一级页表后面动态的开出来,如果没用到就不开,这样就大大的节省了空间。
知道了上述概念之后,我们就来看一下虚拟地址是如何在四级页表中转换的。如图 7-6,CPU产生虚拟地址VA,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 中添加条目。
图7-6 四级页表下的地址翻译情况
7.5 三级Cache支持下的物理内存访问
在上一节中我们已经获得了物理地址VA,我们接着图7-6的右侧部分进行说明。使用CI(后六位再后六位)进行组索引,每组8路,对8路的块分别匹配 CT(前40位)如果匹配成功且块的valid标志位为1,则命中(hit),根据数据偏移量CO(后六位)取出数据返回。 如果没有匹配成功或者匹配成功但是标志位是 1,则不命中(miss),向下一级缓存中查询数据(L2 Cache->L3 Cache->主存)。查询到数据之后,一种简单的放置策略如下:如果映射到的组内有空闲块,则直接放置,否则组内都是有效块,产生冲突(evict),则采用最近最少使用策略 LFU 进行替换。
7.6 hello进程fork时的内存映射
在7.3节中我们已经提到过了mm_struct和vm_area_struct这两个标记符,这里我们就需要用到他们。先来介绍一下:
mm_struct(内存描述符):描述了一个进程的整个虚拟内存空间。
vm_area_struct(区域结构描述符):描述了进程的虚拟内存空间的一个区间。
在用fork创建内存的时候,我们需要以下三个步骤:
创建当前进程的mm_struct,vm_area_struct和页表的原样副本。
两个进程的每个页面都标记为只读页面。
两个进程的每个vm_area_struct都标记为私有,这样就只能在写入时复制。
7.7 hello进程execve时的内存映射
execve函数在shell中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效的替代了当前程序。加载并运行hello需要以下几个步骤:
删除已存在的用户区域。删除shell虚拟地址的用户部分中的已存在的区域结构。
映射私有区域。为hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello 文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。图7.7 概括了私有区域的不同映射。
映射共享区域。如果hello程序与共享对象(或目标)链接,比如标准C 库libc. so, 那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
设置程序计数器(PC) 。execve 做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
7.8 缺页故障与缺页中断处理
缺页现象的发生是由于页表只相当于磁盘的一个缓存,所以不可能保存磁盘中全部的信息,对于有些信息的查询就会出现查询失败的情况,也就是缺页。
对于一个访问虚拟内存的指令来说,如果发生了缺页现象,CPU就会触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,例如图7-7中存放在PP3中的VP4,如果VP4已经被更改,那就先将他存回到磁盘中。
找到了要存储的页后,内核会从磁盘中将需要访问的内存,例如图7-7所示的VP3放入到之前已经操作过的PP3中,并且将PTE中的信息更新,这样就成功的将一个物理地址缓存在了页表中。当异常处理返回的时候,CPU会重新执行访问虚拟内存的操作,这个时候就可以正常的访问,不会发生缺页现象了。
图7-7 缺页现象
7.9动态存储分配管理
7.9.1动态内存分配器的基本原理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为 一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已 分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用 来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已 分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分 配器自身隐式执行的。 分配器分为两种基本风格:显式分配器、隐式分配器。
显式分配器:要求应用显式地释放任何已分配的块。例如C程序中的malloc和free。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么 就释放这个块, 自动释放未使用的已经分配的块的过程叫做垃圾收集。 例如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的内存。
7.9.2隐式空闲链表分配器原理
隐式空闲链表有两种形式,我们分别来介绍一下。
图7-8展示的是第一种形式。首先说明一下每个部分的意义。头部一共四个字节,前三个字节存储的是块的大小,最后一个字节存储的是当前这个块是空闲块还是已分配的块,0代表空闲块,1代表已分配的块。中间的有效载荷就是用于存放已分配的块中的信息用的。最后的填充部分是为了地址对齐等一些要求用的。
图7-8 一个简单的堆块格式
既然是链表,隐式链表的结构就是根据地址从小到大进行连接的,如图7-9。其中的每一个元素表示的是一个空闲块或者一个分配块,由于空闲块会合并我的特性,链表中的元素的连接一定是空闲块的分配块交替连接的。
至于空闲块是如何进行合并的,因为有了 Footer,所以我们可以方便的对前面的空闲块进行合并。合并的 情况一共分为四种:前空后不空,前不空后空,前后都空,前后都不空。对于四 种情况分别进行空闲块合并,我们只需要通过改变 Header 和 Footer 中的值就可以 完成这一操作。
图7-9 隐式空闲链表结构
图7-10是隐式的另一种结构,可以看到与上面的不同的是,只一种结构在最后多了一个与头部相同的结构,这个结构叫做脚部。这个新的结构的作用就是为了在空闲块合并的时候比较方便高效。因为如果利用之前的结构,在合并前面的空闲块的时候,由于我们不知道前面的块的大小,所以我们不能获得前面块的起始位置,这样就只能从链表的开始来找一遍。有了脚部,我们就可以利用脚部中存储的信息来获得前一个块中的地址。
图7-10 使用边界标记的堆块格式
7.9.3显式空闲链表基本原理
图7-11是显示空闲链表的格式,可以看到,与隐式结构不同的是,显示结构在空闲块中增加了8个字节,分别保存当前空闲块的前驱空闲块的地址和后继空闲块的地址。也就是说,显式的结构比隐式结构多维护了一个链表,就是空闲块的链表。这样做的好处就是在我们在malloc的时候,隐式的方法是要遍历所有的块,包括空闲块了分配块。但是显式的结构只需要在空闲块中维护的链表检索就可以了,这样降低了在malloc时候的复杂度。
关于空闲块的维护方式一共有两种,一种是后进先出的方式,另一种是按照地址的方式。按照地址维护很好理解,与隐式的结构大致相同。后进先出的方式的思想是,当一个分配块被free之后,将这个块放到链表的最开头,这样在malloc的时候会首先看一下最后被free的块是否符合要求。这样的好处是释放一个块的时候比较高效,直接放在头部就可以。
图7-11 使用双向空闲链表的堆块格式
7.10本章小结
本章介绍了储存器的地址空间,讲述了虚拟地址、物理地址、线性地址、逻辑地址的概念,还有进程fork和execve时的内存映射的内容。描述了系统如何应对那些缺页异常,最后描述了malloc的内存分配管理机制(C语言为例)。可以看到真正高效的运行起一个程序来是很复杂的。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行,这就是Unix I/O接口。
8.2 简述Unix IO接口及其函数
Unix I/O 接口统一操作:
打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。
Shell创建的每个进程都有三个打开的文件:标准输入,标准输出,标准错误。
改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
读写文件:一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n,给定一个大小为m字节的而文件,当k>=m时,触发EOF。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
关闭文件,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中去。
Unix I/O 函数:
int open(char* filename,int flags,mode_t mode)
这个函数会打开一个已经存在的文件或者创建一个新的文件。
int close(fd)
这个函数会关闭一个打开的文件。
ssize_t read(int fd,void *buf,size_t n)
这个函数会从当前文件位置复制字节到内存位置。
ssize_t wirte(int fd,const void *buf,size_t n)
这个函数从内存复制字节到当前文件位置。
8.3 printf的实现分析
printf需要做的事情是:接受一fmt的格式,然后将匹配到的参数按照fmt格式输出。图8-1是printf的代码,我们可以发现,他调用了两个外部函数,一个是vsprintf,还有一个是write。
图8-1 printf函数的代码
从图8-2中的vsprintf函数可以看出,这个函数的作用是将所有的参数内容格式化后存入buf,然后返回格式化数组的长度。
write函数是将buf中的i个元素写到终端的函数。
Printf的运行过程:
从vsprintf生成显示信息,显示信息传送到write系统函数,write函数再陷阱-系统调用 int 0x80或syscall.字符显示驱动子程序。从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
图 8-2 vsprintf函数
8.4 getchar的实现分析
异步异常-键盘中断的处理:当用户按键时,键盘接口会得到一个代表该按键 的键盘扫描码,同时产生一个中断请求,中断请求抢占当前进程运行键盘中断子 程序,键盘中断子程序先从键盘接口取得该按键的扫描码,然后将该按键扫描码 转换成ASCII码,保存到系统的键盘缓冲区之中。
图8-3中展示了getchar的代码,可以看出,这里面的getchar调用了一个read函数,这个read数是将整个缓冲区都读到了buf里面,然后将返回值是缓冲区的长度。我们可以发现,如果buf长度为0,getchar才会调用read函数,否则是直接将保存的buf中的最前面的元素返回。
图8-3 getchar函数代码
8.5本章小结
本章节讲述了一下linux的I/O设备管理机制,了解了开、关、读、写、转移文件的接口及相关函数,简单分析了printf和getchar函数的实现方法以及操作过程。
结论
hello程序终于走完了他一生的过程,然我们来回顾一下他从一个.c文件是怎样一步一步的变成可以输出我们想看到的结果的程序:
我们首先通过各种各样的文本编辑器,将我们用高级语言编写的程序存到了hello.c文件中。
预处理器将hello.c文件经过初步的修改变成了hello.i文件。接着编译器将hello.i文件处理成为了汇编代码并保存在了hello.s文件中。然后汇编器将hello.s文件处理成了可重定位的目标程序,也就是hello.o文件,这个时候,我们的程序离可以运行就只差一步了。最后链接器将我们的hello.o与外部文件进行链接,终于我们得到了可以跑起来的hello文件了。
当我们在shell中输入运行hello文件的命令的时候,内核会为我们分配好运行程序所需要的堆、用户栈、虚拟内存等一系列信息。方便我们的hello程序能够正常的运行。
当我们需要从外部对hello程序进行操控的时候,我们只需要在键盘上给一个相应的信号,他就会按照我们的指令来执行。
当我们的hello需要访问磁盘中的信息的时候,这时候CPU看到了他找不到的地址VA,他利用自己的工具MMU将他翻译成了可以看懂的地址。
最后当我们的hello执行完所有工作之后,他也就结束了字节的一生,最后被shell回收掉了。
通过这次大作业,我更加全面系统的了解了这门课程,对书中的知识有了更加全面的认识。同时感受到了计算机系统的复杂性以及严密性。我们一个程序的成功运行需要多少计算机硬件和软件的共同配合。
附件
hello.c 源程序
hello.i 修改了的源程序
hello.s 汇编程序
hello.o 可重定位目标程序
hello 可执行目标程序
参考文献
[1] Bryant,R.E. 深入理解计算机系统