15、调试与日志:插件级日志系统、GDB调试动态库、崩溃转储分析

调试插件,跟调试普通程序完全是两码事。

我刚开始搞插件化架构那会儿,就吃过这个亏。主程序跑得好好的,一加载我的插件就崩。问题是——崩在插件里,主程序core dump了,你连插件在哪行挂的都不知道。那叫一个抓瞎。

后来我学乖了。调试和日志,必须从设计阶段就考虑进去。别等到上线出问题了再补,那会儿你连插件是哪个版本都搞不清。

15.1 插件级日志系统:别让日志成为事后诸葛亮

日志这东西,说白了就是你的「黑匣子」。飞机出事找黑匣子,插件崩了也得有日志可查。

但插件日志有个特殊之处:它运行在主进程的地址空间里。你不能随便写文件,也不能跟主进程抢日志资源。

15.1.1 日志回调注册机制

我个人习惯的做法是:主程序提供一个日志回调函数,插件在初始化时注册进去。这样插件只管调用,主程序统一管理日志输出。

// 主程序定义的回调类型
typedef void (*log_callback_t)(int level, const char* file, int line, const char* msg);

// 插件需要实现的初始化接口
typedef struct {
    int   (*init)(log_callback_t logger);
    void  (*run)(void);
    void  (*fini)(void);
} plugin_interface_t;

插件里怎么用?很简单:

static log_callback_t g_logger = NULL;

int init(log_callback_t logger) {
    g_logger = logger;
    g_logger(LOG_INFO, __FILE__, __LINE__, "插件初始化成功");
    return 0;
}

// 封装成宏,用起来顺手
#define PLUG_LOG(level, fmt, ...) \
    do { \
        if (g_logger) { \
            char buf[256]; \
            snprintf(buf, sizeof(buf), fmt, ##__VA_ARGS__); \
            g_logger(level, __FILE__, __LINE__, buf); \
        } \
    } while(0)
小技巧:日志级别建议至少分四级:ERROR、WARN、INFO、DEBUG。线上环境只开ERROR和WARN,调试时再开DEBUG。别一股脑全打出来,性能扛不住。

15.1.2 日志缓冲区与异步写入

插件里打日志,最怕什么?怕写日志的时候卡住,影响主流程。

我遇到过一个问题:插件在临界区里打日志,日志写入文件时触发了磁盘I/O,结果死锁了。嗯,那场面,排查了一整天才找到原因。

解决方案是异步日志。插件把日志扔到一个环形缓冲区里,后台线程慢慢写。这样插件调用日志函数几乎是零开销。

// 环形缓冲区结构
#define LOG_BUF_SIZE 4096
typedef struct {
    char  data[LOG_BUF_SIZE][256];
    int   head;
    int   tail;
    pthread_mutex_t lock;
} log_buffer_t;

void plugin_log(int level, const char* msg) {
    pthread_mutex_lock(&buf->lock);
    int next = (buf->head + 1) % LOG_BUF_SIZE;
    if (next != buf->tail) {  // 没满
        snprintf(buf->data[buf->head], 256, "[%d] %s", level, msg);
        buf->head = next;
    }
    pthread_mutex_unlock(&buf->lock);
}
注意:环形缓冲区满了怎么办?我建议直接丢弃最老的日志。别阻塞,别申请内存。插件日志可以丢,但插件不能崩。

15.2 GDB调试动态库:断点打在.so里

调试动态库,跟调试普通程序最大的区别是:符号加载时机不同。

你想想看,主程序启动时,动态库还没加载呢。GDB怎么知道你的函数在哪?

15.2.1 设置断点的正确姿势

我常用的方法有两种:

  1. 先启动,再设断点:让程序跑起来,dlopen加载完.so之后,再打断点。
  2. 使用pending断点:GDB允许你给尚未加载的函数设断点,等.so加载后自动生效。
# 方法一:先跑起来
(gdb) run
^C  # 等程序加载完插件后,Ctrl+C中断
(gdb) b plugin.c:42
(gdb) continue

# 方法二:pending断点
(gdb) b plugin_run
Function "plugin_run" not defined.
Make breakpoint pending on future shared library load? (y or [n]) y
Breakpoint 1 (plugin_run) pending.
(gdb) run

我个人更推荐方法二。省事,不用盯着屏幕等。

15.2.2 加载符号表

有时候GDB进了.so文件,但看不到源码,只能看到汇编。为什么?因为符号表没加载。

# 查看已加载的共享库
(gdb) info sharedlibrary

# 手动加载符号
(gdb) sharedlibrary my_plugin.so

# 或者直接指定路径
(gdb) add-symbol-file /path/to/my_plugin.so 0x7ffff7bd2000

地址从哪来?用 info proc mappings 或者 cat /proc/<pid>/maps 都能看到。

核心要点:编译插件时一定要加 -g 选项,并且不要 strip。我见过有人为了减小体积把符号表去了,结果线上出问题连堆栈都看不懂。

15.3 崩溃转储分析:从core dump里挖真相

程序崩了,最直接的证据就是core dump文件。但很多人不会用,或者懒得用。

我刚开始做插件开发时,遇到崩溃第一反应是加日志、重新跑。后来发现,有些bug是概率性的,跑十次可能只崩一次。这时候core dump就是救命稻草。

15.3.1 开启core dump

# 查看当前限制
ulimit -c

# 设置为无限制
ulimit -c unlimited

# 设置core文件命名格式(可选)
echo "/tmp/core.%e.%p" > /proc/sys/kernel/core_pattern

注意:ulimit -c unlimited 只对当前shell有效。要永久生效,得改 /etc/security/limits.conf

15.3.2 分析core dump

拿到core文件后,用GDB加载:

gdb /path/to/main_program /tmp/core.main_program.12345

进去之后,先看崩溃位置:

(gdb) bt
#0  0x00007f1234567890 in plugin_process (data=0x0) at plugin.c:42
#1  0x00007f1234561234 in plugin_run () at plugin.c:88
#2  0x0000555abcdef1234 in main (argc=1, argv=0x7fff...) at main.c:120

看到 data=0x0 了吗?空指针解引用。这就是崩溃原因。

15.3.3 查看寄存器与内存

有时候光看堆栈不够,还得看现场:

# 查看当前指令
(gdb) x/10i $pc

# 查看寄存器
(gdb) info registers

# 查看某个地址的内存
(gdb) x/32xb 0x7f1234567890

我记得有一次,堆栈显示崩溃在 memcpy 里。我一开始以为是内存越界,后来看了寄存器才发现——源地址是 0xfffffffffffffff0,明显是计算偏移量时溢出了。

经验之谈:分析core dump时,别只看bt。结合 frame 命令切换到不同栈帧,查看局部变量。很多时候bug不在崩溃的那一行,而在几层调用之前。

15.4 插件调试的终极武器:条件断点与watchpoint

插件里有些bug,不是每次都会触发。比如某个参数为负数时才崩溃。这时候条件断点就派上用场了。

# 条件断点:只在某个条件成立时中断
(gdb) b plugin.c:42 if data->size < 0

# watchpoint:监控某个变量变化
(gdb) watch data->size
(gdb) watch data->size == 0  # 条件watch

watchpoint特别适合排查「谁把这个值改错了」的问题。你想想看,一个全局变量莫名其妙变成了0,你加日志都不知道在哪加的。用watchpoint,一抓一个准。

15.5 日志与调试的协同:让日志帮你定位断点

最后分享一个我自己的习惯。

我会在插件里埋一些「调试锚点」——就是特定的日志输出。比如:

PLUG_LOG(LOG_DEBUG, "ANCHOR: plugin_process called with id=%d", id);

然后在GDB里,我可以根据日志里的id值,决定要不要打断点:

(gdb) b plugin.c:42 if id == 100

这样,日志和调试就打通了。日志告诉我「问题出在id=100那次调用」,GDB直接在那个条件下断住。效率翻倍。

总结一句话:插件调试,日志是眼睛,GDB是手术刀,core dump是病理报告。三者配合,没有查不出的bug。

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