Windows异常处理核心原理(1)--- 理论篇

本文深入解析Windows下的异常处理流程,涵盖CPU异常与程序抛出异常的触发与分发机制,探讨VEH与SEH异常处理链表的工作原理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

                           Windows异常处理核心原理理论篇

前言

这里简单说一下此篇文章应该怎么看,Windows下的异常处理相对来说不是很复杂,但是内容还是蛮多的。也因此呢这个文章也应该是一个系列的开头;在这一篇文章中,我会大概的讲述Windows下异常的处理流程,是一个大概的内容,更详尽的内容留在后面的章节慢慢来说。

理论篇篇幅应该较长,想要详细了解流程的同学还请慢慢看。

异常是什么?

怎么解释异常是什么之前,得先了解我们写程序的时候在什么地方用到了异常;应用层使用Windows的异常处理机制主要通过了三种方式:

  1. 使用AddVectoredExceptionHandler函数来添加一个VEH的向量化异常处理函数;
  2. 使用SetUnhandledExceptionFilter函数来添加一个未处理异常的异常处理函数;
  3. 在C/C++中使用关键字__try {...} __except(...) {...} (后面我直接用__try来代替前面这一长串)的方式来实现的SEH异常处理,当然这里并不是说SEH只能使用_try来实现的,这里是指平时用到的SEH异常捕获一般使用__try关键字;

那么,即便是现在知道了我们常用的使用异常捕获的方式有这些,但是这对于我们来讲,对于我们来说也是使用而已;平时代码里面我使用__try的,Windows自己的VEH以及未处理异常的处理一般情况下不会用到。__try非常好用,我们可以在我们代码的任何位置给我们的代码套上这个关键字。

那么,到底是什么异常?异常是CPU或者程序发生的某种错误,异常处理就是异常产生之后Windows对于产生的错误的一段处理程序;在这里异常分为CPU异常和程序抛出的异常(也称之为模拟异常),我分开讲,

CPU异常就是由CPU发现的异常,比如说,除零异常,内存访问异常,经常我们看到的错误信息,类似于““0xXXXXXXX指令引用的“0xXXXXXXXX内存”,该内存不能为“read””

看着是不是很熟悉? 那么这类错误就是CPU产生的异常,这一类异常的异常代码定义在Windows里面可以找到。这一类异常的产生是由CPU触发的,所以叫CPU异常;

程序抛出的异常也称之为模拟异常,意思就是这个异常不是由CPU触发的,而是由程序触发的异常。比如C++关键字throw,以及Windows API函数RaiseException,当然throw最终仍然会调用RaiseException;也就是这个异常是由程序触发的,当然这个程序也包括C++库里面的函数。此处没图.jpg。

 

CPU异常和程序抛出异常的区别在于,他们产生异常的方式不同;CPU的异常触发是在CPU检测到错误而发出的异常;比如,如果当前正在执行应用层的代码,但是该代码产生了异常,那么这个时候CPU会立刻中止执行,并且换到内核然后进入对应的异常分发函数;而程序抛出的异常则是通过ntdll!NtRaiseException函数进入内核,然后在nt!NtRaiseException的流程中调用异常处理函数。除开这一点儿,其他的都是一样的。

 

异常的产生

虽然看起来这里分成了两种异常,但是实际上这两种异常的最终处理方式是一模一样的,只是异常的触发点不一样;

CPU异常:该异常的产生是当CPU尝试执行指令时检查到的问题;当异常产生时,CPU查询中断处理表,找到异常处理的函数(_KiTrapXX之类)-> KiTrapXX函数里面调用CommonDipatchException-> 调用KiDispatchException进行异常分发;

模拟异常的产生:该异常的产生调用RaiseException->包装异常->NtRaiseException->进入内核-> 调用nt!NtRaiseException-> 调用KiDispatchException进行异常分发; 其中包装异常的意思就是,因为这个异常是模拟的,所以这个异常的具体信息需要由程序自己来填充。

 

异常的分发

说起异常的分发,那么这里故事线就稍稍有一点儿长了。因为Windows分为内核(R0)和应用层(R3),Windows的异常处理机制是内核和应用层都可以使用的。所以这里就需要进行一些区分了。因为如果内核产生了异常,那么自然内核的异常处理函数也在内核里面那么这个事情就好办了,我出现异常了我直接处理就好了。但是应用层如果出现了异常,那么一定他的处理函数是在应用层的,当产生异常的时候,异常的分发总是在内核,那么如果要处理应用层的异常,那么需要先切换到应用层然后才能去执行应用层的异常分发处理。 这里注意了,如果觉得没有看懂,那么这个地方多看几次。

 

内核异常的分发

内核里面的异常处理比起应用层的异常处理来说要简单的多。当异常产生的时候,这时候流程进入到KiDispatchException函数,在该函数内备份当前线程R3的TrapFrame(注意当处理完毕R3异常的时再次调用NtContinue需要此备份的数据),但是因为异常是在内核触发的,所以这个过程其实并没有什么用。它的用处在后面再讲;首先判断这是不是第一次异常,判断是否存在内核调试器,如果有内核调试器,则把当前的异常信息发送给内核调试器;如果没有内核调试器或者内核调试器没有处理该异常,那么便调用RtlDispatchException函数进行异常处理;但是如果RtlDispatchException函数没有处理该异常,那么将再次尝试将异常发送到内核调试器,如果此时内核调试器仍然不存在或者没有处理该异常,那么此时系统会直接蓝屏。

RtlDispatchException函数内部通过fs:[0]来获取关于当前线程的_KPCR,它的成员_NT_TIB::ExceptionList里面存放的是当前线程的异常处理函数链表;原型如下(代码来自ReactOS):


typedef struct _KPCR {
  union {
    NT_TIB NtTib;
    struct {
      struct _EXCEPTION_REGISTRATION_RECORD *Used_ExceptionList;
      PVOID Used_StackBase;
      PVOID Spare2;
      PVOID TssCopy;
      ULONG ContextSwitches;
      KAFFINITY SetMemberCopy;
      PVOID Used_Self;
    };
  };
  struct _KPCR *SelfPcr;
  struct _KPRCB *Prcb;
  KIRQL Irql;
  ULONG IRR;
  ULONG IrrActive;
  ULONG IDR;
  PVOID KdVersionBlock;
  struct _KIDTENTRY *IDT;
  struct _KGDTENTRY *GDT;
  struct _KTSS *TSS;
  USHORT MajorVersion;
  USHORT MinorVersion;
  KAFFINITY SetMember;
  ULONG StallScaleFactor;
  UCHAR SpareUnused;
  UCHAR Number;
  UCHAR Spare0;
  UCHAR SecondLevelCacheAssociativity;
  ULONG VdmAlert;
  ULONG KernelReserved[14];
  ULONG SecondLevelCacheSize;
  ULONG HalReserved[16];
} KPCR, *PKPCR;

typedef struct _NT_TIB {
  struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList;
  PVOID StackBase;
  PVOID StackLimit;
  PVOID SubSystemTib;
  _ANONYMOUS_UNION union {
    PVOID FiberData;
    ULONG Version;
  } DUMMYUNIONNAME;
  PVOID ArbitraryUserPointer;
  struct _NT_TIB *Self;
} NT_TIB, *PNT_TIB;

// 异常处理函数结构体
typedef struct _EXCEPTION_REGISTRATION_RECORD
{
  struct _EXCEPTION_REGISTRATION_RECORD *Next;   // 指向下一个异常处理结构
  PEXCEPTION_ROUTINE Handler;                    // 异常处理函数
} EXCEPTION_REGISTRATION_RECORD, *PEXCEPTION_REGISTRATION_RECORD;

注意:这里不同的系统会不一样:不同的操作系统版本会出现不一样的情况。Windows XP SP3下RtlDispatchException函数只遍历了SEH, 但是在ReactOS里面,内核异常先遍历了VEH链表然后再遍历SEH链表。

 

用户态异常的分发

内核态的异常处理函数自然在内核中,那么用户态的异常处理自然也是在用户态中,当用户态异常经过KiDispatchException时,那么便需要切换到用户态的代码进行异常的分发和处理。

用户态异常的分发这里稍微复杂一些,最主要的原因是处理此异常需要先切换到用户空间,然后交由用户层的异常代码再次进行分发。详细流程如下:

  1. 检查当前是不是第一次分发该异常,为什么有这个标志,因为异常第一次如果没有被处理,那么第二次就不会再次调用异常处理程序,而是直接尝试将异常发给调试器;
  2. 如果当前是第一次分发该异常,那么便尝试将异常发给内核调试器(注意:这里是内核调试器,不是用户调试器);
  3. 如果内核调试器不存在或者没有对该异常进行处理,那么便尝试将异常发送给用户态调试器;
  4. 如果用户态调试不存在或者也没有处理该异常,那么此时边准备一个返回ntdll!KiUserExceptionDispatcher函数的应用层调用栈,准备产生的异常的数据,然后结束本次KiDispatchException函数的运行;因为当函数结束之后,会调用KiServiceExit返回用户层,此时当前的TrapFrame就是准备好的用于执行ntdll!KiUserExceptionDispatcher的环境,所以当从内核退出时,用户态线程便会从执行ntdll!KiUserExceptionDispatcher开始执行;
  5. ntdll!KiUserExceptionDispatcher调用ntdll!RtlDispatchException进行异常的分发,此处的流程和内核态nt!RtlDispatchException流程基本一致; 
  6. 通过RtlCallVectoredExceptionHandlers遍历VEH链表尝试查找异常处理函数;
  7. 如果VEH没有处理函数处理该异常,那么便从fs[0]读取ExceptionList并开始执行SEH的函数处理;
  8. 如果到最后仍然没有处理该异常,这是便会再次主动调用NtRaiseException将该异常重新抛出来,但是此时就不是第一次机会了,此时NtRaiseException流程重新调用了nt!KiDispatchException,并再次进入用户态异常的处理分支,但是此时不再是第一次异常处理,所以此次异常不会再次发给用户态进行分发,而是再次尝试将异常发给用户调试器(注意:此时不会再次将异常发送给内核调试器),此时有两次机会可以让用户态调试器进行异常的处理,最后如果此异常仍然没有被用户态调试器处理,那么nt!KiDispatchException便会调用ZeTerminateProcess直接结束该进程。
  9. 如果在这一步有了异常处理程序处理了该异常,那么这时候便会调用NtContinue,将之前保存的TrapFrame还原;(注意看内核分发里面的标红文本)
  10. 当函数从NtContinue返回时,这时候就会根据上面函数的处理结果继续执行。

注意:这部分的描述应该借助于源码进行观察。

始终都有异常处理函数的用户层代码

应用层异常的分发虽然看起来有可能存在找不到异常处理方案,但是应用层和内核最大的区别在于,如果内核层发生的异常没有被正确执行,那么此时就会产生蓝屏,但是用户层的异常没有被处理,那么肯定不会也是蓝屏吧。当然不会了,如果最终实在是没有异常处理程序处理该异常,那么最终Windows会根据当前系统的设置,调用UnhandledExpctionFilter的函数,这个函数被调用之后,要么弹出一个错误框,要么启动一个调试器等等等。也就是说,几乎是无论如何都会有一个异常处理函数来接管最后的异常,哪怕是弹出一个框框(““0xXXXXXXX指令引用的“0xXXXXXXXX内存”,该内存不能为“read””)。参见图1

 

VEH和SEH

VEH是一个全局链表,全名为Vectored Exception Handler, 这个全局链表里面存放的异常处理函数可以过滤所有线程产生的异常;其处理函数的原型如下:

typedef LONG
(NTAPI *PVECTORED_EXCEPTION_HANDLER)(
    struct _EXCEPTION_POINTERS *ExceptionInfo
);

VEH的注册是通过API函数AddVectoredExceptionHandler进行注册的;他比SEH拥有更优先的级别过滤异常;更多有关于VEH的说明请参见MSDN。

 

SEH是比较特殊的异常处理链表,全名为Structured Exception Handler,SEH的注册结构体只能作为局部变量存在于当前线程的调用栈中,如果一旦结构体的地址不在当前调用栈范围中,那么在进行异常分发时,将不会进入该函数。SEH描述结构的注册随着函数的调用而注册,随着函数的结束而注销。当前有关于SEH的部分还有很多,这里就不专门针对SEH的异常处理做介绍,接下来专门再开一篇来介绍SEH,以及C++编译器用__try来实现的SEH。

和内核态的读取ExceptionList一样,用户态也是从fs[0]出读取当前的异常处理函数链表。不过此时的fs[0]村里面存放的就不是_KPCR结构,而是_TEB,不过_TEB结构的第一个成员仍然是_NT_TIB, 所以这里就无所谓到底是哪个结构了,结构如下:

typedef struct _TEB
{
    NT_TIB          Tib;                        /* 000 */
    PVOID           EnvironmentPointer;         /* 01c */
    CLIENT_ID       ClientId;                   /* 020 */
    PVOID           ActiveRpcHandle;            /* 028 */
    PVOID           ThreadLocalStoragePointer;  /* 02c */
    PVOID           Peb;                        /* 030 */
    ULONG           LastErrorValue;             /* 034 */
    ULONG           CountOfOwnedCriticalSections;/* 038 */
    PVOID           CsrClientThread;            /* 03c */
    PVOID           Win32ThreadInfo;            /* 040 */
    ULONG           Win32ClientInfo[31];        /* 044 used for user32 private data in Wine */
    PVOID           WOW32Reserved;              /* 0c0 */
    ULONG           CurrentLocale;              /* 0c4 */
    ULONG           FpSoftwareStatusRegister;   /* 0c8 */
    PVOID           SystemReserved1[54];        /* 0cc used for kernel32 private data in Wine */
    PVOID           Spare1;                     /* 1a4 */
    LONG            ExceptionCode;              /* 1a8 */
    PVOID     ActivationContextStackPointer;            /* 1a8/02c8 */
    BYTE            SpareBytes1[36];            /* 1ac */
    PVOID           SystemReserved2[10];        /* 1d4 used for ntdll private data in Wine */
    GDI_TEB_BATCH   GdiTebBatch;                /* 1fc */
    ULONG           gdiRgn;                     /* 6dc */
    ULONG           gdiPen;                     /* 6e0 */
    ULONG           gdiBrush;                   /* 6e4 */
    CLIENT_ID       RealClientId;               /* 6e8 */
    HANDLE          GdiCachedProcessHandle;     /* 6f0 */
    ULONG           GdiClientPID;               /* 6f4 */
    ULONG           GdiClientTID;               /* 6f8 */
    PVOID           GdiThreadLocaleInfo;        /* 6fc */
    PVOID           UserReserved[5];            /* 700 */
    PVOID           glDispatchTable[280];        /* 714 */
    ULONG           glReserved1[26];            /* b74 */
    PVOID           glReserved2;                /* bdc */
    PVOID           glSectionInfo;              /* be0 */
    PVOID           glSection;                  /* be4 */
    PVOID           glTable;                    /* be8 */
    PVOID           glCurrentRC;                /* bec */
    PVOID           glContext;                  /* bf0 */
    ULONG           LastStatusValue;            /* bf4 */
    UNICODE_STRING  StaticUnicodeString;        /* bf8 used by advapi32 */
    WCHAR           StaticUnicodeBuffer[261];   /* c00 used by advapi32 */
    PVOID           DeallocationStack;          /* e0c */
    PVOID           TlsSlots[64];               /* e10 */
    LIST_ENTRY      TlsLinks;                   /* f10 */
    PVOID           Vdm;                        /* f18 */
    PVOID           ReservedForNtRpc;           /* f1c */
    PVOID           DbgSsReserved[2];           /* f20 */
    ULONG           HardErrorDisabled;          /* f28 */
    PVOID           Instrumentation[16];        /* f2c */
    PVOID           WinSockData;                /* f6c */
    ULONG           GdiBatchCount;              /* f70 */
    ULONG           Spare2;                     /* f74 */
    ULONG           Spare3;                     /* f78 */
    ULONG           Spare4;                     /* f7c */
    PVOID           ReservedForOle;             /* f80 */
    ULONG           WaitingOnLoaderLock;        /* f84 */
    PVOID           Reserved5[3];               /* f88 */
    PVOID          *TlsExpansionSlots;          /* f94 */
} TEB, *PTEB;

所以,这里的话就是在RtlDispatchException中对SEH链表进行处理。SEH结构的声明见本篇上面的结构原型EXCEPTION_REGISTRATION_RECORD;

注意:EXCEPTION_REGISTRATION_RECORD这个结构体只是一个基本的结构体,里面第一个参数Next指向了下一个异常处理器的结构,第二个参数Handler则存放的是处理函数的指针。C++编译器的扩展了这个结构以使__try{...}__except(...){...}可以嵌套使用。

 

异常分发的理论这里暂时结束,后续我还会继续进行补全。

如果有说错的地方,还请指正,我会第一时间进行修正,欢迎讨论。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值