Windows 95 System Programming SECRETs
(Windows 95 系统程序设计 大奥秘)
原著:Matt Pietrek
笔记:Simon wan
模块、行程、执行绪(Modules, Processes, Threads)
Win32 模块(Modules)
一个 Win32 模块代表的是一个被 Win32 载入器载入的EXE 或DLL 的程序代码、数据、资源。因此,内存中的一个模块都对应到磁盘中的一个档案。EXE 或 DLL 本身并不是模块。是加载器把它们读进内存并产生出模块。
应用程序使用 HMODULEs 来代表被加载的模块。Win32 并没有所谓的节区,所以需要一些其它方法来参考被加载模块。微软的作法是让一个 HMODULE 成为Win32 载入器映像 PE 档案时的起始线性地址。例如,大部份 EXE 程序被加载于地址 0x400000(4MB)处,所以它们的HMODULE 就是 0x400000。是的,这意味着多个 EXE 同时执行时,拥有相同的HMODULE。这不是问题,因为Windows 95 和 NT 为每一个行程维护了一个分离地址空间。
Win32 要求每一个行程有自己的模块串行。如果模块没有隐式联结(implicitly link)DLLs,或说它是以 LoadLibrary 加载 DLLs,行程就没有办法在内存中看到那些DLL 模块 -- 即使其它行程加载了它们也一样。这在 Win16 是相当不同的,Win16的每一个被加载模块可以被所有其它行程看到,不论它们有没有参考到该 DLL。
虽然,「每一个 Win32 行程有自己的模块串行」这件事对于安全防护性和强固性有好的影响,从「利用可共享之程序代码和资源以节省空间」的角度来看却不怎么好。毕竟,如果你执行三份 WINHELP,WINHELP 的码不应该被加载三次,对不对?
KERNEL32 必须面对一个费力的选择。从应用程序眼光来看,每一个行程有自己的模块串行是不错;但从 KERNEL32 的角度来看,单一模块串行比较容易达到程序代码和资源的共享。只要有一个新的行程开始执行,或一个新的 DLL 被加载,KERNEL32 就可以快速检查独一无二的全域性模块串行,看看那个 EXE 或 DLL 是否已经加载?如果是,KERNEL32 就简单地改变其计数值。如果不是,KERNEL32 才需要在内存中为它产生新的模块。
KERNEL32 利用两个数据结构来维护一个全域性模块串行,并且使它看起来好像每一个行程有自己的一个串行。第一个是数据结构 IMTE(Internal Module Table Entry),第二个数据结构是 MODREF。
IMTEs(Internal Module Table Entries)
如图3-1 所示,全域性 KERNEL32 模块串行其实只不过是 IMTEs 指针所组成的数组。稍后所列的伪码中,我将以 pModuleTableArray 代表指向此一数组的指针。这块数组所使用的内存是从 KERNEL32 heap 中配置而来,那是一般的 HeapAlloc 所获得的一个区块。当新模块被加载或踢出内存,KERNEL32 利用 HeapReAlloc 动态扩张或缩减数组大小。当 KERNEL32 产生一个新的 IMTE , 它会搜寻pModuleTableArray 中的空白元素;找到了一个,就把 IMTE 指标放进去。这个元素的数组索引值稍后在我们搜寻 MODREFs 时将有吃重的演出。pModuleTableArray 的第一个元素(索引为 0)用来表示KERNEL32.DLL 模块。
让我快速叙述一下重点。pModuleTableArray 中的每一个非零元素都代表系统中一个被载入的 EXE 或 DLL。每一个这样的元素都是一个 IMTE 指标(在伪码中我以 PIMTE表示)
IMTE 结构
WIN32WLK 原始码中的 MODULE32.H 内含一个 IMTE 结构定义。每一个 IMTE 有
以下字段:
l 00h DWORD un1
这个字段用来放置某种旗标值。
l 04h PIMAGE_NT_HEADERS pNTHdr
这个指针指向一个 IMAGE_NT_HEADERS 结构。然而,它只是该结构内容的一份拷贝。这份拷贝所用的空间是从 KERNEL32 heap 配置来的,所以对所有行程而言都是可见的。而主要的那个 IMAGE_NT_HEADERS 结构位于模块的基地址附近(可能在 2GB 之下),只能给「加载此一模块」之行程存取之。经由拷贝的动作,KERNEL32 就可以轻易地把任何被加载模块的信息让所有行程看见,不需要呼叫 ring0 码以切换 memorycontext。
l 08h DWORD un2
此字段的意义不清楚。其值似乎总是 -1。
l 0Ch PSTR pszFileName
内含一个指标,指向用以建立这个模块的 EXE 或 DLL 的完整檔名。你可以呼叫GetModuleFileName 获得此完整档名,更可以利用 GetModuleHandle 再将档名转换为handle。放置完整档名的区块是从 KERNEL32 heap 中配置来的。
l 10h PSTR pszModName
内含一个指针,指向模块名称。Win32 的模块名称就是 EXE 或 DLL 名称。例如,C:\WINDOWS\CALC.EXE 的模块名称就是 CALC.EXE。pszModName 事实上是指入前面所说的 pszFileName 之中。以前例而言,它将指向第二个 '\' 之后的 "CALC.EXE"。
l 14h WORD cbFileName
此值表示 pszFileName 所指之字符串的字符个数。GetModuleHandle 可以利用此一字段快速决定字符串是否吻合。
l 16h WORD cbModName
此值表示 pszModName 所指之字符串的字符个数。GetModuleHandle 可以利用此一字段快速决定字符串是否吻合。
l 18h DWORD un3
此字段的意义不清楚。
l 1Ch DWORD cSections
此一模块所含之 sections ( .text 、.idata 等等) 的个数。这个值也可以从IMAGE_FILE_HEADERS 结构(第8章)中的 NumberOfSections 字段获得。
l 20h DWORD un5
此字段的意义不清楚。其值总是 0,但是在 COMCTL32.DLL 中,它内含一个指标,指向 KERNEL32 heap 中的一块区域。
l 24h DWORD baseAddress/Module Handle
这个字段内含模块的基地址。在 Win32 中模块的基地址和 HMODULE 以及HINSTANCE 相同,所以此字段也可以被解释为模块的 HMODULE 和 HINSTANCE。EXE 的基地址几乎总是为 0x40000。system DLLs 的基地址在 2GB 之上,是共享记忆区。第8章对于基地址有更多叙述。
l 28h WORD hModule16
此字段内含一个 selector,其线性地址指向一个 Win16 NE module database(其格式在第7章详述)。对于 Win32 程序,NE module database 内含重要信息,包括在哪里可以找到资源等等。这是必要的,因为处理资源的程序代码位在 Win16 的KRNL386.EXE 和USER.EXE 中。请注意,hModule16 并非是以 Win16 的 GlobalAlloc 配置而得,所以这个 selector 并不像 Win16 的全域性内存 handle,因为这个因素和一些其它因素,Win16 TOOLHELP 没办法看到 Win32 模块所镜射的那个 NE 模块。
l 2Ah WORD cUsage
此字段内含模块的参用计数。如果有三个 CALC.EXE 正在执行,CALC.EXE 的moduledatabase 的此一字段即为 3。
l 2Ch DWORD un7
此字段的意义不清楚。通常它内含一个合法的指标,指向一块 KERNEL32 heap 区域。
l 30h PSTR pszFileName2
这个 PSTR(以及后续三个字段)有点神秘。它们似乎为那些「处理本结构之0Ch 至 16h
偏移位置」的相同函式服务。这个字段指向 EXE 或 DLL 完整檔名的一份不同拷贝。pszFileName 和 pszFileName2 所指的字符串似乎总是相同。
l 34h WORD cbFileName2
这个字段内含 pszFileName2 的长度。它总是和 cbFileName 字段相同。
l 36h DWORD pszModName2
这个字段指向 pszFileName2 之中的模块名称。它总是和 pszModName 相同。
l 3Ah WORD cbModName2
这个字段内含 pszModName2 的长度。它总是和 cbModName 字段相同。
MODREF 结构
一个行程拥有它自己的模块串行,它对其它行程所加载的其它模块一无所知。把每个行程都有的模块串行和全域性模块表格连接在一起的就是 MODREF 结构。每个行程(除了奇怪的KERNEL32)都有的模块串行事实上是一个 MODREF 串行,其中一个 MODREF 是针对行程本身(也构成一个模块),其它 MODREFs 是针对行程所使用的每一个 Win32 DLLs。MODREFs 的内存来自 KERNEL32 heap,那是处于 2GB 之上 -- 可共享区域。因此,即使 MODREFs厉行「每一个行程有一个模块串行」的概念,MODREF 串行事实上是谁都看得到的。WIN32WLK 能够走访每一个行程的模块打印就足以证明这一点。
MODREFs 串行的头放在process database(稍后讨论)之中。每一个MODREFs 结构内含一个索引,指向 pModuleTableArray 表格。图3-2 显示MODREFs 和IMTEs 的关系。
WIN32WLK 原始码中的 MODULE32.H 内含一个 MODREF 结构定义。每一个MODREF 有以下字段:
l 00h PMODREF pNextModRef
这个指针指向串行中的下一个 MODREF 结构。串行最后以 NULL 收尾。只要从行程的 process database 中取得 MODREFs 串行的头,然后一一追踪下去,就可以把行程所用到的每一个模块找出来。WIN32WLK 之中就有这样的示范。
l 10h WORD mteIndex
一个以 0 为基准的值,代表 pModuleTableArray 数组的索引。
l 18h PVOID ppdb
这是一个 PPROCESS_DATABASE,也就是一个指向 PROCESS_DATABASE 结构的指标,它提供一个从 MODREF 回头联结至「拥有此一 MODREF 之行程」的数据。稍后我会探讨 PROCESS_DATABASE。
由于 Windows 95 必须看起来像是每一个行程有自己的一个模块串行,所以和模块有关的 API 函式如 GetModuleHandle 不能够在全域模块表格(pModuleTableArray)上动作,它们只能够在自己的 MODREF 串行上动作,然后再藉由获得的索引参考到全域模块表格的元素项目。例如,GetProcAddress 只能够寻找陈列于current MODREF 串行中的模组。即使这个模块已经被其它行程加载,GetProcAddress 还是无法找到它 -- 除非现行(current)行程也载入了它。
KERNEL32 物件
K32 对象是关键性的系统数据结构,放在 KERNEL32 heap 之中。有各式各样的K32 对象,统统都以相同的表头开始。决定它是否为一个 K32 对象的方法就是询问一个问题:应用程序之中可有 handles 代表此一对象?例如,应用程序可以拥有 file handles 或event handles,所以 file 和 event 都是K32 对象。我不曾看过任何应用程序代码拥有MODREF 或 IMTE 的 handles,所以它们并不是 K32 对象。
每一个 K32 对象都以一个共通的表头开始。此表头拥有以下字段:
l 00h DWORD
对象型态。此值决定后续的结构成员如何解释。
l 04h DWORD
这是对象的参用次数(reference count),代表对象被使用的次数。例如,当你呼叫GetFileInformationByHandle,被询问的档案对象的参用次数就会累加 1;而在函式回返之前,参用次数又会减 1。
现在,你或许渴望知道有哪些 K32 对象型态。以下就是一份清单:
K32OBJ_SEMAPHORE(0x1)
K32OBJ_event(0x2)
K32OBJ_mutex(0x3)
K32OBJ_CRITICAL_SECTION(0x4)
K32OBJ_PROCESS(0x5)
K32OBJ_THREAD(0x6)
K32OBJ_FILE(0x7)
K32OBJ_CHANGE(0x8; 请看 FindFirstChangeNotification)
K32OBJ_CONSOLE(0x9)
K32OBJ_SCREEN_BUFFER(0xA)
K32OBJ_MEM_MAPPED_FILE(0xB;请看 CreateFileMapping)
K32OBJ_SERIAL(0xC)
K32OBJ_DEVICE_IOCTL(0xD;请看 DeviceIoControl)
K32OBJ_PIPE(0xE)
K32OBJ_MAILSLOT(0xF)
K32OBJ_TOOLHELP_SNAPSHOT(x10;请看 CreateToolhelp32Snapshot)
K32OBJ_SOCKET(0x11)
本章剩余部份,我们主要的焦点放在行程对象和执行绪对象(IDs 5 和 6)。一个 processdatabase 其实就是一个 K32_PROCESS 对象,而一个 thread database 其实就是一个K32_THREAD 对象。就像你在「什么是 process handle?什么是 process ID?」一节即将看到的,一个 process handle table 其实就是一个指针数组,每一个指针指向各式各样的 K32 对象。
Windows 95 行程(Processes)
行程其实就是一大堆对象的拥有权的集合。也就是说,行程拥有对象。行程可以拥有内存(更精确说是拥有 memory context),可以拥有 file handle,可以拥有执行绪,可以拥有一串行的 DLL 模块(被加载于此一行程的地址空间中)。
注意,行程并不代表「执行事实」(执行绪才是)。行程也不是 EXE 檔。在被载入之前,一个 EXE 档案只不过是一个程序。只有在被加载之后,Windows 95 才为它产生一个行程。一旦 Windows 95 产生一个行程,它也产生一个 memory context,容纳行程的执行绪在其中执行。此外,Windows 95 也产生出第一个执行绪,用来执行行程本身。如有必要,行程可以再产生执行绪。系统还会产生一个 file handle table,行程可以在其中持有一些开启的档案。最后,也是最重要的,Windows 95 产生一个 process database,用以表现这个行程。
Process database 是一种 K32 对象,内含与行程有关的大量信息。稍后我们将在「Windows95 Process Database(PDB)」一节中详细观察其字段。Process database 所使用的内存来自 KERNEL32 heap,因此所有的process database 都可以被其它行程看到。
Process database 含有一系列的执行绪、一系列的被加载模块、预设之process heap 的handle、指向 process handle table 的指标、以及指向 memory context 的指标。此外还有更多更多的东西。
什么是process handle?什么是process ID?
Process handle 基本上和 file handle 一样。它是一个不被明了的数值,你不能够说它是任何东西的指标。系统内部事实上是把 K32 对象的 handle 当作 process handle table 的索引。而从该数组中获得的,才是一个 K32 对象指针。然而,由于应用程序不需要直接处理 handle table,所以 process handle 是没有用的。
记住,因为每一个应用程序有它自己的 handle table,所以不同的行程在各自的地址空间中拥有相同的 process handle 是绝对可能的。例如,正常而言每一个行程都有一个 process handle 为它而开,其值总是 1。也就是说,process handle 并不是可以赖以判别行程的资料。另一个例子是:如果程序为自己这个行程打开另一个 process handle,那么就有两个handles,对应至同一个行程。
一个 process handle 就像一个 file handle。在其行程之外别无意义。至于一个 process ID,则是在各行程之间独一无二不会冲突的数值,它是一个指标,指向process database 结构,甚至虽然微软在其中加了点料。WIN32WLK 示范神奇的转换公式,把 process ID 转换为一个有用的指标。
Windows 95 Process Database(PDB)
Windows 95 的每一个 process database 都是一块从 KERNEL32 heap 配置而来的记忆体。KERNEL32 通常以 PDB 缩写字取代又臭又长的 "process database"。每一个 PDB 就是一个「第一字组为 5(K32OBJ_PROCESS)」的K32 物件。WIN32WLK 程序的PROCDB.H 中有一个 PDB 的C 语言定义,我们把每一个字段看清楚些:
l 00h DWORD Type
此值必为 5(K32OBJ_PROCESS)
l 04h DWORD cReference
参用次数(reference count)。也就是此一 PDB 被使用的次数。
l 08h DWORD un1
此字段之真实意义未知。似乎总是 0。
l 0Ch DWORD someEvent
这是一个指向 K32OBJ_EVENT 对象的指针。Event 对象用于WaitForSingleObject 这样的函式。
l 10h DWORD TerminationStatus
当你呼叫 GetExitCodeProcess,传回的就是这个值。所谓退出代码(exit code)就是 main或 WinMain 的回返值。它也可以被 ExitProcess 或 TerminateProcess 指定。当一个行程还在执行时,此字段为 0x103(STILL_ACTIVE)。
l 14h DWORD un2
此字段之真实意义未知。似乎总是 0。
l 18h DWORD DefaultHeap
Default process heap 的地址。GetProcessHeap 传回的就是这个值。
l 1Ch DWORD MemoryContext
一个指标,指向行程的 memory context。所谓 memory context,内含 page directorymapping,用以提供行程在 4GB 地址空间中的私人区域。第5章对于 memory context 有更多描述。
l 20h DWORD flags
这个旗标值的意义如下:
fDebugSingle 0x00000001 如果设立,表示行程正被除错中
fCreateProcessEvent 0x00000002 设立于除错行程中(在起始之后)
fExitProcessEvent 0x00000004 可以在除错行程中(在结束时候)被设立
fWin16Process 0x00000008 表示一个 16 位程序
fDosProcess 0x00000010 表示一个 DOS 程序
fConsoleProcess 0x00000020 表示一个 console(文字模式)Win32 程序
fFileApisAreOem 0x00000040 请看 API 文件中的 SetFileApisToOEM 说明
fNukeProcess 0x00000080
fServiceProcess 0x00000100 例如 MSGSRV32.EXE
fLoginScriptHack 0x00000800 可能是一个 Novell 网络签入行程
fSendDllNotifications 0x00200000
fDebugEventPending 0x00400000 例如,在除错器中被停下来
fNearlyTerminating 0x00800000
fFaulted 0x08000000
fTerminating 0x10000000
fTerminated 0x20000000
fInitError 0x40000000
fSignaled 0x80000000
l 24h DWORD pPSP
这个值是此一行程之 DOS PSP 的线性地址。Win16 和 Win32 程序都会设定此字段。此一线性地址总是在 1MB(真实模式 DOS 码所能看到的最高地址)之下。请参考28h 栏位。
l 28h WORD PSPSelector
这是一个 selector,指向此一行程之 DOS PSP。Win16 和 Win32 程序都有 DOS PSP。请参考 24h 字段。
l 2Ah WORD MTEIndex
这里内含一个全域模块表格(pModuleTableArray)的索引值。经由此索引值而取出之 IMTE正是此一行程对应的IMTE。IMTE 和pModuleTableArray 已于本章先前讨论过。
l 2Ch WORD cThreads
此字段记录此一行程拥有的执行绪个数。
l 2Eh WORD cNotTermThreads
此字段记录属于行程所有而尚未结束之执行绪个数。它怎么看都应该和上一字段相同。
l 30h WORD un3
此字段之真实意义未知。似乎总是 0。
l 32h WORD cRing0Threads
此字段记录由VMM32.VXD 管理的ring0 执行绪个数。对于一般程序而言,其值应该与cThreads 字段值相同。然而在KERNEL32.DLL 之中,此值比cThreads 字段值多1。
l 34h HANDLE HeapHandle
此字段是一个 HEAP handle,此 HEAP 内含属于这个行程的表格(或可能其它东西)。这里记录的总是 KERNEL32 的 shared heap handle。
l 38h HTASK W16TDB
这是一个 selector,指向行程相关的 Win16 Task Database(TDB)。Win16 和 Win32 程序都有 TDB selector,并且维护一个合法的 TDB。
l 3Ch DWORD MemMapFiles
一个指标,指向「此一行程所使用之内存映像文件所组成的串行」中的第一个节点。每一个内存映像文件都出现为串行中的一个节点。节点的格式是:
DWORD 内存映像文件的基地址
DWORD 指向下一个节点;或是 0。
l 40h PENVIRONMENT_DATABASE pEDB
一个指标,指向 environment database。Environment database 中内含目前的子目录、环境变量、行程命令列、标准 handles(例如 stdin)、以及其它项目。我将在「EnvironmentDatabase」一节中描述其详细格式。
l 44h PHANDLE_TABLE pHandleTable
这是一个指标,指向 process handle table。所有的 handles 都在这里面,包括file handles、event handles、process handles 等等等。在 DOS/Win16 环境中的对等物品是 DOS 的System File Table(SFT)。关于 SFT 请参考Schulman 等人所著的 Undocumented DOS,第二版。然而毕竟有所不同。SFT 适用于整个系统,Win32 process handle table 则只适用于一个行程
l 48h struct _PROCESS_DATABASE * ParentPDB
这是一个指标,指向父行程的 PROCESS_DATABASE。对一般程序而言父行程是Windows95 的档案总管(Explorer)。MSGSRV32 则又是 Explorer 和initial "service" processes 的父行程。
l 4Ch PMODREF MODREFlist
这个字段指向行程的模块串行的起头。这也就是稍早描述过的 MODREFs 串行。
l 50h DWORD ThreadList
一个指标,指向此一行程所拥有的执行绪的串行。目前我还不知道这个串行的真正格式。
l 54h DWORD DebuggeeCB
这是一个被除错程序的 context。当一个行程被除错,这个字段即指向 2GB 以上的一个区域,该区域包含一个指针,指向被除错者的 process database。
l 58h DWORD LocalHeapFreeHead
这个指标指向「此一行程预设之 heap」的自由区块串行起头。第5章对于其格式有详细的描述。
l 5Ch DWORD InitialRing0ID
此字段意义未知。似乎总是为 0。
l 60h CRITICAL_SECTION crst
这个字段是一个 CRITICAL_SECTION,被各式各样的 API 用来同步控制同一行程中的各执行绪。稍后在许多伪码之中你会看到这个 critical section 的作用。
l 78h DWORD un4[3]
这三个 DWORD 之真实意义未知。似乎总是 0。
l 84h DWORD pConsole
如果这个行程使用 console(也就是说它是一个文字模式程序),此一字段即指向一个用于输出的 console 对象(K32OBJ_CONSOLE)。
l 88h DWORD tlsInUseBits1
这 32 个位表示最低的 32 个 TLS(Thread Local Storage)的索引。如果某个位设立,表示对应的 TLS 索引被用掉了。每一个 TLS 索引都不断累加其值,例如:
TLS index: 0 = 0x00000001
TLS index: 1 = 0x00000002
TLS index: 2 = 0x00000004
稍后将有一节专门讨论 TLS。
l 8Ch DWORD tlsInUseBits2
这个 DWORD 表示 TLS 之中第 32~63 个索引的状态。请参考前一字段。
l 90h DWORD ProcessDWORD
此字段之意义未明。有一个未公开函式(GetProcessDword)可以取出其值。
l 94h struct _PROCESS_DATABASE * ProcessGroup
此字段要不为 0,要不就指向一个「行程群」中的为首行程。所谓「行程群」是一群行程,彼此互属。当此一群组被摧毁,其中的所有行程也一并被摧毁。注意,每一个行程都认为自己在自己的「行程群」之中,而也因此这个字段指向行程自己的 PDB。如果行程处在除错状态,它就属于除错器「行程群」。
l 98h DWORD pExeMODREF
这个字段指向 EXE 的 MODREF(前面描述过此一结构,是模块串行中的数据项)。一般而言,EXE 的 MODREF 是模块串行中的头,所以这个字段通常和字段 4Ch 吻合,除非行程又经由 LoadLibrary 或 LoadModule 载入了其它 DLLs。
l 9Ch DWORD TopExcFilter
这个 DWORD 内放行程的 "Top Exception Filter"。如果行程没有安装任何异常情况处理例程,那么就使用这一个。这个值是经由SetUnhandledExceptionFilter 函式设立的。
l A0h DWORD BasePriority
这个 DWORD 存放的是行程的排程优先权。Windows 95 支持 32 个优先权,分为四个等级(右边所列是其预设优先权):
Idle 4
Normal 8
High 13
Realtime 18
在每组之中,优先权还可以略为调高或调低。稍后对此亦有详细说明。
l A4h DWORD HeapOwnList
这个字段指向「行程所有的 heaps」所形成的串行。预设情况下每一个行程只有一个 heap,可经由 GetProcessHeap 取得。然而行程也可以呼叫 HeapCreate 产生另一个 heap。这些 heaps 都放在串行之中。第5章对此一主题有较多的叙述。
l A8h DWORD HeapHandleBlockList
heap 之中的可搬移区块系由 moveable handle table 来管理。每一个 heap 对应一个table。许多个 tables 则形成一个串行。本字段指向串行的头。第5章对于 moveable handletable 有较多的叙述。
l ACh DWORD pSomeHeapPtr
本字段的真正意义不十分明确。通常它是 0,如果不是 0 那么就是一个指标,指向本行程之 default heap 的 moveable handle table。请看 A8h 字段。
l B0h DWORD pConsoleProvider
此字段要不为 0 , 要不就是一个指标, 指向 KERNEL32 的主控台物件(K32OBJ_CONSOLE)。对于 Win32 console 程序而言它似乎总是为 0,但对于WINOLDAP 行程而言就不是 0。WINOLDAP 系用来管理Windows 中的 DOS 程序。
l B4h WORD EnvironSelector
这是一个 selector,指向行程的环境区。这个 selector 的基地址(base 字段)和Environment Database 的 pszEnvironment 字段的值相同B6H WORD ErrorMode这个字段内含由 SetErrorMode 设定的数值。KERNEL32 的 SetErrorMode 会下移(thunkdown)至 KRNL386 的同名函式,所以这个字段反应出 Win16 错误模式代码。它们可能是:
SEM_FAILCRITICALERRORS
SEM_NOALIGHMENTFAULTEXCEPT
SEM_NOGPFAULTERRORBOX
SEM_NOOPENFILEERRORBOX
B8h DWORD pevtLoadFinished
这个字段指向 KERNEL32 的 Event object (K32OBJ_EVENT)。当行程载入之后,此 event即被激发。
l BCh WORD UTState
此字段之意义不明确。
Environment Database
Process database 的 40h 字段中是一个指标,指向一个重要的数据结构,内含与行程有关的信息。KERNEL32 内部称此指标为 pEDB,我把它解释为 "pointer to EnvironmentDatabase" 。就像对待 PROCESS_DATABASE 一样, 我在 PROCDB.H 中描述了ENVIRONMENT_DATABASE 的结构布局。现在我们来看看这些字段:
l 00h PSTR pszEnvironment
这个字段指向行程的环境区。所谓环境区是标准的 DOS 环境(形式一如 string=value;string=value)。行程环境区是一块内存,位于每一个行程私有的数据空间中,通常就在模块被加载的地址之上。
l 04h DWORD un1
此字段意义未明。通常总是 0。
l 08h PSTR pszCmdLine
此字段内含 CreateProcess 函式中的命令列参数内容。大部份情况下这个命令列是一个完整的 EXE 檔名。有时候它会指向空字符串(\0)。
l 0Ch PSTR pszCurrDirectory
此字段指向目前的磁盘目录。
l 10h LPSTARTUPINFOA pStartupInfo
这是一个指针,指向行程的 STARTUPINFOA 结构(定义在 WINBASE.H 之中)。STARTUPINFOA 结构是 CreateProcess 的参数之一,可用来指定窗口的大小、标题、标准的 file handles...等等等。这个字段所指的是该结构的一个副本。
l 14h HANDLE hStdIn
这是一个 file handle,行程用它当做标准的档案输入装置。如果没有用到(例如一个 GUI程序),此值为 -1。
l 18h HANDLE hStdOut
这是一个 file handle,行程用它当做标准的档案输出装置。如果没有用到(例如一个 GUI程序),此值为 -1。
l 1Ch HANDLE hStdErr
这是一个 file handle,行程用它当做标准的错误输出装置。如果没有用到(例如一个 GUI程序),此值为 -1。
l 20h DWORD un2
此字段意义未明。通常总是 1。
l 24h DWORD InheritConsole
从名称可以推测,此一字段表示行程是否继承自 console 程序。请参考 CreateProcess 函式的 CREATE_NEW_CONSOLE 旗标。在我的观察中,此字段总是 0。
l 28h DWORD BreakType
这个字段最可能用来指示 console event(例如 CTRL+C)如何处理。在我所执行过的程序中,它通常为 0,但偶而会是 0xA。
l 2Ch DWORD BreakSem
通常此为 0,但如果过程调用 SetConsoleCtrlHandle,此字段就会指向一个 KERNEL32semaphore object(K32OBJ_SEMAPHORE)。
l 30h DWORD BreakEvent
通常此为 0,但如果过程调用 SetConsoleCtrlHandle,此字段就会指向一个 KERNEL32EVENT object(K32OBJ_EVENT)。
l 34h DWORD BreakThreadID
通常此为 0,但如果过程调用 SetConsoleCtrlHandle,此字段就会指向一个执行绪对象(K32OBJ_THREAD),而该执行绪正是安装此一处理例程的执行绪本身。
l 38h DWORD BreakHandlers
通常此为 0,但如果过程调用 SetConsoleCtrlHandle,此字段就会指向一个从 KERNEL32shared heap 中配置得来的数据结构,内放一系列安装好的主控台控制函式(console controlhandler)。
Process Handle Tables
PROCESS_DATABASE 的 44h 偏移处是一个指标,指向行程的 handle table。我将使用handle 一词代表可以从 handle table 中取得的东西。除了 file handles,Windows 95 还会产生其它的系统对象的 handles,像是行程啦、执行绪啦、event 啦、mutex 啦等等。
handle 的内容理论上来讲是不透明的,也就是说 handle 本身没有办法告诉你它究竟代表什么东西。如果它的值是 5,你判断不出这是一个 file handle 还是一个 mutex handle。然而,一旦你了解 Windows 95 行程的 handle tables,你就可以轻易地将一个 handle 值和其参考到的数据产生关系。
Windows 95 行程的 handle table 构造十分简单。第一个 DWORD 放的是这个表格的最大容量(项目个数)。此值初始为 0x30(48)。然而这并不意味行程最多只能有 48 个打开的 handles。当行程需要更多的 handles,KERNEL32 会重新配置一块内存,使表格有成长空间。每次增加 0x10 个 handles。似乎并没有明显的上限。
第一个DWORD 之后,是由许多结构所组成的数组。每一个结构都由两个DWORD 构成:
DWORD flags
DWORD pK32Object
其中第二字段是个指标,指向 17 种可能的 K32 对象型态。至于第一个字段则是此一对象的access control flags。这些旗标的意义视对象是何种型态而定。对于一个K32OBJ_PROCESS 对象,这些旗标将是PROCESS_xxx ( 定义在 WINNT.H 中) , 像是 PROCESS_TERMNATE 、PROCESS_VM_READ 等等。
进行到这里,也许你已经可以感觉到 handle 是什么东西了。如果你猜想handle 是一个索引,指向行程的 handle table,你对了!一旦这么认为,你就很容易把一个 handle 值比对其所参考的 KERNEL32 对象型态。一个没有用的 handle,其两个 DWORD 一定都填满 0。当程序配置一个新的 handle,KERNEL32 就使用 handle table 中的第一笔空白项的索引值做为 handle。
Thread(执行绪)
行程主要是表达对 file handles、地址空间等等的拥有权,执行绪则主要表达来自模块的码的执行事实。
从抽象层面来说,执行绪是一种方便的表达方式,让你的某一部份码执行 -- 当其它部份码正在等待某些外部事件发生时。将行程的各项工作进一步分配给执行绪之后,你几乎可以消除像 "pooling loop" 这样的动作。Pooling loop 浪费许多 CPU 时间。任何时候,执行绪可能处于三种状态之一。
l 第一种是「执行中」状态(running state)。这个时候 CPU 缓存器内容就是执行绪的缓存器值。当某个执行绪处于执行状态,其它执行绪就处于虚悬状态。
l 第二种情况称为「准备执行」状态(ready to run state)。这种状态下的执行绪没有什么理由不会被执行 -- 时间早晚而已。它终有一刻能够控制 CPU。
l 第三种情况称为「阻塞」状态(blocked state)。执行绪如果被阻塞,表示它正在等待某件事情发生。在那之前排程器不会配置执行绪执行起来。引起执行绪阻塞的东西称为同步控制对象(synchronization objects)。Windows 95 的同步控制对象有 critical sections、events、semaphores、mutexes 四种。
最初,每一个行程以一个执行绪开始。如果需要,行程可以产生更多执行绪。
和行程一样,执行绪是以一块从 KERNEL32 共享内存中配置而来的内存表现出来。这块内存持有所有必要的信息,让 KERNEL32 用来维护一个执行绪。虽然我说「所有必要的信息」,实际上这块内存中有一些指针指向其它信息,不过你懂得我的意思就好。这块内存在本书中被称为thread database,或 TDB。就像 processdatabase 一样,thread database 是一个 KERNEL32 物件,它的第一个 DWORD 值为 6,表示这是一个 K32OBJ_THREAD 对象。
如果你是一位高级程序员,能够改写 DDK 或使用 WDEB386 或 SoftIce/W,你可能遭遇过另一个与执行绪有关的数据结构,名为 THCB(Thread Control Block)。THCB 是执行绪在 ring0 中的呈现。在 Windows 95,执行绪呈现 ring0 和ring3 两份数据结构。ring0 码如 VMM VXD 者经由 THCB 来处理执行绪。ring3 码如 KERNEL32 者则经由 thread database 来处理执行绪。本章描述 ring3 执行绪行为和机制,并不打算涵盖ring0 那一端。
执行绪本身拥有一些东西。第一样东西是缓存器组(register set)。一如稍早我说过,执行绪要不是正在执行,要不就是并未执行(这可不是废话吗)。当执行绪正在执行,它的缓存器组内容被放到 CPU 缓存器中,也就是说执行绪的 EIP 值就是缓存器 EIP 值。当执行绪不在执行状态,它的缓存器必须储存在内存某处。因此,每一个执行绪有一个指针指向一块内存缓冲区,执行绪的缓存器内容就存放在那里。与每一执行绪都有关系的另一样东西是行程。行程中的所有执行绪分享行程的每一样东西,例如,行程拥有 memory context 和一个私有的地址空间,所以其下的所有执行绪都在相同的地址空间中执行。行程有一个 handle table,用来参考档案、主控台(console)、内存映像文件(memory mapped files)、events 等等,行程中的所有执行绪也共享相同的这些 handles。如果 handle 3 代表一个内存映像文件,行程中的任何一个执行绪都可以使用 handle 3 来参考这个内存映像文件。
执行绪还拥有许多其它东西。每一个执行绪有一个专属的堆栈,一个专属的讯息队列,一个专属的 Thread Local Storage(TLS)以及一个专属的结构化异常处理串链。此外,执行绪在执行过程中可能会索求、释放同步控制对象的拥有权。在看过 thread database 之后,我会解释所有这些东西。
什么是Thread Handle?什么是Thread ID?
本章稍早我曾说过 process handle 和 process ID 的不同。我的说明可以轻易地套到 thread
handle 和 thread ID 身上 -- 只要把「行程」改为「执行绪」就好。如果你不确定,请回
头去看「什么是 process handle?什么是 process ID?」那一节。
Thread Database
Thread Database 是一个 K32 对象(K32OBJ_THREAD),从 KERNEL32 共享资料区中配置而来。和 process database 一样,thread database 也并不是直接成为一个串行形式。Win32Wlk 的 THREADDB.H 文件中有 thread database 的 C 语言定义,格式如下:
l 00h DWORD Type
此栏为 6,表示 K32OBJ_THREAD 对象。
l 04h DWORD cReference
此栏内含执行绪的参用次数。
l 08h PPROCESS_DATABASE pProcess
这是一个指标,指向执行绪所属的行程。
l 0Ch DWORD someEvent
一个指针,指向 K32OBJ_EVENT 对象。Event 对象通常被交给WaitForSingleObject。这个 Event 对象正是你呼叫 WaitForSingleObject 函式时给予的 event。
l 10h DWORD pvExcept
这是一个指针,指向结构化异常处理的串链头(结构化异常处理将在稍后讨论)。请注意这个字段也标记了 task database 中的 TIB(Thread Information Block)巢状结构的起始处。TIB 亦将在稍后讨论。
l 14h DWORD TopOfStack
这个字段放的是执行绪堆栈的最高地址。一般而言保留给执行绪的堆栈大小是 1MB。
l 18h DWORD StackLow
这个字段放的是执行绪堆栈的低水位标记(以 page 为单位)。把TopOfStack 减去StackLow 就可以知道执行绪目前使用了多大的堆栈。
l 1Ch WORD W16TDB
这里存放的是 Win16 task database 的 selector。第7章会告诉你,每一个 Win32 行程都有一个 Win16 task database 和一个 Win32 process database。
l 1Eh WORD StackSelector16
Win32 码下移(thunk down)至 16 位码之前,必须先切换到一个 16 位堆栈。这个字段存放的即是该 16 位堆栈的 selector。
l 20h DWORD SelmanList
一个指标,指向执行绪的 SelmanList。"Selman" 意指 "Selector Manager"。KERNEL32 中的 Selman 似乎有责任管理 selectors,执行绪可以从中配置,作为各种用途。
l 24h DWORD UserPointer
此字段的精确意义未明。然而 TIB 结构的文件中说,这个字段可以给应用程序使用。别忘了,TIB 结构在 thread database 中是巢状的。
l 28h PTIB pTIB
这个字段指向 TIB(Thread Information Block)。Windows 95 的 TIB 位在thread database之中,所以此一指标其实是指向 thread database 的另一个字段:pvExcept 字段(偏移位置 10h)。
l 2Ch WORD TIBFlags
这个字段内含一些旗标值,给 TIB 使用。它们是:
TIBF_WIN32(0x0001) 此一执行绪来自 Win32 程序
TIBF_TRAP (0x0002) 某种异常处理(exception handling)
l 2Eh WORD Win16MutexCount
这个字段和 Win16Mutex(也被称为 Win16Lock)有点关系。通常此字段在Win32 执行绪中为 -1,在 Win16 执行绪中为 0。
l 30h DWORD DebugContext
如果执行绪所关系的行程正被除错,此字段将指向一个 debug context 结构。该结构之格式未知,但似乎有一些缓存器值放在其中。如果行程并非处在除错状态,此字段为 0。
l 34h PDWORD pCurrentPriority
此字段指向一个 DWORD,内含执行绪的优先权。该 DWORD 位于 0xC0000000 之上,那结结实实正是 VxD 的势力范围。
l 38h DWORD MessageQueue
此字段的低字组(low WORD)放置一个 Win16 global heap handle,用作执行绪的讯息队列。讯息队列是存放系统转来的窗口讯息的场所,将在第4章介绍。这个字段和W16TDB 的 1Ch 字段有密切关系。
l 3Ch DWORD pTLSArray
这个指针指向执行绪的 TLS 数组。数组中的项目被用于 TlsSetValue 函式家族。TLS 将在本章稍后描述。TLS 数组所用的内存位于 thread database 之后。
l 40h PPROCESS_DATABASE pProcess2
这个字段容器指标,指向此一执行绪所属之行程。它似乎总是和 08h 字段重复。
l 44h DWORD Flags
此园地内含各式各样的旗标值。下面是我所知道的一些旗标意义:
fCreateThreadEvent 0x00000001 执行绪正被除错中
fCancelExceptionAbort 0x00000002
fOnTempStack 0x00000004
fGrowableStack 0x00000008
fDelaySingleStep 0x00000010
fOpenExeAsImmovableFile 0x00000020
fCreateSuspended 0x00000040 呼叫 CreateProcess 时指定了
CREATE_SUSPENDED
fStackOverflow 0x00000080
fNestedCleanAPCs 0x00000100 APC = Asynchronous Procedure Call
fWasOemNowAnsi 0x00000200 ANSI/OEM 档案功能
fOKToSetThreadOem 0x00000400 ANSI/OEM 档案功能
l 48h DWORD TerminationStatus
这个字段将被 GetExitCodeThread 传回。执行绪的退出码可以经由 ExitThread 或TerminateThread 指定。如果执行绪尚在执行,此字段将为 0x103(STILL_ACTIVE)。
l 4Ch WORD TIBSelector
此字段十分重要,内含一个 selector,代表现行执行绪之 TIB (Thread Information Block)。TIB 内含极重要的信息,像是此一执行绪的异常处理的串链头(head of the exceptionhandler chain)。当 Windows 95 切换执行绪,会更改 FS 缓存器,存放此值。如此一来执行绪才能够经由 FS 缓存器获得这些重要数据。
l 4Eh WORD EmulatorSelector
这个字段可能是一个 selector,代表一块内存空间,其中存放80387 仿真器状态(可能是一个 FSAVE-style 的结构)。如果机器使用算术协同处理器(math coprocessor),此栏将为 0。
l 50h DWORD cHandles
这个字段的意义未明,似乎总是 0。
l 54h DWORD WaitNodeList
如果执行绪正在等待一个(以上)的 event 被激发,这个字段就会指向 VxD 区域中的一个由 event 节点所组成的串行。每一个节点内含一个指标,指向一个 event 对象;另含一个指标,指向正在等待 event 的那个执行绪。
l 58h DWORD un4
这个字段的意义未明。通常它不是 0 就是 2。
l 5Ch DWORD Ring0Thread
这个字段内含指标,指向执行绪的 ring0 THCB(Thread Control Block)。
l 60h PTDBX pTDBX
这个字段内含指针,指向 TDBX 结构。TDBX 是执行绪在 VWIN32.VXD 中的呈现,第6章对此结构有比较详细的描述。
l 64h DWORD StackBase
对 Win32 执行绪,此值表示执行绪堆栈的最低可能地址。以此值减堆栈最大地址(本结构的 14h 字段)你就可以计算出有多少地址空间保留给堆栈。对于 Win16 执行绪,此栏为 0。
l 68h DWORD TerminationStack
根据其名,这个字段应该内含执行绪结束程序中最初使用的 ESP 值。对 Win32 执行绪而言,此字段和 TopOfStack(14h 字段)相同。对于 Win16 执行绪,此字段内含一个地址,恰在 shared KERNEL32 heap 之下。
l 6Ch DWORD EmulatorData
我推测此字段是 80387 仿真器数据的 32 位线性地址。如果确实如此,那么此一字段就和 EmulatorSelector 字段(4Eh 字段)有关联。
l 70h DWORD GetLastErrorCode
此字段内含 GetLastError 的传回值。此值可以经由 SetLastError 设定。
l 74h DWORD DebuggerCB
如果执行绪是一个除错器执行绪(也就是说它呼叫 WaitForDebugEvent),则此字段内含指标,指向一块被除错器使用的数据,其中包括除错器的process database,threaddatabase,以及被除错者的 thread database。
l 78h DWORD DebuggerThread
如果执行绪正被除错中,此字段内含一个 non-NULL 值。其真正意义不得而知,因为它的值太低了,不是一个合法的指标。
l 7Ch PCONTEXT ThreadContext
这个指针指向 Intel 的 CONTEXT 结构(定义于 WINNT.H)。该结构内放的是非处于执行状态的执行绪的缓存器值。结构内容可以经由 GetThreadContext 函式和SetThreadContext 函式读写之。
l 80h DWORD Except16List
此字段意义未明。从名称看它似乎应该和异常处理有点关系。我的观察显示,它总是 0。
l 84h DWORD ThunkConnect
意义未明。从名称看它似乎应该和 thunking 动作有点关系。我的观察显示,它总是 0。
l 88h DWORD NegStackBase
如果你把这个字段加上 StackBase 字段(64h 字段),总是得到 FFEF9000。不要问我为什么。
l 8Ch DWORD CurrentSS
这个字段内含一个 16 位堆栈 selector ,此与 32 位码下移(thunk down)至 16 位元码有关。此字段似乎非常近似 StackSelector16 字段(1Eh 字段),我不太清楚两者之间的差异。
l 90h DWORD SSTable
这个指针指向一块内存,内含的信息与「32 位码下移(thunk down)至16 位码」有关。
l 94h DWORD ThunkSS16
这个字段又是内含一个与下移(thunk down)动作有关的信息。在某些执行绪中,它和StackSelector16 字段(1Eh 字段)吻合,而在另一些执行绪中,它又和 CurrentSS 字段(8Ch 字段)相同。
l 98h DWORD TLSArray[64]
这是 64 个 DWORDs 组成的一个数组。每一个 DWORD 是「TLSGetValue 函式根据已知之 TLS ID 而传回的值」。例如,第一个 DWORD 是 TLSGetValue(0) 的传回值,第二个 DWORD 是 TLSGetValue(1) 的传回值,依此类推。TLS 将于稍后描述。
l 198h DWORD DeltaPriority
这个字段内含「此执行绪之优先权」与其「所属行程之优先权等级」之间的差异。它可能是:
THREAD_PRIORITY_LOWEST -2
THREAD_PRIORITY_BELOW_NORMAL -1
THREAD_PRIORITY_NORMAL 0
THREAD_PRIORITY_ABOVE_NORMAL 1(译注:原书为2,是错误的)
THREAD_PRIORITY_HIGHEST 2(译注:原书为1,是错误的)
l 19Ch DWORD un5[7]
这个字段的意义未明。它似乎总是 0。
l 1B8h DWORD pCreateData16
如果此字段不是 0,它就是指向一个结构。该结构有两个 32 位指针:
00h pProcessInfo 一个 PPROCESS_INFORMATION
04h pStartupInfo 一个 PSTARTUPINFO
不过根据我的观察,此字段总是为 0。
l 1BCh DWORD APISuspendCount
每当呼叫 SuspendThread 一次,这个字段就累加 1;每当呼叫 ResumeThread 一次,这个字段就累减 1。
l 1C0h DWORD un6
这个字段的意义未明。
l 1C4h DWORD WOWChain
此字段根据推测与 Windows 95 对于 WOW(Windows on Windows)的支持有关。WOW是 Windows NT 在其保护地址空间中执行 16 位程序的方法,可以阻止它们破坏 32位程序。根据我的观察,此字段总是为 0。
l 1C8h WORD wSSBig
从其名称来看,此字段内含一个平滑模式(flat modal)的 32 位 selector,当作是堆栈节区。然而根据我的观察,此字段总是为 0。
l 1CAh WORD un7
这个字段的意义未明。它可能只是用来填充空间,以使后续字段得以 DWORD 为边界。
l 1CCh DWORD lp16SwitchRec
这个字段的意义未明。从其名称来看,它可能和 Win16 thunking 动作有关。
l 1D0h DWORD un8[6]
这五个字段似乎总是 0。
l 1E8h DWORD pSomeCritSect1
这样字段指向一个 K32OBJ_CRITICAL_SECTION 对象。此一对象在每一个行程中都不一样,它的目的我还不十分清楚。这个字段似乎总是和 pSomeCritSect2 相同。
l 1ECh DWORD pWin16Mutex
这个指标指向 KRNL386.EXE 中的 Win16Mutex。
l 1F0h DWORD pWin32Mutex
这个指标指向 KERNEL32.DLL 中的 Win32Mutex。
l 1F4h DWORD pSomeCritSect2
这样字段指向一个 K32OBJ_CRITICAL_SECTION 对象。此一对象在每一个行程中都不一样。这个字段似乎总是和 pSomeCritSect1 相同。
l 1F8h DWORD un9
这个字段的意义未明。似乎总是 0。
l 1FCh DWORD ripString
从其名称来看,这个字段应该是一个字符串指针,该字符串将在 FatalAppExit RIP 中使用。然而,大部份情况下此字段为 0;如果不是 0,根据我的观察,它却又不指向一个字符串。
l 200h DWORD LastTlsSetValueEIP[64]
64 个 DWORDs 所组成的这个数组,平行于 TLS 数组(thread database 的 98h 偏移处)。数组中的每一笔项目和一个 TLS 索引值有关,含有 EIP 值。EIP 值是从堆栈中取出(由 TlsSetValue 设定)。
最后我要再补充一点:获得 thread database 指标的方法不只一种。除了前面我提过的XOR
把戏外,每一个 Win16 task database 也内含一个指标指向对应的thread database。Task
database 的 54h 字段放置有一个线性地址,代表第一个执行绪的thread database。
Thread Information Block(TIB)
在 thread database 之中,有一些字段对于执行中的程序极为有用。事实上,它们是如此有用,以至于 Win32 架构让它们可以立刻被取用,不需经过 thread database。这些字段被放置到一个名为 Thread Information Block(TIB)的结构中。Thread database 的 10h~3Ch字段统统被放到 TIB 之中。
应用程序如何取用 TIB?如果你看过 Win32 程序的汇编语言码,你会发现 FS 缓存器有频繁的使用率。等等,Win32 不是会搬移节区吗?虽然答案是 Yes,但是Win32 底层(包括 Windows NT、Windows 95、Win32s)都奉献出 FS 缓存器,用以指向目前执行中的执行绪的 TIB。Win32 并不是第一个这么做的操作系统,OS/2 2.0 早就如此了。就像你所感觉到的,是的,当 Windows 95 切换执行绪,排程器必须更改 FS 缓存器值,让它内含一个 selector,指向新的 TIB。
FS 缓存器和 TIB 的主要用途就是增加结构化异常处理串链(structured exception handlingchain)的项目个数。串链的头放在TIB 的 0 偏移处,所以当你看到汇编语言码使用FS:[0],你就知道它正在做某些与结构化异常处理有关的动作了。TIB 的另两个字段也十分广泛地被使用,它们是 pvQueue 和 pvTLSArray (分别是 28h和 2Ch 字段)。pvQueue 字段内放的是现行执行绪的讯息队列的handle,此字段常常被USER.EXE 的窗口系统使用。因为在 Windows 95 之中,「焦点窗口(focus window)」并非整个系统只一个。pvTLSArray 则指向thread database 中的 TLS 数组。编译器厂商把它和可执行档的 .tls section 联用,提供透明化的所谓 ”per-thread global variable”。
虽然 TIB 结构的布局可以从 thread database 中推算而来,我要在这里提供一点摘要。Win32Wlk 的 TIB.H 文件中有 TIB 的 C 语言结构定义。
Windows 95 的 TIB 内容如下:
00h DWORD pvExcept
04h DWORD TopOfStack
08h DWORD StackLow
0Ch WORD W16TDB
0Eh WORD StackSelector16
10h DWORD SelmanList
14h DWORD UserPointer
18h PTIB pTIB
1Ch WORD TIBFlags
1Eh WORD Win16MutexCount
20h DWORD DebugContext
24h PDWORD pCurrentPriority
28h DWORD MessageQueue
2Ch DWORD pTLSArray
如果想知道每一个字段的意义,把其偏移值加 10h,然后看看上一节的结构内容,便知道了。请注意,其中只有一些字段对于其它 Win32 平台是共通的。
Thread Priority(执行绪优先权)
Windows 95 的 VMM 核心排程系统并不真正在乎行程,它只在乎执行绪的优先权,而不管执行绪属于哪一个行程。换句话说,行程也不真正拥有优先权。当然啦,对于执行绪排程服务的终端使用者(也就是应用程序)而言,「行程有优先权」是一种比较有用的抽象想法。任何时候,拥有最高优先权的执行绪,而且它又没有什么东西要等待的话,将会是即将执行的一个。为了确保平顺地让系统运转并避免许多问题,执行绪优先权可以动态地被系统改变。例如,当一个执行绪正在进行 I/O 动作,它的优先权可以暂时性地提高。更详细的谈论这个题目,会耗掉大半章。因此,我将把对执行绪优先权的讨论放在另一本书籍中(或是未来的杂志文章中)。
Windows 95 的 VMM 排程器支持 32 种优先权。这 32 个优先权被区分为四个等级,称为优先权等级(priority classes),每一个等级关系到一组优先权。在一个等级之中,优先权可以上下增减 2。但是也有特殊情况如THREAD_PRIORITY_LEVEL,它可以使优先权完全跳出其等级的规范之外。除非有特别指定,否则操作系统产生一个行程时给予的优先权等级是NORMAL_PRIORITY_CLASS。四个优先权等级的预设优先权以及上下摆荡范围是:
IDLE_PRIORITY_CLASS 4 2~6
NORMAL_PRIORITY_CLASS 9 或7 (前景为 9,背景为 7)6~10
HIGH_PRIORITY_CLASS 13 11~15
REALTIME_PRIORITY_CLASS 24 16~31
执行绪优先权为1 是一种特殊情况。凡是 IDLE_PRIORITY_CLASS 、NORMAL_PRIORITY_CLASS 或 HIGH_PRIORITY_CLASS 三个等级, 都可以经由SetPriorityClass 函式设定优先权为 1。
注意,Windows 95 排程器中的这 32 个优先权,其数值并非对应于 WINBASE.H 的定义。例如, NORMAL_PRIORITY_CLASS 在 WINBASE.H 中定义为 0x20 。KERNEL32.DLL 将这些值映射为适当的 Windows 95 排程器优先权。
结构化异常处理(Structured Exception Handling)
结构化异常处理(Structured Exception Handling,简写为 SEH)在现代化操作系统如OS/2、Windows NT、Windows 95 中,是一个被大肆宣传并且常常被误解的主题。大部份谈到它的书籍和文章都把它放在编译器层面来说。编译器利用一些如 __try、__except、catch、throw 等的保留字,把零乱的操作系统基础接口包装起来。
当一个异常情况(例如 page fault)发生,CPU 会立刻把控制权转给 ring0 的异常处理函式,后者地址存放在中断描述表格(interrupt descriptor table)中。ring0 处理函式才能够决定该如何善后。如果这是一个系统知道怎么处理的异常情况,ring0 码就做必要的措施,然后让指令继续下去。这些异常情况基本上对 ring3 码以及 system DLLs 是不可见的,而且此处我们也不关心它们。此处我们所关心的是,万一系统不知道如何处理这个异常情况,怎么办?老旧的作业系统的典型反应就是把引起异常情况的行程砍掉。这也就是为什么你会看到有臭虫的程序引发一个 UAE 对话盒(在 Windows 3.0 中)或一个GPF 对话盒(在 Windows 3.1 中)然后壮烈身亡的画面。
虽然「将任何引起不可预期之错误的程序结束掉」的哲学无可挑剔,但它毕竟没有包容性。比较好的作法是通知应用程序(或其它应用程序)让它们自己决定怎么办。如果你使用 Win16 TOOLHELP 的 InterruptRegister 函式,你就会看到这种策略的实现。应用程序可以经由它向系统登记一个 callback 函式,用以处理中断和异常情况(但是每一个行程只能有一个处理函式)。当预期的中断或异常情况发生,TOOLHELP 就会呼叫登记过的 callback 函式,并根据其传回值决定如何处理这个异常。WinSpector 或 Dr. Watson这类程序就是利用这种方式,把异常情况下的机器状态记录下来。然后,它们再告诉TOOLHELP 把异常情况交给下一个处理函式。假设没有任何一个TOOLHELP callback 函式将引发异常的指令重新执行起来,系统预设的异常处理函式就会被唤起,砍掉罪魁祸首。
虽然 TOOLHELP 的 callback 体制算是向前迈进一大步(比起完全没有控制好多了),它仍然不够好。32 位 PC 操作系统如 OS/2 和 NT 引入一个比较更有弹性的处理方法。新的方法就是我们所说的结构化异常处理(SEH)。在处理多执行绪以及利用 C++ catch/throw 机制等方面,SEH 表现比以前的方法好得多。对于 C++ 异常情况,应用程式本身可以引发一个和 CPU 异常情况截然不同的异常情况。假设 C++ 的 new 运算子失败,它会丢出一个代表内存不足的异常情况。32 位操作系统的 SEH 机制有足够弹性,以相同的码一并处理语言的异常和硬件的异常。在继续进行之前,我要再次强调我要谈的 SEH 是它们在操作系统层面的实作情况。下面的叙述可能和你在 C/C++ 课程中所受的训练完全不同。在一个拥有 SEH 的系统中,每一个执行绪有它自己的私有串行,内含安装好异常处理函式。当一个异常情况发生,操作系统走访该串行,并呼叫其中的适当函式。这样的动作一直持续到某个函式传回代码,表示它要处理这个异常情况为止。这就是第一阶段:找到正主儿。如果这些函式无一可以处理此一异常情况,系统就会出面,把闹事儿的行程砍掉。我们不关心最后这种情节,因为操作系统把行程砍掉是很简单的事情。当你获得了一个函式用来处理异常情况,第二阶段就是重新再走访串行一遍。未公开函式 RtlUnwind 为我们做这檔事,它被那个「决定处理此一异常情况的处理函式」所呼叫。当 RtlUnwind 一一触发那些串行中的函式,系统会交给它们一个旗标。这个旗标告诉函式说执行绪的堆栈目前正被 "unwound"(译注)。将堆栈 "Unwinding",是「把程序状态恢复到异常处理函式被安装时的状态」的一种方法。不只在 __except 区块中重新恢复执行,系统还给予每一个「被安装,但是不处理此一异常情况」的函式一个机会清理自己。给予这个机会之后,重要的事情如「呼叫堆栈中的 C++ 对象的解构式」就可以在一种有纪律的情况下完成。
理论够多了,Windows 95 真正使用的结构和接口到底是什么呢?先前我介绍 TIB 时曾说过,FS:[0] 总是指向现行执行绪之异常处理函式的串行头,异常处理函式所组成的串列是一个 EXCEPTIONREGISTRATIONRECORD 串行。这个长长的名称来自于 OS/2 2.0的 BSEXCEPT.H 檔。为了某些理由,微软似乎企图对一般人隐藏 SEH 在操作系统层面的信息。EXCEPTIONREGISTRATIONRECORD 结构看起来像这样:
DWORD prev_structure // A pointer to the previously installed
// EXCEPTIONREGISTRATIONRECORD
DWORD ExceptionHandler // Address of the exception handler function.
串行最后是以 -1(prev_structure)表示结束。正常情况下, 程序会依需要从堆栈中挖出空间来制造EXCEPTIONREGISTRATIONRECORD 。在 C/C++ 程序中, 每一个EXCEPTIONREGISTRATIONRECORD 对应一个 __try/__except 区块。当程序进入 __try区块,编译器就在堆栈中产生一个新的 EXCEPTIONREGISTRATIONRECORD 并把它放到串行起头处。离开 __except 区块之后,编译器设定 FS:[0] 指向串行中的下一个EXCEPTIONREGISTRATIONRECORD。图3-3 显示这些被串链起来的资料。
记住,上述 8 字节的结构只是操作系统的最小需求而已。没有什么可以阻止编译器在堆栈之中产生更大的结构并且把 EXCEPTIONREGISTRATIONRECORD 放在结构起首。编译器从上述结构中获得的其它字段可以提供足够的信息,使单独一个异常处理函式适用于所有的 __try 区块。微软公司和 Borland 公司的编译器都使用EXCEPTIONREGISTRATIONRECORD 的扩充结构。
说到异常处理函式,到底它长得什么样子?再一次,微软似乎企图隐藏某些信息,但至少 Win32 表头檔提供了一个函式原型。在 EXCPT.H 檔中,你可以看到这样的原型:
EXCEPTION_DISPOSITION __cdecl _except_handler (
struct _EXCEPTION_RECORD *ExceptionRecord,
void * EstablisherFrame,
struct _CONTEXT *ContextRecord,
void * DispatcherContext
);
乍见之下它彷佛太过复杂了。传回值 EXCEPTION_DISPOSITION 其实只不过是个enum,告诉系统说此一函式如何被用来处理异常情况:
typedef enum _EXCEPTION_DISPOSITION {
ExceptionContinueExecution,
ExceptionContinueSearch,
ExceptionNestedException,
ExceptionCollidedUnwind
} EXCEPTION_DISPOSITION;
最后两项很少会遇到。至于第一项 ExceptionContinueExecution 是告诉系统说异常处理函式已经处理了该异常情况,并打算让执行继续下去。ExceptionContinueSearch 则是告诉系统说异常处理例程不打算处理这个异常情况,系统应该继续走访EXCEPTIONREGISTRATIONRECORD 串行, 直到某个处理函式传回ExceptionContinueExecution。把 _except_handler 函式原型重写一遍,看起来就可接受多了:
int _except_handler (
PEXCEPTION_RECORD ExceptionRecord,
PVOID EstablisherFrame,
PCONTEXT ContextRecord,
PVOID DispatcherContext );
我们发现,一个异常处理函式需要四个指标参数,指向异常情况以及机器状态等信息。这个函式传回一个整数,告诉系统它是否把异常处理好了。EXCEPTION_RECORD 结构内含异常代码,以及其它东西。WINNT.H 对此有些说明。CONTEXT 结构内含异常发生时的缓存器内容,WINNT.H 对此也有说明。EstablisherFrame 参数内含一个指标,指向堆栈 -- EXCEPTIONREGISTRATIONRECORD 结构的设定处。DispatcherContext 参数则似乎没有用到。稍早我曾说过,处理函式会被呼叫两次。第一次是系统寻找适当处理者的时候。第二次是为了系统要 "unwinding",而处理函式被认为会进行任何必要的清理工作(像是呼叫堆迭物件的解构式等等)。这两次启动之间的差别在哪里?ExceptionRecord 结构(被第一个参数所指) 内含一个 ExceptionFlags 旗标, 如果 EH_UNWINDING ( 0x2 ) 或EH_EXIT_UNWIND(0x4)旗标并未设立,那么就是前述第一种情况;如果两个旗标之中有一个设立,那么就是前述第二种情况。
虽然我所说的不足以让你写一个自己的操作系统层面的异常情况处置码,但是因为足够让你了解 SEH 是如何运作的了。为了证明我不是在胡乱吹大气,我写了一个SHOWSEH程序,放在书附磁盘之中。SHOWSEH 利用 __try 段落来设立异常处理串链。统统设立好之后,这个程序即走访 SEH 串行并印出每一个节点的内容。SHOWSEH 的输出结果请看图3-4。我要你注意数点。第一,请注意 "next rec" 字段总是递增,那是编译器用来放置 EXCEPTIONREGISTRATIONRECORD 的堆栈区域。最前面四笔资料反应出 SHOWSEH.C 中的 __try 段落。第二,请注意前四笔资料有一个不变的 ESP 值。输出画面的最后一行则显示 SHOWSEH.C 之中每一个函式的 ESP 值。offset of __except_handler3: 00401468
next rec handler
======== =======
0063FD90 00401468
0063FDC0 00401468
0063FDF0 00401468
0063FE30 00401468
0063FF68 00401468
FFFFFFFF BFFC2D18
in c(), ESP = 0063FD84
in b(), ESP = 0063FD78
in a(), ESP = 0063FDA8
in main(), ESP = 0063FDD8
最后一件值得注意的事情是,最前面五笔数据的 handler 字段都一样,地址落于SHOWSEH 的程序区域(而非数据区域)中,显示出编译器所产生的码对于每一个 __try段落都使用相同的异常处理函式。刚才我说过了,前四笔是SHOWSEH.C 的四个 __try 形成的。至于第 5 个则是在呼叫 main 之前由执行时期函式库安装的。这些处理例程的位址统统都是 __except_handler3 -- Visual C++ 的执行时期函式库中的一个函式。最后一个异常处理函式是预设的系统处理函式,位于 KERNEL32.DLL 中。
Thread Local Storage(执行绪区域储存空间)
TLS 是一个良好的 Win32 特质,让多执行绪程序设计更容易一些。TLS 是一个机制,经由它,程序可以拥有全域变量,但处于「每一执行绪各不相同」的状态。也就是说,行程中的所有执行绪都可以拥有全域变量,但这些变量其实是特定对某个执行绪才有意义。例如,你可能有一个多执行绪程序,每一个执行绪都对不同的档案写檔(也因此它们使用不同的档案 handle)。这种情况下,把每一个执行绪所使用的档案 handle 储存在 TLS 中,将会十分方便。当执行绪需要知道所使用的 handle,它可以从 TLS 获得。重点在于:执行绪用来取得档案 handle 的那一段码在任何情况下都是相同的,而从 TLS中取出的档案 handle 却各不相同。非常灵巧,不是吗?有全域变数的便利,却又分属各执行绪。
当然,你可以使用串行,让一个档案 handle 与一个 thread ID 产生关系,每一个执行绪有一个节点。用此来模拟 TLS。当执行绪需要知道它使用哪一个档案 handle,它可以从串行中寻找档案 handle。你当然可以把档案 handle 储存在区域变量中(位于执行绪的堆栈)。但是却因此必须把这个 handle 在函式与函式之间传来传去。那多痛苦。TLS 可以利用简单的 alloc/set/get/free 函式消除这些问题。虽然 TLS 很方便,它并不是毫无限制。在Windows NT 和Windows 95 之中,有 64 个DWORD slots 供每一个执行绪使用。这意思是一个行程最多可以有64 个「对各执行绪有不同意义」的DWORDs。为了在每个执行绪中保留一个slot,程序应该呼叫 TlsAlloc。每次呼叫 TlsAlloc 就传回一个可被所有执行绪使用的索引值。这个索引值常常被储存在全域变数中。当执行绪要对一个 slot 写入数据,它使用 TlsSetValue,交待一个 TLS 索引以及一笔数据。稍后当执行绪要取出此值,它呼叫TlsGetValue,再次交待一个 TLS 索引。最后,程序呼叫TlsFree 并交待一个TLS 索引,将slot 释放掉。这么一来当然也就让 slot 不再能够被任何执行绪使用,因为TLS 索引值在各执行绪之间是共通的。
虽然 TLS 可以存放单一数值如档案 handle,更常的用途是放置指标,指向执行绪的私有资料。有许多情况,多执行绪程序需要储存一堆数据,而它们又都是与各执行绪相关。许多程序员对此的作法是把这些变量包装为C 结构,然后把结构指针储存在 TLS 中。当新的执行绪诞生,程序就配置一些内存给该结构使用,并且把指针储存在为执行绪保留下来的 TLS 中。一旦执行绪结束,程序代码就释放所有配置来的区块。这种程序风格的最佳示范就是第 10 章的 APISPY32。APISPY32.DLL 需要保持一个堆迭,用来传回它所拦截的函式地址(我在这里使用古典的计算器科学术语「堆栈」,事实上我指的是一个结构数组,以及一个堆栈指针)。由于被拦截的程序可能有许多执行绪,APISPY32.DLL 必须针对每一个执行绪保留各自的传回地址。如果每一个执行绪有 64 个 slots 用来储存执行绪自己的数据,这些空间打哪儿来?稍早我曾说过,每一个 thread database 有 64 个DWORDs 给 TLS 使用。当你以 TLS 函式设定或取出数据,事实上你真正面对的就是那 64 DWORDs。没有任何公开文件告诉我们可以存取其它执行绪的 TLS。让我们更详细地看看这些 TLS 函式。
TlsAlloc
由于 TLS 只提供最多 64 slots 给每一个执行绪使用,所以必须有某种方法追踪哪一个slot 已被使用。KERNEL32 使用两个 DWORDs(总共 64 个位)来记录哪一个 slot 是可用的、哪一个 slot 已经被用。这两个 DWORDs 可想象成为一个 64 位数组,如果某个位设立,就表示它对应的 TLS slot 已被使用。这 64 位 TLS slot 数组存放在 process database 中(可能你会猜想在thread database中,不,不是这样)。记住,当你配置一个 TLS slot,这个 slot 可以在行程所属的任何执行绪中被该索引值参考到。64 位的 TLS slots 数组放在 process database 的 0x88 和0x8C 两个 DWORD 字段。虽然下面的TlsAlloc 函式伪码可能看起来有点复杂,事实上并不会。其中所做的只不过是扫描 64 位数组中的位,看看有没有哪一个是 0。如果找到,就把它改为 1,并传回其数组位置(索引值)。因此,如果第 5 个位是 0,TlsAlloc 就把它改为 1 并传回 4(索引值从 0 开始)。
TlsSetValue
TlsSetValue 可以把数据放入先前配置到的 TLS slot 中。两个参数分别是TLS slot 索引值以及欲写入的数据内容。函式首先检查数组索引是否合法(小于 64)。Windows 95 的早期版本还会检查此一索引是否的确被配置了,但是在beta3 版本中就只做上述的最简单检查。如果索引值的确小于 64,TlsSetValue 就把你指定的数据放入 64 DWORDs 所组成的数组(位于目前的 thread database)的适当位置中。除此之外,TlsSetValue 还更新第二个 64 DWORDs 数组。这个数组内含 EIP 值,TlsSetValue 上一次就是在那里被呼叫。很明显,这些 EIP 是为了除错用的。微软并没有提供什么方法让应用程序取用这块数据。
TlsGetValue
这个函式几乎是 TlsSetValue 的一面镜子,最大的差异是它取出数据而非设定数据。和TlsSetValue 一样,这个函式也是先检查 TLS 索引值合法与否。如果是,TlsGetValue 就使用这个索引值找到 64 DWORDs 数组(位于 thread database 中)的对应数据项,并将其内容传回。
TlsFree
这个函式将 TlsAlloc 和 TlsSetValue 的努力全部抹消掉。TlsFree 先检验你交给它的索引值是否的确被配置过。如果是,它将对应的 64 位 TLS slots 位关闭。然后,为了避免那个已经不再合法的内容被使用,TlsFree 巡访行程中的每一个执行绪,把 0 放到刚刚被释放的那个 TLS slot 上头。于是呢,如果有某个 TLS 索引后来又被重新配置,所有用到该索引的执行绪就保证会取回一个0 值,除非它们再呼叫 TlsSetValue。