目录
第1章 概述
1.1 Hello简介
1.2 环境与工具
1.3 中间结果
1.4 本章小结
第2章 预处理
2.1 预处理的概念与作用
2.2在Ubuntu下预处理的命令
2.3 Hello的预处理结果解析
2.4 本章小结
第3章 编译
3.1 编译的概念与作用
3.2 在Ubuntu下编译的命令
3.3 Hello的编译结果解析
3.4 本章小结
第4章 汇编
4.1 汇编的概念与作用
4.2 在Ubuntu下汇编的命令
4.3 可重定位目标elf格式
4.4 Hello.o的结果解析
4.5 本章小结
第5章 链接
5.1 链接的概念与作用
5.2 在Ubuntu下链接的命令
5.3 可执行目标文件hello的格式
5.4 hello的虚拟地址空间
5.5 链接的重定位过程分析
5.6 hello的执行流程
5.7 Hello的动态链接分析
5.8 本章小结
第6章 hello进程管理
6.1 进程的概念与作用
6.2 简述壳Shell-bash的作用与处理流程
6.3 Hello的fork进程创建过程
6.4 Hello的execve过程
6.5 Hello的进程执行
6.6 hello的异常与信号处理
6.7本章小结
第7章 hello的存储管理
7.1 hello的存储器地址空间
7.2 Intel逻辑地址到线性地址的变换-段式管理
7.3 Hello的线性地址到物理地址的变换-页式管理
7.4 TLB与四级页表支持下的VA到PA的变换
7.5 三级Cache支持下的物理内存访问
7.6 hello进程fork时的内存映射
7.7 hello进程execve时的内存映射
7.8 缺页故障与缺页中断处理
7.9动态存储分配管理
7.10本章小结
第8章 hello的IO管理
8.1 Linux的IO设备管理方法
8.2 简述Unix IO接口及其函数
8.3 printf的实现分析
8.4 getchar的实现分析
8.5本章小结
结论
根据Hello的自白,利用计算机系统的术语,简述Hello的P2P,020的整个过程。
P2P:在linux系统中,hello.c的文本文件先经过cpp预处理成.i文件,接着由cc1编译成.s文件,再经过as进行汇编成.o文件,最终ld链接成可执行文件,用户通过在shell命令行中输入./hello.out运行,shell调用fork产生子进程来运行。
020:shell调用execve先将原先进程所有的区域结构删除,然后构建新的hello.c的虚拟区域结构,接着控制转移到mian函数,运行时通过缺页处理将程序代码和数据载入内存。运行结束后发送给子进程SIGCHILD信号等待shell父进程回收。
列出你为编写本论文,折腾Hello的整个过程中,使用的软硬件环境,以及开发与调试工具。
硬件环境:X64 CPU;2GHz;2G RAM;256GHD Disk 以上
软件环境:Windows7 64位以上;VirtualBox/Vmware 11以上;Ubuntu 16.04 LTS 64位/优麒麟 64位
列出你为编写本论文,生成的中间结果文件的名字,文件的作用等。
Hello.i | Hello.c通过cpp预处理得到 |
Hello.s | Hello.i通过cc1编译得到 |
Hello.o | Hello.s通过as汇编得到 |
Hello | Hello.o通过ld链接得到 |
Hello.out | Hello反汇编 |
本章从较概括的角度简单的给出了hello.c从编写完成到最终成为可执行程序的过程,经过了那几部和使用的工具。
预处理的概念:cpp是预处理器,它根据#开头的语句来修改程序,将引用的库如stdio.h展开合并成一个完整的文本文件。
预处理的作用:
1、处理头文件,例如#include加库或者其他文件名等,将这些头文件中的定义加入到输出文件中。
2、处理宏,根据#if、#ifdef等命令将源程序中的部分内容包含进来或者排除在外。
3、处理条件编译指令,如#ifdef.#else #elif #endif等,这些伪指令的引入使得程序员可以通过定义不同的宏来觉得编译程序对那些代码进行处理,预编译程序将根据有关的文件将那些不必要的代码过滤。
4、处理特殊符号,如LINE是当前行号,FILE被解释为当前被编译的程序的名称。
Gcc hello.c -E -o hello.i
.i文件中程序的源代码仍然存在,前面增加了很多内容,对原文件中的宏进行了宏展开,头文件中的内容被包含进该文件中。例如声明函数、定义结构体、定义变量、定义宏等内容。另外,如果代码中有#define命令还会对相应的符号进行替换。
本章介绍了预处理阶段的内容,预处理可以符号替换定义的宏并引入头文件的内容等。
编译的概念:编译器是as,它可以将上一步生成的.i文件翻译成文本文件.s,输出是程序语言对应的汇编语言。
编译的作用:把源程序翻译成目标程序,还能进行语法检查、调试、修改、覆盖处理等。
Gcc -S hello.i -o hello.s
3.3.0 各节
.file:文件名
.text:代码节
.rodata:只读数据节
.align:对齐方式
.string:声明字符串
.global:全局变量main
.type:生命一个符号是数据还是函数
3.3.1数据
1、字符串
有两个字符串,都在.rodata节,LC0和LC1,
传递字符串。
2、局部变量i:
Mian函数中声明了一个局部变量i,编译器进行编译的时候会把局部变量放在堆栈中,文件中显示i被放在了栈上-4(%rbp)处。
3、mian函数
参数argv和argc是mian的参数,放在了堆栈中
4、立即数
开头是$的是立即数
5、数组argv
Argv是main的一个字符数组,是main的参数,他是一个字符串指针数组,每个元素都指向一个字符串,argv[0]是文件名,以后是运行时参数,放在了栈中:
第一行从栈中取出argv,第三行通过(%rax)得到参数的地址。
3.3.2 全局函数
Hello.c声明了main函数,编译后mian中的字符串常量放在了数据区:
.globl main 代表main是全局函数
3.3.3 赋值操作
Hello.c复制指令有i=0,汇编代码是使用mov实现,mov指令包含movb(一个字节)、movw(两个字节)、movl(四个字节)、movq(八个字节)
3.3.4 算术操作
源程序中只有i++这一种算术操作,使用addl实现:
汇编语言的其他算术操作指令还有
指令 | 效果 |
Leaq S,D | D=&S |
INC D | D+=1 |
DEC D | D-=1 |
NEG D | D=-D |
ADD S,D | D=D+S |
SUB S,D | D=D-S |
3.3.5 条件操作
1、argv!=3,条件判断,汇编语言是cmpl $3, -20(%rbp),设置条件码,根据条件吗确定是否跳转:
2、i<8,循环条件的判断检查,汇编语言是cmpl &7,-4(%rbp),设置条件码,jle根据条件码决定是否跳转:
3.3.6 控制转移
1、先使用cmpl指令设置条件码,然后je或其他指令根据条件码进行跳转:
2、无条件直接跳转jmp:
3.3.7 函数
Hello.c的函数有main、printf、exit、sleep、getchar;
Main的参数是argv和argc,后者是运行时参数个数,前者是运行时参数字符串数组,printf以argv的两个字符串参数作为参数,exit参数1,sleep参数是atoi(argv[3]),返回值在%rax中。
调用函数时先传递参数,将参数压栈,接着压入返回地址,然后保存寄存器信息,并将控制转移给被调用函数,被调用函数在栈上拥有了自己的栈帧并运行,最后根据栈顶的返回地址返回到原函数。
3.3.8 类型转换
Atoi(argv[3])作用是将字符串转换成整数,类型转换还可以在int、float、double、char等类型之间进行。
本章主要介绍了hello.i经过编译过程产生的变化,介绍了C语言中各种操作的汇编指令。
汇编的概念:汇编器as将上一步生成的.s文件汇编成机器指令,把这些指令合成可重定位的目标文件,最终结果是.o二进制文件。
汇编的作用:将汇编语言翻译成机器指令,并添加重定位信息。
Gcc hello.s -c -o hello.o
首先查看ELF Header,命令是readelf -h hello.o,如下图所示,它以16个字节的magic序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序,剩下的部分包含ELF头的大小、目标文件的类型、机器的类型、字节头部表的文件偏移以及节头部表中条目的大小和数量信息。
接着是节头部表的信息,其中每个条目记录了一个节的大小、名字、地址范围和偏移量信息,这里可以看到每个节的起始地址都是0,这是因为.o文件是可重定位文件,每个节都是可重定位的,还包含了各个段的权限,如代码段是只读的而数据段是可读可写的:
然后查看符号表.symtab,readelf -s hello.o
.symtab表存放着程序中定义和引用的函数和全局变量的信息,最后一列代表符号的名称,value这一列都是0,这是用于重定位的,等到生成可执行文件之后就是绝对地址,那时候这个表就不存在了,另外.rel.text和rel.data区域也包含了相应节的重定位符号信息,这里还有大小、类型、是全局符号还是本地符号等信息:
objdump -d -r hello.o 分析hello.o的反汇编,并请与第3章的 hello.s进行对照
反汇编代码:
Hello.s:
两个文件的汇编代码相同,反汇编多出来的是汇编代码对应的机器码,机器指令就是纯粹的二进制表示,CPU根据这些机器码来进行操作,机器码分为操作码的操作数两部分,每一条汇编语言最终都必须转换成机器码才能在CPU中运行。
二者相比,在反汇编文件中,一些跳转指令的操作数由原来的符号跳转变成了直接确定的跳转地址,其次,一些函数调用call指令的操作数由原来的函数名符号变成的当前指令的下一条指令的地址,因为在linux中是通过PC相对寻址的,而这些函数调用都是共享库中的函数,目前还不知道确定的运行时地址,因此记录下一条指令的地址以便链接时进行PC相对寻址。
本章对.s文件使用as进行了汇编,形成了.o可重定位目标文件,分析了它的ELF头、节头部表、符号表等,比较了.s和.o的反汇编代码之间的区别。
链接的概念:链接使用ld连接器将多个目标文件进行处理并最终生成一个可执行文件,这个文件可以被加载到内存中运行。
链接的作用:进行重定位操作,生成可执行文件,进行静态和动态编译使得程序可以连接到庞大的库。
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.ohello.o/usr/lib/x86_64-linux-gnu/libc.so/usr/lib/x86_64-linux-gnu/crtn.o:
Hello的ELF头:
和之前的.o文件的ELF头相比,Type参数变成了可执行文件。
节头部表,其节的个数比之前多了很多,信息项和之前一样,但是可以发现起始地址不再是0,变成了被载入到虚拟地址空间时的起始地址。
符号表.symtab:
使用edb加载hello,查看本进程的虚拟地址空间各段信息,并与5.3对照分析说明。
虚拟地址空间从0x400000到0x400ff0。
根据节头部表,可以查到各个节的内容,比如节头部表给出.rodata节起始地址是0x402000,查找后如下:
包含有输出字符串。
objdump -d -r hello 分析hello与hello.o的不同,说明链接的过程。
结合hello.o的重定位项目,分析hello中对其怎么重定位的。
分析hello与hello.o的反汇编代码:
发现Hello反汇编的代码有相应的虚拟地址,而hello.o的全是0,这是重定位的结果;hello反汇编多了很多节:
节 | 内容 |
.init | 初始化需要的代码 |
.plt | 动态链接过程链接表 |
.dynamic | Ld.so的动态连接信息 |
.data | 已初始化的全局变量 |
.comment | 包含编译器NULL-termminated字符串 |
.interp | Ld.so路径 |
.note.ABI-tag | Linux特有 |
.hash | 符号的哈希表 |
.gnu.hash | GNU扩展的符号的哈希表 |
.dynsym | 存放.dynsym节中的符号名称 |
,gnu.version | 符号版本 |
.gnu.verson_r | 符号引用版本 |
.rela.plt | .plt节的重定位条目 |
.rela.dyn | 运行时动态重定位表 |
.plt | 动态链接过程链接表 |
.fini | 正常终止时需要的代码 |
.eh_frame |
符号定义连接器将所有的重定位节中所有同类型的节合并,就是最终可执行文件的节,然后连接器就把运行时的地址赋值给这个节,赋值给每个符号,最终每条指令和全局变量都会有一个唯一确定的运行时地址,连接器是通过可重定位模块当中的重定位条目来进行的,每当编译器遇到一个目前无法确定运行时地址的符号引用时就会生成一个相应的重定位条目,对于代码来说重定位条目放在.rel.text节。
重定位包括两种方法,一种是绝对地址,一种是PC相对地址:
例如,对于puts函数的调用,hello的反汇编显示机器码为0xffffff46,是PC相对寻址,而且0x401090-0x40114a=0xffffff46,符合PC相对地址计算的公式。
使用edb执行hello,说明从加载hello到_start,到call main,以及程序终止的所有过程。请列出其调用与跳转的各个子程序名或程序地址。
程序名 | 地址 |
Ld-2.27.so!_dl_start | 0x7fd34fa78de0 |
Ld-2.27.so!_dl_init | 0x7fd34fa880b0 |
Hello!_start+0 | 0x4010f0 |
Libc-2.30.so!_libc_start_main+0 | 0x7fd34f8960f0 |
Libc-2.30.so!_cxa_atexit | 0x7fd34f890192 |
Libc-2.30.so!_libc_csu_init | 0x4011c0 |
Libc-2.30.so!_setjmp | 0x7fd34f981029 |
Libc-2.30.so!exit | 0x7fd34fe09923 |
分析hello程序的动态链接项目,通过edb调试,分析在dl_init前后,这些项目的内容变化。要截图标识说明。
动态链接的基本思想:将程序按照模块拆分,子啊运行时才将这些模块链接起来形成完整的程序,而不是像静态链接那样把所有程序模块都连接成一个单独的可执行文件。动态链接的连接过程是在运行时完成的,但是在行测和功能可执行文件时,对于一个外部引用的函数,连接器会检查动态链接库,如果是一个动态链接符号就不对其进行重定位,重定位等到加载运行时进行,而是会为其生成一条重定位记录,GNU编译系统使用延迟绑定将过程地址的而绑定推迟到第一次调用该过程时;
通过书上的介绍,延迟绑定是通过GOT(数据段)和PLT(代码段)实现的;
PLT:是一个数组,每个条目是16字节,PLT【0】跳转到动态链接器,每个调用的库函数都有自己的PLT条目;
GOT:也是数组,每个条目8字节,和PLT来联合使用,GOT[0]和GOT[1]包含动态连接器在解析函数地址时会使用的信息,其余条目对于一个函数,地址在运行时解析,每个条目都匹配一个PLT条目。
GOT起始位置是0x40400
GOT表在调用dl_init之前0x0x404008后的16个字节都是0;
调用_start之后发生改变,0x404008后的两个8个字节分别变为:0x7f6f8dc46170、0x7f6f8da34750,其中GOT[O](对应0x400e28)和GOT[1](对应0x7fb06087e168)包含动态链接器在解析函数地址时会使用的信息。GOT[2](对应0x7fb06066e870)是动态链接器在1d-linux.so模块中的入口点。其余的每个条目对应于一个被调用的函数。
本章主要介绍了链接的过程,详细介绍了.o文件的ELF头表和符号表,介绍了动态链接的过程原理,重定位的过程等。
进程的概念:进程是一个程序执行时的实例,每一个进程都有自己的地址空间,包括代码段、数据段、堆栈、环境变量等。
进程的作用:我们的程序好像是系统中当前运行的唯一程序一样,好象独占处理器和内存;好象无间断地指向程序地指令。
在linux中,shell是一个交互性应用程序,提供了用户和系统之间地一个中介,是一个命令行解释器。
Shell处理用户地指令,循环进行:
终端程序通过调用fork()函数创建一个子进程,子进程得到与父进程完全相同但是独立的一个副本,包括代码段、段、数据段、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,父进程和子进程最大的不同时他们的PID是不同的。父进程与子进程是并发运行的独立进程,内核能够以任意方式交替执行它们的 逻辑控制流的指令。在子进程执行期间,父进程默认选项是显示等待子进程的完成。
当创建了一个子进程之后,子进程调用exceve函数在当前子进程的上下文加载并运行一个新的程序即hello程序,加载并运行需要以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中已存在的区域结构。
(2)映射私有区域。为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些区域结构都是私有的,写时复制的。虚拟地址空间的代码和数据区域被映射为hello文件的.txt和.data区。bss区域是请求二进制零的,映射匿名文件,其大小包含在hello文件中。栈和堆区域也是请求二进制零的,初始长度为零。如图6.4
(3)映射共享区域。如果hello程序与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域。
(4)设置程序计数器(PC)。exceve做的最后一件事就是设置当前进程的上下文中的程序计数器,使之指向代码区域的入口点。下一次调用这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。
除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据 复制。直到 CPU 引用一个被映射的虚拟页时才会进行复制,这时,操作系统利用 它的页面调度机制自动将页面从磁盘传送到内存。
进程提供给应用程序的抽象:
1、一个独立的逻辑控制流,它提供一个假象,好像我们的进程独占的使用处理器
2、 一个私有的地址空间,它提供一个假象,好像我们的程序独占的使用CPU内存。hello进程的执行是依赖于进程所提供的抽象的基础上,下面阐述操作系统所提供的的进程抽象:
1、逻辑控制流::一系列程序计数器 PC 的值的序列叫做逻辑控制流,进程是轮流 使用处理器的,在同一个处理器核心中,每个进程执行它的流的一部分后被抢占 (暂时挂起),然后轮到其他进程。
2、并发流:一个逻辑流的执行时间与另一个流重叠,成为并发流,这两个流成为并发的运行。多个流并发的执行的一般现象成为并发。
3、时间片:一个进程执行它的控制流的一部分的每一时间段叫做时间片。
4、私有地址空间:进程为每个流都提供一种假象,好像它是独占的使用系统地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,在这个意义上,这个地址空间是私有的。
5、用户模式和内核模式::处理器通常使用一个寄存器提供两种模式的区分,该寄 存器描述了进程当前享有的特权,当没有设置模式位时,进程就处于用户模式中, 用户模式的进程不允许执行特权指令,也不允许直接引用地址空间中内核区内的 代码和数据;设置模式位时,进程处于内核模式,该进程可以执行指令集中的任 何命令,并且可以访问系统中的任何内存位置。
6、上下文信息:上下文就是内核重新启动一个被抢占的进程所需要的状态,它由 通用寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈和各种内 核数据结构等对象的值构成。
7、上下文切换:当内核选择一个新的进程运行时,则内核调度了这个进程。在内核调度了一个新的进程运行后,它就抢占当前进程,并使用一种称为上下文切换的机制来将控制转移到新的进程:
1) 保存以前进程的上下文
2)恢复新恢复进程被保存的上下文,
3)将控制传递给这个新恢复的进程 ,来完成上下文切换。
Hello进程执行,shell调用execve函数之后,由以上可知进程为hello分配了全新的虚拟地址空间,hello先运行在用户模式下输出hello 1190303311 王志军,然后调用sleep进入内核模式,内核会处理休眠请求主动释放进程,并将hello进程从运行队列移动到等待队列,定时器开始计时,内核进行上下文切换将当前进程的控制权交给其他进程,当定时器到时会发送一个中断信号,进入内核状态执行中断处理,将hello进程从等待队列中移除重新加入到运行队列。
Hello调用getchar,实际系统调用read函数,首先运行在用户模式,调用getchar时系统调用read函数,陷入内核模式处理来自键盘缓冲区的DMA传输,完成数据传输后终端处理器,内核进行上下文切换到其他进程,完成数据传输后引发中断信号,进程上下文切换到hello
Hello执行时可能遇到的异常:
中断:来自键盘的信号产生中断
故障:缺页异常,首次运行时hello程序的代码和数据都没有加载到内存,第一次调用会出发缺页异常;
陷阱:sleep
终止:不可恢复的错误,程序会停止运行。
运行时什么都不按,最后回车,正常退出;
运行时乱按,会输入到shell,但程序不受影响;
Ctrl z进程停止;
Fg调到前台继续运行;
Kill杀死进程;
Ctrl c直接终止进程;
本章介绍了进程的概念和作用,介绍了进程执行过程中的上下文切换、信号处理等内容。
逻辑地址:在机器语言中,用来指定一个操作数或者是一条指令的地址。每一个逻辑地址都由一个段和一个偏移量组成,偏移量是段起始位置与实际位置之间的距离,实际上也就是hello.o中的相对偏移地址。
线性地址:逻辑地址到物理地址变换之间的中间层。程序代码会产生逻辑地址,或者说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。是hello中的虚拟内存地址。
虚拟地址:虚拟地址就是线性地址。每个进程都有独立且结构相同的虚拟地址空间,从0x400000开始
物理地址:内存中的地址,与CPU地址总线对应,由虚拟地址翻译而来。
逻辑地址由两部分组成,段标识符和段内偏移量,段标识符16位长,是段选择符,前13为是一个索引号,后3为包含一些硬件细节;给定一个完整的逻辑地址,根据一个位判断段描述符是在全局还是在局部的表中,接着取出段描述符的前13位,重新组合成线性地址。
CPU的页式管理,MMU单元将虚拟地址即线性地址翻译成一个物理地址,虚拟地址和物理地址将数据分成4kb大小的页来管理,虚拟地址到物理地址的映射通过页表来管理,每个页都有一个对应的PTE条目映射到物理没存,现代处理器还采用多级页表,通过一层一层的寻址来找到相应的PTE条目,映射到相应的物理页。
课本上的图;
i7的页表翻译,四级页表中,第一级页表相当于第二级页表的索引,第二级页表相当于第三级页表的索引,第三季相当于第四级的索引,第四级页表映射到物理页。采用多级页表是对内存的极大节约。只有经常使用的页表才会保存在内存中。多级页表的不同之处就如上所述,其他过程与一级页表完全一样。对虚拟地址进行划分,假设共有48位,前36位为VPN,则根据VPN访问页表,根据页表判断相应地址的数据是否缓存,若缓存,可直接从页表中读出PPN,则VPO与PPN组成一个完整的物理地址,接下来便可访存。
在虚拟地址翻译成物理地址之后,CPU传递给内存物理地址,进行索引,物理地址分割成组索引、标记和块偏移,先到L1去找,没有就去L2,没有再去L3,再没有就需要从内存加载。
Fork函数被调用时,子进程会继承父进程的虚拟地址空间,会拥有与父进程不同的PID,其他如代码数据和打开的文件描述符表都一样。
execve函数在当前进程中加载并运行包含在可执行目标文件hello中的文件。加载并运行hello有以下几个步骤:
(1)删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
(2)映射私有区域。为新的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制的。代码和数据区域被映射为hello文件中的.text和.data区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在hello中。栈和堆区域也是请求二进制零的,初始长度为零。
(3)映射共享区域。hello与共享对象链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享区域内。
(4)设置程序计数器。execve设置当前进程上下文的程序计数器,使之指向代码区域的入口点。下一次调度这个进程时,将从这个入口点开始执行。
缺页故障:程序执行要读取某个页不再内存中会出发缺页故障;
缺页故障出发缺页中断处理程序,假设MMU在试图翻译某个虚拟地址是,触发了一个缺页。这个异常便会导致控制转移到内核的缺页处理程序,处理程序回执行以下三个步骤:
(1)检查虚拟地址是否合法。该步骤主要检查虚拟地址是否是指向某个区域结构定义的区域内,即是否在访问范围内。如果不在区域内,访问的页是不存在的,则会报错,并终止进程。
(2)要进行的内存访问是否合法。主要判断是否满足读、写或者执行这个区域内页面的权限。例如,如果是因为试图对一个只读页面进行写操作而引起的缺页中断,那么缺页处理程序会触发一个保护异常,并终止这个程序。
(3)在完成上面两个步骤成功到达第三个步骤时,已经确定了缺页是由于正常的不命中原因引起的,此时便可以从磁盘中将缺失的页读入内存。首先选择一个牺牲页面,如果牺牲页面有被修改过,则将牺牲页面写回磁盘,否则直接还如新的页。
这样,当却也处理程序返回时,CPU会重启引起缺页的指令,而这时就没有了缺页的情况。
动态内存分配器维护着一个进程的虚拟内存区域,称为堆.系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址) .对于每个进程,内核维护着一个变量brk, 它指向堆的顶部。
分配器将堆视为一组不同大小的块的集合来维护.每个块就是一个连续的虚拟内存片,要么是已分配的,要么是空闲的.已分配的块显式地保留为供应用程序使用.空闲块可用来分配.空闲块保持空闲,直到它显式地被应用所分配.一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显式执行的,要么是内存分配器自身隐式执行的.
分配器有两种基本风格. 两种风格都要求应用显式地分配块.它们的不同之处在于由哪个实体来负责释放已分配的块
显式分配器(explicit allocator):要求应用显式地释放任何已分配的块.例如,C标准库提供一种叫做malloc程序包的显式分配器.C程序通过调用malloc函数来分配一个块,并通过调用free函数来释放一个块.C++中的new和delete操作符与C中的malloc和free相当.
隐式分配器(implicit allocator):要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块.隐式分配器也叫做垃圾收集器(garbage collector),而自动释放未使用的已分配的块的过程叫做垃圾收集( garbage collection).例如,诸如Lisp、ML以及Java之类的高级语言就依赖垃圾收集来释放已分配的块。
而动态内存分配具有重要的意义,因为知道程序运行时才知道需要分配的内存的大小。
显示分配器有几个约束条件:
(1)能够处理任意请求序列。(2)立即响应分配请求。不允许分配器为了提高性能重新排列或者缓冲请求。(3)只使用堆。(4)对齐块(对齐要求)。(5)不修改已分配的的块。
一个实际的分配器要在吞吐率和利用率之间把握好平衡,就必须考虑一下几个问题:
(1)空闲块组织:如何记录空闲块。
(2)放置:我们如何选择一个合适的空闲块来放置一个新分配的块?
(3)分割:分配完后,如何处理这个空闲块中的剩余部分?
(4)合并:如何处理一个刚刚被释放的块?
隐式空闲链表是满足这些条件的较简单的空闲块组织方式。首先一个块由一个字的头部、有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。此外,一般情况下还有对其的约束条件。
那么问题来了,分配器如何实现合并?这里利用了带边界标记合并的技术。分为四种情况:(1)前面的块和后面的块都是已分配的;(2)前面的块是已分配的,后面的块空闲;(3)前面的块空闲,后面的块已分配;(4)前面的块和后面的块均已分配。
显式空闲链表相比隐式空闲链表具有更高的效率。显式空闲链表是一个双向空闲链表,显式空闲链表使首次适配的时间从块总数的线性时间减少到了空闲块总数的的线性时间。但是显式空闲链表也潜在地提高了内部碎片的程度:
空闲链表中块的排序策略有几种:1、后进先出。新释放的块放在来表开始处;2、按照地址顺序维护链表。分离的空闲链表,通常称为分离存储,就是维护多个空闲链表,减少分配时间。主要有简单分离存储、分离适配、伙伴系统等。
本章介绍了数据存储的地址空间,以及各个地址空间的映射,还有内存分配的一些技术和策略。
一个linux文件就是一个m个字节的序列:B0, B1, … Bk, …, Bm-1
所有的I/O设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当做对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许linux内核引出一个简单、低级的应用接口,称为Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行:UNIX I/O。
Unix IO接口:打开文件,内核返回一个非负整数的文件描述符,通过对此文件描述符对文件进行所有操作。
Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(文件描述符0)、标准输出(描述符为1),标准出错(描述符为2)。头文件定义了常量STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO,他们可用来代替显式的描述符值。
改变当前的文件位置,文件开始位置为文件偏移量,应用程序通过seek操作,可设置文件的当前位置为k。
读写文件,读操作:从文件复制n个字节到内存,从当前文件位置k开始,然后将k增加到k+n;写操作:从内存复制n个字节到文件,当前文件位置为k,然后更新k
关闭文件。当应用完成对文件的访问后,通知内核关闭这个文件。内核会释放文件打开时创建的数据结构,将描述符恢复到描述符池中
unix IO函数:
1、open()函数
功能描述:用于打开或创建文件,在打开或创建文件时可以指定文件的属性及用户的权限等各种参数。
函数原型:int open(const char *pathname,int flags,int perms)
参数:pathname:被打开的文件名(可包括路径名如"dev/ttyS0")flags:文件打开方式,
返回值:成功:返回文件描述符;失败:返回-1
2、close()函数
功能描述:用于关闭一个被打开的的文件
所需头文件: #include
函数原型:int close(int fd)
参数:fd文件描述符
函数返回值:0成功,-1出错
3、read()函数
功能描述: 从文件读取数据。
所需头文件: #include
函数原型:ssize_t read(int fd, void *buf, size_t count);
参数:fd:将要读取数据的文件描述词。buf:指缓冲区,即读取的数据会被放到这个缓冲区中去。count: 表示调用一次read操作,应该读多少数量的字符。
返回值:返回所读取的字节数;0(读到EOF);-1(出错)。
4、write()函数
功能描述: 向文件写入数据。
所需头文件: #include
函数原型:ssize_t write(int fd, void *buf, size_t count);
返回值:写入文件的字节数(成功);-1(出错)
5、lseek()函数
功能描述: 用于在指定的文件描述符中将将文件指针定位到相应位置。
所需头文件:#include ,#include
函数原型:off_t lseek(int fd, off_t offset,int whence);
参数:fd;文件描述符。offset:偏移量,每一个读写操作所需要移动的距离,单位是字节,可正可负(向前移,向后移)
返回值:成功:返回当前位移;失败:返回-1
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;
}
printf函数主要调用了vsprintf和write函数。
下面首先介绍vsprintf(buf, fmt, arg)是什么函数。
int vsprintf(char *buf, const char fmt, va_list args)
{
char p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) {
if (*fmt != ‘%’) {
*p++ = *fmt;
continue;
}
fmt++;
switch (*fmt) {
case ‘x’:
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函数可以看出,这个函数的作用是将所有的参数内容格式化之后存入buf,然后返回格式化数组的长度。
对write进心追踪:
write:
mov eax, _NR_write
mov ebx, [esp + 4]
mov ecx, [esp + 8]
int INT_VECTOR_SYS_CALL
一个int INT_VECTOR_SYS_CALL表示要通过系统来调用sys_call这个函数。
sys_call的实现:
sys_call:
call save
push dword [p_proc_ready]
sti
push ecx
push ebx
call [sys_call_table + eax * 4]
add esp, 4 * 3
mov [esi + EAXREG - P_STACKBASE], eax
cli
ret
于是可以直到printf函数执行过程如下:
从vsprintf生成显示信息,到write系统函数,到陷阱-系统调用 int 0x80或syscall.
字符显示驱动子程序:从ASCII到字模库到显示vram(存储每一个点的RGB颜色信息)。
显示芯片按照刷新频率逐行读取vram,并通过信号线向液晶显示器传输每一个点(RGB分量)。
Getchar是由宏实现的,语句时#define getchar() getc(stdin),getchar接受用户的键盘输入直到遇到一个回车,getchar有一个缓冲区,所有的字符都存放在缓冲区中,直到遇到回车之后才从缓冲区读入字符,文件结果就返回-1,正常返回值是字符的ASCII码。
异步异常-键盘中断的处理:键盘中断处理子程序。接受按键扫描码转成ascii码,保存到系统的键盘缓冲区。
getchar等调用read系统函数,通过系统调用读取按键ascii码,直到接受到回车键才返回。
本章介绍了IO设备的知识,介绍了一些unixIO函数,分析了printf和getchar函数的实现细节。
Hello,每个程序员的初恋,他的生命是短暂的,但是并不因此而暗淡,从预处理到编译,再到汇编链接运行,我见证了hello从一个文本文件到执行显示出我的信息的过程,hello,多么令人难忘和着迷!
Hello诞生之初,程序员用优美的高级语言编写,此时的hello是纯洁的,没有经过任何加工,接着它经过了预处理,褪去了宏定义的羞涩包裹,接着它经过了编译,变成了汇编语言,已经渐渐能够看出想要和CPU并肩作战的愿望,然后经过汇编成了机器码,此时的hello已经不再是起初那个纯洁的hello了,程序员难以看懂hello的心愿,只有通过反汇编能够再睹芳容,然后它经历了链接,重定位,终于变成了可执行文件,似乎一切都准备就绪了,可是CPU呢?CPU也在等待着它的到来,fork,进程,execve,虚拟内存,动态链接,共享库,经历存储体系和CPU处理体系的配合,hello终于成为了一个合格的程序,运行开始,它一行一行的在屏幕上打印出自己的字符串,挥洒自己的热血,然而,浪漫总是短暂的,很快hello就迎来了自己的终止,当然,它还在,等待着那个创造他的程序员再次想起它。