有关本文的PDF和相关附件,请移步GitHub:https://github.com/szm981120/CSAPP_lastwork
目录
摘要
本文以Linux环境下,简单的C程序hello.c从program到process的过程为线索,介绍了GCC编译系统的四个工作环节。又以运行hello程序为核心,展开介绍了程序的进程管理,相关数据的存储管理,和I/O管理。以《深入理解计算机系统》(第三版)为主要参考文献,结合实际操作,图文并茂地介绍了一些具体的概念、原理和实践。对读者理解计算机中程序的一生有一定的帮助作用。
关键词:编译系统;进程;信号与异常;内存管理;Linux I/O管理
第1章 概述
1.1 Hello简介
Hello的P2P(from program to process)过程,就是从编程到处理执行的过程。我们在编译器中编写高级语言代码,比如常用的Visual Studio,codeblocks等,用编译器构建(Build)成功后,就生成了一个.exe可执行程序文件。这只是P2P的表面过程。
P2P的深层过程,可以在Linux下编译代码中得到体现。假设我们在Linux环境下,用GCC编译器驱动程序来编译hello.c,有四个阶段:预处理阶段、编译阶段、汇编阶段、链接阶段,这四个阶段的主角分别是:预处理器(cpp)、编译器(ccl)、汇编器(as)和链接器(ld)。
经历了这四个阶段,hello.c就成为了hello.out(Linux环境),P2P的过程就结束了。
Hello的O2O(from zero-0 to zero-0)过程,就是“赤条条地来,赤条条地走”。从在编辑工具里编辑好hello程序之后,经过曲折的P2P之路,由hello.c经历hello.i, hello.s, hello.o和其他.o一起链接成为hello.out。接着,我们在进程中调用hello,实现了hello的逻辑控制流的意义所在。Execve函数运行起hello,在虚拟内存空间中给hello分配空间,又有地址翻译把hello的虚拟地址翻译成物理地址,硬件根据物理地址在主存中取址,形成软硬件结合的运行体系。Hello运行结束后,进程终止,内存回收,内核把关于hello的一切数据全部抹去,这样hello“挥一挥手,不带走一片云彩”。
不仅仅是针对hello,正是有了P2P和O2O,计算机中的程序才得以正常运行,有条不紊,不会出错。
1.2 环境与工具
硬件环境:Intel Core i5 7200U,2.50GHz,4GB RAM,256GB SSD
软件环境:Windows 10 家庭中文版,Ubuntu 18.04.1 LTS(VMware)
工具:codeblocks,gedit,gcc,objdump,readelf 等
1.3 中间结果
hello.c | 源程序C语言代码 |
hello.i | hello.c预处理后的文本文件 |
hello.s | 编译后的汇编语言文本文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 链接后的可执行目标文件 |
helloo_obj.s | hello.o利用objdump工具的反汇编代码 |
hello_obj.s | hello利用objdump工具的反汇编代码 |
其他中间结果 | readelf和objdump的其他调试代码,反映在了标准输出中,没有保存到本地文件 |
1.4 本章小结
本章介绍了hello的P2P(from program to process)和O2O(from zero-0 to zero-0)过程,是全文的一个简单概括。本章还列举了环境工具和中间结果,以供读者总览。
第2章 预处理
2.1 预处理的概念与作用
预处理是源文件到目标文件转化的第一环节。GCC编译器驱动程序把源程序文件翻译成可执行目标文件,首先要经历预处理阶段。
在预处理阶段,预处理器(cpp)根据以字符#开头的命令,修改原始的C程序。这些命令通常是.c文件开头的一些以#开头的命令。
这些命令告诉预处理器读取相应的文件(如头文件),并把这些文件直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名。
2.2 在Ubuntu下预处理的命令
GCC编译器驱动程序的预处理器的预处理命令格式如下:
gcc -E xx.c -o yy.i
其中,-E选项指只预处理,不编译。xx.c是源文件。-o选项指定了预处理输出文件的文件名,这个文件名就是yy.i,通常我们建议输出文件名与源文件同名,而不同后缀。
Gcc选项还可以加-C选项,表示预处理时不删除注释信息,配合-E选项使用。
2.3 Hello的预处理结果解析
预处理过程其实是把源程序.c文件,扩展成为一个新的文本模式的.i文件,扩展内容就是预处理的插入内容。
由图2.4可以看出,hello.c经过预处理后,源代码前面插入了大量的其它代码,这是由预处理器完成的。并且源代码的预处理命令已经被移除掉了,事实上,这些预处理命令被翻译成了插入的代码。
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 xx.i -o yy.s
或者
gcc -S xx.c -o yy.
其中,-S选项指只编译,不汇编和链接。-o选项指定了输出的文件。至于操作对象,既可以是源文件.c,也可以是预处理后的.i文件,如果是对.c文件操作,则gcc默认对.c文件先进行预处理,再进行编译。
另外,-O选项给编译过程提出了指定的优化级别。Gcc编译器有几种优化模式:-O0, -O, -O1, -O2, -Os, -O3,优化级别越高优化效果越好,但编译时间会变长。
3.3 Hello的编译结果解析
hello.c中包含了很多数据类型和指令操作,下面逐一解析。
3.3.1 数据
hello.c中的常量都是在语句中出现的。这种常量在汇编语言中都是用立即数直接表示的。
- 常量
- 变量
hello.c中的变量主要有全局变量sleepsecs和局部变量i。全局变量在.s文件中的头部声明。
这部分表示,sleepsecs在.text声明为全局变量,存放在.data节中,对齐方式为4字节对齐,并把sleepsecs声明为@object类型,大小为4字节。接着,又给sleepsecs赋值为long类型的值2(源文件中,int sleepsecs = 2.5,long和int类型大小相同,编译器将int转成了long存储,并且把赋值语句右侧的2.5直接舍成2).
至于局部变量i,是在main函数中声明的。对于编译器来说,局部变量要么保存在寄存器中,要么保存在栈空间中。从编译结果看出,此处的变量i被保存到了栈空间中,还可以找到循环的大致位置。
3.3.2 操作
1. 赋值
hello.c中一共有两处赋值,一个是全局变量sleepsecs的初始化赋值,一个是局部变量i的初始化赋值。全局变量的赋值是在.s的头部就已经做好了的,值在.data节中。至于局部变量的赋值,用的是MOV指令。
movl $0, -4(%rbp)
局部变量i保存在栈中,具体位置在-4(%rbp)。这句操作指令的含义是把立即数0,传送到栈上的-4(%rbp)中,即对i赋值。需要注意的是64位系统有几种不同的MOV指令:
指令 | 效果 | 描述 |
MOV S, D |
D←S | 传送 |
movb | 传送字节 | |
movw | 传送字(2字节) | |
movl | 传送双字(4字节) | |
movq | 传送四字(8字节) | |
movabsq I, R | 传送绝对的四字 |
拿hello.c中的i赋值为例,这里用的是movl,也就是传送4字节的数据,而i是int类型的。
2. 类型转换
hello.c中只有一个隐式的类型转换,就是在全局变量的初始化赋值中。这里的类型转换是把浮点数转换为整型,这种情况是基本类型转换中比较复杂的情形。因为对于浮点数来说,向整数舍入,通常是找到最接近浮点数的整数来作为舍入结果。但是,如果有两个整型距离这个浮点数相同,就需要决策了。一种比较好的方式是向偶数舍入,这种方式决定把2.5舍入为2,而把3.5舍入为4.在统计学中,这种舍入方法是简单方法中,较优的一个。因此我们也看到了,在.data节,sleepsecs的值被声明为2.
简单数据类型的转换还有很多种。通常来说,低精度向高精度的舍入不太可能造成数据丢失,但高精度向低精度的转换可能会损失精度,甚至出现意想不到的效果。比如int类型的-1其实是unsigned int类型的最大值,负数转换为unsigned类型时,会有这些麻烦。
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 | 逻辑右移 |
hello.c中无逻辑操作。
4. 关系操作
hello.c中的关系操作在判断语句中,包括循环终止条件的判断。而这些关系操作也仅限于大小比较。汇编语言中的比较不仅限于大小的比较。
指令 | 基于 | 描述 |
cmpb S1, S2 |
S2-S1 | 比较字节 |
cmpw S1, S2 | 比较字 | |
cmpl S1, S2 | 比较双字 | |
cmpq S1, S2 | 比较四字 | |
testb S1, S2 |
S1&S2 | 测试字节 |
testw S1, S2 | 测试字 | |
testl S1, S2 | 测试双字 | |
testq S1, S2 | 测试四字 |
CMP指令根据两个操作数之差来设置条件码。除了只设置条件码而不更新目的寄存器之外,CMP指令和SUB指令行为是一样的。
TEST指令行为与AND指令一样,除了它们只设置条件码而不改变目的寄存器的值。
以hello.c中的第一个条件判断为例:
argc!=3
它的汇编代码是:
cmpl $3, -20(%rbp)
je .L2
这是指,用argc和3作差,根据结果设置条件码,再根据条件码,判断是否跳转到L2处。
5. 控制转移
hello.c中的控制转移也是伴随着条件判断出现的。比如说,如果argc!=3,那么就执行if块的语句。如果i<10的话,那么就继续i++,并转而执行新的迭代。汇编语言中的控制转移有两种,一种是通过访问条件码,用jump指令配合条件控制来执行控制转移。另一种是用条件传送来实现条件分支。不加优化编译的hello.s中无条件传送指令。
指令 | 跳转条件 | 描述 |
jmp Label | 1 | 直接跳转 |
jmp *Operand | 1 | 间接跳转 |
je Label | ZF | 相等/零 |
jne Label | ~ZF | 不相等/非零 |
js Label | SF | 负数 |
jns Label | ~SF | 非负数 |
jg Label | ~(SF^OF)&~ZF | 大于(有符号) |
jge Label | ~(SF^OF) | 大于或等于(有符号) |
jl Label | SF^OF | 小于(有符号) |
jle Label | (SF^OF)|ZF | 小于或等于(有符号) |
ja Label | ~CF&~ZF | 超过(无符号) |
jae Label | ~CF | 超过或相等(无符号) |
jbe Label | CF | 低于(无符号) |
jbe Label | CF|ZF | 低于或相等(无符号) |
对hello.c中的循环终止条件判断解析:
6. 函数操作以及参数
hello.c中共有五个函数调用,除了main函数外,还有printf、exit、sleep和getchar函数。其中,只有getchar没有参数。
函数作为一种过程,假设过程P调用过程Q,有这样的机制:
传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
传递数据。P必须能够向Q提供一个或者多个参数,Q必须能够向P返回一个值。
分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放掉这些空间。这些空间往往是运行时栈。
以main函数为例,作为分析。
7. 字符串和数组
hello.c中也是有字符串和数组的。事实上这里面的字符串作为printf的参数,在主函数中是以如下形式声明的:
而数组char *argv[]是main的参数,在栈空间中,argv的首地址被存放在-32(%rbp)的位置,调用数组元素时,根据栈指针加上偏移量来寻址。
3.4 本章小结
编译是“hello”P2P之路的第二步,结果是把源程序代码转化成了汇编语言代码。汇编语言是高级程序语言和机器语言之间的桥梁。编译系统的工作就是把源程序代码,不断地朝着机器能够识别的代码的方向转化。
第4章 汇编
4.1 汇编的概念与作用
汇编是源文件到目标文件转化的第三环节。经过了预处理和编译,源文件已经变成了由汇编语言编写的.s文件。接下来,由汇编器(as)将.s文件翻译成为机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并把结果保存在目标文件.o中。如果我们在文本编辑器中打开.o文件,只能看到一堆乱码。
4.2 在Ubuntu下汇编的命令
GCC的汇编指令格式如下:
gcc -c xx.c -o yy.o
应作业要求,我们加上如下选项:
gcc -m64 -no-pie -fno-PIC -c hello.c -o hello.o
其中,-c选项是只进行预处理、编译和汇编,而不进行链接。xx.c是操作对象,也可以是.i或.s文件。-o指定了输出文件,输出文件是.o格式文件。建议输出文件名与操作对象同名不同后缀。
4.3 可重定位目标elf格式
ELF头 |
.text |
.rodata |
.data |
.bss |
.symtab |
.rel.text |
.rel.data |
.debug |
.line |
.strtab |
节头部表 |
要分析hello.o的ELF格式,参考表4.1,用
readelf -a hello.o
命令可以将hello.o的ELF信息打印到标准输出上。
4.3.1 ELF头
ELF头以一个16字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。可以得知ELF头的大小为64字节,目标文件类型为ELF64,系统架构为X86-64,节头部表的文件偏移1152字节等。
4.3.2 节头部表
节头部表描述了不同节的位置和大小,目标文件中每个节都有一个固定大小的条目。
4.3.3 重定位节
hello.o的重定位节中一共有8个条目,给出了偏移量、信息、类型、符号值、符号名称+加数等信息。
R_X86_64_PC32是和R_X86_64_32相对应的,比较常见的重定位类型。前者是重定位一个使用32位PC相对地址的引用,而后者是重定位一个使用32位绝对地址的引用。我在会汇编时,最开始是从之前生成的.s文件汇编过来的,这样的话图4.5会不一样,偏移量和.rodata的加数会有些许不同,另外还会遇到R_X86_64_PLT32类型,Linux Kernels网站上给出如下解释:
This Linux kernel change "x86: Treat R_X86_64_PLT32 as R_X86_64_PC32" is included in the Linux 3.18.100 release. This change is authored by H.J. Lu <hjl.tools [at] gmail.com> on Wed Feb 7 14:20:09 2018 -0800.
重定位PC相对引用的重定位算法为:
refaddr = ADDR(s) + r.offset;
*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr);
假设算法运行时,链接器为每个节(用ADDR(s)表示)和每个符号都选择了运行时地址(用ADDR(r.symbol))表示。拿.rodata的重定位为例,它的重定位地址为refptr. 则应先计算引用的运行时地址refaddr = ADDR(s) + r.offset, .rodata的offset为0x16,ADDR(s)是由链接器确定的。然后,更新引用,refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr), .rodata的ADDR(r.symbol)也是由链接器确定的,addend查表可知为+0,refaddr已经算出来了,所以,.rodata的重定位地址我们就可以算出来了。
4.3.4 符号表
符号表是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含ELF符号表。这张符号表包含一个条目的数组,每个条目的格式有如下数据结构:
typedef struct{
int name;
char type:4,
binding:4;
char reserved;
short section;
long value;
long size;
}Elf64_Symbol;
其中,name是字符串表中的字节偏移,指向符号的以null结尾的字符串的名字。value是符号的地址。对于可重定位的模块来说,value是距定义目标的节的起始位置的偏移。size是目标的大小(以字节为单位)。type通常要么是数据,要么是函数。binding字段表示符号是本地的还是全局的。
4.3.5 其它节
有些其它节在我们的hello.o是不存在的。
4.4 Hello.o的结果解析
说明机器语言的构成,与汇编语言的映射关系。特别是机器语言中的操作数与汇编语言不一致,特别是分支转移函数调用等。
hello.o是由hello.s汇编得到的,而hello.o的反汇编却和hello.s不一样了,两者不同之处,主要是汇编器对hello.s做的手脚。汇编之前,hello.s只是把扩展后的源代码翻译成了汇编代码,像是一张使用说明书。而汇编之后,hello.o获得了重定位信息,符号表等ELF格式信息,像是实战视频教程。
汇编之后,hello.o比hello.s更加具体,比如说函数调用的地址,从函数名称变成了主函数首地址加上偏移量,而条件跳转也从跳转到段名称变成了跳转到指定偏移地址。还有对全局变量的引用,之前是.LC0(%rip), 而现在是$0x0, 至于这个全局变量的地址到底在哪,这些信息都保存在重定位信息里了。还有一些细节,立即数的表示由十进制变成了十六进制。
4.5 本章小结
可以说,汇编之后的hello变得更加丰满,更加难懂,也更贴近机器了。hello.o作为可重定位的目标文件,即将和其它目标文件一起链接成最后的可执行目标文件。
到此,我们的hello已经拥有了汇编语言解释,ELF格式的诸多信息,即将达成最后的可执行文件了。
第5章 链接
5.1 链接的概念与作用
链接过程是hello成为可执行目标文件的最后一步。在经历了预处理、编译和汇编之后生成的.o文件,只需要再经过链接器(ld)和其它可重定位目标文件链接,就可以生成最终的可执行目标程序了。
5.2 在Ubuntu下链接的命令
按照要求,我们将如下文件与hello.o链接生成可执行目标文件hello。参考命令如下:
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
链接的文件都是64位库中的一些可重定位目标文件。
5.3 可执行目标文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
readelf -a hello
命令可以将hello文件的ELF格式信息输出到标准输出上。
这里有用的信息包括各段的大小,起始地址等信息。
5.4 hello的虚拟地址空间
正如图5.3所示,hello的各段的虚拟地址空间从0x400000到0x401000,可以在edb的data dump中查看。
5.5 链接的重定位过程分析
乍一看,hello和hello.o的区别有,在链接之前,各段的地址仅仅是一个偏移量,而非“地址”,链接之后,各段有了实质的虚拟地址,每一条指令也有有对应的虚拟地址。之前提到过了,从hello.s到hello.o有一个区别,是有些段寻址时,从.L0(%rip)变成了$0x0,而后者的具体地址放在了重定位信息里。现在,hello.o经过链接中的重定位后,基本上所有未确定的信息都有了确定的地址,比如说,在加载全局变量字符串时(printf的参数字串),hello中给出的指令是:
mov $0x4006f4, %edi
可以说,每一个条目都找到了虚拟内存地址。调用的函数,也都是call函数的虚拟地址。
这个重定位的过程,需要PC相对引用重定位算法好好解析一下。下面就以hello.o中的调用exit函数为例,其指令是这样的:
24: e8 00 00 00 00 callq 29 <main+0x29>
首先,对任意一条指令的重定位,我们需要知道一些信息。
按照重定位PC相对引用算法,先计算引用的运行时地址:
refaddr = 0x4005e7 + 0x27 = 0x40060e
再更新该引用:
*refptr = 0x4004e0 – 0x4 – 0x40060c = 0xfffffed0(-0x130)
其它的指令重定位,如果也是R_X86_64_PC32(R_X86_64_PLT32)的,重定位结果也是按这个算法算。如果是绝对引用的话,有如下算法:
*refptr = (unsigned) (ADDR(r.symbol) + r.addend)
具体示例不再列举了,计算过程和相对引用相似,更简单。
5.6 hello的执行流程
子程序名 | 程序地址 | 简述 |
ld-2.27.so!_dl_start | 0x7fefaff21ea0 | 开始加载 |
ld-2.27.so!_dl_init | 0x7fefaff30630 |
|
libc-2.27.so!__libc_start_main | 0x7fefafb50ab0 |
|
libc-2.27.so!__cxa_atexit | 0x7fefafb72430 |
|
hello!__libc_csu_init | 0x400670 |
|
libc-2.27.so!_setjmp | 0x7fefafb6dc10 |
|
hello!main | 0x4005e7 | hello.c的主函数 |
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 |
|
|
因为hello程序有一个分支调用了exit函数,故程序在那里就终止了,如果在hello中进入了另一条分支,事实上是会运行到libc-2.27.so!exit终止的。而且,每次加载hello的虚拟地址也是不同的。但hello中的main和调用的一些函数的地址是经过重定位之后,固定下来的虚拟地址。
5.7 Hello的动态链接分析
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
hello在调用.so共享库函数时,会涉及到动态链接。现代系统在处理共享库在地址空间中的分配的时候,采用了位置无关代码(PIC)方式。位置无关代码指,编译共享模块的代码段,是把它们加载到内存的任何位置而无需链接器修改。用户对GCC使用-fpic选项指示GNU编译系统生成PIC代码。共享库的编译必须总是使用该选项。
PIC代码引用包括数据引用和函数调用。对数据引用有一个事实,就是代码段中任何指令和数据段中任何变量之间的距离是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。在编译器想要生成对PIC全局变量引用时,在数据段开始的地方创建了全局偏移量表(GOT)。
以图5.14为例说明。在执行例程addvec时,mov语句实现了addcnt的引用。%rip存放的是下一条指令地址,即addl指令地址,运行时代码段中的addl距离数据段中的GOT[3]的距离是常量0x2008b9,故用基址偏移寻址找到GOT[3],而GOT[3]中存放的是addcnt的真实地址,这样mov就实现了把&addcnt存入%rax的操作。
对PIC函数调用,有一个现象叫延迟绑定,即将过程地址的绑定推迟到第一次调用该过程时。把函数地址的解析推迟到它实际被调用的地方,能避免动态链接器在加载时进行成百上千个其实并不需要的重定位。第一次调用过程的运行时开销很大,但是其后的每次调用都只会花费一条指令和一个间接的内存引用。
延迟绑定是通过GOT和过程链接表(PLT)实现的。GOT是数据段的一部分,PLT是代码段的一部分。
对一个函数的调用,首先程序进入函数的PLT条目,PLT条目下的第一条PLT指令通过函数的GOT条目跳转,因为首次调用GOT条目指向的是PLT条目下的第二条指令,所以此时已经运行到了PLT的第二条指令。
PLT的第二条指令通常是把函数的ID压栈,随后跳转到PLT[0]。PLT[0]是一个特殊条目,它跳转到动态链接器中。PLT[0]又借助GOT[1]间接地把动态链接器的一个参数压入栈中,这可能是动态链接器在解析函数地址时会使用的信息。这时,动态链接器会通过两个栈中的条目来确定函数的运行时位置,用这个地址重写它的GOT条目,再把控制传给函数例程。
此时就是第二次调用函数了,控制会转入到它的PLT条目,然后再借它的GOT条目跳转,经过动态链接器的更新,GOT条目中存放的已经是函数的地址,所以这次调用就进入到函数例程了。
下面对hello的dl_start函数的GOT更新环节做解析。
先找到hello的GOT表,之后在edb中对GOT表做跟踪分析。
对GOT[2]的地址0x7f358f0c0750跟踪。
根据图5.19中的函数的GOT条目,配合图5.15的函数的PLT条目来看,实践证明GOT和PLT配合调用函数的机制是正确的。
5.8 本章小结
本章介绍了hello在真正成为process之前的最后一步——链接。简单跟踪了hello的链接过程,包括静态链接和动态链接。重点分析了重定位和动态链接中的PIC调用和引用。终于,心心念的hello的P2P之路,走完了。
第6章 hello进程管理
6.1 进程的概念与作用
进程的经典定义就是一个执行中程序的实例。系统中的每个程序都运行在某个进程的上下文中。上下文是由程序正确运行所需的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。
进程给应用程序提供了关键的抽象,一个独立地逻辑控制流,提供一个假象,好像我们的程序独占地使用处理器,还提供了一个私有的地址空间,提供一个假象,好像我们的程序独占地使用内存系统。
6.2 简述壳Shell-bash的作用与处理流程
shell是一个交互型的应用级程序,它代表用户运行其他程序。Shell执行一系列的读/求值步骤,然后终止。读步骤读取来自用户的一个命令行。求值步骤解析命令行,并代表用户运行程序。
shell的首要任务是调用parseline函数,这个函数解析了以空格分割的命令行参数,并构造最终会传递给execve的argv向量。第一个参数被假设为要么是一个内置的shell命令名,马上就会解释这个命令,要么是一个可执行目标文件,会在一个新的子进程的上下文中加载并运行这个文件。
如果最后一个参数是一个“&”字符,那么parseline返回1,表示应该在后台执行该程序(shell不会等待它完成)。否则,它返回0,表示应该在前台执行这个程序(shell回等待它完成)。
在解析了命令行之后,eval函数调用builtin_command函数,该函数检查第一个命令行参数是否是一个内置的shell命令。如果是,它就立即解释这个命令,并返回值1。否则返回0.简单的shell只有一个内置命令——quit命令,该命令会终止shell。实际使用的shell会有大量的内置命令。
如果builtin_command返回0,那么shell创建一个子进程,并在子进程中执行所请求的程序。如果用户要在后台运行该程序,那么shell返回到循环的顶部,等待下一个命令行。否则,shell使用waitpid函数等待作业终止。当作业终止时,shell就开始下一轮迭代。
6.3 Hello的fork进程创建过程
父函数可以通过fork函数创建一个新的运行的子进程。函数声明如下:
pid_t fork(void);
新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的但是独立地一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的差别在于它们有不同的PID。
有几个需要注意的地方。fork函数调用一次,返回两次。父进程调用一次fork,但却有一次是返回到父进程,而另一次是返回到子进程的。父进程和子进程是并发运行的独立进程,内核可以以任意方式交替执行它们的逻辑控制流中的指令。父进程和子进程还具有相同但是独立的地址空间,从虚拟内存的角度看fork函数,子进程使用父进程的地址空间,但有写时复制的特性。父子进程还有共享的文件。
通常父进程用waitpid函数来等待子进程终止或停止。
pid_t waitpid(pid_t pid, int *statusp, int options);
在父进程调用fork后,到waitpid子进程终止或停止这段时间里,父进程执行的操作,和子进程的操作(如果没有什么其它复杂的操作的话),在时间顺序上是拓扑排序执行的。有可能,这段时间里父子进程的逻辑控制流指令交替执行。而父进程的waitpid后的指令,只能在子进程终止或停止后,waitpid返回后才能执行。
6.4 Hello的execve过程
execve函数在当前进程的上下文中加载并运行一个新程序。函数声明如下:
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,例如找不到filename,execve才会返回到调用程序。正常情况下,execve调用一次,但从不返回。
在execve加载filename之后,调用启动代码。启动代码设置栈,并将控制传递给新程序的主函数,该主函数有如下形式的原型:
int main(int argc, char **argv, char *envp);
main开始执行时,用户栈的组织结构由图6.1所示。
从栈底(高地址)到栈顶(低地址),首先是参数和环境字符串。栈往上紧随其后的是以null结尾的指针数组,其中每个指针都指向栈中的一个环境变量字符串。全局变量environ指向这些之阵中的第一个envp[0]。紧随环境变量数组之后的是以null结尾的argv[]数组,其中每个元素都指向栈中的一个参数字符串。在栈的顶部是系统启动函数libc_start_main的栈帧。
6.5 Hello的进程执行
结合进程上下文信息、进程时间片,阐述进程调度的过程,用户态与核心态转换等等。
hello在执行时,有自己的逻辑控制流。多个进程的逻辑控制流在时间上可以交错,表现为交替运行。每个进程执行它的流的一部分,然后被抢占(暂时挂起),然后轮到其它进程。
一个逻辑流的执行在时间上和另一个流重叠,成为并发流,这两个流并发地运行。一个进程执行它的控制流的一部分的每一时间段叫时间片。
如图6.2,每一个进程在时间段上的黑线段,都是一个时间片。同一时间上不会有两个进程同时占用处理器。在总体上看,并发控制流是在一段时间里是共用处理器的。
当hello执行到sleep函数时,会被挂起一段时间。挂起就是指进程被抢占,也就是hello会在逻辑控制流上出现一段断片。sleep函数的声明如下:
unsigned int sleep(unsigned int secs);
这会使当前进程挂起secs秒,如果请求的时间量到了,sleep返回0,否则返回还剩下的要休眠的秒数。
正常情况下,hello正常运行,直到调用函数sleep,hello进程会临时交出控制权。
进程控制权的交换涉及到上下文切换。操作系统内核使用一种成为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文。上下文就是内核重新启动一个被抢占的进程所需的状态。
在进程执行的某些时刻,内核可以决定抢占当前进程,并重新开始一个先前被抢占了的进程。这种决策就叫调度,是由内核中成为调度器的代码处理的。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程,上下文切换要做到:
- 保存当前进程的上下文
- 恢复某个先前被抢占的进程被保存的上下文
- 将控制传递给这个新恢复的进程
上下文切换有点像电影片场中的拍摄过程。一个场景是一个进程,场景的切换,要保存当前现场,把控制权交给另一个场景的导演,如果那个场景暂时结束了,就把控制再交回来,这时现场也是恢复了的。
再回到hello进程来,调用sleep后,内核中的调度器将hello进程挂起,然后进入到内核模式,由于hello调用sleep的这个过程没有显式地创建新的进程,所以,在hello被抢占了secs秒后,内核又会选择hello进程,恢复它被抢占时的上下文,并把控制交给它,这时,又回到了用户模式。
6.6 hello的异常与信号处理
Ctrl-z后可以运行ps jobs pstree fg kill 等命令,请分别给出各命令及运行结截屏,说明异常与信号的处理。
按照要求,对hello执行过程中,可能遭遇的操作进行分析。先分析一下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 标准输入
这是一个有趣的现象,如果输入3个参数,在hello循环时乱按键盘,向进程给出一些标准输入,那么在循环结束之后,getchar()会把缓冲区里的这些输入发送给shell,等于shell接收到了一些不明所以的输入。
以上四种情况,可以用ps和kill命令来测试,如图6.8中的ctrl+z信号,看似效果是和ctrl+c终止进程一样的,但实际上,如果用bg或fg命令是可以让hello继续运行的。kill命令可以直接杀死进程。
6.7本章小结
正如书中所说,进程是计算机科学中最深刻、最成功的概念之一。进程给应用程序提供的关键抽象,使得进程可以并发地执行。信号和异常的处理,使得并发执行的过程变得井然有序,如信号处理程序,一切都按照规矩运行。shell-bash的建立,给用户和进程之间提供了一个操作平台。总之,这部分内容既底层,又贴近用户。
第7章 hello的存储管理
7.1 hello的存储器地址空间
计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每字节都有一个唯一的物理地址。物理地址是在地址总线上,以电子形式存在的,使得数据总线可以访问主存的某个特定存储单元的内存地址。
在一个带虚拟内存的系统中,CPU从一个有N=2n个地址的地址空间中生成虚拟地址,这个地址空间成为虚拟地址空间。
地址空间是一个非负整数的有序集合,如果地址空间中的整数是连续的,那么我们说它是一个线性地址空间。线性地址就是线性地址空间中的地址。
在有地址变换功能的计算机中,访问指令给出的地址(操作数)叫逻辑地址,也叫相对地址。要经过寻址方式的计算或变换才得到内存储器中的物理地址。
edb调试中看到的hello的指令地址都是16位的虚拟地址,有些访问指令的地址也是逻辑地址,在程序中虚拟地址和逻辑地址没有明显的界限。通常来说我们是看不到程序的物理地址的。至于线性地址,只是一个地址的概念。
逻辑地址转换成线性地址,虚拟地址,是由段式管理执行的。
线性地址转换成物理地址,是由页式管理执行的。
7.2 Intel逻辑地址到线性地址的变换-段式管理
在段式存储管理中,将程序的地址空间划分为若干个段,这样每个进程有一个二维的地址空间。在段式存储管理系统中,则为每个段分配一个连续的分区,而进程中的各个段可以不连续地存放在内存的不同分区中。
用户栈是栈段寄存器,共享库的内存映射区域和运行时堆都是辅助段寄存器,读/写段是数据段寄存器,只读代码段是代码段寄存器。
代码段寄存器中的RPL字段表示CPU的当前特权级。RPL=00,为第0级,位于最高级的内核态,RPL=11,为第3级,位于最低级的用户态,第0级高于第3级。出于环保护机制,内核工作在第0环,用户工作在第3环,中间环留给中间软件用。Linux仅用第0环和第3环。TI=0,选择全局描述符表(GDT),TI=1,选择局部描述符表(LDT)。
段描述符是一种数据结构,实际上就是段表项,分为用户的代码段和数据段描述符,还有系统控制端描述符。
全局描述符表GDT:只有一个,用来存放系统内每个任务都可能访问的描述符,例如,内核代码段、内核数据段、用户代码段、用户数据段以及TSS(任务状态段)等都属于GDT中描述的段
局部描述符表LDT:存放某任务(即用户进程)专用的描述符
中断描述符表IDT:包含256个中断门、陷阱门和任务门描述符
7.3 Hello的线性地址到物理地址的变换-页式管理
虚拟内存被组织为一个由存放在磁盘上的N个连续的字节大小的单元组成的数组。每字节都有一个唯一的虚拟地址,作为到数组的索引。磁盘上数组的内容被缓存在主存中。虚拟页是带虚拟内存系统将虚拟内存分割为大小固定的块,作为磁盘和主存(较高层)之间的传输单元。任何时刻,虚拟页面只有三种情况,要么是未分配的,要么是缓存的,要么是未缓存的。
页表是一个存放在物理内存中的数据结构,将虚拟页映射到物理页。每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘与DRAM之间来回传送页。
页表就是一个页表条目(PTE)的数组。虚拟地址空间中的每个页在页表中一个固定偏移量处都有一个PTE。可以假设每个PTE是由一个有效位和一个n位地址字段组成的。有效位表明了该虚拟页当前是否被缓存在DRAM中。如果设置了有效位,那么地址字段就表示物理内存中相应的物理页的起始位置,这个物理页中缓存了该虚拟页。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,一个非空地址指向的是该虚拟页在磁盘上的起始位置。
形式上来说,地址翻译是一个N元素的虚拟地址空间中的元素到一个M元素的物理地址空间中元素的映射。
图7.2展示了内存管理单元(MMU)如何利用页表来实现虚拟地址到物理地址的映射。CPU中的一个控制寄存器,页表基址寄存器指向当前页表。n位的虚拟地址包含两个部分,一个p位的虚拟页面偏移(VPO)和一个n-p位的虚拟页号(VPN)。MMU利用VPN来选择适当的PTE。将页表条目中的物理页号(PPN)和虚拟地址中的VPO串联起来,就得到相应的物理地址。注意,因为物理和虚拟页面都是P字节的,所以物理页面偏移(PPO)和VPO是相同的。
7.4 TLB与四级页表支持下的VA到PA的变换
Intel Core i7 实现支持48位(256TB)虚拟地址空间和52位(4PB)物理地址空间。Linux使用的是4KB的页。X64 CPU上的PTE为64位(8bytes),所以每个页表一共有512个条目。512个PTE需要有9位VPN来定位。在四级页表的条件下,一共需要36位VPN,因为虚拟地址空间是48位的,所以低12位是VPO。TLB是四路组联的,共有16组,需要有4位TLBI来定位,所以VPN的低4位是TLBI,高32位是TLBT。
Core i7 MMU用四级的页表来将虚拟地址翻译成物理地址。36位VPN被划分成四个9位的片,每个片被用作到一个页表的偏移量。CR3寄存器包含L1页表的物理地址。VPN1提供到一个L1 PTE的偏移量,这个PTE包含L2页表的基地址。VPN2提供到一个PTE的偏移量,以此类推。
7.5 三级Cache支持下的物理内存访问
物理内存访问,是基于MMU将虚拟地址翻译成物理地址之后,向cache中访问的。
图7.8的右半部分,是L1 cache中的物理地址寻址,L2和L3的寻址原理和L1相似。
在cache中物理地址寻址,按照三个步骤:组选择、行匹配和字选择。在冲突不命中时还会发生行替换。
高速缓存(S, E, B, m)是一个高速缓存组的数组。一共有S个组,每个组包含E行,每行包含1个有效位,t个标记位和一个log2B位的数据块。
高速缓存的结构将m个地址位划分为t个标记位,s个组索引位,和b个块偏移位。
在组选择中,cache按照物理地址的s个组索引位(S=2s)来定位该地址映射的组。
选择好组后,遍历组中的每一行,比较行的标记和地址的标记,当且仅当这两者相同,并且行的有效位设为1时,才可以说这一行中包含着地址的一个副本。也就是缓存命中了。
最后是字选择。定位好了要寻址的地址在哪一行之后,根据地址的块偏移量,在行的数据块中偏移寻址,最后得到的字,就是我们寻址得到的字。
如果缓存不命中,那么它需要从存储器层次结构中的下一层取出被请求的块,然后将新的块存储在组索引位指示的组中的一个高速缓存行中。这个过程,如果有冲突不命中,就会触发行的替换。
L2和L3 cache的物理地址寻址,和上述过程类似。
7.6 hello进程fork时的内存映射
进程这一抽象能够为每个进程提供自己私有的虚拟地址空间,可以免受其他进程的错误读写。不过,许多进程有同样的只读代码区域。而且,许多程序需要访问只读运行时库的相同副本。那么,如果每个进程都在物理内存中保持这些常用代码的副本,那就是极端的浪费了。为了避免这种浪费,内存映射中有了共享对象的概念。
如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的。而且这些变化也会反映在磁盘上的原始对象中。
还有一种更节省资源的机制,就是写时复制。
图7.8的情况,是两个进程将一个私有对象映射到它们虚拟内存的不同区域,但是共享这个对象同一个物理副本。对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为写时复制。只要没有进程试图写它自己的私有区域,它们就可以继续共享物理内存中对象的一个单独副本。然而,只要有一个进程试图写私有区域的某个页面,那么这个写操作会触发一个保护故障。当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域中的一个页面而引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的副本,然后恢复这个页面的可写权限,当故障程序返回时,CPU重新执行这个写操作,现在在新创建的页面上这个写操作就可以正常执行了。通过延迟私有对象中的副本直到最后可能的时刻,写时复制最充分地利用了稀有的物理内存。
在父进程用fork调用子进程时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct、区域结构和页表的原样副本。它将两个进程中的每个页面都标记位只读,并将两个进程中的每个区域结构都标记为私有的写时复制。
当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当这两个进程中的任一个后来进行写操作时,写时复制机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
7.7 hello进程execve时的内存映射
hello调用execve后,execve在当前进程中加载并运行包含在可执行目标文件hello.out中的程序,用hello.out程序有效地替代了当前程序。加载并运行hello.out需要以下几个步骤:
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构,所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello.out文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello.out中,栈和堆地址也是请求二进制零的,初始长度为零。图7.9概括了私有区域的不同映射。
- 映射共享区域, 如果hello.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文的程序计数器,使之指向代码区域的入口点。
下一次调度hello进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和页面。
7.8 缺页故障与缺页中断处理
物理内存(DRAM)缓存不命中成为缺页。假设CPU引用了磁盘上的一个字,而这个字所属的虚拟页并未缓存在DRAM中。地址翻译硬件会从内存中读取虚拟页对应的页表,推断出这个虚拟页未被缓存,然后触发一个缺页异常。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页。如果被牺牲的页面被修改了,那么内核会把它复制回磁盘。总之,内核会修改被牺牲页的页表条目,表示它不再缓存在DRAM中了。
之后,内核从磁盘把本来要读取的那个虚拟页,复制到内存中牺牲页的那个位置,更新它的页表条目,随后返回。当异常处理程序返回时,会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重发送到地址翻译硬件。于是,地址翻译硬件可以正常处理现在的页命中了。
7.9动态存储分配管理
动态内存分配器维护着一个进程的虚拟内存区域,称为堆。对于每个进程,内核维护着一个变量brk,它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护。每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的。已分配的块显式地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显式地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格,显式分配器和隐式分配器。两种风格都要求应用显式地分配块。不同之处在于由哪个实体来负责释放已分配的块。
显式分配器:要求应用显式地释放任何已分配的块。C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块。
隐式分配器:要求分配器检测一个已分配块何时不再使用,那么就释放这个块。
7.9.1 隐式空闲链表
隐式空闲链表是隐式分配器组织空闲块的一种形式。隐式空闲链表有如图7.11的堆块数据结构。
在这种情况中,一个块是由一个字的头部、有效载荷,以及可能的一些额外的填充组成的,头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果有双字对齐的约束条件,那么块大小就总是8的倍数,且块大小的最低3位总是0.
头部后面就是应用调用malloc时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。
当一个应用请求一个k字节的块时,分配器搜索空闲链表,查找一个足够大可以放置请求块的空闲块。分配器执行这种搜索方式是由放置策略决定的。一些常见的策略有首次适配、下一次适配和最佳适配。
首次适配从头开始搜索空闲链表,选择第一个合适的空闲块。下一次适配和首次适配相似,只不过是从上一次查询结束的地方开始。最佳适配检查每个空闲块,选择适合所需请求大小的最小空闲块。
如果适配到的空闲块比我们的请求块大小要大很多,那么就要把空闲块分割成两部分,一部分变成分配块,剩下的变成一个新空闲块。
如果分配器找不到一个合适的空闲块(合并后也找不到),那么分配器会调用sbrk函数,向内核申请额外的堆内存。分配器将额外的内存转化成一个大空闲块,把被请求块放置在这个新的大空闲块中。
每一次有新空闲块生成时,都涉及到空闲块的合并。为了解决假碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程叫合并。
通常堆块的结构还有一个脚部,这是在每个块的结尾处,脚部就是头部的一个副本。在合并中,有头部和脚部的块,有利于从被合并块去定位相邻块,获知相邻块的信息。
假设我们有块m1, n, m2,已分配标记为a,空闲标记为f,合并过程有图7.12所示的四种情况。
7.9.2 显式空闲链表
显式空闲链表和隐式空闲链表在很多地方都相似,它将空闲块组织成了一个实际的显式数据结构,就是双向链表。其堆块的数据结构如图7.13所示。
空闲块中的pred祖先指针,指向空闲链表中,该块的前驱,succ后继指针,指向空闲链表中,该块的后继。
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过释放一个块的时间可以是线性的,也可能是个常数,这取决于我们选择的空闲链表中块的排序策略。
一种方法是用后进先出(LIFO),将新释放的块放置在链表的开始处,使用LIFO的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块,在这种情况下,释放一个块可以在线性的时间内完成,如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中的每个块的地址都小于它的后继的地址,在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序首次适配比LIFO排序的首次适配有着更高的内存利用率,接近最佳适配的利用率。
7.10本章小结
本章讲述了64位系统中的内存管理,虚拟内存和物理内存之间的关系,动态内存分配(主要表现为malloc和free)等内容,对程序的内存有一个相对全面的介绍,也讨论了内存的组织形式,和程序与内存的互动。
关于本章内容,具体还请查阅《深入理解计算机系统》第三版的第九章。
第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接口被映射为文件,这使得所有的输入和输出都能以一种统一且一致的方式来执行:
- 打开文件。一个应用程序通过要求内核打开相应的文件,来宣告它想要访问一个I/O设备,内核返回一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件,内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
- Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它们可以用来代替显式的描述符值。
- 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0,这个文件位置是从文件开头起始的字节偏移量,应用程序能够通过执行seek,显式地将改变当前文件位置k。
- 读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的而文件,当k>=m时执行读操作会触发一个成为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的“EOF符号”。类似一个写操作就是从内存中复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k。
- 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。
在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函数是在stdio.h头文件中声明的,具体代码实现如下:
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;
}
它的参数包括一个字串fmt,和…。…表示参数个数不能确定,也就是格式化标准输出,我们也不能确定到底有几个格式串。
在函数的第6行,arg变量定位到了第二个参数,也就是第一个格式串。和这句有关的具体问题,还是请看参考文献[6],本节只是简述printf的实现过程。
va_list是一个数据类型,其声明如下:
typedef char *va_list
至于赋值语句右侧的,是一个地址偏移定位,定位到了从fmt开始之后的第一个char*变量,也就是第二个参数了。
接下来是调用vsprintf函数,并把返回值赋给整型变量i。后来又调用write函数从内存位置buf处复制i个字节到标准输出。想必这个i就是printf需要的输出字符总数,那么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++) { //p初始化为buf,下面即将把fmt解析并把结果存入buf中
/* 寻找格式化字串 */
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++; //此时,fmt指向的是格式化字串的内容了
switch (*fmt) {
/*这是格式化字串为%x的情况*/
case 'x':
itoa(tmp, *((int*)p_next_arg)); //把fmt对应的那个参数字串转换格式,放到tmp串中
strcpy(p, tmp); //tmp串存到p中,也就是buf中
p_next_arg += 4; //定位到下一个参数
p += strlen(tmp); //buf中的指针也要往下走
break;
/* Case %s */
case 's':
break;
default:
break;
}
}
return (p - buf);
}
这个vsprintf只处理了%x这一种格式化字串的情况。已经给出比较详细的注释了。
write函数的汇编代码是这样给出的:
1. write:
2. mov eax, _NR_write
3. mov ebx, [esp + 4]
4. mov ecx, [esp + 8]
5. int INT_VECTOR_SYS_CALL
第5行,表示要通过系统来调用sys_call这个函数,函数实现为:
1. sys_call:
2. call save
3.
4. push dword [p_proc_ready]
5.
6. sti
7.
8. push ecx
9. push ebx
10. call [sys_call_table + eax * 4]
11. add esp, 4 * 3
12.
13. mov [esi + EAXREG - P_STACKBASE], eax
14.
15. cli
16.
17. ret
在write和sys_call中,ecx寄存器中存放的是要打印元素的个数,ebx寄存器中存放的是要打印的buf字符数组中的第一个元素。这个函数的功能就是不断地打印出字符,直到遇到’\0’。
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
到此,printf要打印的东西,就呈现在标准输出上了。
8.4 getchar的实现分析
getchar的函数声明在stdio.h头文件中,具体代码实现如下:
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. }
bb是缓冲区的开始,int变量n初始化为0,只有在n为0的情况下,从缓冲区中读BUFSIZ个字节,就是缓冲区中的内容全部读入。这时候,n的值被修改为,成功读入的字节数,正常情况下n就是一个正数值。返回时,如果n大于0,那么就返回缓冲区的第一个字符。否则,就是没有从缓冲区读到字节,返回EOF。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
8.5本章小结
所有的I/O设备都被模型化为文件,通过文件的读写来实现I/O操作。Unix I/O接口函数可以实现一些I/O操作。printf的实现和vsprintf以及write有关,要先解析格式化字串,再调用I/O函数write写到标准输出上。getchar函数与键盘回车相关,也就是需要异步异常-键盘中断的处理,之后调用I/O函数read,读取标准输入。
结论
用计算机系统的语言,逐条总结hello所经历的过程。
你对计算机系统的设计与实现的深切感悟,你的创新理念,如新的设计与实现方法。
本文围绕着hello在计算机系统中的一生展开,到此已落下帷幕。hello的一生,也是其它程序的一生,计算机系统的学习,就是程序在计算机中的日记,我们和程序一起成长。
hello在计算机系统中的一生概括如下:
- 编辑(诞生)。用codeblocks之类的编译器将正确的程序写用高级语言出来。
- 预处理(编译系统第一环节)。GCC中的预处理器将源代码中的预处理指令拓展,成为新的文本文件。
- 编译(编译系统第二环节)。GCC中的编译器将拓展代码文件按照规则,编译为汇编代码文本文件。
- 汇编(编译系统第三环节)。GCC中的汇编器将汇编代码翻译成机器指令格式,打包成可重定位目标文件。
- 链接(编译系统第四环节)。GCC中的链接器将可重定位目标文件和其它必要的可重定位目标文件一切链接,生成可执行目标文件。
- 运行(进程产生)。在shell中运行hello,shell为hello创建一个子进程,并调用execve执行hello。
- 内存管理(运行同时)。在shell中运行hello的同时,为hello分配了虚拟地址空间。
- 内存访问(运行过程中)。在hello的运行过程中,任何指令语句的执行,都调动着系统和硬件的配合,将虚拟地址翻译成物理地址,并在主存中取址。是CPU和主存之间的交互。
- 信号与异常(插曲)。信号与异常是hello运行过程中的协奏曲,很多信号与异常是必要的。但如果有不必要的信号与异常发生,会对hello造成一些影响。
- 终止(死亡)。hello由于某种原因(正常或非正常)而终止成为僵死进程,shell回收hello,同时内核删除hello的数据相关,为hello善后。
在一个学期里学完计算机系统这么多知识,是非常有挑战的,的确,学期结束后,感觉自己仍有很多迷惑之处。写下这么一篇长文,既是完成作业,也是期末复习,也是对一学期知识的简单总结。
计算机系统不仅仅是硬件人士的圣经,也是软件人员的必读书目。它真正意义在于,了解程序的一生,这样我们才能真正地认识计算机中的程序。
作者才疏学浅,对计算机系统理解鄙陋,缺点和疏漏在所难免,望读者多加指正。
附件
hello.c | 源程序C语言代码 |
hello.i | hello.c预处理后的文本文件 |
hello.s | 编译后的汇编语言文本文件 |
hello.o | 汇编后的可重定位目标文件 |
hello | 链接后的可执行目标文件 |
helloo_obj.s | hello.o利用objdump工具的反汇编代码 |
hello_obj.s | hello利用objdump工具的反汇编代码 |
其他中间结果 | readelf和objdump的其他调试代码,反映在了标准输出中,没有保存到本地文件 |
参考文献
[1] 《深入理解计算机系统》第三版,兰德尔 E.布莱恩特,大卫 R.奥哈拉伦
[2] GCC编译命令常用选项,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] 逻辑地址,https://baike.baidu.com/item/%E9%80%BB%E8%BE%91%E5%9C%B0%E5%9D%80/3283849?fr=aladdin
[6] https://www.cnblogs.com/pianist/p/3315801.html
[7] getchar,https://baike.baidu.com/item/getchar/919709?fr=aladdin