eCrash:无需 Core Dump 的调试

作者:David Frascone

嵌入式 Linux 在嵌入式编程和高级 UNIX 编程之间架起了一座很好的桥梁。它拥有完整的 TCP 协议栈、良好的调试工具和强大的库支持。这造就了一个功能丰富的开发环境。然而,有一个缺点。如何在现场调试发生的问题呢?

在功能齐全的操作系统上,使用 core dump 来调试现场发生的问题很容易。

在非嵌入式 UNIX 系统上,当程序遇到异常时,它会将其当前所有状态输出到文件系统上的一个文件中。这个文件通常被称为 core。这个 core 文件包含了程序在发生故障时使用的所有内存。这允许进行事后调查以诊断异常。

通常,在嵌入式 Linux 系统上,没有(或很少有)持久性磁盘存储。在我工作过的所有系统上,RAM 都比持久性存储多。因此,获取 core dump 是不可能的。本文描述了一些 core dump 的替代方案,这些方案将允许您执行事后调试。

程序可能因异常以外的原因而失败。程序可能会死锁,或者它们可能具有失控线程,这些线程会耗尽所有系统资源(内存、CPU 或其他固定资源)。在这些情况下生成某种持久性崩溃文件也是有益的。

要求

因此,首先我们需要确定要保存的信息。由于内存限制,保存进程的所有内存不是一个可行的选择。如果可以,您只需使用 core dump!但是,我们可以保存其他非常有用的信息。首要的是失败线程的回溯。

回溯是到达程序当前位置所调用的函数列表。即使在没有系统内存和数据的情况下,回溯也可以揭示故障发生时的情况。

许多嵌入式系统也具有日志:错误、警告和指标列表,让您了解发生了什么。在事后转储失败前最后几个日志对于查找故障的根本原因来说是无价的资产。

在复杂的、多线程的系统中,您通常有许多互斥锁。在发生死锁的情况下,显示所有进程的互斥锁和信号量的状态可能很有用。

显示内存使用统计信息也可能有助于诊断问题。

一旦我们确定了要保存的信息,我们仍然需要确定将其保存在哪里。这会因系统而异。如果您的系统根本没有持久性存储,也许您可以将崩溃信息输出到串行终端或在 LCD 显示屏上显示。(我们在那里有严重的空间限制!)如果您的系统有 CompactFlash,您可以将其保存到文件系统。或者,如果它有原始闪存(MTD 设备),您可以将其保存到 jffs2 文件系统,或者可能保存到一个或两个原始扇区。

如果崩溃不太严重,也许可以将崩溃信息上传到 tftp 服务器或发送到远程 syslog 设备。

现在我们已经牢牢掌握了我们想要保存什么,以及可以将其保存到哪些位置,让我们来谈谈我们将如何做到这一点!

回溯

一般来说,获取回溯并不像听起来那么简单。访问系统寄存器(如堆栈指针)因架构而异。值得庆幸的是,FSF 在 GNU 的 C 标准库中为我们提供了帮助(请参阅在线资源)。Libc 具有三个函数,可以帮助我们检索回溯:backtrace()、backtrace_symbols() 和 backtrace_symbols_fd()。

backtrace() 函数使用当前线程的回溯填充指针数组。总的来说,这对于调试来说足够了,但不是很漂亮。

backtrace_symbols() 函数接受由 backtrace() 填充的信息,并返回符号名称(函数名称)。backtrace_symbols 的唯一问题是它不是异步信号安全的。backtrace_symbols() 使用 malloc()。由于 malloc() 使用自旋锁,因此从信号处理程序调用它是不安全的(可能会导致死锁)。

backtrace_symbols_fd() 函数尝试解决与 malloc 相关的信号问题,并将符号信息直接输出到文件描述符。

在信号处理程序内部工作

libc 中的某些函数本身依赖于信号:某些 IO 操作、内存分配等等。因此,我们在处理程序内部应该做的事情非常有限。在我们的例子中,我们可以稍微作弊一下。因为我们的程序已经崩溃了,所以死锁并不是那么大的问题。我的示例中的代码使用了几个不允许的函数,例如 fwrite()、printf() 和 sprintf()。但是,我们可以努力避免一些容易导致死锁的函数,例如 malloc() 和 backtrace_symbols()。

在我看来,我们最大的损失是失去了 backtrace_symbols。但是,这里事情变得更容易了。您始终可以实现自己的符号表,并从指针本身查找函数。

在我的示例中,我有时使用 backtrace_symbols()。我还没有遇到过死锁,但是确实有可能发生。

简单的回溯处理程序

那么,崩溃处理程序是什么样的呢?要获取回溯,我们首先需要捕获我们的信号。一些常见的信号是 SIGSEGV、SIGILL 和 SIGBUS。此外,abort() 通常在断言的情况下被调用,并生成 SIGABRT。

然后,当信号发生时,我们需要保存我们的回溯。以下代码片段详细说明了一个简单的回溯函数,该函数在发生崩溃时将回溯显示到标准输出

void signal_handler(int signo)
    {
       void *stack[20];
    int count, i;

    // Shouldn't use printf . . . oh well
    printf("Caught signal %d\n");

    count = backtrace(stack, 20);
        for (i=0; i < count; i++) {
      printf("Frame %2d: %p\n", i+1, stack[i]);
    }
}
int main(...)
{
  ...
  signal(SIGBUS, signal_handler);
  signal(SIGILL, signal_handler);
  signal(SIGSEGV, signal_handler);
  signal(SIGABRT, signal_handler);
}

Caught signal 11
Frame  1:  0x401a84
Frame  2:  0x401d88
...

这是一个类似的信号处理程序,但它使用 backtrace_symbols 来打印更漂亮的回溯

void signal_handler(int signo)
    {
       void *stack[20];
           char **functions;

    int count, i;

    // Shouldn't use printf . . . oh well
    printf("Caught signal %d\n");

    count = backtrace(stack, 20);
            functions = backtrace_symbols(stack, count);
         for (i=0; i < count; i++) {
      printf("Frame %2d: %s\n", i, functions[i]);
    }
            free(functions);
}

Caught signal 11
Frame  1:  ./a.out [0x401a84]
Frame  2:  ./a.out [0x401bfa]
...

eCrash—通用崩溃处理程序

在撰写本文时,我意识到这大约是我第五次编写崩溃处理程序了。(为什么所有软件不能都是开源的?)因此,我决定编写一个快速库来处理崩溃转储,并为本文提供它。我喜欢我开始使用的小库,但我发现自己需要越来越多的功能。随着我不断扩展它,我意识到这是一个非常有用的库,我希望能够在任何未来的项目中使用它!

我将新库命名为 eCrash,并为其创建了一个 SourceForge 站点(请参阅资源)。从那时起,我一直在扩展它,它现在支持转储多个线程——仅使用 backtrace,使用 backtrace 和 backtrace_symbols,以及使用带有用户提供的符号表的 backtrace,以避免 backtrace_symbols 内部的 malloc()。本文的其余示例将使用 eCrash。

eCrash 相对容易使用。您首先从父线程调用 eCrash_Init()。如果您有一个单线程程序,您已经完成了。将根据参数结构中的设置传递回溯。

如果您有一个多线程程序,则任何希望在崩溃中被回溯的线程(崩溃线程以外的线程)也必须调用 eCrash_RegisterThread()。在发生崩溃时转储所有线程的堆栈有时很有用,而不仅仅是崩溃线程的堆栈。

使用 eCrash,您可以通过设置文件描述符(异步安全写入)、FILE * 流(非异步安全)和/或在发生崩溃时输出的文件的文件名来指定输出应该去哪里。eCrash 将写入提供的所有目标。

eCrash—从其他线程收集堆栈

没有崩溃的线程获取堆栈有点棘手。当线程注册时,它会指定一个线程不捕获或阻止的信号。eCrash 为该信号(称为回溯信号)注册一个处理程序。

当 eCrash 需要转储线程时(当其他线程导致异常时),它通过 pthread_kill() 向该线程发送回溯信号。当捕获到该信号时,线程将其回溯保存到全局区域并继续执行。然后,主异常处理程序可以读取堆栈并显示它。

我们最终得到的是一个非常漂亮的崩溃转储,它准确地显示了系统在发生故障时正在发生的事情。

eCrash—真实世界示例

说够了——是时候来点干货了。现在,让我们把我们讨论的内容付诸实践。我们将使用 eCrash 中包含的 ecrash_test 程序。该程序旨在破坏其任何一个线程(通过尝试写入 NULL 指针来生成分段错误)。

我们使用以下标志执行测试程序

ecrash_test --num_threads=5 --thread_to_crash=3

这会导致测试程序生成五个线程。除了线程 3 之外的所有线程都将调用几个函数,然后进入睡眠状态。线程 3 将调用几个函数(使回溯更有趣)并崩溃。

生成的崩溃文件如清单 1 所示,backtrace_symbols() 如清单 2 所示。由于空间限制,本文的所有清单都在 Linux Journal FTP 站点上提供 (ftp://ftp.linuxjournal.com/pub/lj/listings/issue149/8724.tgz)。

崩溃文件包含我们有问题的线程(导致分段错误的线程)的回溯以及系统上所有线程的回溯。

现在是调试崩溃的时候了。我们将像崩溃发生在远程站点并且系统管理员通过电子邮件向您发送此崩溃文件一样进行调试。

最后一件事:在现实世界中,可执行文件总是被剥离调试信息。但是,没关系。只要您保留一份带有调试信息的程序副本,您就可以发布剥离后的代码副本,并且一切仍然可以正常工作!

因此,在实验室中,您拥有崩溃文件和带有调试信息的程序。运行gdb在程序的调试版本上。我们知道我们有分段错误。因此,从有问题线程的帧零开始,开始列出代码,如清单 3 所示(请参阅 LJ FTP 站点)。

  • 帧 0 在我们的崩溃处理程序内部——这里没什么可看的。

  • 帧 1 也在崩溃处理程序内部。

  • 帧 2 仍然在我们的崩溃处理程序内部。

  • 帧 3 显示没有源文件(它在 libc 内部)。

  • 帧 4 显示实际崩溃(在 crashC 内部)。

  • 帧 5 显示 crashB。

  • 帧 6 显示 crashA。

  • 帧 7 显示 ecrash_test_thread。

  • 帧 8 和 9 是线程在 libc 中创建的位置。

如您所见,使用 gdb 显示函数指针有一个技巧。只需给它一个地址并在列表中取消引用它

(gdb) list *0xWHATEVER

这也适用于 symbolic_names 和偏移量

(gdb) list *main+100

好的,那是我们崩溃的线程,但是其中一个休眠线程呢?检查线程 5 的回溯,清单 4(请参阅 LJ FTP 站点)

  • 帧 0 在我们的回溯处理程序内部。

  • 帧 1 仍在处理程序内部。

  • 帧 2 在 libc 中。

  • 帧 3 在 libc 中。

  • 帧 4 在 libc 中。

  • 帧 5 在 sleepFuncC 内部——它将 for 语句显示为程序计数器,因为我们在 sleep() 函数之外。这是值得注意的,因为发送到告诉线程转储其堆栈的异步信号导致 sleep() 提前退出。

  • 帧 6 显示 sleepFuncB。

  • 帧 7 显示 sleepFuncA。

  • 帧 8 显示 crash_test_thread。

  • 帧 9 是线程在 libc(或 libpthread)中创建的位置。

因此,此线程是休眠线程之一。没什么可看的,但在某些情况下,此线程的信息对于发现崩溃的原因可能至关重要。

其他有用的崩溃信息

现在我们已经彻底讨论了什么是回溯,如何生成回溯,显示回溯的不同方法以及如何使用回溯调试崩溃,现在是时候改变方向了。崩溃文件可以包含更多信息

  • 互斥锁的状态(谁持有锁——对于死锁诊断很有用)。

  • 当前错误日志。

  • 程序统计信息。

  • 内存使用情况。

  • 最近的网络数据包。

以上一些项目可能是事后调试的有用信息。但是,有一个需要注意的地方。因为我们遇到了异常,所以有些地方出了严重问题。我们的数据结构可能已损坏。我们可能内存不足(或耗尽)。

此外,某些线程可能会死锁,等待我们的崩溃线程持有的互斥锁。

因为我们想要显示的某些数据可能会生成另一个异常(如果它已损坏),所以我们希望首先显示最重要的信息,然后显示越来越多不安全的信息。此外,为了防止信息丢失,应始终刷新 FILE* 流上的缓冲区。

结论

诊断已部署的嵌入式系统上的问题可能是一项艰巨的任务。但是,在发生异常时选择要保存或显示的正确数据可以使任务变得容易得多。

使用相对较小的存储空间或远程服务器,您可以保存足够的事后信息,以便能够在系统中找到故障。

本文的资源: /article/9139

David Frascone (dave@frascone.com) 在思科系统公司无线业务部门工作。他目前正在从事下一代控制器设计。

加载 Disqus 评论