从信号源的角度来讲,异常大体可以分为两类:软件异常和硬件异常。前者由操作系统/应用程序引发,后者由CPU引发。
异常和中断的区别在于,中断可以在任何时候发生,和CPU正在执行什么指令无关,可以被取消。而异常是由于CPU执行了某条指令而引起的,不能被取消。
软件异常
用户:RaiseException -> RltRaiseException -> NtRaiseException -> KiRaiseException
内核:RtlRaiseException -> NtRaiseException -> KiRaiseException
软件异常归根结底都是基于 RaiseException 这个用户态 API 和 NtRaiseException 的内核服务建立起来的。
void RaiseException(
DWORD dwExceptionCode , // 异常状态码
DWORD dwExceptionFlags, // 异常标志(可持续 / 不可持续)
DWORD nNumberofArguments, // lpArguments[] 数组长度
const DWORD* lpArguments // 传递给异常处理程序的筛选表达式
);
// 具体作用就是将异常信息传递给 EXCEPTION_RECORD 这个结构,然后再调用 RtlRaiseException 函数
/*
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode; // 异常状态码
DWORD ExceptionFlags; // 异常标志
struct _EXCEPTION_RECORD *ExceptionRecord; // 指向下一个异常的指针
PVOID ExceptionAddress; // 保存异常发生的地址
DWORD NumberParameters; // ExceptionInformation[] 数组参数个数
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; // 异常描述信息
} EXCEPTION_RECORD;
*/
硬件异常
基本可以分为三类,错误异常、陷阱异常、终止异常。
错误异常
处理错误异常时,操作系统首先保护当前环境(上下文),然后然后调用相应的异常处理函数,如果异常处理成功则恢复现场环境继续执行。
陷阱异常
和错误异常不同,陷阱异常发生时会保存要执行的下一条指令的环境(而不是正在执行的指令的环境),调试器的断点就是基于陷阱异常实现的。
终止异常
主要用来处理严重的硬件错误,和上面的异常不同,这种异常不会恢复执行而是直接退出。
异常的分发处理
异常产生时,CPU是通过IDT进入内核来寻找处理函数:
当内核可以处理这个异常时,异常处理程序执行完后会恢复现场并继续执行,这个过程应用程序感知不到。
当内核不能处理这个异常时,如果该异常来自内核,则蓝屏;如果该异常来自应用程序,则异常处理权转交给应用程序的异常处理函数,程序如果处理了该异常则程序继续执行,没有处理则崩溃。
如果程序被调试,则异常处理权限转交给调试器。如果调试器处理了这个异常则程序继续执行,如果没有处理则将异常处理权限转交给应用程序。如果程序没有被调试,则将异常信息连同线程上下文环境送入程序的栈中,并调用 KiUserExceptionDisptcher (由 ntdll 导出,所有的异常分发都会走这个函数),但实际发挥作用的是 RtlExceptionDisptcher,如果该接口返回成功,则调用 ZwContinue 继续执行,否则调用 ZwRaiseException 结束进程。
硬件异常会通过IDT去调用异常处理例程(一般为KiTrap系列函数)。 软件异常则是通过API的层层调用传递异常的信息。
但无论是硬件异常还是软件异常,最后都会走到 KiDispatchException。
windows 提供了三种异常处理方式,结构化异常处理(SEH)、向量化异常处理(VEH、VCH)、顶级异常处理(TopLevelEH)。
结构化异常处理(SEH)
SEH是基于线程的一种处理机制,依赖于栈进行存储和查找,所以也被称作是基于栈帧的异常处理机制(只能处理自己线程的异常,而不是像VEH那样可以影响整个进程)。
其实,可以把SEH理解为高级语言的(try-catch),它的作用就是构造一个SEH节点,并将该节点放入SEH链表头部。
在从 C/C++ 中,可以使用 __try 和 __expect 来处理 windows 的 SEH 异常,还可以使用SetUnhandledExceptionFilter注册一个异常处理函数。
当一个异常产生,且__try 和 __expect没有处理处理这个异常时,异常会转交给SetUnhandledExceptionFilter。但是如果存在调试器,则调试器就会接管这个异常,那么异常就不会走SetUnhandledExceptionFilter注册的异常处理函数(调试器默认情况下是接管的)。
except参数的值有以下三种:
EXCEPTION_CONTINUE_EXECUTION,异常被忽略或已被修复,程序控制留跳转到导致异常的那条指令,并尝试重新执行这条指令,继续恢复运行。
EXCEPTION_CONTINUE_SEARCH,异常不被识别,也即当前的这个__except模块不是这个异常错误所对应的正确异常处理模块,那么系统将继续到上一层的try-except域中,继续查找一个恰当的__except模块;单纯返回常量EXCEPTION_CONTINUE_SEARCH,系统寻找到在它上一层的一个try块,并调用对应的异常过滤程序中的函数(此时若出现异常终止程序,会先忽略)。
EXCEPTION_EXECUTE_HANDLER,异常已经被识别,也即当前的这个异常错误,系统已经找到了并能够确认,这个__except模块就是正确的异常处理模块,控制流将进入到__except模块中。
typedef struct _CONTEXT {
DWORD ContextFlags;
DWORD Dr0;
DWORD Dr1;
DWORD Dr2;
DWORD Dr3;
DWORD Dr6;
DWORD Dr7;
FLOATING_SAVE_AREA FloatSave;
DWORD SegGs;
DWORD SegFs;
DWORD SegEs;
DWORD SegDs;
DWORD Edi;
DWORD Esi;
DWORD Ebx;
DWORD Edx;
DWORD Ecx;
DWORD Eax;
DWORD Ebp;
DWORD Eip;
DWORD SegCs;
DWORD EFlags;
DWORD Esp;
DWORD SegSs;
BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;
向量化异常处理(VEH、VCH)
在每个进程的 ntdll.dll 中有一张 VEH 链表, 可以向其中添加一个节点来注册自己的异常处理器,使用AddVectordExceptionHandler函数。
VCH 和 VEH 类似,但是只会在异常被处理的情况下,最后调用。
顶级异常处理(TopLevelEH)
本质上也是 SEH,在最顶层的SEH中,可以注册一个顶层异常处理器(和 SEH 不同的是它可以影响所有的线程)。
当 SEH 链表中的异常处理器都处理不了某个异常,在最顶层的SEH中就会检查是否注册了顶层异常处理,如果注册了顶级异常处理器,就会给 SEH “最后一次处理异常的机会” ,把异常抛给 TopLevelEH(但如果程序被调试时就会忽略 TopLevelEH)。
各异常之间的关系
- 当异常交由用户处理时,按照以下顺序调用异常处理方式 VEH -> SEH -> VCH。
- 当 VEH 表示处理了异常,就不会传递给 SEH,但是会传递异常给 VCH。
- 当 VEH 没有处理了,就会传递给 SEH。
- 当 SEH 的所有异常处理函数没能够处理异常,会调用默认的 SEH (就是 UEH,只是方式属于SEH)处理函数。
- 当 SEH 处理了异常,从 except 开始执行,就不会再将异常传递给 VCH。
- 当 SEH 返回异常产生处执行,在返回之前会调用 VCH。
┌─────┐ unable ┌─────┐ unable ┌─────┐ unable ┌─────┐
│ VEH ├────────►│ SEH ├────────►│ UEH ├─────────►│ VCH │
└──┬──┘ handle └──┬──┘ handle └─────┘ handle └──▲──┘
│ │ handle │
│ └────────────────────────────────┤
│ handle │
└────────────────────────────────────────────────┘
RtlExceptionDisptcher
其具体工作如下:
首先遍历 VEH 链表,逐个执行异常处理器,一旦某个处理器处理成功则返回成功,线程继续运行。如果 VEH 链表遍历完毕异常仍然没有被处理则遍历 SEH 链表,再逐个执行 SEH 的异常处理器一旦某个处理器处理成功则返回成功,线程继续运行。如果 VEH 和 SEH 都没有处理这个异常,则进程结束。
参考资料: