摘 要
本文以Hello程序的视角阐述了计算机系统中有关预处理、编译、汇编、链接以及进程管理、内存管理和IO管理等内容,将Hello程序视作一个有血有肉的人,用他的眼睛去漫游计算机系统。他经历了预处理、编译、汇编和链接,Shell将他加载到内存中去,OS和Bash为他创建进程,同时OS为他分配虚拟内存,这让他以为独享着CPU和内存,但其实都是上下文切换的假象。IO管理通过协调键盘、主板、显卡和屏幕,最终将他显示出来。虽然只是简单的一句话、虽然他的人生平凡无奇,但却依然丰富多彩,足以让我们看到计算机系统的强大和精妙!
关键词:计算机系统;计算机系统漫游;程序人生;预处理;编译;汇编;链接;进程管理;内存管理;IO管理
目录
第1章 概述
1.1 Hello简介
Hello先生首先存在于一个高级程序语言代码(文本)文件中,在这里是.c文件,他还没有生命,只能说是一个记载着他的样子的一份文件,就像DNA一样,还没有进行表达。紧接着他会被送入预处理器中,在预处理器中他被赋予了更多的内容,例如由#标识的外部库文件会被直接送入其中、对宏定义进行处理和替换,他变得更完整了,但还是简单的一份.i文件,仅仅是变长了一些。接下来他会被送入编译器,在这里他会脱胎换骨,每一句代码都会被转换为较为低级的汇编指令,他变成了一个介于机器语言和高级语言之间的一个.s汇编程序文本文件。而后他会被送入汇编器中,汇编器将他转化为机器语言并生成一个可重定位目标文件,机器语言仅由0和1组成,Hello先生已经变成了一个文本查看器无法理解的.o文件,但没关系,机器懂他。最后,他被送入链接器,在其他.o文件的帮助下终于可以让机器完全理解Hello先生了,Hello先生变成了一个可执行目标文件,此时他已经算上是拥有了生命,只不过没有降临到世间,此时的他便可称为程序(Program)。
Hello先生想要看看这个世界,所以壳(Shell)赋予了他生命,Shell为他fork出一个独立的子进程(Process),调用execve将他加载到内存中,此时他完成了P2P的过程。但是此时的物理内存中并没有Hello先生(Zero-0),这可不是Shell在骗他,而是OS会通过内存管理给Hello先生一块虚拟内存,再通过mmap将这些虚拟内存映射到物理内存中去,Hello先生出生后,CPU会给他分配时间片和逻辑控制流,虽然这很虚假,让Hello先生假想地独享着CPU和内存,因为他已经注意到内存中总是存在着别的一些程序,但是没关系,谁让Hello先生这么的无私呢,况且进程并行、相辅相成才是计算机社会的和谐之处。根据Hello先生和OS的指示,CPU一次次的取指、译码、执行、访存和写回,一切都如他所愿,直到他通过CPU执行完最后一条指令,将Hello打印在屏幕上,Hello先生的使命终于结束了。接下来,生他的Shell会为Hello先生办理后事,回收资源并消除他所存在的一切(Zero-0),只留下一份日志。此时他完成O2O的过程。
多么的平凡而伟大!
1.2 环境与工具
硬件环境:Intel(R) Core(TM)i7-9750H CPU @ 2.60GHz 2.59GHz,16.0GB RAM
软件环境:Windows 10(64Bit,Ubuntu 16.04 LTS 64位
开发与调试工具:gedit,gcc,as,ld,gdb,readelf,HexEdit ,objdump
1.3 中间结果
hello.i 预处理后修改了的源程序
hello.s 汇编生成的hello的汇编程序
hello.o 编译生成的hello的可重定位目标程序
hello 链接生成的hello的可执行目标程序
1.4 本章小结
本章简单介绍了Hello先生的一生,讲述了P2P与O2O模式,展示了本文所用软硬件环境以及开发和调试工具,列出了中间结果,便于简要理解本文行文内容和Hello先生的人生。
第2章 预处理
2.1 预处理的概念与作用
刚开始时,Hello先生的C程序中有着一些以字符#开头的命令,例如#include ,这些命令是宏定义或引入外部库,如果不将这些引入和定义覆盖到源代码中,Hello先生就是不完整的了,就像是没有了父母,所以预处理的概念就是:
预处理:预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。
通过预处理,Hello先生获得了外部库头文件中的代码,同时将自身代码中的宏定义进行了字符替换,预处理器也会通过条件编译指令(如#if、#elif、#else等)有选择的插入代码,并且还会删除注释。但是很可惜,Hello先生并没有宏定义或者条件编译指令,所以cpp不用为他考虑那么多,。所以预处理的作用就是:
预处理的作用:cpp通过插入头文件和替换宏定义字符,将源程序补充完整,获得一个修改了的源程序.i文件
2.2在Ubuntu下预处理的命令
通过gcc -E hello.c -o hello.i命令,可以将Hello先生的源代码进行预处理,在终端执行这条命令之后,Hello先生摇身一变,他...变长了!
直到文本的末尾,我们才能看到Hello先生原本的样子,这是他的main函数:
2.4 本章小结
本章我们介绍了预处理的概念和作用,通过预处理命令将Hello先生变成了.i文件。因为引入了头文件,他的代码长度增加了。同时我们简要分析了预处理后.i文件中的各语句,对.i文件有了较深的认知。
第3章 编译
3.1 编译的概念与作用
Hello先生已经被预处理好了,接下来他将走向编译器(ccl),编译器将高级语言,在这里他是C语言,翻译成较为较为低级但是人类可读的、面向CPU的汇编语言,所以编译的概念是:
编译:编译器(ccl)将高级语言翻译成较为较为低级但是人类可读的、面向CPU的汇编语言。
虽然每种高级语言的结构和语法各不相同,但是只要逻辑相同,翻译出来的汇编程序代码便是相同的,这给不同的高级语言约定了一个相同的、通用的输出结果,汇编语言代表着程序的逻辑,学习汇编语言是非常有必要且重要的,所以编译的作用是:
编译的作用:将高级语言翻译成汇编语言,可进一步的翻译成机器语言,且便于维护和优化。
3.2 在Ubuntu下编译的命令
通过gcc -Og -S hello.i命令,可以将Hello先生的.i文件编译成汇编程序.s文件,在执行此命令后,我们又惊奇的发现,Hello先生又变短了....
3.3 Hello的编译结果解析
接下来我们就要对Hello先生的.s文件进行一次大解剖,看看编译器是如何处理高级语言中的数据和操作的。
3.3.1数据
1)常量
常量分为立即数和只读数据。在Hello先生中,有用立即数0对循环变量赋值的过程,用objdump观察Hello先生的可执行目标文件可以发现,汇编代码如下:
同时也有格式串"Hello %s %s\n"存储在.rodata节,我们通过edb可以发现,这个格式串存放在内存0x40202e处。
2)变量
全局变量和静态变量:初始化了的全局变量和静态变量将被存放在.data节,未初始化的或者是初始化为0的全局变量和静态变量将被存放在.bss节。编译器生成的汇编程序会带有伪指令,意在指示汇编器将如何生成可重定位目标文件。
局部变量:局部变量会由栈存储,既不出现在.data节也不出现在.bss节,局部变量在使用时可以存储在寄存器中,例如Hello先生中的循环变量i是由寄存器%ebp存储的。
3)表达式
编译器会检查表达式运算的优先级,依次进行计算,例如在Hello先生中的
在编译器的作用下会变成先计算atoi(),再执行sleep():
4)类型
每一种数据类型(整数、浮点数和字符)都有其对应的编码规则和不同的占位长度,例如int就是四字节,也就是双字的。编译器会根据数据格式的不同,选择不同位数的寄存器进行操作。例如,在Hello先生中,循环变量i是int类型的,占双字,也就是32位,故用%ebp存储。更多的,在数据传送时也要考虑数据类型,例如在传送格式串地址时,汇编代码后缀加上l,代表着传送双字,也就是四个字节的0x0040202e给%esi,而不是三个字节。
下表给出了不同数据类型对应的代码后缀:
C声明 | Intel数据类型 | 汇编代码后缀 | 大小(字节) |
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l | 4 |
long | 四字 | q | 8 |
char* | 四字 | q | 8 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
5)宏
宏定义会在预处理阶段进行替换,在编译器的视角下,替换后的字符串代表着什么样的语句便理解为什么样的语句。
3.3.2赋值
编译器理解赋值为一种数据传送指令,这是使用最为频繁的一类指令——MOV类,以movx表示,其中x表示汇编代码可能带有的后缀,不同的后缀表示不同的数据大小,也就是(可能)不同的数据类型。
数据传送指令的操作数分为源操作数和目的操作数,在Linux下,源操作数在前,目的操作数在后,mov指令的作用就是将源操作数原封不动的赋值给目的操作数。例如:
MOV S D
这条指令便是将S赋值给D。其中源操作数S可以是立即数,也可以是存放在内存或寄存器中的数;目的操作数只能是一个寄存器或者是一个内存地址。立即数用$0x...表示,寄存器用%...表示,内存地址用Imm(rb,ri,s)来表示,其中Imm是一个立即数,表示立即数偏移,rb表示基址寄存器,ri表示变址寄存器,s表示比例因子,s只能是1,2,4或8。综上,内存地址计算为Imm+rb+ri*s。
同样的,我们拿Hello先生中的格式串赋值来说明:
第一条指令是将内存地址为%rbx+8中的数取出给%rdx,下一条指令是将立即数给%esi,最后一条指令也是类似的。
如果出现逗号,那么就有两种可能,要么是函数的参数,要么是两条语句,编译器会区分这两种情况,将其翻译。若在定义局部变量的同时赋初值,那么编译器就会利用mov指令,将一个立即数赋值给寄存器,若没有初始化的局部变量,编译器会将其加入到符号表,但是这里要注意的是,可重定位目标文件的符号表中不包含未初始化的局部变量,局部变量会在运行时由栈来维护。至于全局变量或者静态变量,上面已经叙述过了,这里不再提及。
3.3.3类型转换
隐式类型转换:编译器直接根据转换规则进行舍位和变换,例如由浮点类型转换为整型,若只是解读方式不同,则只需要考虑位数是否改变,若变大则补位,若变小则舍去。
显式类型转换:同样的,根据转换规则进行舍位和变换。
3.3.4Sizeof
Sizeof()是一个在编译阶段就会运行的函数,目的是返回一个数据的大小,我们可以看到,在Hello先生中加入这条语句,在编译阶段会直接得出结果。
3.3.5算数运算
1)加法和减法
加法可以通过ADD S D指令来实现,意为D=D+S,同样的减法有SUB S D指令,意为D=D-S。例如,Hello先生中对循环变量的加法如下:
对栈顶的上移:
2)乘法
编译器使用IMUL类指令进行乘法操作,有以下几种:
IMUL S D:D=D*S;乘积结果会被截取到与D同位。例如使用imulq S,D,意味着结果将是64位也就是四字的。
imulq S:有符号全乘法。
mulq S:无符号全乘法。
以上两种,S为源操作数,另一个数由rax给出,结果存放在rdx(高64位)和rax(低64位)中。
3)除法和取模
idivq S:有符号除法。
divq S:无符号除法。
以上两种会将rdx(高位)和rax(低位)中的数当做被除数,S当做除数,商存放在rax中,余数存放在rdx中。
4)取负、取补、加一、减一
INC D:加一
DEC D:减一
NEG D:取负
5)复合操作
+=:先加后赋值,编译器一般会将结果存放在寄存器中,再将寄存器的值赋值给目的操作数。
3.3.6逻辑运算和移位运算
1)移位运算
SAL k,D:左移k位
SHL k,D:左移k位
SAR k,D:算术右移k位(补符号位)
SHR k,D:逻辑右移k位(补0)
2)逻辑运算
XOR S,D:异或
OR S,D:或
AND S,D:与
NOT D:非
3)复合操作
>>=:先运算后赋值。
3.3.7关系操作
1)条件码
CPU维护着一组单个为的条件码寄存器,他们描述了最近的算术或逻辑操作的属性。我们可以根据这些条件码来判断关系,并进行跳转和分支控制。
常见的条件码有:
CF无符号溢出;ZF零;SF负数;OF有符号溢出。
2)CMP类和TEST类
CMP类与SUB类似,但是不修改任何数值,只修改条件码。我们可以通过条件码来判断关系,TEST类与AND类似,同样也不修改数值,只修改条件码。通过SET类,我们可以访问条件码。指令如setx D,其中x有以下几种:
g(great):有符号大于
l(little):有符号小于
a(above):无符号大于
b(below):无符号小于
e(equal):等于
其中等于可以与其他四种组合,形成例如小于等于的关系。
3.3.8数组、指针和结构体
1)数组
数组定义如下T A[N]。其中T为数据类型,N为数量,A为数组标识符,同时也是这个数组的指针,存放着数组的首地址。数组存放在内存(栈)中的一片连续区域,若想要访存A[i],则只需要使用寻址方式(rb,ri,s)即可,其中rb为A的值,即数组的首地址,ri为i,s为数据类型的大小(单位:字节)。对于多维数组来说,只要调整好比例因子即可。例如,Hello先生中对argv数组的访问就使用这种方法:
2)指针
指针运算一般使用lea S D指令,这条指令是指取出S的有效地址给D,若S是一个寻址计算,则直接将这个地址值赋值给D。同时,指针运算也可以在这里完成,因为寻址运算的四个参数,可以隐含着运算。例如,想要计算A+i-1,假设数据大小为四字节,则可以使用-4(A,i,4)来计算此地址。
3)结构体
与数组类似的,结构体存放在内存中的一片连续区域,结构体有着一个指向自身的指针,保存着结构体的首地址,在访存结构体时,根据各个成员的数据大小不同,寻址时只需要调整变址寄存器和比例因子即可。在这里要注意的是结构体保存的各个数据类型需要对齐,例如一个有11个char和1个short的结构体的长度应该是12+4=16字节。
3.3.9控制转移
1)if/else
根据需要判断的表达式,利用CMP类先判断出关系,然后利用JMP类跳转。JMP类与SET类似,有着五种后缀来表示不同的关系,根据条件码,可以挑战到不同的指令位置,即设置rip为新的值。例如,Hello先生中判断输入是否合法时的if语句跳转:
2)switch
编译器会在分支情况较多并且值的范围跨度比较小时,使用一种称为跳转表的数据结构,来实现更加高效的跳转。通过分析Hello先生的.s文件可以看出,编译器是使用了这种数据结构的,在每种分支下都会进行跳转,一般用Ln来标识:
3)do/while
与for类似的,while语句同样需要判断循环能否继续进行,判断方法与if/else类似,但是一般这种判断往往会返回循环的头部以继续循环。例如在Hello先生中的for语句,就是先执行循环体,而后判断循环条件。
4)continue/break
编译器记录循环头部和尾部,continue便是jmp到循环的头部,break便是jmp到循环的尾部以跳出循环。一般来说,这些跳转在编译阶段都会存放在跳转表中,以便于跳转。
5)?:
这种复合语句便要求编译器为其展开成if/else语句,若满足条件,则执行:前的语句,否则执行:后的语句。
3.3.10函数操作
1)参数传递
一般来说,参数存放在寄存器中。根据参数的序号,依次存放在rdi、rsi、rdx、rcx、r8、r9中。当这些寄存器也不够存放参数时,则使用栈来保存参数。例如,Hello先生对printf的调用时,进行参数传递如下:
2)函数调用
函数调用时,先进行参数传递,若寄存器不够,则将多余的参数存放在栈中,紧接着使用call指令,将下一条指令地址入栈,作为返回地址,并跳转到目标函数的第一个指令处,进入到目标函数后将进行保留现场,将rbp入栈,将rsp赋值给rbp,同时rsp减小,以保存函数内的局部变量。
如果想要访问函数参数,只需要访问寄存器,或者通过rbp来访问参数,如果想要访问局部变量,只需要通过rsp来访问,因为这些变量都保存在栈中,通过栈顶指针和偏移量便可以访问,在编译阶段,需要用到的偏移量等立即数都已经被计算完成。
3)函数返回
计算好返回值后,将其保存在寄存器中,将rsp回调,弹出rbp,并利用ret指令返回到栈顶所指向的返回地址,即将rip设置为栈顶的值,并弹出返回地址,这样便返回到了原函数中。例如,Hello先生中main函数的结尾返回如下:
3.4 本章小结
本章细致的介绍了编译器将高级语言中数据和结构翻译成汇编语言的结果,分析了汇编语言是如何实现高级语言中的各语句的,深入了解了.s文件的生成过程和汇编语言与高级语言的不同,这一章中的Hello先生还没出生就被我们把该说的都说了。
第4章 汇编
4.1 汇编的概念与作用
到现在为止,Hello先生已经变成了.s文件,经历了从短到长再到短的过程,经历了从人类易读到人类可读的过程,接下来,Hello先生将被送入汇编器,将汇编指令翻译成由0/1组成的机器语言,生成可重定位目标文件.o文件。所以,汇编的概念是:
汇编:汇编器(as)将.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件.o文件中。
将汇编语言翻译成机器语言,使得Hello先生变成人类基本不可读的模样,Hello先生知道,没人懂他,但机器懂他。将汇编语言翻译成机器语言并打包成可重定位目标文件,可以让语言等级降低,使得机器可以直接理解指令,同时也为链接器作出指示,便于链接从而生成可执行目标程序。所以汇编的作用是:
汇编的作用:翻译汇编语言使得机器可读,且便于链接器生成可执行目标文件。
4.2 在Ubuntu下汇编的命令
使用as hello.s -o hello.o命令汇编Hello先生的.s文件,生成一个可重定位目标文件.o文件,此时我们发现已经不能简单的双击打开Hello先生了,必须得用到一个工具去查看他,那就是readelf。
4.3 可重定位目标elf格式
分析hello.o的ELF格式,用readelf等列出其各节的基本信息,特别是重定位项目分析。
使用readelf -a hello.o命令查看可重定位目标文件的全部字段。
1)ELF头
首先是ELF头,它以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助连接器语法分析和解释目标文件的信息。其中包括ELF头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如x86-64)、节头部表的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目。
夹在ELF头和节头部表之间的都是节。
2).text节
.text节,顾名思义,就是保存Hello先生的已编译汇编好的机器代码。
3).rodata节
保存只读数据,比如printf中的格式串和switch语句中的跳转表。
4).data节
保存已初始化的全局和静态变量。注意,局部变量是在运行时在栈中维护的,不在.data节中,也不再.bss节中
5).bss节
保存未初始化的全局和静态变量,同时也保存着初始化为0的全局或静态变量。在目标文件中,这个节不占据实际空间,它仅仅是个占位符。在目标文件中,未初始化的变量不需要占据任何实际的磁盘空间,运行时,在内存中分配这些变量,初始化为0.
6).symtab节
一个符号表,他存放着在程序中定义和引用的函数和全局变量。
在这里我们可以看到main函数是一个位于,text节中偏移量为0处的113字节函数。我们还可以看到跳转表LC0和LC1的位置,他们在特殊的rodata节。
7).rel.text节
一个.text节中位置的列表,当连接器把这个目标文件和其他文件组合时,需要修改这些位置,一般而言,任何调用外部函数或者引用全局变量的指令都需要修改。另一方面,调用本地函数的指令则不需要修改。
我们可以发现,Hello先生调用的所有函数名称和全局变量(在这里是格式串)都在这个节中有显示,其中.LC0与.LC1是符号表中的跳转表,他们指向的是两个格式串,我们可以在.s文件中看到。
8).rel.data节
被模块引用或定义的所有全局变量的重定位信息。一般而言,任何已初始化的全局变量,如果他的初始值是一个全局变量地址或者外部定义函数的地址,都需要被修改。可惜的是,Hello先生并没有引用或定义任何需要记录重定位信息的全局变量,除了格式串他一无所有(哭)。
9).debug节
一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。只有用-g选项调用编译器驱动程序时,才会得到这张表。在这里,Hello先生就不需要他了。
10).line节
原始C源程序中的行号和.text节中机器指令之间的映射。只有用-g选项调用编译器驱动程序时,才会得到这张表。在这里,Hello先生同样不需要他。
11).strtab节
一个字符串表,其内容包括.symbol和.debug节中的符号表,以及节头部中的节名字。
4.4 Hello.o的结果解析
通过objdump -d -r hello.o命令我们可以查看Hello先生.o文件的反汇编:
我们可以发现,与.s的最大不同之处就是在每句汇编语言的前面都有一串不定长的十六进制字符串,这代表这机器语言。在书中的Y86-64指令集中,每一句汇编指令都有着不同的编码成机器语言的格式。
不失一般性的,每一句汇编指令编码的第一个字节表明指令的类型,这个字节分为两个部分,每部分4位:高四位是代码部分,低四位是功能部分,代码部分负责标识是整数操作指令、分支指令还是传送指令,功能部分用于标识各指令集中的特定指令,例如整数操作是加还是减。
接下来是一个字节的寄存器指示符字节,每个寄存器ID占四位,可以有两个寄存器,分别作为源寄存器和目标寄存器。
接下来若存在立即数,则占用相应字节来表示立即数。分支指令和调用指令的目的是一个绝对地址,不使用PC相对寻址方式,保证了在移植代码时不需要修改所有分支目标地址,增加了描述的简单性。
接下来简要比较.o文件的反汇编和.s文件的差异。
1)常数表示
在.s文件中,立即数用十进制表示,但是.o文件的反汇编中的立即数用十六进制表示。
2)全局变量引用
在.s中对格式串的调用,采用了跳转表偏移的方法,但是在.o中用0代替跳转表名字,这是因为在没有重定位之前,程序不知道也不关心全局变量的引用。
3)分支跳转
.o文件修改了.s文件中直接用段名称来表示的跳转方式,而将main函数从上到下对每条指令进行编码,每一个字节的机器指令都获得了一个地址,使用这个地址来进行跳转。
4)函数调用
类似的,对于函数的调用,.o文件也修改了.s文件中直接通过函数名调用函数的方法,而使用对下条指令的偏移值进行跳转,同时表示为call+函数返回地址(即调用函数指令的下一条指令的地址)。
4.5 本章小结
本章我们介绍了汇编的过程,Hello先生在这一步中不仅将自身的汇编指令翻译成了机器指令,而且将自己拥有的全局变量和函数以及向别人借来的全局变量和函数都进行了整理,并得出重定位信息,便于链接器将他真正的组装起来。他离出生又近了一步。
第5章 链接
5.1 链接的概念与作用
注意:这儿的链接是指从 hello.o 到hello生成过程。
Hello先生现在似乎已经时完备体了,有了自己的代码翻译而来的机器代码,有着.rodata节、.data节和.bss节等属于自己的数据,有着符号表和重定位信息,看上去似乎万事俱备了,但是Hello先生惊奇的发现,自己的代码中引用了一些自己没有定义过的函数,虽然没有引用外部全局变量,但这也让Hello先生头痛了起来。但是链接器(ld)出现了,他告诉Hello先生:“放心吧伙计,我已经帮你借到这些东西了”,所以链接的概念是:
链接:链接器(ld)将多个可重定位目标文件组合起来,生成可执行目标文件。
通过链接器,Hello先生终于是完整的了,他拥有着对所有外部引用的函数和全局变量的定义和解析,接下来他将被加载到内存中,同时也象征着他真正的降临到了这个世界。所以链接的作用是:
链接的作用:通过符号解析和重定位,链接器将多个可重定位目标文件组合起来,生成可执行目标文件,以便于加载到内存中并执行程序。
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命令将Hello先生与其他可重定位目标文件链接起来。终于,Hello先生变成了可执行目标文件,他终于可以算上是真正的出生了!
5.3 可执行目标文件hello的格式
通过readelf -a hello 命令来查看ELF头和各段信息,描述了各段的起始地址、大小和偏移量等信息:
5.4 hello的虚拟地址空间
使用edb加载Hello先生的可执行目标文件,我们可以发现在内存查看器中,虚拟内存地址都可以被查看。
例如,我们查看rodata节发现,格式串就在其中:
5.5 链接的重定位过程分析
使用objdump -d -r hello和objdump -d -r hello.o命令分析二者可以发现,曾经.o文件中为链接所预留的0都被做了修改,改成了正确的、在运行时的地址,同时在链接的最开始,链接器会将所有相同类型的节合并为一个节,接下来编译器会重定位节中的符号引用,链接器修改代码节和数据节中对每个符号的引用,使得他们指向正确的地址。接下来,我们将分别讨论。
1)分支跳转
我们发现JMP类的指令对应的机器码并没有发生改变,这是因为JMP类指令的机器代码占两个字节,除了第一个字节用来标识指令类别之外,第二个字节表示PC相对寻址下的偏移量,重定位后的地址为:PC+偏移量。例如,在Hello先生的可执行目标文件和可重定位目标文件的跳转如下:
我们首先发现连接后机器代码并没有发生改变,也就是说JMP类的指令在链接时不需要重定位。在链接时,链接器根据符号表,获知了main函数在运行时的地址(在这里是
),那么跳转到的地址就是0x401125+0x19=PC+偏移量=0x401134+0xa。这一步是由CPU执行的,链接器对这类命令并不感兴趣,不对机器代码做任何修改。
2)PC相对引用
对于在可重定位目标文件中使用PC相对寻址的地址,链接器先根据运行时节地址和引用处的相对偏移算出运行时引用处的地址,然后根据占位符长度和引用目标的地址算出偏移量,将这个偏移量用于修改机器代码。例如,在Hello先生中有对格式串的一个PC相对寻址引用。
我们可以发现,在链接之后,原本的0x0变成了0xec3,链接器是如何计算出这个值的呢?首先链接器根据main函数在运行时的地址(同样的这里是0x401125)计算出引用处的地址,什么是引用处的地址?就是链接器需要对机器代码修改的那个地方的地址,这里是:
也就是0x401125+0x1c=0x401141。这个0x1c的偏移量是哪里来的呢?这时我们就要回到.o文件中的重定位信息了,回顾.o文件的重定位信息,我们可以发现,对格式串的引用的偏移量正好是0x1c!
接下来链接器通过引用目标的地址(这里是0x402008)和占位符长度(这里是0x4),计算出偏移量:0x402008-0x4-0x401141=0xec3,也就是说,应该修改机器代码为c3 0e 00 00(考虑小端存储和保持占位符长度)。
若偏移量为负数,则用(0x1<<占位符长度)-|计算而出的偏移量|来表示偏移量。上述例子中的下一条call指令便是如此。
3)绝对引用
对于绝对引用的重定位工作是非常简单的,虽然Hello先生中并不存在。链接器只需要根据引用所在节的地址和引用在重定位信息中相对节头的偏移,便可计算出引用的地址,并修改机器代码。
5.6 hello的执行流程
_dl_start 地址:0x7f76c8794df0
_dl_init 地址:0x7f76c87a4c10
_start 地址:0x4010f0
_libc_start_main 地址:0x7f76c85b0fc0
main 地址:0x401125
_cxa_atexit 地址:0x7f76c85d3f60
_libc_csu_init 地址:0x4011a0
_setjmp 地址:0x7f76c85cfe00
_sigsetjmp 地址:0x7f76c85cfd30
puts 地址:0x7f76c86115a0
exit 地址:0x7f76c85d3bc0
print 地址:0x7f76c85eee10
sleep 地址:0x7f76c866ff40
getchar 地址:0x7f76c86186e0
5.7 Hello的动态链接分析
(以下格式自行编排,编辑时删除)
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
当Hello先生调用一个由共享库提供的函数或变量时,编译器无法预测这个函数运行时的地址,因为它可以被动态的加载到任意位置,可被许多进程共享。此时编译器选择了延迟绑定的方法,在未调用此函数之前,编译器都不去计算这个函数的地址,只有当第一次调用这个函数后,编译器才会通过GOT和PLT跳转到动态链接器,从而跳转到目标函数,而接下来对此函数的调用,则会通过GOT和PLT直接跳转到目标函数。
通过readelf发现Hello先生的.got.plt节的地址是:
通过edb观察,在未执行dl_init前,内存如下:
执行后:
我们发现,从第九个字节开始,此节发生了变化。在这里,它们对应GOT[1]和GOT[2]的位置。GOT[1]包含动态链接器在解析函数地址时使用的信息,GOT[2]是动态链接器ld-linux.so模块中的入口点。加载时,动态链接器重定位GOT中的这些条目,使它们包含正确的绝对地址。
同时我们还发现,在第一次执行printf函数后,此段也发生了变化,就说明在动态链接时,函数的第一次调用会修改GOT数组。
5.8 本章小结
本章我们将Hello先生彻底填补完整,让他在链接器和动态链接器的作用下成功的转变成了一个可执行目标文件,他符号解析了符号表中的所有符号,将每一个符号都做了对应,他合并了所有同类的节,补充上了汇编遗留下来的空白,修改了机器代码。现在的他,看上去是那么的的完美,就等待着Shell为他分配空间,将他加载到内存,让他降临到这个世界了!Hello!Hello先生!
第6章 hello进程管理
6.1 进程的概念与作用
Hello先生现在已经变成了一个可执行目标程序,但是他仅仅是静静的带在磁盘中,并不能做任何事,他随时等待着Shell为他创建进程,从而加载到内存中去,获得真正的生命。所以,进程的概念是:
进程:一个执行中程序的实例,系统中的每个程序都是运行在某个进程的上下文中。
Hello先生一旦被Shell赋予生命,成为一个系统中的进程,那么他就可以拥有属于自己的私有地址空间,和一个独立的逻辑控制流,仿佛所有CPU和内存都为他服务着,这种假象来自于进程这个概念。所以进程的作用是:
进程的作用:进程提供了两个关键抽象:独立的逻辑控制流,他提供一个假象,好像我们的程序独占地使用处理器。私有的地址空间,他提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
Shell-bash(壳)是一个面向用户的进行进程管理的应用程序,其主要区别于内核。他的作用是根据用户输入的指令,创建、加载或终止进程,同时也可以让用户查看当前进程表。
Shell本质是一个应用程序,他首先打印一个命令行提示符,指示用户可以输入命令,如果用户输入了一个命令,那么Shell将会读取这个命令并解析这个命令,命令的第一个参数要么是Shell内置的命令(例如quit、jobs、bg或fg),那么Shell就会直接执行此命令,要么是一个可执行程序的路径,Shell会创建(fork)出一个子进程,接下来在这个子进程的上下文中加载(execve)这个程序。如果用户要求后台进行此程序,则Shell返回到命令行提示符,否则等待(waitpid)进程终止,并进行进程回收。
6.3 Hello的fork进程创建过程
fork函数,一个会创建一个新的运行着的子进程的函数。新创建的子进程几乎但不完全与父进程相同,子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本(包括代码、数据段、堆、共享库以及用户栈),子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。子进程与父进程最大的区别在于,子进程有不同于父进程的PID。
另外,fork函数是一个调用一次返回两次的特殊函数,子进程返回0,父进程返回子进程的PID。所以可以用返回值来分别编写父子进程的程序,从而让父子进程执行不同的代码。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。execve函数有着三个参数,分别是:
filename:要加载并运行的可执行目标文件。
argv:参数列表
envp:环境变量
execve函数首先调用加载器(loader),将可执行目标文件的代码和数据从磁盘复制到内存中(事实上,加载器是通过将虚拟地址空间中的页映射到可执行目标文件的页大小的片(chunk)上,从而将子进程上下文中的代码段和数据段初始化为可执行目标文件的内容),然后通过跳转到程序的第一条指令或入口点来运行该程序。
在这里,入口点是_start函数,它在ctrl.o中定义,对所有的C程序都是一样的,它调用系统启动函数__libc_start_main,这个函数定义在libc.o中,它初始化执行环境,调用用户层的main函数,处理main函数的返回值,并且在需要的时候把控制返回给内核。
当执行完上述内容后,main函数开始执行,用户栈已经被组织完成,栈顶指向着的是main函数未来的栈帧。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
操作系统内核使用一种称为上下文切换的叫高层形式的一场控制流来实现多任务。内核为进程维护一个上下文,上下文就是内核重新启动一个被抢占的进程所需的装太,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内核数据结构。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程,这中决策就叫做调度(scheduling),是由内核中称为调度器的代码处理的。在内核调度了一个新的进程运行后,他就抢占当前进程,并使用一种称为上下文切换的机制来将控制专业到新的进程。
在介绍上下文切换之前,我们需要了解用户态和内核态两种状态。
控制寄存器中存在一个模式位,它表示当前进程的状态,若设置了模式位,则表示当前进程是处于内核态的,反之是处于用户态的。两种状态下的进程最大的不同就是,内核态的进程享有执行指令集中的所有指令权限,并可以访问系统中任何位置的内存。
当发生上下文切换时,系统会保存当前进程的上下文,恢复某个先前被抢占的进程被保存的上下文,并将控制传递给这个新恢复的进程。在这个过程进程发生了从用户模式到内核模式再到用户模式的过程。
6.6 hello的异常与信号处理
(以下格式自行编排,编辑时删除)
hello执行过程中会出现哪几类异常,会产生哪些信号,又怎么处理的。
程序运行过程中可以按键盘,如不停乱按,包括回车,Ctrl-Z,Ctrl-C等,Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。、
接下来我们终于可以运行Hello先生了,看!他伴着(并不存在的)BGM出现在了终端之上,打印出了我的姓名和学号!但是他的人生和我们的人生是一样的,并不是一帆风顺风雨无阻的,总会有一些不尽人意的事情发生。接下来,我将分析Hello先生在运行中的异常和信号,以及它会怎么处理这些信号。
1)程序运行时输入回车和乱按
在Hello先生运行时,我们不断的敲打回车,发现Hello先生依然完成了他的使命——打印8次我的姓名和学号,只不过在打印过程中有一些我输入的垃圾插在Hello先生的输出中间。
在这个过程中,Hello先生的进程发生中断(interrupt)异常,中断往往是来自于IO设备的,在这里就是我输入的回车,这种异常是异步的,也就是说在进程运行时随时可以发生。在发生中断之后,系统要等待当前指令执行完毕,将控制权交给中断处理程序,中断处理程序总是返回到下一条指令,就好像没有发生过中断一样。
同时,由于输入的内容并不会影响程序运行,系统执行中断处理程序并发送信号,但Hello先生阻塞了这种输入发送的信号,所以乱按和回车不会对Hello先生的人生有任何影响。
2)程序运行时按Ctrl-z
输入Ctrl-z同样会产生中断异常,但是内核会发送一个SIGTSTP信号,表示来自终端的停止信号,父进程接收到这个信号并挂起当前进程。
此时输入ps,打印出各进程的pid可以发现有被挂起的Hello先生:
输入jobs,打印出了被挂起进程组的jid,可以看到Hello先生,以及被挂起的标识Stopped。
输入pstree,打印出当前的进程树:
输入fg 1,来将挂起的Hello先生调至前台,继续作业:
重新运行Hello先生,同样输入Ctrl-z将其挂起,可以发现它在后台被挂起,PID为4207,此时运行kill -9 4207来给Hello先生发送一个SIGKILL信号,从而杀死Hello先生,再运行ps,可以发现Hello先生被杀死了。
3)程序运行时输入Ctrl-c
输入Ctrl-c同样会产生中断异常,同时内核会向父进程发送一个SIGINT信号,表示来自键盘的中断信号,父进程接收到这个信号,结束并回收当前进程。
6.7本章小结
在本章中,我们成功的将Hello先生降临到了这个世间,Shell为他创建了子进程,同时在这个进程的上下文中加载并运行了Hello先生。同时我们也在Hello先生的人生中添加了一些小麻烦,各种信号和异常可以让Hello先生毫发无伤,或者被挂起,或者,残忍的将他杀死。但不管怎么说,进程管理让Hello先生出生了,并假想的独占内存和CPU,这不是挺好的嘛!
第7章 hello的存储管理
7.1 hello的存储器地址空间
Hello先生已经可以通过伟大的Shell来加载到内存中并运行了,但是Hello先生发现事情似乎并没有他想象的那么顺利,自己申请的地址空间那么大,但是为什么物理内存中,真正属于自己的却是那么的少呢?而且execve函数在加载Hello先生的时候,说是要把Hello先生的代码和数据都复制到内存中,但是结果OS就给Hello先生的页大小的片一个映射,就告诉他分配完了?!只有Hello先生伸手去要内存中内容的时候,OS才通过缺页异常来进行实际上的复制,这可把Hello先生气坏了,他决定一探究竟!
在探索Hello先生的内存管理模式之前,我们先明确以下几个概念:
逻辑地址(logical address):在有地址变换功能的计算机中,访问指令给出的地址 (操作数) 叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的实际有效地址,即物理地址。回顾Hello先生的反汇编我们可以发现,在汇编语言中的某些语句中,常有一些寻址计算,这些寻址计算的操作数就是逻辑地址,是程序角度的相较于段起始位置的相对地址。
线性地址(linear address):如果一个地址空间中的整数是连续的,那么我们就说他是一个线性地址空间,其中的地址便是线性地址。Hello先生的线性地址可以说是其逻辑地址(相对地址)加上段基址所得到的地址。
虚拟地址(virtual address):现代处理器使用虚拟地址和虚拟寻址来解决珍贵的内存资源分配问题和防止每个进程的地址空间不被破坏,CPU通过生成一个虚拟地址来访问主存,这个虚拟地址会通过内存管理单元(MMU)翻译成物理地址。在Linux下,线性地址和虚拟地址在数值上相等。
物理地址(physical address):计算机系统的主存被组织成一个有M个连续的字节大小的单元组成的数组,每个字节都一个唯一的物理地址,这给CPU提供了一种原始自然的寻址方式,称为物理寻址,但这往往会带来诸多问题,使用虚拟寻址方式是线代CPU的选择。Hello先生在运行时是对其物理地址完全不知的,他只需向操作系统提供其虚拟地址,让操作系统来进行内存管理和分配相应的物理页。
7.2 Intel逻辑地址到线性地址的变换-段式管理
一个逻辑地址由两部分组成,段标识符和段内偏移量。段标识符是由一个16位长的字段组成,称为段选择符。其中前13位是一个索引号,指向段描述符表中的一个段描述符,这个段描述符描述着一个段的起始位置。TI位指示这个段描述符在全局描述符表(GDT)中还是局部描述符表(LDT)中,从表中读出段的起始位置,而线性地址便是这个段的起始位置加上段内偏移量。
下图表示这一过程:
7.3 Hello的线性地址到物理地址的变换-页式管理
物理内存中常驻一个数据结构,将虚拟页映射到物理页中,称作页表(page table),每次地址翻译硬件讲一个虚拟地址转换为物理地址时,都会读取页表,OS负责维护页表和在磁盘与DRAM之间来回传送页。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个页表条目,也就是页表中一个条目,页表条目中存在一个有效位,标识着这个条目是已在物理地址中缓存的还是未缓存的,若是未缓存的,则它若是一个空地址,则说明它是未分配的,否则是已分配的但是未缓存的,并且指向虚拟内存中的一个页的起始位置。
例如上图,若CPU想要读包含在VP2中的虚拟内存的一个字时,MMU通过地址翻译将这个虚拟地址映射到页表中的一个条目,这里是PTE2,发现PTE2被设置了有效位,那也就是说他已经缓存在了物理内存中,通过物理页号和偏移量组合在一起(地址翻译过程我们稍后再做解释),获得了物理地址,从而获得了内容。此时称发生了页命中。
7.4 TLB与四级页表支持下的VA到PA的变换
接下来我们将仔细的说明地址翻译和多级页表、TLB对地址翻译的辅助作用。
1)单一页表的地址翻译
形式上说,地址翻译是一个N元素的虚拟地址空间(VAS)中的元素和一个M元素的物理地址空间(PAS)中元素的一个映射。利用页表,我们可以进行如下地址翻译过程:
将n为虚拟地址分为虚拟页号(VPN)和虚拟页偏移量(VPO)两部分,其中虚拟页号和页表基址寄存器(PTBR)中存储的页表起始地址共同作用,生成对页表的索引,页表中储存着有效位和物理页号(PPN),若有效位为1,则将这个物理页号取出与虚拟页偏移量组合在一起形成物理地址,若有效位为0,则说明这个页表条目为未分配或者位未缓存的。在这里我们就可以思考一下该如何设置页表条目的状态了,若有效位为0且地址为空则设置为未分配,否则设置地址为虚拟页号,即指向了一个虚拟页。
2)TLB加速地址翻译
TLB(translation lookaside buffer),翻译后备缓冲器,是MMU中关于PTE的一个缓存,是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。其中,TLB将虚拟地址分为以下三部分,TLB标记(TLBT)、TLB索引(TLBI)和虚拟页偏移量(VPO),其中TLBT和TLBI是单一页表地址翻译过程中的虚拟页号(VPN),TLBI占t位。
我们可以发现,TLB与Cache(高速缓存器,我们马上就会介绍Cache)非常相似,标记和索引都是用来寻找行和组的。下图是i7CPU的地址翻译过程,我们接下来的叙述都会围绕这个图展开。
同样的,TLB作为页表的缓存,也存在TLB命中和TLB不命中的情况。当CPU给出一个虚拟地址时,MMU先取出VPN去查询TLB,若TLB命中则取出缓存好的物理页号(PPN)和虚拟地址中的VPO组合在一起,形成物理地址。否则去查询页表,利用多级页表查询得知PPN并获得物理地址。
接下来我们将介绍多级页表。
3)多级页表
如果使用单一页表来进行地址翻译,我们发现想要覆盖整个虚拟地址空间往往是复杂的、不切实际的,所以我们使用多级页表的概念,将第一级页表中的每个条目对应于虚拟内存中的一个较大的片,将这个页表条目指向下一级页表的起始位置,并且将虚拟地址的VPN分为多段,每一段负责索引一个页表中的条目,以此类推下去,使用较少数量的页表便可以对应大面积的虚拟内存片。
以i7Core为例,将虚拟地址的VPN分为四段,每段9位,用于索引页表中的条目,依次类推下去,最后一个页表中存储着PPN,用于得出物理地址。简单思考一下似乎觉得,多级页表往往会导致更昂贵的查找花销,但实际上,通过TLB将不同层次上页表的PTE缓存起来,多级页表的花销并不会比单级页表慢很多。
多级页表的优势在于,往往大部分的虚拟内存都是未分配的,所以一级页表的地址不存在时,二级页表就不可能存在,这产生了巨大的潜在节约。另外,内存管理系统可以在需要的时候调入和调出二级页表,不需要一直将其保存在主存中。
7.5 三级Cache支持下的物理内存访问
获得物理地址后,我们就需要在主存中寻找我们需要的内容,但是我们可以简单的使用物理地址去获得主存中的内容么?显然是不可以的,主存的数据传输速度比CPU慢上很多,更可怕的是,如果你的物理地址指向磁盘中的某一个位置,那么从磁盘中获取数据将会是更加慢的,这会大大增加系统的时间开销,所以我们在CPU和主存之间增加了多级缓存,称为高速缓存器Cache。我们将高速缓存组织为以下形式的结构:
同TLB类似的,我们将物理地址分为三部分,分别是标记、组索引和块偏移,使用标记去索引行,用组索引去索引组,用块偏移指示所取字在块内的偏移。当CPU想从主存物理地址中获取一个字时,内存管理系统首先将物理地址分为上述三部分,在Cache中进行寻找,若缓存命中,则直接将内容返回给CPU,否则发生缓存不命中,系统会在下一层缓存中寻找包含这个内容的块,直到缓存命中,并将块替换到上一层缓存中。
7.6 hello进程fork时的内存映射
当fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给他一个唯一的pid。为了给这个新进程创建虚拟内存,他创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有写时复制。当fork从新进程返回,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的程序,用hello程序有效地替代了当前程序。加载并运行hello需要:1)删除已存在的用户区域:删除当前进程虚拟地址的用户部分中的已存在的区域结构。2)映射私有区域:为新程序hello的代码、数据、bss 和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data 区。bss 区域是请求二进制零的,映射到匿名文件,其大小包含在hello 中。栈和堆区域也是请求二进制零的,初始长度为零。3) 映射共享区域:如果hello程序与共享对象(或目标)链接,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。4) 设置程序计数器(PC) :设置当前进程上下文中的程序计数器,使之指向代码区域的入口点。
下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
7.8 缺页故障与缺页中断处理
如上图所示,当我们想要查询VP3的内容时,发现页表条目未设置有效位,则说明这是未缓存的页,发生了缺页异常,OS通过异常处理程序,将虚拟内存中的内容(也就是磁盘上的内容),复制到了屋里内存中,并将一个物理页替换,在这里被替换的是VP4,。接下来,OS会更新PTE3和PTE4,将控制权返回到当前指令处,重新执行指令。这就是为什么缺页异常这个典型的故障要返回到当前指令处。此时称发生了缺页。
当我们想要分配页面时,例如我们调用了malloc函数时,此时会在磁盘上增加虚拟页,再将虚拟内存对应的页表条目更新。
例如我们想要分配一个虚拟页VP5时,分配过程是:首先在磁盘中创建新的虚拟页VP5,并修改其对应页表条目PTE5为未缓存且指向VP5。
7.9动态存储分配管理
我们发现,在使用虚拟内存时往往要申请和释放虚拟内存,虽然可以使用mmap函数进行虚拟内存的创建和删除,但是使用动态内存分配器会具有更好的移植性。动态内存分配器维护着一个进程的虚拟内存区域,称为堆,分配器将堆视为一组不同大小的块的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。
每当程序显式的要求分配一个块时,程序可以调用malloc函数,其返回请求快的起始地址,若想要释放掉这个块,则可以调用free函数,将这个块释放掉。
对于分配器,有以下需要考虑的实现问题:
空闲块组织:我们如何记录空闲块?
放置:我们如何选择一个合适的空闲块来放置一个新分配的块?
分割:在将一个新分配的块放置到某个空闲块之后,我们如何处理这个空闲块中的剩余部分?
合并:我们如何处理一个刚刚释放的块?
1)空闲块组织
对于空闲块的组织方法,我们可以采用隐式空闲链表,即利用头部记录空闲块的大小,隐式的串联起所有空闲块;或者采用显示空闲链表,在空闲块中占用两个字,来分别指向祖先和后继,如同双向链表一般,显式的指向前驱和后继;或者使用分离的空闲链表,将所有空闲块按照大小组织成各个等价类,每个等价类对应一个链表,链表中的空闲块按照大小一次连接;或者采用红黑树这种高级数据结构,来组织空闲块,红黑树是一种平衡树,它保证了在放置、分割和合并时的平衡性,从而保证了查询时的高效。
2)放置策略
放置策略有首次适配,即遍历链表寻找第一个可以放置的位置;下一次适配,从上一次放置的地方开始寻找下一个可以放置的地方;最佳适配,检查每个空闲块,找到最合适放置的位置。
3)分割策略
放置往往伴随着分割,我们要将新的空闲块重新组织,根据组织方式的不同,有不同效率的分割方法。
4)合并策略
通过带标记的合并方式,分配器可以快速的将刚刚释放出来的块和其相邻的空闲块进行合并,标记即空闲块的脚部,与头部相同,但是便于合并时向上合并,因为向上合并往往是不知道空闲块的大小的,会丧失合并能力,添加标记,便可以方便的进行合并,这种标记很像双向的隐式空闲链表。
另外,分离适配的空闲链表是不需要合并的。
7.10本章小结
在本章中,我们讲述了Hello先生的内存管理,虚拟内存和内存映射的概念让Hello先生空欢喜一场,他以为世界都是围绕着它转的,结果只是假象而已,但是计算机系统是多元的,它允许进程并发、允许进程共享某些代码和数据但又为每个进程维护着私有地址空间,就像人类社会一样,都是有机的结合在一起的,只有这样,一个系统才可以高效的、有纪律的进行工作,你我皆是这其中的一块积木...
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
说了这么多,Hello先生已经有点不耐烦了:“到底还能不能让我输出了!”,别着急Hello先生,你距离完成使命就差一步之遥了,接下来我将介绍能够让Hello先生能做到输出到屏幕上的过程,IO管理。
Linux下,所有的IO设备都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅的映射为文件的方式,允许Linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行。
8.2 简述Unix IO接口及其函数
IO接口使得输入和输出能以一种统一且一致的方法来执行:
1)打开文件
一个应用程序通过要求内核打开相应的文件,来宣告它想要访间一个I/O 设备。内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
2)初始打开文件
Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0) ,标准输出(描述符为1) 和标准错误(描述符为2) 。头文件< unistd.h> 定义了常量STDIN_FILENO 、STOOUT_FILENO 和STDERR_FILENO, 它们可用来代替显式的描述符值。
3)改变当前的文件位置
对于每个打开的文件,内核保持着一个文件位置k, 初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek 操作,显式地设置文件的当前位置为k 。
4)读写文件
一个读操作就是从文件复制n>0 个字节到内存,从当前文件位置k 开始,然后将k增加到k+n 。给定一个大小为m 字节的文件,当k>=m 时执行读操作会触发一个称为EOF 的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF 符号” 。
类似地,写操作就是从内存复制n>0 个字节到一个文件,从当前文件位置k开始,然后更新k 。
5)关闭文件
当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
相关函数:
1)打开文件
进程是通过调用open函数来打开一个已存在的文件或者创建一个新文件的:
open函数将filename转换为一个文件描述符,并且返回描述符数字。返回的描述符总是在进程中当前没有打开的最小描述符。flags参数指明了进程打算如何访问这个文件:只读、只写或可读可写。同时flag也是一个或者更多为掩码的或,为写提供给一些额外的指示。mode参数指定了新文件的访问权限位。
2)关闭文件
进程通过调用close函数关闭一个打开的文件:
3)读写文件
进程通过调用read函数来执行输入、write函数来执行输出:
调用read函数,从当前文件位置复制字节到内存位置,然后更新文件位置。从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示错误,返回值0表示EOF,否则,返回值表示的是实际传送的字节数量。返回类型 ssize_t 是有符号整数。调用write函数,从内存复制字节到当前文件位置,然后更新文件位置。从内存位置buf复制至多n个字节到描述符fd的当前文件位置。返回值-1表示出错,否则,返回值表示的是从内存向文件fd实际传送的字节数量。
8.3 printf的实现分析
我们先来观察一下printf函数的代码,他非常简短,其实只有三条重要语句,我们逐一来进行讲解。
1)va_list arg = (va_list)((char*)(&fmt) + 4);
首先,va_list的定义为:typedef char *va_list,也就是说他是字符指针类型,而(char*)(&fmt) + 4便是printf的第二个参数,通过对程序运行时的栈帧的了解我们能知道,第一个参数最后一个入栈,那么他的地址+4(32位系统)便是第二个参数的地址,也就是说arg是第二个参数的地址。
2)i = vsprintf(buf, fmt, arg);
vsprintf函数返回要打印出字符串的长度,vsprintf的作用是格式化。它接受确定输出格式的格式字符串fmt。用格式字符串对个数变化的参数进行格式化,产生格式化输出。
3)write(buf, i);
其汇编代码为:
int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call函数,这个函数是为了保存中断前进程的状态,简单来说他就实现了功能:显示格式化了的字符串。
sys_call:
;ecx中是要打印出的元素个数
;ebx中的是要打印的buf字符数组中的第一个元素
;这个函数的功能就是不断的打印出字符,直到遇到:'\0'
;[gs:edi]对应的是0x80000h:0采用直接写显存的方法显示字符串
接下里便是通过字符显示驱动子程序,从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
哇哦,Hello先生!你终于出现了!
8.4 getchar的实现分析
getchar由宏实现:#define getchar() getc(stdin)它有一个int型的返回值。当程序调用getchar()时,它会等待用户按键来输入字符。用户输入的字符被存放在缓冲区中,直到用户按了回车键,这时getchar()才会从stdio流中读入一个字符。
getchar函数的返回值是用户输入的字符的ASCII码,若出错返回-1,且将用户输入的字符回显到屏幕.若用户在按回车之前输入了不止一个字符,其他字符会保留在键盘缓存区中,等待后续getchar调用读取.也就是说,后续的getchar调用不会等待用户按键,而直接读取缓冲区中的字符,直到缓冲区中的字符读完为后,才等待用户按键。
这可以看做一个异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。getchar函数调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
本章介绍了Linux系统下的IO管理,通过IO设备,Hello先生终于可以实现在屏幕上输出了!他输出的是什么呢!我的姓名和学号!这可能是一个普普通通但是却脚踏实地潜心钻研,努力研究Hello先生的人吧,我猜的,到底是不是呢?或许等十年二十年之后我再次回来看到这篇文章,我才能得出答案吧!
结论
Hello先生,一个普通而又伟大的程序,他是第一个睁开眼睛看程序世界的人,他是那么的简单,但是却又代表着那千千万万更复杂但却与他所经历的类似的程序。学习他,我们便能漫游这计算机系统,感知它的伟大与精妙。
刚开始,他是一个.c文件,那是一个文本文件,静静的躺在磁盘当中,在OS的眼中,他于其他文件并无二致,甚至都不关心他的内容,而现在的Hello先生也没有任何意识,这只是一个记录着Hello先生最终使命一段高级语言文本罢了。
突然有一天,我心血来潮的将Hello先生进行了初始化,预处理器对宏定义和外部库引入进行了字符串的插入和替换,Hello先生一下子变得好长好长,但又看上去更完整了,他变成了一个.i文件,我饶有兴致。
接下来,我执行了编译命令,编译器将这个.i文件翻译成汇编语言,他那些长长的代码都被汇总,取其精华,变成了一段我更加难以读懂,但勉强可读的汇编语言文本文件.s文件。我很高兴,这或许是Hello先生的成长,把书度厚再读薄的一个过程就是如此吧,我感叹到。
紧接着,我执行了汇编指令,汇编器将这个.s文件中的汇编语言翻译成机器语言,同时生成了一个可重定位目标文件(.o文件),在这一步中,Hello先生摇身一变,变成了拥有一个较为复杂结构的文件,这个文件拥有着Hello先生定义和引用的数据、函数,代码段、数据段、重定位信息段等段式管理结构纷纷出现,这让Hello先生不仅拥有了简单的机器代码,还有着其他更完整的东西,同时,Hello先生还给链接预留了一些空间和信息,帮助链接器将他组装完整。
这不,我已经输入了链接的命令,将各个库动态链接起来,链接器进行符号解析和重定位,将上一步Hello先生中给链接器预留的地方填起来,修改了机器代码,同时利用GOT和PLT数组,给动态链接留了一手,这个时候他已经是可执行目标文件了,随时等待着运行,我开心的拍起了手。
链接之后,他等待这Shell为他分配资源,将他加载并运行。但是谁知Shell为他分配内存的时候,只给了他一些映射,这让Hello先生有些沮丧,但是OS告诉他,没关系,你只要去争取,总会成功的。果然,当Hello先生第一次申请使用那一块虚拟内存时,OS通过内存管理系统为他映射了物理内存,虽然只是一小块,但是没关系,这正是Hello先生所需的,我拍案叫绝。
接下来,CPU每执行Hello先生的一条指令,TLB和四级页表都为Hello先生给出的虚拟地址翻译做足了优化,多级Cache联合硬件和软件纷纷为他效命。Shell通过进程管理,fork出一个子进程,在这个新的子进程中删除原有的虚拟内存,在上下文中给Hello先生的代码和数据做一个内存映射,将Hello先生的虚拟内存初始化,虽然是假象的让Hello先生独自的享用CPU和内存,但是没关系,Hello先生知道这个伟大的计算机系统中,不能缺少其他程序的并行,你我皆是这社会上积木,缺一不可,折让我静下心来。
最后,通过IO管理,Hello先生运行了pirntf函数,将目标打印到了屏幕上,Linux下,黑底白字,赫然显示着我的学号和名字,我沉思着。
这或许只是一个普普通通的大学生,但是他现在坐在电脑面前,认真的查阅着资料,观察着Hello先生的一举一动,对一个困难问题的理解往往需要一天甚至一周的时间苦思冥想才能茅塞顿开,他不求大红大紫,只求脚踏实地,在这个世界上,像Hello先生学习,平凡但注定不平凡。
附件
hello.i 预处理后修改了的源程序
hello.s 汇编生成的Hello先生的汇编程序
hello.o 编译生成的Hello先生的可重定位目标程序
hello 链接生成的Hello先生的可执行目标程序
参考文献
[1] printf函数实现的深度剖析 https://www.cnblogs.com/pianist/p/3315801.html
[2] 兰德尔·布莱恩特. 大卫·奥哈拉伦. 深入理解计算机系统 机械工业出版社
[3] 动态链接原理分析 https://blog.csdn.net/shenhuxi_yu/article/details/71437167
[4] 深入浅出静态链接和动态链接https://blog.csdn.net/kang___xi/article/details/80210717
[5] 逻辑地址、线性地址、物理地址的区别http://blog.csdn.net/erazy0/article/details/6457626