APC机制详解

APC的本质

线程是不能被杀死 挂起和恢复的,线程在执行的时候自己占据着CPU,别人怎么可能控制他呢?举个极端的例子,如果不调用API,屏蔽中断,并保证代码不出现异常,线程将永久占据CPU。所以说线程如果想结束,一定是自己执行代码把自己杀死,不存在别人把线程结束的情况。

那如果想改变一个线程的行为该怎么办?可以给他提供一个函数,让他自己去调用,这个函数就是APC,即异步过程调用

APC队列

我们现在需要讨论是的,如果我给某一个线程提供一个函数,那么这个函数挂在哪里?答案是APC队列,先来看一下当前线程的结构体

kd> dt _KTHREAD
ntdll!_KTHREAD
+0x040 ApcState         : _KAPC_STATE

线程结构体KTHREAD0x40的位置的成员是一个子结构体ApcState,也就是APC队列

kd> dt _KAPC_STATE
nt!_KAPC_STATE
   +0x000 ApcListHead			//2个APC队列 用户APC和内核APC 
   +0x010 Process				//线程所属进程或者所挂靠的进程
   +0x014 KernelApcInProgress	//内核APC是否正在执行
   +0x015 KernelApcPending		//是否有正在等待执行的内核APC
   +0x016 UserApcPending		//是否有正在等待执行的用户APC

_KAPC_STATE的第一个成员是两个APC队列,每一个成员都是一个双向链表,这个双向链表就是APC队列。

APC一共有两个,一个是用户态APC队列,一个是内核态的APC队列,里面存储的都是APC函数。

你想让线程执行某些操作的时候,就可以提供一个函数,挂到这个链表里,在某一个时刻,当前线程会检查当前的函数列表,当里面有函数的时候,就会去调用。这样就相当于改变了线程的行为。

现在我们知道了如果想改变线程的行为,需要提供一个函数挂到线程的APC队列里,准确的说是提供一个APC,接下里需要了解APC的结构。

APC结构

kd> dt _KAPC
ntdll!_KAPC
   +0x000 Type             : UChar
   +0x001 SpareByte0       : UChar
   +0x002 Size             : UChar
   +0x003 SpareByte1       : UChar
   +0x004 SpareLong0       : Uint4B
   +0x008 Thread           : Ptr32 _KTHREAD
   +0x00c ApcListEntry     : _LIST_ENTRY
   +0x014 KernelRoutine    : Ptr32     void 
   +0x018 RundownRoutine   : Ptr32     void 
   +0x01c NormalRoutine    : Ptr32     void 
   +0x020 NormalContext    : Ptr32 Void
   +0x024 SystemArgument1  : Ptr32 Void
   +0x028 SystemArgument2  : Ptr32 Void
   +0x02c ApcStateIndex    : Char
   +0x02d ApcMode          : Char
   +0x02e Inserted         : UChar

其中最重要的是+0x01c NormalRoutine的这个成员,通过这个成员可以找到你提供的APC函数。

现在我们知道了提供APC需要遵循的格式,以及存到线程的位置,但是还有另外的问题,当前的线程什么时候会执行所提供的APC函数

如果想要解决这个问题,需要知道一个内核函数:KiServiceExit

APC相关函数

KiServiceExit

这个函数是系统调用 异常或中断返回用户空间的必经之路

KiDeliveApc

负责执行APC函数

备用APC队列

kd> dt _KTHREAD
ntdll!_KTHREAD
+0x040 ApcState         : _KAPC_STATE
+0x170 SavedApcState    : _KAPC_STATE

在线程结构体0x40的位置是APC队列,在0x170的位置也有一个APC队列,这两个成员的结构是完全一样的

ApcState的含义

线程队列中的APC函数都是与进程相关联的,具体点说:A进程的T线程中所有的APC函数,要访问的内存地址都是A进程的。

但线程是可以挂靠到其他的进程:比如A进程的线程T,通过修改CR3,就可以访问B进程的地址空间,即所谓的进程挂靠。

当T线程挂靠B进程后,APC队列中存储的仍然是原来的APC。具体点说,比如某个APC函数要读取地址为0x12345678的数据,如果此时进行读取,读到的将是B进程的地址空间,这样逻辑就错误了。

为了避免混乱,在T线程挂靠B进程时,会将ApcState中的值暂时存储到SavedApcState中,等回到原进程A时,再将APC队列恢复

所以,SavedApcState又称为备用APC队列

挂靠环境下的ApcState的含义

在挂靠环境下,也是可以将线程APC队列插入APC的,那这种情况下,使用的是哪个APC队列呢?

A进程的T线程挂靠B进程,A是T的所属进程,B是T的挂靠进程

  • ApcState:B进程相关的APC函数
  • SavedApcState:A进程相关的APC函数

在正常情况下,当前进程就是所属进程A,如果是挂靠情况下,当前进程就是挂靠进程B

其他APC相关成员

ApcStatePointer

+0x168 ApcStatePointer  : [2] Ptr32 _KAPC_STATE

在KTHREAD结构体的0x168的位置的成员是一个指针数组,有两个指针,每一个指针都指向一个ApcState

为了操作方便,KTHREAD结构体中定义了一个指针数组ApcStatePointer,长度为2。

正常情况下:

​ ApcStatePointer[0]指向ApcState

​ ApcStatePointer[1]指向SavedApcState

挂靠情况下:

​ ApcStatePointer[0]指向SavedApcState

​ ApcStatePointer[1]指向ApcState

ApcStateIndex

+0x134 ApcStateIndex    : UChar

ApcStateIndex用来标识当前线程处于什么状态:0正常状态 1挂靠状态

ApcStatePointer与ApcStateIndex组合寻址

正常情况下,向ApcState队列插入APC时:

​ ApcStatePointer[0]指向ApcState,此时ApcStateIndex的值为0

​ ApcStatePointer[ApcStateIndex]指向ApcState

挂靠情况下,向ApcState队列中插入APC时:

​ ApcStatePointer[1]指向ApcState,此时ApcStateIndex的值为1

​ ApcStatePointer[ApcStateIndex]指向ApcState

总结:

无论什么环境下,ApcStatePointer[ApcStateIndex]指向的都是ApcState,ApcState则总是表示线程当前使用的APC状态

ApcQueueable

+0x0b8 ApcQueueable     : Pos 5, 1 Bit

ApcQueueable用于表示是否可以向线程的APC队列中插入APC。

当线程正在执行退出的代码时,会将这个值设置为0,如果此时执行插入APC的代码,在插入函数中会判断这个值的状态,如果为0,则插入失败。

APC挂入过程

无论是正常状态还是挂靠状态,都要有两个APC队列,一个内核队列,一个用户队列。每当要挂入一个APC函数时,不管是用户队列还是内核队列,内核都要准备一个KAPC的数据结构,并且将这个KAPC结构挂到相应的APC队列中。

KAPC结构

kd> dt _KAPC
nt!_KAPC
   +0x000 Type		//类型  APC类型为0x12
   +0x002 Size		//本结构体的大小  0x30
   +0x004 Spare0    	//未使用                             
   +0x008 Thread 		//目标线程                                  
   +0x00c ApcListEntry	//APC队列挂的位置
   +0x014 KernelRoutine	//指向一个函数(调用ExFreePoolWithTag 释放APC)
   +0x018 RundownRoutine//略 
   +0x01c NormalRoutine	//用户APC总入口  或者 真正的内核apc函数
   +0x020 NormalContext	//内核APC:NULL  用户APC:真正的APC函数
   +0x024 SystemArgument1//APC函数的参数	
   +0x028 SystemArgument2//APC函数的参数
   +0x02c ApcStateIndex	//挂哪个队列,有四个值:0 1 2 3
   +0x02d ApcMode	//内核APC 用户APC
   +0x02e Inserted	//表示本apc是否已挂入队列 挂入前:0  挂入后  1
  • Type :类型。在Windows里,任何一种内核对象都有一个编号,这个编号用来标识你是属于哪一种类型,APC本身也是一种内核对象,它也有一个编号,是0x12
  • Size:这个成员指的是当前的KAPC的结构体的大小
  • Thread:每一个线程都有自己的APC队列,这个成员指定了APC属于哪一个线程
  • ApcListEntry:APC队列挂的位置,是一个双向链表,通过这个双向链表可以找到下一个APC
  • KernelRoutine:指向一个函数(调用ExFreePoolWithTag 释放APC)。当我们的APC执行完毕以后,当前的KAPC本身的这块内存,会由KernelRoutine指定的函数来释放
  • NormalRoutine:如果当前是内核APC,通过这个值找到的就是真正的内核APC函数;如果当前的APC是用户APC,那么这个位置指向的是用户APC总入口,通过这个总入口可以找到所有用户提供的APC函数
  • NormalContext:如果当前是内核APC,通过这个值为空;如果当前的APC是用户APC,那么这个值指向的是真正的用户APC函数
  • SystemArgument1 SystemArgument2 APC函数的参数
  • ApcStateIndex:当前的APC要挂到哪个队列
  • ApcMode:当前的APC是用户APC还是内核APC
  • Inserted:当前的APC结构体是否已经插入到APC队列

挂入流程

在这里插入图片描述

KeInitializeApc

VOID KeInitializeApc
(
	IN PKAPC Apc,//KAPC指针
	IN PKTHREAD Thread,//目标线程
	IN KAPC_ENVIRONMENT TargetEnvironment,//0 1 2 3四种状态
	IN PKKERNEL_ROUTINE KernelRoutine,//销毁KAPC的函数地址
	IN PKRUNDOWN_ROUTINE RundownRoutine OPTIONAL,
	IN PKNORMAL_ROUTINE NormalRoutine,//用户APC总入口或者内核apc函数
	IN KPROCESSOR_MODE Mode,//要插入用户apc队列还是内核apc队列
	IN PVOID Context//内核APC:NULL  用户APC:真正的APC函数
) 

KeInitializeApc函数的作用就是给当前的KAPC结构体赋值

ApcStateIndex

与KTHREAD(+0x134)的属性同名,但含义不一样:

ApcStateIndex有四个值:

  • 0 原始环境->插入到当前线程的所属进程APC队列,不管是否挂靠都插入到当前线程的所属进程。
  • 1 挂靠环境
  • 2 当前环境->插入到当前进程的APC队列,如果没有挂靠,当前进程则是父进程,如果挂靠了,当前进程就是挂靠进程
  • 3 插入APC时的当前环境->线程随时处于切换状态 当值为3时,在插入APC之前会判断当前线程是否处于挂靠状态 再进行APC插入

KiInsertQueueApc

  1. 根据KAPC结构中的ApcStateIndex找到对应的APC队列
  2. 再根据KAPC结构中的ApcMode确定是用户队列还是内核队列
  3. 将KAPC挂到对应的队列中,挂到KAPC的ApcListEntry处
  4. 再根据KAPC结构中的Inserted置1,标识当前的KAPC为已插入状态
  5. 修改KAPC_STATE结构中的KernelApcPending/UserApcPending

内核APC的执行过程

APC函数的插入和执行并不是同一个线程,具体点说:

在A线程中向B线程插入一个APC,插入的动作是在A线程中完成的,但什么时候执行则由B线程决定。所以叫异步过程调用。

内核APC函数与用户APC函数的执行时间和执行方式也有区别。我们先来了解内核APC的执行过程

执行点:线程切换

IDA打开ntkrnlpa,找到SwapContext函数

在这里插入图片描述

在这个函数即将执行完成的时候,会判断当前是否有要执行的内核APC,接着将判断的结果存到eax,然后返回

在这里插入图片描述

接着找到上一层函数KiSwapContext函数继续跟进

在这里插入图片描述

这个函数也没有对APC进行处理,而是继续返回,继续跟进父函数
在这里插入图片描述

返回到这里,会判断KiSwapContext的返回值,也就是判断当前是否有要处理的内核APC,如果有,则调用KiDeliverApc进行处理。

这个函数有三个参数,第一个参数如果是0,就意味着KiDeliverApc在执行的时候只会处理内核APC,第一个参数如果是1,KiDeliverApc除了处理内核APC以外,还会处理用户APC

流程总结:

  1. SwapContext 判断是否有内核APC
  2. KiSwapThread 切换线程
  3. KiDeliverApc

执行点:系统调用 中断或者异常(_KiServiceExit)

在这里插入图片描述

找到_KiServiceExit函数,这里会判断是否有要执行的用户APC,如果有的话则会调用KiDeliverApc函数进行处理,此时KiDeliverApc第一个参数为1,代表执行用户APC和内核APC。

当要执行用户APC之前,先要执行内核APC

KiDeliverApc函数分析

无论是执行内核APC还是执行用户APC都会调用KiDeliverApc函数,接下来分析KiDeliverApc函数主要做了什么事情
在这里插入图片描述

首先这里会取出内核APC列表,然后执行跳转

在这里插入图片描述

接着判断第一个链表是否为空(内核APC队列),如果不为空则跳转

在这里插入图片描述

跳转以后,首先得到KACP的首地址,然后取出KACP结构体的各个参数,放到局部变量里

在这里插入图片描述
在这里,因为我们要处理的是内核APC,所以NormalRoutine代表是内核APC函数地址,这里会判断内核APC函数地址是否为空,不为空的话则进行跳转

在这里插入图片描述

跳转以后,先判断是否有正在执行内核APC,然后判断是否禁用内核APC,接着将APC从内核队列中摘除。

接着先调用KAPC.KernelRoutine指定的函数 释放KAPC结构体占用的空间

在这里插入图片描述

然后将ApcState.KernelApcInProgress 设置为1 标识正在执行内核APC。

接着将三个参数压入栈里,开始执行真正的内核APC函数

执行完毕以后,将ApcState.KernelApcInProgress 置0,接着再次判断内核APC队列,开始下一轮循环

内核APC执行流程总结:

  1. 判断第一个链表(内核APC队列)是否为空
  2. 判断KTHREAD.ApcState.KernelApcInProgress(是否正在执行内核APC)是否为1
  3. 判断是否禁用内核APC(KTHREAD.KernelApcDisable是否为1)
  4. 将当前KAPC结构体从链表中摘除
  5. 执行KAPC.KernelRoutine指定的函数 释放KAPC结构体占用的空间
  6. 将KTHREAD.ApcState.KernelApcInProgress设置为1 标识正在执行内核APC
  7. 执行真正的内核APC函数(KAPC.NormalRoutine)
  8. 执行完毕 将KernelApcInProgress改为0

总结

  1. 内核APC在线程切换的时候就会执行,这也就意味着,只要插入内核APC很快就会被执行
  2. 在执行用户APC之前会先执行内核APC
  3. 内核APC在内核空间执行,不需要换栈,一个循环全部执行完毕

用户APC的执行过程

当产生系统调用 中断或者异常,线程在返回用户空间前都会调用_KiServiceExit函数,在_KiServiceExit函数里会判断是否有要执行的用户APC,如果有则调用KiDeliverApc函数进行处理

执行用户APC时的堆栈操作

处理用户APC要比处理内核APC复杂的多,因为用户APC函数要在用户空间执行,这里涉及到大量的换栈操作:

当线程从用户层进入内核层时,要保留原来的运行环境,比如各种寄存器 栈的位置等等,然后切换成内核的堆栈,如果正常返回,恢复堆栈环境即可

但如果有用户APC要执行的话,就意味着线程要提前返回到用户空间去执行,而且返回的位置不是线程进入内核时的位置,而是返回到真正执行APC的位置

每处理一个用户APC就会涉及到:内核—>用户空间—>再回到内核空间

执行用户APC最为关键的就是理解堆栈操作的细节

KiDeliverApc函数分析

在这里插入图片描述

KiDeliverApc函数会push三个参数,第一个参数如果为0,代表只处理内核APC,如果为1,代表处理用户APC和内核APC。

也就是说内核APC是无论如何都会执行的。

在这里插入图片描述

取出内核APC队列之后会再次取出用户APC队列,并判断用户APC队列是否为空

.text:00426063                 cmp     [ebp+arg_0], 1 

接着判断KiDeliverApc第一个参数是否为1 如果不是1 说明不处理用户APC,直接返回

.text:00426069                 cmp     byte ptr [esi+4Ah], 0 ;

+0x4A=UserApcPending 表示是否正在执行用户APC,为0说明正在执行的用户APC,继续往下走

.text:0042606F                 mov     byte ptr [esi+4Ah], 0

先将UserApcPending置0,表示当前正在执行用户APC

.text:00426073                 lea     edi, [eax-0Ch]

-0xC 得到KPCR首地址

.text:00426076                 mov     ecx, [edi+1Ch]  ; +0x1C=NormalRoutine 用户APC总入口
.text:00426079                 mov     ebx, [edi+14h]  ; +0x14=KernelRoutine 释放APC的函数
.text:0042607C                 mov     [ebp+var_4], ecx
.text:0042607F                 mov     ecx, [edi+20h]  ; +0x20 NormalContext  用户APC:真正的APC函数
.text:00426082                 mov     [ebp+var_10], ecx
.text:00426085                 mov     ecx, [edi+24h]  ; +0x24 SystemArgument1
.text:00426088                 mov     [ebp+var_C], ecx
.text:0042608B                 mov     ecx, [edi+28h]  ; SystemArgument2

接着取出KAPC结构体的成员,放到局部变量里保存

.text:00426091                 mov     ecx, [eax]      ; --------------------------
.text:00426093                 mov     eax, [eax+4]
.text:00426096                 mov     [eax], ecx      ; 链表操作 将用户APC从链表中移除
.text:00426098                 mov     [ecx+4], eax    ; --------------------------

然后将当前的用户APC从链表中摘除

.text:004260B7                 push    eax
.text:004260B8                 push    edi
.text:004260B9                 call    ebx             ; 调用KAPC.KernelRoutine 释放KAPC结构体内存

接着调用调用KAPC.KernelRoutine指定的函数, 释放KAPC结构体内存

到这里为止,用户APC和内核APC的处理方式就发生了变化。

如果是内核APC这里会直接调用APC入口函数,执行内核APC,但是用户APC执行的方式不一样。当前的堆栈处于0环,而用户APC需要在三环执行。

.text:004260CA                 push    [ebp+var_8]
.text:004260CD                 push    [ebp+var_C]
.text:004260D0                 push    [ebp+var_10]
.text:004260D3                 push    [ebp+var_4]
.text:004260D6                 push    [ebp+arg_8]
.text:004260D9                 push    [ebp+arg_4]
.text:004260DC                 call    _KiInitializeUserApc

接着这里调用了KiInitializeUserApc函数,接下来就要研究一下这个函数是如何实现的

用户APC执行流程总结:

  1. 判断用户APC链表是否为空
  2. 判断第一个参数是为1,为1说明处理用户APC和内核APC
  3. 判断ApcState.UserApcPending(是否正在执行用户APC)是否为1
  4. 将ApcState.UserApcPending设置为0,表示正在处理用户APC
  5. 链表操作 将当前APC从用户队列中拆除
  6. 调用函数(KAPC.KernelRoutine)释放KAPC结构体内存空间
  7. 调用KiInitializeUserApc函数

KiInitializeUserApc函数分析:备份CONTEXT

线程进0环时,原来的运行环境(寄存器栈顶等)保存到_Trap_Frame结构体中,如果要提前返回3环去处理用户APC,就必须修改_Trap_Frame结构体,因为此时Trap_Frame中存储的EIP是从三环进零环时保存的EIP,而不是用户APC函数的地址

比如:进0环时的位置存储在EIP中,现在要提前返回,而且返回的并不是原来的位置,那就意味着必须要修改EIP为新的返回位置,还有堆栈ESP也要修改为处理APC需要的堆栈。那原来的值怎么办?处理完APC后该如何返回原来的位置呢?

KiInitializeUserApc要做的第一件事就是备份:

将原来_Trap_Frame的值备份到一个新的结构体中(CONTEXT),这个功能由其子函数KeContextFromKframes来完成

在这里插入图片描述

找到KiInitializeUserApc函数,首先调用了KeContextFromKframes,将Trap_Frame备份到Context

第一个参数ebx是Trap_Frame结构体首地址,第三个参数ecx是CONTEXT结构体首地址

那么问题在于CONTEXT结构体存到哪?肯定不能存到当前函数的局部变量里。Windows想了一个办法,把这个结构体和APC需要的参数,直接存到三环的堆栈里

KiInitializeUserApc函数分析:堆栈图

.text:00429EFC                 mov     esi, [ebp+var_224] ; 2E8-224=C4 刚好是CONTEXT结构体ESP的偏移
.text:00429F02                 and     esi, 0FFFFFFFCh ; 进行4字节对齐
.text:00429F05                 sub     esi, eax        ; 在0环直接修改3环的栈 将用户3环的栈减0x2DC个字节

首先esi是CONTEXT结构体里ESP的偏移,也就是三环的堆栈,然后进行4字节对齐。

接着将用户3环的栈减0x2DC个字节,此时三环的堆栈被拉伸,为什么是2DC个字节呢?

因为CONTEXT结构体的大小加上用户APC所需要的4个参数正好是2DC个字节,如下图:

在这里插入图片描述

.text:00429F16                 lea     edi, [esi+10h] 

此时的esi指向的是-2DC的位置,也就是上图的NormalRoutine,+10降低堆栈,将指针指向SystemArgument2

.text:00429F19                 mov     ecx, 0B3h
.text:00429F1E                 lea     esi, [ebp+var_2E8]
.text:00429F24                 rep movsd

这几行代码将CONTEXT复制到了三环的堆栈

.text:00429FAC                 push    4
.text:00429FAE                 pop     ecx
.text:00429FAF                 add     eax, ecx
.text:00429FB1                 mov     [ebp+var_2EC], eax
.text:00429FB7                 mov     edx, [ebp+arg_C]
.text:00429FBA                 mov     [eax], edx
.text:00429FBC                 add     eax, ecx
.text:00429FBE                 mov     [ebp+var_2EC], eax
.text:00429FC4                 mov     edx, [ebp+arg_10]
.text:00429FC7                 mov     [eax], edx
.text:00429FC9                 add     eax, ecx
.text:00429FCB                 mov     [ebp+var_2EC], eax
.text:00429FD1                 mov     edx, [ebp+arg_14]
.text:00429FD4                 mov     [eax], edx
.text:00429FD6                 add     eax, ecx
.text:00429FD8                 mov     [ebp+var_2EC], eax ; 修正3环堆栈栈顶

接着这几行代码就是将APC函数执行时需要的4个值压入到3环的堆栈

KiInitializeUserApc函数分析:准备用户层执行环境

当KiInitializeUserApc将CONTEXT和执行用户APC所需要的4个值备份到3环的堆栈时,就开始准备用户层的执行环境了

在这里插入图片描述

.text:00429F2D                 push    23h
.text:00429F2F                 pop     eax             ; eax=0x23
.text:00429F30                 mov     [ebx+78h], eax  ; 修改Trap_Frame中的SS
.text:00429F33                 mov     [ebx+38h], eax  ; 修改Trap_Frame中的DS
.text:00429F36                 mov     [ebx+34h], eax  ; 修改Trap_Frame中的ES
.text:00429F39                 mov     dword ptr [ebx+50h], 3Bh ; 修改Trap_Frame中的FS
.text:00429F40                 and     dword ptr [ebx+30h], 0 ; 修改Trap_Frame中的GS

首先修改段寄存器 SS DS FS GS

.text:00429F78                 mov     [ebx+70h], eax  ; 修改Trap_Frame中的EFLAGS

接着修改EFLAGS寄存器

.text:00429F97                 mov     [ebx+74h], eax  ; 修改Trap_Frame中的ESP
.text:00429F9A                 mov     ecx, _KeUserApcDispatcher
.text:00429FA0                 mov     [ebx+68h], ecx  ; 修改Trap_Frame中的EIP

然后修改ESP和EIP。这个EIP就是执行用户APC时返回到3环的位置。

这个位置是固定的,是一个全局变量:KeUserApcDispatcher。这个值在系统启动的时候已经赋值好了,是3环的一个函数:ntdll.KiUserApcDispatcher

然后回到3环,由KiUserApcDispatcher执行用户APC

总结:

  1. 段寄存器 SS DS FS GS
  2. 修改EFLAGS寄存器
  3. 修改ESP
  4. 修改EIP->ntdll.KiUserApcDispatcher

ntdll.KiUserApcDispatcher函数分析

在这里插入图片描述

找到KiUserApcDispatcher函数,结合上面的堆栈图我们可以得知,esp+0x10的位置就是CONTEXT指针

此时的ESP指向的是NormalRoutine,pop eax将NormalRoutine赋值给了eax,然后call eax开始处理用户APC的总入口

处理完用户的APC函数之后,会调用ZwContinue,这个函数的意义在于:

  1. 返回内核,如果还有用户APC,重复上面的执行过程
  2. 如果没有需要执行的用户APC,会将CONTEXT赋值给Trap_Frame结构体,回到0环

总结

  1. 内核APC在线程切换时执行,不需要换栈,比较简单,一个循环执行完毕
  2. 用户APC在系统调用、中断或异常返回3环前会进行判断,如果有要执行的用户APC,再执行。
  3. 用户APC执行前会先执行内核APC
评论将由博主筛选后显示,对所有人可见 | 还能输入1000个字符 “速评一下”
©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页