17、调试信息:DWARF格式,-g选项,符号调试与源码映射
调试这事儿,说白了就是让程序在出问题时能告诉我们「它到底在想什么」。我刚开始写C程序那会儿,调试全靠printf大法,满屏打印信息,找bug跟大海捞针似的。后来接触了GDB,才真正体会到什么叫「源码级调试」——你写的每一行代码,都能和机器指令对应上。这背后,就是DWARF格式在起作用。
17.1 -g选项:调试信息的开关
编译时加个-g,看起来简单,背后却大有文章。我见过不少新手,编译时忘了加-g,结果程序崩了连个行号都看不到,只能对着汇编代码发呆。
核心要点:-g选项告诉编译器,在生成的目标文件中嵌入调试信息。这些信息包括:源码行号、变量名、类型信息、函数调用栈等。
举个例子:
// test.c
#include <stdio.h>
int add(int a, int b) {
int result = a + b;
return result;
}
int main() {
int x = 10;
int y = 20;
int sum = add(x, y);
printf("sum = %d\n", sum);
return 0;
}
编译时:
gcc -g -o test test.c # 带调试信息
gcc -o test test.c # 不带调试信息
用file命令看看区别:
$ file test
test: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,
BuildID[sha1]=..., not stripped
$ file test_no_debug
test_no_debug: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
for GNU/Linux 2.6.32, BuildID[sha1]=..., stripped
看到没?带-g的显示「not stripped」,不带的是「stripped」。这个「stripped」就是调试信息被剥离了。
我的习惯:开发阶段永远加-g,发布时再用strip命令去掉调试信息。这样既能调试,又能减小最终二进制体积。
17.2 DWARF格式:调试信息的骨架
DWARF(Debugging With Attributed Record Formats)是目前Linux/Unix系统上最主流的调试信息格式。它不像STAB那样简单粗暴,而是设计得非常精巧。
DWARF的核心数据结构是「调试信息条目」(DIE,Debugging Information Entry)。每个DIE描述一个程序实体,比如一个函数、一个变量、一个类型。DIE之间通过父子关系组织成一棵树。
// DWARF DIE 示例(简化)
<1><0x0000002a> DW_TAG_compile_unit
DW_AT_name : test.c
DW_AT_language : DW_LANG_C
DW_AT_low_pc : 0x004004d6
DW_AT_high_pc : 0x0040052a
<2><0x0000003b> DW_TAG_subprogram
DW_AT_name : add
DW_AT_low_pc : 0x004004d6
DW_AT_high_pc : 0x004004f0
<3><0x0000004c> DW_TAG_formal_parameter
DW_AT_name : a
DW_AT_type : <0x0000005d>
<3><0x00000058> DW_TAG_formal_parameter
DW_AT_name : b
DW_AT_type : <0x0000005d>
<3><0x00000064> DW_TAG_variable
DW_AT_name : result
DW_AT_type : <0x0000005d>
这棵树描述了:编译单元(test.c)下有一个函数add,函数有两个参数a和b,还有一个局部变量result。每个节点都带有属性,比如地址范围、类型引用等。
注意:DWARF格式有多个版本,目前主流是DWARF 4和DWARF 5。不同版本之间不兼容,调试器需要支持对应版本才能解析。
17.3 符号调试:从地址到源码
调试器怎么知道当前执行到哪一行了?靠的是「行号表」(Line Number Table)。这个表记录了每条机器指令对应的源码行号。
用readelf看看行号表:
$ readelf --debug-dump=line test
CU: test.c
File name Line number Starting address
test.c 5 0x4004d6
test.c 6 0x4004de
test.c 7 0x4004e8
test.c 10 0x4004f0
test.c 11 0x4004f8
test.c 12 0x400502
test.c 13 0x40050c
test.c 14 0x40051e
你看,地址0x4004d6对应test.c的第5行,也就是int add(int a, int b) {。当程序执行到0x4004de时,调试器就知道当前在第6行int result = a + b;。
这就是「源码映射」的本质——把二进制地址和源码行号对应起来。
17.4 变量调试:从名字到值
调试时查看变量值,背后也有一套机制。DWARF记录了每个变量的位置信息,可能是寄存器、内存地址,甚至是复杂的表达式。
// 变量位置信息示例
<2><0x0000004c> DW_TAG_formal_parameter
DW_AT_name : a
DW_AT_location : DW_OP_reg5 // 参数a在寄存器r5中
<2><0x00000064> DW_TAG_variable
DW_AT_name : result
DW_AT_location : DW_OP_fbreg -12 // result在栈帧偏移-12处
调试器读取这些信息,就知道去哪里找变量的值。比如result在栈帧偏移-12的位置,调试器就读取那个地址的内容。
我曾经遇到过一个坑:优化选项-O2会导致变量被优化掉,或者放在寄存器里,调试时根本看不到。后来我学乖了,调试时用-O0 -g,发布时再用-O2。
17.5 DWARF结构图
下面这张图展示了DWARF调试信息的整体结构:
17.6 实际调试中的源码映射
用GDB调试时,源码映射是透明的。你只需要:
$ gdb ./test
(gdb) break add
(gdb) run
(gdb) print a
$1 = 10
(gdb) print b
$2 = 20
(gdb) step
(gdb) print result
$3 = 30
GDB背后做了这些事:
- 读取
.debug_info找到add函数的DIE - 从
.debug_line找到函数入口地址 - 设置断点时,把源码行号转换成机器地址
- 单步执行时,根据行号表决定下一步停在哪一行
- 打印变量时,从
.debug_info读取变量位置信息,再读取实际值
关键点:没有DWARF调试信息,GDB就只能看到汇编指令和内存地址,没法跟源码对应起来。这就是为什么-g选项如此重要。
17.7 调试信息的优化与取舍
调试信息也不是越多越好。我见过一个项目,加了-g后二进制体积膨胀了3倍。这时候就需要权衡:
| 编译选项 | 调试信息 | 二进制大小 | 调试能力 |
|---|---|---|---|
-g0 |
无 | 最小 | 只能看汇编 |
-g1 |
最少(行号+函数名) | 较小 | 能看行号,不能看变量 |
-g(默认) |
标准(完整调试信息) | 较大 | 完整源码级调试 |
-g3 |
最多(包含宏定义) | 最大 | 能展开宏 |
我的建议:日常开发用-g就够了。如果遇到宏相关的bug,可以临时用-g3。发布时用strip去掉调试信息,或者用-g1保留行号用于崩溃分析。
17.8 调试信息的剥离与恢复
有时候你拿到一个崩溃的core dump,但二进制文件已经被strip过了。怎么办?
我遇到过这种情况:线上程序崩溃,core dump里只有地址,没有行号。幸好我们保留了带调试信息的版本:
# 编译时保留调试信息
gcc -g -o test test.c
# 发布时剥离调试信息,但保存到单独文件
objcopy --only-keep-debug test test.debug
objcopy --strip-debug test test.stripped
# 调试时关联调试信息
gdb test.stripped -s test.debug -c core.dump
这样,线上跑的是精简版,调试时又能用完整的调试信息。两全其美。
注意:调试信息文件和二进制文件必须完全匹配,包括编译时间、代码版本等。否则GDB会报错「no debugging symbols found」。
DWARF格式和-g选项,是C语言调试的基石。理解了它们,你就能真正掌控调试过程,而不是靠运气找bug。下次遇到段错误,别急着加printf,试试GDB加上-g编译的程序,你会发现调试其实可以很优雅。
公众号:蓝海资料掘金营,微信deep3321