调试信息格式:DWARF标准,stabs,调试符号表

调试信息,说白了就是让调试器(比如GDB)能看懂你的程序在干什么。没有它,你看到的全是机器码和寄存器地址,根本没法跟源代码对应上。我刚开始做嵌入式开发时,就吃过这个亏——编译时忘了加-g选项,结果程序崩了,GDB里全是问号,那叫一个抓瞎。

调试信息到底存了什么?

调试信息的核心任务,就是把二进制代码和源代码关联起来。具体来说,它要记录这些东西:

  • 行号映射:哪条机器指令对应源代码的哪一行
  • 变量信息:变量名、类型、作用域、内存地址或寄存器位置
  • 函数信息:函数名、参数列表、返回类型、栈帧布局
  • 类型信息:结构体、联合体、枚举、typedef 的完整定义
  • 宏定义#define 宏的名称和展开内容

这些信息存储在目标文件的特定段(section)里。比如 ELF 格式下,调试信息通常放在 .debug_* 段中。

stabs:老前辈的遗产

stabs 是最早的调试信息格式之一,起源于 Unix 的 a.out 格式。它的名字来自 "symbol table" 的缩写。stabs 把调试信息编码成特殊的符号表条目,用一串字符串来描述变量、类型、行号等。

举个例子,一个 stabs 条目可能长这样:

.stabs "main:F(0,1)", N_FUN, 0, 0, _main
.stabs "x:p(0,2)", N_PSYM, 0, 0, 0
.stabs "int:t(0,2)=r(0,2);-2147483648;2147483647;", N_LSYM, 0, 0, 0

看着是不是有点头疼?stabs 的语法确实晦涩,而且扩展性差。我在维护一个老旧项目时遇到过它——那是个 90 年代的嵌入式系统,编译器只支持 stabs。每次调试都要手动解析这些字符串,效率极低。

stabs 的主要问题:

  • 信息编码在符号表中,导致符号表膨胀
  • 不支持 C++ 的复杂特性(模板、命名空间等)
  • 跨平台兼容性差
  • 解析效率低

现在除了极少数遗留系统,基本没人用 stabs 了。如果你还在用,我建议尽快迁移到 DWARF。

DWARF:现代调试信息的标准

DWARF(Debugging With Attributed Record Formats)是目前 Linux、macOS、嵌入式系统的主流调试信息格式。它设计得比 stabs 优雅得多,用树形结构来描述程序的调试信息。

DWARF 的核心概念是 DIE(Debugging Information Entry)。每个 DIE 代表一个程序实体,比如函数、变量、类型、编译单元等。DIE 之间通过父子关系形成一棵树。

一个简单的 DWARF 结构示意:

编译单元 (DW_TAG_compile_unit)
├── 子程序 (DW_TAG_subprogram)  // main 函数
│   ├── 形式参数 (DW_TAG_formal_parameter)  // argc
│   ├── 形式参数 (DW_TAG_formal_parameter)  // argv
│   └── 变量 (DW_TAG_variable)  // 局部变量 x
├── 基类型 (DW_TAG_base_type)  // int
└── 行号表 (DW_TAG_line_number_table)

每个 DIE 都有一组属性(Attribute),比如:

  • DW_AT_name:实体名称
  • DW_AT_type:指向类型 DIE 的引用
  • DW_AT_location:变量在内存或寄存器中的位置
  • DW_AT_low_pc / DW_AT_high_pc:函数或代码块的地址范围

DWARF 的版本演进:

版本 发布时间 主要改进
DWARF 1 1992 初版,基本功能
DWARF 2 1993 引入 DIE 树结构,支持 C++
DWARF 3 2005 增加模板、命名空间支持
DWARF 4 2010 优化编码效率,增加类型单元
DWARF 5 2017 改进行号表,支持宏、分割对象文件

目前主流编译器默认生成 DWARF 4 或 DWARF 5。我个人建议用 DWARF 5,它在处理大项目时性能更好。

调试符号表:调试器的地图

调试符号表(Symbol Table)是调试信息的索引。它记录了符号名到地址、类型、作用域的映射。调试器拿到符号表后,才能把地址翻译成变量名、函数名。

在 ELF 文件中,符号表通常放在 .symtab 段,而调试信息在 .debug_info.debug_line 等段中。符号表是调试信息的"目录",调试器先查符号表,再根据符号表里的指针去 .debug_* 段找详细信息。

举个例子,你用 GDB 打印一个变量:

(gdb) print my_var
$1 = 42

GDB 背后做了这些事:

  1. 在符号表中查找 my_var,得到它的地址和类型信息
  2. 根据类型信息,知道 my_var 是 int 类型,占 4 字节
  3. 从地址读取 4 字节数据,按 int 格式解释
  4. 显示结果

如果没有调试符号表,GDB 只能告诉你地址 0x7fff12345678 处的值是 0x0000002a,你根本不知道那是什么。

如何查看调试信息?

你可以用 readelfobjdump 来查看目标文件中的调试信息:

# 查看 DWARF 调试信息
readelf --debug-dump=info myprogram

# 查看行号表
readelf --debug-dump=line myprogram

# 查看符号表
readelf --symbols myprogram

# 用 objdump 查看 stabs
objdump --stabs myprogram

我曾经用这些命令排查过一个栈回溯错误。程序在某个函数里崩溃,但 GDB 显示的调用栈完全不对。我 dump 出 DWARF 信息,发现是编译器优化导致栈帧布局变了,调试信息没跟上。最后加了 -fno-omit-frame-pointer 才解决。

调试信息的取舍

调试信息不是免费的。它会让目标文件体积膨胀 2-5 倍。对于嵌入式系统或发布版本,你可能需要权衡:

  • 开发版本:保留完整调试信息(-g
  • 发布版本:可以 strip 掉调试信息(strip --strip-debug),或者生成单独的调试文件(objcopy --only-keep-debug
  • 部分调试:只保留行号信息(-g1),不保留变量和类型信息

这里有个坑:strip 掉调试信息后,如果程序崩溃,你只能看到地址,看不到源代码行号。所以生产环境最好保留 core dump 和对应的调试文件,方便事后分析。

SVG:调试信息结构总览

调试信息结构总览 源文件 (main.c) 编译器 (gcc -g) 目标文件 (main.o) 目标文件内部 .text (机器码) .data / .bss (数据) .symtab (符号表) .debug_* (DWARF) 调试器 (GDB) 调试器读取符号表和 DWARF 信息 实现源代码级调试

核心要点:调试信息是连接源代码和机器码的桥梁。DWARF 是现代标准,stabs 是历史遗留。调试符号表是调试器的"地图",没有它,调试器就是瞎子。

我的建议:日常开发用 -g -gdwarf-5 编译。发布版本用 strip --strip-debug 剥离调试信息,但保留一份单独的调试文件。这样既减小了发布包体积,又能在出问题时快速定位。

注意:编译器优化(-O2 及以上)会导致调试信息不准确。变量可能被优化掉,行号可能错位。调试时建议用 -O0 -g,或者用 -Og(优化但保留调试体验)。

调试信息这块,说白了就是"用空间换时间"。多花点磁盘空间,省下的是你排查 bug 的宝贵时间。我见过太多人为了省那几百 KB,编译时不加 -g,结果出问题后花几天去反汇编。嗯,这笔账怎么算都不划算。