胡言乱语计算机二
2011-02-27 19:36 curer 阅读(14044) 评论(9) 编辑 收藏 举报中断和异常处理是OS中非常重要的组成部分,当然windows也不例外,他们是我们理解OS一些概念、机制的基础。
中断和异常简单的来说,就是在程序正常执行流程中,突发一个事件,导致程序的执行流程转向另外的执行流程中。而我们绝大多数编写的程序都是顺序执行的,我们的确体会不到这样的机制能够给我们带来多少好处。但是这个在OS的设计中,确实深入到各个方面,以至于没有中断异常处理,现代OS根本无法构建。为了简单理解,我们可以看看这个例子。
比如我们准备带我们的宠物狗狗出去散步,但是由于狗狗非常淘气,经常单独行动(这里,我们是无法预知狗狗会在什么时候跑掉的),在没有任何其他道具的帮助下,我们只能每隔一段时间去看看狗狗是否跟着我们。那么用code来模仿这个行为,那么就是这个样子。
while(living) //人的行为简单说其实就是不断的重复重复...
{
//do something
...
if(!VerifyDog())//看看狗狗还跟的没有
{
//狗丢了....
}
...
}
如果我们这个while循环中,没有其他事情,仅仅是和狗狗在一起,那么狗狗不会丢,但是如果我们突然遇到一个美女,驻足观赏了一会之后…狗狗不见了。
是的,我们通过轮询这种方式,不可能保证狗狗一定会在我们身边。那么如何才能保证狗狗一定在我们身边呢?无论我们走到哪里都会紧紧跟着呢?生活常识告诉我们,只需要给狗狗戴上链子,这样狗狗就会“听话”的跟在我们左右。那么我们就可以通过这样的code来展示我们的行为。
while(living)
{
leash(FuncProc);
while(bOutSide)
{
//doSomeThing
}
UnLeash();
}
FuncProc()
{
狗狗想跑,抓紧链子。:)
}
好了,在我们带狗狗出去玩之前,我们给狗狗栓了链子,那么我们在外出的时候,就可以随行所欲的“看美女了”。一旦狗狗有不轨行为,我们只是需要下意识的抓紧便能保证狗狗不会跑丢。而这,要比隔一段时间查看狗狗在不在要简单多了,我们可以完全从这个事情上解脱出来。做更重要的事情。而这一切,其实就是默默跑了一个异常处理的过程。
- 我们通过leash(FuncProc); 注册了一个callback函数
- 我们可以做我们想做的事情,而不在我们的while循环中体现任何有关狗狗的信息。
- 当狗狗有不轨行为时,链子第一时间通知我们狗狗的行为,然后我们能够在第一时间制止狗狗。
- 然后我们继续while中的事情。
- 当然,回家了要把狗狗放出来(大部分的狗狗都不喜欢狗链子….)。
从某种角度上来看,OS相当于人,而我们写的一些应用层的程序则相当于宠物狗狗。而异常处理,就是这条链子。对操作系统来说,他希望找到一条能够应对所有狗狗的超级链子。而在这么多狗狗中,总有那些不听话的,希望能够摆脱这条链子的狗狗。而这条链子也在这2端的发展下,变得越来越强壮。
这篇文章的角度就是站在OS的角度,希望找到这条能够应对各种狗狗(主要是不听话的狗狗)的链子。也就是初步学习windows 的SEH的整体设计和思路(这个帽子的确很大)。下一篇则站在狗狗的角度来学习我们如何能够摆脱这条链子(这个帽子也挺大的),也就是SEH的相关安全机制。当然这篇是理解后面的基础。
当然,OS本身的实现和人狗链子的关系也是微妙的。现代操作系统的整个设计是分层的。有些时候是人是狗已经不再重要了。就像真实世界的我们,我们无法从那些枷锁中挣脱,真的。我们有时候真不知道我们是人?是狗?
闲话扯得太多了。为了更清楚的了解异常,我们需要进一步了解我们的计算机。类似的这种程序控制流的变化,还有中断(interrupt),陷阱(trap),错误(fault),终止(abort)而中断,我们通常又分为软中断,和硬中断(这个又通常省略硬字)。是的,我相信对于绝大多数的和我一个水平的菜鸟来说,这些概念都是可以令人抓狂的。而造成这样的原因主要是在于计算机的发展是在是太快速了,所以,有些概念在这个过程中得到了扩展,而这些概念又和计算机体系结构密切相关,所以我们经常看到甚至是一些权威书籍之间有概念的冲突,是的。这个当然不是人家的错误,只是处在了不同的时空,而这也就是语言的悲剧。他永远不可能给我们准确的答案,除非数学。所以这里我们需要先搞清楚这些概念。把那些恼人的语言上的细枝末节过滤掉之后,整个东东也不复杂了。当然整个学习都在我们最“熟悉”的x86下,其他平台也不不难掌握了。
为了减少中英文的差异,在一些概念描述上,这里就不再一次引入另一门语言,虽然他是伟大的语言。为了能够较为清晰的了解他们之间的区别。我们不得不扯一些硬件相关的知识,事实上,也许这些“旁敲侧击”让你回想到了学校的那些认为不重要的课程《机组》《模电》《计算机体系结构》《微机原理接口》等。
在8086下,原谅我再次重复,有2种这样的机制。interrupt、exception。interrupt分为可屏蔽中断和不可屏蔽中断。exception分为 fault、trap、abort。概念有点多,慢慢来。
经过上一篇的胡扯,我们知道了CPU眼中,把这些硬件当成逻辑存储器,最后和存储器构成一个地址空间。但是有些笼统,这里稍微再了解一点。和CPU通过总线相连的芯片除了各种存储器外,还有3种芯片
- 各种接口卡(显卡,网卡)上的接口芯片,它们控制接口卡
- 主板上的接口芯片,CPU通过它们对一些外设访问
- 其他芯片,存储相关的系统信息或对输入输出处理
而这些芯片都有一组可以由CPU读写的寄存器。这些寄存器有2点相同。
- 和CPU总线相连。
- CPU对它们进行读写是通过控制些向他们所在的芯片发出端口读写命令
所以,在CPU看来,这些外部的寄存器相当于端口,并对他们一起编址,从而又搞了一个端口地址空间,或是叫做IO端口空间。这里再扯的远一点,事实上,在x86下,我们现在知道有2个地址空间,一个是存储器的地址空间还有一个是IO地址空间,为什么不把IO地址空间也映射到存储器地址空间中呢?这样我们就可以再弄一些逻辑存储器了。是的这样设计的确简单,但是却浪费了一些CPU的地址空间,所以当时intel考虑到为了不浪费而又搞了一个IO地址空间,所以我们访问这些端口的时候,也就必须通过另外的指令来做。我们把这种IO编址称为独立编址。x86一共有64K个8bit的IO端口。事实上还有另一种思路和我们之前的想法一致,将这些IO的空间跑到了存储器地址空间,而这个又叫做统一编址。也就是说,这些IO寄存器与存储器单元一样看待。这样的设计在ARM中则比较常见。呵呵,有点乱,让我们看个例子。
那就用我们最熟悉的键盘来说。当我们在键盘上操作时,CPU是如何知道的呢?
键盘中有专门的芯片用于监视键盘上每一个键的状态,当我们按下一个键时,接通电路,这个芯片就会产生一个成为扫描码的东东,来表示键的位置,当键弹起的时候,同样产生一个扫描码。扫描码就保存在了键盘接口芯片的寄存器中(CPU来看这就是一个端口)。那么当键盘的输入到到端口时,我们这篇文章的主角终于来了。芯片向CPU发出中断信息。到这里,键盘的事情OK了。
那么芯片是如何给CPU发送中断呢?我们可以通过线将CPU和芯片连接起来,但是这样会遇到一个无法回避的问题,可以和CPU连接的线是有限的,但是CPU外部的这些设备确是非常多的,所以必须管理这些设备,让其中一些设备共享一条线。而这也就是中断控制器的作用之一,所以,真实的情况是类似如下的。
inter x86通过2片中断控制器8259A来响应外部中断源(就是指产生中断的设备,比如键盘)。和中断控制器相连的每条线被称为中断线。我们看出如果想发送中断给CPU,那么必须得获得这条线的控制权。那么我们申请中断线的这个动作,叫做IRQ(Interrupt ReQuirement)。当然“申请中断线”这也有一个更加专业的叫法申请IRQ或是申请中断号。
而这个8259A做的事情就是
- 监视中断线,检查产生的IRQ信号
- 如果中断线上产生了一个IRQ信号
- 把IRQ信号转换成对应的中断向量
- 这个向量存放在中断控制器的一个IO端口,CPU从而可以通过数据总线访问向量
- 这个信号则发送到CPU的INTR引脚,也即是触发一个中断
- 等待CPU确认中断之后,写入PIC(可编程中断控制器)的IO端口,并清INTR
但是事实上还没有完,我们知道CPU在一个时刻只能处理一个事情。我们有那么多的外围设备,当他们都要CPU时间时,这里就有一个先后问题了。也就是需要对这些中断分级别。而且在有些特殊的中断下,是不可以被其他中断打断的。呵呵,这里就引入了可屏蔽中断和不可屏蔽中断。那么我们就可以有选择的处理一些中断,而放弃另一些中断在一些极端情况下。而且有些中断处理过程中是不可以再处理中断的。可屏蔽中断IRQ信号通常是通过CPU的INTR引脚发给CPU的。而不可屏蔽中断信号通常是通过NMI引脚发给CPU。呵呵,这就是可屏蔽中断和不可屏蔽中断区别之一。
那么我们再来搞定异常。
为了能够稍微再了解一点,我们需要知道一些CPU内部是如何执行一条指令的。原谅我这里就不赘述了。只是搞一个例子。
当CPU执行汇编指令IDIV。首先会检查源操作数(除数)是否为0,如果为0,则CPU在执行这条指令中,就会产生一个除零异常(在CPU眼里,汇编也成高级语言了)。通过中断的例子,我们看出,中断是CPU外部告诉CPU的执行流程需要转变。那么异常和中断最大的不同就是异常是CPU自己本身执行时发现的。中断的来源通常是外部设备,异常的来源则有3种。
- 程序错误,当CPU在执行程序指令时遇到操作数错误或是指令非法。前者的例子就是除零,后者的例子可以是在用户模式下执行特权指令
- 某些特殊指令。这些指令的本身就是产生异常。
- intel在P6引入了机器检查异常(Machine Check Exception)。当CPU执行指令期间检测到CPU内部或是外部硬件错误。
可见,对CPU来说,中断是异步的,因为CPU完全是被动的指望外部硬件给他一个信号。而异常,则是CPU自己发起,从CPU来看则是同步的。
我们之前已经了解了CPU是如何获知异常,中断。那么CPU接下来的动作又是什么呢?首先需要区分出这些不同的异常,中断。具体则是需要区分出产生中断的来源,8086使用被称为中断类型码的数据来表示中断源。中断类型码为一个字节数据。8086最多可以表示256种中断源。那么接受到各种中断或是异常时,接下来需要做不同处理,那么分别处理中断或是异常的程序入口,叫做中断向量(这里可没有异常向量一回事,这里被统一看待)。而这些中断向量组成一张线性表被称为中断向量表。由于是线性表,我们可以很容易的构造一个类型码到中断向量的映射。
这里还需要强调一点。之前我们提到的异常的分类,那么这些异常的区别是什么呢?既然是控制流的转变,那么就有如何恢复和如何报告控制流转变这2种情况需要我们考虑,而事实上,fault abort trap 就是按照这样划分的。
- 错误类(fault)异常是在产生这个异常指令的指令之前发起。fault通常在执行指令或是执行指令之中被CPU检测到。如果检测到,那么异常将机器的状态恢复到这条指令开始的地方,这样。指令就可以继续执行。可见错误类异常可以无缝继续执行,但是如果没有解决,则会陷入死循环。
- 陷阱(trap)异常是在产生这个异常指令完成之后,立即产生。那么异常之后的恢复就是引起这条指令的下一个指令(这个是逻辑上的,可不是空间上的)。 可见trap也是可以无缝继续执行。
- 终止(abort)异常用来报告严重的错误,而这种错误已经严重到不可恢复的地步,也可以说,我们已经不知道该如何恢复了。整个都乱了。比如一些硬件错误。
当然到了80386之后,由于保护模式的引入,这部分又引入了各种门,中断们,陷阱门等等,恢复的过程也分了内核态和用户态。而这些个过程,绝大多数书籍都有相关详细的说明,这里不做赘述。有兴趣的同学可以自己翻阅。这里引入这些概念是为了对硬件处理中断有一个笼统的概念,这样会对我们理解OS是如何模拟这个异常过程提供一些帮助。
这里还需要再强调一点,我们可以通过一些指令来屏蔽 可屏蔽中断,事实上,大多数的外接设备都是可屏蔽中断。但是我们的异常确是不可以被屏蔽的。有了之前的基础,我们可以从硬件本身的搭建思考这个原因,也可以从整体设计去思考这样设计的原因。这里算是留个问题吧。我已经觉得我是在是太罗嗦了。
当然这些划分也不是非常精确,有一些错误类异常也是不能恢复执行的。哎,没办法,谁让这计算机是人造的呢。事实上,计算机的世界中充斥着“差不多”,“懒惰”这类思想,如果非要钻严谨的牛角尖只会增加自己的痛苦。因为我们不是在搞数学,有些东西可能背负着我们现在的视野所不能企及的历史问题。所以还是那句话,存在即是道理。我们没有资格对他们评头论足,在我们彻底搞出一个自认为更加合理的东西之前。当然这也是人造科学最让一些喜欢追求完美的家伙们郁闷的地方。呵呵,如果真的有可能,真的应该去拥抱数学。当然人造科学也有好处,是的。他和我们大多数的思维一致,甚至是我们现实经验的抽象,看看计算机的那些最基本的核心概念,stack queue。
差点忘了,还有一个软中断的概念,这里通常指的是INT n这样的指令。如果站在CPU的角度,这个明显是异常,因为这个控制流的转变是CPU内部检测到的。
让我们从茫茫的硬件跳出来。别忘了我们是要了解OS的异常处理。我们可对CPU的世界没有兴趣。是的。正是因为这些硬件太过于底层,而且不同的硬件结构都有不同的地方。那么操作系统如何来保证自己可以在多个这样的硬件平台上执行呢?一个最常见,最通俗的就是OS自己再抽象一个异常,中断。自己定义一个。是的,再没有比自己推倒旧体制,重建新秩序让人更兴奋的了。这样上层的应用就不需要考虑这些细枝末节了。事实上,OS是将这些异常,中断打包在一起管理的。因为他们本质上都是程序执行流程的转换。我们只能看到这是一种“跳转,恢复”而已。所以,现在我们可以忽略掉上面所讲的所有东西。(这里可能会有一种误解,所以我这里叫胡言乱语,要想完全真实的了解这些过程,intel手册,当然如果想要吃下这东西,《机组》《体系结构》….)。
庄子也讲,计人之所知,不若其所不知;其生之时,不若未生之时;以其至小求穷其至大之域,是故迷乱而不能自得也。这些东西还是因人而异。我这里可没有给大家传递必须要学那些硬件知识,或是鼓励大家怎么怎么做。事实上我也没有这个能力,退一步,即使我有这个能力,也没有这个资格。用自己的特例来推广到大家这本身就是一个本末倒置的问题。其实,哎,再加一句废话。存在即是理由,技术本身没有错,错的只是人的角度而已。而这也就是人造科学的悲剧,它不可能让所有人都满意,相反那些只有神才能理解的东西--数学,不以人类意志为转移。
让我们扯开那些鸡毛蒜皮的东西,静下心来。站在OS的角度来观察异常。那就拿我们最“熟悉”的windows。好吧。windows自己搞了一个异常处理机制。而这里面最熟悉的就是SEH了。当然这不是windows中唯一的异常处理机制。等我们了解SEH之后,再了解他。因为他并不是NT设计之初就存在的。这里可能又要绕绕了。MS真正的操作系统是NT。而95 98 只是dos的一层皮,并不真正算我们想象中的操作系统。
当然NT也搞了自己的一套中断的概念,只是这里。我觉得我们应该暂时放下。在OS,IO处理那部分在来讲述。我觉得这次的硬件有些多了。涉及的太多,反而不能讲述清楚了。
既然我们要弄一个抽象的中间值,那么我们显然只能从异常被CPU识别,然后经过各种机制之后,扔给我们来看的就是NT给我们定义的异常,也是给他自己定义的异常。
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
DWORD ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;
EXCEPTION_RECORD 定义异常,更多的可以参考msdn,http://msdn.microsoft.com/en-us/library/aa363082(VS.85).aspx这里只是简单提几点。
ExceptionCode 是NT分配给异常的编码,用来表示是一个什么样的异常。比如我们最熟悉的 0xC0000005 STATUS_ACCESS_VIOLATION。
ExceptionAddress 则表示异常产生的地方。(这里其实是不准确的,如果我们从硬件的角度去看,因为有些地方windows替我们做了一些额外的事情。后面会提到一个例子。)
既然要模拟实现这种程序控制流的转换,那么我们需要提供系统一个函数执行的入口,说白了就是一个函数指针。那么就是下面的这个样子。
EXCEPTION_DISPOSITION
__cdecl _except_handler(
struct _EXCEPTION_RECORD *ExceptionRecord,
void * EstablisherFrame,
struct _CONTEXT *ContextRecord,
void * DispatcherContext
);
让我们先略过这些参数。思考一些问题。我们应该如何注册我们的函数入口,何时解除我们的回调函数。如果站在NT的角度,这些函数入口又该记录在哪里?
最直观的想法就是模仿硬件那种线性设计,搞一张表,来存储这些函数入口。但是我们这种直接想到的,往往都不是真实的设计。我这里只能猜想,由于这些设计都是20年前的东西,过多的访问外部的表,可能会冲击缓存性能。
那么我们怎么做才能在少影响缓存性能的情况下,还能触发程序执行流程转换?而且我们并不知道哪里会出现程序流程的转换。而且由于我们需要模拟这个流程,也就是要支持底层中的中断来让我们普通程序捕获的同时,我们还想要扩展,自定义一些异常来抛出,使得程序流程转换。这个的确是一个复杂的问题。事实上,在我们的程序中已经给我们提供了一个实现这个机制的空间。他就是计算机中里程碑的东东,堆栈。
堆栈是什么时候出现的,这个已经不清楚了。但是可以确定的是现在已经几乎没有能够脱离堆栈而运行的程序。堆栈给我们提供了非常出色的环境。
- 记录子程序调用轨迹,使得嵌套子程序调用成为可能
- 通过堆栈传递子程序调用参数,从而简化程序设计。因为寄存器的数目很可能是不够用的。
- 基于堆栈的程序局部变量的概念得以出现,使得模块化程序设计和结构化程序设计成为可能,也最后导致了进程,线程。
- 程序执行的轨迹和局部变量结合一起成为“脉络”即上下文。这给我们恢复程序执行,跳转程序执行提供了保障,甚至后面的调度。
我猜想,正是因为SEH的设计和堆栈密不可分,所以才叫“结构化异常处理”吧。
回想一开始的那个带狗狗出去玩的例子,栓链子和解开链子,构成了一段受保护的空间。事实上,编译器在给我们构造可以支持SEH的环境时,也是这样的。进入__try block之前,加保护,在当前程序的堆栈空间中构造一些结构,也就是我们的回调函数入口等其他一些必要的结构。而当我们离开__try block 之后,则就像释放普通的临时变量一样释放掉这些结构体。而且堆栈还有其他的好处,由于我们可以根据堆栈去构造类似printf的不定参数的功能,所以,编译器在支持NT的SEH机制时,可以加入其他一些结构,这样比较方便的扩展和优化这些基本的机制,从而提高效率。减少支持异常的开销。而且事实上,对于异常的开销,如果没有触发异常,现代的编译器已经几乎做到零开销,只是当真正的触发异常时,效率会大幅下降。当然。异常提供了非常强大的转换程序流程的能力,而这也是那些病毒,木马最喜欢的事情。更不说这些相关的SEH结构体就构造在程序的堆栈中,这种没有任何保护的地方。所以需要非常多的安全机制协同保护。而这些问题,大概直到vista后win7才基本上做到了完美,或是那些牛人还没有找到漏洞。
所以说,我们自定义的异常,一般都是程序执行流程中的极端情况,万万不能像理解硬件中断那种思想去理解,也就是异常是不能用来控制程序中大多数流程转变。只能用于极端情况,作为最后的手段。当然,这个可能并不是20年前的那些牛人都能想到的。相关的有关SEH编译器级实现可以参考
Matt Pietrek的文章
http://www.microsoft.com/msj/0197/exception/exception.aspx,
还有我自己写的2篇。
http://www.cnblogs.com/studentdeng/archive/2010/12/15/1907232.html
http://www.cnblogs.com/studentdeng/archive/2010/12/21/1912991.html
原谅我把我的文章和Matt Pietrek的放到一起。当然,这些东西都是最最基础的。
但是这篇可不是去学习具体的实现机制,要明白这个,还是需要一定的汇编基础和多一些的耐心,其实,怎么说呢,对于c的反汇编,主要还是靠耐心吧,没有太多的技术(纯个人感觉,我觉得c++的需要更多些技术)。而且如果再稍微了解一点SEH的安全机制,就会发现,我们似乎又回到了最开始的想法。构造一个表来保存这些回调函数的一些信息。呵呵,事物的发展似乎又回到了原点,但是我们的认识却不在一个层面上了。螺旋发展,也增加了我们学习这些古老东东的难度。真的,有时候真不知道为什么自己会处在这个时代。20年前的操作系统还有人敢说能够比较全面地了解。对于现在的千万级,有的linux甚至是亿级代码量来说。呼呼。穷极一生也仅仅皮毛而已,不过对于计算机这种新科学来说,还能搞个皮毛。而物理,数学那就是还没了解就over了。。。。。。
对比之前的硬件结构,我们已经了解了我们自定义的“异常信号”,“异常向量”。那么我们又是如何根据“异常信号”找到这些“异常向量”呢?那么我们就必须要了解NT的异常触发的模型了。当异常跳出来时,NT的顺序是这样的。(准确说异常在用户态下,关于内核态的我们不管他)
- 调试器 first chance
- 堆栈上的那些回调函数
- 调试器 second chance
- 环境子系统
怎么说呢,我觉得这篇真的好长,环境子系统还是放在OS进程线程那部分了解吧。现在可以把它想象成NT进程的妈吧。反正只要是要创建一个进程都要告她一下。当异常最后也没找到属于他的回调函数,那么就给他妈管教了。当然,NT之所以这样设计异常,就是要构建一个非常强壮的设计。能够将问题层层分类解决。是的,当问题复杂到一定程度之后,虽然我们通常的理解是一步到位会快些,但是事实往往分层更容易开发,维护,管理和甚至是优化。如果不分层,那么想象,搞这么多信息,还需要能支持用户扩展,而且,这个异常处理充斥整个系统本身设计。MS如何能够协同那么多人开发,即使都是天才?
整个NT处理异常就是这样的,异常来了,OS先找debugger,debugger不管,那就在程序堆栈上找人,也不管,唉,debugger你还不管? 好吧,subsystem你搞定吧。对于这种谁都不管的异常,正式一点的叫法是未处理异常,这个也其实是比较复杂的,因为2K, XP,vista的策略都不同。策略本身不复杂但为什么不同? 有机会的话,需要再深入一点学习一点。
这里面对大多数同学,如果不了解汇编的话,可能无法理解这个调试器为什么要跑2次?要知道我们现在可是在和效率赛跑,而且为什么调试器首先要捕获异常?而不是我们本身的程序?
我们想一个问题。在我们使用debugger调试程序的时候,我们的程序凭什么能够加入断点,然后停下来?CPU执行可是老老实实的按照一条条的指令跑的,没有那些花花肠子。当我们单步调试的时候,为什么程序执行一条指令(我这里指调试汇编代码,调试c,c++这些还需要稍微麻烦一点)就要停下来呢?当我们有了中断的概念就不难理解这个问题了。是的。就是发生了中断。导致CPU暂停了当前被调试进程,而把控制流转向到了debugger。那么怎么暂停?为什么能够暂停?这个还是交给OS的调度和同步那里再学习吧。
事实上,这个断点(我说的这种)就是指令INT 3。他可以说是我们非常熟悉,但又陌生的。不知道大家在一开始用c编程的时候,至少是我自己,在辛苦了半天之后,一运行发现屏幕上跑出一大堆烫烫烫烫。事实上,他就是INT 3。
INT 3的机器码是1100 1100b(0xCC)。一个字节长。编译器在debug下会给我们创建额外的栈空间并填上INT 3,至于为什么这样,我这里就不啰嗦了。这种纯菜鸟错误,通常是没把指针玩明白。有点远,呵呵,为什么扯这么远,是因为在有了之前的硬件知识,这里很可能产生疑问。看这个指令的样子,我们发现他是个软中断,或称为trap。那么根据之前所讲的,这里的异常地址应该是这条指令的下一条。但是我们这里看到的却是我们打断点的这条指令。那么要搞清楚这个,又要稍微绕绕下。当我们加入一个断点的时候,vc会自动记录下我们这个断点的位置信息。当调试的时候,vc会把这些断点位置处的指令换成我们的INT 3。也就是替换掉一个字节。当然,替换之前要保存原来的。这个过程叫做落实断点(resolve break point)。那么当CPU执行到INT 3指令时。是的。这时我们就明白了,为什么要先让调试器捕获异常了。这些东西要是先给了我们做,那么调试器就没法子实现功能了。然后就是一系列的硬件,OS的事情。vc把之前我们的代码再恢复过来。所以这也就是RtlRaiseException产生的软件异常并不会先扔给debugger first chance。因为我们自己搞得东西是不可能和debugger有任何关系的。
我们这里需要特别关注下NT干的事。
对于NT来说,INT 3 会导致CPU执行KiTrap03程序。(为什么执行这个,我们在IO那里再了解)。在WRK中,我们可以看到这部分的源代码。这里不得不提MS,不知道哪个脑子别了改锥的人想出了一个这么限定,代码不能超过50行。无语。
mov esi, ecx ; ExceptionInfo 2
mov edi, edx ; ExceptionInfo 3
mov edx, eax ; ExceptionInfo 1
mov ebx, [ebp]+TsEip
dec ebx ; (ebx)-> int3 instruction
mov ecx, 3
mov eax, STATUS_BREAKPOINT
call CommonDispatchException ; Never return
不管怎么说,人家总是做出贡献了。我们看到了dec ebx。是的。在处理异常的时候,nt这里修正了一下,而那个1,就是INT 3这条指令的长度。现在我们就明白为什么我们能正确看到我们的代码了。也验证了trap的流程。
绕了很远,把思路拉回来。
让我们再思考一个问题,程序流程突然转变了,甚至可能永远回不来了,而且我们现在的代码是看不出这个状态。也就是我们随时都可能被别人抢了。哎,可惜啊,咱们处在最底层。人为刀俎,我为鱼肉。但是NT还是比较有人性的。而且这也是很有必要的。在程序流程突然转变了。在保护的这段代码中,可能有一些关键的操作没有做。比如内存释放,一些同步的锁等。但是由于某种未知原因,我们只能放弃这部分操作。那么我们的这部分被动代码什么时候执行呢?而且,由于这些保护的代码很可能由于函数调用,在形式上或是非形式上,都可以形成一个嵌套的关系。这些释放的顺序,从那里开始释放,释放到哪里?这都是NT需要给我们规划好的。
不要被我复杂的言论迷惑,实际上整个过程很简单。因为我们程序的执行流程信息都在堆栈上。我们根据堆栈信息我们可以很容易找到起点和终点。那么从问题出发点,到结束点。我们便走了2次。第一次从出发点到结束点,这是为了找到处理程序。第二次则是叫做stack unwind,栈展开。 从出发点到结束点。当然这个过程中,依然有很多更复杂的问题。异常查找时很可能产生死循环。unwind的过程也可以被随时中断或是走到其他地方。而导致我们写的那些备用代码无法执行。事实上NT给了我们非常多的选择。vc只是披了一层皮。
SEH就像ReactOS上写的一样,“SEH is a game which is played between OS and Compiler (Keywords: __try, __except, __finally)。
写在最后
- 我发现我越来越罗嗦了。絮叨絮叨的像个大妈。
- 后面的部分依然有点穿越。在不谈具体实现的细节下去说清楚SEH的基本过程,对我还是太复杂了,应该是我还没有理解透彻,慢慢来吧。具体过程可以参考我推荐的3篇文章,当然。最好的方法是自己推导。
- 我真的不知道这篇文章是写给自己还是写给别人看的。是的。如果我是读者,当我看到这篇文章的时候,我真想向这个作者扔砖头。