📄
字号:
漫谈兼容内核之十二:Windows的APC机制
[i]毛德操[/i]
前两篇漫谈中讲到,除ntdll.dll外,在启动一个新进程运行时,PE格式DLL映像的装入和动态连接是由ntdll.dll中的函数LdrInitializeThunk()作为APC函数执行而完成的。这就牵涉到了Windows的APC机制,APC是“异步过程调用(Asyncroneus Procedure Call)”的缩写。从大体上说,Windows的APC机制相当于Linux的Signal机制,实质上是一种对于应用软件(线程)的“软件中断”机制。但是读者将会看到,APC机制至少在形式上与软件中断机制还是有相当的区别,而称之为“异步过程调用”确实更为贴切。
APC与系统调用是密切连系在一起的,在这个意义上APC是系统调用界面的一部分。然而APC又与设备驱动有着很密切的关系。例如,ntddk.h中提供“写文件”系统调用ZwWriteFile()、即NtWriteFile()的调用界面为:
[code]NTSYSAPI
NTSTATUS
NTAPI
ZwWriteFile(
IN HANDLE FileHandle,
IN HANDLE Event OPTIONAL,
IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
IN PVOID ApcContext OPTIONAL,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PVOID Buffer,
IN ULONG Length,
IN PLARGE_INTEGER ByteOffset OPTIONAL,
IN PULONG Key OPTIONAL
);[/code]
这里有个参数ApcRoutine,这是一个函数指针。什么时候要用到这个指针呢?原来,文件操作有“同步”和“异步”之分。普通的写文件操作是同步写,启动这种操作的线程在内核进行写文件操作期间被“阻塞(blocked)”而进入“睡眠”,直到设备驱动完成了操作以后才又将该线程“唤醒”而从系统调用返回。但是,如果目标文件是按异步操作打开的,即在通过W32的API函数CreateFile()打开目标文件时把调用参数dwFlagsAndAttributes设置成FILE_FLAG_OVERLAPPED,那么调用者就不会被阻塞,而是把事情交给内核、不等实际的操作完成就返回了。但是此时要把ApcRoutine设置成指向某个APC函数。这样,当设备驱动完成实际的操作时,就会使调用者线程执行这个APC函数,就像是发生了一次中断。执行该APC函数时的调用界面为:
[code]typedef
VOID
(NTAPI *PIO_APC_ROUTINE) (IN PVOID ApcContext,
IN PIO_STATUS_BLOCK IoStatusBlock, IN ULONG Reserved);[/code]
这里的指针ApcContext就是NtWriteFile()调用界面上传下来的,至于作什么解释、起什么作用,那是包括APC函数在内的用户软件自己的事,内核只是把它传递给APC函数。
在这个过程中,把ApcRoutine设置成指向APC函数相当于登记了一个中断服务程序,而设备驱动在完成实际的文件操作后就向调用者线程发出相当于中断请求的“APC请求”,使其执行这个APC函数。
从这个角度说,APC机制又应该说是设备驱动框架的一部分。事实上,读者以后还会看到,APC机制与设备驱动的关系比这里所见的还要更加密切。此外,APC机制与异常处理的关系也很密切。
不仅内核可以向一个线程发出APC请求,别的线程、乃至目标线程自身也可以发出这样的请求。Windows为应用程序提供了一个函数QueueUserAPC(),就是用于此项目的,下面是ReactOS中这个函数的代码:
[code]DWORD STDCALL
QueueUserAPC(PAPCFUNC pfnAPC, HANDLE hThread, ULONG_PTR dwData)
{
NTSTATUS Status;
Status = NtQueueApcThread(hThread, IntCallUserApc,
pfnAPC, (PVOID)dwData, NULL);
if (Status)
SetLastErrorByStatus(Status);
return NT_SUCCESS(Status);
}[/code]
参数pfnAPC是函数指针,这就是APC函数。另一个参数hThread是指向目标线程对象(已打开)的Handle,这可以是当前线程本身,也可以是同一进程中别的线程,还可以是别的进程中的某个线程。值得注意的是:如果目标线程在另一个进程中,那么pfnAPC必须是这个函数在目标线程所在用户空间的地址,而不是这个函数在本线程所在空间的地址。最后一个参数dwData则是需要传递给APC函数的参数。
这里的NtQueueApcThread()是个系统调用。“Native API”书中有关于NtQueueApcThread()的一些说明。这个系统调用把一个“用户APC请求”挂入目标线程的APC队列(更确切地说,是把一个带有函数指针的数据结构挂入队列)。注意其第二个参数是需要执行的APC函数指针,本该是pfnAPC,这里却换成了函数IntCallUserApc(),而pfnAPC倒变成了第三个参数,成了需要传递给IntCallUserApc()的参数之一。IntCallUserApc()是kernel32.dll内部的一个函数,但是并未引出,所以不能从外部直接加以调用。
APC是针对具体线程、要求由具体线程加以执行的,所以每个线程都有自己的APC队列。内核中代表着线程的数据结构是ETHREAD,而ETHREAD中的第一个成分Tcb是KTHREAD数据结构,线程的APC队列就在KTHREAD里面:
[code]typedef struct _KTHREAD
{
. . . . . .
/* Thread state (one of THREAD_STATE_xxx constants below) */
UCHAR State; /* 2D */
BOOLEAN Alerted[2]; /* 2E */
. . . . . .
KAPC_STATE ApcState; /* 34 */
ULONG ContextSwitches; /* 4C */
. . . . . .
ULONG KernelApcDisable; /* D0 */
. . . . . .
PKQUEUE Queue; /* E0 */
KSPIN_LOCK ApcQueueLock; /* E4 */
. . . . . .
PKAPC_STATE ApcStatePointer[2]; /* 12C */
. . . . . .
KAPC_STATE SavedApcState; /* 140 */
UCHAR Alertable; /* 158 */
UCHAR ApcStateIndex; /* 159 */
UCHAR ApcQueueable; /* 15A */
. . . . . .
KAPC SuspendApc; /* 160 */
. . . . . .
} KTHREAD;[/code]
Microsoft并不公开这个数据结构的定义,所以ReactOS代码中对这个数据结构的定义带有逆向工程的痕迹,每一行后面的十六进制数值就是相应结构成分在数据结构中的位移。这里我们最关心的是ApcState,这又是一个数据结构、即KAPC_STATE。可以看出,KAPC_STATE的大小是0x18字节。其定义如下:
[code]typedef struct _KAPC_STATE {
LIST_ENTRY ApcListHead[2];
PKPROCESS Process;
BOOLEAN KernelApcInProgress;
BOOLEAN KernelApcPending;
BOOLEAN UserApcPending;
} KAPC_STATE, *PKAPC_STATE, *__restrict PRKAPC_STATE;[/code]
显然,这里的ApcListHead就是APC队列头。不过这是个大小为2的数组,说明实际上(每个线程)有两个APC队列。这是因为APC函数分为用户APC和内核APC两种,各有各的队列。所谓用户APC,是指相应的APC函数位于用户空间、在用户空间执行;而内核APC,则相应的APC函数为内核函数。
读者也许已经注意到,KTHREAD结构中除ApcState外还有SavedApcState也是KAPC_STATE数据结构。此外还有ApcStatePointer[2]和ApcStateIndex两个结构成分。这是干什么用的呢?原来,在Windows的内核中,一个线程可以暂时“挂靠(Attach)”到另一个进程的地址空间。比方说,线程T本来是属于进程A的,当这个线程在内核中运行时,如果其活动与用户空间有关(APC就是与用户空间有关),那么当时的用户空间应该就是进程A的用户空间。但是Windows内核允许一些跨进程的操作(例如将ntdll.dll的映像装入新创进程B的用户空间并对其进行操作),所以有时候需要把当时的用户空间切换到别的进程(例如B) 的用户空间,这就称为“挂靠(Attach)”,对此我将另行撰文介绍。在当前线程挂靠在另一个进程的期间,既然用户空间是别的进程的用户空间,挂在队列中的APC请求就变成“牛头不对马嘴”了,所以此时要把这些队列转移到别的地方,以免乱套,然后在回到原进程的用户空间时再于恢复。那么转移到什么地方呢?就是SavedApcState。当然,还要有状态信息说明本线程当前是处于“原始环境”还是“挂靠环境”,这就是ApcStateIndex的作用。代码中为SavedApcState的值定义了一种枚举类型:
[code]typedef enum _KAPC_ENVIRONMENT
{
OriginalApcEnvironment,
AttachedApcEnvironment,
CurrentApcEnvironment
} KAPC_ENVIRONMENT;[/code]
实际可用于ApcStateIndex的只是OriginalApcEnvironment和AttachedApcEnvironment,即0和1。读者也许又要问,在挂靠环境下原来的APC队列确实不适用了,但不去用它就是,何必要把它转移呢?再说,APC队列转移以后,ApcState不是空下来不用了吗?问题在于,在挂靠环境下也可能会有(针对所挂靠进程的)APC请求(不过当然不是来自用户空间),所以需要有用于两种不同环境的APC队列,于是便有了ApcState和SavedApcState。进一步,为了提供操作上的灵活性,又增加了一个KAPC_STATE指针数组ApcStatePointer[2],就用ApcStateIndex的当前值作为下标,而数组中的指针则根据情况可以分别指向两个APC_STATE数据结构中的一个。
这样,以ApcStateIndex的当前数值为下标,从指针数组ApcStatePointer[2]中就可以得到指向ApcState或SavedApcState的指针,而要求把一个APC请求挂入队列时则可以指定是要挂入哪一个环境的队列。实际上,当ApcStateIndex的值为OriginalApcEnvironment、即0时,使用的是ApcState;为AttachedApcEnvironment、即1时,则用的是SavedApcState。
每当要求挂入一个APC函数时,不管是用户APC还是内核APC,内核都要为之准备好一个KAPC数据结构,并将其挂入相应的队列。
[code]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;
PVOID SystemArgument1;
PVOID SystemArgument2;
CCHAR ApcStateIndex;
KPROCESSOR_MODE ApcMode;
BOOLEAN Inserted;
} KAPC, *PKAPC;[/code]
结构中的ApcListEntry就是用来将KAPC结构挂入队列的。注意这个数据结构中有三个函数指针,即KernelRoutine、RundownRoutine、NormalRoutine。其中只有NormalRoutine才指向(执行)APC函数的请求者所提供的函数,其余两个都是辅助性的。以NtQueueApcThread()为例,其请求者(调用者)QueueUserAPC()所提供的函数是IntCallUserApc(),所以NormalRoutine应该指向这个函数。注意真正的请求者其实是QueueUserAPC()的调用者,真正的目标APC函数也并非IntCallUserApc(),而是前面的函数指针pfnAPC所指向的函数,而IntCallUserApc()起着类似于“门户”的作用。
现在我们可以往下看系统调用NtQueueApcThread()的实现了。
[code]NTSTATUS
STDCALL
NtQueueApcThread(HANDLE ThreadHandle, PKNORMAL_ROUTINE ApcRoutine,
PVOID NormalContext, PVOID SystemArgument1, PVOID SystemArgument2)
{
PKAPC Apc;
PETHREAD Thread;
KPROCESSOR_MODE PreviousMode = ExGetPreviousMode();
NTSTATUS Status;
/* Get ETHREAD from Handle */
Status = ObReferenceObjectByHandle(ThreadHandle, THREAD_SET_CONTEXT,
PsThreadType, PreviousMode, (PVOID)&Thread, NULL);
. . . . . .
/* Allocate an APC */
Apc = ExAllocatePoolWithTag(NonPagedPool, sizeof(KAPC), TAG('P', 's', 'a', 'p'));
. . . . . .
/* Initialize and Queue a user mode apc (always!) */
KeInitializeApc(Apc, &Thread->Tcb, OriginalApcEnvironment,
KiFreeApcRoutine, NULL, ApcRoutine, UserMode, NormalContext);
if (!KeInsertQueueApc(Apc, SystemArgument1, SystemArgument2,
IO_NO_INCREMENT))
{
Status = STATUS_UNSUCCESSFUL;
} else {
Status = STATUS_SUCCESS;
}
/* Dereference Thread and Return */
ObDereferenceObject(Thread);
return Status;
}[/code]
先看调用参数。第一个参数是代表着某个已打开线程的Handle,这说明所要求的APC函数的执行者、即目标线程、可以是另一个线程,而不必是请求者线程本身。第二个参数不言自明。第三个参数NormalContext,以及后面的两个参数,则是准备传递给APC函数的参数,至于怎样解释和使用这几个参数是APC函数的事。看一下前面QueueUserAPC()的代码,就可以知道这里的APC函数是IntCallUserApc(),而准备传给它的参数分别为pfnAPC、dwData、和NULL,前者是真正的目标APC函数指针,后两者是要传给它的参数。
根据Handle找到目标线程的ETHREAD数据结构以后,就为APC函数分配一个KAPC数据结构,并通过KeInitializeApc()加以初始化。
[code][NtQueueApcThread() > KeInitializeApc()]
VOID
STDCALL
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -