C++ 异常原理:以一个小程序为例

简介: 作者在调查某个 bug 时涉及到 C++ 异常,借此机会以本文把 C++ 异常机制梳理清楚供大家参考。

最近我们在调查某个 bug 涉及到 C++ 异常。平时较少用 C++ 异常,借此机会把 C++ 异常机制梳理清楚。互联网上现有的资料不多,大多过于深奥。因此写下这篇文档备忘。


C++ 异常的实现机制有 SJLJ、Dwarf CFI、EHABI。具体选择哪种实现和操作系统及体系结构相关。它是 C++ ABI 的一部分。这里我们仅关注 Dwarf CFI,它是 Linux 在 x86_64 和 arm64 上的默认实现。


完整的 C++ 异常机制需要编译器生成的代码、C++ 运行时(libstdc++libc++)、unwind 库分工协作完成。本文为了描述浅显易懂,并不区分它们三者。


测试程序

我们从下面的小程序出发,分析 C++ 异常的实现原理。这个程序演示了几个关键点:


1.f() 分配异常对象并抛出来;


2.向上回溯栈帧,沿途析构 g() 栈上的对象;


3.main() 匹配到 catch 语句,处理异常。


#include <stdio.h>
struct A {
    A() { printf("A\n"); }
    ~A() { printf("~A\n"); }
};
struct E {
    E() { printf("E\n"); }
    ~E() { printf("~E\n"); }
};
void f()
{
    throw E();
}
void g()
{
    A a;
    f();
}
int main()
{
    try {
        g();
    } catch (int n) {
        printf("catch int %d\n", n);
    } catch (const E& e) {
        printf("catch E %p\n", &e);
    }
    return 0;
}

抛出异常


为了方便描述,我们下面以 C 语法描述编译器为异常生成的代码。(小技巧:在 CompilerExplorer 网站能看到各种编译器生成的汇编代码。)


让我们先看抛异常的 f() 函数。它抛出了类型为 E 的异常,除此以外没有其它功能。


void f()
{
    // throw E();
    E* e = __cxa_allocate_exception(sizeof(struct E));  // 从堆上分配异常对象
    e->E();                 // 构造异常对象
    __cxa_throw(            // 抛异常
        e,                  // 异常对象
        &typeid(struct E),  // 异常对象的类型,这是编译时生成的静态对象
        &E::~E);            // 异常对象的析构函数
}

这些 __cxa 开头的函数是由 C++ 运行时库提供的。


__cxa_allocate_exception() 从堆上分配异常对象和其它内部数据结构。


__cxa_throw() 会向上回溯栈帧,依次回溯到 g()main()


传播异常

我们再来看 g()g() 没有 catch 语句,异常会继续向上传播。但是在此之前还有一个栈上对象 a,因此回溯栈桢时需要在此停留,以析构 a 对象。


这里引出一个概念:着陆场(landing pad)。下面代码中第 9~10 行是 f() 正常返回的执行路径。若 f() 抛异常,则会跳转到第 15 行。这里称为着陆场。这里第 15 行析构了a 对象,第 16 行继续向上回溯到 main()


void g() 
{
    // A a;
    A a;    // 在栈上分配 a 对象
    a.A();  // 构造 a 对象
    
    // f();
    f();    // 调用 f()
    a.~A(); // f() 正常返回走到这里
    goto end_of_catch;
   
    // f() 抛异常跳转到这里。
    // 尽管 g() 没有 catch,但是 a 需要析构,因此也有着落场。
    // 此时 rax 指向异常对象头,rdx 表示匹配的动作。
    a.~A();       // 析构 a 对象
    _Unwind_Resume(e);  // 没有匹配的 catch,继续回溯栈帧

end_of_catch:
    return;
}

捕获异常

最后来看 main()main() 中有 catch 语句,第二个 catch 语句匹捕获到 E 类型的异常。


int main()
{
    // try {
    //    g();
    // }
    g();  // 调用 g()
          // 如果 try { ... } 在 g() 后面还有其它代码,会放在这里
    goto end_of_catch;  // g() 正常返回走到这里
   
    // 这里是 throw 的着陆场。
    // $rax 指向异常对象。
    // $rdx 表示动作:
    // 0 表示不 catch,继续向上回溯栈帧;
    // 1 表示匹配第一个 catch;
    // 2 表示匹配第二个 catch。
    void *p = rax;
    int action = rdx;
    
    // 如果 try { ... } 有对象需要析构,在这里析构。
    // 下面我们开始匹配 catch 了。
    
    // catch (int n) {
    //     printf("catch int %d\n", n);
    // }
    if (action == 1) {
        n = *(int *) e;
        printf("catch int %d\n", n);
        goto end_of_catch;
    }
    
    // catch (const E& e) {
    //     printf("catch E\n");
    // }
    if (action == 2) {
        E *e = __cxa_begin_catch(p);
        printf("catch E %p\n", e);
        __cxa_end_catch();  // 内部析构 e 对象
        goto end_of_catch;
    }
    
    _Unwind_Resume(p);  // 如果没有匹配的 catch,继续回溯栈帧。
end_of_catch:
    return 0;
}

其它细节

前面埋了个包袱:__cxa_throw() 是回溯栈帧和找到着陆场呢?已知 PC 指针位置,这些信息编译时确定的。编译时产生 .eh_frame.gcc_except_table 段,运行时借助这两张表可以找到上层栈帧和着陆场的位置。详细的描述过于复杂,请参考本文末尾的链接。


找到着陆场后,在运行时依次根据捕获的异常类型来匹配 catch 语句,这里用到了 C++ RTTI 信息。若匹配不到合适的 catch 语句,则继续向上回溯栈帧传播异常。

参考资料:

1、Itanium C++ ABI: Exception Handling:https://itanium-cxx-abi.github.io/cxx-abi/abi-eh.html

2、Exception Handling ABI for the Arm Architecture:https://github.com/ARM-software/abi-aa/blob/844a79fd4c77252a11342709e3b27b2c9f590cf1/ehabi32/ehabi32.rst

3、libunwind LLVM Unwinder:https://github.com/llvm/llvm-project/blob/main/libunwind/docs/index.rst

4、Linux 栈回溯(x86_64):https://zhuanlan.zhihu.com/p/302726082

5、.eh_frame:https://www.airs.com/blog/archives/460

6、.gcc_except_table:https://www.airs.com/blog/archives/464


作者 | 石超
来源 | 阿里云开发者公众号


相关文章
|
2天前
|
编译器 Linux C语言
我的C++奇迹之旅相遇:支持函数重载的原理
我的C++奇迹之旅相遇:支持函数重载的原理
|
2天前
|
SQL 安全 程序员
C++:异常
C++:异常
22 7
|
2天前
|
设计模式 算法 C++
【C++】STL之迭代器介绍、原理、失效
【C++】STL之迭代器介绍、原理、失效
13 2
|
2天前
|
SQL 缓存 安全
【C++入门到精通】异常 | 异常的使用 | 自定义异常体系 [ C++入门 ]
【C++入门到精通】异常 | 异常的使用 | 自定义异常体系 [ C++入门 ]
11 2
|
2天前
|
编解码 JavaScript 前端开发
【专栏】介绍了字符串Base64编解码的基本原理和在Java、Python、C++、JavaScript及Go等编程语言中的实现示例
【4月更文挑战第29天】本文介绍了字符串Base64编解码的基本原理和在Java、Python、C++、JavaScript及Go等编程语言中的实现示例。Base64编码将24位二进制数据转换为32位可打印字符,用“=”作填充。文中展示了各语言的编码解码代码,帮助开发者理解并应用于实际项目。
|
2天前
|
设计模式 C语言 C++
【C++进阶(六)】STL大法--栈和队列深度剖析&优先级队列&适配器原理
【C++进阶(六)】STL大法--栈和队列深度剖析&优先级队列&适配器原理
|
2天前
|
存储 C++
C++底层原理
C++底层原理
24 0
|
2天前
|
C++
关于C++多态 的基本知识 与 底层原理
关于C++多态 的基本知识 与 底层原理
|
2天前
|
C++
C++异常之栈解旋
C++异常之栈解旋
|
2天前
|
存储 算法 C++
C++:stack、queue、priority_queue增删查改模拟实现、deque底层原理
C++:stack、queue、priority_queue增删查改模拟实现、deque底层原理
35 0