本文主要探讨了一个入门级的C语言程序hello.c在linux系统下的执行过程。文章首先阐述了hello.c从C语言程序经过预处理、编译、汇编、链接生成可执行目标文件的流程。接着,结合进程管理、存储管理以及IO管理的理论,详细描述了hello程序在计算机系统中如何被运行并顺利输出信息。通过本次系列学习,对于初学者来说,可帮助我更好地理解计算机系统各个环节的配合,并为今后深入学习与研究提供了基础。
关键词:C语言程序、hello.c、linux系统、预处理、编译、汇编、链接、进程管理、存储管理、IO管理、计算机系统;
目 录
第1章 概述
1.1 Hello简介
Hello程序的P2P过程指从源代码hello.c经过预处理、编译、汇编、链接生成可执行目标文件,再由操作系统进行进程管理、存储管理、IO管理等一系列操作,最终在计算机硬件上运行并输出信息的完整过程。Hello程序被操作系统fork为子进程,并通过execve载入内存并映射地址空间。存储管理由操作系统的MMU负责将虚拟地址转换成物理地址,同时各种缓存技术如TLB和Cache加速访问。IO管理与信号处理使得程序能够与键盘、主板、显卡、屏幕等设备交互。整个过程实现了从源代码到可执行程序再到进程运行的有机衔接。
1.2 环境与工具
硬件环境:AMD Ryzen 7 5800H; RAM 32G; NVIDIA GeForce RTX3050 Ti Laptop GPU。
软件环境:Windows 10; VirtualBox; Ubuntu-20.04.4。
开发与调试工具:gcc; vim; edb; readelf; HexEdit
1.3 中间结果
文件名 | 作用 |
Hello.c | hello程序的源代码 |
Hello.i | 源代码经过预处理后的结果 |
Hello.s | 编译后生成的汇编程序 |
Hello.o | 汇编后生成的可重定位文件 |
hello | 链接之后生成的可执行文件 |
Hello.out | 添加相关编译参数的可执行文件 |
hello_o_elf.txt | 查看hello.o的elf格式对应的文本文件 |
hello_o_asm.txt | 查看hello.o的反汇编对应的文本文件 |
hello_elf.txt | 查看hello的elf格式对应的文本文件 |
hello_asm.txt | 查看hello的反汇编对应的文本文件 |
1.4 本章小结
本章介绍了Hello程序的P2P过程,包括从源代码到可执行程序再到进程运行的完整过程,以及涉及到的操作系统功能和存储管理等技术。同时还介绍了开发和调试过程中使用的环境和工具,以及生成的中间结果文件的作用。
第2章 预处理
2.1 预处理的概念与作用
预处理是编译过程中的一部分,预处理器会解析源代码文件中以#开头的行,并试图将其解释为预处理指令,包括条件编译指令、宏定义、源文件包含指令、行控制指令、错误指令以及指定编译器功能指令。执行完所有的预处理指令后,预处理器会生成一个.i文件。预处理的作用主要是为编译器提供合法的源代码文件,使其能够更好地理解源代码并生成正确的汇编代码。同时,预处理还可以通过宏定义和头文件包含等方式实现代码重用和简化代码编写。
2.2在Ubuntu下预处理的命令
如图,在Ubuntu的终端中进入hello.c所在文件夹并输入gcc -E hello.c -o hello.i
即可获得hello.i文件如下图。
2.3 Hello的预处理结果解析
对比两个文件的内容我们可以看到,预处理器完成了如下过程:首先,预处理器将stdio.h、unistd.h和stdlib.h中的内容读入到了hello.i中,其中包括一些类型的声明定义和一些函数的声明。然后预处理器将所有用#define定义的宏替换为了对应的值并且删除了注释内容。
2.4 本章小结
第2章介绍了预处理的概念及作用,以及在Ubuntu下进行预处理的命令。同时,详细解析了Hello程序进行预处理后生成的Hello.i文件,包括去除注释、宏替换、头文件包含等过程,并举例说明了头文件中常用的内容和格式。本章重点介绍了预处理的作用,即为编译器提供合法的源代码文件,使得编译器能够更好地理解源代码并生成正确的汇编代码。预处理过程还可以通过宏定义和头文件包含等方式实现代码重用和简化代码编写。
第3章 编译
3.1 编译的概念与作用
编译的概念:编译器将目标源程序hello.i翻译成汇编语言程序hello.s。
编译的作用:为不同的源程序语言提供了统一的汇编语言输出,便于机器以相同的标准执行。
3.2 在Ubuntu下编译的命令
如图,在Ubuntu的终端中进入hello.c所在文件夹并输入gcc -S hello.i -o hello.s
即可获得hello.o文件如下图。
3.3 Hello的编译结果解析
3.3.1 数据
1. 数字常量
在hello.c中出现的数字常量在hello.s中都有相应的对应。编译器将数字常量以立即数的形式进行处理。
将整型数argc与4比较,数字常量4在hello.s中以立即数$4的形式出现。
在循环的判断条件中出现数字常量,用立即数表示。注意到由于循环的判断条件为i<5,编译器将其翻译为小于等于立即数$4。
2. 字符串常量
在hello.c文件中,有两个字符串,如下图所示:
编译器对这两句字符串进行处理时,将这两个字符串放入内存中的 .rodata节常量区中,如下图所示:
打印字符串常量时,编译器将语句翻译为先将字符串存放的地址存入寄存器%edi,再进行打印,具体过程如下图所示:
3. 局部变量
编译器一般将局部变量存放在寄存器中或者栈中,存在寄存器中时可以看作是寄存器别名,节省从内存中读取数据的时间。
传入参数int argc存放在寄存器%edi中。
传入参数char *argv[]存放在栈中,其中首地址存放在栈中-32(%rbp)的位置,argv[1]地址为-32(%rbp)+$8,argv[2]地址为-32(%rbp)+$16,argv[3]地址为-32(%rbp)+$24。在hello.s中的使用如下图所示:
局部变量int argc存放在栈中-20(%rbp)的位置。
局部变量int i存放在栈中-4(%rbp)的位置。
3.3.2 赋值
编译器将赋值的操作主编为对相应的寄存器或栈进行赋值。
在hello. c文件中,将i的赋为0
经过编译器cc1的编译,在hello.s文件中,该语句转变为:
在3.3.1中,已经知道栈中-4(%rbp)的位置存放的是局部变量i,因此是将i赋值为0。
3.3.3 类型转换
Hello.c文件中, argv[3]的类型为字符型,经过函数atoi()转换为整型。在hello.c文件中的实现如下图所示:
经过编译器编译后,步骤变为首先将argv[3]从栈中取出,赋值给%rdi,通过调用call atoi指令调用atoi函数,最终转换为整型数,存放在%eax中。
3.3.4 算术操作
Hello.c文件中,算术运算有for循环中的i++经过编译器后,被翻译为:
在3.3.1中,已经知道栈中-4(%rbp)的位置存放的是局部变量i,每次执行这条指令,实现的是i自增1。
3.3.5 关系操作
对于关系操作,编译器一般会将关系操作翻译为cmp语句。在源文件hello.c中,有两处关系操作。
经过编译,在hello.s文件中,该关系比较实现为:
在3.3.1中,已经知道栈中-20(%rbp)的位置存放的是argc,因此这条指令就是在判断4与argc的关系。
比较i与4的大小,i >= 4时跳出循环。经过编译,在hello.s文件中,该关系比较实现为:
在3.3.1中,已经知道栈中-4(%rbp)的位置存放的是i,并且这里将i<5替换为判断i<=4,意义与原C语言程序相同。
3.3.6 数组/指针/结构操作
编译器对源代码中数组的操作往往翻译为对地址的加减操作,在hello.c的源代码中,存在对数组argv[]的访问:
经过编译器的翻译,在hello.s的文件中,对数组argv的访问变为:
其中首地址存放在栈中-32(%rbp)的位置,argv[1]地址为-32(%rbp)+$8,argv[2]地址为-32(%rbp)+$16,argv[3]地址为-32(%rbp)+$24即进行了地址的加减操作以访问数组。
3.3.7 控制转移
控制转移是指C语言源文件中的选择分支、循环结构等经过编译器的翻译,产生一些跳转的语句,编译器cc1编译后的文件hello.s中,控制转移有三处:
比较argc是否等于4,如果相等,跳转.L2,否则顺序执行。
无条件跳转,将i初始化为0后,无条件跳转至循环中。
比较i是否小于等于4(小于5),如果小于等于4,则跳转至.L4,即满足循环条件,继续循环;如果大于7,则顺序执行,跳出循环。
3.3.8 函数调用
函数调用一般会进行参数传递和返回值。在hello.s中,一共有六次函数调用。
调用puts()函数。首先将调用函数所需要的参数,即需要打印的字符串常量的地址存放在寄存器%rdi中,然后执行call puts@PLT指令打印字符串。对应的C语言源程序如下:
可以看到,在C语言源程序中执行该命令用的是printf函数,由于打印的是一个单纯的字符串,因此编译器对它进行了优化,改用puts函数进行打印。相应的hello.s文件中的指令如下:
调用exit()函数,参数为1。对应的C语言源程序如下图所示:
在hello.s中,首先进行参数的准备。将立即数1放入寄存器%edi中,然后执行call exit指令调用exit函数。
调用含其他参数的printf()函数。对应的C语言源程序如下图所示:
在hello.s中,首先进行参数的准备。将argv[2]放入寄存器%rdx中,将argv[1]放入寄存器%rsi中,将字符串"Hello %s %s\n"放入寄存器%rdi中,然后执行call printf指令进行打印。
调用atoi()函数,参数为argv[3],以下是C语言原程序中的函数调用:
在hello.s中,首先进行参数的准备。将argv[3]放入寄存器%rdi中,然后执行call atoi@PLT指令调用atoi函数。
调用sleep()函数,参数为atoi(argv[3])的返回值,以下是C语言原程序中的函数调用:
在hello.s中,首先进行参数的准备。将atoi(argv[3])的返回值放入寄存器%edi中,然后执行call sleep指令调用sleep函数。
调用getchar()函数,没有参数,以下是C语言原程序中的函数调用:
在hello.s中,直接执行call getchar指令进行函数的调用。
3.4 本章小结
编译是将高级语言代码翻译成计算机可执行的机器码的过程。本文主要讲解了在Ubuntu下通过gcc编译器将C语言源程序hello.c编译成可执行文件hello.o的过程,并对编译器对程序中各种语句的翻译进行了详细的解析。
在编译器对源程序进行编译时,数字常量和字符串常量会被处理成立即数和放入常量区,局部变量会存在寄存器或栈中,传入函数的参数会存在特定的寄存器或栈中。赋值、算术运算、关系操作、数组/指针/结构操作等操作也都有相应的汇编实现。控制转移是C语言程序中常见的跳转语句,编译器一般使用cmp指令来进行比较操作,并通过je、jne、jg等指令来实现跳转。在函数调用时,编译器会进行参数传递和返回值处理,调用puts、exit、printf、atoi、sleep和getchar等函数都有对应的汇编指令。
总之,编译器可以将高级语言的代码翻译成底层机器码并生成可执行文件,从而方便计算机执行高级语言程序,提高程序的执行效率。
第4章 汇编
4.1 汇编的概念与作用
概念:将.s文件翻译成机器语言指令,并将这些指令打包成可重定位目标程序的格式.将结果保存到.o文件里面
作用:将给人看的汇编文本文件,翻译成给机器看的二进制代码
4.2 在Ubuntu下汇编的命令
如图,在Ubuntu的终端中进入hello.c所在文件夹并输as hello.s -o hello.o
用readelf等列出其各节的基本信息,也可以用以下的语句将输出内容保存为文本文件再进行查看:readelf -a hello.o > hello_o_elf.txt
4.3 可重定位目标elf格式
4.3.1 ELF头
hello.o的ELF格式的开头是ELF头,它以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息:包括ELF头的大小、目标文件的类型、处理器体系结构、节头部表的文件偏移,以及节头部表中条目的大小和数量。
4.3.2 节头部表
ELF文件格式中的节头部表描述了目标文件中不同节的类型、地址、大小、偏移等信息,以及可以对各部分进行的操作权限。
4.3.3 重定位节
ELF文件格式中的重定位节包含两个部分:.rela.text节与.rela.eh_frame节。
.rela.text节包含.text节中的位置的列表,含有该.text中所需要进行重定位操作的信息,当链接器(ld)将目标文件与其他文件由进行结合时,需要修改这些位置
.rela.eh_frame节包含了对en_frame节的重定位信息。
4.3.3 符号表
ELF文件格式中的符号表中存放了程序中所定义和引用的的全局变量以及函数的信息。(不包含局部变量)
4.4 Hello.o的结果解析
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照分析。
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
输入以下命令查看hello.o对应的反汇编代码:
objdump -d -r hello.o
也可以用以下的语句将输出内容保存为文本文件再进行查看:
objdump -d -r hello.o > hello_o_asm.txt
得到反汇编代码如下:
可以看见hello.o对应的反汇编代码每一句都有一串相应的地址与二进制的指令,以十六进制的数值展示出来。
对比hello.o的反汇编代码,与hello.s的内容进行比较,发现以下几点的不同:
4.4.1 数字进制不同
hello.s中操作数都是十进制的,而在反汇编代码中以十六进制表示,这对应着在机器中以二进制的形式存在。
4.4.2 对字符串常量的引用不同
hello.s中是用的全局变量所在的那一段的名称加上%rip的值,而hello.o中用的是0加%rip的值,因为当前为可重定位目标文件,之后还需经过重定位方可确定其具体位置,所以这里都用0来代替。
4.4.3 分支转移的不同
hello.s中列出了每个段的段名,分支转移时,跳转指令后用对应的段的名称表示跳转位置;而在hello.o的反汇编代码中每个段都有明确的地址,跳转指令后用相应的地址表示跳转位置。
4.4.4 函数调用的不同
在hello.s中调用函数时,在call指令之后直接引用函数名称,而在hello.o的反汇编代码中,在call指令后加上下一条指令的地址来表示。观察机器语言,发现其中操作数都为0,这是因为通过重定位信息,再链接生成可执行文件后才会生成其确定的地址,所以这里的相对地址都用0代替。
4.5 本章小结
汇编语言是机器语言的助记符,可以将人类易于理解和编写的汇编代码翻译成机器可执行的二进制指令。在Ubuntu下,使用as命令可以将汇编代码翻译成目标文件,格式为ELF(可重定位目标)格式,包括ELF头、节头部表、重定位节和符号表等。
ELF头包含了生成该文件的系统的字的大小和字节顺序,剩下的部分包含链接器所需的信息;节头部表则描述了目标文件中不同节的类型、地址、大小、偏移等信息,以及可以对各部分进行的操作权限;重定位节包含了对需要进行重定位操作的位置的列表,符号表中存放了程序中所定义和引用的的全局变量以及函数的信息。
使用objdump命令可以查看hello.o的反汇编代码,其中每个指令都有一串地址和对应的二进制指令,以十六进制的数值展示出来。与hello.s进行对比分析,可以发现数字进制不同、对字符串常量的引用不同、分支转移和函数调用的表示方式不同等。
总之,汇编语言是理解计算机底层运行原理的重要工具,其翻译的目标文件也为程序的链接与执行提供了必要的信息。
第5章 链接
5.1 链接的概念与作用
链接是指将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可以被加载(复制)到内存并执行。链接可以执行于编译时,也就是在源代码被翻译成机器代码时;也可以执行于加载时,也就是在程序被加载器加载到内存并执行时;甚至执行于运行时,也就是由应用程序来执行。
5.1.2 链接的作用
链接的作用是将预编译好了的一个目标文件(hello.o)或若干目标文件外加链接库合并成为一个可执行目标文件(hello)。使得分离编译称为可能,不用将一个大型的应用程序组织为一个巨大的源文件,而是可以把它分解为可独立修改和编译的模块。当改变这些模块中的一个时,只需简单重新编译它并重新链接即可,不必重新编译其他文件。
5.2 在Ubuntu下链接的命令
在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.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
使用readelf进行分析,输入:
readelf -a hello
或:
readelf -a hello > hello_elf.txt
进行对ELF文件进行查看:
5.4 hello的虚拟地址空间
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
首先用edb加载hello,在终端输入如下命令:edb --run hello 完成加载。
通过readelf查看hello的程序头,可以发现其中的虚拟地址在edb的Data Dump中都能找到相应的位置,且大小也相对应。
5.5 链接的重定位过程分析
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
首先查看hello的反汇编代码,在终端中输入如下指令即可查看:
objdump -d -r hello
也可以用以下的语句将输出内容保存为文本文件再进行查看:
objdump -d -r hello > hello_asm.txt
将hello.o与hello文件的反汇编代码进行对比分析,得到如下几方面的不同。
- 代码量增加
- 插入了C标准库中的代码
在hello.o的反汇编程序中,只有main函数,没有调用的函数段;经过链接过程后,原来调用的C标准库中的代码都被插入了代码中,并且每个函数都被分配了各自的虚拟地址。
- 指令分配虚拟地址
在hello.o的反汇编程序中, main函数中的所有语句前面的地址都是从main函数开始从0开始依次递增的,而不是虚拟地址;经过链接后,每一条语句都被分配了虚拟地址。
- 字符串常量的引用
在hello.o的反汇编程序中,字符串常量的位置是用0加%rip的值来表示的,这是由于当时字符串常量并未分配虚拟内存;而在hello的反汇编程序中,因为字符串常量都有了相应的位置,所以用实际的相对下一条语句的偏移量加%rip(下一条语句的地址)的值来描述其位置。
- 函数调用
在hello.o的反汇编程序中,字符串常量的位置是用0加%rip的值来表示的,这是由于当时字符串常量并未分配虚拟内存;而在hello的反汇编程序中,因为字符串常量都有了相应的位置,所以用实际的相对下一条语句的偏移量加%rip(下一条语句的地址)的值来描述其位置。
- 跳转指令
在hello.o的反汇编程序中,对于跳转指令,在其后加上目的地址,为main从0开始对每条指令分配的地址;而在hello的反汇编程序中,由于各语句拥有了各自的虚拟地址,所以同样加上目的地址,但这里是每条指令的虚拟地址。
- 链接的过程分析
链接的过程主要分为符号解析和重定位这两个过程。
1)符号解析:符号解析解析目标文件定义和引用符号,并将每个符号引用和一个符号定义相关联。
2)重定位:编译器和汇编器生成从0开始的代码和数据节。而链接器通过把每个符号定义与一个虚拟内存地址相关联,从而将这些代码和数据节重定位,然后链接器会修改对所有这些符号的引用,使得它们指向这个虚拟内存地址。
对于hello来说,链接器把hello中的符号定义都与一个虚拟内存位置关相关联,重定位了这些节,并在之后对符号的引用中把它们指向重定位后的地址。hello中每条指令都对应了一个虚拟地址,而且对每个函数,全局变量也都它关联到了一个虚拟地址,在函数调用,全局变量的引用,以及跳转等操作时都通过虚拟地址来进行,从而执行这些指令。
5.6 hello的执行流程
(以下格式自行编排,编辑时删除)
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
从加载hello到_start,到call main,以及程序终止的所有过程如下:
_dl_start 地址:0x7f894f9badf0
_dl_init 地址:0x0x7f894f9cac10
_start 地址:0x401090
_libc_start_main 地址:0x7fce59403ab0
_cxa_atexit 地址:0x7f38b81b9430
_libc_csu_init 地址:0x4005c0
_setjmp 地址:0x7f38b81b4c10
_sigsetjmp 地址:0x7efd8eb79b70
_sigjmp_save 地址:0x7efd8eb79bd0
main 地址:0x401176
(argc!=4时
puts 地址:0x401030
exit 地址:0x401070
此时输出窗口打印出“用法: Hello 学号 姓名 秒数!”,程序终止。)
print 地址:0x401040
sleep 地址:0x401080 (以上两个在循环体中执行5次)
此时窗口打印5行“Hello 2021111125 刘博”
getchar 地址:0x4004d0
等待用户输入回车,输入回车后:
_dl_runtime_resolve_xsave 地址:0x7f5852241680
_dl_fixup 地址:0x7f5852239df0
_uflow 地址:0x7f593a9a10d0
exit 地址:0x7f889f672120
程序终止。
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
当程序调用一个共享库的函数时,编译器不能预测这个函数在什么地址,因为定义它的共享模块在运行时可以加载到任何位置。这时,编译系统提供了延迟绑定的方法,即:将过程地址的加载推迟到第一次调用该过程时。通过观察edb对hello的执行情况,便可发现dl_init前后后.got.plt节发生的变化。
首先,通过readelf找到.got.plt节在地址为0x404000的地方开始,大小为0x48。因此,结束地址为0x40400047,这两个地址之间部分便是.got.plt的内容。
在edb中的Data Dump中找到这个地址,观察.got.plt节的,发现在dl_init前后,.got.plt的第8到15个字节发生了变化。
在这里,这些变化的字节分别对应GOT[1]和GOT[2]的位置。其中, GOT[1]包括动态链接器在解析函数地址时使用的信息,而GOT[2]则是动态链接器ld-linux.so模块中的入口点。加载时,动态链接器将重定位GOT中的这些条目,使它们包含正确的地址。内存的变化如下图所示:
5.8 本章小结
本章主要介绍了链接的相关概念和作用,Ubuntu下的链接命令,可执行目标文件的格式,以及涉及到的一些概念,例如重定位和符号解析等。在分析了hello程序的ELF格式、虚拟地址空间和重定位过程后,我们通过使用edb调试工具来分析hello的动态链接。最后,我们总结了静态链接和动态链接的区别。
第6章 hello进程管理
6.1 进程的概念与作用
进程就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器地内容、程序计数器、环境变量以及打开文件描述符的集合。
进程提供独立的逻辑控制流,好像我们的程序独占地使用处理器;也提供一个私有的地址空间,好像我们的程序独占地使用内存系统;使CPU被科学有效地划分成多个部分以并行地运行多个进程。
6.2 简述壳Shell-bash的作用与处理流程
6.2.1 壳Shell-bash的作用
Shell为用户提供命令行界面,使用户可以在这个界面中输入shell命令,然后shell执行一系列的读/求值步骤,读步骤读取用户的输入的命令行,求值步骤则解析命令行,并运行程序。完成后重复上述步骤,直到用户退出shell。从而完成用户与计算机的交互来操作计算机。
6.2.2 壳Shell-bash的处理流程
Shell打印一个命令行提示符,等待用户输入指令。在用户输入指令后,从终端读取该命令并进行解析,若该命令为shell的内置命令,则立即执行该命令;若不是内置命令,是一个可执行目标文件,则shell创建会通过fork创建一个子进程,并通过execve加载并运行该可执行目标文件,用waitpid命令等待执行结束后对其进行回收,从内核中将其删除;若将该文件转到后台运行,则shell返回到循环的顶部,等待下一个命令行。完成上述过程后,shell重复上述过程,直到用户退出shell。
6.3 Hello的fork进程创建过程
Fork过程:
父进程通过调用fork()函数可以创建一个新的运行的子进程。
调用fork()函数后,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程虚拟地址空间相同的但独立的一份副本,包括代码、数据段、堆、共享库以及用户栈,子进程获得与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork()函数时,子进程可以读写父进程中打开的任何文件。
子进程有不同于父进程的PID,fork()被调用一次,返回两次。子进程返回0,父进程返回子进程的PID。
Hello进行fork进程创建,需要在shell中输入如下命令:
./hello 2021111125 刘博 2
这时在shell中就会调用fork()函数为hello创建一个shell的子进程。
6.4 Hello的execve过程
execeve过程:在shell给hello进行fork()函数创建子进程之后,会调用execve函数,在进程的上下文中加载并运行hello,调用_start创建新的且被初始化为0的栈等,随后将控制给主函数main,并传入参数列表和环境变量列表。
只有当出现错误时,exceve才会返回到调用程序,否则,exceve调用一次且不返回。在exceve加载完毕可执行目标文件hello后,会调用启动代码,启动代码设置栈,将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序,由此将控制转移给新程序的主函数。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
当开始运行hello时,内存为hello分配时间片,如一个系统运行着多个进程,那么处理器的一个物理控制流就被分成了多个逻辑控制流,逻辑流的执行是交错的,它们轮流使用处理器,会存在并发执行的现象。其中,一个进程执行它的控制流的一部分的每一时间段叫做时间片。然后在用户态下执行并保存上下文。
如果在此期间内发生了异常或系统中断,则内核会休眠该进程,并在核心态中进行上下文切换,控制将交付给其他进程。
当hello 执行到 sleep时,hello 会休眠,再次上下文切换,控制交付给其他进程,一段时间后再次上下文切换,恢复hello在休眠前的上下文信息,控制权回到 hello 继续执行。
hello在循环后,程序调用 getchar() , hello 从用户态进入核心态,并再次上下文切换,控制交付给其他进程。最终,内核从其他进程回到 hello 进程,在return后进程结束。
6.6 hello的异常与信号处理
6.6.1 正常运行
正常执行时,hello每隔两秒打印一行“Hello 2021111125 刘博”,进入循环,共打印五次。打印完毕后,调用getchar()函数,等待用户输入回车后程序终止。Shell回收hello子进程,继续等待用户输入指令。
6.6.2 不停乱按
当在程序执行过程中随机乱按时,按下的字符串会直接显示,但不会干扰程序的运行,由于在乱按过程中没有输入回车,所以在最后一行hello的字符串打印完毕后,需要敲一个回车才能退出程序。
6.6.3按回车
在hello执行过程中敲回车时,会首先再打印的过程中显示换行,一个回车显示一排换行。在打印完毕最后一行字符串后,由于输入的回车依然存在于stdin中,所以在调用getchar()函数时,会读取stdin中的回车,因此无需再敲回车键,便能终止程序。
程序终止后,发现terminal中出现7个空行,这是因为在程序的执行过程中,敲了8下回车键,因此都留在stdin中,getchar()只接收了其中的第一个回车,由于在程序终止后没有清空stdin,剩余的回车保留在其中。当terminal继续运行时,遇到回车便开始处理,但单独的回车相当于一个空行,被terminal忽略,读入但不执行任何操作,因此留下了7个空行。
6.6.4 按Ctrl +z
在程序执行过程中按Ctrl-z,产生中断异常,发送信号SIGSTP,这时hello的父进程shell会接收到信号SIGSTP并运行信号处理程序。
最终的结果是hello被挂起,并打印相关信息。
输入ps
Ctrl-z之后,在shell命令行中输入ps,打印出各进程的pid,其中包括被挂起的hello。
输入jobs
Ctrl-z之后,在shell命令行中输入jobs,打印出被挂起的hello的jid及标识。
输入pstree -p
Ctrl-z之后,在shell命令行中输入pstree -p,查看进程树之间的关系,同时输出对应的进程pid。
输入fg
Ctrl-z之后,在shell命令行中输入fg,被挂起在后台的hello进程被重新调到前台执行,打印出剩余部分,按回车后终止程序。
输入kill
Ctrl-z之后,输入ps,得到hello的pid为26305,因此,在terminal中输入kill -9 26305,可以发送信号SIGKILL给进程26305,该进程被杀死。
6.6.5 按Ctrl-c
运行hello时按Ctrl-C,会导致断异常,从而内核产生信号SIGINT,发送给hello的父进程,父进程收到它后,向子进程发生SIGKILL来强制终止子进程hello并回收它。这时再运行ps,可以发现并没有进程hello,可以说明他已经被终止并回收了。
6.7本章小结
本章主要介绍了进程的概念、作用,以及一个hello程序的进程创建、执行和异常处理过程。进程是指正在运行的一个程序的实例,可以并发执行多个任务,各进程之间通过IPC机制协作。Shell是一种用户界面,可以解释并执行用户输入的指令,并将其转换成对应的操作系统命令或程序调用。本章讲解了Bash Shell的处理流程,以及在Shell中执行hello程序所涉及的fork()、execve()和各种信号的处理方法。
第7章 hello的存储管理
7.1 hello的存储器地址空间
结合hello说明逻辑地址、线性地址、虚拟地址、物理地址的概念。
7.1.1 逻辑地址
逻辑地址为由程序产生的与段有关的偏移地址。逻辑地址分为两个部分,一个部分为段基址,另一个部分为段偏移量。在CPU保护模式下,需要经过寻址方式的计算和变换才可以得到内存中的有效地址。
在hello的反汇编代码中的地址即为逻辑地址,需要加上相应的段基址才能得到真正的地址。
7.1.2 线性地址
线性地址是地址空间中连续的整数,是逻辑地址到物理地址变换之间的中间层。在各段中,逻辑地址是段中的偏移地址,其偏移量加上基地址就是线性地址。
可执行目标文件hello反汇编代码中的偏移地址(逻辑地址)与基地址相加后,即得到了对应内容的线性地址。
7.1.3 虚拟地址
虚拟地址是指程序访问存储器所使用的逻辑地址。使用虚拟寻址时,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址在被送至内存前先转换成适当的物理地址。在linux中,虚拟地址数值等于线性地址。
在查看可执行目标文件hello的elf格式时,程序头中的VirtAddr即为各节的虚拟地址。由于在linux中,虚拟地址数值等于线性地址,所以在hello反汇编代码中的地址加上对应段基地址的值即为虚拟地址。
7.1.4 物理地址
计算机系统的主存被组织成一个由 M 个连续的字节大小的单元组成的数组,其中每一个字节都被给予一个唯一的物理地址。
在hello的运行过程中,hello内的虚拟地址经过地址翻译后得到的即为物理地址,并在机器中通过物理地址来访问数据。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由2部分组成:段选择符和段内偏移量。
段选择符的构成如下图所示:由一个16位长的字段组成;其中前13位是索引号,用来确定当前使用的段描述符在描述符表中的位置;后面3位表示一些硬件细节,包含TI与RPL:TI选择全局(GDT)或局部描述符表(LDT),RPL选择内核态与用户态。
根据段选择符,首先判断应该选择全局描述符表(GDT,TI=0)还是局部描述符表(LDT,TI=1);然后根据GDT与LDT所对应的寄存器,得到地址和大小,获得段描述符表;接着,查看段选择符的前13位,通过索引在段描述符表中找到对应的段描述符;从而可以得到Base字段,即开始位置的线性地址。
将开始位置的线性地址与段内偏移量相加,就能得到相应的线性地址。
7.3 Hello的线性地址到物理地址的变换-页式管理
线性地址(虚拟地址)由虚拟页号VPN和虚拟页偏移VPO组成。
页表是一个页表条目 (Page Table Entry, PTE)的数组,将虚拟页地址映射到物理页地址。
由虚拟地址到物理地址的变换通过以下步骤进行:首先从得到hello进程的页目录地址,将这个地址存储在相应的寄存器;同时根据线性地址前10位,即面目录索引,在数组中找到与之对应的索引项,即一个页表的地址,其中存储页的地址,然后根据线性地址的中间10位,即页表索引,在页表中找到页的起始地址,最后将页的起始地址与线性地址中最后12位,即偏移量,将它们相加相加,就可以得到物理地址。
当引用内容时,首先内存管理单元从线性地址中抽取出虚拟页号,并且检查高速地址变址缓存,看它是否缓存于DRAM中,若命中,则再查找对应组中是否命中。若命中,将缓存的内容返回给内存管理单元。若不命中,即需要的内容不在物理内存中,则产生缺页中断,需要从虚拟内存所给出对应的磁盘的内容重新加载到物理内存中。
7.4 TLB与四级页表支持下的VA到PA的变换
现代 CPU 都包含一张名为 TLB(Transfer Look-aside Table),叫做快表,或者高速地址变址缓存,以加速对于页表的访问。
TLB通常有高度的相联度。用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有T=2^t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
若TLB命中,会经历如下步骤:CPU产生一个虚拟地址;MMU从TLB中取出相应的PTE;MMU将这个虚拟地址翻译成一个物理地址,并且将它发送到高速缓存/主存;高速缓存/主存将所请求的数据字返回给CPU。
若TLB不命中,对于四级页表来说虚拟地址被划分成4个VPN和1个VPO,VPN的每个片表示一个到第i级页表的索引,即偏移量,CR3寄存器包含L1页表的物理地址余下的页表中,第j级页表中的每个PTE,1≤j≤3,都指向j+1级的某个页表的基址。最后在L4页表中对应的PTE中取出PPN,与VPO连接,从而形成物理地址PA。
经过四级页表支持下的VA到PA的变换,虽然所经历的步骤更多,但如果一级页表的一个PTE是空的,对应的二级页表就不会存在,因此可以节省大量未被使用的空间。
7.5 三级Cache支持下的物理内存访问
MMU将物理地址发给L1缓存,在得到物理地址PA后,缓存从物理地址中取出缓存偏移CO、缓存组索引CI以及缓存标记CT。通过CI组索引,每组8路,对8路的块分别进行CT匹配,若匹配成功且块的有效位为1,则检测到一个命中,根据CO取出数据,并返回给MMU,进而传递给CPU;如果匹配不成功,则为不命中,继续向低一级缓存查询,按照L1-L2-L3-主存的顺序,查找成功后,将相应的块将其放入当前cache中。若映射到的组内存在空闲块,则可以直接放置;否则产生冲突,通过使用LRU进行替换。
7.6 hello进程fork时的内存映射
当fork()函数被父进程调用时,内核创建一个子进程,为新的子进程创建各种数据结构,并分配给子进程一个唯一的pid(与父进程不同)。
为了给hello进程创建虚拟内存,fork()函数创建了当前进程的mm_struct、区域结构和页表的原样副本,并将两个进程的每个页面都标记为只读,将两个进程中的区域结构都标记为私有的写时复制。
当fork()在从新的进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同但相互独立,映射的也是同一个物理内存。当这两个进程中的任一个进行写操作时,写时复制机制就会创建新页面,在新的页面中进行写操作,并且原来的虚拟内存映射到创建的新页面上,因此每个进程都具有私有的地址空间。
7.7 hello进程execve时的内存映射
当fork()函数被父进程调用时,内核创建一个子进程,为新的子进程创建各种数据结构,并分配给子进程一个唯一的pid(与父进程不同)。
为了给hello进程创建虚拟内存,fork()函数创建了当前进程的mm_struct、区域结构和页表的原样副本,并将两个进程的每个页面都标记为只读,将两个进程中的区域结构都标记为私有的写时复制。
当fork()在从新的进程中返回时,hello进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同但相互独立,映射的也是同一个物理内存。当这两个进程中的任一个进行写操作时,写时复制机制就会创建新页面,在新的页面中进行写操作,并且原来的虚拟内存映射到创建的新页面上,因此每个进程都具有私有的地址空间。
7.8 缺页故障与缺页中断处理
在虚拟内存的习惯说法中,DRAM缓存不命中称为缺页。如下例所示:CPU引用了VP3中的一个字,VP3并未缓存在DRAM中。地址翻译硬件从内存中读取PTE3,从有效位为0推断出VP3未被缓存,从而触发一个缺页异常。缺页异常会调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,即存放在PP3中的VP4。若VP4已经被修改了,那么内核就会将它复制回磁盘,否则直接修改。无论哪种情况,内核都会修改VP4的页表条目,替换为VP3的页表条目。
接下来,内存从磁盘复制VP3到内存中的PP3,更新PTE3,随后返回。当异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。此时,VP3已经缓存在主存中,可以正常处理。
7.9动态存储分配管理
7.9.1 动态内存管理的基本方法
当C程序运行时需要额外虚拟内存时,用动态内存分配器是一种更加方便、有更好可移植性的方法。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向更高的地址延申。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将内存视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块保留供应用程序使用,空闲块可用来分配。
分配器有两种基本风格:显式分配器与隐式分配器。其中显式分配器要求应用显式地释放任何已分配的块,如C语言中调用malloc函数来分配一个块,然后调用free函数来释放一个块。隐式分配器要求分配器检测一个已分配块何时不再被程序所使用,就释放这个块。因此,隐式分配器也叫垃圾分配器。
在hello中,printf函数会调用malloc函数。malloc函数返回一个指针,指向大小至少为size字节的内存块。当程序不再需要malloc分配的区域时,需要通过free函数进行释放。
以上就是动态内存管理的方法。
7.9.2 动态内存管理的策略
程序使用动态内存分配最重要的原因是:有时,当我们运行程序时,才会直到某些数据结构的大小,这样便需要使用到动态内存分配。
例如,要求一个C语言程序读一个ASCII码整数的链表,每一行一个整数,从stdin到一个C数组。输入是由整数n和接下来要读和存储到数组中的n个整数组成的。最简单的方法就是静态地定义这个数组,最大数组大小MAXN固定。
然而,如果这个程序使用者想要读取一个比MAXN大的文件,唯一的办法就是修改程序中MAXN的值,对大型软件产品而言不是一个好方法。
一种更好的方法是在已知了n的值后,动态地分配这个数组。使用这种方法,数组大小的最大值就只由虚拟内存数量来限制了。
hello中的printf作为一个已经编译、汇编好了,等待链接的函数,修改固定参数也是不现实的。
首先,对与堆中的块的组织,可以选择隐式、显示、分离空闲链表等。当分配器查找空闲块时,又可以采用不同的放置策略,如首次适配(从头开始搜索链表)、下一次适配(从上一次找到的空闲块的剩余块除法)以及最佳适配等。在分割空闲块时,又可以采用将剩余块分割为新的空闲块的策略。在合并时,可以采用带边界标记的合并,就像在上一段基本方法中所描述,通过边界标记来判断当前块周围是否也同样是空闲块,以此来判断是否需要合并。
以上就是动态内存管理的策略。
7.10本章小结
本章主要介绍了hello的存储器地址空间。结合了hello,说明了逻辑地址、线性地址、虚拟地址、物理地址的概念,以及它们的区别与联系,互相转化的方法。
分析了段式管理是如何完成逻辑地址到线性地址(虚拟地址)的变换的,包括段选择符和段内偏移量的作用。
分析了页式管理是如何完成线性地址到物理地址的变换的。
分析了TLB与四级页表支持下的VA到PA的变换。高速地址变址缓存TLB有加速对于页表访问的功能。以四级页表为例,介绍了多级页表的层次、工作流程以及节省空间的优点。
介绍了三级Cache支持下的物理内存访问的流程,包括在命中情况下的与未命中情况下的。
分析了hello进程fork与execve时的内存映射,着重从写时复制机制介绍了创建子进程时的虚拟内存相同且独立与物理内存共享。
介绍了缺页故障与缺页中断的处理。以一个VM缺页为例,介绍了缺页中断的处理流程。
分析了动态存储分配管理。从动态内存管理的基本方法与动态内存管理的策略两个方面对动态内存管理进行介绍。
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
设备的模型化:文件
设备管理:unix io接口
在Linux中,所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。
这种将设备映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这就是Unix I/O接口。
这使得所有的输入和输出都能以一种统一且一致的方式来执行。这便是Linux的IO设备管理方法。
8.2 简述Unix IO接口及其函数
打开文件:应用程序请求内核打开相应的文件,宣告其想要访问IO设备
对应的函数:
改变当前的文件位置:对于每个打开的文件,内核保持着一个文件位置k。
应用程序执行seek操作,显示设置文件的当前位置为k。
读写文件:一个读操作是从文件里面复制n >
0个字节到内存里面。从当前文件位置k开始,增加k到k+n。给定大小m文件,k>=m时,触发EOF条件,然后应用程序检测到。类似,写操作是从内存里面复制n>0字节到文件里面,从当前文件位置k开始,然后更新k。
关闭文件:应用程序完成了文件访问以后,通知内核来关闭文件。
打开文件的函数:
#include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int open(char *filename,int flags,mode_t mode);
关闭文件的函数
#include <unistd.h> int close(int fd);
读写文件函数
#include <unistd.h> //读文件函数 ssize_t read(int fd,void* buf,size_t n); //写文件函数 ssize_t write(int fd,const void *buf,size_t n);
8.3 printf的实现分析
(以下格式自行编排,编辑时删除)
首先观察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; }
其中,调用了函数vsprrintf与write
vsprintf生成显示信息:
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++;//将fmt字符串拷贝到buf缓冲里面,直到出现格式化串 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是一个系统函数
vsprintf返回的是一个要打印的字符串的长度
通过接受确定输出格式的格式化字符串,对参数进行格式化,输出到格式化buf里面,然后将buf传给write,利用write进行输出
write系统函数
write
1.write: 2. mov eax, _NR_write 3. mov ebx, [esp + 4] 4. mov ecx, [esp + 8] 5. int INT_VECTOR_SYS_CALL
Write干的就是给寄存器传好参然后进入一个陷阱-系统调用
下面进入syscall函数进行刨析:
syscall
sys_call: ;ecx中是要打印出的元素个数 ;ebx中的是要打印的buf字符数组中的第一个元素 ;这个函数的功能就是不断的打印出字符,直到遇到:’\0’ ;[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串 xor si,si mov ah,0Fh mov al,[ebx+si] cmp al,’\0’ je .end mov [gs:edi],ax inc si loop: sys_call .end: ret
Syscall在这里,只有一个功能:显示格式化了的字符串。
它将串里面的字节,从寄存器里面通过总线,复制到显卡显存里面,存放Ascll码。字符显示驱动子程序将通过ASCII码在字模库中找到点阵信息将点阵信息存储到vram中。显示芯片会按照一定的刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。最后我们的打印字符串就显示在了屏幕上。
8.4 getchar的实现分析
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
对个getchar的实现分析:
getchar
1. int getchar(void) 2. { 3. static char buf[BUFSIZ]; 4. static char *bb = buf; 5. static int n = 0; 6. if(n == 0) 7. { 8. n = read(0, buf, BUFSIZ); 9. bb = buf; 10. } 11. return(–n >= 0)?(unsigned char) *bb++ : EOF; 12. }
用户通过按下键盘,键盘的接口得到了代表按键的键盘码,产生了中断,这个中断,抢占了当前的进程,通过上下文切换机制,进入键盘中断子程序,子程序从键盘接口(文件),获得键盘按下的扫描码,让那后将这个扫描码转为ASCII码的形式,存到键盘的缓冲区里面。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章具体讨论看hello的IO管理
通过分析说明Linux的IO设备管理方法/Unix
IO接口及其函数/printf的实现分析/getchar的实现分析
探讨了小小hello程序背后支撑着它的IO基础
结论
为了在系统上运行hello.c程序,hello.c首先经过预处理器(cpp)得到修改了的源程序hello.i。这时的hello.i代码量比hello.c大大增加,其中注释部分被删除了,引用的头文件被插入到了源代码中,除头文件与注释以外的源代码保持不变。
接着,编译器(cc1)将hello.i翻译为汇编程序hello.s。在这个过程中,高级语言被翻译为机器逻辑下的汇编语言,编译器可能会根据编译选项的不同对程序进行一些优化。
然后hello.s经过汇编器(as)翻译成机器语言指令,把这些指令打包成可重定位目标程序hello.o。hello.o是一个二进制文件,可以查看它的ELF格式和相应的反汇编代码。查看ELF格式可以发现,hello.o是由不同的节组成的,每个节都有相应的大小和功能。
接下来,经过链接器,将调用的标准C库中的函数(如printf等)对应的预编译好了的目标文件以某种方式合并到hello.o文件中,得到可执行目标程序hello。hello也是一个二进制文件,可以查看它的ELF格式与反汇编代码。相比于hello.o,hello的代码量大大增加,这是由于一些C标准库中的函数(如printf)等被插入了hello中。同时,每一行代码、字符串常量等,都会被分配相应的虚拟地址,其中的数字以二进制表示,并以十六进制的形式显示出来。Hello是可以直接运行的文件。
当我们运行hello时,在shell中利用fork()函数创建子进程,为子进程分配一个与父进程相同但独立的虚拟内存空间,实行写时复制机制,再用execve()加载hello程序,这时,hello就由程序(program)变成了一个进程(process)。
映射虚拟内存,程序开始时载入物理内存,进入CPU处理。CPU为执行文件hello分配时间片,进行取指、译码、执行等流水线操作。
内存管理器和CPU在执行过程中通过L1、L2、L3三级缓存和TLB多级页表,经过一系列地址变换,从逻辑地址,到线性地址,最终得到物理,利用物理地址,从物理内存中取得数据。
最后通过I\O系统根据代码指令进行输出。在程序运行结束后,父进程会对其进行回收,内核把它从系统中清除。这就是hello所经历的过程。
附件
列出所有的中间产物的文件名,并予以说明起作用。
文件名 | 作用 |
Hello.c | hello程序的源代码 |
Hello.i | 源代码经过预处理后的结果 |
Hello.s | 编译后生成的汇编程序 |
Hello.o | 汇编后生成的可重定位文件 |
hello | 链接之后生成的可执行文件 |
Hello.out | 添加相关编译参数的可执行文件 |
hello_o_elf.txt | 查看hello.o的elf格式对应的文本文件 |
hello_o_asm.txt | 查看hello.o的反汇编对应的文本文件 |
hello_elf.txt | 查看hello的elf格式对应的文本文件 |
hello_asm.txt | 查看hello的反汇编对应的文本文件 |
参考文献
[1] Randal E.Bryant等.深入理解计算机系统(原书第3版)[M]. 北京:机械工业出版社,2016.7