谈谈对APC的一点理解
异步过程调用(APCs) 是NT异步处理体系结构中的一个基础部分,理解了它,对于了解NT怎样操作和执行几个核心的系统操作很有帮助。 1) APCs允许用户程序和系统元件在一个进程的地址空间内某个线程的上下文中执行代码。 2) I/O管理器使用APCs来完成一个线程发起的异步的I/O操作。例如:当一个设备驱动调用IoCompleteRequest来通知I/O管理器,它已经结束处理一个异步I/O请求时,I/O管理器排队一个apc到发起请求的线程。然后线程在一个较低IRQL级别,来执行APC. APC的作用是从系统空间拷贝I/O操作结果和状态信息到线程虚拟内存空间的一个缓冲中。 3) 使用APC可以得到或者设置一个线程的上下文和挂起线程的执行。 尽管APCs在nt体系结构下被广泛使用,但是关于怎样使用它的文档却非常的缺乏。本篇我们详细介绍下nt系统是怎样处理APCs的,并且记录导出的nt函数,方便设备驱动开发者在他们的程序中使用APCs。我也会展示一个非常可靠的NT的APC调度子程序KiDeliverApc的实现,来帮助你更好的掌握APC调度的内幕。 APC对象 在NT中,有两种类型的APCs:用户模式和内核模式。用户APCs运行在用户模式下目标线程当前上下文中,并且需要从目标线程得到许可来运行。特别是,用户模式的APCs需要目标线程处在alertable等待状态才能被成功的调度执行。通过调用下面任意一个函数,都可以让线程进入这种状态。这些函数是:KeWaitForSingleObject, KeWaitForMultipleObjects, KeWaitForMutexObject, KeDelayExecutionThread。 对于用户模式下,可以调用函数SleepEx, SignalObjectAndWait, WaitForSingleObjectEx, WaitForMultipleObjectsEx,MsgWaitForMultipleObjectsEx 都可以使目标线程处于alertable等待状态,从而让用户模式APCs执行,原因是这些函数最终都是调用了内核中的KeWaitForSingleObject, KeWaitForMultipleObjects, KeWaitForMutexObject, KeDelayExecutionThread等函数。另外通过调用一个未公开的alert-test服务KeTestAlertThread,用户线程可以使用户模式APCs执行。 当一个用户模式APC被投递到一个线程,调用上面的等待函数,如果返回等待状态STATUS_USER_APC,在返回用户模式时,内核转去控制APC例程,当APC例程完成后,再继续线程的执行. 和用户模式APCs比较,内核模式APCs执行在内核模式下。可以被划分为常规的和特殊的两类。当APCs被投递到一个特殊的线程,特殊的内核模式APCs不需要从线程得到许可来运行。然而,常规的内核模式APCs在他们成功执行前,需要有特定的环境。此外,特殊的内核APC被尽可能快地执行,既只要APC_LEVEL级上有可调度的活动。在很多情况下,特殊的内核APC甚至能唤醒阻塞的线程。普通的内核APC仅在所有特殊APC都被执行完,并且目标线程仍在运行,同时该线程中也没有其它内核模式APC正执行时才执行。用户模式APC在所有内核模式APC执行完后才执行,并且仅在目标线程有alertable属性时才执行。 每一个等待执行的APC都存在于一个线程执行体,由内核管理的队列中。系统中的每一个线程都包含两个APC队列,一个是为用户模式APCs,另一个是为内核模式APCs的。 NT通过一个成为KAPC的内核控制对象来描述一个APC.尽管DDK中没有明确的文档化APCs,但是在NTDDK.H中却非常清楚的定义了APC对象。从下面的KAPC对象的定义看,有些是不需要说明的。像Type和Size。Type表示了这是一个APC内核对象。在nt中,每一个内核对象或者执行体对象都有Type和Size这两个域。由此处理函数可以确定当前处理的对象。Size表示一个字对齐的结构体的大小。也就是指明了对象占的内存空间大小。Spare0看起来有些晦涩难懂,但是它是没用什么任何深远的意义,仅仅是为了内存补齐。其他的域将在下面的篇幅中介绍。 //------------------------------------------------------------------------------------------------------- 几个函数声明和结构定义: typedef struct _KAPC { CSHORT Type; CSHORT Size; ULONG Spare0; struct _KTHREAD *Thread; LIST_ENTRY ApcListEntry; PKKERNEL_ROUTINE KernelRoutine; PKRUNDOWN_ROUTINE RundownRoutine; PKNORMAL_ROUTINE NormalRoutine; PVOID NormalContext; // // N.B. The following two members MUST be together. // PVOID SystemArgument1; PVOID SystemArgument2; CCHAR ApcStateIndex; KPROCESSOR_MODE ApcMode; BOOLEAN Inserted; } KAPC, *PKAPC, *RESTRICTED_POINTER PRKAPC; //------ APC环境 一个线程在它执行的任意时刻,假设当前的IRQL是在Passive级,它可能需要临时在其他的进程上下文中执行代码,为了完成这个操作,线程调用系统功能函数KeAttachProcess,在从这个调用返回时,线程执行在另一个进程的地址空间。先前所有在线程自己的进程上下文中等待执行的APCs,由于这时其所属进程的地址空间不是当前可用的,因此他们不能被投递执行。然而,新的插入到这个线程的APCs可以执行在这个新的进程空间。甚至当线程最后从新的进程中分离时,新的插入到这个线程的APCs还可以在这个线程所属的进程上下文中执行。 为了达到控制APC传送的这个程度,NT中每个线程维护了两个APC环境或者说是状态。每一个APC环境包含了用户模式的APC队列和内核模式的APC队列,一个指向当前进程对象的指针和三个控制变量,用于指出:是否有未决的内核模式APCs(KernelApcPending),是否有常规内核模式APC在进行中(KernelApcInProgress),是否有未决的用户模式的APC(UserApcPending). 这些APC的环境保存在线程对象的ApcStatePointer域中。这个域是由2个元素组成的数组。即:+0x138 ApcStatePointer : [2] Ptr32 _KAPC_STATE typedef struct _KAPC_STATE { LIST_ENTRY ApcListHead[MaximumMode]; struct _KPROCESS *Process; BOOLEAN KernelApcInProgress; BOOLEAN KernelApcPending; BOOLEAN UserApcPending; } KAPC_STATE, *PKAPC_STATE, *PRKAPC_STATE; lkd> dt _kthread ntdll!_KTHREAD +0x000 Header : _DISPATCHER_HEADER +0x010 MutantListHead : _LIST_ENTRY +0x018 InitialStack : Ptr32 Void +0x01c StackLimit : Ptr32 Void +0x020 Teb : Ptr32 Void +0x024 TlsArray : Ptr32 Void +0x028 KernelStack : Ptr32 Void +0x02c DebugActive : UChar +0x02d State : UChar +0x02e Alerted : [2] UChar +0x030 Iopl : UChar +0x031 NpxState : UChar +0x032 Saturation : Char +0x033 Priority : Char +0x034 ApcState : _KAPC_STATE +0x04c ContextSwitches : Uint4B +0x050 IdleSwapBlock : UChar +0x051 Spare0 : [3] UChar +0x054 WaitStatus : Int4B +0x058 WaitIrql : UChar +0x059 WaitMode : Char +0x05a WaitNext : UChar +0x05b WaitReason : UChar +0x05c WaitBlockList : Ptr32 _KWAIT_BLOCK +0x060 WaitListEntry : _LIST_ENTRY +0x060 SwapListEntry : _SINGLE_LIST_ENTRY +0x068 WaitTime : Uint4B +0x06c BasePriority : Char +0x06d DecrementCount : UChar +0x06e PriorityDecrement : Char +0x06f Quantum : Char +0x070 WaitBlock : [4] _KWAIT_BLOCK +0x0d0 LegoData : Ptr32 Void +0x0d4 KernelApcDisable : Uint4B +0x0d8 UserAffinity : Uint4B +0x0dc SystemAffinityActive : UChar +0x0dd PowerState : UChar +0x0de NpxIrql : UChar +0x0df InitialNode : UChar +0x0e0 ServiceTable : Ptr32 Void +0x0e4 Queue : Ptr32 _KQUEUE +0x0e8 ApcQueueLock : Uint4B +0x0f0 Timer : _KTIMER +0x118 QueueListEntry : _LIST_ENTRY +0x120 SoftAffinity : Uint4B +0x124 Affinity : Uint4B +0x128 Preempted : UChar +0x129 ProcessReadyQueue : UChar +0x12a KernelStackResident : UChar +0x12b NextProcessor : UChar +0x12c CallbackStack : Ptr32 Void +0x130 Win32Thread : Ptr32 Void +0x134 TrapFrame : Ptr32 _KTRAP_FRAME +0x138 ApcStatePointer : [2] Ptr32 _KAPC_STATE +0x140 PreviousMode : Char +0x141 EnableStackSwap : UChar +0x142 LargeStack : UChar +0x143 ResourceIndex : UChar +0x144 KernelTime : Uint4B +0x148 UserTime : Uint4B +0x14c SavedApcState : _KAPC_STATE +0x164 Alertable : UChar +0x165 ApcStateIndex : UChar +0x166 ApcQueueable : UChar +0x167 AutoAlignment : UChar +0x168 StackBase : Ptr32 Void +0x16c SuspendApc : _KAPC +0x19c SuspendSemaphore : _KSEMAPHORE +0x1b0 ThreadListEntry : _LIST_ENTRY +0x1b8 FreezeCount : Char +0x1b9 SuspendCount : Char +0x1ba IdealProcessor : UChar +0x1bb DisableBoost : UChar 主APC环境是位于线程对象的ApcState 域,即: +0x034 ApcState : _KAPC_STATE 线程中等待在当前进程上下文中执行的APC保存在ApcState的队列中。无论何时,NT的APC派发器(dispatcher)和其他系统元件查询一个线程未决的APCs时, 他们都会检查主APC环境,如果这里有任何未决的APCs,就会马上被投递,或者修改它的控制变量稍后投递。 第二个APC环境是位于线程对象的SavedApcState域,当线程临时挂接到其他进程时,它是用来备份主APC环境的。 当一个线程调用KeAttachProcess,在另外的进程上下文中执行后续的代码时,ApcState域的内容就被拷贝到SavedApcState域。然后ApcState域被清空,它的APC队列重新初始化,控制变量设置为0,当前进程域设置为新的进程。这些步骤成功的确保先前在线程所属的进程上下文地址空间中等待的APCs,当线程运行在其它不同的进程上下文时,这些APCs不被传送执行。随后,ApcStatePointer域数组内容被更新来反映新的状态,数组中第一个元素指向SavedApcState域,第二个元素指向ApcState域,表明线程所属进程上下文的APC环境位于SavedApcState域。线程的新的进程上下文的APC环境位于ApcState域。最后,当前进程上下文切换到新的进程上下文。 对于一个APC对象,决定当前APC环境的是ApcStateIndex域。ApcStateIndex域的值作为ApcStatePointer域数组的索引来得到目标APC环境指针。随后,目标APC环境指针用来在相应的队列中存放apc对象. 当线程从新的进程中脱离时(KeDetachProcess), 任何在新的进程地址空间中等待执行的未决的内核APCs被派发执行。随后SavedApcState 域的内容被拷贝回ApcState域。SavedApcState 域的内容被清空,线程的ApcStateIndex域被设为OriginalApcEnvironment,ApcStatePointer域更新,当前进程上下文切换到线程所属进程。 使用APCs 设备驱动程序使用两个主要函数来利用APCs, 第一个是KeInitializeApc,用来初始化APC对象。这个函数接受一个驱动分配的APC对象,一个目标线程对象指针,APC环境索引(指出APC对象存放于哪个APC环境),APC的kernel,rundown和normal例程指针,APC类型(用户模式或者内核模式)和一个上下文参数。 函数声明如下: NTKERNELAPI VOID KeInitializeApc ( IN PRKAPC Apc, IN PKTHREAD Thread, IN KAPC_ENVIRONMENT Environment, IN PKKERNEL_ROUTINE KernelRoutine, IN PKRUNDOWN_ROUTINE RundownRoutine OPTIONAL, IN PKNORMAL_ROUTINE NormalRoutine OPTIONAL, IN KPROCESSOR_MODE ApcMode, IN PVOID NormalContext ); typedef enum _KAPC_ENVIRONMENT { OriginalApcEnvironment, AttachedApcEnvironment, CurrentApcEnvironment } KAPC_ENVIRONMENT; KeInitializeApc 首先设置APC对象的Type和Size域一个适当的值,然后检查参数Environment的值,如果是CurrentApcEnvironment,那么ApcStateIndex域设置为目标线程的ApcStateIndex域。否则,ApcStateIndex域设置为参数Environment的值。随后,函数直接用参数设置APC对象Thread,RundownRoutine,KernelRoutine域的值。为了正确地确定APC的类型,KeInitializeApc 检查参数NormalRoutine的值,如果是NULL,ApcMode域的值设置为KernelMode,NormalContext域设置为NULL。如果NormalRoutine的值不是NULL,这时候它一定指向一个有效的例程,就用相应的参数来设置ApcMode域和NormalContext域。最后,KeInitializeApc 设置Inserted域为FALSE.然而初始化APC对象,并没有把它存放到相应的APC队列中。 从这个解释看,你可以了解到APCs对象如果缺少有效的NormalRoutine,就会被当作内核模式APCs.尤其是它们会被认为是特殊的内核模式APCs. 实际上,I/O管理器就是用这类的APC来完成异步I/O操作。相反地,APC对象定义了有效的NormalRoutine,并且ApcMode域是KernelMode,就会被当作常规的内核模式APCs,否则就会被当作是用户模式APCs. NTDDK.H中KernelRoutine, RundownRoutine, and NormalRoutine 的定义如下: typedef VOID (*PKKERNEL_ROUTINE) ( IN struct _KAPC *Apc, IN OUT PKNORMAL_ROUTINE *NormalRoutine, IN OUT PVOID *NormalContext, IN OUT PVOID *SystemArgument1, IN OUT PVOID *SystemArgument2 ); typedef VOID (*PKRUNDOWN_ROUTINE) ( IN struct _KAPC *Apc ); typedef VOID (*PKNORMAL_ROUTINE) ( IN PVOID NormalContext, IN PVOID SystemArgument1, IN PVOID SystemArgument2 ); //------------------ 通常,无论是什么类型,每个APC对象必须要包含一个有效的KernelRoutine 函数指针。当这个APC被NT的APC dispatcher传送执行时,这个例程首先被执行。用户模式的APCs必须包含一个有效的NormalRoutine 函数指针,这个函数必须在用户内存区域。同样的,常规内核模式APCs也必须包含一个有效的NormalRoutine,但是它就像KernelRoutine一样运行在内核模式。作为可选择的,任意类型的APC都可以定义一个有效的RundownRoutine,这个例程必须在内核内存区域,并且仅仅当系统需要释放APC队列的内容时,才被调用。例如线程退出时,在这种情况下,KernelRoutine和NormalRoutine都不执行,只有RundownRoutine执行。没有这个例程的APC对象会被删除。 记住,投递APCs到一个线程的动作,仅仅是操作系统调用KiDeliverApc完成的。执行APC实际上就是调用APC内的例程。 一旦APC对象完成初始化后,设备驱动调用KeInsertQueueApc来将APC对象存放到目标线程的相应的APC队列中。这个函数接受一个由KeInitializeApc完成初始化的APC对象指针,两个系统参数和一个优先级增量。跟传递给KeInitializeApc函数的参数context 一样,这两个系统参数只是在APC的例程执行时,简单的传递给APC的例程。 NTKERNELAPI BOOLEAN KeInsertQueueApc ( IN PRKAPC Apc, IN PVOID SystemArgument1, IN PVOID SystemArgument2, IN KPRIORITY Increment ); //----------------- 在KeInsertQueueApc 将APC对象存放到目标线程相应的APC队列之前,它首先检查目标线程是否是APC queueable。如果不是,函数立即返回FALSE.如果是,函数直接用参数设置SystemArgument1域和SystemArgument2 域,随后,函数调用KiInsertQueueApc来将APC对象存放到相应的APC队列。 KiInsertQueueApc 仅仅接受一个APC对象和一个优先级增量。这个函数首先得到线程APC队列的spinlock并且持有它,防止其他线程修改当前线程的APC结构。随后,检查APC对象的Inserted 域。如果是TRUE,表明这个APC对象已经存放到APC队列中了,函数立即返回FALSE.如果APC对象的Inserted 域是FALSE.函数通过ApcStateIndex域来确定目标APC环境,然后把APC对象存放到相应的APC队列中,即将APC对象中的ApcListEntry 域链入到APC环境的ApcListHead域中。链入的位置由APC的类型决定。常规的内核模式APC,用户模式APC都是存放到相应的APC队列的末端。相反的,如果队列中已经存放了一些APC对象,特殊的内核模式APC存放到队列中第一个常规内核模式APC对象的前面。如果是内核定义的一个当线程退出时使用的用户APC,它也会被放在相应的队列的前面。然后,线程的主APC环境中的UserApcPending域杯设置为TRUE。这时KiInsertQueueApc 设置APC对象的Inserted 域为TRUE,表明这个APC对象已经存放到APC队列中了。接下来,检查这个APC对象是否被排队到线程的当前进程上下文APC环境中,如果不是,函数立即返回TRUE。如果这是一个内核模式APC,线程主APC环境中的KernelApcPending域设置为TRUE。 在WIN32 SDK文档中是这样描述APCs的: 当一个APC被成功的存放到它的队列后,发出一个软中断,APC将会在线程被调度运行的下一个时间片执行。然而这不是完全正确的。这样一个软中断,仅仅是当一个内核模式的APC(无论是常规的内核模式APC还是特殊的内核模式APC)针对于调用线程时,才会发出。随后函数返回TRUE。 1)如果APC不是针对于调用线程,目标线程在Passive权限等级处在等待状态; 2)这是一个常规内核模式APC 3)这个线程不再临界区 4)没有其他的常规内核模式APC仍然在进行中 那么这个线程被唤醒,返回状态是STATUS_KERNEL_APC。但是等待状态没有aborted。 如果这是一个用户模式APC,KiInsertQueueApc检查判断目标线程是否是alertable等待状态,并且WaitMode域等于UserMode。如果是,主APC环境的UserApcPending 域设置为TRUE。等待状态返回STATUS_USER_APC,最后,函数释放spinlock,返回TRUE,表示APC对象已经被成功放入队列。 早期作为APC管理函数的补充,设备驱动开发者可以使用未公开的系统服务NtQueueApcThread来直接将一个用户模式的APC投递到某个线程。 这个函数内部实际上是调用了KeInitializeApc 和KeInsertQueueApc 来完成这个任务。 NTSYSAPI NTSTATUS NTAPI NtQueueApcThread ( IN HANDLE Thread, IN PKNORMAL_ROUTINE NormalRoutine, IN PVOID NormalContext, IN PVOID SystemArgument1, IN PVOID SystemArgument2 ); NT的APC派发器 NT检查是否线程有未决的APCs. 然后APC派发器子程序KiDeliverApc在这个线程上下文执行来开始将未决的APC执行。注意,这个行为中断了线程的正常执行流程,首先将控制权给APC派发器,随后当KiDeliverApc完成后,继续线程的执行。 例如:当一个线程被调度运行时,最后一步,上下文切换函数 SwapContext 用来检查是否新的线程有未决的内核APCs.如果是,SwapContext要么(1)请求一个APC级别的软中断来开始APC执行,由于新线程运行在低的IRQL(Passive级别。 或者(2)返回TRUE,表示新的线程有未决的内核APCs。 究竟是执行(1)还是(2)取决于新线程所处的IRQL级别. 如果它的权限级别高于Passive级,SwapContext 执行(1),如果它是在Passive级,则选择执行(2). SwapContext的返回值仅仅是特定系统函数可用的,这些系统函数调用SwapContext来强制切换线程上下文到另一个线程. 然后,当这些系统函数经过一段时间再继续时,他们通常检查SwapContext 的返回值,如果是TRUE,他们就会调用APC派发器来投递内核APCs到当前的线程. 例如: 系统函数KiSwapThread被等待服务用来放弃处理器,直到等待结束。这个函数内部调用SwapContext。当等待结束,继续从调用SwapContext处执行时,就会检查SwapContext的返回值。如果是TRUE,KiSwapThread会降低IRQL级别到APC级,然后调用KiDeliverApc来在当前线程执行内核APCs. 对于用户APCs, 内核调用APC派发器仅仅是当线程回到用户模式,并且线程的主APC环境的UserApcPending域为TRUE时。例如:当系统服务派发器KiSystemService完成一个系统服务请求正打算回到用户模式时,它会检查是否有未决的用户APCs。在执行上,KiDeliverApc调用用户APC的KernelRoutine. 随后,KiInitializeUserApc函数被调用,用来设置线程的陷阱帧。所以从内核模式退出时,线程开始在用户模式下执行 。KiInitializeUserApc的函数的作用是拷贝当前线程先前的执行状态(当进入内核模式时,这个状态保存在线程内核栈创建的陷阱帧里),从内核栈到线程的用户模式栈,初始化用户模式APC。APC派发器子程序KiUserApcDispatcher在Ntdll.dll内。最后,加载陷阱帧的EIP寄存器和Ntdll.dll中KiUserApcDispatcher的地址。当陷阱帧最后释放时,内核将控制转交给KiUserApcDispatcher,这个函数调用APC的NormalRoutine例程,NormalRoutine函数地址以及参数都在栈中,当例程完成时,它调用NtContinue来让线程利用在栈中先前的上下文继续执行,仿佛什么事情也没有发生过。 当内核调用KiDeliverApc来执行一个用户模式APC时,线程中的PreviousMode域被设为UserMode. TrapFrame域指向线程的陷阱帧。当内核调用KiDeliverApc来执行内核APCs时,线程中的PreviousMode域被设为KernelMode. TrapFrame域指向NULL。 注意,无论何时只要KernelRoutine被调用,传递给它的指针是一个局部的APC属性的副本,由于APC对象已经脱离了队列,所以可以安全的在KernelRoutine中释放APC内存。此外,这个例程在它的参数被传递给其他例程之前,有一个最后的机会来修改这些参数。 结论: APC提供了一个非常有用的机制,允许在特定的线程上下文中异步的执行代码。作为一个设备驱动开发者,你可以依赖APCs在某个特定的线程上下文中执行一个例程,而不需要线程的许可和干涉。对于用户应用程序,用户模式APCs可以用来有效地实现一些回调通知机制。 |