Memory Management

类别:编程语言 点击:0 评论:0 推荐:

Windows 95 System Programming SECRETs

(Windows 95 系统程序设计 大奥秘)

原著:Matt Pietrek

笔记:Simon wan

内存管理(Memory Management) Windows 95 之中的Win32 行程地址空间

Windows 3.x 之中,所有程序都在同一个地址空间中执行。于是任何程序都很容易读取另一个程序的内存。更糟的是,程序还可以改变其它程序的内存内容,这就提供给那些有臭虫的程序一张通往地狱的车票啰。例如,16 位 Windows 程序甚至在 Windows95 中可以取得 16 位 USER DGROUP 的selector 并随意写些垃圾进去。于是窗口系统只好对你说拜拜了。

Windows 95 给予每一行程一个独立地址空间。所谓「独立地址空间」,我意思是程序只能看到它自己的内存,其它行程所使用的内存是不可处理的。更精确地说,Windows95 内存管理器使用 CPU 「以分页为基础」的内存管理哲学,确保只有目前行程所拥有的内存才会被映像到CPU 的 4GB 地址空间中。其它行程所拥有的 RAM 并不会出现在目前行程的page tables 中。这样做的最大好处是,一个问题程序最多只能破坏它自己,不会影响其它人。

为恐你对这个 Windows 新性质太过兴奋,我必须告诉你,它其实一点也不新。UNIX 行之有年矣,Windows NT 亦复如此。我们只能说,微软目前主推的桌上型操作系统拥有了高级操作系统的最基础性质。至于 Win32s,这个 Win32 家族中同父异母的姊妹,并不使用分离地址空间。

虽然把每一个行程的内存分隔开来是很重要的,但某些内存还是需要被所有行程共享。也就是说所有行程的线性地址空间中的某些页应该被映像到相同的 RAM 身上。为什么?最好的例子就是 system DLLs。每一个行程都需要 KERNEL32.DLL,如果每一个行程都载入一份崭新的 KERNEL32.DLL, 那将是无可置信的超级大浪费。因此KERNEL32(以及 USER32 等其它 system DLLs)应该驻扎在共享区域中。当 Windows 作业系统切换 page tables 以便执行另一个行程,它会把映像到共享内存的那些 page table留下不动。我将以其它例子说明共享内存的必要性。

由于 Windows 95 把不同行程的内存都分隔开来,所以对「Windows 95 如何布置4GB地址空间」的任何讨论都将离不开所谓的 memory context 观念。Memory context 基本上是一系列的 RAM pages,以及它们所映像的线性地址。用另一句话说,memory context 是操作系统给予一个行程的线性地址的视野(view)。

每一个行程有它自己一套 memory context。当 Windows 95 排程器将某个行程暂停而让另一个行程执行,它必须把 memory context 也切换过来。由于每一个行程有自己一套memory context,所以有时候它又被称为 process context。有时候它也被称为 address context。不论你把它叫作什么,记住一点,地址本身没有意义,除非你指明这个地址在哪一个 memory context 中。

从最上层来看,Windows 95 的 Win32 行程的内存布局十分简单。在 4GB 地址范围中,最底部的 2GB(0~7FFFFFFFh)保留给应用程序,2GB 以上(80000000h~FFFFFFFFh)则保留给操作系统。这两部份又都有细部切割。图5-1 显示 4GB 地址空间中的各个细目。如果你有 Windows 95 DDK (译注:Device Development Kit),请你阅读线上说明文件中的 "Arenas" 主题中的 “Page Mapping and Address Spaces” 一节。

第一个 4MB 地址空间是给系统虚拟机器中的每一个行程共享。其中位于 1MB 之下的那一部份,内含 MS-DOS 的内存映像(memory image),在 Windows 95 启动时载入。1MB 之下的有趣东西还包括 16 位global heap 的较低部份。一如我在 WindowsInternals 第2章所说,Windows 3.1 中的所有 16 位 heap 节区的线性地址,不是在1MB 之下就是在 2GB 之上。如果它是以 GMEM_FIXED 属性配置而得,那么常常就是在 1MB 之下。你会在地址空间的最初 4MB 中看到许多 16 位system DLLs,因为它们之中有许多(例如 KRNL386)需要「fixed 并且pagelocked」的内存。这是很重要的一点,稍后我还会再讨论。

下一个区域是 4MB 到 2GB。这是 Win32 行程所使用的地址空间。每一个 Win32 行程把它自己的码、自己的数据、自己的资源映像到这将近 2GB 的范围来。当memorycontext 的切换动作发生,其实就是换另一组 pages,映射到这个范围。除非特别指定,否则映像到此区域的 RAM pages 不能够被其它行程存取。除了应用程序的码和数据,它所用到的任何 DLLs 的码和资料也放在此区。在这里面你还可以发现应用程序的heap和stack(每一个执行绪有一个stack)。

    Win32 程序预设加载于非常低的位置(4MB)。除非你真的了解分页动作,否则这样的概念有点不协调。怎么能够有一个以上的程序加载到同一个地址呢?答案是:它们共享了相同的线性地址,但却不是相同的实际地址。一般而言,行程中的线性地址并不会映射到相同数值的实际地址。由于分页运算的关系,每一个行程可以认为自己拥有的是4MB~2GB 整个空间。它无法看到其它行程的内存,其它行程也无法看到它 -- 即令彼此其实享用同一个线性地址。分页「魔术」使它们在实际上有所区别。

上述规则(为每一个行程保存个别的 4MB~2GB 地址空间)的例外情况就是:Windows 95认为「把相同的实际内存开放给同一程序的多份副本(执行个体,instances)共享」是安全的。拿程序代码来说好了,因为程序通常不会修改其程序代码,如果你执行同一程序的多份副本,那么 Windows 95 节省内存的作法就是:把内含程序代码的实际内存映像到每一个程序副本的地址空间中。

从最纯净的操作系统观点来看,如果每一个 16 位行程也都有它自己的地址空间,类似 32 位行程那样,真是最好不过。不幸的是大量 16 位程序都依赖「能够看到其他程序的内存」这种能力而生存下去。为了保留 16 位程序的兼容性,Windows 95 势必得提供比 Win32 行程更大的权力给它们。Windows NT 3.5 让每一个 Win16 行程在它自己的地址空间中跑,但是因此消耗更多内存并导至更高的复杂性。Windows 95 的设计人员似乎感觉这样的效益不值得其所付出的代价。

自从我看过 Windows 95,有一个问题就引起我的兴趣:16 位程序如何以不同行程的身份而仍能够分享其地址空间?结论是,16 位程序所使用的内存总是来自4MB 以下和2GB 以上,所谓的可共享区域。现在让我们把眼睛移到 4GB 的上半部。从图5-1 你可以看出它被切割为两部份。2GB 至3GB 之间给所有行程共享,并意图给 ring3 操作系统码使用。在这个区域的最低部份,你将发现 16 位的global heap。而在它之上,你看到的是内存映像文件。这相当有趣,并且值得深思。

如果内存映像文件位于可被所有行程共享的区域,很显然任何行程都可以看到它,甚至不需要对它做映像动作(译注:指的是Win32 MapViewOfFile 这个动作)。是的,这样的假设是正确的。在 Windows 95 之中,一个内存映像文件可以被所有行程存取得到。这个情况与 Windows NT 不相同。Windows NT 使用更精巧的分页模式,使内存映像档只能够被「对此档案做了映像动作」的行程看到。

2GB~3GB 区域之最上层为 32 位 system DLLs(KERNEL32、USER32 等等)的藏身处。为了保留最多的空间给内存映像文件使用,ring3 system DLLs 从 3GB 开始往低处载入。下面是 SoftIce/W MOD 命令的输出片段,明白表示了这个事实:

:mod

hMod Base PEHeader Module Name EXE File Name

019F BFF700000 0147:BFF70080 KERNEL32 C:\WINDOWS\SYSTEM\KERNEL32.DLL

01A7 BFF200000 0147:81525AF4 GDI32 C:\WINDOWS\SYSTEM\GDI32.DLL

186F BFEF00000 0147:81525E98 ADVAPI32 C:\WINDOWS\SYSTEM\ADVAPI32.DLL

1827 BFC000000 0147:815270F0 USER32 C:\WINDOWS\SYSTEM\USER32.DLL

其中第二栏是模块的加载地址。KERNEL32 是第一个被加载的 32 位system DLL,极端接近 3GB(地址 BFF700000)。接下来是 USER32,位于BFF200000,并尽量和KERNEL32 接壤。也许你会以为这些地址是在加载的时候临场计算出来的,不,不是这样。微软有一个工具程序(Win32 SDK 中的 REBASE.EXE),可以算出一个 DLL 需要多少地址空间,然后算出最佳加载地址,使这些 system DLLs 可以尽量紧密地连接在一起。当这些 system DLLs 被编译联结之后(译注:当然不是被你),微软接着又修改DLLs,使它们拥有由 REBASE.EXE 所计算出来的较佳加载地址。这使得所有 systems DLLs 都能够以最快时间加载,Windows 95 加载器不需要再对它们做「复位位(relocation)」的工作。Windows 95 地址空间的最后一大块是 3GB~4GB(C0000000h~FFFFFFFFh)。最后这1GB是给 ring0 系统组件(也就是 VxDs)用的。

内存共享(Sharing Memory)

Win16 中的所有程序和所有 DLLs 所拥有的所有内存都可以被其它程序和 DLLs 存取。这是因为每一个 Win16 行程使用的都是同一个区域描述表格(local descriptor table,LDT)。因此,行程之间共享内存是很轻易的事:只要让两个(以上)程序使用相同的selector 即可。将欲给别人共享之内存设定为 GMEM_SHARE 属性,其实并非必要。是啊,不必理会微软信誓旦旦的警告。

现在让我们比对一下 Windows 95 的内存管理,它把每一个 Win32 行程的地址空间都区分开来,除非你特别指定哪一块要共享。不幸的是,指定共享并不只是像使用GMEM_SHARE 属性那么简单 -- 事实上在 GlobalAlloc 中使用 GMEM_SHARE 属性是没有用的。也就是说 GMEM_SHARE 毫无用处:Win16 根本不需要它,因为每一样东西都可共享;Win32 则根本忽略它。

可能你曾经听一些所谓的 Win32 权威人士说过,在 Windows 95 或 NT 中共享内存的唯一方法就是使用内存映像文件(memory mapped file)。那的确是一个方法,但不是唯一方法。如果你只是想在同一程序的不同执行个体(instance)中分享小量的内存,杀鸡何必用牛刀?虽然本书把焦点放在程序与程序之间的可读/可写数据的共享,但是别忘了,4GB 地址空间有一半保留给系统使用,它们总是可以被所有行程共享。

从低层来说,所谓内存共享,只不过就是把一页页的 RAM 映射到一个以上的行程位址空间中。这些RAM 可以被映像到相同的线性地址,也可以被映像到不同的线性地址。在 Windows 95 中,经由内存映像文件(memory mapped file)而完成的内存共享区域,总是在不同的行程中有着相同的线性地址。稍后的 PHYS 程序会揭露此一事实。然而,在你的 Win32 程序中做此假设是很危险的,因为 Windows NT 并不保证内存映像文件在每一个行程中有相同的线性地址。许多 Win32 程序设计书籍都涵盖有内存映像文件这个主题,所以我不打算在这里说太多。

最简单的内存共享办法反而没有被太多人提起。事实上,只要在联结时指定程序的 datasections 为 SHARED 属性,你就可以轻易地在同一程序的每一个执行个体(instance)之间,或是 DLL 的每一使用者之间,共享这份数据。只要将 Win32 DLL 的 data section指定为 SHARED,其性质就会像 Win16 DLL 一样。真幸运,Windows 95 给我们这么简单又有弹性的数据共享方式。你可以在 EXE 或 DLL 中产生多个 data sections,把所有你打算共享的数据放到其中一个 data section,然后把它的属性设为 SHARED。至于其它的 data sections 仍然使用预设的属性(nonshared)。PHYS 程序会示范这一切。

一般而言微软编译器会把所有初始化过的数据放进一个名为 .data 的 section 中,然后留给它一个 IMAGE_SCN_MEM_SHARED 以外的属性。这会使得每当有一个执行个体(instance)产生,该数据就会复制一份数据,专属给执行个体使用。为了共享内存,你可以要求编译器产生一个新的section,名称随你取,但只有前8个字符有意义。例如:

#pragma data_seg("sharedat")

在 #pragma 之后,你可以宣告任何你想要被共享的数据变量。你应该初始化这些数据,否则它们会被编译器放到另一个专放未初始化资料的 data section 去。变量宣告完之后,如果你要恢复原来的 data section 属性,只要加上一行即可:

#pragma data_seg()

最后,你必须将你的共享心愿传达给联结器知道。你有两种方法,传统作法是在DEF 檔中设定 section 属性:

SECTIONS

SHAREDAT READ WRITE SHARED

另一个作法是在联结器命令列参数中指定属性。RWS 代表 Read、Write、Shared:

LINK /SECTION:SHAREDAT, RWS <其它的联结器选项及文件名称>

我应该告诉你一些「使用者需知」之类的警告。如果你将你的数据初始化为程序代码或资料符号的指针,那么当 DLL 被加载于不同行程的不同线性地址上,事情会变得颇为有趣。看看这个表面上没有什么问题的数据宣告(在一个可共享的data section 中):

int i;

int *AddressOf_i = &i;

问题出在 DLL 被加载之前,AddressOf_i 无法确定下来。因此,DLL 必须内含一个待修正记录(fixup record),告诉加载器记得修正 AddressOf_i 的值。当 DLL 第一次载入,没有问题。但是如果另一个行程随后也加载此 DLL,而加载地址却没有与前一行程相同的话,由于 AddressOf_i 已被用于第一个行程(它是被共享的,不是吗),载入器不能够插手修改 AddressOf_i 的值。于是,对于第二个行程而言,AddressOf_i 的值是错误的。利用指标,可以解决这个问题。我可以使用一个非共享的数据变量,内放一个指标指向共享数据。由于此一指标是每个行程皆有一份,所以加载器可以修正其值,使它在每一个行程中都正确无误。

除了将你的资料分享出来,Windows 95 还可以共享其它内存。我已经说过了,2GB 以上全都是共享的。然而,Windows 95 也微微开放了 2GB 以下的一部份区域。如果你执行一个程序的多份副本(instance),或是在一个以上的行程中使用相同的DLL,那么每重复一份码都是一种浪费。虽然 code section 并没有IMAGE_SCN_MEM_SHARED 属性,Windows 95 还是只加载一份程序代码,然后使用CPU 的 page table,把程序代码映射到其它的 memory context 之中。

这种分享 code section 的作法很好,唯一例外就是当 DLL 没有办法载入到不同行程中的相同线性地址时。假设 FOO.DLL 被两个行程使用,行程A加载 FOO.DLL 并放到线性地址 X 处。行程B使用另一组 DLLs(其中包括 FOO.DLL)。当行程B载入FOO.DLL,某些其它的 DLLs 已经占用了地址 X,于是 FOO.DLL 只好使用其它地址。如果你的程序处于这种情况,解决之道是重新设定 DLL 的基底加载地址,设到一个从没有被其它行程使用的线性地址上。

Windows 95 的 "Copy on Write"(写入时才拷贝)

既然知道 Windows 95 极尽可能地共享程序代码,我们很自然就会关心:除错器对此如何因应。有什么问题吗?噢,除错器会在你的码内写入中断点(breakpoint)指令(INT 3,opcode 0xCC)。如果除错器写入中断点指令的那个 code page 是被两个行程共享的话,就会有潜在问题。要知道,除错器只对一个行程除错,另一个行程即使碰到中断点,也不应该受影响。当操作系统看到 INT 3 并且得知该行程并非处于被除错状态时,它就把该行程结束掉,因为这是一种无法处理的异常情况。好,如果Windows 95 的内存管理系统果真如上节我说的那样,你就没有办法对一个「同时被多个行程所使用」的 DLL 除错了 -- 那样将无可避免地导至其它行程莫名其妙被结束掉。更别说是对某个执行个体(instance)除错,而另一个执行个体还能正常运作了。

高级操作系统如 UNIX 之流,对付此问题的方法是所谓的 "copy on write" 机制。一个拥有 copy on write 机制的系统(如 Windows NT),内存管理器会使用 CPU 的分页机制,尽可能将内存共享出来,而在必要的时候又将某些 RAM page 复制一份。

给个实际例子会比较清楚一些。假设某程序的两个个体(instances)都正在执行,共享相同的 code pages(都是只读性质)。其中之一处于除错状态,使用者告诉除错器在程序某处放上一个中断点(breakpoint)。当除错器企图写入中断点指令,会触发一个 page fault(因为 code page 拥有只读属性)。操作系统一看到这个page fault,首先断定是除错器企图读内存内容,这是合法的。然而,随后「写入到共享的code page 中」的动作就不应该被允许了。系统于是会先将受影响的各页拷贝一份,并改变被除错者的 page table,使映像关系转变到这份拷贝版。一旦内存被拷贝并被映像,系统就可以让写入动作过关了。写入(中断点)的动作只影响该份拷贝内容,不影响原先内容。

"Copy on write" 并不只在分享程序代码时才派上用场。在 Windows NT 中,可写入的datapages 一开始也是只读属性,当应用程序对其中一个 page 写入数据,CPU 会产生 pagefault。操作系统于是把这个 page 改登记为「可读可写」。为什么要这么麻烦呢?因为如此一来内存管理器还是可以把其它只读的 data pages 共享给大家。如果稍后有人对这些 data pages 做写入动作,”copy on write” 机制会拒绝之,并另外提供 RAM pages 给每一个行程。

Copy on write 机制的最大好处就是尽可能让内存获得共享效益。只有在必要时刻,系统才会对共享内存做出新的拷贝。不幸的是,copy on write 机制需要一个精巧的记忆体管理系统,和一个精巧 page table 管理系统,而 Windows 95 还够不上格,因为Windows 95 并非直接在分页层面支持 copy on write。这对于Windows 95 早期使用者而言是极大的苦恼,毕竟微软一直推销说所有 Win32 程序在 Windows 95 和 NT 上都执行得一样好。当主要特质(如 ”copy on write”)缺席,「执行得一样好」这句话可就有漏洞啰。

Windows 95 并不是盲目而愚蠢地就把数据写入共享内存中。由于必须有某些动作以使除错器能够工作,Windows 95 支持一个所谓的「copy on write 虚拟机制」。在这个虚拟机制中,当共享内存身上出现page fault,WriteProcessMemory 动作就会发生。作业系统首先确定你要写入的地址是否落于共享内存中,如果是,系统会将原来的pages 拷贝一份,然后把新的 pages 映像到相同的线性地址,然后再进行写入动作。PHYS 程序证明,copy on write 虚拟机制的确有效地运作着。

虽然 WriteProcessMemory 足够让除错器得以对大部份的 DLLs 除错,它却不能够对2GB 以上的区域除错。由于 system DLLs 如 KERNEL32 者位于 2GB 之上,所以一般的应用程序除错器没有办法像在 Windows NT 之中那样地对它们除错。试看看,在Windows 95 中启动你最熟悉的应用程序除错器,尝试进入(step into,译注)一个系统呼叫之中。不论 Visual C++ 除错器或Turbo Debugger 都沉默地跳出(step out,译注)该系统呼叫 -- 甚至即使你是在一个反组译窗口并要求进入呼叫之中。如果你希望走入Windows 95 系统码之中,你需要一个系统层面的除错器,像是 SoftIce/W 或 WDEB386之类。

Memory Contexts

虽然抽象说明 memory contexts 也是不错,不过有时候来点实务经验更好。Windows 95 必须维护一些数据结构,用以记录哪一页的 RAM 映像到行程的哪一块线性地址。为了了解 Windows 95 的 memory contexts,你必须了解 CPU 的分页机制。我将带你快速浏览80386 的分页机制,至于更先进的细节就省略不提了。如果你对分页有兴趣,请参考 Intel手册或其它 386 架构的书籍。

80386 级的 CPU 使用双层查询表格,将一个线性地址转换为一个实际地址,再送往位址总线(address bus)。第一层查询表格称为 page directory,有 4KB 那么大,可视为 1024 个 DWORDs 组成的数组。每一个 DWORD 内含一个实际地址,指向一个名为 page table 的 4KB 空间 -- 它同样也是 1024 个 DWORDs 组成的数组,每一个DWORD 内含一个实际地址,指向 4KB 实际内存(RAM)。

为了使用 page directory 和 page table,CPU 把 32 位线性地址切割为三部份,如图5-5所示。最高 10 个位给 CPU 当做 page directory 的数组索引,选出一个 page table。接下来的 10 位则当做该 page table 的数组索引,选出一笔数据,内含 4KB RAM 的起始地址。最后 12 个位则用来做为这 4KB RAM 的偏移值,精确指出一个字节。

CPU 到哪里找出 page directory?CR3 缓存器是也!这是 80386 所引进的一个特殊暂存器。memory contexts 的最粗糙生产方式就是为每一个行程产生一个 page directory 以及1024 个 page tables,然后在适当时刻改变 CR3 缓存器内容,使它指向当时行程的 pagedirectory。

这种作法的问题是,为了映像整个 4GB 地址空间,你需要 1024 个 page tables,每个大小是 4KB。每一个行程光为这个就耗掉 4MB 内存,不符合经济效益。Windows 95 的作法是只维护单独一块 4MB 区域当做 page tables,并时时修改 page directory 中的资料项,使 CPU 能够快速改变 pages 的映像。



也许你担心,光为了分页就用掉 4MB,是不是太多了些。噢,不必担心,操作系统可以藉由 page directory 这一层,告诉 CPU 说某一个page table(占用 4KB)不在内存中(not present),于是就可以省下 4KB RAM。Page directory 和 page tables 很少真正使用将近 4MB 的实际内存,但它们的确是使用 4MB 地址空间,就从 FF800000h 开始。Page directory 也位于这 4MB 之中。利用 SoftIce/W 可以观察得到它们。

Windows 95 的内存管理函式

Windows 95 的内存管理函式分为四层,上层函式依赖下层函式。最底层是 VMM 提供的函式,用来配置大块内存并在其中操作 pages。应用程序并不直接呼叫这一层函式,KERNEL32.DLL 才会用到它们,用以成就较高阶的函式。

第二层是 KERNEL32 提供的 VirtualXXX 函式:VirtualAlloc、VirualFree、VirtualProtect等等。这些函式系以 VMM 函式为基础,用来管理大块内存,并以 page 为单位。

更上一层是 KERNEL32 的 HeapXXX 函式,包括 HeapAlloc、HeapFree、HeapCreate 等等。它们大约相当于 C 函式库中的内存相关函式(如 malloc、free 等等)。事实上,在 Windows NT SDK 的 C 函式库中,malloc 只是 HeapAlloc 的另一个包装而已。

最上一层是 LocalXXX 和 GlobalXXX 函式。但和 Win16 不同的是,这两组函式基本上没有差别,例如 LocalAlloc 就和 GlobalAlloc 完全相同。KERNEL32 开放出这两个函式,但使用同一个函式地址。LocalXXX 和 GlobalXXX 其实只是 HeapXXX 的上层包装而已,其实没有太多理由需要在 Win32 程序中使用LocalAlloc 和 GlobalAlloc 这样的函式。这些内存管理函式不再像 Win16 的GlobalAlloc 一样需要和selector 打交道,也不再像 Win16 的LocalAlloc 一样从程序的数据节区中挖空间。它们之所以继续存在于Win32,主要理由就是让原来的 Win16 程序更容易生存。

本文地址:http://com.8s8s.com/it/it24506.htm