跳到主要内容

异常处理:调试技巧与替代方案

异常最难的地方不是抛,而是"不知道谁抛的"。本文聚焦两件事:如何快速定位异常源头,以及在禁用异常的环境中如何组织错误处理。

一、异常的三条基本规则​

  1. 按值抛出,按引用捕获:catch (const std::exception& e);
  2. 析构函数不抛:默认 noexcept,抛了就 std::terminate;
  3. 不为控制流用异常:异常是异常路径,不是 if/else 的替代。
try {
risky();
} catch (const std::invalid_argument& e) { // 具体类型在前
log(e.what());
} catch (const std::exception& e) { // 基类在后
log(e.what());
} catch (...) { // 兜底
log("unknown");
}

顺序很重要:基类写在前面会让派生类型的 catch 永远进不去。

二、常见崩溃:std::terminate​

以下四种是 C++ 程序最常见触发 std::terminate 的原因。

出现 terminate called after throwing an instance of ... 时有几种典型原因:

原因 1:没有 catch 的异常退出线程​

std::thread t([] { throw std::runtime_error("boom"); });
t.join(); // 线程函数内未捕获异常 → std::terminate

修复:线程入口必须整体包住:

std::thread t([] {
try { work(); } catch (const std::exception& e) { log(e.what()); }
});

原因 2:析构函数抛异常​

~Foo() { throw std::runtime_error("x"); } // ❌ 若在栈展开中抛出 → terminate

原因 3:noexcept 函数内抛异常​

void f() noexcept { throw std::runtime_error("x"); } // → terminate

原因 4:异常跨过 C 栈帧​

异常穿过 C 代码(无 unwind 信息)会直接 terminate。回调和跨语言边界时必须就地捕获。

三、定位异常源头​

异常已抛出但不知道来源时,用下面三种手段定位:先让程序在抛出点停下,再让未捕获异常留下可读信息,最后可在服务器侧保留现场。

方法 1:gdb 上 catch throw​

:gdb 上 catch throw

gdb ./app
(gdb) catch throw
(gdb) run
# 命中后
(gdb) bt # 查看抛出点

catch throw 会在每次抛异常时停下,适合定位"不知道谁抛的"。

方法 2:terminate 处理函数​

#include <exception>

int main() {
std::set_terminate([] {
auto e = std::current_exception();
if (e) {
try { std::rethrow_exception(e); }
catch (const std::exception& ex) {
std::fprintf(stderr, "uncaught: %s\n", ex.what());
}
}
std::abort();
});
// ...
}

在崩溃日志里至少能看到异常类型与消息。

方法 3:ASan / UBSan 与 panic​

服务器环境可以在启动参数里把未捕获异常转成 coredump,方便事后分析。

四、异常安全与资源的联动​

void process() {
auto conn = std::make_unique<Conn>(); // RAII
std::lock_guard<std::mutex> lk(mtx); // RAII
conn->send(data); // 抛异常也不会泄漏/死锁
}

资源管理交给 RAII 之后,异常处理只需关注业务语义,不需要写 catch 做清理。

五、性能现实:异常到底有多贵​

  • 零成本模型:不抛出时几乎无开销(只有栈展开表的体积成本);
  • 抛出时:栈展开 + 类型匹配,通常是微秒级;
  • 在热路径里频繁抛(例如每帧抛几十次):代价显著,应改为返回状态码。

结论:异常适合"真的异常",不适合高频分支。

六、异常不适用的场景​

场景原因替代
嵌入式/内核/驱动常用 -fno-exceptions返回码 + goto cleanup 或 RAII
实时控制栈展开时间不可预测同上
与 C 库大量交互跨语言边界不安全返回码
高频校验失败性能std::optional / expected

-fno-exceptions 下怎么写​

// 构造不能失败,用 init() 返回状态
class Socket {
public:
enum class Status { Ok, Failed };
Status open(const char* host, int port) {
fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd_ < 0) return Status::Failed;
return Status::Ok;
}
~Socket() { if (fd_ >= 0) ::close(fd_); } // 仍可用 RAII
private:
int fd_ = -1;
};

要点:构造函数保持简单不失败,把可能失败的初始化放到显式 open()/init();析构照旧负责释放。

七、用返回值表达错误​

// C++17
std::optional<Config> loadConfig(const std::string& path);

// C++23 / 第三方
std::expected<Config, ErrorCode> loadConfig2(const std::string& path);

区别:optional 只说"没有",不携带原因;expected 能携带错误码与消息,是"无异常错误处理"的最佳载体。

if (auto cfg = loadConfig(path)) {
use(*cfg);
} else {
fallback();
}

八、自定义异常类型​

class ParseError : public std::runtime_error {
public:
ParseError(const std::string& msg, std::size_t line)
: std::runtime_error(msg), line_(line) {}
std::size_t line() const noexcept { return line_; }
private:
std::size_t line_;
};

建议继承 std::runtime_error(或 logic_error),保证任何上层 catch (const std::exception&) 都能兜住。

九、检查清单​

  1. 线程入口、回调入口、异步任务是否都包了 try/catch?
  2. 析构函数是否全部 noexcept(不抛)?
  3. catch 顺序是否正确(派生在前)?
  4. 是否按引用捕获(const std::exception&)而不是按值?
  5. 是否有异常跨过 C 边界的路径?
  6. 是否在崩溃现场保留了异常类型与消息?

异常处理的目标不是"不崩溃",而是"崩溃时知道为什么"。