当程序因为发生异常崩溃时,你可以在调试器里看到异常发生点之前的一系列函数调用。而如果你按照异常的推荐用法,只在外层某个地方捕获异常的话,那虽然代码看起来很干净,但缺点也来了——在捕获异常的地方你只能看到异常本身携带的信息,而看不到发生异常的函数调用路径。由于抛异常的代码可能不在我们自己的控制之下,我们甚至没法使用编译器提供的特殊函数(如 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_stack
中 pop
)的元素。如果不是想降低对 C++ 标准的要求的话,其实我也可以使用 span
。注意 get_popped
成员函数使用了 const&
修饰:你可以在一个 const
的 trace_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()
我们可以看到,从 bar
到 main
的函数调用路径都已经被正确输出了。在调用路径上、但不在异常路径上的 foo
则不产生输出。
这就是我想要的效果了。