同其它操作系统一样,
CE.NET也支持32位虚拟内存机制、按需分配内存和内存映射文件等。但是与其它Windows操作系统又有明显的不同。毕竟Windows
CE是一种嵌入式实时性的操作系统,在内存管理方面必须要比其它Windows操作系统更节约物理内存和虚拟地址空间。在内存管理API方面,为了便于移
植程序,Windows
CE和其它Windows操作系统函数声明基本一致,这使一个在其它Windows下开发的程序员可以直接使用早就熟悉的API函数,
但是CE下内存管理的原理开发者还是应该熟悉的。
1、ROM和RAM
对于早期采用的设
备一般采用ROM + RAM
,在ROM中存放的所有文件可以是压缩的也可以是不压缩的,这取决于OEM(原始设备制造商)。OEM在定制CE内核时可以设置一个标志告诉ROM镜像制
作工具(romimage.exe)是否压缩文件。对于ROM中存放的模块(DLL、EXE文件)来说,如果是压缩的,模块在运行前先解压并全部存放到
RAM中。如果是不压缩的,并且ROM介质支持线性访问(line-accessed),就可以本地执行(executed in
place,缩写为XIP)。利用本地执行方式运行应用程序、DLL的优点是:采用这种技术在加载EXE或DLL时,其中的代码段数据不加载到物理内存
中,内核只是分配虚拟地址空间给代码段,当执行代码时内核会到实际存放在ROM设
备上的文件中寻找代码并执行。采用这样的技术既可以节省可用内存又可以减少加载的时间。但是这种技术有一定的局限性,如果要让CPU到ROM中去寻找代码
执行,那么ROM介质必须支持线性访问,这就要求ROM介质支持线性访问,而不是块访问。XIP这种加载方式的缺点就是执行速度相对较慢,CPU访问
ROM的速度肯定远慢于访问RAM的速度。
基于Windows
CE的产品开始采用FLASH、IDE等永久存储设备时,内核镜像(.bin)和其它应用程序文件开始存放到永久存储设备中而不是ROM中,这不仅仅是因
为硬盘或者FLASH的I/O速度比ROM快,更因为现在的内核包含的功能多并且文件数量增加,因而需要的存储空间很大,一般都在20MB左右。再加上其
它开发商开发的应用程序文件,要求的空间就更大了。CE启动时内核镜像由加载程序解压并将系统文件加载到RAM的NK,NK是在config.bib中定
义的一段RAM区域,专用于保存内核镜像解压出来的所有文件。Windows
CE将NK看作是ROM,当执行一个应用程序时,CE内核将这个应用程序需要的系统DLL(在NK中保存)加载到Slot 1(地址范围0x0200
0000-0x03FF FFFF,在Windows CE.NET中Slot 1专用于XIP DLL使用)。Slot
1是一段虚拟地址,当CPU执行DLL的代码时,CPU会根据地址映射关系到NK中寻找实际的代码执行,因为NK是一段实际的物理内存,I/O速度非常
快,所以相对于在ROM中执行,DLL的运行效率得到很大提高。
非XIP DLL在加载时CE内核会在调用DLL的进程的地址空间中申请足够大的地址空间,并且执行代码时按需提交物理内存。
RAM和ROM文件系统是Windows
CE默认的文件系统。RAM文件系统的优点是支持文件压缩、支持事务机制(和数据库中的事务机制相似)、数据I/O较快。Windows
CE.NET启动时把除了NK以外的RAM分为对象存储(object store)区域和应用程序内存(program
memory)区域,并且默认各使用一半RAM。在基于Windows
CE的设备没有采用永久存储器之前,对象存储的作用相当于永久存储器,对象存储区域采用RAM文件系统来保存文件,对象存储中可以存储的对象类型有文件、
目录、数据库、记录、数据库卷。默认在对象存储中存储的对象全部是压缩的。当整个系统关闭时,设备的电源还继续提供电力给RAM,这样对象存储中保存的所
有数据就不会丢失。应用程序内存区域留给所有应用程序运行时使用。基于Windows
CE的设备采用永久存储器后,对象存储的作用就被永久存储器替代了,所以采用永久存储器后,应该减小对象存储区域的大小。如果定制的Windows
CE的内核包含了资源管理器(explorer.exe),那么打开“控制面板”,在“系统”-“内存”中,可以调节这两个存储区域的比例。滑块向左,则
释放对象存储区域的一些可用内存并将这些内存划到应用程序内存区域中。滑块向右则相反。
2、内存结构
Windows
CE.NET只能管理512MB的物理内存和4GB大小的虚拟地址空间。不同的CPU内存管理方法也不同。对于MIPS和SHX系列CPU来说,物理地址
映射是由CPU完成的,CE内核可以直接访问512MB的物理内存。对于x86系列和ARM系列的CPU来说,在内核启动过程中它会将现有物理内存地址全
部映射到0x8000
0000以上的虚拟地址空间中供内核以后使用。OEM可以通过OEMAddressTable来详细定义虚拟地址和物理地址的映射关系。
OEMAddressTable本身并不是一个文件,它只是存在于其它文件中描述虚拟地址和实际物理地址的映射关系的数据。比如文件oem
init.asm中包含一段代码:dd 80000000h, 0, 04000000h 。它表示将整个物理地址(0x0400
0000=64MB)共64MB映射到虚拟地址从0x8000 0000到0x8400
0000中。关于OEMAddressTable我将在以后关于PB的文章中讲述。
整个4GB虚拟地址空间主要划分为两部分,从0x8000 0000以上为内核使用部分,0x8000 0000以下为应用程序使用部分。详细见下表:
地址范围 |
用途 |
0x0000 0000到0x41FF FFFF |
由所有应用程序使用。共33个槽,每个槽占32MB。槽0(Slot 0)由当前占有CPU的进程使用。槽1由XIP DLL使用。其它槽用于进程使用,每个进程占用一个槽。 |
0x4200 0000到0x7FFF FFFF |
由所有应用程序共享的区域。32MB地址空间有时不能够满足一些进程的需求。那么进程可以使用这个范围的地址空间。在这个区域里应用程序可以建堆、创建内存映射文件、分配大的地址空间等。 |
0xA000 0000到0xBFFF FFFF |
在这个范围内核重复定义0x8000 0000到0x9FFF FFFF之间定义的物理地址映射空间。区别是在这范围映射的虚拟地址空间不能够用于缓冲。
我举例来说明:假设一个产品有64MB物理内存。如上文所述定义好OEMAddressTable后。内核启动后一个物理地址映射空间范围在
0x8000 0000到0x8400 0000,那么内核会从0xA000 0000到0xA400
0000定义一个同样范围的地址空间,这个地址空间和0x8000 0000到0x8400
0000映射到相同的物理地址。但这个虚拟地址空间不能够用于缓冲。 |
0xC000 0000到0xC1FF FFFF |
系统保留空间 |
0xC200 0000到0xC3FF FFFF |
内核程序nk.exe使用的地址空间。 |
0xC400 0000到0xDFFF FFFF |
这个范围为用户定义的静态虚拟地址空间,但这个地址空间只能用于非缓冲使用。 利用OEMAddressTable定义物理地址映
射空间后,每次内核启动时这个范围都不改变了,除非产品包含的物理内存容量发生变化。假如增加到128MB物理内存,那么物理地址映射空间也向后扩大了一
倍。Windows
CE.NET也允许用户创建静态的物理地址映射空间。用户可以调用CreateStaticMapping函数或者
NKCreateStaticMapping函数来映射某一段物理地址到0xC400 0000和0xE000
0000之间的某一个范围。需要注意的是用这个函数创建的静态虚拟地址只能够由内核访问,而且不能用于缓冲。 |
0xE000 0000到0xFFFF FFFF |
内核使用的虚拟地址。当内核需要大的虚拟地址空间时,会在这个范围内分配。 |
图1 Windows CE.NET内存结构
3、进程地址空间结构
进程地址空间结构如图2所示。这个图源至MSDN。Windows
CE.NET同以前版本的Windows CE操作系统在进程地址空间上有所不同,以前的Windows CE把XIP
DLL也加载到进程的32MB地址空间中,而Windows CE.NET把XIP DLL单独加载到Slot
1中,这样对于每个进程来说,它总的地址空间就大了一倍,也就是64MB。这个问题我在讲解进程的时候提到过。
当一个应用程序启动
时,内核为这个程序选择一个空闲的槽(Slot),并且加载所有的代码、资源,并分配堆栈,加载DLL等。当这个进程得到CPU使用权时,它的整个地址空
间被内核映射到Slot 0,也就是当前进程使用的地址空间,然后开始运行。图中给出的地址实际上是经过映射到Slot
0之后的结构。从图中可以看出,进程首先加载代码段,因为每个进程最低部64KB作为保留区域,所以代码段从0x0001
0000开始,内核为代码段分配足够的虚拟地址空间后,接着分配空间为只读数据和可读/可写数据,接着分配空间为资源数据,之后分配空间为默认堆和栈。非
XIP DLL从进程最高地址向下开始加载。非XIP
DLL的加载按如下规则:内核先检查要加载的DLL是否被其它进程加载过,如果加载过,就做一个地址的重定位。这样就避免了整个系统内多次加载相同
DLL。如果没有加载过,就按照从槽的高地址到槽的低地址的顺序查找空闲的地址空间。然后分配足够的地址空间用于加载DLL。因为每个进程在执行前都要映
射到Slot 0,而且进程使用的所有DLL可能来自不同的槽(Slot),为避免所有使用的DLL在映射到Slot
0中出现地址空间冲突的现象,内核的加载器(Loader)在加载DLL时会查找所有槽中加载的DLL的地址,保证在映射到Slot
0时不会发生地址冲突现象。假如系统内有两个进程,进程A只加载了DLL A,进程B需要加载DLL A和DLL B,那么进程B会留出DLL
A的地址空间,然后加载DLL B,也就是说进程B映射到Slot 0时,DLL A的地址空间和DLL
B的地址空间是相邻的,不会发生冲突。好在Windows CE下DLL都很小,而且一个应用程序使用的DLL多数是系统的DLL(存在于Slot
1)。所以目前来看进程的地址空间还够用。
图2 进程地址空间结构 |
4、堆和栈
堆是一段连续的较大的虚拟地址空间。应用程序在堆中可以动态地分配、释放所需大小的内存块。利用堆的优
点是在一定范围内减小了内存碎块。而且开发者分配内存块前不必去了解CPU的类型。因为不同的CPU分页大小不相同,每个内存页可能是1KB、4KB或更
多。在堆内分配内存块可以是任意大小的,而直接分配内存就必须以内存页为单位。当一个应用程序启动时,内核在进程所在的地址空间中为进程分配一个默认
192KB大小的虚拟地址空间,但是并不立刻提交物理内存。如果在运行当中192KB不能满足需求,那么内核会在进程地址空间中重新查找一个足够大小的空
闲的地址空间,然后复制原来堆的数据,最后释放原来的堆所占的地址空间。这是因为默认的堆的高地址处还有栈,所以必须重新分配一个。Windows
CE.NET的堆有明显的缺点,不同于其它Windows操作系统下的堆管理,在Windows
CE.NET创建的堆中创建的内存块不能够移动,多次创建内存块、释放内存块会产生内存碎块,这样的话当需要分配一个大一点的连续的内存块时,本来空闲的
内存块加起来足够用,但是这些内存块是分隔的,不符合要求。像Windows
2000或98的内核会频繁的移动分散的正使用的内存块,使它们聚集在一起。这也是为什么有时需要句柄而不用指针的原因。由于Windows
CE.NET的堆的缺点,开发者如果要频繁的在堆中创建、释放内存块的话,最好自己创建一个单独的堆,而不用默认的堆。而且我还建议最好直接在全局地址空
间中(0x4200 0000到0x7FFF
FFFF)分配所需地址空间。因为进程地址空间可用的实在太小了。关于堆函数我在这就不多说了,和其它Windows操作系统堆API基本一致。请参考帮
助文档。
栈也是一段连续的虚拟地址空间,和堆相比空间要小的多,它是专为函数使用的。当调用一个函数时(包括线程),内核会产生一个
默认的栈,并且内核会立刻提交少量的物理内存(也可以禁止内核立刻提交物理内存)。栈的大小和CPU有关,一般为64KB,并且保留顶部2KB为了防止溢
出。可以修改栈的大小,具体修改方法在讲解线程的时候已经说过了,这里就不再重复了。修改栈的大小一般时候不会发生,如果采用在编译链接时修改大小,那么
所有栈的大小都会改变,这不太合理。实际开发中最好不要在栈中分配很大、很多的内存块,如果分配的内存块超过了默认栈的限制,那么会引起访问非法并且内核
会立刻终止进程。最好在进程的堆中分配大的内存块并且在函数返回前释放,或者在创建线程时指定栈的大小。
5、内存映射文件
与虚拟内存一样,内存映射文件用来保留一个地址空间,并提交物理存储器。早期的内存映射文件并不是提交物理内存供调用者使用,而是提交永久存储器上的文
件数据。当然操作系统会为永久存储器保留一个读缓冲区,这样读取文件数据就快多了。内存映射文件的特点使它很适合于加载EXE或DLL文件。这样可以节省
内存又减少了加载所需时间。还可以使用它来映射大容量的文件,这样就不必在读取文件数据前设置很大的缓冲区。另外内存映射文件常用于进程间通信,也是进程
间通信的主要手段,其它进程之间通信机制都是基于内存映射文件来实现。为了更快的在进程之间通信,现在的内存映射文件也可以提交物理内存,这样内存映射文
件既可以提交物理内存又可以提交文件。
Windows
CE.NET同样支持无名和有名的内存映射文件。我建议在开发软件的过程中,如果需要读写大容量的文件,或者需要在不同进程内的线程之间通信,最好采用内
存映射文件,而且最好在全局地址空间内(0x4200 0000到0x7FFF FFFF)分配。这会使我们事半功倍。
5.1 映射数据文件
第一步:调用CreateFileForMapping函数。在Windows
CE.NET中推荐使用这个函数替代CreateFile函数。CreateFileForMapping函数由内核执行并创建文件,它也可以打开由
CreateFile函数创建的文件。其参数同CreateFile相似。参数1指定文件路径,注意文件路径的格式是没有盘符的,参数2指定访问方式(读
或写),参数3指定共享模式,参数4指定
安全属性(必须设置为NULL),参数5指定是创建还是打开文件,参数6指定文件属性,参数7忽略。具体参数细节参见Windows CE.NET帮助。函数返回创建或者打开的文件的句柄。
第二步:调用CreateFileMapping函数。这个函数创建一个无名的或者有名的内存映射文件对象。参数1为文件句柄。这个值由CreateFileForMapping函数返回。参数2为
安全属
性(必须设置为NULL),参数3指定要映射的文件的保护属性(只读或者读写),参数4和参数5共同用于指定要映射的文件的大小。文件的容量过大将导致
32位整数也不能表示,所以这里用64位变量表示,其中参数4为高32位数,参数5为低32位数。最后一个参数指定内存映射文件的名称。这里可以设置为
NULL,表示不需要名字。
第三步:调用MapViewOfFile函数。这个函数用于保留一段足够的地址空间,并且将永久存储器上
的文件数据映射到这个地址空间。映射后这段地址空间又叫做文件视图,映射范围可以是全部文件,也可以是部分文件。这里需要注意的是如果文件很大,那这个函
数将在全局地址空间内分配地址空间。参数1指定内存映射文件对象的句柄,这个值由CreateFileMapping函数返回。参数2和
CreateFileMapping函数中参数3很相似,都是用于限定访问权限。参数3和参数4共同用于指定映射区域的开始位置。其中参数3为高32位
数,参数4为低32位数。参数5指定映射区域的大小。需要注意的是参数3和参数4指定的64位数开始位置可以不是64KB的倍数。而其它Windows操
作系统就必须限制以64KB为单位。另外还要注意的是帮助文档中说不能保证一个文件的映射视图是连续的,并建议为了防止访问非法,应该加入结构化异常处理
机制。这个可能性我认为很小,一般对于大于2MB的虚拟地址空间的申请,内核都会在全局地址空间中分配。全局地址空间(0x4200
0000到0x7FFF FFFF)近1GB的空间应该足够用了。毕竟Windows
CE下的文件都很小。不过在代码中加入结构化异常处理也不是坏事。我们应该养成凡是读写文件数据时都加入结构化异常处理的习惯。
第四步:进行读/写操作。MapViewOfFile函数如果成功执行,那么返回映射视图的首地址。这时就可以把视图当成是一个缓冲区,开始读或写操作了。
第五步:执行结束工作。先调用UnmapViewOfFile函数撤销文件映射视图。参数只有一个,指定视图首地址。然后调用CloseHandle函数关闭内存映射文件对象,参数为句柄。最后再次调用CloseHandle函数,关闭打开的文件的句柄。
5.2 进程之间通信
进程之间有时需要通信。系统提供的进程之间的通信机制比如COM、剪贴板等,在底层实现上都是利用内存映射文件技术。其实进程之间通信的思路很简单,在
这里我顺便讲一下。在其它Windows操作系统中,每个进程独自占有4GB的地址空间,高2GB是内核的地址空间,而低2GB是进程的地址空间。一个进
程所能访问的所有低2GB地址都是自己的地址空间,当访问内核地址空间时就会受到内核的限制。这样一个进程当然无法访问其它进程了。为解决进程间通信的问
题,内存映射文件技术被利用作为解决方案。原来内存映射文件只映射类似磁盘一类的存储器上的文件。而为了更快速地在进程之间通信,内存映射文件还可以提交
物理内存。实现方法是通过访问同一个内存映射文件对象(映射到物理内存),两个进程或多个进程就能够访问到同一块物理内存,这样一个进程写到物理内存的数
据,其它进程就能够看到了。而Windows
CE虽然每个进程只占有32MB的地址空间,而且所有进程全部处于4GB的地址空间中,但是彼此还是不能够随意访问的。在Windows
CE下除了使用内存映射文件技术外,还有一种方法也很适合使用,就是利用对象存储。对象存储本身使用RAM文件系统,用普通的操作文件的API就可以创
建、读取存在于对象存储区域内的文件。\Windows
目录就存在于对象存储区域内。我们可以利用在\Windows目录下创建文件来实现进程间通信。这种方法既实现简单,只需调用几个文件API函数,又可以
减少通信时间,因为\Windows目录存在于物理内存中,数据I/O当然很快了。利用对象存储来实现进程之间的通信是我自己想出来的,MSDN或其它文
档并没有这方面的说明。需要注意的就是对象存储区域的大小。另外从实现的代码量上看也不如内存映射文件技术。
下面讲解如何利用内存映
射文件实现进程之间的通信。假设进程A和进程B需要通信,那么进程A需要先创建一个内存映射文件(之前不必调用CreateFileForMapping
函数来创建文件,因为不需要创建文件)。这个内存映射文件可以是在永久存储器中,也可以是在内存中。为了减小通信时间,最好提交物理内存。进程A在调用
CreateFileMapping函数时,参数1指定为INVALID_HANDLE_VALUE,这表示这个内存映射文件对象将要把物理内存提交到地
址空间中。最后一个参数一定要指定一个名字。进程B也同样调用CreateFileMapping函数,而且参数相同。内核会根据名字来判断是否已经存在
一个内存映射文件对象,如果创建了就返回原来的对象的句柄。接下去就不用细说了。参照5.1去执行就可以了。要注意的是进程B调用
CreateFileMapping函数后要按如下代码检验函数执行结果:
HANDLE hMap; hMap = CreateFileMapping(INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, 1000, L"abc"); if (hMap == NULL || GetLastError() != ERROR_ALREADY_EXISTS) { MessageBox(L"create file mapping fail"); return; } |
6、分配大的虚拟地址空间
可以用内存映射文件来分配大的虚拟地址空间。也可以直接调用VirtualAlloc函
数来分配。VirtualAlloc函数是最底层的分配虚拟地址空间的函数。它会在调用进程内分配符合条件的地址空间并且自动用0初始化提交的存储器。传
递一个你希望的虚拟地址空间的首地址给参数1(如果为0,那么内核自动查找一个符合条件的空间),参数2为大小(单位:字节),参数3为分配类型(提交还
是保留),参数4为保护标志(只读、读写、执行等)。函数返回分配的地址空间的首地址。在进程地址空间中每个分配的块有三种状态:可用、保留、提交。参数
3就是指明块的状态。我在做实验时发现,给参数1传递非0值均不成功,即使传递0给参数1让内核自动查找,得到的返回值再次用于参数1也不成功。释放这个
虚拟地址空间调用VirtualFree函数。VirtualFree函数参数1指定首地址,参数2指定大小,参数3指定释放类型(撤销提交、释放)。函
数成功返回真,失败返回假。参数3有两个标志,并且不能复合。当指定撤销提交标志(MEM_DECOMMIT)时,函数将取消这个虚拟地址空间的物理内存
的映射,但是保留这块虚拟地址空间。如果这个虚拟地址空间没有提交函数也不会失败返回。当指定释放标志(MEM_RELEASE)时,如果这块虚拟地址空
间含有同样的标志(保留或者提交)。函数将释放这块虚拟地址空间。如果这个虚拟地址空间有一部分提交了,其它部分没有提交,那么必须先调用此函数,并传递
撤销提交标志,先将提交的这部分取消物理内存映射。然后再次调用此函数,传递释放标志。这样整个虚拟地址空间就都能够释放了。关于虚拟地址空间还有其它函
数,比如VirtualQuery、VirtualProtect。在这里就不介绍了,请参见Windows CE.NET帮助。
作者注:
《进程、线程和内存管理》讲解的内容是我根据以前在PC机Windows操作系统中掌握的相关知识,又查看了Windows
CE.NET的帮助文档和MSDN中Technical Articles和knowledge Base而得出的结论。遗憾的是Windows
CE.NET的帮助文档介绍的太简单,我只能把掌握的知识和查看到的知识相结合,另外我还做了一些实验。我感谢浏览此文章的各位Windows
CE下开发者,如果你们认为有哪些地方说的不正确的,希望指出来让我改正错误。让更多的人看到的是准确无误的文章。
付林林:毕业时间:2001年,专业:计算机。从毕业起一直从事软件开发工作。目前从事 Windows CE 下操作系统内核定制和应用程序开发。在两年的时间里积累了CE下开发的一点点经验。希望和 CE 下开发者交流、探讨,更希望你们能不吝赐教。
阅读(1817) | 评论(0) | 转发(0) |