Windows内存机制原理

进程的虚拟地址空间

WIndows为每个32位进程分配了4GB的虚拟地址空间。每个进程都认为自己拥有4GB的内存空间, 4GB的大小是因为:32位 CPU可以取地址的空间为2的32次方,就是4GB大小。
而64位进程, CPU可以取地址的空间为2的64次方,所以64位进程的虚拟地址空间大小为16EB。

进程的隔离

每个进程都有自己专用的地址空间,就是这个虚拟地址空间。进程中的线程只能访问它所在的进程的内存。线程是看不到其他进程的内存的,也无法访问它们。

系统为了隔离进程,使得每个进程只能访问进程自己申请的内存,而不能访问其他进程的内存。操作系统对每个进程的内存使用线性地址编制,通过内存的分页机制,在进程需要访问物理内存时,通过进程的页表找到实际物理内存的地址,通过系统读写内存中的数据。

当我们在Windows中双击一个应用程序图标后,操作系统创建该应用程序的一个进程,Windows使得每个进程都拥有2GB的地址空间,这2GB地址空间用于程序存放代码、数据、堆栈、自由存储区(堆),另外2GB用于共享系统使用。

这些地址是该进程空间中的虚拟地址,并不是物理内存中的地址。虚拟地址空间,只是Windows为该进程分配的,一个虚拟的地址空间,只有转换为物理内存关联后才有意义 。

虚拟地址空间分区

应用程序虽然有这么大的地址空间可用,但是这只是虚拟地址空间,不是物理存储器。这个地址空间只不过是一个内存的地址区间。

每个进程的虚拟地址空间又被划分为许多个分区(partion)。
由于地址空间的分区,依赖于操作系统的底层实现。因此会随着Windows内核的不同而略有变化。

32位Winows内核和64位Windows内核的分区基本一致,唯一的不同在于分区的大小和分区的位置。

内核模式分区   2G              高地址。   系统运行的空间,所有进程共用的,用户模式的的代码不能访问这部分代码,若要访问,需要通过系统提供的API进入到内核态。

64KB 禁入分区   64K           用来分隔内核模式跟用户模式的

用户模式分区    2G              应用程序可以访问  ,用户代码在这里跑,堆栈都在这里,用户可以随便用,一般出错都在这里。

空指针赋值分区   64K          0x00000000到0x0000FFFF    ,用来给空指针赋值的,这个分区不可操作,操作就报错。

  • 空指针赋值分区:用来给空指针赋值的,这个分区不可操作,操作就报错。
  • 用户模式分区:用户代码在这里跑,堆栈都在这里,用户可以随便用,一般出错都在这里。
  • 64kb禁入分区:就是为了分隔内核模式跟用户模式的。
  • 内核模式分区:系统运行的空间,所有进程共用的,用户模式的的代码不能访问这部分代码,若要访问,需要通过系统提供的API进入到内核态。

空指针赋值分区

       空指针赋值分区是进程地址空间中,从0x00000000到0x0000FFFF的闭区间,保留该分区是为了帮助程序捕获,对空指针的赋值。没有任何办法,可以让我们分配到,位于这一地址空间的虚拟内存,就算使用Win32的应用程序编程接口(appliction programming interface,通常简称API)也不行。如果进程中的线程试图读写该分区内的内存地址,就会引发访问违规。在默认情况下,访问违规会导致系统先向用户显示一个消息框,然后结束应用程序。

        如果malloc无法分配足够的内存它会返回NULL。地址空间中的空指针赋值分区是被禁止访问的,访问它会引发内存访问违规,并导致进程被终止。

用户模式分区

        用户模式分区是进程地址空间的驻地,可用地址空间和用户模式分区的大小,取决于CPU体系结构。进程无法通过指针读写或以任何方式,访问驻留在用户模式分区中的其他进程的数据。对所有应用程序来说,进程的大部分数据都保存在这一分区。由于每个进程都有自己的数据分区。从而使得整个系统更加坚固。

      Microsoft不允许用户程序访问2GB以上的地址空间,为了让此类应用程序即使在用模式分区大于2GB的环境下仍能正常运行,Microsoft提供代了一种模式来增大用户模式分区,最多不超过3GB。

       当系统即将运行一个应用程序时,它会检查应用程序在链接时,是否使用了/LARGEADDRESSAWARE链接器开关。如果使用了,则相应于应用程序,在声明它会充分利用大用户模式地址空间,而不会对内在地址进行任何不当的操作。反之,如果应用程序在链接时没有使用/LARGEADDRESSAWARE开关,那么操作系统会保留用户模式分区中2GB以上到内核模式开始处的整个部分。程序内存使用分布,在这一分区中。

内核模式分区

        内核模式分区是操作系统代码的驻地,与线程调度、内存管理、文件系统支持、网络支持,设备驱动程序相关的代码,都载入到该分区。在 内核模式分区内的所有东西为所有进程共有。虽然这一分区就在每个进程中用户模式分区的上方,但该分区中的所有代码和数据都被完全保护起来。如果一个应用程序试图读写这一分区中的内存地址,会引发违规。

两个内存概念

物理内存

物理内存就是插在主板上的内存条。内存条的容量多大,物理内存就有多大。如果大量的物理内存被占用,可能导致物理内存消耗殆尽。

虚拟内存

虚拟内存就是在硬盘上,划分一块页面文件,充当内存。系统将暂时不用的资源放在虚拟内存上,等到需要时再调出来用。

三个地址概念

物理地址(physical address)

用于内存芯片级的单元寻址,与处理器和CPU连接的地址总线相对应。
虽然可以直接把物理地址理解成插在机器上那根内存条,把内存看成一个从0字节一直到 最大字节的逐字节的编号大数组,但事实上,这只是硬件提供给软件的抽象,内存的寻址方式并不是这样。

逻辑地址(logical address):

逻辑地址是指由程序产生的与段相关的偏移地址部分。例如,在C语言中,取指针值(&操作)这个值就是逻辑地址,它是相对于当前进程数据段的地址,和绝对物理地址不相干。

只有在Intel实模式下,逻辑地址才和物理地址相等,因为实模式下没有分段或分页机制,CPU不进行自动地址转换;

应用程序开发人员仅需与逻辑地址打交道,而分段和分页机制对程序员来说是完全透明的,仅由系统编程人员涉及。应用程序开发人员虽然可以直接操作内存,那也只是在操作系统给你分配的内存段操作。
Intel为了兼容,将远古时代的段式内存管理方式保留了下来。

逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。
Intel中段式管理中,对逻辑地址要求,“一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量, 表示为 [段标识符:段内偏移量],也就是说,0x08111111,应该表示为[A的代码段标识符: 0x08111111]。

虚拟地址

线性地址(linear address)或也叫虚拟地址(virtual address)
跟逻辑地址类似,它也不是一个真实的物理地址,如果逻辑地址是对应的硬件平台,段式管理转换前地址的话,那么线性地址则对应了硬件页式内存的转换前地址。

每个进程都有4GB的虚拟地址空间
程序中都是使用4GB的虚拟地址,访问物理内存需要使用物理地址,物理地址是放在寻址总线上的地址,以字节(8位)为单位。

堆栈段说明

栈(Stack)
       每个线程都有自己的栈。当系统创建线程时,会为线程栈预订一块空间区域,并给区域调拨一些物理存储器。默认情况下,系统会预订1MB的地址空间并调拨两个页面的存储器。但是,在构建应用程序时开发人员可以通过两种方法来改变该默认值,一种方法是使用Microsoft C++编译器的/F选项,另一种方法是使用Microsoft C++链接器的/STACK选项。在构建应用程序时,链接器会把想要的栈的大小写入到.exe或.dll文件的PE文件头中。当前系统创线栈的时候,会根据PE文件头中的大小,来预订地址空间的区域。但是在调用CreateThread或_beginthreadex函数时,开发人员也可以另外指定需要在一开始就调拨的存储器数量。这两个函数都有一个参数,可以用来指定一开始要调拨给线程栈的地址空间区域的存储器的大小。如果该参数设为0,那么系统会使用PE文件头中指定的大小,所使用的都是默认值(即区域大小为1MB),每次调拨一个存储页面。


堆(Heap)
       堆非常适用分配大量的小型数据。堆是用来管理链表和树的最佳方式。堆的优点是它能让我们专心解决手头上的问题,而不必理会分配粒度和页面边界这类事情。堆的缺点是分配和释放内存块的速度比其他方式慢,而且也无法对物理存储器的调拨和摊销进行直接控制。
       进程初始化时候,系统会在进程的地址空间中创建一个堆,这个堆被称为进程的默认堆(Default heep)。在默认情况下,这个堆的地址空间区域的大小是1MB。但是系统可以增大进程的默认堆,使它大于1MB。我们也可以在创建应用程序的时候用/HEAP链接器开关来改变默认区域大小。由于动态链接库(.DLL)没有与之关联的堆,因此在创建DLL的时候不应使用/HEAP开关。

分页机制

Windows内存管理机制,底层最核心的东西是分页机制。

分页机制使每个进程有自己的4G虚拟空间,使我们可以用虚拟线性地址来跑程序。每个进程有自己的工作集,工作集中的数据,可以指明虚拟线性地址,对应到怎样的物理地址。


在分页机制所形成的,线性地址空间里,我们对内存进行进一步划分,涉及的概念有堆、栈、自由存储等。对堆进行操作的API有HeapCreate、HeapAlloc等。操纵自由存储的API有VirtualAlloc等。此外内存映射文件使用的也应该算是自由存储的空间。栈则用来存放函数参数和局部变量,随着stack frame的建立和销毁其自动进行增长和缩减。

对x86 CPU分段机制是必须的,分页机制是可选的。

为什么这里只提到了分页机制。分段机制仍然存在,一是为了兼容以前的16位程序,二是Windows毕竟要区分ring 0和ring 3两个特权级。用SoftIce看一下GDT(全局描述表)你基本上会看到如下内容:


内存的分页 

windows的内存体系结构,基于虚拟的线性的地址和分页机制。线性地址的分配是以页为单位进行的,物理地址的管理更是以页为单位。我们可以调用函数从地址空间中预定一块内存,在实际使用的时候,再从物理内存中调拨,相当于C语言中的声明与定义,当不再需要内存的时候可以还给系统,先将一块内存标记为可用的(标记线性空间中的地址空闲可用),当积攒够了一定的空闲内存,是在取消提交(把物理内存归还给操作系统)。对于物理内存而言,在暂时不用或者内存紧张的情况下,可以被交换到磁盘上的页交换文件中,在需要的时候,CPU缺页中断时,再从页交换文件中,载入到内存中,这样就提高了内存的使用效率。页交换文件的使用,当然需要一定的代价,频繁的在磁盘与内存页数据交换,会导致系统性能下降,一般而言,采用增加内存的办法,比提升CPU对系统的性能改善更大。对于程序的数据,可以采用交换页的技术,来扩展内存,以提高物理内存的使用效率,对于数据内容多变,而且大小不可预计的内存,使用交换页确实能提高效率,但是对于可以预知整块内存大小且需要连续的空间,如文件镜像,固定大小的数据文件等,使用内存映射文件是效率更高的方式。分页内存机制调配内存的过程可以粗略的描述如下:

每个物理地址对应一个虚拟地址?

1GB那页表该有多长,所以将内存分页管理,

内存分配是按页来分配的。

一页为4K大小(4096),4K就是最小单位。虚拟地址到物理地址的映射见图,中间的那个就是页表了。

内存页面大小。

内存分配粒度。

进程最小内存地址。

进程最大内存地址。

如何映射? 
进程被创建时会建立一个 虚拟内从到物理内存的映射表--------页表,根据页表可以将虚拟内存和物理内存关联起来

如果CPU启用了MMU,CPU核发出的地址将被MMU截获,从CPU到MMU的地址称为虚拟地址(Virtual Address, 以下简称VA),而MMU将这个地址翻译成另一个地址发到CPU芯片的外部地址引脚上,也就是将
虚拟地址映射成物理地址, 虚拟地址示意图

MMU将虚拟地址映射到物理地址是以页(Page)为单位的,对于32位CPU,通常一页为4K。例如,虚拟地址 0xB7001000~0xB7001FFF是一个页,可能被MMU映射到物理地址0x2000~0x2FFF, 物理内存中的一个物理页面也称为一个页框(Page Frame)

既然虚拟地址最终要转换为物理地址,那么为何还需要虚拟地址呢?
      虚拟地址提供了权限检查功能:比如我们设置虚拟地址和物理地址之间的映射关系时,可以设置某块地址是只读的,只写的,只有CPU处于管理模式时才能访问等。这些功能可以让系统的内核,用户程序的运行空间相互独立:用户程序即使出错,也无法破坏内核;用户程序A崩溃了,也无法影响到用户程序B。

当开启分段分页机制时,典型的x86寻址过程为。

页为粒度

程序是不能直接操作物理内存的,所有的数据都需要保存在线性的虚拟内存(逻辑地址)中。使用虚拟内存主要使用函数VirtualAlloc来预定和提交内存,使用VirtualFree来归还或取消提交内存。
虚拟内存的操作以页为粒度,适合用来管理大型对象数组或大型结构数组。对于存在页交换文件的内存页,若我们能确定整页的内存数据不会改变,或者放弃在内存中的改变,下回直接从页交换文件中重新载入,则称该内存页为可重设的,不需要被交换到页文件中,直接覆盖其中的内容,在需要的时候重新从页文件中载入。预定提交重设用的同一个函数说明如下:

预定虚拟内存和调拨物理内存,失败返回NULL,成功返回lpAddress的取整的值
LPVOID VirtualAlloc{
     LPVOID lpAddress, // 要分配的内存区域的地址,按分配粒度向上取整,为NULL则由系统决定
     DWORD dwSize, // 分配的大小,分配粒度的整数倍
     DWORD flAllocationType, // 分配的类型
     DWORD flProtect // 该内存的初始保护属性
};
 

内存映射文件

内存映射文件特别合适用来处理下列事情:

1、系统使用内存映射文件将exe或是dll文件本身,作为后备存储器,而非系统页交换文件,大大节省了页交换空间,也提高了启动速度。由于是映射到各自的逻辑地址的,所以每个进程保存自己的副本,所有的变量互不共享,但是可以在使用同一DLL的不同进程间,通过DLL的数据段,共享变量。
2、使用内存映射文件,来将磁盘上的文件,映射到进程的空间区域,使得开发人员操作文件就像操作内存数据一样,将对文件的操作交由操作系统来管理,简化了开发人员的工作。这是最常用的方式。

进程间通信的方法

windows提供了多种进程间通信的方法,但它们都是基于内存映射文件实现的。

进程间通信:只要在不同进程中,映射了同一文件内容,当其中一个映射被改变时,就算还没有保存到磁盘上。其他进程自动会获取到改变。
windows的进程除了可以直接向系统申请内存之外,还可以使用运行时库提供的内存堆和栈,简单的有如下说明:

虽然运行时库,提供的堆,足以满足我们的需要,但我们还是会基于以下原因来创建自己的堆:

  • 一:对数据保护。创建两个或多个独立的堆,每个堆保存不同的结构,对两个堆分别操作,可以使问题局部化。
  • 二:更有效的内存管理。创建额外的堆,管理同样大小的对象。这样在释放一个空间后可以刚好容纳另一个对象。
  • 三:内存访问局部化。将需要同时访问的数据放在相邻的区域,可以减少缺页中断的次数。
  • 四:避免线程同步开销。默认堆的访问是依次进行的。堆函数必须执行额外的代码,来保证线程安全性。通过创建额外的堆,可以避免同步开销。
  • 五:快速释放。我们可以直接释放整个堆而不需要手动的释放每个内存块。这不但极其方便,而且还可以更快的运行。


要创建并管理自己的堆,需要使用以下接口,首先要创建堆:

HANDLE HeapCreate(
    DWORD fdwOptions, //如何操作堆
    SIZE_T dwInitilialize, //一开始要调拨给堆的字节数向上取整到CPU页面大小的整数倍
    SIZE_T dwMaximumSize //堆所能增长到的最大大小,即预定的地址空间的最大大小。若为0,那么堆可增长到用尽所有的物理存储器为止。
); 
fdwOptions表示对堆的操作该如何进行
HEAP_NO_SERIALIZE        标志使得多个线程可以同时访问一个堆,这使得堆中的数据可能会遭到破坏,因此应该避免使用。
HEAP_GENERATE_EXCEPTIONS    标志告诉系统,每当在堆中分配或者重新分配内存块失败的时候,抛出一个异常。
HEAP_CREATE_ENABLE_EXECUTE     标志告诉系统,我们想在堆中存放可执行代码。如果不设置这个标志,那么当我们试图在来自堆的内存块中执行代码时,系统会抛出EXCEPTION_ACCESS_VIOLATION异常。


有了堆之后从堆中分配内存时要:

  • 遍历已分配的内存的链表和闲置内存的链表。
  • 找到一块足够大的闲置内存块。
  • 分配一块新的内存,将2找到的内存块标记为已分配。
  • 将新分配的内存块添加到已分配的链表中。

猜你喜欢

转载自blog.csdn.net/panjunnn/article/details/110779528
今日推荐