APC异步过程调用

        我们今天详细介绍一下APC机制,也就是异步过程调用。以下是该机制的一个定义:一种能在特定线程环境中异步执行的系统机制,往线程APC队列添加APC,系统会产生一个软中断,在线程下一次被调度的时候,就会执行APC函数(以该线程的上下背景文执行这段回调代码)。乍一看十分的复杂,本质就是线程执行时内部维护一个APC队列,用来存储一些回调函数,等到时机合适的时候调用这些函数来执行一些逻辑。

        那么我们在介绍这个东西之前,首先一定要知道它存在的价值是什么,因为“存在必合理”嘛,一个东西一定是有它的价值才会被保留。APC的作用就是:当我们需要在指定的线程上下文执行代码(访问用户态地址,必须在用户进程的地址空间中)或者推迟某些工作或者异步IO时,就需要使用APC来帮助延迟。

APC是什么?

        APC是一个机制,用来做线程回调函数的一个机制。每一个线程中默认维护两个APC链表,一个是内核态APC链表,一个是用户态APC链表,用来分别存储两种类型APC数据。

结合Windbg查看APC在KTHREAD中显示

        既然APC是线程的属性,那么就一定在线程对象KTHREAD中有所体现,我们使用Windbg来查看以下是如何定义的:

调试环境为32位的Win 7操作系统

//KTHREAD中有关APC的成员
1: kd> dt _KTHREAD
+0x03a Alerted          : [2] UChar    //Alerted[0]是内核模式Alert,Alerted[1]是用户模式Alert
+0x03c Alertable        : Pos 5, 1 Bit //表示线程当前是否在alertable wait状态(KeWaitForSingleObject传入Execute,KernelMode,TRUE就会进入)
+0x03c MiscFlags        : Int4B        //包含一些线程的杂项状态
+0x040 ApcState         : _KAPC_STATE  //指向APC链表
+0x040 ApcStateFill     : [23] UChar   //内存填充符
+0x088 Teb              : Ptr32 Void
+0x134 ApcStateIndex    : UChar        //当前APC环境的索引,0代表原始APC队列,1代表附加APC队列
+0x168 ApcStatePointer  : [2] Ptr32 _KAPC_STATE  //ApcStatePointer[0]指向OriginalApcEnvironment状态,另一个指向AttachedApcEnvironment
+0x170 SavedApcState    : _KAPC_STATE  //保存的 APC 状态,用于当线程从附加环境 恢复 到原始环境时,能恢复之前的 APC 队列状态
+0x170 SavedApcStateFill : [23] UChar  //填充字节,用于对齐

我没有贴出全部的成员,只是列举出了APC的相关成员,对应的注释已经标注,我们重点关注ApcState对象,该对象指向一个_KAPC_STATE结构体

nt!_KAPC_STATE
+0x000 ApcListHead      : [2] _LIST_ENTRY         //两个LIST_ENTRY,分别指向Kernel APC队列和User APC队列(挂载的都是APC请求)
+0x010 Process          : Ptr32 _KPROCESS         //当前APC所属的进程对象
+0x014 KernelApcInProgress : UChar                //内核APC正在执行
+0x015 KernelApcPending : UChar                   //内核APC挂起等待执行
+0x016 UserApcPending   : UChar                   //用户态APC挂起

该结构体中前两个成员非常重要,ApcListHead是两个链表指针的数组,Process是APC所属的进程对象。ApcListHead[0]指向Kernel APC队列,而ApcListHead[1]指向User APC队列,该队列中都是_KAPC成员,我们给出定义

typedef struct _KAPC
{
    CSHORT  Type;                 // 对象类型 (Dispatcher Object type)
    CSHORT  Size;                 // 结构大小
    ULONG   Spare0;               // 对齐/保留
    struct _KTHREAD *Thread;      // APC要投递到的目标线程(APC的操作对象是线程)
    LIST_ENTRY ApcListEntry;      // 链表节点,用于挂到线程的APC队列
    PKKERNEL_ROUTINE KernelRoutine;   // 必须有,内核例程
    PKRUNDOWN_ROUTINE RundownRoutine; // 可选,线程退出时清理例程
    PKNORMAL_ROUTINE NormalRoutine;   // 可选,普通例程
    PVOID NormalContext;          // NormalRoutine 的上下文参数
    PVOID SystemArgument1;        // 传递给 NormalRoutine 的参数
    PVOID SystemArgument2;        // 传递给 NormalRoutine 的参数
    CCHAR  ApcStateIndex;         // 标识目标 APC 队列(用户/内核)
    KPROCESSOR_MODE ApcMode;      // APC 在用户态还是内核态执行
    BOOLEAN Inserted;             // 是否已经插入队列
} KAPC, *PKAPC;

我们通过一张图片来形象的展示:

同时借助KAPC中的一些成员,我们开始下面的介绍:

三类回调函数

  • KernelRoutine(内核例程):在 APC 被交付前由内核调用,运行在 APC_LEVEL(因此不能阻塞、不能访问分页内存等),常用于准备或修改后续的 NormalRoutine。

  • NormalRoutine(普通例程):真正的“工作例程”。对于用户态APC,NormalRoutine 在用户态执行;对于普通内核APC,NormalRoutine 运行在PASSIVE_LEVEL(内核态) 执行(可以阻塞、调用可分页代码)。是否有 NormalRoutine 也决定了 APC 是“普通内核 APC”还是“特殊内核 APC”(无 NormalRoutine 的内核 APC 通常是特殊内核 APC)。

  • RundownRoutine(清理例程):当线程退出或系统需要丢弃尚未交付的 APC 时调用(必须驻留内核内存,用于释放 APC 相关资源)。

三类函数的函数都放在KAPC结构体中:调用顺序是KernelRoutine->NormalRoutine->RundownRoutine

同时给出三个函数的函数指针,因为函数是系统编译好的,参数也是固定的,所以我们需要定义之后才能调用

​
//三个函数的函数指针,需要在头文件中声明使用
typedef VOID(NTAPI* PKNORMAL_ROUTINE)(
	PVOID NormalContext,
	PVOID SystemArgument1,
	PVOID SystemArgument2
	);

typedef VOID(NTAPI* PKKERNEL_ROUTINE)(
	PRKAPC Apc,
	PKNORMAL_ROUTINE* NormalRoutine,
	PVOID* NormalContext,
	PVOID* SystemArgument1,
	PVOID* SystemArgument2
	);

typedef VOID
(NTAPI* PKRUNDOWN_ROUTINE)(
	IN struct _KAPC* Apc);

​

四类APC函数

  • 特殊用户态 APC(Special user-mode APC):严格在用户态执行,总是会执行,即使目标线程不在alertable 等待状态(从内核态返回用户态会调用该APC)

  • 常规用户态 APC(Regular user-mode APC):只在目标线程处于 alertable 等待状态时执行,线程必须是 alertable 才能运行这些 APC(经典运用场景如重叠 I/O 完成回调,QueueUserAPC等)

  • 普通内核 APC(Normal kernel APC):内核模式下于 PASSIVE_LEVEL 执行(会抢占所有用户态代码,包括用户 APC),文件系统驱动经常使用这种 APC 来在线程上下文中运行工作项

  • 特殊内核 APC(Special kernel APC):在内核模式下于 APC_LEVEL 执行(不能阻塞、不能访问分页内存),会比普通内核 APC 和用户 APC 更早被调度(用于 I/O 完成等场景),没有NormalRoutine,只有KernelRoutine

我们通常使用较多的就是常规的用户态和内核态APC,其他两个使用较少

三种APC环境

名称说明
OriginalApcEnvironment原始 APC 环境APC 被插入到线程的 原始 APC 队列。这个队列始终跟随线程自己的进程。无论线程是否被附加到别的进程,这个环境都指向线程最初所属的进程。
AttachedApcEnvironment附加 APC 环境APC 被插入到线程当前 附加的 APC 队列。当线程被 KeStackAttachProcess 附加到另一个进程时,它就拥有一个“附加 APC 队列”。这个环境主要用于内核在附加上下文里操作时,仍然能调度 APC。
CurrentApcEnvironment当前 APC 环境表示“取决于线程此时的状态”:如果线程在原始进程上下文里,就等价于 OriginalApcEnvironment;如果线程处于附加进程上下文,就等价于 AttachedApcEnvironment

APC被调用的时机

用户态调用APC的时机:

1.线程处于alertable等待:当线程调用以下带 Ex 且bAlertable = TRUE的函数时,会进入alertable 等待,此时如果 APC 队列里有挂起的用户 APC,系统会立即调度执行它们

SleepEx(dwMilliseconds, TRUE);
WaitForSingleObjectEx(hObject, dwMilliseconds, TRUE);          //TRUE就是进入alertable等待
WaitForMultipleObjectsEx(nCount, pHandles, bWaitAll, dwMilliseconds, TRUE);
MsgWaitForMultipleObjectsEx(..., MWMO_ALERTABLE);    //MWMO_ALERTABLE也是进入alertable等待

2.线程返回到用户态(特殊用户 APC 的时机):特殊用户 APC(Special User APC)不同于普通用户 APC,它不依赖 alertable 等待,当线程从内核返回用户态时(例如一个系统调用返回),系统会检查队列里的特殊用户APC并执行

3.调用显式的 APC 检查函数:直接将对应API中的事件等待时间修改为0或者无限就会直接触发APC调度

4.异步 I/O 完成(I/O Completion APC):当用户以 FILE_FLAG_OVERLAPPED 打开文件/设备,并且指定FILE_COMPLETION_ROUTINE作为回调时,APC会在发起异步IO请求的时候被投递,该线程必须进入alertable 等待回调才会被调用

内核态调用APC的时机:

1.特殊内核 APC:一旦中断级为APC_LEVEL 就会迅速执行,速度很快

2.普通内核APC: 等待线程回到 PASSIVE_LEVEL 并允许 APC 时,执行KernelRoutine(APC_LEVEL,准备阶段)->NormalRoutine(PASSIVE_LEVEL,真正工作),适合可阻塞/可分页的工作

3.线程终止 / RundownRoutine:如果线程结束了还有未执行的 APC,系统不会调用NormalRoutine,而是直接调用RundownRoutine(PASSIVE_LEVEL),必须在这里清理内存

APC终止调用的情况:

1. 如果线程进入Critical Region 状态,普通内核 APC 会被推迟

2.线程/进程的IRQL始终大于APC_LEVEL,APC不会被调用

注意:驱动级别的APC在进入DriverUnload之前,必须要确保自己插入的APC已经被执行完毕或者取消了,否则容易造成蓝屏(当 APC 被线程调度执行 → 调用已卸载的内存 )

线程的两个APC状态

每个线程实际上有两个APC状态:

  • OriginalApcState:线程的默认APC 队列

  • ApcState:当内核把当前线程“附加”到其他进程上下文时,线程会暂时有一个新的 APC 队列

熟悉内核WDM编程的朋友应该知道​​​​​​,有一个函数叫KeStackAttachProcess,就是通过切换自己的CR3页表指针来附加到其他进程的内存空间,这样我们就会看到其他进程中线程的信息,也包括其他进程的APC列表。我们也可以将其类比理解PE文件中导入表的OriginalFirstThunk和FirstThunk结构,在没有进入虚拟内存之前,两者指向的东西是一样的,一旦文件被加载到虚拟内存中,两者指向的便开始不同(桥2的断裂),APC也是如此,在没有附加到别的进程前,ApcState和

OriginalApcState都是一样的,只有调用KeStackAttachProcess函数后,才会产生ApcState的不同。

APC的使用

以上说了那么多概念性的知识点,我们现在来结合代码具体感受一下,首先先给出常用函数API的定义:

KeInitializeApc:初始化KAPC结构体

VOID KeInitializeApc(
    PRKAPC              Apc,              //传入ExAllocatePool申请的非分页内存
    PRKTHREAD           Thread,           //要投递 APC 的目标线程
    KAPC_ENVIRONMENT    Environment,      //决定APC被放在哪个队列,一般是OriginalApcEnvironment
    PKKERNEL_ROUTINE    KernelRoutine,    //核心回调函数,APC调度首先调用它
    PKRUNDOWN_ROUTINE   RundownRoutine,   //如果APC在执行前被取消(KeRemoveQueueApc),系统调用
    PKNORMAL_ROUTINE    NormalRoutine,    //真正用户/内核逻辑的回调
    KPROCESSOR_MODE     ApcMode,          //KernelMode APC或者UserMode APC
    PVOID               NormalContext     //传给 NormalRoutine 的上下文参数
);

//对于第三参数:如果没有使用KeAttach函数,需要传入AttachedApcEnvironment

注意:对于三个函数KernelRoutine,RundownRoutine,NormalRoutine都是传入函数指针,对应的参数都是已经固定好的

KeInsertQueueApc:将APC插入线程APC队列

BOOLEAN KeInsertQueueApc(
    PRKAPC Apc,                 //传入KeInitializeApc好的结构体
    PVOID SystemArgument1,      //传给 KernelRoutine 和/或 NormalRoutine 的第 1 个参数
    PVOID SystemArgument2,      //传给 KernelRoutine 和/或 NormalRoutine 的第 2 个参数
    KPRIORITY Increment         //是否提升线程优先级(通常 IO_NO_INCREMENT 或 0)
);
//该函数不是堵塞函数,只负责将对应APC插入线程的APC队列,并不关心APC线程是否执行,只要插入成功就返回成功

然后我们分别给出普通版的用户态APC和内核态APC的实例代码:

//用于内核APC(IRQL ≤ APC_LEVEL就会执行)
PKAPC v1 = ExAllocatePool(NonPagedPool, sizeof(KAPC));
//用于用户APC(NormalRoutine在用户态执行)
PKAPC v2 = ExAllocatePool(NonPagedPool, sizeof(KAPC));  
//构建内核态APC
KeInitializeApc(v1, (PKTHREAD)EThread,OriginalApcEnvironment, &KernelApcKernelCallback,NULL, NULL, KernelMode, NULL);
//构建用户态APC
KeInitializeApc(v2, (PKTHREAD)EThread,OriginalApcEnvironment, &KernelApcUserCallback,NULL, (PKNORMAL_ROUTINE)(ULONG_PTR)CallbackRoutine, UserMode, ParameterData1);
//插入用户态线程APC队列(二,三参数是APC函数的参数)
KeInsertQueueApc(v1, NULL, NULL, 0);
//插入用户态线程APC队列
KeInsertQueueApc(v2, ParameterData2, ParameterData3, 0)

VOID KernelApcKernelCallback(PKAPC Apc, PKNORMAL_ROUTINE* NormalRoutine, PVOID* NormalContext, PVOID* SystemArgument1, PVOID* SystemArgument2)
{
	UNREFERENCED_PARAMETER(NormalRoutine);
	UNREFERENCED_PARAMETER(NormalContext);
	UNREFERENCED_PARAMETER(SystemArgument1);
	UNREFERENCED_PARAMETER(SystemArgument2);
	KeTestAlertThread(UserMode);      
	ExFreePool(Apc);
}

VOID KernelApcUserCallback(PKAPC Apc, PKNORMAL_ROUTINE* NormalRoutine, PVOID* NormalContext, PVOID* SystemArgument1, PVOID* SystemArgument2)
{
	UNREFERENCED_PARAMETER(NormalContext);
	UNREFERENCED_PARAMETER(SystemArgument1);
	UNREFERENCED_PARAMETER(SystemArgument2);
	if (PsIsThreadTerminating(PsGetCurrentThread()))
	{
		*NormalRoutine = NULL;
	}
#ifdef  _WIN64
	if (PsGetCurrentProcessWow64Process() != NULL)
	{
		//用于系统给32位线程投递用户APC,自身是64位进程,给32线程做环境适配
		PsWrapApcWow64Thread(NormalContext, (PVOID*)NormalRoutine);
	}
#endif 
	ExFreePool(Apc);
}

我们从代码中可以看出,通过传入的UserMode和KernelMode就可以判断是用户态APC还是内核态APC。一般不使用RundownRoutine函数。

总结

        综上就是APC的基本概念和基本使用方法,当然说的也很浅显,如果想要进一步了解还是推荐大家阅读一下微软官方的原解释:异步过程调用 - Win32 apps | Microsoft Learn 

如果有问题或者哪里说的不清楚也欢迎大家在评论区指正^_^

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值