程序修改原理与操作流程&PE文件病毒一览&深入理解计算机系统 第九章 虚拟存储器
1.序 言
当时做信13日版姬狩(0.88版)的时候,我对汇编、破解这块也不懂,但因为mod有需求,所以边学边做,渐渐懂了不少。我现在写的东西,算比较浅的,如果将来想改其他游戏需要具体问题具体分析,这里只是一个引导文章。
老版姬狩mod那会儿,内存修改内容游侠的S大做过不少,所以我自己做的不多。当时的想法是,编写另一个程序,在游戏启动后对其自动进行内存修改,但是,因为壳、杀毒软件等等众多不稳定因素,玩家们常跳出不能游戏,而且当时还做了一些变态的防修改、防作弊工作,使得游戏更加不稳定,我很后悔。因此,在筹备第二版姬狩的时候,就准备固化到原程序里,这样比较稳定。萌娘MOD里就是这种方式的修改。
***********************************************************************************************************************************
2.PE头的知识和修改
我们电脑上所用的程序一般都是exe格式,但不管什么文件,存储在计算机里的都是二进制的数据,程序也不例外。虽然实际上来讲,exe是由许多0和1组成的,但是我们一般将它们读取成16进制的数来进行研究,这样比较方便。
比如说,10进制的18,用16进制表示是12,用二进制表示就是0001 0010。
像0x12这样的数,我们称为一个字节(byte),两个字节就是一个字(word),两个字就是双字(dword)。计算机中,表示数据地址的数都是双字,像0x0057104C,这里的“0x”表示后面的是一个16进制的数。
微软的系统,在开始执行一个应用程序的时候,会自动开辟一个4G大小的虚拟内存空间,是虚拟的,所以只是逻辑上认为它是4G大小,实际上它几乎不占用内存,也不占用硬盘空间,只有当其中有代码载入或开辟了内存空间时才会占用计算机资源。总的来说,它是一种地址间的对应关系。
我们可以把这个虚拟空间想像成地图,但是这个地图上是空白的,什么都没有。而exe文件就是写了这些“内容”的文件,计算机会把这些代码对号入座的填到对应的位置上。什么地方有什么内容都是exe文件规定好了的,而每段程序代码所对应的虚拟地址也写在exe文件里,我们一般称之为PE文件头。
PE文件头是怎么规定的,格式应该怎么样,这里就不详说了,因为我们有工具——LordPE,用它可以对PE文件头进行编辑。
双击LordPE_hh.EXE打开程序,这个是汉化的版本,LordPE.EXE这个是原来的英文版本。

红框内是现在正在运行着的程序的信息,我们不用管,点击右边蓝色框里的“PE编辑器”按钮。
此时会弹出文件选择对话框,选择你要改的程序,比如说我们要改的信野13的主程序,NOBU13PK.exe。

这时候就可以看到一个程序PE头的一些信息了,内容很多,但我们只要注意两个地方,镜像基址和块对齐。
镜像基址,是一个额外的偏移量,它规定了计算机把程序代码载入到虚拟内存中时的起始偏移量。
块对齐,它规定了,每个代码块、数据块的最小大小,我们如果想增加一段代码空间,就必须是这个大小的倍数。
接下来点“区段”按钮。

好了,弹出了区段表,可以看到程序都定义了哪些区段。所谓区段,可以想象成一个个的住宅区,里面住着的就是程序的代码。每段都由程序制作者定义了名字、起始地址和大小。
比如说第一行的.text段:
名称 VOffset VSize ROffset RSize 标志
.text 00001000 00A7BCFA 00001000 00A7C000 C0000040
名称,顾名思义,就是这个区段的名字,这个可以随便定义,计算机执行程序的时候并不在乎它叫什么名字,是用不到的。
VOffset,虚拟地址,它是一个参量,如果我们把这个数加上之前说过的那个镜像基址,在信野13中为0x400000,加上这个VOffset就是0x401000,这个地址就是.text这个区段里的代码载入到虚拟内存中时的实际地址,也就是我们常用金山游侠、CE等内存修改工具时用到的“内存地址”。
VSize,就是这个区段在程序运行后占用的虚拟内存空间的大小,这个大小可以按实际占用大小来算,可以看到这里的0x00A7BCFA很随意,并不是上面说过的块大小——0x1000的倍数。
ROffset,物理偏移量,这个是对exe文件而言的,它指出了,这个块的数据在exe文件中是从哪个地址开始的。
RSize,物理大小,这个区段在exe文件中占用的存储空间的大小。这个大小必须是块大小的倍数。
标志,这个区段是干什么的,是代码么?有数据么?可以读写么?这些信息就是标志的作用。
因为我们要固化代码到程序,有很多地方不只是修改,还要增加一些代码,而这些代码是需要空间来存储的。
所以接下来,我们就是要找到一些空闲的空间来写这些代码。
有两种方式,第一种,找到原先程序里本来就没用到的一些空白区域,直接写在那里。但是信野13里几乎没有这样的空间,所以作罢。
第二种,自己增加一段空间。
这里又有三种方式。
1. 观察区段定义,找到虚拟空间之间空白的区域,然后扩大区段大小达到增加空间的目的,这个我试过,信野13在运行的时候会释放一些代码到这些空白区域,实际上并不能使用,另外这种方式也不简单,放弃。
2. 增加一个区段,这是一般修改中用到的方法,但是因为信野13繁中PK版目前还没有去壳的破解exe放出,这里增加一个区段会导致游戏出错进不了游戏,只好放弃。
3. 扩大最后一个区段的大小,在这里就是.settec这个段,为什么是最后一个段,因为在末尾加比较容易、方便。
好了,点击.settec段,再单击右键,在弹出菜单中选择“编辑区段”。
可以看到这段的物理大小和虚拟大小都是0x0015A000,我们要增加一段,比如说增
0x4000大小的空间。将这两个数改成0x0015E000就行了。然后击“确定”退出区段修改框,再点击区段表对话框右上角的红色小叉退出到一开始打开PE编辑器时的界面。再点击“保存”按钮,保存现在对PE头的修改。

实际上,这个保存主要是为了保存PE头里的其他信息,我们增加段大小的数值,在退出段编辑器的时候工具已经对程序做了修改,所以如果怕误操作修改错了,最后自己先备份一下要修改的程序。
这一步所做的只是规定了这一段空白的区域,就像政府批了文件说可以在某个地方建楼,但是实际上那个地方可能还是原始森林……
接下来要做的就是在exe文件中增加一段真正的物理储存空间。
我们需要一个16进制编辑器,我用的是Hexworkshop这款软件,但是大多数人用的是Ultra Edit,希望名字没记错。看自己喜好用。
用编辑器打开exe文件。拖到文件末尾。

插入0x4000字节的内容。关于填充字节,可以是0x00,也可以是0xcc,这个随意填,但是一般填0xcc比较好,那样编辑起来比较直观,不容易出错。
上面的图是我用Hex Workshop编辑的界面,如果你用的是其他16进制编辑器,可能界面有所不同,需自行研究操作。
最后记得保存。
这一步,我们是从0xEF7568处加了0x4000字节。
*这一步中的插入的0x4000的这个大小严格上来讲并不正确!我们可以把最后一个区段的物理偏移与物理大小相加,得到的大小是0xEF6000,也就是说,这个程序严格上来说,exe文件的真实大小应该是0xEF6000,但是实际的大小却是0xEF7568!也就是说,原程序后面还附加了一些内容。我们在区段定义里加了0x4000的空间,但是因为实际大小比理论大小要大,所以0x4000是有点大了。我们应当填充的大小应该是:0x4000-(0xEF7568-0xEF6000)=0x2A98。而原程序后面多加的内容是当成这增加的0x4000空间的一部分了,所以我们真正可以用的地方是从exe文件中的0xEF7568处开始,内存中是从(0x0FC13000+0x15A000+0x400000)+(0xEF7568-0xEF6000)=0x1016E568处开始。
萌娘mod中,我加了0x4000字节的空间,因为多加了点空间其实也没什么问题,也就这样了。
我们新增加的代码在内存(我们一般讲的内存地址都是虚拟内存,真正的物理内存中的内容我们是看不到的)中是从哪个地方开始的呢?
通过前面讲到知识我们已经可以计算了。
这样,我们新增的内容,在内存中的地址是从0x1016E568开始,对应的exe文件中的内容是从0xEF7568开始。
***********************************************************************************************************************
3.内存研究成果的固化操作
做好前面的准备工作,就可以着手进行固化操作了。我是从干净的原程序开始做起讲的,如果是在我之前改过的exe文件上修改,则不需要之前讲过的步骤。
通过前面的说明,我们已经了解,对于增加的内存代码,我们是有要求的,就是要在0x1016E568后面才行,那里才有我们的新增的空间。
有些内存修改研究者,往往在改内存代码时见缝插针。如下面的代码:
00629EA4 jmp 0x400100
……
……
00400100 mov eax,0x10
在内存0x00400100处新增了代码。
我们知道,第一段的偏移是0x1000,而虚拟偏移量是0x400000,也就是说第一段程序是从0x401000处开始的,0x00400100这个地方,在exe文件中并没有指定的位置。另外,新增的代码写在自己定义的空间里才是最安全的,不容易出错。所以,我们要对别人的研究成果进行修改才能使用。
改成如下:
00629EA4 jmp 0x1016F000
……
……
1016F000 mov eax,0x10
修改代码往往是用OllyDbg这款工具完成的,可以说这是crack界的神器,非它不可。一般的工作都可以通过它来完成,但是如果是破解一些比较厉害的软件,可能就不行了,需要硬调试工具,那个在这里就不谈了。
有两种方式去调试一个程序,一种是先启动你的目标程序,再点OllyDbg菜单上的“文件”——“附加”,选择你的目标程序的进程,再点“附加”按钮,就会载入目标程序的信息,等待片刻,此时会自动中断目标程序的运行,按F9就可以继续让目标程序运行了。
另一种方式是,直接在菜单上点“文件”——“打开”,选择你的目标程序,工具会自动载入目标程序的代码,同样点工具栏上的运行按钮,或是按F9都可以让目标程序运行。
用打开方式载入信野13时,会提示代码被压缩或加密,这个不用管它,点是或否都行。如果你在这一步打开时,工具提示无法启动,或者游戏启动出错,那就说明你前面新增空间的部分有地方做得不对。
按F9运行游戏,进入游戏看到主界面,这时候就可以切到工具这里进行修改了。

在上图蓝色框内右键——“转到”——“表达式”,打开一个对话框,在里面输入内存地址,按确定,转到对应的内存处,在对应的地方按下空格键就可以输入汇编码来进行修改了。
通常,在修改完成后,只要右键点击,使用“复制到可执行文件”命令,就可以把修改的内容写到exe文件中,但是信野13因为壳的关系,会出现无法定位的情况。这时候就需要人工进行输入修改了。
举个例子:
我要修改女武将可以跟姬百合,研究发现只要把内存0x008517c0处改成0x00就可以了。
计算一下这个地方对应在exe文件中的具体位置,0x008517c0-0x00400000=0x004517c0,用16进制编辑器打开游戏程序NOBU13PK.exe,把0x004517c0处改成0x00,保存。重新运行程序,用CE或者Ollydbg等可以查看内存的工具,找到0x8517c0处,发现,改过的数值并不是0x00,而是0x83!这是因为有壳的关系,数值发生了变化。幸好的是,信野13的壳很有意思,如果把0x83这个数再写到exe文件对应的地址0x004517c0处,你会发现,重启游戏后,数值居然正确了。这就是目前修改原有代码的步骤。
再举个例子:
我要修改0x1016F201处为:
1016F201 8948 04 mov dword ptr ds:[eax+4], ecx
计算一下0x1016f201对应在exe文件中的地址:
0x1016f201-(0x0FC13000+0x00400000-0x00D9C000)
=0x1016F201-0x0F277000
=0xEF8201
因为这里是我们新增的内容,壳管不到,所以就简单多了,只要在0xEF8201处写入这段汇编的机器码0x8948 04就行了。有时候,需要写入的代码会比较长,手工输入一大堆数字也挺烦的,但是也没什么办法,目前我也没找到什么好的工具或是方法。
用Ollydbg主要是可以进行调试,另外,用Ollydbg写汇编码的时候,生成的机器码要比CE规范,极个别的汇编码CE会出错。但是如果你习惯用CE来改代码,也不是不可以。
好了,这就是需要了解的内容了,一时兴起写的文章,或许有疏漏,请见谅。
PE文件病毒一览
PE文件简介
简介
PE文件的全称是Portable Executable,意为可移植的可执行的文件,常见的EXE、DLL、OCX、SYS、COM都是PE文件,PE文件是微软Windows操作系统上的程序文件(可能是间接被执行,如DLL)
PE文件是指32位可执行文件,也称为PE32。64位的可执行文件称为PE+或PE32+,是PE(PE32)的一种扩展形式(请注意不是PE64)。
PE文件使用的是一个平面地址空间,所有代码和数据都合并在一起,文件内容被分割为不同区块,区块包含代码和数据,区块按页对齐,没有大小限制,每个块都有自己在内存的属性,如是否包含代码,是否可读可写
认识到PE文件不是作为单一内存映射文件被载入内存是很重要的。Windows 加载器(又称PE 装载器)遍历PE文件并决定文件的哪一部分被映射,这种映射方式是将文件较高的偏移位置映射到较高的内存地址中。磁盘文件一旦被载入内存,磁盘上的数据结构布局和内存中的数据结构布局就是一致的。这样,如果在磁盘的数据结构中寻找一些内容, 那么几乎都能在被载入的内存映射文件中找到相同的信息,但数据之间的相对位置可能会改变,某项的偏移地址可能区别于原始的偏移位置。不管怎样,对所有表现出来的信息,都允许进行从磁盘文件偏移到内存偏移的转换。
结构总览

一些基本概念

-
基地址
当PE文件通过Windows加载器载入内存后,内存中的版本称为模块( Module )。映射文件的起始地址称为模块句柄( hModule ),可以通过模块句柄访问内存中的其他数据结构。这个初始内存地址也称为基地址( ImageBase )。
内存中的模块代表进程将这个可执行文件所需要的代码、数据、资源、输入表、输出表及其他有用的数据结构所使用的内存都放在一个 连续的内存块中,程序员只要知道装载程序文件映像到内存后的基地址即可。PE文件的剩余部分可以被读入,但可能无法被映射。例如,在将调试信息放到文件尾部时,PE的一个字段会告诉系统把文件映射到内存时需要使用多少内存,不能被映射的数据将被放置在文件的尾部。
基地址的值是由PE文件本身设定的。按照默认设置,用Visual C++建立的EXE文件的基地址
是40000h、DLL 文件的基地址是000000h。可以在创建应用程序的EXE文件时改变这个地址,方
法是在链接应用时使用链接程序的/BASE选项,或者在链接后通过REBASE应用程序进行设置。 -
虚拟地址
PE文件被系统加载器映射到内存中,每个都有自己虚拟空间,它的内存地址称为虚拟地址(VA)
-
相对虚拟地址
为了避免在PE文件中出现绝对内存地址引入了相对虚拟地址( Relative Virtual Address, RVA )
的概念。RVA只是内存中的一个简单的、相对于PE文件载入地址的偏移位置,它是一个“相对”地
址(或称偏移量)。
将一个RVA转换成真实的地址只是简单地翻转这个过程,即用实际的载入地址加RVA,得到
实际的内存地址。它们之间的关系如下:
虚拟地址(VA) =基地址( ImageBase) +相对虚拟地址( RVA) -
文件偏移地址
当PE文件储存在磁盘中时,某个数据的位置相对于文件头的偏移量称为文件偏移地址( File
Offset )或物理地址( RAW Offset )。文件偏移地址从PE文件的第1个字节开始计数,起始值为0。
用十六进制工具(例如Hex Workshop、WinHex 等)打开文件时所显示的地址就是文件偏移地址。
基本结构
基本结构

结构详解

MS-DOS头
64字节,是用来兼容MS-DOS操作系统的,目的是当这个文件在MS-DOS上运行时提示一段文字,大部分情况下是:This program cannot be run in DOS mode.还有一个目的,就是指明NT头在文件中的位置
定义:
|
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
IMAGE_DOS_HEADER {<!-- -->
WORD e_magic; // +0000h - EXE标志,“MZ” WORD e_cblp; // +0002h - 最后(部分)页中的字节数 WORD e_cp; // +0004h - 文件中的全部和部分页数 WORD e_crlc; // +0006h - 重定位表中的指针数 WORD e_cparhdr; // +0008h - 头部尺寸,以段落为单位 WORD e_minalloc; // +000ah - 所需的最小附加段 WORD e_maxalloc; // +000ch - 所需的最大附加段 WORD e_ss; // +000eh - 初始的SS值(相对偏移量) WORD e_sp; // +0010h - 初始的SP值 WORD e_csum; // +0012h - 补码校验值 WORD e_ip; // +0014h - 初始的IP值,代码入口 WORD e_cs; // +0016h - 初始的CS值 WORD e_lfarlc; // +0018h - 重定位表的字节偏移量 WORD e_ovno; // +001ah - 覆盖号 WORD e_res[4]; // +001ch - 保留字00 WORD e_oemid; // +0024h - OEM标识符 WORD e_oeminfo; // +0026h - OEM信息 WORD e_res2[10]; // +0028h - 保留字 LONG e_lfanew; // +003ch - PE头相对于文件的偏移地址 } |
需要关注的两个域
-
e_magic
一个WORD类型,值是一个常数0x4D5A,用文本编辑器查看该值位‘MZ’,可执行文件必须都是’MZ’开头
-
e_Ifanew 最后4字节
RVA,为32位可执行文件扩展的域,用来表示DOS头之后的NT头相对文件起始地址的偏移
DOS头和NT头之间是DOS存根,没有它程序也能正常执行
PE文件头
包含windows PE文件的主要信息,其中包括一个‘PE’字样的签名,PE文件头(IMAGE_FILE_HEADER)和PE可选头(IMAGE_OPTIONAL_HEADER32)
PE装载器将从IMAGE DOS _HEADER结构的e. _lfanew字段里找到PE Header的起始偏移量,用其加上基址,得到PE文件头的指针。
定义:
|
1
2 3 4 5 |
IMAGE_NT_HEADERS {<!-- -->
DWORD Signature; // +0000h - PE文件标识,“PE00” IMAGE_FILE_HEADER FileHeader; // +0004h - PE标准头 IMAGE_OPTIONAL_HEADER32 OptionalHeader; // +0018h - PE扩展头 } |
需要关注的
-
Signature
类似e_magic,其高16位是0,低16是0x4550,字符是PE00
IMAGE_FILE_HEADER
20字节
PE文件头:记录了PE文件的全局属性,如运行平台,PE文件类型,文件中存在的节总数等
|
1
2 3 4 5 6 7 8 9 10 |
IMAGE_FILE_HEADER {<!-- -->
WORD Machine; // +0004h - 运行平台 WORD NumberOfSections; // +0006h - PE中节的数量,当定义的节段数与实际不符时,将发生运行错误 DWORD TimeDateStamp; // +0008h - 文件创建日期和时间 DWORD PointerToSymbolTable; // +000ch - 指向符号表 DWORD NumberOfSymbols; // +0010h - 符号表中的符号数量 WORD SizeOfOptionalHeader; // +0014h - 扩展头结构的长度 WORD Characteristics; // +0016h - 文件属性,是否是可运行状态等 } |
重要成员(设置不正确会导致文件无法正常运行)
-
Machine
每个CPU都有唯一的Machine码
-
NumberOfEsctions
NumberOfEsctions指文件中存在的节段(又称节区)数量,也就是节表中的项数。该值一定要大于0,且当定义的节段数与实际不符时,将发生运行错误。
-
SizeOfOptionalHeader
扩展头结构的长度
-
Characteristics
可执行文件属性
DLL 文件对应的 IMAGE_FILE_RELOCS_STRIPPED 位总是为0,而EXE文件的这个标志位总是为1,即DLL中不删除重定位信息,EXE文件中删除重定位信息
IMAGE_OPTIONAL_HEADER32
PE可选头:文件执行时的入口地址、文件被操作系统装入内存后的默认基地址,以及节在磁盘和内存中的对齐单位等信息均可以在此结构中找到。对该结构中的某些数值的随意改动可能会造成PE文件的加载或运行失败
|
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
IMAGE_OPTIONAL_HEADER {<!-- -->
WORD Magic; // +0018h - 魔术字107h = ROM Image,10bh = exe Image BYTE MajorLinkerVersion; // +001ah - 链接器版本号 BYTE MinorLinkerVersion; // +001bh - DWORD SizeOfCode; // +001ch - 所有含代码的节的总大小 DWORD SizeOfInitializedData; // +0020h - 所有含已初始化数据的节的总大小 DWORD SizeOfUninitializedData; // +0024h - 所有含未初始化数据的节的大小 DWORD AddressOfEntryPoint; // +0028h - 程序执行入口RVA DWORD BaseOfCode; // +002ch - 代码的节的起始RVA DWORD BaseOfData; // +0030h - 数据的节的起始RVA DWORD ImageBase; // +0034h - 程序的建议装载地址 DWORD SectionAlignment; // +0038h - 内存中的节的对齐粒度 DWORD FileAlignment; // +003ch - 文件中的节的对齐粒度 WORD MajorOperatingSystemVersion; // +0040h - 操作系统版本号 WORD MinorOperatingSystemVersion; // +0042h - WORD MajorImageVersion; // +0044h - 该PE的版本号 WORD MinorImageVersion; // +0046h - 链接器版本号 WORD MajorSubsystemVersion; // +0048h - 所需子系统的版本号 WORD MinorSubsystemVersion; // +004ah - DWORD Win32VersionValue; // +004ch - 未用 DWORD SizeOfImage; // +0050h - 内存中的整个PE映象尺寸 DWORD SizeOfHeaders; // +0054h - 所有头+节表的大小 DWORD CheckSum; // +0058h - 校验和 WORD Subsystem; // +005ch - 文件的子系统 WORD DllCharacteristics; // +005eh - DLL文件特性 DWORD SizeOfStackReserve; // +0060h - 初始化时的栈大小 DWORD SizeOfStackCommit; // +0064h - 初始化时实际提交的栈大小 DWORD SizeOfHeapReserve; // +0068h - 初始化时保留的堆大小 DWORD SizeOfHeapCommit; // +006ch - 初始化时实际提交的堆大小 DWORD LoaderFlags; // +0070h - 与调试有关 DWORD NumberOfRvaAndSizes; // +0074h - 下面的数据目录结构的项目数量 IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; // 0078h - 数据目录 } |
重要成员(程序运行所必须的,设置错误将导致无法正常运行)
-
Magic
可选头的类型
1
2
3#define IMAGE_NT_OPTIONAL_HDR32_MAGIC 0x10b // 32位PE可选头
#defineIMAGE_NT_OPTIONAL_HDR64_MAGIC 0x20b // 64位PE可选头
#defineIMAGE_ROM_OPTIONAL_HDR_MAGIC 0x107 -
AddressOfEntryPoint
程序入口的RVA,对于exe可以理解为WinMain的RVA。对于DLL可以理解为DllMain的RVA,对于驱动程序,可以理解为DriverEntry的RVA
-
ImageBase
映象(加载到内存中的PE文件)的基地址
EXE文件总是按照这个地址装入
DLL文件则包含了重定位信息,地址可变(因为个DLL文件全部使用宿主EXE文件的地址空间,优先装入的地址可能被其他DLL文件占用)
一般来说,使用开发工具(VB/VC++/Delphi)创建好EXE文件后,其ImageBase值为00400000,DLL文件的ImageBase值为10000000(当然也可以指定其他值)。
执行PE文件时,PE装载器先创建进程,再将文件载入内存,然后把EIP寄存器的值设置为ImageBase+AddressOfEntryPoint
-
SectionAlignment,FileAlignment
FileAlignment指定了节段在磁盘文件中的最小单位,而SectionAlignment则指定了节区在内存中的最小单位(SectionAlignment必须大于或者等于FileAlignment)
-
SizeOfImage
当PE文件加载到内存时,SizeOfImage指定了PE Image在虚拟内存中所占用的空间大小,一般文件大小与加载到内存中的大小是不同的
-
SizeOfHeader
所有文件头(包括节表)的大小,这个值是以FileAlignment对齐的,必须是FileAlignment的整数倍
-
Subsystem
用来区分系统驱动文件(.sys)与普通可执行文件(.exe,*.dll)

-
IMAGE_OPTIONAL_DIRECTORY
定义了PE文件中出现的所有不同类型的数据的目录信息
1
2
3
4IMAGE_DATA_DIRECTORY {<!-- -->
DWORD VirtualAddress; // +0000h - 数据的起始RVA
DWORD Size; // +0004h - 数据块的长度
}数组中的每一项对应一个特定的数据结构

重点关注导入和导出表
-
NumberOfRvaAndSizes
数据目录的项数,即上面这个数组的项数
-
DllCharacteristics
DLL的文件属性,只对DLL文件有效,可以是下面定义中某些的组合
是PE文件后续节的描述,windows根据节表的描述加载每个节,每个节表项记录了PE中与某个特定的节有关的信息,如节的属性、节的大小、在文件和内存中的起始位置等
1
IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // +0000h - 8个字节节名
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc; // +0008h - 节区的尺寸
DWORD VirtualAddress; // +000ch - 节区的RVA地址
DWORD SizeOfRawData; // +0010h - 在文件中对齐后的尺寸
DWORD PointerToRawData; // +0014h - 在文件中的偏移
DWORD PointerToRelocations; // +0018h - 在OBJ文件中使用
DWORD PointerToLinenumbers; // +001ch - 行号表的位置(供调试用)
WORD NumberOfRelocations; // +0020h - 在OBJ文件中使用
WORD NumberOfLinenumbers; // +0022h - 行号表中行号的数量
DWORD Characteristics; // +0024h - 节的属性
}
|
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
#### 节表
节表是PE文件后续节的描述 由一系列的*IMAGE_SECTION_HEADER*结构排列而成,每个结构用来描述一个节,结构的排列顺序和它们描述的节在文件中的排列顺序是一致的。全部有效结构的最后以一个空的*IMAGE_SECTION_HEADER*结构作为结束,所以节表中*IMAGE_SECTION_HEADER*结构数量等于节的数量加一。 节表总是被存放在紧接在PE文件头的地方 节段头定义: ```c typedef struct _IMAGE_SECTION_HEADER { BYTE Name[IMAGE_SIZEOF_SHORT_NAME];名字 union { DWORD PhysicalAddress; DWORD VirtualSize; } Misc;尺寸 DWORD VirtualAddress; RVA DWORD SizeOfRawData; DWORD PointerToRawData; DWORD PointerToRelocations; DWORD PointerToLinenumbers; WORD NumberOfRelocations; WORD NumberOfLinenumbers; DWORD Characteristics; } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER; |
重要关注
-
Name
区块名。这是一个由8个ASCII码组成,用来定义区块的名称的数组。多数区块名都习惯性以一个“.”作为开头(例如:.text),这个“.”实际上是不是必须的。
值得我们注意的是,如果区块名达到8 个字节,后面就没有0字符了。
前边带有一个“”的区块名字会从连接器那里得到特殊的待遇,前边带有“”的相同名字的区块在载入时候将会被合并,在合并之后的区块中,他们是按照“$”后边的字符的字母顺序进行合并的。
每个区块的名称都是唯一的,不能有同名的两个区块。但事实上节的名称不代表任何含义,他的存在仅仅是为了正规统一编程的时候方便程序员查看方便而设置的一个标记而已。
所以将包含代码的区块命名为“.Data”或者说将包含数据的区块命名为“.Code”都是合法的。
当我们要从PE 文件中读取需要的区块时候,不能以区块的名称作为定位的标准和依据,正确的方法是按照IMAGE_OPTIONAL_HEADER32 结构中的数据目录字段结合进行定位。 -
VirtualSize
对表对应的区块的大小,这是区块的数据在没有进行对齐处理前的实际大小。
-
VirtualAddress
该区块装载到内存中的RVA地址。这个地址是按照内存页来对齐的,因此它的数值总是SectionAlignment的值的整数倍。
-
PointerToRawData
指出节在磁盘文件中所处的位置。这个数值是从文件头开始算起的偏移量。
-
SizeOfRawData
该区块在磁盘中所占的大小,这个数值等于VirtualSize字段的值按照FileAlignment的值对齐以后的大小。
-
Characteristics
该区块的属性。该字段是按位来指出区块的属性(如代码/数据/可读/可写等)的标志。
装载过程:
依靠PointerToRawData,SizeOfRawData,VirtualAddress,VirtualSize这4个字段的值,装载器就可以从PE文件中找出某个节(从 PointerToRawData 偏移开始的 SizeOfRawData 字节)的数据,并将它映射到内存中去(映射到从模块基地址偏移 VirtualAddress 的地方,并占用以VirtualSize的值按照页的尺寸对齐后的空间大小)
节块
PE文件有不同的节段:code(代码),data(数据),resource(资源)

用LordPE打开如下
在这里插入图片描述

每个是一个容器,可以包含代码、数据等等,每个节可以有独立的内存权限,比如代码节默认有读/执行权限,节的名字和数量可以自己定义
当编程从PE 文件中读取需要的内容时,如输入表、输出表,不能以区块名字作为参考,正确的方法是按照数据目录表中的字段来进行定位
-
块的偏移地址
按照IMAGE_OPTIONAL_HEADER32中的FileAlignment字段的值进行对齐的,而当被加载到内存中时是按照同一结构中的SectionAlignment字段的值设置对齐的,两者的值可能不同
-
常见区块名称
可以自己命名



如果两个区块有相似,一致的属性,那么在链接的时候能被合并成一个单一区块
-
区块对齐
无论是在内存中存放还是在磁盘中存放,区块是要对齐的,但它们一般的对齐值是不同的
PE 文件头里边的FileAligment 定义了磁盘区块的对齐值。每一个区块从对齐值的倍数的偏移位置开始存放。而区块的实际代码或数据的大小不一定刚好是这么多,所以在多余的地方一般以00h 来填充,这就是区块间的间隙
PE 文件头里边的SectionAligment 定义了内存中区块的对齐值。PE 文件被映射到内存中时,区块总是至少从一个页边界开始
-
RVA和文件偏移的转换
RVA 是当PE 文件被装载到内存中后,某个数据位置相对于文件头的偏移量。将 RVA 的值加上文件被装载的基地址,就可以找到数据在内存中的实际地址
任何的 RVA 必须经过到文件偏移的换算,才能用来定位并访问文件中的数据
换算方法
步骤一:循环扫描区块表得出每个区块在内存中的起始 RVA(根据IMAGE_SECTION_HEADER 中的VirtualAddress 字段),并根据区块的大小(根据IMAGE_SECTION_HEADER 中的SizeOfRawData 字段)算出区块的结束 RVA(两者相加即可),最后判断目标 RVA 是否落在该区块内。
步骤二:通过步骤一定位了目标 RVA 处于具体的某个区块中后,那么用目标 RVA 减去该区块的起始 RVA ,这样就能得到目标 RVA 相对于起始地址的偏移量 RVA2.
步骤三:在区块表中获取该区块在文件中所处的偏移地址(根据IMAGE_SECTION_HEADER 中的PointerToRawData 字段), 将这个偏移值加上步骤二得到的 RVA2 值,就得到了真正的文件偏移地址。
PE文件加载过程
一些概念
- 虚拟内存地址(Virtual Address, VA)PE文件中的指令被装入内存后的地址。
- 相对虚拟内存地址(Reverse Virtual Address, RVA相对虚拟地址是内存地址相对于映射基址的偏移量。
- 文件偏移地址(File Offset Address, FOA)数据在PE文件中的地址叫文件偏移地址,这是文件在磁盘上存放时相对于文件开头的偏移。
- 装载基址(Image base)PE装入内存时的基地址。默认情况下,EXE文件在内存中的基地址时0x00400000, DLL文件是0x10000000。这些位置可以通过修改编译选项更改。
- 虚拟内存地址、映射基址、相对虚拟内存地址的关系:
|
1
|
VA = Image Base + RVA
|
PE头内部信息大多是RVA形式存在。原因在于(主要是DLL)加载到进程虚拟内存的特定位置时,该位置可能已经加载了其他的PE文件(DLL)。此时必须通过重定向(Relocation)将其加载到其他空白的位置,若PE头信息使用的是VA,则无法正常访问。因此使用RVA来重定向信息,即使发生了重定向,只要相对于基准位置的相对位置没有变化,就能正常访问到指定信息,不会出现任何问题。
- 文件偏移是相对于文件开始处0字节的偏移,相对虚拟地址则是相对于装载基址0x00400000处的偏移。(1)PE文件中的数据按照磁盘数据标准存放,以0x200字节为基本单位进行组织,PE数据节的大小永远是0x200的整数倍。(2)当代码装入内存后,将按照内存数据标准存放,并以0x1000字节为基本单位进行组织,内存中的节总是0x1000的整数倍。
- 内存中数据节相对于装载基址的偏移量和文件中数据节的偏移量的差异称为节偏移。
|
1
2 |
文件偏移地址 = 虚拟内存地址(VA) - 装载基址(Image Base) - 节偏移
= RVA - 节偏移 |
具体过程:

- PE装载器首先检查DOS header里的PE header的偏移量。如果找到,则直接跳转到PE header的位置
- 当PE装载器跳转到PE header后,第二步要做的就是检查PE header是否有效。如果该PE header有效,就跳转到PE header的尾部
- 紧跟PE header尾部的是节表。PE装载器执行完第二步后开始读取节表中的节段信息,并采用文件映射(Windows装载器在装载的时候仅仅建立好虚拟地址和PE文件之间的映射关系,只有真正执行到某个内存页中的指令或者访问某一页中的数据时,这个页面才会被从磁盘提交到物理内存,这种机制使文件装入的速度和文件大小没有太大的关系)的方法将这些节段映射到内存,同时附上节表里指定节段的读写属性
- PE文件映射入内存后,PE装载器将继续处理PE文件中类似 import table (输入表)的逻辑部分
PE文件被加载到内存中后,它就被称作映像(image),由于内存中是按照页对齐的,所以占用的空间会比硬盘中大一些。
结构图

导入表
输入表就相当于 EXE文件与 DLL文件沟通的钥匙,所有的导入函数信息都会写入输入表中,在PE 文件映射到内存后,Windows 将相应的 DLL文件装入,EXE 文件通过“输入表”找到相应的 DLL 中的导入函数,从而完成程序的正常运行,这一动态连接的过程都是由“输入表”参与的。
-
输入函数
输入函数,表示被程序调用但是它的代码不在程序代码中的,而在DLL中的函数。对于这些函数,磁盘上的可执行文件只是保留相关的函数信息,如函数名,DLL文件名等。在程序运行前,程序是没有保存这些函数在内存中的地址。当程序运行起来时,windows加载器会把相关的DLL装入内存,并且将输入函数的指令与函数真在内存中正的地址联系起来。输入表(导入表)就是用来保存这些函数的信息的。
-
输出表的位置
我们知道,在IMAGE_OPTIONAL_HEADER 中的 DataDirectory数组保存了输入表的RVA跟大小。通过RVA可以在OllyDbg中加载程序通过ImageBase+RVA 找到输入表,或者通过RVA计算出文件偏移地址,查看磁盘中的可执行文件,通过文件偏移地址找到输入表。
-
IAT
在PE文件内有一组数据结构,它们分别对应于被输入的DLL。每一个这样的结构都给出了被输入的DLL的名称并指向一组函数指针。这组函数指针称为输入地址表( Import Address Table, IAT )。每一个被引入的API在IAT里都有保留的位置,在那里它将被Windows加载器写入输入函数的地址。最后一点特别重要: 一旦模块被载入,IAT 中将包含所要调用输入函数的地址。
-
导入表的结构
输入表是以一个IMAGE_IMPORT_DESCRIPTOR(简称IID) 的数组开始。每个被 PE文件链接进来的 DLL文件都分别对应一个 IID数组结构。在这个 IID数组中,并没有指出有多少个项(就是没有明确指明有多少个链接文件),但它最后是以一个全为NULL(0) 的 IID 作为结束的标志。
IID结构如下
1
2
3
4
5
6
7
8
9
10typedef struct_IMAGE_IMPORT_DESCRIPTOR {<!-- -->
_ANONYMOUS_UNION union{<!-- --> //00h
DWORD Characteristics;
DWORD OriginalFirstThunk;
} DUMMYUNIONNAME;
DWORD TimeDateStamp; //04h
DWORD ForwarderChain; //08h
DWORD Name; //0Ch
DWORD FirstThunk; //10h
}IMAGE_IMPORT_DESCRIPTOR,*PIMAGE_IMPORT_DESCRIPTOR;重点关注
-
OriginalFirstThunk
包含指向输入表名称(INT)的RVA。一个IMAGE_THUNK_DATA数组叫做输入名称表Import Name Table(INT),用来保存函数,数组中的每个IMAGE THUNK DATA结构都指IMAGE_IMPORT_ BY_NAME结构,数组以一个内容为0的IMAGE _THUNK _DATA结构结束。
-
Name
DLL名字的指针, 指向一个用NULL作为结束符的ASCII字符串的一个RVA,该字符串是该导入DLL文件的名称,如:KERNEL32.DLL
-
FirstThunk
它也指向IMAGE_THUNK_DATA数组叫做输入地址表Import Address Table(IAT)
OriginalFirstThunk与FirstThunk 相似,它们分别指向两个本质上相同的数组IMAGE THUNK
DATA结构。这些数组有好几种叫法,最常见的是输人名称表( Import Name Table, INT )和输人地
址表( Import Address Table, IAT)。IMAGE_THUNK_DATA
1
2
3
4
5typedef struct_IMAGE_THUNK_DATA32 {<!-- -->
union {<!-- -->
DWORD ForwarderString;//转向者字符串的RVA
DWORD Function;//被输入的函数的内存地址
DWORD Ordinal;//被输入的API的序数值
DWORD AddressOfData;//指向 IMAGE_IMPORT_BY_NAME
} u1;
}IMAGE_THUNK_DATA32,*PIMAGE_THUNK_DATA32;1
2
3当IMAGE_THUNK_DATA 的值最高位为1时,表示函数是以序号方式输入,这时低31为被当作函数序号。当最高位是0时,表示函数是以字符串类型的函数名方式输入的,这时,IMAGE_THUNK_DATA 的值为指向IMAGE_IMPORT_BY_NAME 的结构的RVA。
**IMAGE_IMPORT_BY_NAME** -
|
1
2 3 4 |
typedef struct_IMAGE_IMPORT_BY_NAME {<!-- -->
WORD Hint; // 表示这个函数在其所驻留DLL的输出表的序号,不是必须的 BYTE Name[1]; //函数名,是一个ASCII字符串以0结尾,大小不固定 } IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME; |

两个 IMAGE_THUNK_DATA 数组指向IMAGE_THUNK_DATA 的原因
第1个数组(由OriginalFirstThunk所指向)是单独的一项,不可改写,称为INT,有时也称为提示名表( Hint- -nameTable)。第2个数组(由FirstThunk所指向)是由PE装载器重写的。PE装载器先搜索OriginalFirst。当程序加载时,IAT 会被PE加载器重写,Table)。第2个数组(由FirstThunk所指向)是由PE装载器重写的。PE装载器先搜索OriginalFirstThunk,如果找到,加载程序就迭代搜索数组中的每个指针,找出每个IMAGE IMPORT_ BY NAME结构所指向的输人函数的地址。然后,加载器用函数真正的人口地址来替代由FirstThunk 指向的IMAGE THUNK _DATA数组里元素的值。“Jmp dword ptr xxxxxx]”语句中的“xxxxd”是指FirstThunk数组中的-一个人口,因此称为输人地址表( Import Address Table, IAT)。所以,当PE文件装载内存后准备执行时,图11.13 已转换成如图11.14所示的状态,所有函数人口地址排列在- -起。此时,输人表中的其他部分就不重要了,程序依靠IAT提供的函数地址就可以正常运行。。PE加载器先搜索INT,PE加载器迭代搜索INT数组中的每个指针,找出 INT所指向的IMAGE_IMPORT_BY_NAME结构中的函数在内存中的真正的地址,并把它替代原来IAT中的值。当完成后,INT就没有用了,程序只需要IAT就可以正常运行了。
导出表
用来描述模块中的导出函数的结构,如果一个模块导出了函数,那么这个函数会被记录在导出表中,这样通过GetProcAddress函数就能动态获取到函数的地址。
当一个DLL函数能被EXE或另一个DLL文件使用时,就相当于被输出了。
函数导出的方式有两种,一种是按名字导出,一种是按序号导出。
定义
|
1
2 3 4 5 6 7 8 9 10 11 12 13 |
typedef struct _IMAGE_EXPORT_DIRECTORY {<!-- -->
DWORD Characteristics; DWORD TimeDateStamp; WORD MajorVersion; WORD MinorVersion; DWORD Name; DWORD Base; DWORD NumberOfFunctions; DWORD NumberOfNames; DWORD AddressOfFunctions; // RVAfrom base of image DWORD AddressOfNames; // RVA frombase of image DWORD AddressOfNameOrdinals; // RVAfrom base of image 数组中的每一项与AddressOfNames中的每一项对应,表示该名字的函数在AddressOfFunctions中的序号 }IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY; |

获取导出函数地址时,先在AddressOfNames中找到对应的名字,比如Func2,他在AddressOfNames中是第二项,然后从AddressOfNameOrdinals中取出第二项的值,这里是2,表示函数入口保存在AddressOfFunctions这个数组中下标为2的项里,即第三项,取出其中的值,加上模块基地址便是导出函数的地址。如果函数是以序号导出的,那么查找的时候直接用序号减去Base,得到的值就是函数在AddressOfFunctions中的下标。
-
从序号查找函数入口地址
1.定位到PE 文件头
2.从PE 文件头中的 IMAGE_OPTIONAL_HEADER32 结构中取出数据目录表,并从第一个数据目录中得到导出表的RVA
3.从导出表的 Base 字段得到起始序号
4.将需要查找的导出序号减去起始序号,得到函数在入口地址表中的索引
5.检测索引值是否大于导出表的 NumberOfFunctions 字段的值,如果大于后者的话,说明输入的序号是无效的
6.用这个索引值在 AddressOfFunctions 字段指向的导出函数入口地址表中取出相应的项目,这就是函数入口地址的RVA 值,当函数被装入内存的时候,这个RVA 值加上模块实际装入的基地址,就得到了函数真正的入口地址
-
从函数名称查找入口地址
1.最初的步骤是一样的,那就是首先得到导出表的地址
2.从导出表的 NumberOfNames 字段得到已命名函数的总数,并以这个数字作为循环的次数来构造一个循环
3.从 AddressOfNames 字段指向得到的函数名称地址表的第一项开始,在循环中将每一项定义的函数名与要查找的函数名相比较,如果没有任何一个函数名是符合的,表示文件中没有指定名称的函数
4.如果某一项定义的函数名与要查找的函数名符合,那么记下这个函数名在字符串地址表中的索引值,然后在 AddressOfNamesOrdinals 指向的数组中以同样的索引值取出数组项的值,我们这里假设这个值是x
5.最后,以 x 值作为索引值,在 AddressOfFunctions 字段指向的函数入口地址表中获取的 RVA 就是函数的入口地址

重定位
当链接器生成一个PE文件时,会假设这个文件在执行时被装载到默认的基地址处,并把code
和data的相关地址都写入PE文件。如果载人时将默认的值作为基地址载人,则不需要重定位。但
是,如果PE文件被装载到虚拟内存的另一个地址中,链接器登记的那个地址就是错误的,这时就
需要用重定位表来调整。在PE文件中,重定位表往往单独作为一块,用“.reloc" 表示。
基址重定位概念
PE格式不参考外部DLL或模块中的其他区块,而是把文件中所有可能需要修改的地址放在一个数组里。如果PE文件不在首选的地址载人,那么文件中的每一个定位都需要被修正。对加载器来说,它不需要知道关于地址使用的任何细节,只要知道有一系列的数据需要以某种一致的方式来修正就可以了。
对EXE文件来说,每个文件总是使用独立的虚拟地址空间,所以EXE总是能够按照这个地址载入,这意味着EXE文件不再需要重定位信息。对DLL来说,因为多个DLL文件使用宿主EXE文件的地址空间,不能保证载人地址没有被其他DLL使用,所以DLL文件中必须包含重定位信息,除非用一个/FIXED开关来忽略它们。在Visual Studio .NET中,链接器会为Debug和Release模式的EXE文件省略基址重定位,因此,在不同系统中跟踪同一个DLL文件时,其虚拟地址是不同的
重定位表的结构
基址重定位表( Base Relocation Table )位于-个.reloc区块内,找到它们的正确方式是通过数据目录表IMAGE_DIRECTORY_ ENTRY _BASERELOC条目查找。基址重定位数据采用类似按页分割的方法组织,是由许多重定位块串接成的,每个块中存放4KB ( 1000h)的重定位信息,每个重定位数据块的大小必须以DWORD( 4字节)对齐。它们以一一个IMAGE_ BASE RELOCATION结构开始,格式如下。
|
1
2 3 4 5 6 |
struct_IMAGE_BASE_RELOCATION
{<!-- --> DWORD VirtualAddress; //重定位数据开始的RVA地址 DWORD SizeOfBlock; //重定位块的长度 WORD TypeOffset; //重定位项位数组 }IMAGE_BASE_RELOCATION; |
- VirtualAddress:这组重定位数据的开始RVA地址。各重定位项的地址加这个值才是该重定
位项的完整RVA地址。 - SizeOfBlock:当前重定位结构的大小。因为VirtualAddress 和SizeOfBlock的大小都是固定的
4字节,所以这个值减8就是TypeOffset数组的大小 - Typeffset:一个数组。数组每项大小为2字节,共16位。这16位分为高4位和低12位。
高4位代表重定位类型;低12位是重定位地址,它与VirtualAddress相加就是指向PE映像
中需要修改的地址数据的指针。
常见重定位类型

重定位表结构图

PE病毒分析
参考链接
特点
该病毒是将可执行文件的代码中程序入口地址改为病毒的程序入口,这样就会导致用户在运行的时候执行病毒文件
PE文件判断
- 判断是否是PE文件
-
检验文件头部第一个字的值是否等于MZ,如果是,则DOS头有效。
-
用DOS头的字段e_lfanew来定位PE头。
-
比较PE透的第一个双字的值是否等于45500000H(PE\0\0)。
-
感染的一般方法
- 判断目标文件开始的两个字节是否为“MZ”;
- 判断PE文件标记“PE”;
- 判断感染标记,如果已被感染过则跳过这个文件,否则继续;
- 获得Directory(数据目录)的个数,每个数据目录信息占8个字节;
- 得到节表起始位置:Directory的地址+数据目录占用的字节数=节表起始位置;
- 得到目前最后节表的末尾偏移(紧接其后用于写入一个新的病毒节):
节表起始位置+节的个数×(每个节表占用的字节数28H)=目前最后节表的末尾偏移 - 开始写入新的节表项
- 写入节名(8字节);
- 写入节的实际字节数(4字节);
- 写入新节在内存中的开始偏移地址(4字节),同时可以计算出病毒入口位置:
上节在内存中的开始偏移地址+(上节大小/节对齐+1)×节对齐 - 写入新节(即病毒节)在文件中对齐后的大小;
- 写入新节在文件中的开始位置:
上节在文件中的开始位置+上节对齐后的大小
- 修改映像文件头中的节表数目
- 修改AddressOfEntryPoint(即程序入口点指向病毒入口位置),同时保存旧的AddressOfEntryPoint,以便返回HOST继续执行。
- 更新SizeOfImage(内存中整个PE映像尺寸=原SizeOfImage+病毒节经过内存节对齐后的大小);
- 写入感染标记(可以放在PE头中);
- 写入病毒代码到新节指向的文件偏移中。
虚拟存储器
定义:
对主存的抽象机制,是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互。
功能:
1. 将主存看成是一个存储在磁盘上的地址空间的高速缓存,在内存中只保存活动区域,并根据需要在磁盘和内存之间来回传送数据。
2. 为进程提供了一致的地址空间,从而简化了存储器管理。
3. 保护了每个进程的地址空间不被其他进程所破坏。
9.1 物理和虚拟地址
CPU通过生成一个虚拟地址(Virtual address,VA)来访问主存。将虚拟地址转换为物理地址叫做地址翻译(address translation)。地址翻译也需要CPU硬件和操作系统之间的紧密结合。
CPU芯片上有叫做存储器管理单元(Memory Management Unit,MMU)的专用硬件。利用存储在主存中的查询表来动态翻译虚拟地址。 查询表由操作系统管理。
9.2 地址空间
9.3 虚拟存储器作为缓存的工具
概念上而言,虚拟存储器(VM)被组织为一个存放在磁盘上的N个连续字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址,这个唯一的虚拟地址作为到数组的索引。磁盘上数组的内容被缓存到主存中。VM系统通过将虚拟存储器分割为称为虚拟页的大小固定的块来处理磁盘和主存信息交互问题。
任何时候,虚拟页的集合都被分为3个不相交的子集。
1、[未分配的] VM系统还未分配(或者创建)的页。未分配的块没有任何数据与之相关联。不占用磁盘空间
2、[缓存的] 当前缓存在物理存储器的已分配页。
3、[未缓存的] 没有缓存在物理页面存储器中的已分配页。
9.3.2 页表
9.4 虚拟存储器作为存储器管理的工具
操作系统为每个进程提供一个独立的页表,VM简化了链接和加载,代码和数据共享,以及应用程序的存储器分配。
1.简化链接
独立的空间地址意味着每个进程的存储器映像使用相同的格式。文本节总是从0x08048000(32位)处
或0x400000(64位)处开始。然后是数据,bss节,栈。一致性极大简化了链接器的设计和实现。
2.简化加载
在ELF可执行文件中.text和.data节是连续的。要把这些节加载到一个新创建的进程中,linux加载器.
分配虚拟页的一个连续的片,从地址0x08048000处(32bit)开始, 或者从0x400000(64bit),
[把这些虚拟页标记为无效,将页表条目指向目标文件中适当的位置,加载器从不实际拷贝任何数据从磁盘到存储器.]
3.简化共享
4.简化存储器分配
malloc在堆空间分配一个适当数字(例如k)个连续的虚拟存储器页面,并且将他们映射到物理存储器中任意
位置的k个任意(不一定连续)的物理页面。
9.5 虚拟存储器作为存储器保护的工具
9.7.2 Linux虚拟存储器系统
Linux为每个进程维持一个单独的虚拟地址空间:内核虚拟存储器和进程虚拟存储器。
内核虚拟存储器包含内核中的代码和数据。
1、内核虚拟存储器的某些区域被映射到所有进程共享的物理页面.如:内核代码,全局数据结构。
2、Linux将一组连续的虚拟页面(大小等同于系统DRAM总量)映射到相应的一组物理页面。[直接映射,不使用页表]
3、内核虚拟存储器包含每个进程不相同的数据。页表,内核在进程上下文中时使用的栈等。
1.Linux 虚拟存储器区域
Linux将虚拟存储器组织成一些区域(也叫做段)的集合。一个区域
就是已经存在着的(已分配的) 虚拟存储器的连续片,这些片/页已某种形式相关联。
如:代码段,数据段,堆,共享库段,用户栈。
所有存在的虚拟页都保存在某个区域,允许虚拟地址空间有间隙。
虚拟存储器区域的内核数据结构
task_struct
mm_struct: 描述了虚拟存储器的当前状态。
pgd: 指向第一级页表的基址。当进程运行时,内核将pgd存放在CR3控制寄存器
mmap: 指向vm_area_structs的链表
vm_area_structs描述了当前虚拟地址空间的一个区域(area).
vm_start:指向这个区域的起始处。
vm_end:指向这个区域的结束处。
vm_port:描述这个区域内包含的所有页的读写许可权限。
vm_flags:描述这个区域页面是否与其他进程共享,还是私有等
vm_next: 指向链表的下一个区域。
2.Linux缺页异常处理
MMU在试图翻译虚拟地址A时,触发缺页。这个异常导致控制转移到缺页处理程序,执行如下步骤:
1、虚拟地址A是合法的吗? A在某个区域结构定义的区域内吗?
解决方法: 缺页处理程序搜索区域结构链表。把A和每个区域的vm_start和vm_end做比较。
如果不合法,触发段错误。
2、试图访问的存储器是否合法? 即:是否有读,写,执行这个页面的权限?
如果不合法,触发保护异常,终止进程。一切正常的话
3、若不存在以上情况,则选择牺牲页,替换,重新执行指令
9.8 存储器映射
定义:
Linux 通过将一个虚拟存储器区域与一个磁盘上的对象关联,以初始化这个虚拟存储器区域的内容。
虚拟存储器区域可以映射到以下两种类型文件:
1、Unix文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分。
例如,一个可执行文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页面的初始化内容。
仅仅是初始化,虚拟页面此时还并未进入物理存储器,直到CPU第一次引用这个页面。
2、匿名文件
匿名文件由内核创建,包含的全是二进制零。CPU第一次引用这样区域(匿名文件)的虚拟页面时,
将存储器中牺牲页面全部用二进制零覆盖。并将虚拟页面标记为驻留在存储器中。
注意在磁盘和存储器之间并没有实际的数据传送。又叫请求二进制零的页(demand-zero page)。
注意: 一个虚拟页被初始化了,它就在一个有内核维护的专门的交换文件(交换空间)之间切换。
在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数。
9.8.1 再看共享对象
一个对象可以被映像到虚拟存储器的一个区域,要么作为共享对象,要么作为私有对象.
私有对象的写时拷贝
9.8.2 再看fork函数
当fork函数被当前进程调用时:
1、内核为新进程创建内核数据结构,并分配给它唯一一个PID。
2、为了给新进程创建虚拟存储器[创建页目录]。
3、创建了当前进程的mm_struct,区域结构和页表的原样拷贝。
4、将两个进程的每个页面都标记为[只读]。并给两个区域进程的每个区域结构都标记为[私有的写时拷贝]。
注意:[没有对物理存储器进行拷贝,利用的是私有对象的写时拷贝技术。]
9.8.3 再看execve函数
假设运行在当前的进程中的程序执行了如下的调用:
execve("a.out",NULL,NULL);
execve函数在当前进程加载并执行目标文件a.out中的程序,用a.out代替当前程序。
加载并运行需要以下几个步骤。
1、删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的区域结构。
2、映射私有区域:为新程序的文本,数据,bss和栈区域创建新的区域结构。所有新的区域结构都是私有的,写时拷贝的。
文本和数据区域被映射到a.out文件中的文件和数据区。bss区域是请求二进制零,映射到匿名文件。
3、映射共享区域
4、设置程序计数器
5、execve最后一件事设置PC指向文本区域的入口点。
9.8.4 使用mmap函数的用户级存储器映射
Unix进程可以使用mmap函数来创建新的虚拟存储器区域,并将对象映射到这些区域中
#include <unistd.h>
#include <sys/mman.h>
void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offset);
返回:若成功时则为指向映射区域的指正,若出错则为MAP_FAILED(-1).
munmap函数删除虚拟存储器的区域
#include <unistd.h>
#include <sys/mman.h>
void *munmap(void *start,size_t length);
返回:若成功则为0,若出错则为-1
练习题 9.5代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include "csapp.h" // 自己写的
#include <errno.h>
#include <fcntl.h>
int main(int argc, char* argv[]){
if (argc < 2)
csapp_error("lack of filename.");
int fd;
if ((fd = open(argv[1], O_RDWR)) < 0)
csapp_error2("open error",errno);
off_t length;
if ((length = lseek(fd, 0, SEEK_END)) < 0)
csapp_error2("lseek error.",errno);
char* bufptr = (char*)mmap(NULL,length,PROT_READ,MAP_PRIVATE,fd,0);
if (!bufptr)
csapp_error2("mmap error.", errno);
fprintf(stdout,"%s",bufptr);
exit(0);
}
mmap为什么比传统的读写速度要快
mmap : 将文件内容直接映射到进程的地址空间,通过对这段内存的读写,来达到对文件的读写目的;read,write : 每次调用都需要从用户态到内核态的切换,且数据需要从用户态拷贝到内核态,然后再写入磁盘,增加了中间步骤mmap的缺点 : 不能改变文件长度,无法写入多余的字符。
9.9 动态存储器分配
malloc通过调用sbrk函数来实现内存的分配,且在在sbrk之上加了一层对所分配的内存的管理,而sbrk以及brk是实现从虚拟内存到内存的映射的
Linux内存分配小结--malloc、brk、mmap
动态存储器分配器维护着一个进程的虚拟存储区域,称为堆(heap)。
系统之间细节不同,但是不失通用型。
1、堆是一个请求二进制零的区域。
2、紧接着未初始化的bss区域,并向上生长(向更高的地址)。
3、对于每个进程,内核维护一个变量[brk],指向堆顶,当堆空间不足时,利用sbrk函数修改该变量。
4、分配器将堆视为一组不同大小的块block的集合来维护。
每个块就是一个连续的虚拟存储器片,要么是已分配,要么是空闲。

浙公网安备 33010602011771号