C++幕后故事(十三)--吃掉异常

读者如果觉得我文章还不错的,,希望可以多多支持下我,文章可以转发,但是必须保留原出处和原作者署名。为了获取更好的阅读体验请关注微信公众号。更多内容请关注我的微信公众号:cpp手艺人

image

这一节我们主要学习:

1.异常是如何实现的?
2.异常是如何销毁局部变量的?它怎么知道哪些临时变量需要销毁?
3.异常效率真的很低?

1.前言 责任链设计模式

如果你能够理解设计模式中的责任链模式,那么你对异常原理模型也就吃透一大半了。什么,有这么神奇?或许你会问,这是个啥套路咋没有见过。那我形象、具体、简单的给你举个例子。在高中的化学课程中,你是不是经常做到这样的题目从一种液体中将各种化学元素分离出来。还记得是怎么做的?是不是将液体不断倒入各种试剂中反应,生成其他物质,然后分离出来的。

image

简单画了一张图帮助大家理解下。在整个流入的方向中,我们把自己感兴趣的元素给拦截下来,不感兴趣的就让其通过,走到下一个关卡。然后在重复同样的操作,一直到最后。

前面费了口舌简单解释了下责任链设计模式,或许你心里直打鼓,真的这么简单?但是这和异常处理有什么?

这里请允许我卖个关子,我们继续阅读下去。

2.异常所需要的数据结构

这次我们只站在上层应用的角度看异常是这么实现的,并不会太关注操作系统底层。我们只要用好C++封装好的就行。
在分析具体的实现原理之前,我们先思考两个问题。
1.捕捉异常和抛出异常,他们两个之间是怎么联系起来的。你怎么知道我抛出是个啥,又是在哪个catch段中处理的。你想想在谍战片中两个特务是怎么接头的,是不是一个说上句,另外一个人说下局。啪,暗号对上了。哦,原来是自己人自己人。这个暗号,其实就是什么呢,说的再通俗一点就像是古代的信物。
那么在异常里面这个信物是什么呢。
2.编译器为了实现这个异常,肯定是加了不少的数据结构,用来支撑背后所需要实现的功能。
好了,我要上数据结构了,不要慌,看看就好,大家不感兴趣的直接跳到2.3节。

2.1异常安装部分结构

funcInfo struc ; (sizeof=0x14)
magicNumber dd ;编译器生成标记固定数字0x19930520
maxState dd ;最大栈展开数的下标值
pUnwindMap dd ;指向栈展开函数表的指针,指向UnwindMapEntry表结构
dwTryCount dd ; try块数量
pTryBlockMap dd ;try块列表,指向TryBlockMapEntry表结构
nIPMapEntries dd
pIPtoStateMap dd
pESTypeList dd
EHFlags dd
FuncInfo ends
UnwindMapEntry struc ; ( sizeof=0x08)
toState dd ;栈展开数下标值
lpFunAction dd ;展开执行函数
UnwindMapEntry ends
TryBlockMapEntry struc ; (sizeof=0x14)
tryLow dd ; try块的最小状态索引,用于范围检查
tryHigh dd ;try块的最大状态索引,用于范围检查
catchHigh dd ;catch块的最高状态索引,用于范围检查
dwCatchCount dd ; catch块个数
pCatchHandlerArray dd ; catch块描述
TryBlockMapEntry ends
HandlerType struc ; (sizeof=0x10)
adjectives dd ;
pType dd ;类型指针,指向RTTI类型
dispCatchObj dd ;
addressOfHandler dd ; 对应的catch块地址
HandlerType ends

2.2异常抛出部分结构

ThrowInfo struc ; (sizeof= 0x10)
nFlag dd ;抛出异常类型标记
pDestructor dd ;异常对象的析构函数地址
pForwardCompat dd ;未知
pCatchTableTypeArray dd ; catch块类型表,指向CatchTableTypeArray表结构
ThrowInfo ends
CatchTableType struc ; (sizeof=0x1C)
nFlag dd ;异常对象类型标志
pTypeInfo dd ;指向异常类型结构,TypeDescriptor表结构
dwOffsetToThis dd ;基类偏移
dwOffsetToVBase dd ;虚基类偏移
dwOffsetToVbTable . PMD ;基类虚表偏移
sizeOrOffset dd ; catch块类型表,指向CatchTableTypeArray表结构
pCopyFunction dd ;复制构造函数的指针
CatchTableType ends
CatchTableTypeArray struc ; (sizeof=0x8)
dwCount dd ;CatchTableType数组包含的元素个数
pPCatchTableType dd ; catch块的类型信息,类型为CatchTableType**
CatchTableTypeArray ends
TypeDescriptor struc
spare dd 保留,可能用于RTTI名称记录
name dd 类型名称
TypeDescriptor Ends

这些就是支撑异常特性的数据结构,看完了是不是觉得很复杂,很深奥。没事,你只要简单的瞄一眼就OK了。但是接下来的这张图,我希望你能够仔细的看,认真的体会。这张图就是异常的核心部分所在。

2.3图示解释

image

这张图,已经把异常原理展示的七七八八了。从这张图中我们可以得出那些信息呢?

1.发生异常时,C++系统是怎么销毁对象,又怎么知道有哪些对象需要销毁?

FuncInfo中的pUnwindMap里面就是保存需要销毁对象的方法地址。

每当一行语句中有个对象(自己new的和基本数据类型除外)生成了,就会往里这个表里面写入一条记录。

2.try和catch信息存在哪里了?

FuncInfo中的pTryBlockMap就是存放了信息,一个try块中可能包含了多个catch信息,所以这里面就包含了一个pCatchHandlerArray指向数组指针,dwCatchCount就是表明了有几个catch块信息。

3.C++运行时系统怎么知道我抛出的异常时try内还是try外呢?

pTryBlockMap中有个tryLow和tryHigh,就是一个区间范围,在抛出异常的时候会有一个索引。如果在tryLow和tryHigh的范围内就是在try内,否则就是try外。

4.在异常抛出时,怎么知道我抛出的异常对象类型,以及是如何销毁这个对象的?

ThrowInfo中nFlag就是异常对象类型,是个指针,还是引用,还是简单类型。pDestrcutor就是存放异常对象的析构函数地址。

5.抛出的异常对象信息存在哪里?

从图中就可以看到了CatchTableType struct就是存放了抛出的异常对象的所有信息。这里面包含了对象的类型,对象的大小,父类和虚基类,以及copy构造函数等等信息。

眼尖的同学可能看到RTII了,对。这是非常重要的数据结构。安装异常部分和抛出异常就是**RTTI**这个桥梁连接起来的

3.实例验证

代码如下:

class Test {
public:
    Test(int number) : m_number(number) { cout << "start test..." << number << endl; }
    ~Test() {  cout << "end test..." << m_number << endl; }
    Test(const Test &test) : m_number(test.m_number)
    {
         cout << "copy ctor " << m_number << endl; 
    }
int m_number = 0;
};

int main(int argc, char *argv[])
{
    Test test(0);
    try {
        Test test1(1);
        throw Test(1);
        Test test2(2);
    } catch (int index) {
        cout << "raw name:" << typeid(index).raw_name() << endl;
        cout << "name:" << typeid(index).name() << endl;
    } catch (Test test) {
        cout << "raw name:" << typeid(test).raw_name() << endl;
        cout << "name:" << typeid(test).name() << endl;
        cout << "error" << endl;
    }
    return 0;
}

我们在看下最后打印的结果,我把这里注释了,然后再回头看。

//  Test test(0);构造函数
start test...0
//  try块中的Test test(1);构造函数
start test...1
//  try块中的throw test(1);构造函数
start test...2
//  catch块中的Test拷贝构造函数
copy ctor 2
//  try块中的Test test(1);析构函数
end test...1
raw name:.?AVTest@@
name:class Test
error
//  try块中的Test test(2);析构函数
end test...2
//  catch块中的Test析构函数
end test...2
//  Test test(0);析构函数
end test...0

我们把VS编译出来的exe,用IDA Pro打开静态分析下。接下来,我通过IDA带领大家梳理下整个流程。

3.1 异常安装部分:

1.注意看这里__ehhandler$main结构体安装fs:0位置,这个位置很重要。

image

2.点击__ehhandler$_main,这里看一个结构stru_41F66C,并跳转到j__CxxFrameHandler3

image

3.点击stru_41F66C,这里看一个结构stru_41F66C,这里我们看到FuncInfo所有的详细信息。

image

4.再看看HandlerType结构,注意这里有两个catch块,所以会有两个HandlerType.

1.第一个HanlderType

image

??_R0H@8 这里面保存的就是需要捕捉的类型信息

image

__catch$_main$0 catch对应的处理过程

image

2.第二个__HandlerType

image

__??_R0?AVTest@@@8需要捕捉的类型信息

image

___catch$_main$1 对应的_catch块处理过程

image

  1. __UnwindMapEntry_main$1 对应的_里面就是保存了临时对象的销毁,这里面有很多个,我就选择其中的一个来解释

    image

​ 点击___unwindfunclet$_main$6_main$1 对应的_进入,看这里就是Test的析构函数。

image

3.2 异常抛出部分

1.这里抛出异常部分,push两个参数进去,一个是类型信息,一个是Test对象

image

2.我们点击下offset__TI1?AVTest@@,看下里面的详细信息

image

请仔细看旁边的注释,解释了每个字段的含义。我们在进入看看。

j_??1Test@@QAE@XZ里面是Test析构函数

image

<font color=”red”>\CTA1?AVTest@@

image

在进入___CT??_R0?AVTest@@@8??0Test@@QAE@ABV0@@Z4结构体看看

image

再看??_R0?AVTest@@@8结构体,这个RTTI了保存Test的类型数据。

image

3.3 异常发生时流程

在上面实例中,为了加深你的理解,我把所有的数据结构对应的数据都找出来了。接下来我就说下整体的流程。

1.当发生异常时,C++运行时系统会调用j___CxxThrowException@8,这里面存放了两个参数,一个是当前对象,另一个是当前对象的类型信息。

2.C++运行时系统就会在指定的地址调用我们之前设置的函数(回调函数)。指定的地址就是一开始main里面设定好的,fs:0这个位置,在3.1 异常安装部分的第一节_ehhandler$main

3.此时拿到了对象,以及根据对象的类型,进行是否拷贝等其他操作。再者跟着对象的类型信息和catch块中的类型信息进行比对,如果对象信息一致表示需要捕捉这个异常。然后在进入对应的catch块中进行其他操作。如果最后类型都不匹配,会把这个异常一直向上传递,直到所有的类型都不匹配,那么就会终止程序。

好,把我整个数据整理了下。方便大家理解。

3.4 异常传递链

上面说了这么多,好像还是和责任链模式没有扯上关系。接下来我们就说说异常传递过程。

首先我们看下函数嵌套调用流程。

void D()
{
    throw Test(1);
}

void C()
{
    try {
        D();    
    } catch (int i) {
        // todo something...
    }   
}

void B()
{
    try {
        C();
    } catch (char ch) {
        // todo something...
    }
}

void A()
{
    try {
        B();
    } catch (std::string msg) {
        // todo something...
    }
}

int main()
{
    try {
        A();
    } catch (Test test) {
          cout << "catch test exception" << endl;
    } catch (...) {
        cout << "..." << endl;
    }
    return 0;
}

// 打印结果
// start test...1
// copy ctor 1
// catch test exception
// end test...1
// end test...1

我们画张图来理解下:

image

简单解释下:

1.图中画出整个过程的堆栈过程,main是在最底层,然后依次往上排。

2.异常是D中抛出的,抛出的类型为Test,但是在D中没有捕获,接着就往下传递到C。此时C中虽然有了catch块进行捕获,但是捕获的类型int,类型不匹配接着传递到B中。同样B中捕获的类型也是不匹配的,在传递到A()。A中也是类型不匹配,在将异常传递main()中。而此时main进行异常类型匹配上了,此时进入到相应的catch块中处理。如果最后类型都不匹配的话,则程序异常终止。注意在main的catch(…)表示捕获任何异常,从而避免了程序的异常退出。

此时,你再回头看看打印结果,是不是加深你的理解。

4.总结

对与异常的理解,我希望你不要拘泥于那些为了实现异常的数据结构。你只需要理解两点:1.责任链模式。2.异常安装和异常捕获的信物RTTI。理解了这两点,对异常的理解已经掌握全局了。

其实对与异常的处理和系统的结合的非常密切,这里为了防止坑挖的越来越大,我自己都填不了,所以我特意忽略了windows SHE的处理机制。忽略这部分的内容,站在C++的角度看异常处理流程将会非常的简洁,便于理解。

最后回答下前面的问题:

1.异常是如何实现的?

请看图13-1

2.异常是如何销毁局部变量的?它怎么知道哪些临时变量需要销毁?

所有的临时变量都是放在UnwindMapEntry数组中。每当有临时对象产生时,就会加入这个数组中。

3.异常效率真的很低?

从空间角度看:因为增加了很多数据结构,这样必然会造成程序的体积变大。

从执行效率角度看:在没有发生异常时,就没有那些异常的数据结构的判断,所以效率根本就没有降低。但是一旦发生了异常,就会进行很多其他的操作,造成效率一定程度的降低。

最后大家想对操作系统层面异常有深入的理解,请参考下面的这个链接。

https://www.codeproject.com/Articles/2126/How-a-C-compiler-implements-exception-handling


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 635672377@qq.com

文章标题:C++幕后故事(十三)--吃掉异常

文章字数:3.4k

本文作者:刘世雄

发布时间:2020-06-30, 17:49:41

最后更新:2020-07-30, 10:02:29

原始链接:http://lsxcpp.com/2020/07/01/cpp-13-eat-exception/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录