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调试信息的整体结构:

DWARF调试信息结构 可执行文件 (ELF) .debug_info 段 (DIE树) 编译单元 → 函数 → 参数/变量 → 类型 .debug_line (行号表) .debug_abbrev (缩写表) .debug_str (字符串表) 地址 ↔ 行号映射 DIE属性定义 变量名/类型名 多个调试段协同工作,实现完整的源码级调试

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背后做了这些事:

  1. 读取.debug_info找到add函数的DIE
  2. .debug_line找到函数入口地址
  3. 设置断点时,把源码行号转换成机器地址
  4. 单步执行时,根据行号表决定下一步停在哪一行
  5. 打印变量时,从.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