异常处理:调试技巧与替代方案
异常最难的地方不是抛,而是"不知道谁抛的"。本文聚焦两件事:如何快速定位异常源头,以及在禁用异常的环境中如何组织错误处理。
一、异常的三条基本规则
- 按值抛出,按引用捕获:
catch (const std::exception& e); - 析构函数不抛:默认
noexcept,抛了就std::terminate; - 不为控制流用异常:异常是异常路径,不是
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&) 都能兜住。
九、检查清单
- 线程入口、回调入口、异步任务是否都包了 try/catch?
- 析构函数是否全部
noexcept(不抛)? catch顺序是否正确(派生在前)?- 是否按引用捕获(
const std::exception&)而不是按值? - 是否有异常跨过 C 边界的路径?
- 是否在崩溃现场保留了异常类型与消息?
异常处理的目标不是"不崩溃",而是"崩溃时知道为什么"。