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)
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 设置断点的正确姿势
我常用的方法有两种:
- 先启动,再设断点:让程序跑起来,dlopen加载完.so之后,再打断点。
- 使用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,明显是计算偏移量时溢出了。
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直接在那个条件下断住。效率翻倍。