Windows 95的“Copy on Write”(写入时才复制)
既然知道Windows 95尽其可能的共享程序代码,我们很自然就会关心:调试器(Debugger)对此如何应对。有什么问题吗?哦,调试器会在你的程序代码中写入中断点(
break point)指令(INT 3,opcode 0xCC)。如果调试器写入中断点指令的那个code page是被两个进程共享的话,就会有潜在的问题。要知道,调试器只针对一个进程进行调试,另一个进程即使碰到了断点,也不应该受到影响。当操作系统看到INT 3并且得知该进程并非处于调试状态时,它就把该进程结束掉,因为这是一种无法处理的异常情况。好,如果Windows 95的内存管理系统真的如我所讲的话,你就没有办法对一个“同时被多个进程所使用的” DLL进行调试了---那样将无可避免地导致其他进程莫名其妙的被结束掉。更别说是对某个执行体(Instance)进程调试,而另一个执行体却还正常的运作了。
高级操作系统如UNIX,对付此问题的方法是所谓的“Copy con Write”机制。一个拥有copy on write机制的系统(如Windows NT),内存管理器会使用CPU的分页机制,尽可能的将内存共享出来,而在必要的时候又将某些RAM Page复制一份。
给个实际例子会比较清楚一些。假设某个程序的两个执行体(Instanced)都在执行,共享相同的code pages(都是只读性质)。其中之一处于调试状态,使用者告诉调试器在程序某处放上一个中断点(
break point)。当调试器企图写入中断指令时,会触发一个page fault(因为code page拥有只读属性)。操作系统一看到这个page fault,首先断定是调试器企图读取内存中的数据,这是合法的。然后,随后“写入到共享的code page中”的动作就不应该被允许了。系统于是会先将受影响的各个页复制一份,并改变被调试者的page table,使映射关系转到新复制出来的页上。一旦内存被复制并被映射,系统就可以让写入动作通过了。写入(中断点)的动作只会影响被复制出来的页,不会影响原先的内容。
“Copy on Write”并不只在分享程序代码时才派上用场。在Windows NT中,可写入的data pages一开始也是只读属性,当应用程序对其中一个page写入数据时,CPU会产生page fault。操作系统于是把这个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之类。
译注:
step into和step out都是调试器上的命令,前者表示要进入一个函数之中,后者表示要跳离当前的函数。
PHYS程序
潜伏在PHYS表面下的,是一些底层的系统代码,微软可能不希望你知道那么多。在一个设计良好的操作系统中,应用程序不应该能够处理实际内存和线性地址之间的映射关系。一般而言也没有必要这么做。但这些正是PHYS程序机能的核心。由于Windows 95并未提供方法让我们获得page mapping的关系,PHYS只好绕过操作系统自己来。PHYS的一部分代码走在刀锋边缘,促使在ring0(x86 CPU的最高权限)中执行。一般应用程序都在ring3执行,未经过操作系统的谨慎控制,不可能进入ring0。由于PHYS所需的ring0代码并未经过操作系统核准,我必须写一个一般性的机制,让ring3的Win32程序可以调用ring0代码。你可以轻易修改PHYS ring0代码,放入你自己的应用程序中。
为了把线性地址映射到实际地址,GetPhysicalAddrFromLinera函数必须“Party with”page tables。“Party with”是微软的官方术语,意思是做某些你不应该做的事。Page Tables是一个复杂的主题,我将在下一节“Memory Context”中描述它。如果现在你不知道什么是page tables,只要把它想象是一个数据结构,用来描述线性地址与实际地址之间的映射关系即可。Page tables由操作系统维护,提供给CPU使用。阅读CPU手册,你会发现Page Directory由寄存器CR3保存。不幸的是,你必须拥有很高的权限才能取出CR3的值。企图在ring3取出CR3的值只会导致一个General Protection Fault(异常情况 0Dh)。当Windows 95看到这个异常情况后,它会分析这个指令并发现后者并没有足够的权限。Windows 95并不会把发出这个指令的程序结束掉,它只是无声无息的把控制权交还给应用程序。但,当然不含CR3的值。
这意味着什么?Windows 95不让应用程序偷袭Page Tables。当然啦,我可以写一个VxD(它在ring0执行)取出CR3的值,但是我不喜欢我的系统中围绕太多的VxDs。此外,即使我能够取得CR3的值,还有一个大问题。CR3值代表一个实际地址,却没有什么好办法可以把实际地址转换为线性地址(PHYS只能使用线性地址)。除非我把分页机制关闭(译注:于是实际地址等于线性地址),否则我似乎不能对CR3值做些什么。
另一个想法是,看看Windows 95能不能把page tables映射为ring3代码能够直接使用的线性地址。我们知道整个4MB page tables总是被映射在线性地址0xFF800000处(距线性地址顶端8MB)。那么你是不是可以产生一个指针,指向该处,就可以直接读取page tables的内容?不,没这么好,这些表格并非如你所想得没有保护。Page Directory以及每个Page Table中的每一笔数据都有一个user/supervisor位,指示“任何权限等级的代码都可以存取它”或是“只有ring0代码才能存取它”。每个Page Table的user/supervisor位都是0,表示整个4MB Page Tables对于ring3代码而言是块禁地,我们势必得将我们的代码在ring0执行,才能取得page tables。
从一个ring3 Windows程序中调用ring0 16位代码,重点其实在于所谓的CPU Call gates,它提供一种让“低权限的代码调用高权限代码,例如ring3调用ring0”的方法。由于Windows并没有给你这么一个Call gates,所以要想实现就必须进入LDT并产生一个call gate。为了进入LDT,使用INT 2Fh子功能即可。
在一个Win32程序中以一般化的机制调用ring0代码,当然是比较棘手的,但不会太棘手。下面所列的GetPhysicalAddressFromLinear就是个好例子。首先,你必须调用GetRing0Callgate以产生一个call gate selector。该函数接受两个参数,第一个是“你所希望执行的ring0代码”的32位线性地址,第二个是DWORD参数的个数,它们将被压倒stack之中,供ring0代码取用。
一但你拥有call gate selector,下一个动作就是把它存储在一个6位数组的远程指针中(也就是一个FWORD)。6位数组? 是的,在32位模式中,远程调用是通过一个16位selector和一个32位偏移地址实现的。偏移地址是32位,这暗示着selector将是针对32位节区而非16位节区,这就有点像Win32程序的flat地址模式。我们希望利用这个call gate selector做出一个远程调用,为的是让CPU切换至ring0。在下面的示例代码中,call gate selector被存储在6位数组的较高三个WORDs。指针的偏移值并不重要,因为CPU会忽略它们,并从call gate描述符所记录的偏移值中载入EIP中。产生这个指针之后,程序代码改用嵌入式汇编调用此指针函数(因为C编译器只知道32位近程调用)。我在调用call gate的动作前后加入了cli和sti两个指令,为的是避免在ring0中发生中断(Interrupts)。
DWORD GetPhysicalAddrFromLinear( DWORD linear)
{
if ( !callgate1 )
{
callgate1 = GetRing0Callgate( (DWORD)_GetPhysicalAddrFromLinear,1 );
}
if ( !callgate1 )
{
WORD myFwordPtr[3];
myFwordPtr[2] = callgate1;
__asm push [linear]
__asm cli
__asm call fword ptr [myFwordPtr]
__asm sti
// The return value is in EAX. The compiler will complain, but…..
}
else
return 0xFFFFFFFF;
}
从Win32程序进入ring0有些奇特的要求。为了某些因素,我必须写一个PAGETABL.ASM。第一,16:32远程调用会使得CPU设置8位数组到stack中,而不是传统的4位。因此在设定EBP Frame之后,第一个参数是EBP+0Ch而不是EBP+08h。更重要的,欲返回至ring3时,需要一个16:32 RETF而不是32位的近程返回。和16:32远程调用一样,编译器也不知道怎么产生一个16:32 RETF。
现在让我做一个整理。当你从Win32程序中调用ring0,第一步是写ring0代码,并注意上面的警告。接下来在Win32代码中调用GetRing0Callgate,把你的ring0函数名及其参数个数传回去。然后根据这个call gate产生一个16:32远程指针,并调用之。最后,当你不再需要那个ring0函数,调用FreeRing0Callgate释放即可。整个过程并不是很精简,但总比全部由操作系统支配的好。
Memory Contexts
虽然抽象说明memory context也是不错,不过有时候来点实际经验更好。Windows 95必须维护一些数据结构,用以记录那一页的RAM映射到进程到哪一块线性地址。为了了解Windows 95的memory context,你必须了解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个位则当作页表的索引,选出一项数据,内含4KB RAM的起始地址。最后12个位则用来为这4KB RAM的偏移值,精确指出一个实际的二进制位。
那么CPU到哪里找出页目录(page directory)呢?CR3寄存器是也!这是80386所引入的一个特殊寄存器。Memory context的最粗糙的产生方式就是为每个进程产生一个页目录(page directory)以及1024个页表(page table),然后在适当时刻改变CR3寄存器的内容,使其指向当前进程的页目录(page directory)。
这种做法的问题是,为了映射整个4GB地址空间,你需要1024个页表(page table),每个大小是4KB。每个进程光为这个就耗掉4MB内存,不符合经济效益。Windows 95的做法是只维护一块4MB区域当作页表(page table),并时时修改页目录(page directory)的内容,使CPU能够快速改变页面映射。
也许你担心,光为了分页就用掉4MB,是不是太多了些?噢,不必担心,操作系统可以通过页目录(page directory)这一层,告诉CPU说某个页表(page table,占用4KB)不在内存中(not present),于是就可以省下4KB RAM。页目录(page directory)和页表(page table)很少真正使用将近4MB的实际内存,但它们的确是使用4MB地址空间,就从FF800000h开始。页目录(page directory)也位于这4MB之中。利用SoftICE/W可以观察到它们。
你可以轻易找出页目录(page directory)的线性地址:只要利用SoftICE/W的CR命令取出CR3值。在我的机器上,CR3为6EE000h。这是一个实际地址,所以你必须先将其转换为线性地址才能够在程序中使用。SoftICE/W的PHYS命令可以轻易的完成此事,它会搜寻所有的页表(page table),找出所有与“你所指定的实际地址”有映射关系的线性地址。下达PHYS 6EE000h命令,我获得两个线性地址,其中第二个是FFBFE000h,正位于保留给页表使用的4MB地址空间中。
既然我们能够通过SoftICE/W找到页目录,我们应该能够在页目录中设立一个硬件写入断点(write breakpoint),以证明或推翻前面我说的有关于memory context swithing的论点。如果中断点确实起作用了,表示context switching的确是因为对页表的操作而完成,同时,前述的写入地址也可以给我们一个线所,让我们更清楚context switching的反应。
我在SoftICE/W上做个小小的实验,证实页目录确实会改变。为了观察,我回头看看写入动作发生前的一些指令,如下所示:
_ContextSwitch
0028:C0004856 MOV EAX,[C001084C]
0028:C000485B MOV EDX,[ESP+04]
0028:C000485F CMP EAX,EDX
0028:C0004861 JZ C0004893
0028:C0004863 PUSH ESI
0028:C0004864 PUSH EDI
0028:C0004865 MOV EDI,FFBFE000
0028:C000486A MOV ECX,[EDX+04]
0028:C000486D MOV ESI,[EDX]
0028:C000486F REPZ MOVSD
0028:C0004871 MOV ECX,[EAX+04]
0028:C0004874 SUB ECX,[EDX+04]
0028:C0004877 JBE C0004880
0028:C0004879 MOV EAX,[C00107E0]
0028:C000487E REPZ STOSD
0028:C0004880 XCHG EDX,[C001084C]
0028:C0004886 MOV EAX,EDX
0028:C0004888 MOV ECX,[C0010CDC]
0028:C000488E MOV CR3,ECX
0028:C0004891 POP EDI
0028:C0004892 POP ESI
0028:C0004893 RET
_ContextSwitch的核心动作是REPZ MOVSD和REPZ STOSD两个指令。REPZ MOVSD之前的三个MOV指令是用来设定某个东西,用以将一块内存从某处复制到另一处。复制的对象是FFBFE000h,这正是我们稍早所见的页目录的起始位置。这表示,此一常式产生了一组新的页表,映射到页目录之中。它所复制的每个DWORDs都对应于页表(最大有1024个)中的一个。
另一件有趣的事情是,被移动的DWORDs个数并未写死。相反的,程序代码在入ECX,内含DWORDs个数。第二个指令REPZ STOSD的效果并不明显,它用来比较“这一次被复制的DWORDs个数”和“前一次_ContextSwitch被调用时所复制的DWORDs个数”。如果本次比前一次少,表示有一些页表是前一个memory context专用的,新的memory context不应该看到。如有必要,REPZ STOSD会把其他的页目录数据项(参看译注)标记为“non-present”。
译注:
我所谓的“页目录数据项”就是“page directory entries”,有人简称为PDE。至于page table entries,有人简称为PTE。
SoftICE/W很好心的把_ContextSwitch标记放在程序列表的顶端。_ContextSwitch是VMM的一个Services,其地址出现在VMM Services表格中,此表格通过VMM的Device Descriptor Block(DDB)的一个标志位指出。SoftICE/W如何知道这个Service的名称?请看看Windows 95 DDK中的VMM.INC。每一行若以VxD_Service起头,就是VMM VxD的Service。接近底部的地方你会看到_ContextSwitch。另两个邻近的Services:_PageModify和_PageModifyPermissions,也很有趣。
我们发现,Windows 95必须保持一组pages,以及一个页数值,给每个memory context使用。再一次我们可以利用SoftICE/W的Addr命令验证之:
在这个列表之中,FREECELL、WINMINE、MMTASK以及HEAPWALK都是Win16程序。有趣的是,即使Win16程序可以彼此看到对方,Windows 95一样以分离的进程对待之,并以不同的memory context伺候。然而这只是一种理论,因为Win16的程序代码和数据区总是被载入到共享区域(0-4MB以及2GB以上)。因此,Win16程序总是能够看到彼此,甚至虽然技术上它们拥有不同的memory context。
上述列表中的其他进程都是Win32进程。Tables位容易引起误会,它其实是指构成该memory context所需的页表的个数。每个页目录映射1024个页表,每个page table entry(PTE)映射到一个4KB区域,每个page directory entry(PDE)映射到4MB线性地址空间。请注意,16位程序只使用两个页表,这是因为16程序不需要Win32进程区域(0x00400000-0x7FFFFFFF)。至于Win32进程就一定需要这个区域了,不过其中大部分也都是“not present”。
对于Windows 95的memory context这里我不再继续了,因为和现在的Windows 2000/XP差异太大,对于现在的学习没有什么价值。
Windows 95的内存管理函数
Windows 95的内存管理函数分为四层,上层函数依赖下层函数。最底层是VMM提供的函数,用来分配大块内存并在其中操作pages。应用程序并不直接调用这一层的函数,KERNEL32.DLL才会用到它们,用以完成高层的函数。
第二层是KERNEL32提供的VirtualXXX函数:VirtualAlloc、VirtualFree、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程序更容易生存。本章剩余部分会深入挖掘这四层函数。除了最底层的VMM函数之外,我会提供每个内存管理函数的虚拟代码。某些Win32函数可能没有在Windows 95中实做出来,或者可能只是单纯映射到其他函数。我都会意义之处。
VMM函数
此处的内容,不再适用于Windows NT系列,这里就不再罗嗦了。
Win32的Virtual函数
译注:
此处的Virtual函数是指以Virtual开头的Win32 API(都与内存管理有关)。和C++中的虚拟函数(Virtual function)没有任何关系。
Win32内存管理API函数中最底层的就是Virtual函数,例如VirtualAlloc和VirtualProtect。这些Virtual函数用来管理和分配大块内存。在Windows 95之中,Virtual函数对内存的基本单位是4KB,这使得它们不适合用来取代C/C++的malloc和new。它们之中大部分都是VMM函数的一层薄包装。关于这一点,当我展示Virtual函数的虚拟代码是你就会看出来。
Win16之中与这些Virtual函数最接近的要算是GlobalHeap函数了,例如GlobalAlloc。Win16的GlobalHeap函数和Win32的Virutal函数都允许你分配大块内存。但是和GlobalHeap不同的是,Virtual函数并不使用selector来指向内存块。它们以4KB为内存的最小单位,不使用selector。而Win16 GlobalHeap函数允许你配置相20h那么小的内存块。
VirtualAlloc
VirtualAlloc有多重功能。任何VMM内存管理器对待线性内存的每一个page的态度都是free、reserved或committed。VirtualAlloc使你得以单向改变某一个范围内的pages的状态。它可以改变page的状态,从free到reserved,或是从free到committed。此外,它也可以改变原本reserved的pages成为committed状态。
最后一种改变,从reserved到committed,对于稀疏内存和stack的实现极有价值。程序首先利用VirtualAlloc保留一块够大的内存空间,足够满足程序中的任何要求。然后程序设定一个结构化异常例程(Structured Exception Handler)搜寻被保留的内存范围内的page faults。当这些page faults发生,程序再次调用VirtualAlloc。这一次VirtualAlloc把引起page fault的pages,从reserved状态改变为committed状态。这样一来程序就可以分配巨量内存而不需要先获得实际的RAM。只有当这些pages真正被使用了,才需要映射到实际的RAM。
一般而言,VirtualAlloc被操作系统和应用程序使用,在程序的地址空间(也就是2GB以下)分配内存。然而,它有一个未公开的标志(0x80000000),允许它获取2GB以上的内存。你可以使用内存映射文件完成相同的事情。事实上,粗略的说,内存映射文件所使用的地址范围,正相当于VirtualAlloc以0x80000000标志分配而来的地址。
Win32 VirtualAlloc函数保留内存时,是以最接近的64KB边界开始的。然而,并不是VirtualAlloc做这个“切割”动作,是VirtualAlloc调用_PageReserve完成。
VirtualAlloc首先检查请求的内存是否太大。这里的“太大”意味着超过2GB-4MB,这是一个应用程序的线性地址保留区的大小。然后,VirtualAlloc计算出需要多少pages,然后把开始的地址下移到最接近的4KB边界,也把最末端的位置上移到最接近的4KB边界。因此,如果你要求2bit的空间,一个在某个page的最后,另一个在另一page的最前面,那么VirtualAlloc会尝试保留两个pages。