C++

给异常加上堆栈

Posted by w@hidva.com on March 25, 2024

不止一次, 有同学发给我一个 exception what message, 希望我能告知他们这个异常究竟是从哪里抛出的.

ex-with-stack-req

经历过C++ 异常与 longjmp: 尘埃落定之后, 我对给异常加上堆栈是有一种模糊可行的想法的, 正好现在有机会抽出了时间实现了这种想法. 关于 C++ 异常机制实现以及相关 ABI 标准, 见前文, 不再在本文中叙述.

基本思想也很简单, 就是我们维护着类型为 std::unordered_map<void*, StackTrace>的全局变量 ex_trace_map. 之后 hook __cxa_throw, 获取此时堆栈, 并将此时异常对象的地址以及堆栈本身保存到 ex_trace_map 中. 并提供相应的接口供用户查询, 比如:

[[gnu::noinline]] void f1(int ex_kind) {
  if (ex_kind == kMyEx) {
    // throw 会调用 __cxa_throw, 在我们 hook 之后的逻辑中,
    // 会获取堆栈, 并将此时异常对象的地址与堆栈保存在 ex_trace_map 中.
    throw MyException("I am going now.");
  } else if (ex_kind == kStdEx) {
    throw std::runtime_error("I bid you all a very fond farewell.");
  } else {  // kNoEx
    std::cerr << "Goodbye." << std::endl;
  }
}

int main() {
  try {
    f1(kMyEx);
  } catch (const MyException& e) {
    // GetTrace 会查询 ex_trace_map, 找出 e 对应的堆栈信息.
    auto sp = GetTrace(e);
    std::cerr << sp.ToString() << std::endl;
  }
}

具体实现

对 __cxa_throw 的 hook 也很简单, 我们只要在业务代码中重新定义下即可; 这样在 throw 调用 __cxa_throw 抛出异常时, 根据目前链接约定以及符号查询规则, 便会走到我们 hook 后的逻辑中.

namespace __cxxabiv1 {

extern "C" {
__attribute__((visibility("default"))) void __cxa_throw(void*, std::type_info*, void (*)(void*))
  __attribute__((__noreturn__));
}

#if 1
void __cxa_throw(void* thrownException, std::type_info* type, void (*destructor)(void*)) {
  static auto orig_cxa_throw = reinterpret_cast<decltype(&__cxa_throw)>(dlsym(RTLD_NEXT, "__cxa_throw"));
  my_throw_callback(thrownException, type, &destructor);
  orig_cxa_throw(thrownException, type, destructor);
  __builtin_unreachable();  // orig_cxa_throw never returns
}
#endif


static void my_throw_callback(void* ex, std::type_info* ti, Deleter* deleter) noexcept {
  static thread_local bool handling_throw = false;

  if (handling_throw) {
    return;
  }
  SCOPE_EXIT { handling_throw = false; };
  handling_throw = true;

  try {
    do_throw_callback(ex, ti, deleter);
  } catch (const std::bad_alloc&) {
  }
}

// 通过 hook __cxa_throw 确保其调用 do_throw_callback.
static void do_throw_callback(void* ex, std::type_info*, Deleter* deleter) {
  auto& ex_map = ex_trace_map();

  {
    // ExceptionMeta 会获取当前堆栈并保存在 ex_meta 中.
    auto ex_meta = ExceptionMeta(*deleter);  // catch stack trace.
    auto _guard = std::lock_guard<std::shared_mutex>(ex_map.mutex);
    auto res = ex_map.map.emplace(ex, std::move(ex_meta));
    DCHECK(res.second);
  }

  // 必须在最后一步变更 deleter, 确保 MetaDeleter 被调用时, ex 一定在 map 中.
  // 如果 MetaDeleter 被调用时, ex 不在 map 中, 则会导致 crash.
  *deleter = MetaDeleter;
}

// MetaDeleter 会在异常对象析构时调用, 此时将其从 ex_trace_map 中移除.
static void MetaDeleter(void* ex) noexcept {
  auto& ex_map = ex_trace_map();
  Deleter deleter = nullptr;
  {
    auto _guard = std::lock_guard<std::shared_mutex>(ex_map.mutex);
    auto iter = ex_map.map.find(ex);
    DCHECK(iter != ex_map.map.end());
    deleter = iter->second.deleter;
    ex_map.map.erase(iter);
  }
  if (deleter) {
    deleter(ex);
  }
}


}

Q: 这里为啥没有 hook rethrow_exception/__cxa_rethrow?

A: 如下程序所示, 无论是 rethrow_exception 还是 __cxa_rethrow 总是使用同一个异常对象, 此时不需要更新 ex_trace_map.

int main() {
  try {
    try {
      try {
        throw std::runtime_error("x");
      } catch (const std::exception& e) {
        printf("1: %p\n", &e);
        throw;  // __cxa_rethrow
      }
    } catch (const std::exception& e) {
      printf("2: %p\n", &e);
      std::rethrow_exception(std::current_exception());
    }
  } catch (const std::exception& e) {
    printf("3: %p\n", &e);
  }
  return 0;
}

后记

目前针对异常不带有堆栈信息的处理方法, 我们很多同学会常态化 try catch 异常, 其 catch handler 只是简单输出日志, 之后再次 rethrow 异常:

try {
  co_await PrepareSelf(state);
} catch (...) {
  auto ex = std::current_exception();
  LOG(ERROR) << "Prepare error: " << GetExceptionInfo(ex);
  std::rethrow_exception(ex);
}

在有这套机制之后, 我们便不再需要如此, 而是只在业务最顶层/最外层增加对异常的处理, 此时可以输出堆栈等信息方便问题排查.