分享

Windows CE 进程、线程和内存管理

 沧海九粟 2007-09-28
进程、线程、内存管理是一个内核最基本的服务,也是一个内核最主要的组成部分。这几方面的知识是一个软件开发人员必须掌握的基础知识。虽然一个人不懂这些知识也能编写简单的程序,但这样的程序只能算是皮毛。掌握了进程、线程和内存管理方面的知识,就能够充分利用操作系统内核提供的服务,提高你编写的软件的执行效率、更节省资源、更健壮。顺便说一下,在Windows CE.net下可以运行用Visual Studio.net开发的.net平台上运行的软件,但这样的软件是最上层的软件,离操作系统内核太远了。不但执行效率相对较低,而且还要把.net 框架加到内核中。所以在大多数情况下,EVC仍然是第一选择。


图一 CE内核结构

一、进程和线程

1、概念:
  Windows CE.NET是一个抢占多任务操作系统,抢占多任务又被称为调度。在调度过程中,内核的调度系统包含一个当前所有进程中线程的优先级列表,并对所有的线程按优先级排列顺序。当中断发生时,调度系统重新安排所有线程的排列顺序。
  一个进程是一个正运行的应用程序的实例。它由两个部分组成:一个是操作系统用来管理这个进程的内核对象。另一个是这个进程拥有的地址空间。这个地址空间包含应用程序的代码段、静态数据段、堆、栈,非XIP(Execute In Place)DLL。从执行角度方面看,一个进程由一个或多个线程组成。一个线程是一个执行单元,它控制CPU执行进程中某一段代码段。一个线程可以访问这个进程中所有的地址空间和资源。一个进程最少包括一个线程来执行代码,这个线程又叫做主线程。

2、进程:
  Windows CE.NET最多支持32个进程同时运行。这是由整个系统分配给所有进程的总地址空间决定的。低于Windows CE 4.0版本(也就是低于.NET的版本)的CE操作系统,总进程空间从0x0000 0000到0x4200 0000 ,每32MB地址空间为一个槽(Slot),共33个槽。当一个进程启动时,内核选择一个没有被占用的槽作为这个进程的地址空间。其中0x0000 0000到0x01FF FFFF这个槽称为Slot 0。每个进程在即将得到CPU控制权时,将整个地址映射到Slot 0。这个进程在帮助文档中称为当前运行进程(currently running process)。分配一个槽后,内核在这个槽内按由低地址到高地址顺序为代码段、静态数据段分配足够的地址空间,然后是堆、栈,栈之后的空间为所有 DLL保留,包括XIP和非XIP DLL。注意Slot 0最底部64KB是永远保留的。从Slot 1 到 Slot32 为进程使用。前几个槽一般为系统程序使用。如filesys.exe、device.exe、gwes.exe等。
  Windows CE.NET与低版本操作系统略有不同。这一点是从MSDN 的"Technical Articles"和"Knowledge Base"的文章中找到的,这的确让我费了一番功夫。在Windows CE.NET的帮助文件中只能找到和早期版本相同的说法,而"Technical Articles"和"Knowledge Base"中有几篇文章清楚的说明了Windows CE.NET 和低版本操作系统的不同。在低版本操作系统中,的确如上所说分为33个槽,Slot 0用于当前运行进程,共支持32个进程同时运行。而且所有DLL都加载到进程的地址空间。但Windows CE.NET下 Slot 1也用于当前进程(Slot 1只用于加载所有XIP DLL)。那么一个进程就不是占有32MB地址空间了,而是64MB。在讲解内存管理时我会具体讲解。
创建一个进程的API函数如下:
BOOL  CreateProcess(LPCTSTR  lpApplicationName, LPTSTR  lpCommandLine,
LPSECURITY_ATTRIBUTES  lpProcessAttributes,
LPSECURITY_ATTRIBUTES  lpThreadAttributes,
BOOL  bInheritHandles,
DWORD  dwCreationFlags,
LPVOID  lpEnvironment,
LPCTSTR  lpCurrentDirectory,
LPSTARTUPINFO  lpStartupInfo,
LPPROCESS_INFORMATION  lpProcessInformation );
  Windows CE.NET不支持安全性、当前目录、继承性,所以这个函数很多参数都必须设为0或FALSE。具体第3、4、7、8、9设为0,第5设为FALSE。第 1参数为应用程序名称,这个参数不能为NULL。如果只传递应用程序名称而没有指定路径,那么系统将先搜索\Windows目录,接着搜索OEM指定的搜索路径。第2参数用于传递启动参数,必须为UNICODE码。第6参数为创建标志。可以为0(创建一个常规进程)、CREATE_SUSPENDED(启动后挂起)、DEBUG_PROCESS(用于创建这个进程的父进程调试用)、DEBUG_ONLY_THIS_PROCESS(不调试子进程)、 CREATE_NEW_CONSOLE(控制台进程)。第10参数传递给它一个PROCESS_INFORMATION结构变量的地址。返回进程和主线程的句柄和ID。
  终止一个进程最好是由WinMain函数返回。在主线程中调用ExitThread函数也可以。在当前进程终止另一个进程使用 TerminateProcess函数。CE下的TerminateProcess函数要比其他Windows下TerminateProcess函数功能强大。CE下的TerminateProcess函数在使进程退出时,会通知每个加载的DLL并做出进程退出时该做的所有处理工作。

3、线程:
  线程除了能够访问进程的资源外,每个线程还拥有自己的栈。栈的大小是可以调整的,最小为1KB或4KB(也就是一个内存页。内存页的大小取决于 CPU),一般默认为64KB,但栈顶端永远保留2KB为防止溢出。如果要改变栈初始时大小,在EVC"Project"-"Settings"- "Link"链接选项"/STACK"后的参数中指定大小。其中参数1为默认大小,参数2为一个内存页大小,都用十六进制表示。如果将栈的初始值设置太小,很容易导致系统访问非法并立即终止进程。
  线程有五中状态,分别为运行、挂起、睡眠、阻塞、终止。当所有线程全部处于阻塞状态时,内核处于空闲模式(Idle mode),这时对CPU的电力供应将减小。
创建一个线程的API函数如下:
HANDLE  CreateThread(LPSECURITY_ATTRIBUTES  lpThreadAttributes,
DWORD  dwStackSize,
LPTHREAD_START_ROUTINE  lpStartAddress,
LPVOID  lpParameter,
DWORD  dwCreationFlags,
LPDWORD  lpThreadId );      
Windows CE.NET 不支持安全性所以参数1必须设置为0。如果参数5为STACK_SIZE_PARAM_IS_A_RESERVATION,那么参数2可以指定栈的大小,内核将按照参数2的数值来为此线程拥有的栈保留地址空间。如果参数5不为STACK_SIZE_PARAM_IS_A_RESERVATION,那么参数 2必须设置为0。参数3为执行路径的首地址,也就是函数的地址。参数4用来向线程中传递一个参数。参数5除了上面说明外,还可以为0、 CREATE_SUSPENDED。CREATE_SUSPENDED表示这个线程在创建后一直处于挂起状态,直到用ResumeThread函数来恢复。最后一个参数保存函数返回的创建的线程ID。
  退出一个线程同退出一个进程有类似的方法。最好是由函数返回,在线程中调用 ExitThead函数也可以。在当前线程中终止另一个线程使用TerminateThread函数。此函数在使一个线程退出时,会通知这个线程加载的所有DLL。这样DLL就可以做结束工作了。
  Windows CE.NET不像其他Windows操作系统将进程分为不同的优先级类,Windows CE.NET只将线程分为256个优先级。0优先级最高,255最低,0到248优先级属于实时性优先级。0到247优先级一般分配给实时性应用程序、驱动程序、系统程序。249到255优先级中,251优先级(THREAD_PRIORITY_NORMAL)是正常优先级。255优先级(THREAD_PRIORITY_IDLE)为空闲优先级。249优先级(THREAD_PRIORITY_HIGHEST)是高优先级。248到 255优先级一般分配给普通应用程序线程使用。具体分段见下表:
 
优先级范围 分配对象
0-96 高于驱动程序的程序
97-152 基于Windows CE的驱动程序
153-247 低于驱动程序的程序
248-255 普通的应用程序

  Windows CE.NET操作系统具有实时性,所以调度系统必须保证高优先级线程先运行,低优先级线程在高优先级线程终止后或者阻塞时才能得到CPU时间片。而且一旦发生中断,内核会暂停低优先级线程的运行,让高优先级线程继续运行,直到终止或者阻塞。具有相同优先级的线程平均占有CPU时间片,当一个线程使用完了 CPU时间片或在时间片内阻塞、睡眠,那么其他相同优先级的线程会占有时间片。这里提到的CPU时间片是指内核限制线程占有CPU的时间,默认为 100ms。OEM可以更改这个值,甚至设置为0。如果为0,当前线程将一直占有CPU,直到更高优先级线程要求占有CPU。这个调度算法好像是很有效、很完美,但却存在着一种情况,当这种情况发生时程序会死锁。举例来说:一个应用程序包含两个线程,线程1是高优先级,线程2是低优先级,当线程1运行过程中处于阻塞时,线程2得到时间片,线程2这次进入了一个临界区,我们都知道临界区内的资源是不会被其它线程访问的,当线程2正运行时,线程1已经从阻塞状态转变为运行状态,而这次线程1却要访问线程2的资源,这个资源却被临界区锁定,那么线程1只能等待,等待线程2从临界区中运行结束并释放资源的独占权。但是线程2却永远不会得到时间片,因为CE保证高优先级线程会先运行。这时程序就会处于死锁状态。当然系统不会死锁,因为还有更高优先级的线程、驱动程序在运行。对于这种情况,CE采取优先级转换的办法来解决。就是当发生这种情况时,内核将线程2的优先级提高到线程1的优先级水平。这样线程2就可以执行完临界区代码了,线程1也就能够访问资源了。然后内核再恢复线程2原来的优先级。
  挂起一个线程使用SuspendThread函数。参数只有一个――线程的句柄。要说明的是如果要挂起的线程正调用一个内核功能,这时执行此函数可能会失败。需要多次调用此函数直到函数返回值不为 0xFFFFFFFF,说明挂起成功。恢复线程使用ResumeThread函数。参数也只有一个――线程的句柄。
  关于线程本地存储器和纤程,实际用到的时候非常少,这部分知识可以参考《Windows核心编程》。

二、同步

  在多数情况下,线程之间难免要相互通信、相互协调才能完成任务。比如,当有多个线程共同访问同一个资源时,就必须保证一个线程正读取这个资源数据的时候,其它线程不能够修改它。这就需要线程之间相互通信,了解对方的行为。再有当一个线程要准备执行下一个任务之前,它必须等待另一个线程终止才能运行,这也需要彼此相互通信。实际开发过程中,线程间需要同步的情况非常多。Windows CE.NET给我们提供了很多的同步机制,熟练的掌握这些机制并合理运用会使线程之间的同步更合理、更高效。进程间的通信机制在下一篇文章中讲解。
  Windows CE.NET具有两种运行模式:用户模式和内核模式。并且允许一个运行于用户模式的应用程序随时切换为内核模式,或切换回来。线程同步的有些解决办法运行在用户模式,有些运行在内核模式。《Windows核心编程》上说从用户模式切换到内核模式再切换回来至少要1000个CPU周期。我查看过CE下API 函数SetKMode的源码,这个函数用于在两种模式间切换,改变模式只需修改一些标志,至于需要多少个CPU周期很难确定。但至少可以肯定来回切换是需要一定时间的。所以在选择同步机制上应该优先考虑运行在用户模式的同步解决办法。

1、互锁函数
  互锁函数运行在用户模式。它能保证当一个线程访问一个变量时,其它线程无法访问此变量,以确保变量值的唯一性。这种访问方式被称为原子访问。互锁函数及其功能见如下列表:
 
函数 参数和功能
InterlockedIncrement 参数为PLONG类型。此函数使一个LONG变量增1
InterlockedDecrement 参数为PLONG类型。此函数使一个LONG变量减1
InterlockedExchangeAdd 参数1为PLONG类型,参数2为LONG类型。此函数将参数2赋给参数1指向的值
InterlockedExchange 参数1为PLONG类型,参数2为LONG类型。此函数将参数2的值赋给参数1指向的值
InterlockedExchangePointer 参数为PVOID* 类型,参数2为PVOID类型。此函数功能同上。具体参见帮助
InterlockedCompareExchange 参数1为PLONG类型,参数2为LONG类型,参数3为LONG类型。此函数将参数1指向的值与参数3比较,相同则把参数2的值赋给参数1指向的值。不相同则不变
InterlockedCompareExchangePointer 参数1为PVOID* 类型,参数2为PVOID类型,参数3为PVOID。此函数功能同上。具体参见帮助

2、临界区
  临界区对象运行在用户模式。它能保证在临界区内所有被访问的资源不被其它线程访问,直到当前线程执行完临界区代码。除了API外,MFC也对临界区函数进行了封装。临界区相关函数:

void  InitializeCriticalSection ( LPCRITICAL_SECTION );
void  EnterCriticalSection ( LPCRITICAL_SECTION );
void  LeaveCriticalSection ( LPCRITICAL_SECTION );
void  DeleteCriticalSection ( LPCRITICAL_SECTION );      
举例如下:
void CriticalSectionExample (void)
{
CRITICAL_SECTION  csMyCriticalSection;
InitializeCriticalSection (&csMyCriticalSection);  ///初始化临界区变量
__try
{
EnterCriticalSection (&csMyCriticalSection);   ///开始保护机制
///此处编写代码
}
__finally   ///异常处理,无论是否异常都执行此段代码
{
LeaveCriticalSection (&csMyCriticalSection);  ///撤销保护机制
}
}
MFC类使用更简单:
CCriticalSection  cs;
cs.Lock();
///编写代码
cs.Unlock();      
  使用临界区要注意的是避免死锁。当有两个线程,每个线程都有临界区,而且临界区保护的资源有相同的时候,这时就要在编写代码时多加考虑。

3、事件对象
  事件对象运行在内核模式。与用户模式不同,内核模式下线程利用等待函数来等待所需要的事件、信号,这个等待过程由操作系统内核来完成,而线程处于睡眠状态,当接收到信号后,内核恢复线程的运行。内核模式的优点是线程在等待过程中并不浪费CPU时间,缺点是从用户模式切换到内核模式需要一定的时间,而且还要切换回来。在讲解事件对象前应该先谈谈等待函数。等待函数有四个。具体参数和功能见下表:

 
函数 参数和功能
WaitForSingleObject 参数1为HANDLE类型,参数2为DWORD类型。此函数等待参数1标识的事件,等待时间为参数2的值,单位ms。如果不超时,当事件成为有信号状态时,线程唤醒继续运行。
WaitForMultipleObjects 参数1为DWORD类型,参数2为HANDLE * 类型,参数3为BOOL类型,参数4为DWORD类型。此函数等待参数2指向的数组中包含的所有事件。如果不超时,当参数3为FALSE时,只要有一个事件处于有信号状态,函数就返回这个事件的索引。参数3为TRUE时,等待所有事件都处于有信号状态时才返回。
MsgWaitForMultipleObjects 参数1为DWORD类型,参数2为LPHANDLE类型,参数3为BOOL类型,参数4为DWORD类型,参数5为 DWORD类型。此函数功能上同WaitForMultipleObjects函数相似,只是多了一个唤醒掩码。唤醒掩码都是和消息有关的。此函数不但能够为事件等待,还能为特定的消息等待。其实这个函数就是专为等待消息而定义的。
MsgWaitForMultipleObjectsEx 参数1为DWORD类型,参数2为LPHANDLE类型,参数3为DWORD类型,参数4为DWORD类型,参数5为 DWORD类型。此函数是MsgWaitForMultipleObjects函数的扩展。将原来函数的参数3除掉,添加参数5为标志。标志有两个值:0 或MWMO_INPUTAVAILABLE。

  如果一个线程既要执行大量任务同时又要响应用户的按键消息,这两个专用于等待消息的函数将非常有用。

和事件有关的函数有:

HANDLE  CreateEvent(LPSECURITY_ATTRIBUTES  lpEventAttributes,
BOOL bManualReset,
BOOL  bInitialState,
LPTSTR  lpName);
BOOL  SetEvent(HANDLE  hEvent );
BOOL  PulseEvent(HANDLE  hEvent);
BOOL  ResetEvent(HANDLE  hEvent);
HANDLE  OpenEvent(DWORD  dwDesiredAccess,
BOOL  bInheritHandle,
LPCTSTR  lpName );
  事件对象是最常用的内核模式同步方法。它包含一个使用计数和两个BOOL变量。其中一个BOOL变量指定这个事件对象是自动重置还是手工重置。另一个BOOL变量指定当前事件对象处于有信号状态还是无信号状态。
  函数CreateEvent创建一个事件对象,参数1必须为NULL,参数2指定是否手工重新设置事件对象的状态。如果为FALSE,当等待函数接到信号并返回后此事件对象被自动置为无信号状态。这时等待此事件对象的其它线程就不会被唤醒,因为事件对象已经被置为无信号状态。如果参数2设置为TRUE,当等待函数接到信号并返回后事件对象不会被自动置于无信号状态,其它等待此事件对象的线程都能够被唤醒。用ResetEvent函数可以手工将事件对象置为无信号状态。相反SetEvent函数将事件对象置为有信号状态。PulseEvent函数将事件对象置为有信号状态,然后立即置为无信号状态,在实际开发中这个函数很少使用。OpenEvent函数打开已经创建的事件对象,一般用于不同进程内的线程同步。在调用CreateEvent创建一个事件对象时,传递一个名字给参数4,这样在其它进程中的线程就可以调用OpenEvent函数并指定事件对象的名字,来访问这个事件对象。

4、互斥对象
  互斥对象运行在内核模式。它的行为特性同临界区非常相似,在一个线程访问某个共享资源时,它能够保证其它线程不能访问这个资源。不同的是,互斥对象运行在内核模式,从时间上比临界区要慢。由于内核对象具有全局性,不同的进程都能够访问,这样利用互斥对象就可以让不同的进程中的线程互斥访问一个共享资源。而临界区只能在一个进程内有效。

和互斥相关的函数有:
HANDLE CreateMutex(LPSECURITY_ATTRIBUTES  lpMutexAttributes,
BOOL  bInitialOwner,
LPCTSTR  lpName);
BOOL  ReleaseMutex(HANDLE  hMutex);      
  互斥对象包含一个引用计数,一个线程ID和一个递归计数。引用计数是所有内核对象都含有的。线程ID表示哪个线程正在使用互斥资源,当ID为0时,互斥对象发出信号。递归计数用于一个线程多次等待同一个互斥对象。函数CreateMutex创建一个互斥对象,参数1必须设置为NULL,参数2如果设置为 FALSE,表示当前线程并不占有互斥资源,互斥对象的线程ID和递归计数都被设置为0,互斥对象处于有信号状态。如果设置为TRUE,表示当前线程将占有互斥资源,互斥对象的线程ID被设置为当前线程ID,递归计数被设置为1,互斥对象处于无信号状态。当调用等待函数时,等待函数检验互斥对象的线程ID 是否为0,如果为0,说明当前没有线程访问互斥资源,内核将线程唤醒,并且将互斥对象的递归计数加1。当一个线程被唤醒后,必须调用函数 ReleaseMutex将互斥对象的递归计数减1。如果一个线程多次调用等待函数,就必须以同样的次数调用ReleaseMutex函数。与其它 Windows不同的是,和互斥相关的函数中没有OpenMutex函数。要在不同进程中访问同一互斥对象,调用CreateMutex函数,参数传递互斥对象的名称,返回这个互斥对象的句柄。

5、信标对象
  信标对象,也叫信号灯,用于限制资源访问数量,他包含一个引用计数,一个当前可用资源数,一个最大可用资源数。如果当前可用资源数大于0,信标对象处于有信号状态。当可用资源数等于0,信标对象处于无信号状态。

和信标对象相关的函数:
HANDLE  CreateSemaphore(LPSECURITY_ATTRIBUTES  lpSemaphoreAttributes,
LONG  lInitialCount,
LONG  lMaximumCount,
LPCTSTR  lpName);
BOOL  ReleaseSemaphore(HANDLE  hSemaphore,
LONG  lReleaseCount,
LPLONG  lpPreviousCount);
  函数CreateSemaphore的参数1为NULL,参数2为当前可用资源初始值,参数3为最大可用资源数,参数4为名字。当参数2的值等于0时,信标对象处于无信号状态,这时内核将调用等待函数的线程置于睡眠状态,如果参数2的值大于0,信标对象处于有信号状态,这时内核将调用等待函数的线程置于运行状态,并将信标对象的当前可用资源数减1。函数ReleaseSemaphore的参数1为信标对象的句柄,参数2为要释放的资源数,参数3返回原来可用资源数,调用此函数将当前可用资源数加上参数2的值。当一个线程访问完可用资源后,应该调用ReleaseSemaphore函数使当前可用资源数递增。要在不同进程中访问同一信标对象,调用CreateSemaphore函数并传递信标对象的名称,得到已经在其它进程创建的信标对象的句柄。CE下没有OpenSemaphore函数。另外我还要说明一点,等待函数默认将信标对象的当前可用资源数减1,但线程可能一次使用多个资源,这就可能出现问题了。为避免问题出现,应该遵守一个线程只使用一个资源的原则。

6、消息队列
  Windows CE.NET允许一个应用程序或驱动程序创建自己的消息队列。消息队列既可以作为在线程之间传递数据的工具,也可以作为线程之间同步的工具。它的优点是需要很小的内存,一般只用于点到点的通信。

和消息队列相关的函数:
HANDLE  WINAPI  CreateMsgQueue(LPCWSTR  lpszName,
LPMSGQUEUEOPTIONS  lpOptions);
BOOL  WINAPI  CloseMsgQueue(HANDLE  hMsgQ);
BOOL  GetMsgQueueInfo(HANDLE  hMsgQ,
LPMSGQUEUEINFO  lpInfo);
HANDLE  WINAPI  OpenMsgQueue(HANDLE  hSrcProc,
HANDLE  hMsgQ,
LPMSGQUEUEOPTIONS  lpOptions);
BOOL  ReadMsgQueue(HANDLE  hMsgQ,
LPVOID  lpBuffer,
DWORD  cbBufferSize,
LPDWORD  lpNumberOfBytesRead,
DWORD  dwTimeout,
DWORD  *pdwFlags);
BOOL  WINAPI  WriteMsgQueue(HANDLE  hMsgQ,
LPVOID  lpBuffer,
DWORD  cbDataSize,
DWORD  dwTimeout,
DWORD  dwFlags);      
  使用CreateMsgQueue函数创建一个消息队列,传递一个MSGQUEUEOPTIONS结构指针。在这个结构中设置标志(允许队列缓冲区动态改变大小,允许直接读或者写操作而不管之前是否有过写操作或读操作)、队列允许的最大消息数、队列属性(只读或者只写)。使用WriteMsgQueue 函数把一个消息写入到消息队列中。传递一个消息队列的缓冲区、消息数据的大小、写入缓冲区的超时值、标志。使用ReadMsgQueue函数把一个消息从消息队列中读出。使用CloseMsgQueue函数关闭消息队列缓冲区。使用OpenMsgQueue函数能够打开其它进程中创建的消息队列。另外可以用等待函数等待消息队列的变化。当消息队列由没有消息到有消息时,或由满消息到不满消息时唤醒调用等待函数的线程。关于消息队列我并没有实验过,MSDN 上有几个简单的例子。
 
三、内存管理

  同其它Windows操作系统一样,Windows CE.NET也支持32位虚拟内存机制、按需分配内存和内存映射文件等。但是与其它Windows操作系统又有明显的不同。毕竟Windows CE是一种嵌入式实时性的操作系统,在内存管理方面必须要比其它Windows操作系统更节约物理内存和虚拟地址空间。在内存管理API方面,为了便于移植程序,Windows CE和其它Windows操作系统函数声明基本一致,这使一个在其它Windows下开发的程序员可以直接使用早就熟悉的API函数,但是CE下内存管理的原理开发者还是应该熟悉的。

1、ROM和RAM
  最早的基于Windows CE的民用产品,采用的存储设备都是ROM + RAM ,ROM保存CE内核文件、应用程序,而RAM用于内核、所有应用程序运行时使用,关闭电源时必须给RAM提供电力来保存系统配置信息、用户产生的文件等。为了适应这样的存储硬件,CE采用了ROM文件系统和RAM文件系统。在ROM中存放的模块可以是压缩的,也可以是不压缩的,这取决于 OEM。OEM在定制内核时可以设置是否压缩模块。如果是压缩的,模块在运行前先解压并全部存放到RAM中。如果是不压缩的,就本地执行(XIP, executed in place)。本地执行和其它Windows操作系统下执行应用程序、DLL方式一致,也就是应用了内存映射文件技术。在这里我顺便讲一下。在启动时应用程序或DLL的代码段不加载到物理内存中,内核只是分配虚拟地址空间给代码段,当执行代码时内核会到实际存放在硬盘上的文件中寻找代码并执行。采用这样的技术既可以节省可用内存又可以减少加载的时间。请注意,操作系统首先会到为硬盘准备的缓冲区里读取代码数据,如果没有就命令硬盘读取应用程序文件数据到缓冲区。所以缓冲区设置大点是有好处的。Windows CE的本地执行就是采用这样的技术来加载ROM内的应用程序和DLL的。所以Windows CE的DLL分为XIP DLL和非XIP DLL。这种加载方式的缺点就是执行相对较慢一点,如果用PB创建一个具有实时性特点的内核,一定不能选用XIP技术。
  到后来基于 Windows CE的产品开始采用FLASH、IDE等永久存储设备时,文件系统又加了个FAT。内核文件和其它应用程序也可以存放到永久存储设备中,内核由加载程序解压并加载到RAM的对象存储区域(object store),包含在内核中的所有系统应用程序文件和DLL文件都存放到这个区域。当执行一个应用程序时,内核将这个应用程序调用的系统DLL加载到 Slot 1(0x0200 0000-0x03FF FFFF)。在Windows CE.NET中Slot 1专用于XIP DLL使用。
  RAM文件系统专用于对象存储。在以前的文章中曾经讲过,它和ROM文件系统是Windows CE默认的文件系统。Windows CE启动后把RAM分为对象存储区域(object store)和应用程序内存区域(program memory)。对象存储区域采用RAM文件系统来保存文件,一般用于保存内核解开的所有文件。应用程序内存区域留给所有应用程序运行时使用。在 Windows CE下"控制面板"-"系统"-"内存"中,可以调节这两个存储区域的比例,滑块向左,则释放对象存储区域的一些内存并将这些内存划到应用程序内存区域中。滑块向右则相反。

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下开发者,如果你们认为有哪些地方说的不正确的,希望指出来让我改正错误。让更多的人看到的是准确无误的文章。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多