2. 编译与调试信息:-g选项、调试符号表、优化与strip
好,咱们正式开始聊 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(优化尺寸)。优化级别越高,编译器对代码的「改写」就越狠。
具体来说,优化会带来以下问题:
- 变量被优化掉:局部变量可能直接存在寄存器里,甚至被完全消除。你
print var时,GDB 告诉你value optimized out。 - 代码重排:编译器可能把循环展开、函数内联、指令调度。你设的断点可能根本不会命中,或者命中的位置跟你想象的不一样。
- 行号映射混乱:多行代码被合并成一条指令,或者一条指令对应多行代码。单步调试时,光标乱跳。
看个例子:
// 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 知识体系总览
下面这张图,把咱们这一节的核心逻辑串起来了:
从这张图可以看得很清楚:-g 是调试信息的「源头」,优化选项是调试的「干扰因素」,而 strip 则是调试信息的「终结者」。三者共同决定了你最终能用 GDB 做什么、不能做什么。
2.6 小结
这一节咱们聊了四个核心点:
-g选项是调试信息的开关,开发阶段务必加上- 调试符号表是 GDB 的「地图」,包含行号、变量、类型等信息
- 优化选项会干扰调试,建议调试时用
-O0 -g,必要时用-Og strip命令用于发布版本,但记得保留一份带符号的版本
嗯,这些基础打牢了,后面咱们聊堆栈回溯、core dump 分析时,你才不会一头雾水。