调试信息格式: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 背后做了这些事:
- 在符号表中查找
my_var,得到它的地址和类型信息 - 根据类型信息,知道
my_var是 int 类型,占 4 字节 - 从地址读取 4 字节数据,按 int 格式解释
- 显示结果
如果没有调试符号表,GDB 只能告诉你地址 0x7fff12345678 处的值是 0x0000002a,你根本不知道那是什么。
如何查看调试信息?
你可以用 readelf 或 objdump 来查看目标文件中的调试信息:
# 查看 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:调试信息结构总览
核心要点:调试信息是连接源代码和机器码的桥梁。DWARF 是现代标准,stabs 是历史遗留。调试符号表是调试器的"地图",没有它,调试器就是瞎子。
我的建议:日常开发用 -g -gdwarf-5 编译。发布版本用 strip --strip-debug 剥离调试信息,但保留一份单独的调试文件。这样既减小了发布包体积,又能在出问题时快速定位。
注意:编译器优化(-O2 及以上)会导致调试信息不准确。变量可能被优化掉,行号可能错位。调试时建议用 -O0 -g,或者用 -Og(优化但保留调试体验)。
调试信息这块,说白了就是"用空间换时间"。多花点磁盘空间,省下的是你排查 bug 的宝贵时间。我见过太多人为了省那几百 KB,编译时不加 -g,结果出问题后花几天去反汇编。嗯,这笔账怎么算都不划算。