2. GDB核心概念:进程与线程的调试模型、调试符号(DWARF)、ptrace系统调用、断点的本质
好,咱们正式开始啃硬骨头了。
这一章,我会带你深入GDB的底层。说白了,就是搞清楚GDB到底是怎么“附身”到你的程序上的,又是怎么读懂你的代码的,以及那个让你停下来的断点,到底是什么东西。
我个人习惯,搞懂一个工具,先别急着学命令,先搞清楚它的“世界观”。GDB的世界观,就是围绕进程、线程、调试符号和系统调用展开的。
2.1 进程与线程的调试模型
GDB调试一个程序,本质上是一个“三体问题”。哪三个体?
- 被调试程序:你的那个跑起来就停不下来的进程(或线程)。
- 调试器本身:GDB这个进程。
- 操作系统内核:真正的“裁判”,负责传递信号、管理状态。
GDB并不是直接“钻进”你的程序里。它用的是父子进程模型,或者更准确地说,是追踪者与被追踪者模型。
当你敲下 gdb ./a.out 并输入 run 时,GDB会先 fork() 一个子进程。这个子进程通过 execve() 加载你的 a.out。但在加载之前,GDB会调用一个关键函数——ptrace(PTRACE_TRACEME, ...)。这个调用告诉内核:“嘿,这个子进程归我管了,它的一切异常信号,先通知我,别让它自己处理。”
这就是调试模型的起点。被调试进程(子进程)的生死、暂停、异常,都由调试器(父进程)通过内核来间接控制。
核心要点: 调试器是“观察者”,不是“附身者”。它通过系统调用和信号机制,间接控制被调试进程的执行流。
对于多线程程序,情况稍微复杂一点。GDB调试多线程时,默认是“全停模式”。什么意思?就是只要一个线程触发了断点,GDB会暂停所有线程。这是为了避免线程间的竞态条件导致调试信息混乱。我记得早期调试一个多线程网络服务时,就因为没注意这个模式,单步调试一个线程,结果其他线程还在疯狂收发数据,状态完全对不上,排查了好久才发现是这个问题。
当然,GDB也支持“单线程模式”,你可以用 set scheduler-locking on 来让其他线程继续跑。但这就像在雷区里跳舞,你得非常清楚自己在做什么。
2.2 调试符号(DWARF)
你想想看,机器只认识0和1,它怎么知道变量名叫 my_counter?怎么知道 main 函数从哪个地址开始?
答案就是调试符号。GDB读取的可执行文件里,除了代码段、数据段,还有一个专门的调试信息段。在Linux世界里,这个格式叫 DWARF(Debugging With Attributed Record Formats)。
DWARF 不是一个人名,它是一个标准。它用一套非常复杂的树形结构,描述了源代码和机器码之间的映射关系。比如:
- 这个地址对应源代码的哪一行?
- 这个变量在栈上的哪个位置?
- 这个函数的参数类型是什么?
GCC编译时加 -g 选项,就是在生成这个 DWARF 信息。不加 -g,GDB 也能调试,但你就看不到源码、变量名了,只能看到赤裸裸的汇编地址。
我的经验: 我曾经接手过一个线上coredump,编译时没加 -g,只有一堆地址。那感觉就像拿着地图碎片拼图,痛苦至极。从那以后,我所有项目的发布包,都会保留一份带 -g 符号的版本,哪怕它很大。这是血的教训。
DWARF 信息可以用 readelf -w 命令查看,你会看到一堆 DW_TAG、DW_AT 开头的条目。别被吓到,你不需要记住所有标签,只需要知道:GDB 就是靠这些信息,把冰冷的地址,翻译成你熟悉的代码行和变量名。
2.3 ptrace 系统调用
如果说 GDB 是将军,那 ptrace 就是它手里的尚方宝剑。ptrace 是一个 Linux 系统调用,它允许一个进程(调试器)观察和控制另一个进程(被调试者)的执行,并读写其内存和寄存器。
它的原型很简单:
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
关键在于第一个参数 request,它决定了你要干什么。常用的有:
| request 值 | 作用 |
|---|---|
PTRACE_TRACEME |
子进程调用,声明自己被父进程追踪 |
PTRACE_ATTACH |
调试器附加到一个正在运行的进程 |
PTRACE_PEEKTEXT |
读取被调试进程的内存 |
PTRACE_POKETEXT |
写入被调试进程的内存 |
PTRACE_GETREGS |
读取被调试进程的寄存器 |
PTRACE_CONT |
让被调试进程继续运行 |
PTRACE_SINGLESTEP |
让被调试进程单步执行一条指令 |
GDB 的几乎所有核心功能,底层都是通过这些 ptrace 请求实现的。比如设置断点,就是通过 PTRACE_POKETEXT 把目标地址的指令替换成一条特殊的“陷阱”指令(比如 x86 上的 int3,即 0xCC)。
注意: ptrace 是一个特权系统调用。普通进程不能随便调试其他进程。要么你是 root,要么被调试进程是你的子进程,或者你有 CAP_SYS_PTRACE 权限。这也是为什么 Docker 容器里默认不能 gdb attach 的原因——容器默认去掉了这个权限。
2.4 断点的本质
好了,终于到了最核心的问题:断点到底是什么?
你可能会觉得,断点就是GDB记住了一个地址,然后程序跑到那里就停。嗯,对了一半。但具体是怎么“停”的呢?
断点的本质,是指令替换。
当你用 b main 设置断点时,GDB 做了两件事:
- 保存原指令:GDB 读取
main函数入口地址处的原始机器指令,把它存到自己的内存里。 - 写入陷阱指令:GDB 用
ptrace(PTRACE_POKETEXT, ...)把那个地址的第一个字节,替换成0xCC(即int3指令)。
当 CPU 执行到 0xCC 时,它会触发一个软中断(trap)。操作系统内核捕获到这个中断,发现这个进程正在被 ptrace 追踪,于是内核发送一个 SIGTRAP 信号给这个进程。但信号不会直接发给进程,而是先通知调试器(GDB)。
GDB 收到信号后,就知道:“哦,我的断点被触发了!” 然后它做三件事:
- 暂停被调试进程。
- 把刚才替换掉的
0xCC恢复成原来的指令(这样你才能看到正确的反汇编)。 - 等待你的命令(比如
next、continue)。
当你输入 continue 时,GDB 会再次把 0xCC 写回去(因为下次可能还要断),然后让程序继续跑。
所以,断点就是一个“偷梁换柱”的把戏。 GDB 用一条会触发异常的指令,替换了你的正常指令。异常发生后,GDB 再悄悄把原指令换回来,让你感觉什么都没发生过。
嗯,这里要注意一个细节:硬件断点。有些CPU(比如ARM)有专门的调试寄存器,可以设置硬件断点,不需要替换指令。但原理类似,都是让CPU在特定地址停下来。GDB 默认用软件断点(指令替换),除非你指定 hbreak。
我曾经在调试一个自修改代码的程序时,遇到了一个诡异的问题:断点设置后,程序跑飞了。排查了半天,才发现那个程序会在运行时修改自己的代码段,而我的断点指令 0xCC 被它当成数据给改了。从那以后,我对自修改代码的程序,一律用硬件断点。
好了,这一章的核心概念就这些。你理解了进程模型、DWARF、ptrace 和断点的本质,就相当于拿到了 GDB 的“源代码”。后面的章节,我们所有的操作,都是建立在这些基石之上的。
一句话总结: GDB 是一个通过 ptrace 系统调用,利用 DWARF 调试符号,通过指令替换(断点)和信号机制,来控制另一个进程执行流的调试器。