|
Memory Management(2)  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 CSC="0" NumberType="1" Negative="False" HasSpace="False" SourceValue="19" UnitName="F">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 的映像。
|