Windows Via C/C++ note 2

好像和第一篇隔了很久,主要是因为我懒,而且一开始我纠结要按顺序看书还是按照我自己的兴趣来,最后兴趣占了上风所以就鸽了一阵子。。。今天强迫自己整理一下这些笔记的时候才想起来这个大坑。


Windows Via C/C++ note 2(内存管理)

Windows 和 Linux 很多地方都相似,比如说虚拟地址的使用,内存的分页分段等等。但是相较而言,Windows 某些内存管理会相比较来说更加上层(?或者反过来,更加底层?)

体系结构

分区

Windows 上的地址空间的划分大约是这样的:

1
2
3
4
5
6
7
8
9
+-------------------+-------------------------+---------------------------------------+
| 分区 | 32 bit | 64 bit |
+-------------------+-------------------------+---------------------------------------+
| 空指针赋值 | 0x00000000-0x0000ffff | 0x0000000000000000-0x000000000000ffff |
+-------------------+-------------------------+---------------------------------------+
| 用户模式 | 0x00010000-0x7ffeffff | 0x0000000000010000-0x000007fffffeffff |
+-------------------+-------------------------+---------------------------------------+
| 内核模式 | 0x7fff0000-0x7fffffff | 0x0000080000000000-0xffffffffffffffff |
+-------------------+-------------------------+---------------------------------------+

虽然用户模式下的内存是进程可以控制的,但是进程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
2
3
4
5
BOOL GetProcessMemoryInfo(
HANDLE hProcess,
PPROCESS_MEMORY_COUNTERS ppmc,
DWORD cdSize
)

利用这个API能够查看到当前运行的进程中可能用到的最大内存,也称为 工作集。为了提高程序性能,我们可以考虑减小工作集。

使用虚拟内存的API

对于预定(Reserve)|调度(Commit)虚拟内存的时候,我们统一使用下列API

1
2
3
4
5
6
PVOID VirtualAlloc(
PVOID pvAddress,
SIZE_T dwSizw,
DWORD fdwAllocationType,
DWORD fdwProtect
)

对于参数fdwAllocationType,在预定内存的时候需要传入MEM_RESERVE,而调度内存的时候传入MEM_COMMIT。并且预定区域和调度物理存储器的时候的保护属性一般是相同的。不过在预定阶段,分配的大小始终是64KB对其的,而调度的时候则是按照4KB对齐。如果不想分开来写,可以写成如下形式同时完成预定的和调度

1
VirtualAlloc(NULL, 0x1000 * 4, MEM_RESERVE|MEM_COMMIT, PAGE_READWRITE); //注意第二个参数

相应的,为了撤销调拨的物理存储器和释放对应区域,可以通过调用下列API

1
2
3
4
5
BOOL VirtualFree(
LPVOID pvAddress,
SIZE_T dwSize,
DWORD fdwFreeType
)

这个函数第一个参数必须接受一个区域的基地址(也就是之前在VirtualAlloc的返回值)在fdwFreeTypeMEM_RELEASE的时候会将传入的地址中的所有地址撤销调度并且释放改区域。而传入MEM_DECOMMIT的时候,可以指定撤销的调度地址的大小。(注意不能取消指定范围內的区域)

一个页面的保护属性修改,我们可以使用这个API

1
2
3
4
5
6
BOOL VirtualProtect(
PVOID pvAddress,
SIZE_T dwSize,
DWORD flNewProtect,
PDWORD fplOldProtect
)

可以修改指向内存的基地址开始的,指定大小的区域的(当然这两个值最后都会页对齐)进行修改。

线程栈与虚拟内存

Windows中对于栈的操作也是按照虚拟内存的管理方式来管理的。每一条线程,都会由系统创建一个线程栈,这个线程栈就是一个由系统预定并且调度的空间。一般来说,系统会预定一块 1M 大的区域,然后在其中调度两页来使用。这些参数其实都是可以定制的:

  • 在CreateThread/_begintreadex 函数中,可以指定一开始要调拨给线程的栈地址空间大小。
  • PE文件头中会记录需要预定的地址空间的大小

可以看到,在第二个调度的页面中,是一个 防护页面(guard page)
当线程的栈越来越深,试图访问防护页面的内存的时候,系统就会得到通知。此时系统会执行如下逻辑:

  • 给下一个相邻的页面调度物理存储器
  • 消除防护页面的 GUARD_PAGE 的标志
  • 给刚刚分配的页面指定这个标志。

这个技术能够保证 只有线程需要的时候才给栈调度足够的存储器,从而减小存储器的使用。

注意,当调用不断加深的时候,整个栈会如图所示:

如图,如果此时栈还要加深的话,那么此时栈底的这个页面也不会被标志为防护页面。如果程序继续运行到这,系统为栈底调度页面的时候,就会抛出一个错误EXCEPTION_STACK_OVERFLOW。系统将使用结构化异常处理(structure exception handling,SEH)来处理这个问题。当弹出这个错误后,不单单时这个线程,整个进程都将被收回控制权。

为什么不对最后一个页面调度页面,而是用其作为边界检测呢?
若相邻的区域中,下一个地址也已经被调度了,那么就会 发生内存被破坏而无法察觉的情况:

另外一种难以察觉的溢出就是 栈下溢,例如:

1
2
3
4
5
int TestForStackOverflow(){
BYTE aBytes[1000];

aBytes[100000] = 0;
}

如果对应的位置上是另一条线程的已调拨区域,就可能会引发访问违规却无法察觉。比如这个操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DWORD WINAPI ThreadFunc(PVOID pvParam){
BYTE aBytes[0x10];

MEMORY_BASIC_INFORMATION mbi;

// The Stack All Size

SIZE_T s = (SIZE_T)mbi.AllocationBase + 1024 * 1024;

// Point to stack reserve memory end
PBYTE pAddress = (PBYTE)s;
BYTE* pBytes = (BYTE*)VirtualAlloc(pAddress, 0x10000, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);

// Write the 1 from this stack to next memory
aBytes[0x10000] = 1;
}

由于栈后方正好有一个空间,就导致程序能够溢出并且不发生错误。

C/C++运行时栈的检查

PS:这里的栈检查不是指放置canary的那种检查
对于一个调度默认大小的栈(一页4KB)来说,这样的代码会带来很大的问题

1
2
3
4
5
void Function(){
int nValues[4000];

nValues[0] = 1;
}

如上,我们第一次访问的地址就低于了 防护页面的地址,而之前的逻辑中我们可以知道,我们是首先访问了防护页面,才会为我们调度新的页面地址。如果严格按照上述逻辑的话,此时应该会发生访问越界。为了防止这个错误,编译器会插入一些代码来调用C运行库的栈检查函数。因为栈编译期间,就能够知道一个栈需要的大小。如果需要的栈空间大于目标系统的页面大小,编译器就会自动插入代码来调用栈检查函数。这个栈检查函数大致逻辑如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void StackCheck(int nBytesNeededFromStack){

...
while (nBytesNeededFromStack >= PAGESIZE){
pbStackPtr -= PAGESIZE;

// access one bytes at guard page to force
// new page to be committed
pbStackPtr[0] = 0;

nBytesNeededFromStack -= PAGESIZE;
}
...
}

后记

说起来这篇好像内容缺了堆相关的,记起来之后会补上(咕咕咕??)