C++ 捕获异常崩溃全流程

错误码、异常处理、断言如何取舍

在 C++ 中,错误处理有多种方式,包括返回错误码(error code)、异常处理(exception handling)、断言(assertion)等。选择哪种方式处理错误取决于具体情况。

通常情况下,使用异常处理是更好的选择。异常处理能够将错误和异常情况与普通控制流分离开来,使得代码更加清晰和易读。此外,异常处理还能够提供更丰富的错误信息和调试信息,可以让开发人员更容易地理解和调试程序。

相比之下,使用返回错误码的方式处理错误可能会导致代码变得混乱和难以维护。错误码可能需要在多个函数之间传递,增加了代码的复杂度,同时也容易被忽略或者忘记检查。此外,错误码通常只提供了一些基本的信息,对于调试程序并不够充分。

不过,在一些特殊情况下,返回错误码的方式也是有用的。例如,某些API需要使用错误码来表示错误,这时候就必须使用返回错误码的方式。另外,对于一些轻微的错误或者性能要求比较高的代码,使用返回错误码的方式也可以提高程序的执行效率。

总的来说,如果你的代码中可能会遇到比较严重的错误或者异常情况,那么使用异常处理是更好的选择。如果你的代码中只有一些轻微的错误或者性能要求比较高,那么使用返回错误码的方式也是可以的。在实际开发中,可以根据具体情况选择合适的方式。

捕获异常或崩溃的若干方式

  1. 使用 try-catch 块:使用 try-catch 块可以捕获异常并打印相关信息。例如:
try {
    // some code that may throw an exception
}
catch (const std::exception& ex) {
    std::cerr << "Caught exception: " << ex.what() << std::endl;
}
  1. 使用 signal 处理程序:在 C++ 程序中,使用 signal 处理程序可以捕获和处理程序崩溃的信号。例如:
 #include <signal.h>

void signal_handler(int signal) {
    // handle the signal here
}

int main() {
    // register the signal handler
    signal(SIGSEGV, signal_handler);
    // some code that may cause a segmentation fault
    return 0;
}

  1. 使用 C++ 异常处理机制:在 C++ 程序中,使用 C++ 异常处理机制可以捕获异常并打印相关信息。例如:
 class my_exception : public std::exception {
public:
    virtual const char* what() const throw() {
        return "My Exception";
    }
};

int main() {
    try {
        // some code that may throw an exception
        throw my_exception();
    }
    catch (const std::exception& ex) {
        std::cerr << "Caught exception: " << ex.what() << std::endl;
    }
    return 0;
}

在程序崩溃时,可以使用以上方法之一收集崩溃的现场信息,并将其记录在日志文件中或者通过网络发送到崩溃日志收集系统中。在记录崩溃信息时,应该包含堆栈跟踪信息、程序状态、操作系统版本等相关信息,以便于后续分析和解决问题。

signal 信号收集崩溃堆栈

当程序崩溃时,操作系统会发送一个信号给程序,通知它发生了异常。在 C++中,可以通过 signal 函数来注册一个信号处理程序,使程序能够在接收到该信号时执行自定义的代码。

在信号处理程序中,可以使用系统提供的 backtrace 函数和 backtrace_symbols 函数来获取当前的堆栈跟踪信息。backtrace 函数可以返回当前程序的调用堆栈信息,即函数调用关系的序列;backtrace_symbols 函数可以将调用堆栈信息转换为可读的字符串。

以下是一个在信号处理程序中获取堆栈跟踪信息并输出到日志文件的示例代码:

#include <execinfo.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void signal_handler(int sig) {
    void *array[10];
    size_t size;

    // get void*'s for all entries on the stack
    size = backtrace(array, 10);

    // print out all the frames to a log file
    FILE* fp = fopen("crash.log", "w");
    if (fp != NULL) {
        fprintf(fp, "Error: signal %d\n", sig);
        backtrace_symbols_fd(array, size, fileno(fp));
        fclose(fp);
    }

    _exit(1); // exit the program
}

int main() {
    // register signal handler
    signal(SIGSEGV, signal_handler);

    // your code here

    return 0;
}

在上面的代码中,当程序接收到 SIGSEGV 信号时,会跳转到 signal_handler 函数中执行相应的代码。在这个函数中,首先调用 backtrace 函数获取当前的堆栈跟踪信息,然后将其输出到一个名为 crash.log的日志文件中。最后调用 _exit 函数来终止程序的执行。

注意,当程序崩溃时,可能已经无法正常执行代码,因此需要谨慎地编写信号处理程序,以避免进一步的崩溃或数据损坏。同时,还应该将收集到的堆栈跟踪信息和其他相关信息一起记录下来,以便于分析和解决问题。

crash.log"文件格式

输出的"crash.log"文件格式通常是一个包含堆栈跟踪信息的文本文件,每一行都表示一个函数调用,按照调用顺序从上到下排列。

具体来说,每行都包含以下信息:

  • 函数调用地址:表示该函数在程序内存中的位置。
  • 函数名和参数:表示该函数的名称和参数列表。如果无法获取函数名,则显示为"???"。
  • 文件名和行号:表示该函数在源代码中的位置。如果无法获取文件名和行号,则显示为"(unknown)"。
    以下是一个示例"crash.log"文件的内容:
Error: signal 11
./myprogram() [0x400a5d]
./myprogram() [0x400a9d]
/lib64/libc.so.6(__libc_start_main+0xf5) [0x7f8fc1a5cb05]
./myprogram() [0x4008f9]

其中,第一列是函数调用地址,第二列是函数名和参数,第三列是文件名和行号(如果有的话)。这些信息可以帮助开发人员追踪程序的执行路径,并定位问题所在的代码行。但需要注意的是,函数名和行号可能会因为编译器优化等原因而不准确,因此需要谨慎使用。

符号表定位行号

符号表(Symbol Table)是一个存储程序函数名、变量名和其他符号信息的数据库,通常包含函数名、函数的起始地址、函数的大小、文件名、行号等信息。

在Linux下,可以使用GNU Binutils提供的addr2line命令来查询符号表,从而将函数地址和行号转换为源代码中的具体位置。

以下是一个使用addr2line命令查询符号表并输出对应代码位置的示例代码:

#include <execinfo.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void signal_handler(int sig) {
    void *array[10];
    size_t size;

    // get void*'s for all entries on the stack
    size = backtrace(array, 10);

    // print out all the frames to a log file
    FILE* fp = fopen("crash.log", "w");
    if (fp != NULL) {
        fprintf(fp, "Error: signal %d\n", sig);
        for (int i = 0; i < size; i++) {
            fprintf(fp, "[%d] ", i);
            if (array[i] != NULL) {
                char addr2line_cmd[256];
                sprintf(addr2line_cmd, "addr2line -f -e ./myprogram %p", array[i]);
                FILE* addr2line_fp = popen(addr2line_cmd, "r");
                if (addr2line_fp != NULL) {
                    char line[256];
                    while (fgets(line, sizeof(line), addr2line_fp) != NULL) {
                        fprintf(fp, "%s", line);
                    }
                    pclose(addr2line_fp);
                }
            } else {
                fprintf(fp, "(unknown)\n");
            }
        }
        fclose(fp);
    }

    _exit(1); // exit the program
}

int main() {
    // register signal handler
    signal(SIGSEGV, signal_handler);

    // your code here

    return 0;
}

在这个代码中,对于每一个函数调用地址,使用 sprintf 函数构造一个 addr2line 命令,并使用 popen 函数执行该命令,将输出结果输出到日志文件中。addr2line 命令中的 -f 选项表示输出完整的函数名和文件名,-e 选项指定可执行文件的路径。

使用 addr2line 命令查询符号表可以更方便地查找堆栈跟踪信息中对应的代码位置,但需要注意的是,该方法可能会降低程序的执行效率,并且符号表信息可能需要单独维护和更新。

转换

将"crash.log"经过符号表的查找后,输出的信息会将堆栈跟踪信息中的函数名和行号转换为具体的代码位置。

例如,假设原始的"crash.log"内容如下:

Error: signal 11
[0] ./myprogram(+0x1139) [0x563a21a8f139]
[1] ./myprogram(+0x1298) [0x563a21a8f298]
[2] /lib/x86_64-linux-gnu/libc.so.6(+0x3ef20) [0x7f9c1d069f20]
[3] ./myprogram(+0xe75) [0x563a21a8ee75]
[4] /lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xf3) [0x7f9c1cf9e0b3]
[5] ./myprogram(+0x992) [0x563a21a8e992]

经过符号表查找后,可以得到每个函数调用地址对应的函数名和行号。例如,假设经过符号表查找后,对应的函数名和行号如下:

./myprogram: foo() at example.cpp:27
./myprogram: bar() at example.cpp:36
/lib/x86_64-linux-gnu/libc.so.6: __GI___libc_read() at ../sysdeps/unix/syscall-template.S:185
./myprogram: main() at example.cpp:45
/lib/x86_64-linux-gnu/libc.so.6: __libc_start_main() at ../csu/libc-start.c:308
./myprogram: _start() at ???:0

然后,将符号表中获取到的函数名和行号替换原始"crash.log"中的函数地址信息,重新输出日志信息,得到类似以下的内容:

Error: signal 11
[0] ./myprogram(foo+0x1d) [0x563a21a8f139]
    at example.cpp:27
[1] ./myprogram(bar+0x18) [0x563a21a8f298]
    at example.cpp:36
[2] /lib/x86_64-linux-gnu/libc.so.6(__GI___libc_read+0x10) [0x7f9c1d069f20]
    at ../sysdeps/unix/syscall-template.S:185
[3] ./myprogram(main+0x25) [0x563a21a8ee75]
    at example.cpp:45
[4] /lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0xf3) [0x7f9c1cf9e0b3]
    at ../csu/libc-start.c:308
[5] ./myprogram(_start+0x2a) [0x563a21a8e992]
    at ???:0

其中,函数地址信息被替换为了具体的函数名和行号,使得日志信息更加清晰和易读。

上传进程的创建

在程序崩溃后,程序的状态是不确定的,可能会存在一些问题,如内存泄漏、资源未释放等。因此,直接在崩溃后的程序中上传服务器可能会存在风险,可能会加重程序的负担,导致程序异常退出。因此,建议采用新创建一个上传的进程,单独去做的方式。

使用新进程上传可以实现程序的隔离,避免上传过程中对原程序的影响。同时,由于新进程的独立性,上传过程中出现异常不会影响原程序的正常运行。此外,还可以对上传进程进行单独的优化和配置,以确保上传过程的高效和可靠。

需要注意的是,上传进程应该尽可能地轻量级,以避免对系统资源的浪费。同时,在上传过程中要注意数据的传输安全,以及上传数据的完整性和准确性,以确保开发者能够及时准确地获取到程序崩溃的信息。

fork

fork() 是在 Unix/Linux 系统中用于创建新进程的系统调用,可以通过它创建一个与原进程完全相同的子进程。子进程将执行原进程的副本,包括原进程的代码、数据、堆栈和文件描述符等。

在 C++ 程序中,可以使用 fork() 系统调用创建新进程,以下是一个简单的示例:

#include <iostream>
#include <unistd.h>

int main() {
    pid_t pid = fork(); // 创建新进程

    if (pid == -1) { // 创建失败
        std::cerr << "Failed to fork a new process." << std::endl;
    } else if (pid == 0) { // 子进程
        // 在子进程中进行上传操作
        std::cout << "This is the child process." << std::endl;
    } else { // 父进程
        // 继续执行原程序
        std::cout << "This is the parent process." << std::endl;
    }

    return 0;
}

上述代码中,调用 fork() 后会返回两个值:在父进程中,fork() 返回新创建子进程的进程 ID;在子进程中,fork() 返回 0。通过这个返回值,可以在父进程和子进程中分别执行不同的代码。

在上述代码中,如果 fork() 调用失败,则会输出错误信息;如果成功创建了子进程,则在子进程中进行上传操作;如果在父进程中,则继续执行原程序。

exec

在子进程中,可以使用 exec() 系列函数来执行二进制文件或其他可执行文件。exec() 系列函数将子进程的执行替换为新程序的执行,从而实现了进程的替换。

以下是一个简单的示例,演示了如何使用 fork() 和 exec() 来创建并执行一个新进程:

#include <iostream>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork(); // 创建新进程

    if (pid == -1) { // 创建失败
        std::cerr << "Failed to fork a new process." << std::endl;
    } else if (pid == 0) { // 子进程
        // 在子进程中执行新的程序
        execl("/bin/ls", "ls", "-l", nullptr); // 执行 ls -l 命令
        std::cerr << "Failed to exec the new process." << std::endl; // execl函数执行失败,输出错误信息
    } else { // 父进程
        // 等待子进程执行完毕
        int status;
        waitpid(pid, &status, 0);
        std::cout << "The child process has exited with status: " << WEXITSTATUS(status) << std::endl;
    }

    return 0;
}

上述代码中,在子进程中使用了 execl() 函数来执行 /bin/ls 程序,传入了 ls 和 -l 两个参数。如果 execl()函数执行成功,则会替换子进程的执行为 /bin/ls 程序的执行,并输出相应的结果;如果执行失败,则会输出错误信息。

在父进程中,调用 waitpid() 函数等待子进程执行完毕,并获取子进程的退出状态。最后输出子进程的退出状态。

需要注意的是,exec() 系列函数执行成功后,将不会返回,因此在调用 execl() 之后的代码将不会被执行。如果需要在子进程中进行其他操作,需要在 exec() 函数执行之前创建子进程并进行相应的操作。

上报策略

  1. 上报时机:当程序发生崩溃时,应立即上报崩溃现场信息日志。可以使用前面提到的信号量机制来捕获崩溃信息,然后立即将信息上传到云端,以便开发者及时获得崩溃信息并进行调试。此外,也可以在程序发生崩溃后,在崩溃处理完毕后再进行上报,但这会延迟开发者的调试时间,不太推荐。

  2. 上报周期:可以设置定期上报崩溃现场信息日志,以便及时发现程序中存在的问题。一般建议将上报周期设置为1天或更短,以确保及时发现程序中存在的问题。

  3. 上报数量:为避免数据量过大,一般可以设置每次上传的日志数量。可以将多个崩溃信息日志合并为一个文件进行上传,或者将日志进行压缩,以减小上传的数据量。

  4. 上报优先级:对于重要的崩溃现场信息日志,可以设置更高的上传优先级,以便开发者及时获得相关信息。可以采用异步上传的方式,将重要的日志先行上传,以确保信息的及时性。

  • 4
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值