目录
一、中断与异常处理
1.1 中断与异常
1.2 IDT
1.3 异常的概念
1.4 异常分类
二、windows异常处理方式
2.1 概述
2.2 结构化异常处理
2.3 向量化异常处理之VEH
2.4 向量化异常处理之VCH
2.5 默认的异常处理函数
2.6 如何手动安装 SEH 节点
2.7 异常处理的优先级
一、中断与异常处理
1.1 中断与异常
计算机其实就是执行指令的机器,只要给它设定一个起始位置,它就会一条接一条地按顺序执行指令。但这种只知道按顺序执行的方式会有问题,就像人在路上走路不躲开来往的车,或者心脏病发作了还非要把饭吃完一样,可能会让自己出问题。
为了解决这个问题,就有了中断和异常这两种机制。有了它们,计算机变得更灵活。中断和异常能让处理器去执行正常控制流程之外的代码。
中断一般是由外部硬件引发的,它的作用是告诉操作系统有一些操作发生了。比如键盘、鼠标、时钟这些设备,它们引发的中断属于异步事件。而异常通常是程序在执行的时候出了问题,需要马上处理,它和处理器当前正在执行的任务有关,是同步事件。
CPU为了能高效地和外部硬件互动,采用了中断机制。就拿鼠标来说,它是接在CPU外面的硬件。要是CPU一直不停地去监测鼠标有没有移动、有没有点击,会占用大量的CPU资源。但如果鼠标在有移动、点击等动作时主动通知CPU,情况就好多了。这样一来,CPU就不用一直盯着每个外部硬件,等外部硬件需要CPU响应时,硬件自己会去通知CPU。在主板上,CPU有一根引脚和外部硬件相连,通过这根引脚,它们就能传递信息,这为中断机制提供了硬件上的支持。
实际上,外部中断是由PIC(可编程中断控制器)控制的。
当外部硬件产生中断,并且CPU能处理这个中断(也就是IF标志位没有被设置成屏蔽所有中断),CPU会从PIC那里读取两个字节的数据,一般是“0xcd 0x??”。这两个字节的数据会被CPU当成指令来执行,其中0xCD是INT XX中断指令的操作码,另一个字节就是中断编号。INT XX中断指令为CPU响应外部硬件提供了软件方面的支持。
中断不只是来自外部硬件,CPU内部也可能产生中断。比如CPU执行除以0的操作时,会主动产生一个中断,让处理除数为0的代码开始运行,防止CPU死机。因为除数是0时,CPU算不出结果,却会一直不停地算,导致下一条指令没办法执行,所以CPU得有个功能能强制结束当前这条无效的指令。
为了能更清楚地区分这两种不同来源的中断,一般把外部硬件产生的中断就叫做“中断”,把CPU内部产生的中断叫做“异常”。不管是外部中断还是内部异常,都会有一个中断号,这个中断号就是IDT表(中断描述符表)的索引号,通过它能找到处理中断的函数的地址。
1.2 中断描述符表(IDT)
系统会统一管理中断和异常,针对每一种中断或者异常,系统都配备了一个处理函数,这个函数就是陷阱处理器。当中断或者异常出现时,原本正在执行的指令就会暂停,转而让特定的陷阱处理器来处理中断或异常。
在保护模式的环境下,如果产生了中断或者异常,CPU会借助中断描述符表(IDT)来查找对应的处理函数。所以,IDT表就像是CPU(硬件)和操作系统(软件)之间交接中断和异常的重要“关卡”。操作系统在刚开始启动的时候,有一项重要的工作,就是设置好IDT表,并且准备好处理各种异常和中断的函数。
IDT是存放在物理内存里的一个线性表,一共有256项。在64位模式下,IDT表的每个元素长度是16个字节,整个表的长度是4096字节(也就是4Kb);在32位模式下,每个元素长度是8个字节,IDT表总长度为2048字节(即2Kb)。IDT表是在操作系统启动的初期就被初始化的,所以IDT表以及里面的中断处理函数都是由操作系统提供的。
(1)查看IDT
1. 先用Windbg和VirtualKD把双机调试环境配置好,然后把符号加载完成。
2. 输入命令“!idt/a” 。
这里不同索引区间有不同作用:
- 0x00到0x14这个区间,用来存放各种异常对应的陷阱处理器。
- 0x14到0x20这个区间,是保留着的,暂时没其他用途。
- 0x20到0x29这个区间,目前是空着的,系统和程序员可以自行分配和注册使用。
- 0x2A到0x2E这个区间,是Windows系统自己使用的5个中断号。
- 0x30到0xFF这个区间,是给硬件中断用的。
(2)使用PC-Hunter查看IDT
要查看IDT表及对应陷阱处理器的内容,可以打开PC - Hunter软件,找到“内核钩子”选项并点击,然后在展开的子项中进行相关操作,即可查看IDT表中的内容以及与之对应的陷阱处理器中的内容。
1.3 异常的概念
异常其实就是CPU内部主动搞出来的中断。那CPU到底在啥情况下会产生中断呢?为啥又要产生中断呢?要弄清楚这些问题,就得了解一些底层的运作机制。
(1)CPU为啥要主动产生异常
咱们用高级语言写好的程序,会被编译成可执行程序。这个可执行程序里存的就是机器码,也就是机器指令(Machine Instruction)。很多人以为到机器码这就算到底层了,其实不是。当机器码被读取到CPU内部后,指令还会被进一步细分成一个个独立的小段,这些小段就叫微指令。
微指令是这么回事:把一条指令拆成好多条微指令,按顺序执行这些微指令,就能实现这条指令原本的功能。好多条微指令组合起来就成了一个微程序,而一个微程序对应着一条机器指令。说白了,一条机器指令的功能得靠若干条微指令组成的序列来实现,一条机器指令要完成的操作也是被拆成若干条微指令去完成,并且由微指令来解释和执行。
要是一条指令里的某条微指令出问题了,那这条指令就执行不下去了,后面的指令也就没法接着执行,CPU就卡在那了。比如说,我们申请了一块内存,准备往里面写数据。要是申请内存这一步失败了,那就不该再往内存里写数据了,而应该跳到其他代码接着执行。在CPU里,就是靠内部中断把代码的执行流程转移到别的地方。
(2)CPU在哪些情况下会产生异常
最常见的情况就是除0异常,因为除数是0的时候,CPU根本没法计算。还有内存缺页异常。在保护模式下,CPU开启了分页机制,程序用的都是线性地址,但CPU实际操作得用物理地址。所以,当执行像mov eax, [401000]这种带有线性地址的指令时,得先把线性地址转换成物理地址。Windows系统还有个换页机制,当物理内存不够用了,就允许把部分物理内存里的分页数据交换到磁盘文件里存着。要是CPU去访问一个线性地址,结果发现这个地址的数据不在物理内存里,那就会产生缺页异常。缺页异常特别常见,在Windows系统上,一秒钟可能就产生好几百次。产生缺页异常,主要是为了让操作系统有机会把之前交换到硬盘的物理内存数据再换回到物理内存里。
(3)CPU产生异常后是怎么处理的
不同类型的异常,处理方式也不一样。就拿缺页异常来说,异常一触发,IDT表里对应的函数就会被调用,然后操作系统把对应的物理内存分页数据从硬盘换回到物理内存。等换完了,操作系统会用iret指令回到触发异常的那条指令,这时候再执行这条指令,就不会再出现缺页异常了,程序就能正常接着跑了。
对于其他异常,Windows会调用一个函数来好好处理。这个处理过程包括给异常找到合适的异常处理函数,比如把异常交给调试器处理,或者交给程序自己的结构化异常处理机制(VEH、SEH)来处理。这个过程就叫做异常的分发,调用的这个函数就叫异常分发函数。在这个过程中,有一些细节挺值得琢磨的,比如异常处理函数是怎么知道到底是哪个地址发生了缺页异常的。
1.4 异常分类
按照CPU报告异常的方式,以及导致异常的指令能不能安全地重新执行,异常能分成三类,分别是错误、陷阱、终止。
(1)错误
错误类异常一般是能被纠正的。当出现错误类异常时,会先把当前线程的环境保存下来,然后从IDT里找到专门处理这个错误的陷阱处理器。等处理好了,再恢复线程环境,接着回到产生错误的地方继续执行。常见的错误类异常就是内存页错误。要注意,线程环境里保存的EIP是导致异常的那条指令的地址,不是下一条指令的地址。
(2)陷阱
和错误类异常不一样,陷阱类异常产生的时候,出错的指令已经执行完了。所以线程环境里保存的是产生异常的指令的下一条指令的地址。一般来说,陷阱类异常也能恢复执行。常见的陷阱类异常有int 3异常,调试器实现软件断点就靠它。
(3)中止
要是产生了中止类异常,那就说明出了非常严重的错误,程序通常没办法恢复执行了。比如说程序一开始就有问题,但运行了一段时间才表现出来,或者正在处理一个异常的时候,又出现了新的异常。
(4)异常分类表
二、windows异常处理方式
2.1 概述
异常机制的作用就是让计算机能把程序运行时出现的错误处理得更好。从编程的角度来讲,它能把错误处理和程序原本的逻辑分开,这样开发者就能专心去开发程序的关键功能,还能统一管理程序可能出现的各种异常情况。Windows系统提供了好几种异常处理机制,主要有下面这几种:
- SEH - 结构化异常处理
- VEH - 向量化异常处理
- VCH - 向量化异常处理
2.2 结构化异常处理
Structed Exception Handler(结构化异常处理),大家一般简称它为SEH,这是微软给出的一种处理异常的办法。在VC++编程环境里,程序员可以借助try、finally、except、leave这四个微软提供的关键字,有效地运用这个机制。下面简单讲讲它的用法。
(1)try与finally
try和finally是组合起来用的,代码大概像下面这样:
try {//这里面是被保护的代码块
}
finally {//这里是终结处理块
}
try里面的代码就是被保护的部分,finally里面的是终结处理器。不管try里的代码是怎么离开这个被保护区域的,最后都会去执行终结处理器里的内容。离开被保护代码块的情况有两种:
1. 正常情况:代码顺顺利利地执行完了,或者执行了_leave语句。
2. 非正常情况:代码运行过程中产生了异常,又或者因为遇到 return、goto、break、continue 这些控制程序流程的语句,从而离开了保护代码块。在终结处理块里,可以用int cdecl AbnormalTermination(void);
这个函数,来判断代码是正常还是非正常离开的。根据判断结果,开发者就能决定后续操作,比如继续运行程序、重启程序、释放资源,或者尝试解决错误。需要注意的是,在被保护的代码区域里,最好用_leave 语句,而不是 return 语句。
(2)try与except
代码结构如下:
try {//被保护的代码块在这儿
}
except(/*过滤表达式*/) {//这里是异常处理块
}
这里面重要的是过滤表达式的取值,有3种可能:
- EXCEPTION_EXECUTE_HANDLER(1):表示执行紧跟在_except后面的异常处理块(可以理解成“我能处理,交给我”)。
- EXCEPTION_CONTINUE_SEARCH(0):意思是去找下一个能处理异常的地方(也就是“我处理不了,找别人帮忙”)。
- EXCEPTION_CONTINUE_EXECUTION(-1):就是接着执行产生异常的那条指令(类似于“不相信会出错,再执行一次看看”)。只要被保护的代码块产生了异常,程序就会执行except部分。过滤表达式的形式多种多样,它可以是一个数值、一个函数调用,或者是一个运算符组成的表达式,只要这个表达式算出来的值是上面说的这三种情况之一就行。
在异常处理过程中,有两个常用的函数。第一个是`unsigned long GetExceptionCode(void)`,这个函数返回的值代表了异常的类型。下面是相关的示例代码:
int a = 0;
DWORD Filter(DWORD Code, _EXCEPTION_POINTERS* pexception_Point) {if (EXCEPTION_ACCESS_VIOLATION == Code) {//大家可以F12看一下都有哪些异常//return EXCEPTION_EXECUTE_HANDLER; //请执行异常处理块吧pexception_Point->ContextRecord->Eax = (DWORD)&a;return EXCEPTION_CONTINUE_EXECUTION; //修复了异常,从产生异常处执行。}else if (EXCEPTION_BREAKPOINT == Code) {return EXCEPTION_EXECUTE_HANDLER; //请执行异常处理块吧}else if (EXCEPTION_STACK_OVERFLOW == Code) {return EXCEPTION_CONTINUE_SEARCH; //修复不了,让别人修复吧}
}
int _tmain(int argc, _TCHAR* argv[]) {try {_asm mov eax, 0;_asm mov [eax], 100;printf("安全渡过异常");}except(Filter(GetExceptionCode(), GetExceptionInformation())) {printf("进入了异常处理块");}system("pause");return 0;
事例代码二:结构化异常处理的嵌套
#include <windows.h>
static unsigned int nStep = 1;
void Fun_B() {int x, y = 0;__try {x = 5 / y; //引发异常}finally {printf("Step %d:执行Fun_B的finally块的内容\n", nStep++);}
}
void Fun_A() {__try {Fun_B();}finally {printf("Step %d:执行Fun_A的finally块的内容\n", nStep++);}
}
long MyExcepteFilter() {printf("Step %d:执行main的异常过滤器\n", nStep++);return EXCEPTION_EXECUTE_HANDLER;
}
int main() {__try {Fun_A();}__except(MyExcepteFilter()) {printf("Step %d:执行main的except块的内容\n", nStep++);}system("pause");return 0;
}
2.3 向量化异常处理之VEH
结构化异常处理作用范围相对有限,而向量化异常处理就厉害多了。它是全局性的,只要完成注册,对整个进程都起作用,而且使用起来特别方便。要使用向量化异常处理,只需要提供一个回调函数,然后通过一个API完成注册操作。从那之后,不管进程里出现什么样的异常,都会去调用这个回调函数。常见的向量化异常处理形式有VEH和VCH这两种。
说起VEH,我们首先得知道一个相关的API :
PVOID WINAPI AddVectoredExceptionHandler(_In_ULONG First, //调那顺序_In_PVECTORED_EXCEPTION_HANDLER Handler //回调函数
);
参数说明:
- 参数1:异常处理函数被调用的顺序。
- 参数2:异常处理回调函数。
回调函数定义如下:
typedef LONG(NTAPI *PVECTORED_EXCEPTION_HANDLER)(struct _EXCEPTION_POINTERS *ExceptionInfo
);
回调函数的返回值只有两种情况:
- EXCEPTION_CONTINUE_EXECUTION(-1) :继续执行。
- EXCEPTION_CONTINUE_SEARCH(0) :继续搜索。
2.4 向量化异常处理之VCH
VCH和VEH的运作方式差不多,都得借助一个API以及一个回调函数来实现相应功能。只不过在具体使用时,VCH所用到的API和回调函数,其名字与VEH所对应的有所不同 。
PVOID WINAPI AddVectoredContinueHandler(VLH_In_ULONG First, //调风顺序_In_PVECTORED_EXCEPTION_HANDLER Handler);
typedef LONG(NTAPI *PVECTORED_EXCEPTION_struct _EXCEPTION_POINTERS *ExceptionInfo);
我们能够通过编写程序代码,来探究VEH(向量化异常处理)、VCH(另一种向量化异常处理方式)以及SEH(结构化异常处理)这三者之间是如何相互作用和影响的 。在代码中设置不同的异常场景,观察这几种异常处理机制在面对异常时,以怎样的顺序被调用、各自如何处理异常,以及它们之间的处理结果是怎样相互影响的。
VEH及SEH:
#include "stdafx.h"
#include <windows.h>LONG WINAPI veh(EXCEPTION_POINTERS* pExce)
{printf("veh\n");// 继续执行, 说明异常已被处理,产生异常的指令将会// 被继续执行EXCEPTION_CONTINUE_EXECUTION;// 让下一个veh节点处理异常.return EXCEPTION_CONTINUE_SEARCH;
}LONG WINAPI seh(EXCEPTION_POINTERS* pExce){printf("seh\n");// 让下一个veh节点处理异常.return EXCEPTION_CONTINUE_SEARCH;
}int _tmain(int argc, _TCHAR* argv[])
{//1. 将异常处理函数注册到系统AddVectoredExceptionHandler(TRUE, veh);__try{*(int*)0 = 0;}__except (seh(GetExceptionInformation())){}return 0;
}
VCH及SEH:
#include "stdafx.h"
#include <windows.h>// VCH回调函数
LONG WINAPI vch(EXCEPTION_POINTERS* pExce) {printf("vch\n");// 让下一个异常处理节点处理异常return EXCEPTION_CONTINUE_SEARCH;
}// SEH异常处理函数
LONG WINAPI seh(EXCEPTION_POINTERS* pExce) {printf("seh\n");// 让下一个异常处理节点处理异常return EXCEPTION_CONTINUE_SEARCH;
}int _tmain(int argc, _TCHAR* argv[]) {// 注册VCH回调函数AddVectoredContinueHandler(TRUE, vch);__try {// 触发一个除零异常int a = 1 / 0;}__except (seh(GetExceptionInformation())) {// SEH异常处理块}return 0;
}
2.5 默认的异常处理函数
除了前面提到的那些异常处理办法,还有一种方式。从本质上讲,它属于SEH的范畴。这种方式能够添加一个默认的SEH异常处理程序。和其他一些异常处理机制类似,它也是由一个API函数以及一个回调函数构成 。
LPTOP_LEVEL_EXCEPTION_FILTER SetUnhandledExceptionFilter(_In_opt_LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter );
回调函数原型:
typedef LONG(WINAPI *PTOP_LEVEL_EXCEPTION_FILTER)(_In_struct _EXCEPTION_POINTERS *ExceptionInfo );
要留意啦,这个回调函数不是随便就会运行的。只有当SEH已经在起作用了,而且所有SEH相关的处理方法都没办法搞定异常的情况下,这个回调函数才会开始运行。
2.6 如何手动安装 SEH 节点
手动安装 SEH 节点的作用:
自定义异常处理逻辑:在 Windows 系统里,SEH 机制能够让程序在碰到异常时,调用特定的异常处理函数。通过手动安装 SEH 节点,你能够自定义异常处理逻辑,按照自身需求对异常进行处理,而不局限于系统默认的异常处理方式。
精准控制异常处理流程:手动安装 SEH 节点可以让你精确控制异常处理函数的调用顺序与执行流程。你能够在代码里灵活地添加、移除或者调整 SEH 节点,从而实现对异常处理流程的精细控制。
增强程序的健壮性:在程序里手动安装 SEH 节点,可以捕获并处理各种异常情况,防止程序因未处理的异常而崩溃,进而增强程序的健壮性和稳定性。
代码示例:
#include "stdafx.h"
#include <windows.h>EXCEPTION_DISPOSITION NTAPI seh(struct _EXCEPTION_RECORD *ExceptionRecord,PVOID EstablisherFrame,struct _CONTEXT *ContextRecord,PVOID DispatcherContext)
{printf("seh\n");// 继续执行return ExceptionContinueExecution;
}int _tmain(int argc, _TCHAR* argv[])
{
// EXCEPTION_REGISTRATION_RECORD node;/** 产生异常后 , 操作系统使用fs段寄存器找到TEB, * 通过TEB.ExceptionList 找到SEH链表的头节点, * 通过节点中记录的异常处理函数的地址调用该函数.*/
// node.Handler = seh;
// node.Next = NULL;_asm{push seh; // 将SEH异常处理函数的地址入栈push fs:[0];//将SEH头节点的地址入栈;// esp + 0 -- > [fs:0]; node.Next;;// esp + 4 -- > [seh]; node.handler;mov fs:[0], esp;// fs:[0] = &node;}*(int*)0 = 0;// 平衡栈空间// 还原FS:[0]原始的头节点_asm{pop fs : [0]; // 将栈顶的数据(原异常头节点的地址)恢复到FS:[0],然后再平衡4个字节的栈add esp, 4; // 平衡剩下的4字节的栈.}return 0;
}
该程序的主要目的是展示如何手动安装和移除 SEH 节点,以及自定义异常处理逻辑。通过手动安装 SEH 节点,程序能够捕获并处理异常,避免因未处理的异常而崩溃。
2.7 异常处理的优先级
下面演示 Windows 系统中不同异常处理机制(VEH、SEH、VCH、UEH)的优先级和调用顺序。
#include "stdafx.h"
#include <windows.h>LONG WINAPI vch(EXCEPTION_POINTERS* pExcept){printf("vch\n");return EXCEPTION_CONTINUE_SEARCH;
}LONG WINAPI veh(EXCEPTION_POINTERS* pExcept){printf("veh\n");return EXCEPTION_CONTINUE_SEARCH;
}LONG WINAPI seh(EXCEPTION_POINTERS* pExcept){printf("seh\n");return EXCEPTION_CONTINUE_SEARCH;
}LONG WINAPI ueh(EXCEPTION_POINTERS* pExcept){printf("ueh\n");return EXCEPTION_CONTINUE_SEARCH;
}int _tmain(int argc, _TCHAR* argv[])
{AddVectoredContinueHandler(TRUE, vch);//vchAddVectoredExceptionHandler(TRUE, veh);//veh// 在64位系统下, 当程序被调试时,UEH不会被调用// 不被调试才会被调用.// 在32位系统下,被调试时也会被调用.SetUnhandledExceptionFilter(ueh);__try{*(int*)0 = 0;}__except (seh(GetExceptionInformation())){}return 0;
}
通过触发一个访问违规异常,来展示不同异常处理机制的调用顺序。正常情况下,当异常发生时,系统会按照 VEH → SEH → (可能的 VCH)→ UEH
的顺序依次调用异常处理函数。在这个程序中,每个异常处理函数都返回 EXCEPTION_CONTINUE_SEARCH
,表示无法处理该异常,从而让系统继续寻找下一个异常处理函数。通过输出的信息,你可以观察到不同异常处理函数的调用顺序,进而了解它们之间的优先级关系。
通过上面的试验代码,总结出下面这些异常处理的规律:
(1)要是异常要交给用户来处理,会按照VEH、SEH、VCH这个顺序调用异常处理方式。
(2)如果VEH说它把异常处理好了,就不会把异常再传给SEH,但还是会传给VCH。
(3)如果VEH没处理好异常,就会把异常传给SEH。
(4)如果SEH里所有的异常处理函数都没办法处理异常,就会调用默认的SEH处理函数。
(5)如果SEH处理好了异常,从except那里开始接着执行,就不会再把异常传给VCH。
(6)如果SEH要回到异常产生的地方接着执行,在回去之前会调用VCH。
注意事项:
(1)执行顺序不可变:Windows严格保持VEH→SEH→VCH→UEH的调用顺序
(2)调试器优先原则:当存在调试器时,所有异常会首先发送给调试器
(3)返回值影响:
EXCEPTION_CONTINUE_EXECUTION:尝试恢复执行
EXCEPTION_CONTINUE_SEARCH:传递给下一处理程序
EXCEPTION_EXECUTE_HANDLER:执行异常处理代码
(4)64位系统差异:在x64架构下,SEH的实现机制与x86有所不同,但优先级顺序保持不变