3. 启动与附加

调试器怎么跟目标进程「搭上线」?这是每个用 GDB 的人首先要搞明白的事。我个人习惯把这四种方式分成两类:主动调试和被动调试。说白了,一种是你从头开始跑程序,另一种是程序已经跑起来了,你半路插进去看。

嗯,咱们一个一个说。

3.1 gdb <program>:最基础的启动方式

这是最直接的方式。你编译好一个程序,然后直接丢给 GDB:

gdb ./my_program

进去之后,GDB 会加载符号表,停在等待命令的状态。这时候你可以设断点、改环境变量,然后敲 run 让程序跑起来。

我记得刚入行那会儿,有个同事死活搞不懂为什么 GDB 启动后程序没跑。他以为跟 Windows 下的 IDE 一样,双击就自动运行了。其实 GDB 的设计哲学是:启动 ≠ 运行。它只是把程序加载到内存里,准备好符号信息,等你下命令。

核心原理:gdb <program> 做的事情本质上是 fork + execve。GDB 先 fork 出一个子进程,然后在子进程里调用 execve 加载你的程序。父进程(GDB 自身)通过 ptrace 系统调用跟踪子进程。

这里有个小细节:

gdb --args ./my_program --flag1 --flag2 value

用 --args 可以把命令行参数直接传给目标程序,省得进去再敲 set args。我项目中经常这么用,尤其是调试那些参数特别多的服务端程序。

3.2 gdb attach <pid>:调试正在运行的进程

有时候程序已经跑起来了,突然卡死或者出现异常。这时候你不能重启它——生产环境哪能随便重启?那就用 attach。

gdb -p 12345
# 或者
gdb
(gdb) attach 12345

attach 的原理很有意思。GDB 调用 ptrace(PTRACE_ATTACH, pid),内核会给目标进程发送一个 SIGSTOP 信号,把它停下来。然后 GDB 就可以读取它的内存、寄存器,设置断点。

注意:attach 到进程后,当你退出 GDB 或者 detach 时,目标进程会继续运行。但如果你在调试过程中不小心杀了 GDB,目标进程可能被挂在停止状态。我曾经在生产环境上踩过这个坑——GDB 意外崩溃,服务进程卡死了,最后只能手动 kill -9 再重启。

还有一个权限问题:你只能 attach 到属于你的进程,或者你有 CAP_SYS_PTRACE 权限的进程。Docker 容器里默认限制了 ptrace 能力,需要加 --cap-add=SYS_PTRACE 才能用。

3.3 gdb <program> <corefile>:事后分析

程序崩溃了,core dump 留下了。这时候你不需要让程序再跑一遍,直接分析 core 文件就行:

gdb ./my_program core.12345

Core 文件本质上是进程崩溃时内存的「快照」。内核在进程收到致命信号(比如 SIGSEGV)时,会把进程的地址空间、寄存器状态、打开的文件描述符等信息全部写到磁盘上。

我特别喜欢这种方式。为什么呢?因为很多 bug 是偶发的,你盯着它跑它就不出问题,你一转身它就崩了。有了 core 文件,你可以慢慢分析,不用着急。

小技巧:生产环境上记得设置 ulimit -c unlimited,否则 core 文件根本不会生成。另外,用 sysctl kernel.core_pattern 可以控制 core 文件的存放路径和命名格式。我一般设成 /var/crash/core.%e.%p.%t,这样能区分是哪个程序、哪个进程、什么时间崩的。

分析 core 文件时,最常用的命令是 bt(backtrace)看调用栈,frame 切换栈帧,info locals 看局部变量。但要注意,core 文件里只有崩溃时刻的内存状态,你没法继续单步执行——毕竟程序已经死了。

3.4 远程调试:gdbserver

嵌入式开发、Docker 容器、或者目标机器上没有 GDB 的时候,就得用远程调试了。

原理很简单:目标机器上跑一个 gdbserver,它负责跟被调试程序交互(通过 ptrace)。开发机上跑 GDB,通过网络或者串口跟 gdbserver 通信。

启动方式:

# 目标机器上
gdbserver :2345 ./my_program

# 开发机上
gdb ./my_program
(gdb) target remote 192.168.1.100:2345

通信协议是 GDB 远程串行协议(RSP),基于 ASCII 的文本协议。GDB 发送类似 $g#67 这样的命令去读寄存器,gdbserver 返回十六进制编码的数据。

为什么需要两份相同的程序? 开发机上的 ./my_program 是用来读取符号表和调试信息的。目标机器上跑的程序不需要符号表(可以 strip 掉),但代码必须完全一致。否则行号对不上,你看到的源码跟实际执行的代码不匹配。

我曾经调试一个 ARM 嵌入式设备,目标机器只有 64MB 内存,根本跑不动完整的 GDB。用 gdbserver 只占几百 KB,完美解决问题。不过要注意网络延迟——跨洋调试的时候,每次单步都要等几百毫秒,那感觉真是酸爽。

知识体系总览

下面这张图把这四种方式的核心逻辑串起来了:

GDB 四种调试启动方式 GDB 调试器 方式一:gdb <program> 主动启动,fork + execve + ptrace 方式二:gdb attach <pid> 附加到运行中的进程 方式三:gdb <prog> <core> 事后分析 core dump 文件 方式四:远程调试 (gdbserver) GDB + gdbserver,RSP 协议通信 核心机制:所有方式底层都依赖 ptrace 系统调用 区别在于:启动时机不同、目标进程状态不同、通信方式不同

四种方式对比

方式 适用场景 目标进程状态 能否单步 需要符号表
gdb <program> 开发调试,从头开始 未启动 是 是
gdb attach <pid> 调试已运行的进程 运行中(被暂停) 是 是
gdb <prog> <core> 事后分析崩溃现场 已崩溃(已死亡) 否 是
远程调试 嵌入式、容器、跨机器 运行中(被暂停) 是(有延迟) 开发机需要

我的建议:日常开发用方式一就够了。线上问题排查,优先用方式三(core 文件分析),因为不影响线上服务。实在不行再用方式二 attach,但记得操作要快,调试完马上 detach。远程调试是最后的手段,网络延迟会让你怀疑人生。

嗯,这四种方式覆盖了绝大多数调试场景。你想想看,从程序还没启动,到跑起来出问题,再到崩溃后分析,再到跨机器调试——GDB 都给你安排得明明白白的。底层都是 ptrace,只是上层封装了不同的入口而已。

可可精品资料库