再谈Windows NT/2000环境切换
WebCrazy(tsu00@263.net)
线程是Windows NT/2000环境切换的最基本单位。在<<浅析Windows NT/2000环境切换>>(Nsfocus Magazine 12)一文中,我只对进程CR3切换进行了较详细的讨论,但未涉及线程调度的内容,本文将尽量讲述这些部分内容。在这之前,还是先看看以下的代码:
//-----------------------------------------------
//
// EnumThreads-information from KPEB and KTEB
// Only test on Windows 2000 Server Chinese Edition
// Build 2195(Free)!Programmed By WebCrazy
// (tsu00@263.net) on 5-23-2000!
//
//-----------------------------------------------
#define KTEBListOffsetKPEB 0x50
#define PIDOffset 0x9c
#define KPEBListOffset 0xa0
#define ProcessNameOffset 0x1fc
#define StackTopOffset 0x18
#define StackBtmOffset 0x1c
#define UserTEBOffset 0x20
#define StackPtrOffset 0x28
#define KTEBListOffset 0x1a4
#define KTEBPIDOffset 0x1e0
#define TIDOffset 0x1e4
void DisplayThreadFromKPEB(void *kpeb)
{
char ProcessName[16];
ULONG PID;
ULONG TID;
ULONG StackBtm,StackTop,StackPtr,UserTEB;
PLIST_ENTRY KTEBListHead, KTEBListPtr;
KTEBListHead=KTEBListPtr=(PLIST_ENTRY)((int)kpeb+KTEBListOffsetKPEB);
do
{
void *kteb;
kteb=(void *)((*(ULONG *)KTEBListPtr)-KTEBListOffset);
TID=*(ULONG *)(((char *)kteb)+TIDOffset);
StackBtm=*(ULONG *)(((char *)kteb)+StackBtmOffset);
StackTop=*(ULONG *)(((char *)kteb)+StackTopOffset);
StackPtr=*(ULONG *)(((char *)kteb)+StackPtrOffset);
UserTEB=*(ULONG *)(((char *)kteb)+UserTEBOffset);
memset(ProcessName, 0, sizeof(ProcessName));
memcpy(ProcessName, ((char *)kpeb)+ProcessNameOffset, 16);
PID=*(ULONG *)(((char *)kpeb)+PIDOffset);
// or PID=*(ULONG *)(((char *)kteb)+KTEBPIDOffset);
DbgPrint(" %04X %08X %08X %08X %08X %08X %s(%X)\n",
TID,kteb,StackBtm,StackTop,StackPtr,UserTEB,ProcessName,PID);
KTEBListPtr=KTEBListPtr->Flink;
}while (KTEBListPtr->Flink!=KTEBListHead);
}
void EnumThreads()
{
PLIST_ENTRY KPEBListHead, KPEBListPtr;
if(((USHORT)NtBuildNumber)!=2195){
DbgPrint("Only test on Windows 2000 Server Build 2195!\n");
return;
}
DbgPrint("\n TID KTEB Addr StackBtm StackTop StackPtr User TEB Process
Name (PID)");
DbgPrint("\n ---- -------- -------- -------- -------- -------- -------
----- -----\n");
KPEBListHead=KPEBListPtr=(PLIST_ENTRY)(((char *)PsInitialSystemProcess)+KPEBListOffset);
while (KPEBListPtr->Flink!=KPEBListHead) {
void *kpeb;
kpeb=(void *)(((char *)KPEBListPtr)-KPEBListOffset);
DisplayThreadFromKPEB(kpeb);
DbgPrint("\n");
KPEBListPtr=KPEBListPtr->Flink;
}
}
这段代码列出的EnumThreads函数在Windows 2000 Server Build 2195中的输出结果如下:
TID KTEB Addr StackBtm StackTop StackPtr User TEB Process Name(PID)
---- -------- -------- -------- -------- -------- -----------------
0004 FE4E19E0 F9019000 F901C000 F901B9C4 00000000 System(8)
000C FE4E0C80 F9021000 F9024000 F9023D34 00000000 System(8)
0010 FE4E0A00 F9025000 F9028000 F9027D34 00000000 System(8)
0014 FE4E0780 F9029000 F902C000 F902BD34 00000000 System(8)
0018 FE4E0500 F902D000 F9030000 F902FD34 00000000 System(8)
001C FE4E0280 F9031000 F9034000 F9033D34 00000000 System(8)
.
.(略)
.
从运行结果可知EnumThreads主要是实现将系统当前所有的线程列出,上面的输出格式与SoftICE的thread命令一致。代码中使用了一些Undocumented的KPEB/KTEB数据项:
1.进程的线程链表
这是一个LIST_ENTRY结构的项(占用两个32位的指针即8字节),位于KPEB后的50h处,上面代码由KTEBListOffsetKPEB表示。
2.线程链表相对KTEB的偏移
位于KTEB后1a4h处(KTEBListOffset定义)。
输出结果中的如StackBtm、StackTop、StackPtr等请参阅<<SOFTICE COMMAND REFERENCE>>,它们在KTEB中的位置请直接看代码前的定义。
在理解了EnumThreads程序段与我上次给出的实现EnumProcesses的底层代码后,也差不多明白了Windows NT/2000是如何组织、管理进程与线程了,这对理解线程调度可是至关重要的。虽然如此但要讨论线程调度,还是再看看几个重要的数据(我不再具体说明如何取得这些数据具体位置的方法了,如果您很想知道还是建议您再看看<<浅析Windows NT/2000环境切换>>):
1. 进程状态(Status) //KPEB+65h UCHAR
典型的进程状态有:Running、Ready与Idle。
应该说明的是在单处理器的机子中处于Running状态的进程只有一个,且其不受KPEB中的这个值约束,系统通过调用IoGetCurrentProcess内核例程获得。我在<<再谈Windows NT/2000内部数据结构>>对IoGetCurrentProcess进行了比较详细的介绍。
当KPEB中Status值为0时,进程状态为Ready;为1时进程状态为Idle;为2时进程状态为Transition等等。
正像前面所提及的线程是Windows NT/2000环境切换的基本单位,实际上系统并不执行进程,进程状态和以下将要提及的进程优先级是很抽象的概念,只是系统调用时对线程的范围限制,Microsoft提出这些概念我想主要是隐藏系统内部Thread调度行为。但在有线程状态的前提下其也不是说就随便附个值即可。曾有次我将System进程的状态从Ready改为Transition后,只能眼巴巴的看着屏幕上的程序代码,不能存盘。因为此时系统已经变得懒洋洋的,不再响应我的千呼万唤了。
2. 线程状态 //KTEB+2dh UCHAR
在KTEB中有一成员State主要是指出当前线程状态,其位于KTEB+2d处(单字节)。它主要有如下几个值(值取自SoftICE的输出结果):
0 - Initialized (表示State的值为0时,表示线程状态为Initialized,以下类同)
1 - Ready
2 - Running
3 - StandBy
4 - Terminated
5 - Waiting
6 - Transition
在David Solomon与Mark Russinovich的<<INSIDE MICROSOFT WINDOWS 2000,THIRD EDITION>>中是如此描述的:
To quote:
--------
The thread states are as follows:
Ready
When looking for a thread to execute, the dispatcher considers only the pool of threads in the
ready state. These threads are simply waiting to execute.
Standby
A thread in the standby state has been selected to run next on a particular processor. When
the correct conditions exist,the dispatcher performs a context switch to this thread. Only one
thread can be in the standby state for each processor on the system.
Running
Once the dispatcher performs a context switch to a thread, the thread enters the running
state and executes. The thread's execution continues until the kernel preempts it to run a higher priority thread, its quantum ends, it terminates, or it voluntarily enters the wait state.
Waiting
A thread can enter the wait state in several ways: a thread can voluntarily wait on an object to synchronize its execution, the operating system (the I/O system, for example) can wait on the thread's behalf, or an environment subsystem can direct the thread to suspend itself. When the thread's wait ends, depending on the priority, the thread either begins running immediately or is
moved back to the ready state.
Transition
A thread enters the transition state if it is ready for execution but its kernel stack is
paged out of memory. For example, the thread's kernel stack might be paged out of memory. Once
its kernel stack is brought back into memory, the thread enters the ready state.
Terminated
When a thread finishes executing, it enters the terminated state. Once terminated, a thread
object might or might not be deleted. (The object manager sets policy regarding when to delete
the object.) If the executive has a pointer to the thread object, it can reinitialize the thread
object and use it again.
Initialized
Used internally while a thread is being created.
--------
我以下主要对waiting的状态进行分析:
在Windows NT/2000中线程能调用KeWaitForSingleObject、KeWaitForMultipleObjects等自动放弃自己的执行时间总量(Quantum)。系统当前执行的线程由系统中的Processor Control Block(PRCB,注意与 Processor Control Region区别)中的CurrentThread成员指定。还记得我介绍过的如何得到当前线程吗(取FS:124H中的DWORD值)?其实就是指向这个CurrentThread成员了。PRCB的定义KPRCB在ntddk.h中。系统通过如下函数获得KPRCB指针:
_KeGetCurrentPrcb
0008:80465310 MOV EAX,[FFDFF020] 取KPCR(Processor Control Region)成员Prcb
0008:80465315 RET
系统当前线程状态为Running。其它线程状态由几个(通常最大为THREAD_WAIT_OBJECTS+1个,否则就会出现BSOD,但Microsoft还定义了个MAXIMUM_WAIT_OBJECTS,这就要看您传递给系统的参数了)KWAIT_BLOCK结构表示,这些值以及以下将要谈到的表示线程等待理由的KWAIT_REASON也均可从ntddk.h中找到。线程KWAIT_BLOCK结构数据处于KTEB+6ch处。上次我提到的发生context switch的两种情况,要么可以用event,semphore等同步对象,要么可以用timer内核对象表示,这样可以形成线程等待对列,来表示线程当前状态。
由于KWAIT_BLOCK、KWAIT_REASON、还有event、timer等在Windows NT/2000中是少有的几个Documented成员,您在知道KWAIT_BLOCK的具体位置后,大可以自己读出线程等待队列。不过SoftICE已经为你呈现了所有这些内部结构了。
从上分析对于线程状态,牵涉到比较多的内容,我将一部分分析抄录如下:
:u _KeReadStateThread
_KeReadStateThread
0008:8042F029 MOV EAX,[ESP+04]
0008:8042F02D MOV AL,[EAX+04]
0008:8042F030 RET 0004
:bpx _KeReadStateThread if (tid==_tid)
:bl //这命令后退出调试器
00) BPX _KeReadStateThread IF (TID==0x3BC)
Break due to BPX _KeReadStateThread IF (TID==0x3BC) (ET=22.28 seconds)
//分析一下_KeReadStateThread的第一个参数(也是唯一的参数)
:what dword(@(esp+04)) //您应该理解每个线程,每个时刻线程状态由哪个内核对象确定都是不固定的吧
The value FF6811E0 is (a) Kernel Timer object (handle=0230) for explorer(398)
| |
|_Timer内核对象 |_这个对象在explorer进程中的句柄
:timer dword(@(esp+04))
Timer Object at FF6811E0
Dispatcher Type: 08
Dispatcher Size: 000A
Signal State: Signaled
.
.(略)
.
//SoftICE中的timer命令只是读出timer对象数据
//所以你可以直接读DISPATCHER_HEADER(Common dispatcher object header)中的SignalState成员(见ntddk.h)
//即下面这个命令
:? #byte(@(@(esp+04)+4))
00000001 0000000001 "" //1代表Signaled,试过将其从1改为0吗?(从Signaled改为Not Signaled)
好了以上分析的这个线程当前状态取决于timer对象(Object Pointer:0xFF6811E0)的状态(Jeffrey Richter说Signaled表示When time comes due)。我已经是从最简单的方面来分析了,很多线程当前状态往往不仅仅取决于一个对象,SoftICE中Thread Wait List也即是这个概念。
谈了这么多让线程等待的对象,现在来说说KWAIT_REASON,在KTEB中有专门表示thread wait reason的一个成员,它位于KTEB偏移57h处,占用一个CHAR的空间,严格的说这才是真正表示致使线程处于wait状态的原因,上面的那么多的讨论只不过是解释什么内核对象造成这一wait reason的。DDK Documentation是这样定义wait reason的
WaitReason
Specifies the reason for the wait. A driver should set this value to Executive, unless it is doing work on behalf of a user and is running in the context of a user thread, in which case it should set this value to UserRequest.
其中所提及的是否user thread是由KTEB另一个位于55h偏移处的单字符成员,它由0代表内核模式,1代表用户模式。上面提到了wait reason在驱动程序编程中最常见(并不是系统内核态代码中最多见的)的两个值:Executive与UserRequest,至于其其它值请参阅ntddk.h。
3.进程优先级(KPEB+62h)、线程基本优先级(BasePriority,KTEB+68h)、线程动态优先级(Dyn Priority,KTEB+33h)
这三个值各自占用一个字节。其中Thread Dyn Priority在Spy++中显示为Current Priority,而在Microsoft的WinDbg与Windows 2000 Server Resource Kit中的一些小工具,如pstat.exe等中则直接用Priority表示,但在SoftICE中则显示为Dyn Priority。由于直接用Priority又不容易表达这么多的优先级。鉴于我文中所有内容都基于SoftICE的分析,我在本文中均沿用SoftICE中的名称。其实Microsoft在KTEB结构中还提供PriorityDecrement等其它使系统随时动态更改当前优先级,这也是我比较喜欢使用Dyn Priority的一个原因之一。至于这些优先级的详细讨论请参阅参考资料中的<<WINDOWS NT DEVICE DRIVER DEVELOPMENT>>,其对核心态的这些值的作用进行了比较多的说明。
4.线程亲缘性(Affinity)
由于我目前尚未有条件测试多处理机的情况,我也不好在这多说,有条件的朋友我很希望您能说说。
5.线程拥有的时间总量(Thread Quantum)
单字节,位于KTEB+6b处。指出CPU可以让线程调度的时间总量(Quantum)。在Processor Control Block中系统存有三个_KTHREAD(KTEB)结构的成员CurrentThread、NextThread与IdleThread,分别代表系统当前处理器正在执行的线程、将要被调用的线程与系统空闲(Idle)线程,Idle线程通常只是简单的调用KiIdleLoop,直到系统新的中断来临,以对其它线程进行调用。Windows NT/2000中调用KiQuantumEnd判断当前线程是否使用完自己的时间总量,如果当前线程已执行完Quantum,则在KPRCB中NextThread非空时返回NextThread,作为系统调用的下一个线程。系统通过调用KiFindReadyThread寻找下一个处于Ready状态的线程。
_KiQuantumEnd
0008:804315B9 PUSH EBP
0008:804315BA MOV EBP,ESP
0008:804315BC PUSH ECX
0008:804315BD PUSH EBX
0008:804315BE PUSH ESI
0008:804315BF PUSH EDI
0008:804315C0 MOV EAX,DS:[FFDFF020] ;KPRCB->EAX
0008:804315C6 MOV EDI,EAX ;KPRCB->EDI
0008:804315C8 MOV EAX,FS:[00000124] ;Current Thread's KTEB->EAX
0008:804315CE MOV ESI,EAX ;Current Thread's KTEB->ESI
0008:804315D0 CALL [__imp__KeRaiseIrqlToDpcLevel] ;将IRQL提升到DISPATCH_LEVEL,学过
;ddk的朋友应该都比较熟悉
0008:804315D6 XOR EBX,EBX
0008:804315D8 MOV [EBP-01],AL ;Save Old IRQL
0008:804315DB CMP [ESI+6B],BL ;判断当前线程的Quantum
0008:804315DE JG 804315F2 ;在Quantum小于等于0时获取NextThread
0008:804315E0 MOV EAX,[ESI+44]
0008:804315E3 CMP [EAX+69],BL
0008:804315E6 JZ 80431608
0008:804315E8 CMP BYTE PTR [ESI+33],10
0008:804315EC JL 80431608
0008:804315EE MOV BYTE PTR [ESI+6B],7F
0008:804315F2 MOV ESI,[EDI+08] ;KPRCB's NextThread->ESI
0008:804315F5 CMP ESI,EBX ;KPRCB's NextThread是否为空
0008:804315F7 JNZ 80431601
0008:804315F9 MOV CL,[EBP-01]
0008:804315FC CALL @KiUnlockDispatcherDatabase
0008:80431601 MOV EAX,ESI ;将NextThread返回
0008:80431603 POP EDI
0008:80431604 POP ESI
0008:80431605 POP EBX
0008:80431606 LEAVE
0008:80431607 RET
0008:80431608 MOVSX EDX,BYTE PTR [ESI+33]
.
.(代码很长,牵涉到调度算法,看来只能您自己去认真看看了)
.
6.线程所属进程的KPEB(KTEB+22ch处)
主要是更容易的在KTEB与KPEB间进行些数据交换。
其实上面部分内部数据在Linux中也可以找到实现对应功能的体现,如Windows NT/2000中的Thread Quantum对应Linux task_struct中counter成员等等。我在<<浅析Windows NT/2000环境切换>>中就指出过Windows NT/2000实际上发生任务切换的情况只有两种,我也在上次给出了时间中断的部分代码,给出了SwapContext(主要是CR3切换)代码。Windows中的每一个进程都分别拥有私有的内存空间,私有的内核对象(句柄表Handle Table)等等,这些都是在环境切换的基础上实现的,也是一个操作系统Robustness and Reliability的基础。http://www.research.microsoft.com/中有很多文章对这有过验证,大可以翻翻看看。关于这部分的实现您还可以再看看如下的一些例程(限于篇幅我不再列出代码):
⊙ KiFindReadyThread
⊙ KiReadyThread
⊙ KiSwapThread
⊙ SwapContext
其中SwapContext与KiSwapThread是系统真正切换的代码,是不是经常在stack trace中看到这个函数,在此你应该可以比较容易的明白了吧。(关于stack trace请参阅DDK Documentation中的Anatomy of a Stack Trace段或SoftICE Command Reference中的stack与thread命令的解释)
另一种线程自动放弃执行的情况,跟踪KeWaitForSingleObject、KeSetEvent等就相对比较容易了,拿Event Object举个例子吧,由于知道Event的结构,在您的实验用机上您大可以随便更改DISPATCHER_HEADER中的SignalState成员更改Object状态,看您要它是Clear还是Signalled了(上面我给出了如何用SoftICE实现)。甚至在您理解了我开头给出的代码(其实说白了只是读出一双向链表中的数据,如果是在Linux中,我相信你也根本没有耐心看到这儿了),还有理解了KWAIT_BLOCK所定义Thread Wait List后,只要给您一内核调试器,我相信您想阻塞哪个线程就哪个线程了,加上理解了线程优先级后,您想让哪个线程多占用CPU时间都可以。不过我可不保证您这时候机子是否还Robustness and Reliability了。
还是顺便提一下,Windows NT/2000中调度代码运行在DISPATCH_LEVEL IRQL上,已防止通常运行在PASSIVE_LEVEL的普通代码对其的中断。
Jeffrey Richter在<<PROGRAMMING APPLICATIONS for MICROSOFT WINDOWS,FOURTH EDITION>>中曾指出:
⊙ Microsoft doesn't fully document the behavior of the scheduler.
⊙ Microsoft doesn't let applications take full advantage of the scheduler's features.
⊙ Microsoft tells you that the scheduler's algorithm is subject to change so that you
can code defensively.
从这几点看Microsoft并没将调度行为固定,实际上Windows NT 4.0与Windows 2000在调度算法上就有不同,而本文所提及的所有代码均取自或只在Windows 2000 Server Build 2195中测试过。
我想对DDK有过学习的朋友应该都知道PsSetCreateProcessNotifyRoutine与PsSetCreateThreadNotifyRoutine这两个让用户注册系统建立与删除进程或线程时调用回调函数的Fully Documented例程,知道它们就是Mark Russinovich的NTPMON的实现的最主要的两个函数吧。其实在Windows 2000中Microsoft还提供实现类似功能的与环境切换有关的函数,即KeSetSwapContextNotifyRoutine和KeSetThreadSelectNotifyRoutine,不过这两个函数却是Undocumented的,并且只能在Windows 2000的特定版本上运行(请查阅MSDN Magazine)。
在Linux中所有的代码都是公开的,但真正要理解调度代码还是很困难的,何况本身就比Linux复杂(我尚未浏览过Linux 2.4.X源码,我这样说纯系个人目前感觉)而且只能在一大堆汇编代码中搜寻的Windows NT/2000,其分析的难度真的是可想而知的。所以我希望您能将文中错误或遗漏说明的地方告诉我(tsu00@263.net),谢谢!
参考资料:
1.Jeffrey Richter
<<PROGRAMMING APPLICATIONS for MICROSOFT WINDOWS,FOURTH EDITION>>
2.Peter Viscarola and Anthony Mason <<WINDOWS NT DEVICE DRIVER DEVELOPMENT>>
3.Numega <<SOFTICE COMMAND REFERENCE>>
4.Windows 2000 DDK Documentation
5.David Solomon and Mark Russinovich <<INSIDE MICROSOFT WINDOWS 2000,THIRD EDITION>>