好像和第一篇隔了很久,主要是因为我懒,而且一开始我纠结要按顺序看书还是按照我自己的兴趣来,最后兴趣占了上风所以就鸽了一阵子。。。今天强迫自己整理一下这些笔记的时候才想起来这个大坑。
Windows Via C/C++ note 2(内存管理)
Windows 和 Linux 很多地方都相似,比如说虚拟地址的使用,内存的分页分段等等。但是相较而言,Windows 某些内存管理会相比较来说更加上层(?或者反过来,更加底层?)
体系结构
分区
Windows 上的地址空间的划分大约是这样的:
1 | +-------------------+-------------------------+---------------------------------------+ |
虽然用户模式下的内存是进程可以控制的,但是进程A是不能够访问到进程B中用户模式下任意一个地址的内容。Windows 中的所有.exe|.dll
都会载入到用户态中。
可以获得更大的用户空间
如上表,内核模式几乎占据了一半的地址空间,但是其实上用户可以通过boot configuration data(BCD)
指令来设置当前用户的地址空间
1 | BCD /set IncreaseUserVA 3072 ; 当前用户的地址空间上升到3GB |
不过如果这样处理之后,Windows 内核的性能就会下降,导致进程数量的创建受限,运行速度下降等等
64->32的兼容:地址的最高位
因为用户模式的最高位在32bit下是0(0x7xxxxxxx),所以有一些应用的程序会将当前指针的最高位用作标志位。当其应用访问指针的时候会检查这个标志位。所以如果64bit 的程序简单的使用了更多地址的话,就会导致这个标志位被覆盖,从而导致应用的崩溃。为了保护这一点,Windows 在链接阶段的时候提供了标志位/LARGEADDRESSA WARE
,如果使用这个标志位,程序才会使用更高位的地址,否则程序的地址将会限制在0x000000007ffeffff
以下。这个运行环境就被称为address sandbox(地址空间沙箱)
内存分配
Windows 下的内存存在的形式主要有这几种
- 闲置 Free
- 私有 Private
- 映像 Image
- 已映射 Mapped
在Windows 下的内存分配得到一个可以使用的内存主要分为两步:
- 预定 Reserve
- 调度 Commit
Reserve 得到的 Memory 暂时未提交给应用,因此是不能使用的。但同时其相当于占据了一部分的地址空间,这部分的地址空间在之后的程序运行中也不能被其他的用途占用(除非被释放了)
预定地址 Reserve
系统创建一个进程的时候,就会赋予其一个完整的地址空间。我们需要调用函数VirutalAlloc
来预定(Reserve)其中的区域(region)
分配地址空间的时候,系统会确保当前的起始地址正好是分配粒度(allocation granularity)的整数倍(也就是所谓的对齐)。目前来收,大部分的平台默认的分配粒度都是64KB(0x10000),也就是说,系统会把分配的内存地址对其到这个地址上
但是对于操作系统来说不存在这个限制,也就是说操作系统为我们申请的内存空间不会被对齐。操作系统也需要申请内存空间,包括PEB,TEB还有内建的堆等等
预定的空间大小也是需要对齐的,预定空间大小通常是和页面大小(4KB)对齐的。如果我们申请的空间大小为10KB,那么x32/64的电脑会强制帮我们申请一个12KB大小的堆
如果程序之后不使用预定地址的时候,应通过VritualFree
将当前的地址空间释放
调拨物理存储器 Commit
光是预定空间还是不能使用的,我们还需要向操作系统提交我们的预定地址,让操作系统为地址分配物理存储器。这个过程被称为调度(Committing)。物理存储器始终都是以页面为单位来进行调拨的。调拨的时候使用的函数也是VirtualAlloc
。
当我们调拨的时候,其实我们不需要为整个预定地址空间都进行调拨,可以指定为整个区域中的第二个和第四个页面进行调度。
同样,当我们不再使用物理存储器的时候,我们应该撤销调拨(decommitting),同样也是通过VirtualFree
来释放。
物理存储器?
物理存储器包括物理内存,硬盘这类存储介质。但是具体要用哪个取决于整个映射执行的过程。
物理存储器与页交换文件
刚刚讲了很多,不知道大家有没有这样的问题:
都说每个进程的地址空间都有32bit那么大(对于32位而言),也就是4GB都占满了。但是计算机中有好多个进程在运行,怎么放得下呢?
我玩的游戏都有 30GB ,但是如果整个游戏都放到内存里的话其他的程序又怎么运行呢?
过去内存条本身有多大,计算机可用的内存就有多大。但是现在的计算机会采取虚拟内存的技术,将磁盘中的一部分物理内存分配出来作为内存文件,这类文件就称为 页交换文件(paging file)。也就是说,当程序在访问一个地址的时候,这个地址所映射的可能不是一个内存的地址,而是一个磁盘上的数据,这个时候就会和《计算机组成原理课程》中提到的一样,会发生 缺页。当发生缺页之后,程序会尝试从内存中寻找一块空闲内存页(找不到的时候,会用一个算法选取一个),然后和页交换文件中的页进行交换。
之前提到的申请地址空间并且调拨物理存储器的时候,其实本质上 也就是从硬盘中的页交换文件分配 得到。只有在发生了缺页中断之后,才会将当前的地址空间放入内存。
当一个线程访问所属进程的地址空间的地址的时候,可能会有两种情况
- 当前数据存放在内存中
- 当前数据未存放于内存
那么整体的工作流程大致如下
如果频繁的发生内存和页交换文件之间进行页面复制,机器就会花费大量时间在内存处理上,这个过程称为 硬盘颠簸(thrash)。如果颠簸过于严重,就会导致当前系统运行过慢。
不在页交换文件的物理存储器
我们知道每一个exe或者dll都会被加载到内存中执行,那么是不是所有的exe/dll都会在加载后放入页交换文件中呢?
系统在加载exe文件的时候,实际上并不会将其放入到页交换文件。系统会计算出要加载的exe/dll的大小,然后在进程中预定一个区域用于存放文件的代码和数据,之后会直接将 该区域与文件本身所在的物理存储器相关联 ,相当于说文件本身(file image 文件映像)就被映射到了内存中。
这种把一个位于硬盘上的文件映像(也就是一个.exe或者.dll)用作地址空间区域对应的物理存储器时,这个文件映像就是 内存映射文件(memory mapped file)。
一般来说,dll/exe被载入时,系统就会预定地址区域(region),并且把当前的文件映像映射到这个区域中。
但是其实系统提供了另一种形式,支持将数据文件映射到地址空间上,之后会介绍(类似于linux 中的map函数)
操作:修改页交换文件
系统其实默认是会在每一个磁盘下都组织页交换文件的,但是其实我们可以手动更改其大小。
内存区域与映射
之前介绍过内存的类型,这里对其进行进一步的解释:
闲置
即是说当前区域中的虚拟地址没有任何后备存储器。改地址空间尚未预定(未Reserve)。
私有
区域中的虚拟地址以系统的页交换文件作为后备存储器
映像
区域中的虚拟地址在最初的时候以映像文件(.exe/.dll)作为后备存储器。但是如果发生了写入(例如数据段被写入)则发生页交换,之后会用页交换文件作为后备存储器。
已映射
区域中的虚拟地址在最初的时候以内存映射文件(数据文件)作为后备存储器。但是如果发生了写入(例如数据段被写入)则发生页交换,之后会用页交换文件作为后备存储器。
PE文件在进行映射的时候,每个 段(section 都必须另起一页,而且起始地址必须要是系统页面大小的整数倍。
页大小目前默认是4KB
区分一下: 段的起始地址必须是系统页大小整数倍(4KB),而整体区域必须是粒度的整数倍(最小64KB)
一个 **块(block)**就是一个连续的页面,同一个块里面是连续的页面,这些页面的具有相同的保护属性,而且会以 相同类型的物理存储器作为后备存储器。
虚拟内存
Windows 下有三种使用内存的方式
- 虚拟内存:适用于大型对象或者大型数组管理
- 内存映射文件:适用于大型数据流文件,已经在不同进程之间共享数据
- 堆:用于管理小型对象
查询虚拟内存的API
对于许多基本信息(页面大小,分配粒度等)我们不应该自己硬编码在程序中,而是应该使用系统API去得到进程初始化时设置的变量:
1 | VOID GetSystemInfo(LPSYSTEM_INFP psi); |
注意这个API在32bit程序运行在64bit下的(Windows 32bit On Windows 64bit,WOW64模拟层)情况时,此时用这个查询得到的值和同一个系统中,64bit运行得到的值有所不同。
查看虚拟内存状态的话,可以使用下列API:
1 | BOOL GetProcessMemoryInfo( |
利用这个API能够查看到当前运行的进程中可能用到的最大内存,也称为 工作集。为了提高程序性能,我们可以考虑减小工作集。
使用虚拟内存的API
对于预定(Reserve)|调度(Commit)虚拟内存的时候,我们统一使用下列API
1 | PVOID VirtualAlloc( |
对于参数fdwAllocationType,在预定内存的时候需要传入MEM_RESERVE
,而调度内存的时候传入MEM_COMMIT
。并且预定区域和调度物理存储器的时候的保护属性一般是相同的。不过在预定阶段,分配的大小始终是64KB对其的,而调度的时候则是按照4KB对齐。如果不想分开来写,可以写成如下形式同时完成预定的和调度
1 | VirtualAlloc(NULL, 0x1000 * 4, MEM_RESERVE|MEM_COMMIT, PAGE_READWRITE); //注意第二个参数 |
相应的,为了撤销调拨的物理存储器和释放对应区域,可以通过调用下列API
1 | BOOL VirtualFree( |
这个函数第一个参数必须接受一个区域的基地址(也就是之前在VirtualAlloc
的返回值)在fdwFreeType
为MEM_RELEASE
的时候会将传入的地址中的所有地址撤销调度并且释放改区域。而传入MEM_DECOMMIT
的时候,可以指定撤销的调度地址的大小。(注意不能取消指定范围內的区域)
一个页面的保护属性修改,我们可以使用这个API
1 | BOOL VirtualProtect( |
可以修改指向内存的基地址开始的,指定大小的区域的(当然这两个值最后都会页对齐)进行修改。
线程栈与虚拟内存
Windows中对于栈的操作也是按照虚拟内存的管理方式来管理的。每一条线程,都会由系统创建一个线程栈,这个线程栈就是一个由系统预定并且调度的空间。一般来说,系统会预定一块 1M 大的区域,然后在其中调度两页来使用。这些参数其实都是可以定制的:
- 在CreateThread/_begintreadex 函数中,可以指定一开始要调拨给线程的栈地址空间大小。
- PE文件头中会记录需要预定的地址空间的大小
可以看到,在第二个调度的页面中,是一个 防护页面(guard page)。
当线程的栈越来越深,试图访问防护页面的内存的时候,系统就会得到通知。此时系统会执行如下逻辑:
- 给下一个相邻的页面调度物理存储器
- 消除防护页面的 GUARD_PAGE 的标志
- 给刚刚分配的页面指定这个标志。
这个技术能够保证 只有线程需要的时候才给栈调度足够的存储器,从而减小存储器的使用。
注意,当调用不断加深的时候,整个栈会如图所示:
如图,如果此时栈还要加深的话,那么此时栈底的这个页面也不会被标志为防护页面。如果程序继续运行到这,系统为栈底调度页面的时候,就会抛出一个错误EXCEPTION_STACK_OVERFLOW
。系统将使用结构化异常处理(structure exception handling,SEH)来处理这个问题。当弹出这个错误后,不单单时这个线程,整个进程都将被收回控制权。
为什么不对最后一个页面调度页面,而是用其作为边界检测呢?
若相邻的区域中,下一个地址也已经被调度了,那么就会 发生内存被破坏而无法察觉的情况:
另外一种难以察觉的溢出就是 栈下溢,例如:
1 | int TestForStackOverflow(){ |
如果对应的位置上是另一条线程的已调拨区域,就可能会引发访问违规却无法察觉。比如这个操作:
1 | DWORD WINAPI ThreadFunc(PVOID pvParam){ |
由于栈后方正好有一个空间,就导致程序能够溢出并且不发生错误。
C/C++运行时栈的检查
PS:这里的栈检查不是指放置canary的那种检查
对于一个调度默认大小的栈(一页4KB)来说,这样的代码会带来很大的问题
1 | void Function(){ |
如上,我们第一次访问的地址就低于了 防护页面的地址,而之前的逻辑中我们可以知道,我们是首先访问了防护页面,才会为我们调度新的页面地址。如果严格按照上述逻辑的话,此时应该会发生访问越界。为了防止这个错误,编译器会插入一些代码来调用C运行库的栈检查函数。因为栈编译期间,就能够知道一个栈需要的大小。如果需要的栈空间大于目标系统的页面大小,编译器就会自动插入代码来调用栈检查函数。这个栈检查函数大致逻辑如下
1 | void StackCheck(int nBytesNeededFromStack){ |
后记
说起来这篇好像内容缺了堆相关的,记起来之后会补上(咕咕咕??)