15、信号与异常处理:跨平台信号处理、结构化异常(SEH)封装、崩溃捕获

说到信号和异常处理,我脑子里立刻浮现出几年前的一个深夜。服务器在线上突然挂了,查了半天日志,最后发现是一个野指针在 Linux 上触发了 SIGSEGV,而 Windows 上同样的逻辑却悄无声息地崩溃了。嗯,从那天起,我就下定决心要把跨平台的异常处理机制彻底搞透。

你想想看,一个 C++ 程序跑在不同的操作系统上,遇到非法内存访问、除零错误、Ctrl+C 中断,表现可能完全不一样。Windows 有它的结构化异常处理(SEH),Linux/Unix 用的是信号机制。作为跨平台开发者,我们得把这些差异统一封装起来。

15.1 信号处理基础:POSIX 信号

在 Linux 和 macOS 上,信号是操作系统通知进程的一种方式。常见的信号有:

信号 默认行为 典型场景
SIGSEGV 终止进程 + 核心转储 访问非法内存地址
SIGFPE 终止进程 + 核心转储 除零或浮点异常
SIGABRT 终止进程 + 核心转储 调用 abort()
SIGINT 终止进程 用户按下 Ctrl+C
SIGTERM 终止进程 kill 命令默认发送

注册信号处理函数,用的是 signal() 或更推荐的 sigaction()。我个人习惯用 sigaction(),因为它更可控,行为也更一致。

#include <csignal>
#include <cstring>
#include <unistd.h>

void crash_handler(int sig, siginfo_t* info, void* context) {
    // 注意:信号处理函数中能做的事非常有限
    const char* msg = "捕获到崩溃信号!\n";
    write(STDERR_FILENO, msg, strlen(msg));
    
    // 这里可以尝试记录堆栈信息
    // 但绝对不能调用 malloc、printf 等非异步安全函数
    
    // 恢复默认处理并重新发送信号,让系统生成 core dump
    signal(sig, SIG_DFL);
    raise(sig);
}

void setup_signal_handlers() {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_sigaction = crash_handler;
    sa.sa_flags = SA_SIGINFO | SA_NODEFER;
    
    sigaction(SIGSEGV, &sa, nullptr);
    sigaction(SIGFPE,  &sa, nullptr);
    sigaction(SIGABRT, &sa, nullptr);
}
⚠️ 重要警告:信号处理函数必须是异步信号安全的。我曾经在信号处理里调用了 free(),结果直接死锁了。能用的函数就那么几个:write()sigaction()_exit() 等。别想着打日志、写文件,那都是坑。

15.2 Windows 结构化异常处理(SEH)

Windows 的 SEH 是另一套体系。它用 __try/__except__try/__finally 来捕获硬件异常。说白了,就是 Windows 版的 try-catch,但捕获的是硬件级别的异常。

#include <windows.h>
#include <stdio.h>

void test_seh() {
    __try {
        // 故意触发访问违规
        int* p = nullptr;
        *p = 42;
    }
    __except(EXCEPTION_EXECUTE_HANDLER) {
        printf("捕获到异常!异常码: 0x%X\n", 
               GetExceptionCode());
    }
}

// 更高级的用法:自定义异常过滤器
LONG WINAPI custom_filter(EXCEPTION_POINTERS* ep) {
    printf("异常地址: 0x%p\n", ep->ExceptionRecord->ExceptionAddress);
    printf("异常码: 0x%X\n", ep->ExceptionRecord->ExceptionCode);
    
    // 返回 EXCEPTION_EXECUTE_HANDLER 表示由 __except 处理
    return EXCEPTION_EXECUTE_HANDLER;
}

void test_with_filter() {
    __try {
        int arr[10];
        arr[100] = 0;  // 栈溢出
    }
    __except(custom_filter(GetExceptionInformation())) {
        printf("异常已处理\n");
    }
}

这里有个细节:__except 后面的表达式可以是三种值:EXCEPTION_EXECUTE_HANDLER(处理异常)、EXCEPTION_CONTINUE_SEARCH(继续向上传播)、EXCEPTION_CONTINUE_EXECUTION(忽略异常继续执行)。最后那个我建议你慎用,除非你真的知道自己在干什么。

15.3 跨平台封装:统一异常捕获接口

好了,现在我们要把这两套东西封装成一个统一的接口。我的思路是这样的:

  1. 定义一个回调函数类型,用于处理崩溃事件
  2. 在 Linux 上用 sigaction() 注册信号处理
  3. 在 Windows 上用 SetUnhandledExceptionFilter() 注册顶层异常过滤器
  4. 在回调中尽量收集有用的上下文信息
// crash_handler.hpp
#pragma once
#include <functional>
#include <string>

namespace crossplat {

// 崩溃信息结构体
struct CrashInfo {
    uint64_t    exception_code;
    void*       exception_address;
    std::string thread_name;
    // 可以扩展更多字段
};

// 崩溃回调类型
using CrashCallback = std::function<void(const CrashInfo&)>;

// 初始化崩溃捕获
void init_crash_handler(CrashCallback callback);

// 获取当前堆栈(跨平台实现)
std::string get_stack_trace();

} // namespace crossplat
// crash_handler_linux.cpp
#include "crash_handler.hpp"
#include <csignal>
#include <cstring>
#include <unistd.h>
#include <execinfo.h>

namespace crossplat {

static CrashCallback g_callback;

void signal_handler(int sig, siginfo_t* info, void* context) {
    CrashInfo ci;
    ci.exception_code = sig;
    ci.exception_address = info->si_addr;
    
    if (g_callback) {
        g_callback(ci);
    }
    
    // 恢复默认处理
    signal(sig, SIG_DFL);
    raise(sig);
}

void init_crash_handler(CrashCallback callback) {
    g_callback = callback;
    
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_sigaction = signal_handler;
    sa.sa_flags = SA_SIGINFO | SA_NODEFER;
    
    sigaction(SIGSEGV, &sa, nullptr);
    sigaction(SIGFPE,  &sa, nullptr);
    sigaction(SIGABRT, &sa, nullptr);
    sigaction(SIGILL,  &sa, nullptr);
}

std::string get_stack_trace() {
    void* buffer[128];
    int frames = backtrace(buffer, 128);
    char** symbols = backtrace_symbols(buffer, frames);
    
    std::string result;
    for (int i = 0; i < frames; ++i) {
        result += symbols[i];
        result += '\n';
    }
    free(symbols);
    return result;
}

} // namespace crossplat
// crash_handler_win.cpp
#include "crash_handler.hpp"
#include <windows.h>
#include <dbghelp.h>

namespace crossplat {

static CrashCallback g_callback;

LONG WINAPI unhandled_exception_filter(EXCEPTION_POINTERS* ep) {
    CrashInfo ci;
    ci.exception_code = ep->ExceptionRecord->ExceptionCode;
    ci.exception_address = ep->ExceptionRecord->ExceptionAddress;
    
    if (g_callback) {
        g_callback(ci);
    }
    
    return EXCEPTION_EXECUTE_HANDLER;
}

void init_crash_handler(CrashCallback callback) {
    g_callback = callback;
    SetUnhandledExceptionFilter(unhandled_exception_filter);
    
    // 同时处理 C++ 异常
    std::set_terminate([]() {
        CrashInfo ci;
        ci.exception_code = 0xE06D7363; // MSVC C++ 异常码
        ci.exception_address = nullptr;
        if (g_callback) {
            g_callback(ci);
        }
        abort();
    });
}

std::string get_stack_trace() {
    // 使用 StackWalk64 获取堆栈
    // 代码略长,这里展示核心逻辑
    HANDLE process = GetCurrentProcess();
    HANDLE thread = GetCurrentThread();
    
    SymInitialize(process, nullptr, TRUE);
    
    // ... 堆栈遍历代码
    return "stack trace (Windows)";
}

} // namespace crossplat
💡 实战经验:我在封装这个接口时踩过一个坑——Windows 上 SetUnhandledExceptionFilter 只对未处理的异常生效。如果你的代码里已经有 __try/__except 捕获了异常,那这个过滤器就不会被调用。所以建议在程序入口处尽早注册,并且确保所有线程都使用统一的异常处理策略。

15.4 崩溃信息收集与日志

光捕获崩溃还不够,我们得把现场信息保存下来。我个人习惯在回调中做以下几件事:

  • 记录异常码和异常地址
  • 获取当前线程的堆栈
  • 记录系统版本、CPU 信息
  • 将信息写入一个独立的崩溃日志文件
void my_crash_callback(const CrashInfo& info) {
    // 注意:这里不能分配内存、不能加锁
    // 最好用预先分配好的缓冲区
    
    static char buffer[4096];
    int len = snprintf(buffer, sizeof(buffer),
        "异常码: 0x%llX\n"
        "异常地址: 0x%p\n"
        "堆栈:\n%s\n",
        info.exception_code,
        info.exception_address,
        get_stack_trace().c_str());
    
    // 写入文件描述符(预先打开)
    write(crash_log_fd, buffer, len);
    
    // 也可以尝试用 mmap 写入共享内存
    // 让另一个进程来读取
}

核心原则:崩溃处理函数中,能做的事极其有限。不要调用任何可能分配内存、加锁、或者访问复杂数据结构的函数。最好的做法是:

  • write() 写入文件描述符
  • mmap 写入共享内存
  • sigqueue() 通知另一个监控进程

15.5 知识体系总览

下面这张图展示了本章的核心知识结构,从底层机制到上层封装,一目了然:

跨平台异常处理知识体系 Linux / macOS (POSIX 信号) Windows (SEH 结构化异常) 信号机制 sigaction() 注册处理函数 SIGSEGV / SIGFPE / SIGABRT SEH 机制 __try/__except 捕获异常 SetUnhandledExceptionFilter() 跨平台封装层 (CrashHandler) 统一回调接口 + 崩溃信息结构体 应用层:崩溃日志记录 / 堆栈回溯 / 监控告警

15.6 避坑指南与最佳实践

做了这么多年跨平台开发,我总结了几条关于异常处理的铁律:

⚠️ 我曾经踩过的坑:
  • 在信号处理函数中调用 printf 导致死锁——改用 write()
  • 忘记恢复默认信号处理,导致递归调用信号处理函数——用 SA_NODEFER 标志
  • Windows 上 __except 中访问无效指针——异常过滤器里也要小心
  • 多线程环境下,一个线程崩溃其他线程还在跑——考虑用 pthread_kill 通知所有线程

最后说一句:崩溃捕获是最后一道防线,别指望它能解决所有问题。真正靠谱的做法是:

  • 写好单元测试,覆盖边界情况
  • 用静态分析工具检查代码
  • 在开发阶段启用 AddressSanitizer
  • 上线前做充分的压力测试

嗯,这一章的内容就到这里。记住,异常处理不是为了掩盖 bug,而是为了在 bug 发生时,我们能拿到足够的信息去修复它。

公众号:蓝海资料掘金营,微信deep3321