黎明灰烬 博客 +

Google Breakpad:脱离符号的调试工具

一点说明:
这其实是原始文档的一点中文摘录。原本已经从博客中删去(因为我不准备将博客变成一个「科普」性质的存档站点),但 Google 告诉我这篇文章似乎对大家还是有点用的,于是将其还原在这里。
有两点值得注意:第一,我本人没有在项目中实际使用过 Breakpad,因此无法解答任何实践方面的问题(有人写了 Windows 平台的实操指南);第二,尽管有很多项目都使用了 Breakpad ,我并不认为这会给项目的质量带来任何显著的影响——如果触发 bug 的场景比较复杂,那么对于复杂 bug 即使本地调试进展也不会很快,更不用说使用 Breakpad 抓取的信息了;而如果触发 bug 的场景比较简单,那么这样的 bug 应当在产品发布之前就被修复,而不是依赖于 Breakpad 这样的发布后辅助调试手段。
那么,为什么还有相当多的项目使用了 Breakpad 呢?两个原因:第一是这些程序员闲着没事做,为了月末能给老板汇报,就花点时间集成一下;第二是这些程序员水平太低,用了 Breakpad 之后可能会显得自己水平高。
个人认为 Breakpad 是一个有趣但没什么实际作用的工具。

Breakpad 是一套用于抓取应用崩溃数据的工具。 Breakpad 可以在移除编译器调试信息后,抓取、压缩 minidump 信息,将其发送回你的服务器,然后为 C/C++ 生成调用栈。

尽管 Google Breakpad 将自己定位成一个崩溃报告工具, 但实际上它更像一个调试辅助工具。 Breakpad 的特点主要在于崩溃报告部分支持无符号抓取。 整套工具实现了在客户使用无符号的发布版应用前提下, 开发者也能以较低代价恢复应用崩溃现场的调用栈。

Introduction

现有的崩溃报告系统均有所不足。 GNOME 的 Bug-Buddy 和 Apple 的 CrashReporter 在生成崩溃快照时需要符号信息。 微软的 Windows Error Report 和 SupportSoft 的 Talkback 只传送崩溃进程的状态。

Breakpad 可以在移除编译器调试信息后,抓取、压缩 minidump 信息,将其发送回你的服务器,然后为 C/C++ 生成调用栈。

Google Breakpad

三大组件

Breakpad 包含三大组件:

minidump 的文件格式

minidump 是微软开发的和核心文件类似的文件格式。它包含:

minidump 概览

默认情况下,Breakpad 初始化时注册一个异常或信号处理函数, 该函数能在异常发生时生成 minidump。 注册方法依赖于平台:Windows 中使用 SetUnhandledExceptionFilter(); OS X 中创建一个线程来等待 Mach 例外端口;Linux 中安装一个能处理像 SIGSEGVSIGILL 之类例外的信号处理函数。

上传崩溃现场的方法也有所不同:在 Windows 和 Linux 中, 调用一个独立的函数库上次;在 OS X 中, 会有一个进程来请求用户授权上传操作。

考虑到在崩溃进程中抓取信息可能会破坏现场, 总是在新创建的进程中抓取数据。

Breakpad 组件

src 目录下有三个目录:

src/tools/{platform}/dump_sys 用于在给库去符号之前生成 Breakpad 符号文件。

Breakpad Client 库

Breakpad Client 库用于监视应用的崩溃,并收集、上传数据。 这主要通过异常/信号处理函数实现。

由于应用运行在的处理器和操作系统可能不同,Client 的设计变化较大。 Client 库和用来发送快照的库会链接成应用的一部分。 异常在不同平台有不同表现,Breakpad 在 ExceptionHandler 对象中设置了处理函数。具体的内部实现依平台而异。

异常处理原则

在处理异常时要高度小心,因为系统已经处于不可预知状态。 Breakpad 在设计中遵循了以下原则来确保安全:

抓取异常现场的机制

总体而言,当出现异常时,Breakpad 使用一个处理线程来保护异常线程的状态。

异常发生时,异常线程很难抓去自己的状态,有时甚至根本不可能。 同时,在栈溢出异常发生时,在一个线程中处理所有的异常也非常困难。 在处理异常时再发生异常是很危险的,因为此时系统资源分配很棘手。

Breakpad 在注册异常处理函数时就创建了处理线程。 在 Mac OS X 中,处理线程在初始化应用时就创建了。 当异常发生时,该线程会直接收到异常事件。 在 Windows 和 Linux 中,异常会传递给处理线程中的一小段代码。

Breakpad 经过精心设计,即使处理栈溢出异常也不会超出栈保护页。

用户也可随时可直接调用处理函数。

抓取异常现场的步骤

当异常发生时,Breakpad 抓取异常现场会经历多个阶段: 事件过滤、现场抓取、快照生成和快照上传。

Breakpad 可使用回调方法支持事件过滤, 从而帮助开发者忽略掉不感兴趣的崩溃事件。 当异常发生时,Breakpad 会使用开发者自定义的回调方法来 检查是否要监测当前的崩溃信息。 如果不需要, Breakpad 会将异常传递给其他的处理函数。

Breakpad 抓取异常现场(包括各个线程处理器状态、上下文、 栈、已加在的库和代码段等)后,将其存入快照中。 快照文件甚至使用了一种防止文件名冲突的机制。

在快照生存后,Breakpad 会调用第二个回调函数(第一个是事件过滤)。 这个函数主要完成崩溃报告工作,同时可以再收集一些应用数据。 它甚至能进行一些处理,使得 Breakpad 好像从来没有运行过。 这种功能让开发者可以同时使用 Breakpad 和传统的调试技术。 使用这个回调函数也应当小心谨慎,因为,进程早已崩溃。

最后,Breakpad 使用 HTTP POST 请求向指定的地址发送崩溃数据。 在 Linux 中,这一工作中通过 liburl 完成。

Breakpad Processor 库

Breakpad Processor 库用于从 minidump 生成跟踪栈。

当 processor 的 MinidumpProcessor 类获得一个 minidump 文件时, 它会用 Minidump 的类来读取。

在得到基本的信息后,会用 Stackwalker 来搜寻每个线程的栈,这个过程会生成 线程上下文、调试数据、包含指令的栈帧。 线程的这些信息能够“重现”进程,然后用 SymbolSupplier 来定位符号文件。 SourceLineResolver 拿到符号文件后生成和栈帧对应的调试信息,可能能精确到行号。

处理结果构成一个 ProcessState 对象,包含了线程及其栈帧的向量。

Breakpad 异常处理

Breakpad 使用 SetUnhandledExceptionFilter 作为所有没有作特殊处理的异常的处理函数。 处理异常主要有两种方法:进程内和进程外。 两者的区别就是异常处理函数和异常函数是否处于同一进程。

进程内处理机制相对简单一点。 但由于异常发生时进程已经处于不安全状态,抓取过程中可能会写坏处理函数, 因此需要进程外处理。

Linux 系统调用

有时候,应用会在系统调用中崩溃。这里对 Breakpad 处理这种情况的方法作简要介绍。

Linux 中的系统调用是一系列帮助用户使用内核功能的特殊库 linux-gate.so。 内核为所有进程映射了该库。 而系统调用使用的调用门函数 kernel_vsyscall 并未用 EBP 存储栈帧指针。 不过 Breakpad processor 可以扫描符号文件中的 STACK 行来支持特殊的栈帧。 Breakpad 源码目录 src/client/data 下有 linux-gate.so 的符号文件。 如果线程在系统调用中崩溃,那么扫描调用栈时就会用到该文件。

EIP 处在 kernel_vsyscall 中时, kernel_vsyscall 会将数据压入栈。 通过检查压入栈的数据量,processor 就能知道怎么找到上一个栈帧。 例如,符号文件有下面一行:

MODULE Linux x86 random_debug_id linux-gate.so 
PUBLIC 400 0 kernel_vsyscall 
STACK WIN 4 100 1 1 0 0 0 0 0 1

其中,PUBLIC 这一行表示 kernel_vsyscall 相对 linux-gate.so 启使位置有 400 字节。 STACK 一行的数据分别表示:100,函数大小;1 ,压栈大小; 1 ,出栈大小;最后一个 1 表示 EBP 在被该函数使用前就压入了栈。

注意:这些函数可能和内核版本相关。尽管栈信息可能保持相对稳定, 但 kernel_vsyscall 的偏移可能会发生变化,从而导致符号文件无效。

Linux 异常处理

Breakpad 使用用户独立的守护进程来抓取 minidump 。 一方面,不需要在每次启动支持 Breakpad 应用时产生新的进程。 另一方面,各个进程的数据相互独立,保证了安全性。

当一个进程的 Breakpad 初始化时,它会检查守护进程是否已经 启动,如果没有则启动。检查和启动操作的竞争并不会导致新的问题, 守护进程会检查守护服务器是否在监听。 即使有多个守护进程启动, 他们中也只有一个进程会成功地用 bind() 向文件系统绑定 socket,其他的都会自动退出。这种情况和错误处理有点相似。 即使双方都断开 socket 连接, Linux 也不在文件系统清除它。 因此,检查 socket 连接是否存在还不够。 虽然如此,检查处理列表或者发送 ping 询问就能解决这个问题。

Breakpad 之所以使用 socket 是因为它是全双工的。 当客户机调用 connect() 时,内核就为客户机和服务器建立 socket 连接。

守护线程执行时会使用 ptrace()/proc,大体的流程是:

  1. 操作系统发出信号,表示进程崩溃了
  2. 信号处理函数暂停所有其他线程
  3. 信号处理函数向服务器发送 CRASH_DUMP_REQUEST,等待回应
  4. 服务器介入,生成并将 minidump 异步地写入磁盘
  5. 服务器发回操作完成的信息

从崩溃快照中恢复调用栈

本小节主要介绍 Breakpad 如何结合崩溃快照 minidump 和符号文件从而生成崩溃进程调用栈。 恢复过程主要分为两个阶段:准备处理调用栈和恢复线程的调用栈。

准备处理调用栈

实例化 MinidumpProcessor 类,调用 MinidumpProcessor::Process 方法时开始处理调用栈。 MinidumpProcessor 的构造函数需要两个参数: SymbolSupplierSourceLineResolverInterfaceSymbolSupplier 用于检索 minidump 所对应的符号文件; SourceLineResolverInterface 利用该符号文件来生成栈帧,并找到调用者。

这个过程还会从 minidump 中生成有助于恢复调用栈的 一些其他信息,如:线程列表 MinidumpThreadList 、 已加载的模块 MinidumpModuleList 、导致崩溃的异常 MinidumpException

接下来,从 minidump 中为 MinidumpThreadList 中的所有线程生成更为详细的信息。还会生成:线程的内存 MinidumpMemoryRegion (包括栈)、CPU 上下文 MinidumpContext 。 如果是某线程出发了进程崩溃,那么他的 CPU 上下文 MinidumpContext 会从 MinidumpException 生成。

在这之后,Breakpad 就将所有这些数据、方法传递给 Stackwalker::StackwalkerForCPU 方法。 该方法会根据崩溃的平台来选择不同的子类,并返回一个 Stackwalker 子类实例。

恢复线程的调用栈

当上述的 Stackwalker 实例返回后,Breakpad processor 就调用 Stackwalker::Walk 生成能代表某个线程的一系列栈帧。 Stackwalker 调用 GetContextFrame 方法返回栈顶的栈帧 StackFrameStackFrame 还包含初始的 CPU 状态。

然后,调用栈恢复器针对 每个栈帧 执行下列 5 个步骤。

检索模块

通过调用模块列表的 GetModuleForAddress 方法, 可以根据当前栈帧的指令指针地址来确定当前使用的是哪个模块。

定位符号文件

找到模块后,调用 SymbolSupplier::GetCStringSymbolData 方法来定位符号文件。这一般通过将模块的调试文件名和调试 标识符作为搜索关键字实现。SimpleSymbolSupplier 直接将这个关键字作为文件路径的一部分在磁盘上定位符号文件。

加载符号

在找到符号文件后,Breakpad 使用 SourceLineResolverInterface::LoadModuleUsingMemoryBuffer 方法来夹在符号文件。BasicSourceLineResolver 类主要将文本格式的符号文件解析成特定的数据结构, 从而使得检索一些数据时更加方便。 受到影响主要包括:函数名的地址、源代码的行号和其他信息。

获取源码行号

当符号文件成功加载,SourceLineResolverInterface::FillSourceLineInfo 能生成当前栈帧的函数名和在源代码中的行号。 用当前栈帧中的指令指针减去模块基地址汇得到一个相对地址, 该地址即是模块中的指令的相对偏移地址。 由于上一步生成的函数表包含函数的地址信息, 用该地址在函数表中搜索就能知道当前栈帧是在执行哪个函数。 类似的,根据这个偏移地址,可以继续在原文件行号信息中定位到某一行。

如果没能在当前模块中找到行号,那么将搜索其他符号文件的公共符号 (带有 PUBLIC 标记的行)。 由于公共符号只有起始地址, 因此只要找到比偏移地址小,且最为接近的符号即可。

查找调用栈帧

现在,当前栈帧的信息已经生成,Breakpad 以当前栈帧为参数, 使用 Stackwalker::GetCallerFrame 来寻找栈中的下一帧,即调用者的栈帧。 值得注意的是,尽管不同平台上该方法的实现机制可能不同,但仍有共通点。

首先是用 SourceLineResolverInterface 解析出更为详细的现场信息, 通过 FindCFIFrameInfo 完成。 这里会用到从二进制文件中解析出的 DWARF CFI 信息,包括地址区域信息。

找到解析信息后,根据当前寄存器状态和线程栈内存, 就能恢复调用者栈帧的寄存器状态。 如果没找到解析信息,Stackwalker 会尝试其他方法。 在某些平台上,会尝试对栈帧指针去引用来生成栈帧指针。

如果没能在其他方法中找到调用者的栈帧, 大多数平台上的 Stackwalker 会做两部分工作:

如果找到了调用者栈帧,则将该栈作为当前栈继续操作。 如果实在找不到,活着栈帧是非法的,调用栈恢复过程会立即停止。

在 Linux 应用中使用 Breakpad

构建和集成 Breakpad

运行 ./configure && make 会在源码目录下生成库 src/client/linux/libreakpad_client.a。 应用可以使用这个库来生成 minidump

编译应用时链接 libreakpad_client.a 并使用头文件 src/client/linux/handler/exception_handler.h。 现在,实例化 ExceptionHandler 对象就可以处理例外了。 因为这种处理机制在 ExceptionHandler 对象生命周期中生效,应当尽早初始化。

ExceptionHandler 的构造函数至少有两个参数:

注意:尽量不要在回调函数中做大量工作,因为此时进程处于不安全状态。 最好是用 forkexec 产生新进程来继续工作。 如果你非要这么干,可以使用 Breakpad 的源码中 src/third_party/lss 的一系列重写的 libc 函数和系统调用包装。

Breakpad 还有一些 HTTP 上传的源代码可以参考。

集成的示例

static bool dumpCallback(const google_breakpad::MinidumpDescriptor& descriptor,
        void* context, bool succeeded)
{
    printf(Dump path: %s\n, descriptor.path());
    return succeeded;
}

void crash()
{
    volatile int* a = (int*)(NULL);
    *a = 1;
}

int main(int argc, char* argv[])
{
    google_breakpad::MinidumpDescriptor descriptor(/tmp);
    google_breakpad::ExceptionHandler eh(descriptor, NULL, dumpCallback, NULL, true, -1);

    crash();

    return 0;
}

生成应用的符号文件

生成可读调用栈的前提条件是由符号文件。 符号文件可以通过以下方法生产:

  1. 在编译应用的二进制代码时使用 -g 选项
  2. ./configure && make 编译 dump_sys
  3. dump_sys 生成符号文件,如 $ ./dump_sys ./my-binary > my.sym

在使用 minidump_stackwalk 前,要将符号文件放到文件第一行指定的目录中。 可以参考 Mozilla 仓库里的 symbolstore.py 或下面的示例。

$ head -n1 test.sym
MODULE Linux x86_64 6EDC6ACDB282125843FD59DA9C81BD830 test

$ mkdir -p ./symbols/test/6EDC6ACDB282125843FD59DA9C81BD830

$ mv test.sym ./symbols/test/6EDC6ACDB282125843FD59DA9C81BD830

生成跟踪栈

google-breakpad/src/processor 目录下找到 minidump_stackwalk。 下面的命令生成跟踪栈,并打印。

$ ./minidump_stackwalk minidump.dump ./symbols

黎明灰烬 博客

Creative Commons License 信箱:i(at)jackwish.net

计算机

清 谈