外观
用 backtrace + addr2line 定位 C/C++ 程序崩溃
gdb 在代码调试,定位 bug 上十分强大,但在集群上有时使用受限。很多集群登录节点不允许跑计算程序,计算节点才能运行程序,但 gdb 是交互式工具,所以会遇到问题:
登录节点能开 gdb,但程序不能跑; 计算节点能跑程序,但不好交互
这里介绍一套轻量级方案:在程序内部用 backtrace 捕获调用栈,再用 addr2line 将地址翻译成源码文件和行号。 backtrace + addr2line 的组合只需要:
- 编译时加
-g - 运行时打印地址,事后离线分析
一、backtrace:运行时手动捕获调用栈
backtrace 是 GNU C 库(glibc)提供的函数,头文件为 <execinfo.h>。
核心函数
#include <execinfo.h>
// 将当前调用栈的返回地址写入 buffer,最多写 size 个
int backtrace(void **buffer, int size);
// 将地址数组转成可读字符串(格式:模块(符号+偏移) [地址])
char **backtrace_symbols(void *const *buffer, int size);
// 同上,但直接写到文件描述符,不做 malloc(信号处理中更安全)
void backtrace_symbols_fd(void *const *buffer, int size, int fd);最小示例
#include <execinfo.h>
#include <stdio.h>
#include <unistd.h>
void print_backtrace(void)
{
void *buffer[64];
int n = backtrace(buffer, 64);
fprintf(stderr, "--- backtrace (%d frames) ---\n", n);
backtrace_symbols_fd(buffer, n, STDERR_FILENO);
}
void foo(void) { print_backtrace(); }
void bar(void) { foo(); }
int main(void)
{
bar();
return 0;
}编译和运行:
gcc -g -rdynamic -no-pie -o demo demo.c
./demo-rdynamic 让链接器把所有符号导出到动态符号表,backtrace_symbols 才能解析函数名;-no-pie 禁用位置无关可执行文件(position-independent-executable),使输出的地址为绝对地址,可直接传给 addr2line(否则需要计算偏移)。输出类似:
--- backtrace (7 frames) ---
./demo(print_backtrace+0x32)[0x4011c8]
./demo(foo+0xd)[0x401232]
./demo(bar+0xd)[0x401242]
./demo(main+0xd)[0x401252]
/lib/x86_64-linux-gnu/libc.so.6(+0x2a1ca)[0x7d0ad722a1ca]
/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x7d0ad722a28b]
./demo(_start+0x25)[0x4010d5]方括号里的就是我们需要的地址。
二、在信号处理中捕获崩溃
实际应用中,程序崩溃往往来自段错误(SIGSEGV)。可以注册一个信号处理函数,在退出前打印调用栈:
#include <execinfo.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
static void crash_handler(int sig)
{
void *buffer[64];
int n = backtrace(buffer, 64);
fprintf(stderr, "Caught signal %d, backtrace:\n", sig);
backtrace_symbols_fd(buffer, n, STDERR_FILENO);
/* 恢复默认处理,让内核生成 core dump */
signal(sig, SIG_DFL);
raise(sig);
}
void setup_crash_handler(void)
{
struct sigaction sa = {0};
sa.sa_handler = crash_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGSEGV, &sa, NULL);
sigaction(SIGABRT, &sa, NULL);
sigaction(SIGBUS, &sa, NULL);
}
int main()
{
setup_crash_handler();
// 触发段错误(SIGSEGV)
int *p = NULL;
*p = 1;
// 触发断言失败(SIGABRT)
assert(1 == 2);
// 触发总线错误(访问未映射的地址)(SIGBUS)
char *q = (char *)0x12345678;
*q = 'x';
return 0;
}注意:
backtrace_symbols_fd不调用malloc,在信号处理函数中比backtrace_symbols更安全。
为什么要先 signal(sig, SIG_DFL) 再 raise(sig)?
每个信号都有一个"处理方式",进程可以设置成三种之一:
| 处理方式 | 含义 |
|---|---|
SIG_DFL | 默认:由内核处理,通常是终止进程并生成 core dump |
SIG_IGN | 忽略:收到信号什么都不做 |
| 自定义函数 | 捕获:执行用户注册的处理函数 |
用 sigaction 注册 crash_handler 后,SIGSEGV 的处理方式已经变成了自定义函数。此时如果直接 raise(sig),信号处理方式还是 crash_handler,会再次进入函数,造成无限递归。
因此需要先恢复内核默认处理,再重新触发信号:
程序崩溃(SIGSEGV)
→ crash_handler 执行(打印 backtrace)
→ signal(SIGSEGV, SIG_DFL) ← 恢复内核默认处理
→ raise(SIGSEGV) ← 重新触发
→ 内核接手:终止进程 + 生成 core dump直接用 exit() 不会生成 core dump;用 abort() 若 SIGABRT 也被捕获同样会递归。恢复 SIG_DFL 再 raise 是保证走内核标准崩溃流程的正确做法。有了 core dump 文件后,可以在任意机器上离线分析,不需要程序还在运行。
三、addr2line:把地址翻译成源码行
addr2line 是 GNU Binutils 的工具,读取 ELF 文件中的 DWARF 调试信息,将指令地址翻译成 文件名:行号。
基本用法
addr2line -e <可执行文件> -f -p <地址> [地址...]常用选项:
| 选项 | 说明 |
|---|---|
-e | 指定可执行文件或共享库 |
-f | 同时打印函数名 |
-p | 让输出更易读(pretty-print) |
-C | 对 C++ 符号做 demangle |
示例
接上面的输出,取地址 0x401232(foo 函数):
addr2line -e demo -f -p -C 0x401232
Or
addr2line -fpCe demo 0x401232输出:
foo at /home/user/demo.c:12也一次分析多个地址:
addr2line -e demo -f -p 0x4011c8 0x401232 0x401242 0x401252四、自动化:一键解析 backtrace 输出
手动复制地址太繁琐,可以写个小脚本自动提取并解析:
#!/bin/bash
# parse_backtrace.sh <可执行文件> <日志文件>
BIN=$1
LOG=$2
grep -oP '\[0x[0-9a-f]+\]' "$LOG" | tr -d '[]' | while read addr; do
echo -n "$addr -> "
addr2line -e "$BIN" -f -p -C -i "$addr"
done用法:
./demo 2>crash.log
bash parse_backtrace.sh ./demo crash.log示例输出:
0x4011c8 -> print_backtrace at demo.c:8
0x401232 -> foo at demo.c:12
0x401242 -> bar at demo.c:13
0x401252 -> main at demo.c:16五、共享库的地址处理
当崩溃发生在 .so 文件中时,backtrace_symbols 会给出类似:
/path/to/libfoo.so(+0x1234) [0x7f8a12345678]注意括号内的 +0x1234 是相对偏移,不是绝对地址。需要用偏移量而不是绝对地址来查询:
addr2line -e /path/to/libfoo.so -f -p 0x1234如果只有绝对地址,可以用 /proc/<pid>/maps 找到库的加载基址,然后相减得到偏移。
六、编译注意事项
| 编译选项 | 作用 |
|---|---|
-g | 生成 DWARF 调试信息,addr2line 必须 |
-g3 | 包含宏定义信息 |
-rdynamic | 导出所有符号,backtrace_symbols 可解析函数名 |
-O0 | 关闭优化,调用栈更完整(开启优化后内联会使栈帧消失) |
-fno-omit-frame-pointer | 保留帧指针,改善 backtrace 的准确性 |
-no-pie | 禁用 PIE,输出绝对地址,可直接用 addr2line 分析 |
为什么要加 -no-pie
这涉及到 PIE(Position Independent Executable) 和 Linux 的地址空间随机化机制。
PIE 让可执行文件像共享库一样被编译成位置无关代码,加载时可以放在内存的任意位置。Linux 内核的 ASLR(Address Space Layout Randomization) 会利用这一点,每次运行时随机化 PIE 可执行文件的加载基址。
backtrace 输出的是运行时的绝对地址,addr2line 查的是ELF 文件中记录的地址:
- 非 PIE:加载地址固定,运行时地址 = ELF 中记录的地址,直接传给
addr2line即可。 - PIE:加载基址每次随机,运行时地址 = 随机基址 + 段内偏移,两者不一致,
addr2line查不到结果。
如果不加
-no-pie,也可以读/proc/<pid>/maps获取实际加载基址,相减得偏移再查,但比直接加编译选项麻烦很多。
版权所有
版权归属:Guisong Wu