异常的路径追踪

当程序因为发生异常崩溃时,你可以在调试器里看到异常发生点之前的一系列函数调用。而如果你按照异常的推荐用法,只在外层某个地方捕获异常的话,那虽然代码看起来很干净,但缺点也来了——在捕获异常的地方你只能看到异常本身携带的信息,而看不到发生异常的函数调用路径。由于抛异常的代码可能不在我们自己的控制之下,我们甚至没法使用编译器提供的特殊函数(如 GCC 的 __builtin_return_address)来辅助检查现场——因为在捕获异常的地方,栈展开(stack unwinding)已经结束,异常发生点的信息已经丢失了。

由于这个原因,某些项目组使用了一种牺牲表达简洁性的做法:在很多函数里加上了 try/catch/throw,在异常抛出的路径上一路记录信息到类似日志之类的地方,以方便后续的调试。这在我看来,非常不可取:这样做的结果是代码可读性几乎就跟使用错误码一样糟糕,同时性能还不如错误码,这又何苦呢……

当然,记录异常发生路径这个需求真实存在:为了方便调试,我们不仅需要知道发生了什么异常,我们还需要知道异常是怎么发生的。有没有一种做法能让我们在不牺牲表达的简洁性的前提之下,能够知道异常发生时的函数调用路径?

答案是肯定的。


早在 2022 年初,我就写了一篇文章,介绍了一种在不能修改 new 相关代码的情况下,知道哪里使用了 new 分配内存的方法。当时使用的技巧,在现在也基本适用。

这一方法的基本原理是:函数里可以按需设置特定的上下文,这些上下文以后进先出的顺序放到(push)一个线程局部的栈对象里。在 operator new 调用发生时,它会从栈对象的顶部取得“当前”上下文,并把该上下文记录到分配的内存里。当函数返回时,RAII 机制能自动弹出(pop)在这个函数里加入栈对象的上下文,从而保证“当前”上下文仍然是正确的。

类似的概念也可以用于异常的跟踪。当异常发生时,栈会展开,RAII 对象同样能够记录从异常发生点到异常捕获位置之间的上下文变化。但这里有个困难:普通的栈结构在对象弹出之后,不再允许访问弹出的对象(否则又构成了悬空引用)。因此我们并不能使用 C++ 标准库的 stack

这个问题不难解决。我们只需要写出一个类似 stack、但行为略有不同的容器适配器即可。我们需要的额外行为是:当元素被弹出时,它并不会立即被删除;我们可以在 pop 操作时仅简单地做标记,而在下一次要插入新元素、需要复用原有的存储空间时,才清除已删除的元素。

下面是该容器适配器的核心代码:

template <typename T, typename Container = std::deque<T>>
class trace_stack {
public:
    template <typename... Args>
    void emplace(Args&&... args)
    {
        discard_popped();
        container_.emplace_back(std::forward<Args>(args)...);
    }

    void pop()
    {
        assert(!empty());
        ++trace_count_;
    }

    reference top()
    {
        assert(!empty());
        return *(container_.end() - trace_count_ - 1);
    }

    void discard_popped()
    {
        if (trace_count_ == 0) {
            return;
        }
        container_.erase(container_.end() - trace_count_,
                         container_.end());
        trace_count_ = 0;
    }

    trace_stack_subrange<Container> get_popped() const&
    {
        return {container_.end() - trace_count_,
                container_.end()};
    }

private:
    Container container_;
    size_type trace_count_{};
};

这里没有展示的 trace_stack_subrange 是一个简单的子范围,允许通过遍历来访问其中(已从 trace_stackpop)的元素。如果不是想降低对 C++ 标准的要求的话,其实我也可以使用 span。注意 get_popped 成员函数使用了 const& 修饰:你可以在一个 consttrace_stack 上调用 get_popped,但你不可以在一个 trace_stack 的右值对象上调用 get_popped——允许这样做的话,很可能会产生悬空引用,太危险了。

完整代码请参见下面的链接:https://github.com/adah1972/nvwa/blob/master/nvwa/trace_stack.h


解决了存储上下文的容器的问题之后,我们还有一个小问题需要解决。我们需要知道,在跟踪上下文的 RAII 对象的析构函数被调用时,异常是不是正在被抛出。如果异常正在被抛出,我们应当正常记录弹出的上下文;反之,如果只是一个正常的函数返回,我们就可以把弹出的上下文丢弃掉。

原本存储和恢复上下文的代码是这样的:

void save_context(const context& ctx)
{
    context_stack.push(ctx);
}

void restore_context(const context& ctx)
{
    assert(!context_stack.empty() && context_stack.top() == ctx);
    context_stack.pop();
}

要达到我们需要的效果,我们只需要使用 uncaught_exception(C++11 和 C++14)或 uncaught_exceptions(C++17 起)函数即可。下面是修改后的 C++17 代码:

void save_context(const context& ctx)
{
    context_stack.push(ctx);
}

void restore_context(const context& ctx)
{
    assert(!context_stack.empty() && context_stack.top() == ctx);
    context_stack.pop();
    if (std::uncaught_exceptions() == 0) {
        context_stack.discard_popped();
    }
}

在记录了这样的信息后,我们可以在异常的捕获位置简单调用下面的函数:

void print_exception_contexts(FILE* fp = stdout)
{
    auto popped_items = context_stack.get_popped();
    auto it = popped_items.rbegin();
    auto end = popped_items.rend();
    for (int i = 0; it != end; ++i, ++it) {
        fprintf(fp, "%d: %s/%s\n", i, it->file, it->func);
    }
}

目前,我已经在 Nvwa 项目实现了使用这一方法的完整代码。


下面是一段完整的展示该功能的代码:

#include <exception>
#include <stdexcept>
#include <nvwa/context.h>

void foo()
{
    NVWA_CONTEXT_CHECKPOINT();
}

void bar()
{
    NVWA_CONTEXT_CHECKPOINT();
    foo();
    throw std::runtime_error("Bad bar");
}

void test()
{
    NVWA_CONTEXT_CHECKPOINT();
    bar();
}

int main()
{
    NVWA_CONTEXT_CHECKPOINT();
    try {
        test();
    }
    catch (const std::exception& e) {
        printf("Caught exception: %s\n", e.what());
        nvwa::print_exception_contexts();
    }
}

如果我们把这个文件放在 Nvwa 项目的根目录下,命名为 test_exception_trace.cpp,那使用 GCC 的构建命令行是:

g++ -std=c++17 -I. test_exception_trace.cpp nvwa/context.cpp

运行该程序,我们会得到下面的输出:

Caught exception: Bad bar
0: test_exception_trace.cpp/void bar()
1: test_exception_trace.cpp/void test()

我们可以看到,从 barmain 的函数调用路径都已经被正确输出了。在调用路径上、但不在异常路径上的 foo 则不产生输出。

这就是我想要的效果了。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值