2. 编译与调试信息

好,咱们正式开始聊 GDB 调试的第一个硬核话题——编译时那些跟调试信息打交道的选项。

说实话,我见过太多工程师,代码写得飞起,一崩就懵。为什么?因为编译出来的二进制里,压根没带调试信息。GDB 拿到一个光秃秃的 .out 文件,就像你拿到一本没有目录、没有页码、甚至没有章节标题的书——想找到问题在哪,全靠猜。

所以这一节,咱们就把「调试信息」这事儿彻底聊透。

2.1 -g 选项:调试信息的开关

-g 是 GCC 家族编译器里最基础的调试选项。它的作用很简单:告诉编译器,在生成的目标文件或可执行文件里,嵌入调试符号表。

这个符号表里有什么?我列一下核心内容:

  • 源代码的行号与机器指令的对应关系
  • 局部变量、全局变量的名字、类型、地址
  • 函数名、参数列表、栈帧信息
  • 结构体、联合体、枚举等类型的完整定义

举个例子,你写了个简单的 C 程序:

// 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 z = add(x, y);
    printf("z = %d\n", z);
    return 0;
}

编译时加 -g:

gcc -g -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 3.2.0, BuildID[sha1]=..., with debug_info, not stripped

看到 with debug_info 了吗?这就是 -g 的功劳。

我的习惯:开发阶段永远加 -g。哪怕你觉得代码很稳,也加上。多占那几百 KB 的磁盘空间,比你崩溃后抓瞎半小时划算得多。

2.2 调试符号表:GDB 的「地图」

调试符号表,说白了就是 GDB 的地图。没有它,GDB 只能看到一堆地址和机器码,完全不知道哪个地址对应哪行代码、哪个变量叫什么名字。

咱们用 readelf 看看符号表长什么样:

$ readelf -S test | grep debug
  [28] .debug_aranges   PROGBITS         0000000000000000  000030b0
  [29] .debug_info      PROGBITS         0000000000000000  000030e0
  [30] .debug_abbrev    PROGBITS         0000000000000000  000034b8
  [31] .debug_line      PROGBITS         0000000000000000  00003628
  [32] .debug_str       PROGBITS         0000000000000000  000037b8

这些 .debug_* 段就是调试符号表的核心。其中:

  • .debug_info:保存了类型、变量、函数等高层信息
  • .debug_line:保存了源代码行号与机器指令的映射
  • .debug_str:保存了字符串常量,比如变量名、函数名

GDB 启动时,会读取这些段,构建出你熟悉的 list、print、break 等命令所需的数据结构。

注意:如果你用 strip 命令删除了符号表,或者编译时没加 -g,GDB 仍然可以运行,但你会看到类似 No symbol table is loaded 的提示。这时候你只能做地址级别的调试,比如 x/i $rip 看汇编,但没法看源码、没法按行号设断点。

2.3 优化选项对调试的影响

这是个大坑。我当年刚入行时,就吃过这个亏。

GCC 的优化选项从 -O0(不优化)到 -O3(激进优化),甚至还有 -Os(优化尺寸)。优化级别越高,编译器对代码的「改写」就越狠。

具体来说,优化会带来以下问题:

  1. 变量被优化掉:局部变量可能直接存在寄存器里,甚至被完全消除。你 print var 时,GDB 告诉你 value optimized out。
  2. 代码重排:编译器可能把循环展开、函数内联、指令调度。你设的断点可能根本不会命中,或者命中的位置跟你想象的不一样。
  3. 行号映射混乱:多行代码被合并成一条指令,或者一条指令对应多行代码。单步调试时,光标乱跳。

看个例子:

// opt.c
int main() {
    int a = 1;
    int b = 2;
    int c = a + b;
    return c;
}

用 -O0 -g 编译,GDB 里 print a 正常显示 1。但用 -O2 -g 编译:

(gdb) print a
$1 = <optimized out>

为什么?因为 a 和 b 的值在编译期就被计算出来了,编译器直接把 c 替换成了常量 3,变量 a 和 b 压根没存在过。

避坑指南:我曾经在调试一个性能优化后的模块时,花了整整一个下午追一个变量,结果发现它被优化掉了。从那以后,我养成了一个习惯:调试时用 -O0 -g,确认逻辑正确后,再用目标优化级别重新编译做性能测试。

当然,有些 bug 只在特定优化级别下出现(比如编译器优化引入的并发问题)。这时候你不得不用 -O2 -g 调试。我的建议是:

  • 先用 -O0 复现逻辑层面的问题
  • 如果问题只在 -O2 下出现,那就用 -Og(GCC 专门为调试优化的级别,介于 -O1 和 -O2 之间,但尽量保留调试信息)
  • 实在不行,才用 -O2 -g,并做好「有些变量看不到」的心理准备

2.4 strip 命令:删掉调试信息

strip 是 binutils 工具集里的一个命令,作用是从目标文件或可执行文件中删除符号表和调试信息。

什么时候用?

  • 发布版本:你不想让用户或攻击者看到你的内部符号名、源码行号
  • 嵌入式系统:存储空间有限,能省一点是一点
  • 减少体积:调试信息可能占可执行文件体积的 50% 以上

用法很简单:

$ strip test
$ file test
test: ELF 64-bit LSB executable, x86-64, ..., stripped

看到 stripped 了吗?这时候你再试试 GDB:

$ gdb test
Reading symbols from test...
(No debugging symbols found in test)
(gdb) break main
No symbol table is loaded.  Use the "file" command.

嗯,GDB 直接罢工了。

我的做法:发布版本用 strip 删掉调试信息,但保留一份带调试信息的版本,存到内部的符号服务器上。线上出问题后,用 gdb 加载 core dump 和带符号的版本,照样能调试。这叫「发布剥离,保留符号」。

2.5 知识体系总览

下面这张图,把咱们这一节的核心逻辑串起来了:

编译与调试信息知识体系 源代码 (.c/.cpp) GCC 编译器 -g 选项:嵌入调试信息 -O0/-O1/-O2/-O3/-Og 调试符号表 (.debug_*) 变量优化、代码重排 strip 命令:删除调试信息 GDB 调试受限

从这张图可以看得很清楚:-g 是调试信息的「源头」,优化选项是调试的「干扰因素」,而 strip 则是调试信息的「终结者」。三者共同决定了你最终能用 GDB 做什么、不能做什么。

2.6 小结

这一节咱们聊了四个核心点:

  • -g 选项是调试信息的开关,开发阶段务必加上
  • 调试符号表是 GDB 的「地图」,包含行号、变量、类型等信息
  • 优化选项会干扰调试,建议调试时用 -O0 -g,必要时用 -Og
  • strip 命令用于发布版本,但记得保留一份带符号的版本

嗯,这些基础打牢了,后面咱们聊堆栈回溯、core dump 分析时,你才不会一头雾水。