第一篇 理论篇
1、进程的虚拟地址空间
1.1 虚拟地址空间分区
在Windows系统中,每个进程都被赋予它自己的虚拟地址空间,对于32位进程来说,这个地址空间为4GB(即0x00000000~0xFFFFFFFF)。当进程的一个线程正在运行时,该线程可以访问只属于它的进程的内存;属于所有其他进程的内存则隐藏着,并且不能被正在运行的线程访问。对于Window2000以上的操作系统,属于操作系统本身的内存也是隐藏的,不能被正在运行的用户线程访问(Windows98则可以,所以98很容易被搞死机,^-^)。
进程的4GB虚拟地址空间被划分为若干分区,不同的操作系统,其分区略有不同,以下内容适用于Windows2000及以上系统:
n NULL指针分配的分区
0x00000000~0x0000FFFF (64K)
这个分区的设置是为了帮助程序员掌握NULL指针的分配情况,如果你的进程中的线程试图读取该分区的地址空间的数据,或者将数据写入该分区的地址空间,那么CPU就会引发一个访问违规(0xC0000005)。保护这个分区是及其有用的,它可以帮助你发现NULL指针的分配情况。
n 用户方式分区
0x00010000~0x7FFEFFFF (3GB方式:0x00010000~0xBFFEFFF)
这个分区是进程的私有(非共享)地址空间所在的地方,一个进程不能读取、写入、或者以任何方式访问驻留在该分区的另一个进程的数据。对于所有应用程序来说,该分区是维护进程的大部分数据的地方。由于每个进程可以得到它自己的私有的、非共享分区,以便存放它的数据,因此,应用程序不太可能被其他的应用程序所破坏,这使得系统更加健壮。
在Windows2000中,所有的EXE和DLL模块均加载在该分区,每个进程可以将这些DLL加载到该分区的不同地址中,系统还可以在这个分区中映射该进程可以访问的所有内存映射文件。
n 64KB禁止进入的分区
0x7FFF000~0x7FFFFFFF (3GB方式:0x-xBFFF0000~0xBFFFFFFF)
这个位于用户方式分区上面的64KB分区是禁止进入的,访问该分区中的内存的任何企图均将导致访问违规。微软之所以保留该分区,是为了便于操作系统检测用户进程对内核方式分区(0x80000000以上区域)的任何访问企图。
n 内核方式分区
0x8000000~0xFFFFFFFF (3GB方式:0xC0000000~0xFFFFFFFF)
这个分区是存放操作系统代码的地方,用于线程调度、内存管理、文件系统支持、网络支持和所有设备驱动程序的代码全部都在这个分区加载。驻留在这个分区中的一切均可被所有进程共享。在Windows2000中,这些组件是完全受到保护的。如果你试图访问该分区中的内存地址,你的线程将会产生访问违规。
1.2 地址空间中的区域
当进程被创建并被赋予它的地址空间时,该可用地址空间的主体是空闲的(即未分配的);若要使用该地址空间的各个部分,必须通过调用VirtualAlloc函数来分配它里边的各个区域。对一个地址空间的区域进行分配的操作称为保留(reserving)。
每当你保留地址空间的一个区域时,系统要确保该区域从一个分配粒度的边界开始,对于x86平台来说,这个分配粒度是64K(可用GetSystemInfo函数查询该值)。当你保留地址空间的一个区域时,系统还要确保该区域的大小是系统的页面大小的倍数。页面是操作系统在管理内存时使用的一个内存单位,对于目前的x86平台,页面大小为4K(可用GetSystemInfo函数查询该值)。
在程序不再需要访问已经保留的地址空间区域时,该区域应该被释放,这个过程称为释放地址空间的区域,通过调用VirtualFree函数来完成的。
若有使用已保留的地址空间区域,必须分配物理存储器,然后将该物理存储器映射到已保留的地址空间区域。这个过程称为提交物理存储器。物理存储器总是以页面的形式来提交的,若要将物理存储器提交给一个已保留的地址空间区域,也要调用VirtualAlloc函数。当将物理存储器提交给地址空间区域时,不必将物理存储器提交给整个区域(可以只提交某些页)。
当不再需要访问保留的地址空间区域中已提交的物理存储器时,该物理存储器应该被释放,这个过程称为回收物理存储器,它也是通过VirtualFree来完成的。
在这里我们所指的物理存储器,通常是指的分页文件,因为在调用VirtualAlloc函数提交物理存储器的时候,操作系统只是在分页文件中为请求分配一块区域。然后操作系统会根据后续的访问请求,动态的将要访问的页数据交换到内存中。所以,通常的物理存储器是由分页文件来维护的。
但是也有另外一种情况,物理存储器并是分页文件,这是模块加载的时候所发生的事情。当启动一个应用程序的时候,系统将打开应用程序的EXE文件,确定该应用程序的代码和数据的大小,然后系统要保留一个地址空间的区域,并指明与该区域相关联的物理存储器是在EXE文件本身中。即系统并不是从页文件中分配地址空间,而是将EXE文件的实际内容(即程序映象)用作程序的保留地址空间区域。这使得应用程序的加载非常迅速,并使页文件能够保持得非常小。
当硬盘上的一个程序的文件映象(EXE或DLL文件)用作地址空间区域的物理存储器时,它称为内存映射文件。当一个EXE或DLL文件被加载时,系统将自动保留一个地址空间的区域,并将该文件映象映射到该区域中。
1.3 确定地址空间的状态
查询地址空间中内存地址的某些信息(如大小、存储器类型、保护属性等),可以使用VirtualQuery函数;此外Windows还提供了另外一个函数,它使一个进程能够查询另外一个进程的内存信息,这个函数是VirtualQueryEx(调试程序经常使用该函数)。
VirtualQuery的函数原型是:
SIZE_T VirtualQuery(
LPCVOID lpAddress, 待查询的内存地址
PMEMORY_BASIC_INFORMATION lpBuffer, 用于保存返回信息的缓冲区
SIZE_T dwLength 指明缓冲区的长度
);
其中,第二个参数为内存信息结构,其定义如下:
typedef struct _MEMORY_BASIC_INFORMATION {
PVOID BaseAddress; 内存区域页的基址,该值是lpAddress圆整为页边界的值,
PVOID AllocationBase; 调用VirtualAlloc函数分配区域地址空间时的起始地址。
DWORD AllocationProtect; 分配时设置的地址包含属性。
SIZE_T RegionSize; 区域大小(字节数)。
DWORD State; 内存状态,MEM_COMMIT/MEM_FREE/MEM_RESERVE
DWORD Protect; 区域的访问属性。
DWORD Type; 区域的类型,MEM_IMAGE/MEM_MAPPED/MEM_PRIVATE
} MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;
2 进程和线程
2.1 进程
进程通常被定义为一个正在运行的程序的实例组成,它由两部分组成:
n 操作系统用来管理进程的内核对象:该内核对象也是操作系统用来存放关于进程的统计信息的地方;
n 地址空间:它包含所有可执行模块或动态库模块的代码和数据,它还包含动态内存分配的空间,如线程堆栈和堆分配空间。
进程是不活泼的,若要使进程完成某项操作,它必须拥有一个在它的环境中运行的线程,该线程负责执行包含在进程的地址空间中的代码。当创建一个进程时,系统会自动创建它的第一个线程,称为主线程,然后该线程可以创建其他的线程。每个线程都有它自己的一组CPU寄存器(称为线程的上下文,Context)和它自己的堆栈。
当调用CreateProcess创建进程时,系统将执行下列操作:
n 系统找出调用CreateProcess时设定的EXE文件,如果无法找到,则返回FALSE;
n 系统创建一个新进程内核对象;
n 系统为这个新进程创建一个私有地址空间;
n 系统保留一个足够大的地址空间区域,用于存放该EXE文件。该区域需要的位置在EXE文件本身中设定,按照默认设置,EXE文件的基地址为0x00400000。可以在创建应用程序EXE文件时覆盖这个地址,方式是使用链接程序的/BASE选项;
n 系统注意到支持已保留区域的物理存储器是在磁盘上的EXE文件中,而不是系统的分页文件中。
当EXE文件被映射到进程的地址空间中之后,系统将访问EXE文件的一个某个,该部分列出了包含EXE文件中的代码要调用的函数的DLL文件。然后,系统为每个DLL文件调用LoadLibrary函数,如果任何一个DLL需要更多的DLL,那么系统也将调用LoadLibrary函数,以便加载这些DLL。
每当系统调用LoadLibrary函数来加载一个DLL时,系统将执行下列操作步骤:
n 系统保留一个足够大的地址空间区域,用于存放该DLL文件。该区域需要的位置在DLL文件本身中设定。按照默认设置,VC建立的DLL文件的基址是0x10000000,可以使用/BASE选项覆盖该基址。Windows提供的所有标准系统DLL都拥有不同的基地址,这样如果这些标志的系统DLL被加载到单个地址空间,它们就不会重叠。
n 如果系统无法在该DLL的首选基地址上保留一个区域,其原因可能是该区域已经被另一个DLL或EXE占用,也可能因为该区域不够大,此时系统将设法寻找另一个地址空间的区域来为该DLL保留地址空间。如果一个DLL不能加载到其基址,将非常不利:如果系统没有重定位信息,将无法加载该DLL;系统必须在DLL中执行某些重定位操作。
n 系统会注意到支持已保留区域的物理存储器位于磁盘上的DLL文件,而不是在系统的分页文件中。
2.2 线程
与进程类似,线程也由两部分组成:
n 线程的内核对象:操作系统用它来对线程实施管理,内核对象也是系统用来存放线程统计信息的地方;
n 线程堆栈:它用于维护线程在执行代码时需要的所有函数和局部变量。
进程从来不执行任何东西,它只是线程的容器。线程总是在某个进程环境中创建的,而且它的整个生命周期都在该进程中。这意味着线程在它的进程地址空间中执行代码,并且在进程的地址空间中对数据进行操作。因此,如果在单个进程环境中,有两个或多个线程正在运行,那么这两个(或多个)线程将共享单个地址空间,它们能够执行相同的代码,以及对相同的数据进行操作。此外,这些线程还能共享内核对象句柄,因为句柄表也是依赖于每个进程而存在的,而不是依赖于每个线程。
调用CreateThread可使系统创建一个线程内核对象,该对象的初始引用计数为2(在线程停止运行和从CreateThread返回的句柄关闭之前,线程内核对象不会被撤消)。线程的内核对象的其他属性也被初始化,暂停计数被设置为1,退出代码设置为STILL_ACTIVE(0x103),该内核对象设置为未通知状态。
一旦内核对象创建完成,系统就分配用于线程的堆栈的内存,该内存是从进程的地址空间分配而来的,因为线程并不拥有它自己的地址空间。然后系统将两个值写入新线程的堆栈的上端(栈底:线程堆栈总是从内存的高地址向低地址建立)。写入堆栈的第一个值是传递给CreateThread的pvParam参数的值,紧靠它下面的是pfnStartAddr参数的值。
每个线程都有它自己的一组CPU寄存器,称为线程的上下文(Context)。该上下文反映了线程上次运行时该线程的CPU寄存器的状态。线程的这组CPU寄存器保存在一个CONTEXT结构(定义于WinNT.h头文件)中,CONTEXT结构本身则包含在线程的内核对象中。
typedef struct _CONTEXT
{
// 该字段控制上下文结构中的内容
DWORD ContextFlags;
// 调试寄存器
DWORD Dr0;
DWORD Dr1;
DWORD Dr2;
DWORD Dr3;
DWORD Dr6;
DWORD Dr7;
// 浮点运算相关信息
FLOATING_SAVE_AREA FloatSave;
// 段寄存器
DWORD SegGs;
DWORD SegFs;
DWORD SegEs;
DWORD SegDs;
// 通用寄存器
DWORD Edi;
DWORD Esi;
DWORD Ebx;
DWORD Edx;
DWORD Ecx;
DWORD Eax;
// 控制寄存器
DWORD Ebp;
DWORD Eip;
DWORD SegCs;
DWORD EFlags;
DWORD Esp;
DWORD SegSs;
// 扩展寄存器
BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;
指令指针(EIP)和堆栈指针(ESP)寄存器是线程上下文中两个最重要的寄存器,当线程的内核对象被初始化时,CONTEXT结构的堆栈指针寄存器(ESP)被设置未线程堆栈上用来存放pfnStartAddr的地址,指令指针(EIP)寄存器置为称为BaseThreadStart的未公开(和未导出)的函数的地址中,该函数在Kernel32.DLL中(这也是实现CreateThread的地方)。
VOID BaseThreadStart( PTHREAD_START_ROUTINE pfnStartAddr, PVOID pvParam )
{
try
{
if ( NtCurrentTeb()->NtTib.Version == OS2_VERSION )
{
if ( !BaseRunningInServerProcess )
CsrNewThread();
}
ExitThread( (pfnStartAddr)(pvParam) );
}
except( UnhandledExceptionFilter( GetExceptionInformation() ))
{
if ( !BaseRunningInServerProcess )
ExitProcess(GetExceptionCode());
else
ExitThread(GetExceptionCode());
}
}
当线程完全初始化后,系统就要查看CREATE_SUSPENDED标志是否被传递给CreateThread函数,如果该标志没有传递,系统便将线程的暂停计数递减为0,该线程然后可以调度到一个进程中;然后系统用上次保存在线程上下文中的值加载到实际的CPU寄存器中,这时线程就可以执行代码,并对它的进程的地址空间中的数据进行操作。
由于新线程的指令指针(EIP)被置为BaseThreadStart,因此该函数实际上是线程开始执行的地方。BaseThreadStart的原型会使你认为该函数接收了两个参数,但是这表示该函数是由另一个函数来调用的,而实际情况并非如此。新线程只是在此处产生并且开始执行。BaseThreadStart认为它是由另一个函数调用的,因为它可以访问两个函数。但是,之所以可以访问这些参数,是因为操作系统将这两个参数的值显式的写入了线程的堆栈(这就是通常的函数传递参数的方法)。注意:有些CPU结构使用CPU寄存器而不是堆栈来传递参数,对于这样的CPU,系统将在允许线程执行BaseThreadStart函数之前对相应的寄存器正确的进行初始化。
当新线程执行BaseThreadStart函数时,将会出现下列情况:
n 在线程函数中建立一个结构化异常(SHE)处理帧,这样在线程执行时产生的任何异常情况都会得到系统的某种默认处理;
n 系统调用线程函数,并将你传递给CreateThread函数的pvParam参数传递给它;
n 当线程函数返回时,BaseThreadStart调用ExitThread,并将线程的返回值传递给它。该线程的内核对象的引用计数被递减,线程停止执行。
n 如果线程产生一个没有处理的异常条件,由BaseThreadStart函数建立的SHE帧将处理该异常条件。通常情况下,这意味着向用户显示一个消息框,并且在用户撤消该消息框时,BaseThreadStart调用ExitProcess,以终止整个进程的执行,而不只是终止线程的运行。
注意:在BaseThreadStart函数中,线程要么调用ExitThread,要么调用ExitProcess。这意味着线程不能退出该函数,它总在该函数中被撤消,这就是BaseThreadStart的原型规定返回VOID,而它从来不返回的原因(这也应该就是在创建线程对象时,没有所谓的返回地址入栈过程的原因)。
另外,由于使用BaseThreadStart,线程函数可以在它完成处理后返回。当BaseThreadStart调用线程函数时,它会把返回地址入栈,这样线程函数就能知道在何处返回。但是,BaseThreadStart不允许返回,如果它不强制撤消线程,而是试图返回,那么几乎可以肯定会引发访问违例,因为线程堆栈上不存在返回地址,而BaseThreadStart将试图返回到某个随机的内存位置。
当进程的主线程被初始化时,它的指令指针(EIP)被设置为另一个未公开的函数,称为BaseProcessStart,该函数几乎与BaseThreadStart相同:
VOID BaseProcessStart(PPROCESS_START_ROUTINE lpStartAddress )
{
Try
{
NtSetInformationThread( NtCurrentThread(),
ThreadQuerySetWin32StartAddress,
&lpStartAddress,
sizeof( lpStartAddress ));
ExitThread( (lpStartAddress)() );
}
except( UnhandledExceptionFilter( GetExceptionInformation() ))
{
if ( !BaseRunningInServerProcess )
ExitProcess(GetExceptionCode());
else
ExitThread(GetExceptionCode());
}
}
这两个函数的最主要差别在于BaseProcessThread没有pvParam参数,当BaseProcessStart开始执行时,它调用C/C++运行期库的启动代码,该启动代码先要初始化main(或wmain、WinMain、wWinMain)函数,然后再调用这些函数。当EntryPoint函数返回时,C/C++运行期库的启动代码就调用ExitProcess函数。因此,对于C/C++应用程序来说,主线程从不返回BaseProcessStart函数。(UnhandledExceptionFilter函数的实现代码由于比较长一些,不在此列出,有兴趣的同志可以自行研究Win2K的代码。)
当线程终止运行时将发生以下的操作:
n 线程拥有的所有用户对象均被释放。在Windows中,大多数对象是由包含创建这些对象的线程的进程所拥有的,但是一个线程拥有两个用户对象,即窗口和钩子。当线程终止运行时,系统会自动撤消任何窗口,并且卸载线程创建的或安装的任何钩子。其他对象只有在拥有线程的进程终止运行时才被撤消。
n 线程的退出代码从STILL_ACTIVE该为传递给ExitThread或TerminateThread的代码。
n 线程的内核对象的状态变为已通知;
n 如果线程是进程中的最后一个活动线程,系统也将进程视为已经终止运行;
n 线程内核对象的引用计数递减1;当一个线程终止运行时,在与它相关联的线程内核对象的所有未结束的引用关闭之前(即引用计数变为0之前),线程内核对象不会自动释放。
当线程终止运行后,别的线程可以调用GetExitcodeThread来检查线程是否已经终止,如果它已经终止,则可以得到它的退出代码。
2.3 线程堆栈
每当创建一个线程时,系统就会为线程的堆栈保留一个堆栈空间区域,并将一些物理存储器提交给这个已保留区域。按照默认设置,系统保留1MB的地址空间并提交两个页面的内存。但是,这些默认值是可以修改的,方法是在链接应用程序时设置/STACK选项:
/STACK:reserved[,commit]
当创建一个线程的堆栈时,系统将会保留一个链接程序的/STACK开关指明的地址空间区域;但是当调用CreateThread或_beginthreadex函数时,可以覆盖原先提交的内存数量,这两个函数都有一个参数,可以用来覆盖原先提交给堆栈的地址空间的内存数量。如果设定的这个值为0,那么系统将使用/STACK开关指明的已提交的堆栈大小值。(如下表所示,页面大小为4K