欢迎光临
我们一直在努力

深入理解 GDB 调试原理:从 `ptrace`、软件断点到 DWARF 与远程调试

文章目录

  • 深入理解 GDB 调试原理:从 `ptrace`、软件断点到 DWARF 与远程调试
    • 一、先给出结论:GDB 本质上由三层能力组成
  • 二、两个程序:调试器与被调试程序
  • 三、GDB 如何启动一个被调试程序
  • 四、GDB 如何读取寄存器和内存
    • 4.1 读取寄存器
    • 4.2 读取内存
  • 五、软件断点为什么能让程序停下来
    • 5.1 断点不是一个抽象的“暂停标记”
    • 5.2 x86 软件断点:`INT3`
  • 六、命中软件断点后为什么不能直接 `continue`
  • 七、为什么 x86 命中 `INT3` 后需要调整 `RIP`
  • 八、逻辑断点、软件断点和硬件断点不是一回事
    • 8.1 逻辑断点
    • 8.2 软件断点
    • 8.3 硬件断点与观察点
  • 九、单步执行到底是什么
    • 9.1 机器指令级单步:`stepi` / `nexti`
    • 9.2 源码级单步:`step` / `next`
  • 十、CPU 乱序执行为什么没有破坏断点语义
  • 十一、`-g` 到底向可执行文件中加入了什么
  • 十二、DWARF 的核心数据结构:DIE
  • 十三、GDB 如何把源码行转换为机器地址
    • 为什么断点可能跳到下一行
  • 十四、GDB 的 `list` 为什么能显示源代码
  • 十五、GDB 如何找到变量的值
    • 15.1 变量不一定在栈上
    • 15.2 为什么会出现 `<optimized out>`
    • 15.3 类型信息有什么用
  • 十六、栈帧、CFA 与调用栈回溯
    • 16.1 不能只靠 `RBP` 链
    • 16.2 DWARF CFI
    • 16.3 GDB 的回溯信息来源
    • 16.4 为什么栈会回溯失败
  • 十七、函数序言为什么会影响断点位置
  • 十八、信号为什么会让 GDB 停下来
  • 十九、`Ctrl+C` 如何中断正在运行的程序
  • 二十、条件断点是怎样工作的
  • 二十一、GDB 如何求值 `print a + b * 2`
  • 二十二、多线程调试为什么复杂
  • 二十三、`attach` 到正在运行的进程
    • 为什么会出现 `Operation not permitted`
  • 二十四、GDB 与 ELF 符号表是什么关系
  • 二十五、ASLR 与地址重定位
  • 二十六、Core Dump 为什么可以“离线调试”
  • 二十七、远程调试为什么仍然可以使用本机 GDB
  • 二十八、RSP 协议解决什么问题
  • 二十九、GDB/MI 与 RSP 的区别
    • 29.1 GDB/MI
    • 29.2 RSP
    • 29.3 在 VS Code 中可能出现的完整链路
  • 三十、为什么 GDB 需要为 inferior 分配单独终端
  • 三十一、Python API 为什么可以扩展 GDB
  • 三十二、一个极简调试器需要实现什么
    • 32.1 进程控制模块
    • 32.2 寄存器模块
    • 32.3 内存模块
    • 32.4 软件断点模块
    • 32.5 事件循环
  • 三十三、建议亲手验证的三个实验
    • 实验一:观察软件断点修改代码字节
    • 实验二:观察源码单步与指令单步的差别
    • 实验三:查看 DWARF
  • 三十四、常见误区修正
    • 误区一:GDB 的全部原理就是 `ptrace`
    • 误区二:断点就是内核提供的 `set_breakpoint` 系统调用
    • 误区三:命中 `INT3` 后恢复原字节并直接继续即可
    • 误区四:变量都在栈上
    • 误区五:DWARF 会把完整源码放进 ELF
    • 误区六:`bt` 只是沿着 `RBP` 链读取返回地址
    • 误区七:源码级 `next` 就是 CPU 执行一条指令
    • 误区八:VS Code 自己实现了底层调试
    • 误区九:所有平台上的 GDB 都依赖 Linux `ptrace`
  • 三十五、从一条 `break` 命令看完整数据流
  • 三十六、总结
    • 第一层:目标执行控制
    • 第二层:处理器调试机制
    • 第三层:源码语义恢复
    • 第四层:工具生态
    • 参考资料

深入理解 GDB 调试原理:从 ptrace、软件断点到 DWARF 与远程调试

本文不以罗列 GDB 命令为目标,而是回答一个更底层的问题: 当我们执行 break、run、next、print 和 bt 时,GDB 到底做了什么?

讨论环境以 Linux + x86-64 + ELF + DWARF 为主。GDB 也支持 Windows、macOS、裸机、模拟器和多种处理器架构,但这些平台控制目标程序所使用的系统接口并不一定是 ptrace。


一、先给出结论:GDB 本质上由三层能力组成

很多人会把 GDB 的原理概括为:

GDB 通过 ptrace 控制另一个进程。

这句话没有错,但只描述了最底层的一部分。

一个真正可用的源码级调试器至少需要三层能力:

┌─────────────────────────────────────────────┐
│ 用户交互层 │
│ CLI、TUI、IDE、VS Code、GDB/MI、Python API │
├─────────────────────────────────────────────┤
│ 符号与语义层 │
│ ELF、符号表、DWARF、表达式求值、栈回溯 │
├─────────────────────────────────────────────┤
│ 目标控制层 │
│ ptrace、寄存器、内存、信号、断点、单步 │
└─────────────────────────────────────────────┘

三层分别解决不同问题:

  • 目标控制层回答:“怎样让程序停下来、继续运行、读取寄存器和内存?”
  • 符号与语义层回答:“源码第 20 行对应哪个机器地址?变量 count 当前存在哪里?它是什么类型?”
  • 用户交互层回答:“怎样把底层信息组织成人类或 IDE 可以使用的接口?”
  • 因此,GDB 不是一个简单的“进程暂停工具”,而是一个把源码世界、机器世界和操作系统进程控制机制连接起来的系统。


    二、两个程序:调试器与被调试程序

    在调试术语中:

    • Debugger / Tracer:调试器,即 GDB;
    • Inferior / Tracee:被调试程序;
    • Target:GDB 所控制的目标,可以是本地进程、远程设备、裸机、内核或模拟器。

    Linux 进程之间通常拥有独立的虚拟地址空间。普通进程不能随意读取或修改另一个进程的内存和寄存器,否则进程隔离将失去意义。

    Linux 为调试器、系统调用跟踪器和故障分析工具提供了 ptrace 系统调用。它允许一个进程在权限允许的情况下:

    • 观察另一个线程的执行状态;
    • 暂停和恢复目标线程;
    • 读取、修改目标内存;
    • 读取、修改目标寄存器;
    • 在系统调用入口或出口处停止;
    • 让目标执行一条机器指令后停止;
    • 接收目标线程的信号停止事件。

    需要特别注意:

    Linux 的 ptrace 操作对象在内核语义上是线程,不是抽象意义上的整个多线程进程。

    一个多线程程序中的每个线程都有自己的寄存器现场、程序计数器和停止状态。GDB 必须管理线程集合,而不是只管理一个 PID。


    三、GDB 如何启动一个被调试程序

    执行下面的命令:

    gdb ./demo
    (gdb) run

    从概念上看,本地 Linux 调试通常包含以下流程:

    GDB
    │
    ├─ 创建子进程
    │
    └─ 子进程声明 PTRACE_TRACEME
    │
    └─ execve("./demo", …)
    │
    └─ 内核产生一次可被调试器观察的停止事件

    一个高度简化的伪代码如下:

    pid_t pid = fork();

    if (pid == 0) {
    ptrace(PTRACE_TRACEME, 0, NULL, NULL);
    execve(program, argv, envp);
    _exit(127);
    }

    for (;;) {
    int status = 0;
    waitpid(pid, &status, 0);

    if (WIFSTOPPED(status)) {
    // 读取停止原因、寄存器和内存,
    // 然后等待用户输入下一条调试命令。
    }

    if (WIFEXITED(status) || WIFSIGNALED(status)) {
    break;
    }
    }

    实际 GDB 远比这段代码复杂。它需要处理:

    • 多线程;
    • fork、vfork、clone 和 exec;
    • 动态链接器事件;
    • 共享库加载与卸载;
    • 信号转发;
    • 异步执行;
    • 非停止模式;
    • 多个 inferior;
    • 不同处理器和操作系统的目标后端。

    但最核心的控制循环确实可以概括为:

    等待目标停止
    ↓
    分析停止原因
    ↓
    读取或修改目标状态
    ↓
    根据用户命令恢复目标
    ↓
    再次等待目标停止

    GDB 并不是一直“占用 CPU 盯着程序”。大部分时间里,GDB 阻塞在类似 waitpid() 的等待操作中,直到内核报告目标线程停止、退出或发生其他事件。


    四、GDB 如何读取寄存器和内存

    4.1 读取寄存器

    在 x86-64 上,程序当前执行到哪里由 RIP 决定,栈顶由 RSP 决定,其他通用寄存器保存参数、中间值和地址。

    当用户执行:

    info registers

    GDB 会通过目标后端请求寄存器集合。在 Linux 本地调试中,底层通常会使用类似:

    ptrace(PTRACE_GETREGSET, tid, ...);

    或架构相关的寄存器读取请求。

    GDB 得到的不是“变量”,而是一组机器寄存器的原始位模式。随后,GDB 再结合处理器架构定义,解释这些位分别对应 RIP、RSP、RAX、向量寄存器等。

    4.2 读取内存

    当用户执行:

    x/16xb 0x555555555149

    或打印一个存放在内存中的变量时,GDB 会向目标后端发起内存读取。

    传统 ptrace 接口中的 PTRACE_PEEKDATA、PTRACE_PEEKTEXT 通常按机器字读取,而不是天然按一个字节读取。因此,若调试器只想修改一个字节,往往需要:

  • 读取包含该字节的整个机器字;
  • 修改本地副本中的目标字节;
  • 将整个机器字写回。
  • 现代调试器也可能根据系统能力使用其他更高效的进程内存访问接口,但这不改变基本模型:

    GDB 看到的是目标地址空间中的字节;变量名、类型和作用域来自另外的调试信息。


    五、软件断点为什么能让程序停下来

    5.1 断点不是一个抽象的“暂停标记”

    当用户执行:

    break main

    GDB 首先得到的是一个逻辑断点:

    “当程序执行到 main 对应的位置时停止”

    但 CPU 并不认识“函数名”或“源码行号”。CPU 只认识机器地址和机器指令。

    GDB 必须先完成两步转换:

    函数名或源码行
    ↓
    通过符号和行号信息找到机器地址
    ↓
    在目标地址上安装处理器能够识别的断点

    5.2 x86 软件断点:INT3

    在 x86 和 x86-64 上,最常见的软件断点指令是:

    int3

    它的机器码只有一个字节:

    0xCC

    假设原始指令为:

    地址 0x401136:55 48 89 e5 …

    GDB 在 0x401136 安装软件断点后,目标内存可能暂时变为:

    地址 0x401136:cc 48 89 e5 …

    GDB 同时必须保存被覆盖的原始字节 0x55。

    CPU 执行到 0xCC 时会产生断点异常。Linux 内核识别到这是一个被跟踪线程后,使线程进入 ptrace-stop,并向 GDB 报告通常表现为 SIGTRAP 的停止原因。

    完整链路如下:

    CPU 取到 0xCC
    ↓
    产生断点异常
    ↓
    进入内核异常处理
    ↓
    目标线程进入 ptrace-stop
    ↓
    GDB 的 waitpid() 返回
    ↓
    GDB 检查停止原因
    ↓
    将停止位置解释为某个逻辑断点
    ↓
    向用户显示源码、线程和栈帧

    这里应使用“异常”或“陷阱”来描述 INT3,而不是把它简单理解成普通外部硬件中断。


    六、命中软件断点后为什么不能直接 continue

    这是软件断点原理中最容易被省略的一步。

    假设 GDB 把某条原始指令的首字节替换成了 0xCC。目标命中断点后,如果 GDB只是把程序计数器移回去,然后直接继续运行,那么 CPU 会再次执行 0xCC,程序会立刻再次停在同一位置。

    因此,GDB 通常需要执行如下过程:

    1. 识别命中的断点
    2. 恢复该地址处的原始指令字节
    3. 将程序计数器调整到原始指令起始地址
    4. 让目标只执行这一条原始指令
    5. 目标再次停止
    6. 重新写入断点指令 0xCC
    7. 再执行普通 continue

    用伪代码表示:

    restore_original_byte(bp.address);
    set_pc(bp.address);

    ptrace(PTRACE_SINGLESTEP, tid, NULL, NULL);
    waitpid(tid, &status, 0);

    write_breakpoint_opcode(bp.address); // 重新写入 0xCC
    ptrace(PTRACE_CONT, tid, NULL, NULL);

    这也是为什么断点管理不能只保存一个地址。GDB 至少还需要维护:

    struct SoftwareBreakpoint {
    uintptr_t address;
    unsigned char original_byte;
    bool inserted;
    bool enabled;
    };

    真实实现还必须考虑:

    • 多个逻辑断点映射到同一地址;
    • 多线程同时运行;
    • 断点所在页的内存属性;
    • 共享库重定位;
    • 临时断点;
    • 条件断点;
    • 目标自行修改代码;
    • 远程目标是否由 GDB 还是远端 stub 插入断点。

    七、为什么 x86 命中 INT3 后需要调整 RIP

    x86 执行 INT3 后,CPU 报告的指令指针通常已经指向 INT3 后面的地址。

    假设:

    0x401136: cc
    0x401137: 48

    目标停下时,RIP 可能是:

    0x401137

    但用户认为自己停在:

    0x401136

    因此 GDB 需要根据架构的断点规则,把停止地址修正为断点地址。对单字节 INT3 来说,通常表现为将程序计数器回退一个字节。

    不要把“RIP – 1”理解为所有架构都适用的规则。不同架构的断点指令长度和异常语义不同:

    • x86/x86-64 常用 INT3;
    • AArch64 常用 BRK;
    • RISC-V 常用 EBREAK;
    • 某些目标可能使用远端 stub 或硬件调试资源。

    断点地址修正规则属于 GDB 的架构相关目标实现。


    八、逻辑断点、软件断点和硬件断点不是一回事

    8.1 逻辑断点

    用户创建的是逻辑断点:

    break foo
    break demo.cpp:42
    break *0x401136

    它包含:

    • 用户指定的位置;
    • 条件;
    • 命中次数;
    • 命令列表;
    • 是否启用;
    • 是否临时;
    • 可能对应的多个实际地址。

    例如 C++ 模板、内联函数和重载函数可能让一个源码位置解析成多个机器地址。

    8.2 软件断点

    软件断点通过修改代码字节实现,数量通常不受少量调试寄存器限制,但存在以下代价:

    • 修改了目标代码内存;
    • 命中后需要恢复、单步和重新插入;
    • 只读存储器或某些受保护目标上可能无法使用;
    • 多线程环境中需要协调其他线程。

    8.3 硬件断点与观察点

    处理器通常提供有限数量的硬件调试寄存器,可监控:

    • 指令地址执行;
    • 某个地址被写入;
    • 某个地址被读取或写入。

    GDB 的 watch、rwatch、awatch 在条件允许时可使用硬件观察点。

    与软件断点不同,硬件观察点通常不需要修改目标内存,而是由 CPU 在发生指定内存访问时触发调试异常。

    限制也很明显:

    • 数量有限;
    • 支持的长度、对齐和访问类型受架构限制;
    • 复杂表达式可能无法直接映射为硬件观察点;
    • 无硬件支持时,软件观察点可能需要反复单步并比较值,速度很慢。

    九、单步执行到底是什么

    “单步”至少有两个完全不同的层次。

    9.1 机器指令级单步:stepi / nexti

    Linux ptrace 提供 PTRACE_SINGLESTEP。它要求目标执行一条机器指令后再次停止。

    在 x86 上,底层可利用处理器的陷阱标志等调试机制,使 CPU 在完成一条指令后产生调试异常。GDB 重新从 waitpid() 中获得控制权。

    概念流程:

    当前停止在指令 A
    ↓
    GDB 请求 SINGLESTEP
    ↓
    CPU 执行指令 A
    ↓
    CPU 产生单步调试异常
    ↓
    内核使目标停止
    ↓
    GDB 再次获得控制权

    9.2 源码级单步:step / next

    源码的一行可能对应:

    • 零条机器指令;
    • 一条机器指令;
    • 多条机器指令;
    • 多个不连续的地址区间;
    • 被内联到多个位置的指令;
    • 被优化器移动或删除的代码。

    因此,源码级 step 并不等于执行一次 PTRACE_SINGLESTEP。

    GDB 需要结合 DWARF 行号表判断:

    当前 PC 属于哪一行
    下一条机器指令属于哪一行
    是否进入了一个有调试信息的函数
    是否应该停在编译器生成的函数序言中

    源码级单步通常由以下技术组合实现:

    • 机器指令单步;
    • 临时断点;
    • 行号表查询;
    • 函数调用分析;
    • 栈帧变化检测;
    • 架构相关的软件单步算法。

    next 与 step 的核心区别是:

    • step 允许进入被调用函数;
    • next 试图把函数调用整体视为当前源码行的一部分,在调用返回后再停止。

    一种简化的 next 实现思路是:

  • 分析当前指令是否为函数调用;
  • 计算调用后的返回地址;
  • 在返回地址设置临时断点;
  • 继续运行;
  • 返回地址断点命中后删除临时断点。
  • 真实情况还要处理递归、异常、长跳转、尾调用、信号和多线程等问题。


    十、CPU 乱序执行为什么没有破坏断点语义

    现代 CPU 会进行:

    • 多发射;
    • 流水线执行;
    • 乱序执行;
    • 分支预测;
    • 推测执行。

    从微架构内部看,断点后面的指令可能已经被取指,甚至部分执行。但调试器要求看到一种严格状态:

    断点之前的指令全部生效
    断点指令尚未作为普通程序逻辑继续执行
    断点之后的指令没有对体系结构状态产生可见影响

    处理器通过精确异常机制保证异常在体系结构层面表现为发生在明确的指令边界。

    乱序执行处理器通常借助重排序缓冲区等结构,让指令可以乱序执行,但按程序顺序提交体系结构结果。发生异常时:

  • 提交异常指令之前允许提交的结果;
  • 丢弃更年轻指令的推测结果;
  • 保存一个可恢复的体系结构状态;
  • 转入异常处理程序。
  • 因此,GDB 读取到的是一个架构承诺的精确状态,而不是流水线中间的混乱状态。

    这部分能力主要由处理器和内核保证,不是 GDB 自己修复出来的。


    十一、-g 到底向可执行文件中加入了什么

    如果只有 ptrace,GDB 可以做到:

    • 在某个数值地址停止;
    • 显示寄存器;
    • 读取某段内存;
    • 单步执行机器指令。

    但它不知道:

    • main 在哪里;
    • demo.cpp:42 对应哪个地址;
    • user 是什么类型;
    • 局部变量 count 当前存在哪里;
    • 某个地址属于哪个函数和源码行;
    • 当前栈帧如何恢复调用者。

    这些信息主要由编译器在 -g 选项下生成。Linux ELF 环境中最常见的调试信息格式是 DWARF。

    例如:

    g++ -g -O0 demo.cpp -o demo

    -g 的核心意义不是“让 GDB 获得权限”,而是:

    让编译器记录源码结构与生成后机器程序之间的映射关系。

    调试信息通常位于 ELF 的若干调试节中。常见内容包括:

    .debug_info 编译单元、函数、变量、类型等主体信息
    .debug_abbrev DIE 属性编码的缩写表
    .debug_line 源文件、行号与机器地址的映射
    .debug_str 调试字符串
    .debug_loclists 变量位置随 PC 变化的位置列表
    .debug_rnglists 代码地址范围列表
    .debug_frame 调试栈展开信息
    .eh_frame 常用于异常展开,也可用于栈回溯

    不同编译器、DWARF 版本和链接选项生成的具体节可能不同。调试信息也可以从主程序中剥离,放入单独的 debug file。


    十二、DWARF 的核心数据结构:DIE

    DWARF 使用一种层级化的条目表示源码结构。条目称为:

    Debugging Information Entry,DIE

    一个简化的结构可能如下:

    DW_TAG_compile_unit
    ├── DW_TAG_base_type int
    ├── DW_TAG_structure_type User
    │ ├── DW_TAG_member id
    │ └── DW_TAG_member name
    ├── DW_TAG_subprogram calculate
    │ ├── DW_TAG_formal_parameter value
    │ ├── DW_TAG_variable result
    │ └── DW_TAG_lexical_block
    │ └── DW_TAG_variable i
    └── DW_TAG_subprogram main

    不同 DIE 通过属性描述:

    • 名称;
    • 类型;
    • 声明文件、行、列;
    • 地址范围;
    • 是否内联;
    • 变量位置;
    • 参数位置;
    • 词法作用域;
    • 子节点关系。

    这使 GDB 能够把机器状态重新解释成源码语义。


    十三、GDB 如何把源码行转换为机器地址

    用户执行:

    break demo.cpp:42

    GDB 必须查询行号程序,也就是通常位于 .debug_line 中的映射信息。

    可以把行号表粗略理解为:

    机器地址源文件行号状态
    0x401136 demo.cpp 40 statement
    0x40113d demo.cpp 41 statement
    0x401147 demo.cpp 42 statement
    0x401154 demo.cpp 42 continuation
    0x401160 demo.cpp 43 statement

    实际 DWARF 行号表是一段状态机程序,不只是一个简单静态数组,但最终可以产生类似的地址—源码位置矩阵。

    于是:

    break demo.cpp:42
    ↓
    查找第 42 行可停止的地址
    ↓
    得到 0x401147
    ↓
    在 0x401147 插入软件或硬件断点

    反向查询同样成立:

    当前 RIP = 0x401154
    ↓
    查询行号表
    ↓
    显示 demo.cpp:42

    为什么断点可能跳到下一行

    某一源码行可能没有对应的可执行机器指令,例如:

    int x; // 可能只声明,不产生当前可停止指令

    或者代码被优化删除。此时 GDB 可能把断点移动到下一个可停止位置。

    因此,“在第 42 行设置断点”并不等价于“CPU 一定在与文本第 42 行一一对应的指令上停止”。


    十四、GDB 的 list 为什么能显示源代码

    通常情况下,DWARF 并不会完整嵌入一份源码文本,而是保存:

    • 编译目录;
    • 源文件路径;
    • 文件索引;
    • 行号信息。

    GDB 根据这些路径重新打开源文件。

    这会产生几个常见问题:

  • 二进制从构建机器复制到另一台机器后,原始绝对路径不存在;
  • 源码目录发生移动;
  • 当前源码已经修改,与二进制不匹配;
  • 容器、远程设备与本机路径不同。
  • 此时可以通过 GDB 的源码路径替换和搜索目录机制建立映射。

    关键原则是:

    调试器中的源码必须与生成该二进制的源码版本匹配,否则显示的行和实际机器指令可能不一致。


    十五、GDB 如何找到变量的值

    用户执行:

    print count

    看起来只是读取一个变量,实际上 GDB 至少要完成以下工作:

    1. 根据当前栈帧和词法作用域查找名为 count 的符号
    2. 找到变量对应的 DWARF DIE
    3. 解析变量类型
    4. 根据当前 PC 选择适用的位置描述
    5. 执行 DWARF 位置表达式
    6. 从寄存器、内存或常量中取得原始值
    7. 按语言和类型规则格式化结果

    15.1 变量不一定在栈上

    变量可能位于:

    • 某个寄存器;
    • 栈帧中的某个偏移;
    • 全局内存地址;
    • TLS 区域;
    • 多个寄存器或内存片段的组合;
    • 一个可直接计算出的常量;
    • 在不同 PC 范围内位于不同位置;
    • 已经被优化删除,当前没有可恢复位置。

    因此,“GDB 根据 CFA 加一个固定偏移读取所有局部变量”是错误的概括。

    DWARF 可以用位置表达式描述复杂位置,例如概念上的:

    DW_OP_fbreg -24

    表示相对于帧基址的偏移;也可能是:

    DW_OP_reg5

    表示值位于某个寄存器;还可能使用位置列表:

    PC 范围 A:变量在寄存器 RAX
    PC 范围 B:变量在栈偏移 -24
    PC 范围 C:变量没有可用位置

    15.2 为什么会出现 <optimized out>

    开启优化后,编译器可能:

    • 删除从未被观察的变量;
    • 把变量替换为常量;
    • 合并多个变量;
    • 复用同一寄存器;
    • 改变语句顺序;
    • 内联函数;
    • 删除栈帧;
    • 把一个变量拆分到多个位置。

    如果调试信息无法描述或恢复当前源码变量,GDB 会显示:

    <optimized out>

    这不一定是 GDB 读取失败,而可能是机器程序中已经不存在一个独立、稳定的“该变量当前值”。

    15.3 类型信息有什么用

    类型 DIE 可以描述:

    • 基本类型大小和编码;
    • 指针目标类型;
    • 数组边界;
    • 结构体成员及偏移;
    • 继承关系;
    • 枚举;
    • 函数参数和返回类型;
    • const、volatile 等限定信息。

    于是 GDB 才能把同一组字节解释为:

    整数、浮点数、指针、数组、结构体或对象

    打印 C 字符串不仅依赖“这是一个指针”,还依赖当前语言规则、目标类型和 GDB 的表达式求值语义。


    十六、栈帧、CFA 与调用栈回溯

    执行:

    backtrace

    GDB 会尝试从当前栈帧逐层恢复调用者。

    一个栈帧通常需要恢复:

    • 调用者的程序计数器;
    • 调用者的栈指针;
    • 被保存的寄存器;
    • 当前函数参数和局部变量的上下文。

    16.1 不能只靠 RBP 链

    在关闭帧指针省略时,函数可能形成传统链表:

    当前 RBP
    ↓
    保存的上一个 RBP
    ↓
    更上层 RBP

    这使栈回溯比较直观。

    但优化构建中,编译器经常省略帧指针,将 RBP 当普通寄存器使用。此时仅沿 RBP 链回溯会失败。

    16.2 DWARF CFI

    编译器可以生成 Call Frame Information,描述在不同指令位置:

    • CFA 怎样计算;
    • 返回地址保存在哪里;
    • 某个寄存器的调用者值保存在哪里。

    CFA 可以理解为一个用于描述调用帧的稳定参考值,但它不必永远等于某个固定寄存器。

    概念规则可能是:

    CFA = RSP + 16
    返回地址 = *(CFA – 8)
    调用者 RBP = *(CFA – 16)

    当函数序言继续执行后,规则也可能变化:

    CFA = RBP + 16

    GDB 根据当前 PC 选择正确的展开规则,恢复上一帧,然后重复这一过程。

    16.3 GDB 的回溯信息来源

    GDB 可能综合使用:

    • 当前寄存器;
    • 栈内存;
    • DWARF .debug_frame;
    • .eh_frame 展开信息;
    • 帧指针;
    • ABI 约定;
    • 架构相关的 prologue 分析;
    • 操作系统或语言运行时提供的特殊 unwinder。

    所以 bt 并不是简单地“不断读取栈上的返回地址”。

    16.4 为什么栈会回溯失败

    常见原因包括:

    • 栈内存越界写被破坏;
    • 返回地址被覆盖;
    • 缺少展开信息;
    • 调试符号不匹配;
    • JIT 或手写汇编没有正确展开描述;
    • 当前停在异步信号、异常或特殊 trampoline 中;
    • 编译器优化造成尾调用;
    • 可执行文件、共享库和 core 文件版本不一致。

    十七、函数序言为什么会影响断点位置

    一个函数开始处可能先执行编译器生成的序言:

    push rbp
    mov rbp, rsp
    sub rsp, 0x20
    mov DWORD PTR [rbp-0x14], edi

    这些指令负责:

    • 建立栈帧;
    • 保存寄存器;
    • 分配局部变量空间;
    • 把参数移动到编译器选择的位置。

    当用户执行:

    break function_name

    GDB 可能选择函数入口后的某个“第一条源码语句”地址,而不是符号的最低地址。这样停止时,函数参数和栈帧通常已经处于更适合源码级观察的状态。

    不过具体行为取决于:

    • 调试信息;
    • 编译器;
    • 优化级别;
    • GDB 的 prologue 分析;
    • 用户使用的是函数断点、地址断点还是行断点。

    十八、信号为什么会让 GDB 停下来

    Linux 中,除断点陷阱外,程序还可能因为各种信号停止:

    • SIGSEGV:非法内存访问;
    • SIGILL:非法指令;
    • SIGFPE:算术异常;
    • SIGABRT:主动中止;
    • SIGTRAP:调试陷阱;
    • SIGINT:终端中断。

    被 ptrace 跟踪的线程在信号交付过程中通常会先进入停止状态,由 tracer 获得观察和控制机会。

    于是 GDB 可以决定:

    是否向用户报告该信号
    是否让目标停住
    是否把信号继续传递给目标程序
    是否压制或替换该信号

    这就是 GDB 中 handle 配置的底层意义。

    例如发生段错误时:

    CPU 检测页故障或权限错误
    ↓
    内核判断无法由正常缺页机制解决
    ↓
    准备向进程交付 SIGSEGV
    ↓
    由于进程正在被跟踪,先产生 ptrace-stop
    ↓
    GDB 获得控制权
    ↓
    GDB 显示停止位置、寄存器和调用栈

    此时程序通常还没有按照默认动作立即终止,所以用户仍能检查现场。


    十九、Ctrl+C 如何中断正在运行的程序

    当 inferior 正在运行时,GDB 需要一种异步方式重新获得控制权。

    在本地终端环境中,Ctrl+C 涉及:

    • 终端前台进程组;
    • SIGINT;
    • GDB 与 inferior 的终端控制关系;
    • GDB 对目标发出的中断请求;
    • 是否把 SIGINT 继续传给 inferior。

    具体行为取决于运行模式和终端配置,但核心仍然是:

    GDB 先让目标产生一个可观察的停止事件,然后决定该信号是否应继续交给被调试程序。

    因此,“在 GDB 中按 Ctrl+C”和“程序脱离调试器直接收到 SIGINT”并不总是完全相同。


    二十、条件断点是怎样工作的

    用户可以设置:

    break foo if count == 100

    一种常见实现方式是:

  • 在 foo 对应地址安装普通断点;
  • 每次命中后目标先停止;
  • GDB 读取变量并计算表达式 count == 100;
  • 条件为真时向用户报告;
  • 条件为假时自动执行断点跨越流程并继续。
  • 这意味着条件断点可能频繁触发调试器与目标之间的上下文切换,性能开销较大。

    某些目标支持 agent expression 或条件下放,可以让远端 stub 在目标侧判断条件,减少主机与远端之间的通信。但这属于目标能力优化,不是所有场景都具备。


    二十一、GDB 如何求值 print a + b * 2

    GDB 不只是读取符号,还内置了表达式解析器。

    执行:

    print a + b * 2

    大致包含:

    解析表达式语法
    ↓
    按照当前语言查找符号 a、b
    ↓
    解析类型和隐式转换
    ↓
    定位并读取变量值
    ↓
    在调试器中完成运算
    ↓
    按目标语言格式打印

    某些表达式可能要求调用 inferior 中的函数:

    print object.method()

    这类操作比纯读取危险得多,因为 GDB 可能需要临时改变寄存器和栈、在目标中执行函数,再恢复现场。被调用函数还可能:

    • 修改程序状态;
    • 阻塞;
    • 产生异常;
    • 获取锁;
    • 创建线程;
    • 造成新的崩溃。

    因此调试器中的函数调用不是无副作用的查询。


    二十二、多线程调试为什么复杂

    软件断点修改的是进程共享的代码地址,而寄存器和执行位置属于各个线程。

    设想线程 A 命中断点,GDB 正准备:

  • 恢复原始指令;
  • 单步越过;
  • 重新插入断点。
  • 此时如果线程 B 仍在运行,它可能在断点暂时被移除的窗口中经过同一地址,从而漏掉断点。

    因此 GDB 需要根据调试模式进行线程协调,例如:

    • all-stop:一个线程停止时,其他线程也停止;
    • non-stop:各线程可独立运行和停止;
    • scheduler locking:控制单步时允许哪些线程运行;
    • 为断点跨越设置线程专属控制状态。

    多线程断点处理不是“给每个线程各写一次 0xCC”。代码页通常在同一进程的线程间共享,真正困难的是运行时协调。


    二十三、attach 到正在运行的进程

    执行:

    gdb -p <pid>

    GDB 会尝试附加到现有进程。Linux 可以使用 PTRACE_ATTACH 或更现代的附加控制方式建立跟踪关系。

    附加后通常需要:

    • 让目标线程进入停止状态;
    • 枚举线程;
    • 读取寄存器;
    • 识别可执行文件;
    • 读取进程内存映射;
    • 加载共享库符号;
    • 建立线程和 inferior 状态。

    为什么会出现 Operation not permitted

    ptrace 可以读取密码、密钥、令牌和其他敏感内存,也可以修改程序执行,因此它受到严格权限控制。

    影响附加权限的因素包括:

    • UID 和进程 dumpable 状态;
    • CAP_SYS_PTRACE;
    • Yama LSM 的 ptrace_scope;
    • PID namespace;
    • 容器安全策略;
    • seccomp;
    • 目标进程主动设置的限制。

    不要把“使用 root”当成常规解决方案。正确做法是先理解系统的安全策略,并只授予必要权限。


    二十四、GDB 与 ELF 符号表是什么关系

    DWARF 与普通符号表不是同一概念。

    ELF 符号表可以提供:

    • 函数和全局对象名称;
    • 符号地址或节偏移;
    • 绑定和可见性;
    • 符号大小。

    DWARF 则能进一步提供:

    • 源文件与行号;
    • 局部变量;
    • 词法作用域;
    • 复杂类型;
    • 内联调用;
    • 变量位置表达式;
    • 栈展开规则。

    一个被 strip 的二进制仍然可以运行,因为 CPU 不需要源码符号。但它可能只保留动态链接所必需的少量符号,导致 GDB 只能显示:

    0x00007f… in ?? ()

    调试生产崩溃时,常见工程实践是:

  • 发布剥离调试信息的二进制;
  • 在符号服务器或构建归档中保存对应 debug file;
  • 通过 build-id 等机制匹配二进制和符号;
  • 分析 core 时加载完全对应的符号文件。

  • 二十五、ASLR 与地址重定位

    启用 ASLR 后,同一个程序每次运行时的实际加载地址可能不同。

    DWARF 和 ELF 中的地址不能总被简单当作最终虚拟地址。GDB 需要结合:

    • ELF 装载段;
    • PIE;
    • 共享库基址;
    • 进程内存映射;
    • 重定位偏移;
    • 动态加载事件。

    概念上:

    链接时地址 + 运行时装载偏移 = 实际虚拟地址

    因此远程调试或 core 调试时,只有符号文件还不够,还必须知道各模块在目标地址空间中的实际映射位置。


    二十六、Core Dump 为什么可以“离线调试”

    core dump 是进程在某个时刻的状态快照,通常包含:

    • 线程寄存器;
    • 部分或全部虚拟内存映射内容;
    • 信号和线程相关说明信息;
    • 被映射文件的信息。

    使用:

    gdb ./program core

    GDB 将:

    • 可执行文件和共享库作为代码、只读数据与符号来源;
    • core 文件作为崩溃时寄存器和内存来源;
    • DWARF 作为源码、类型、变量和栈展开语义来源。

    因此 core 调试可以执行:

    bt
    info registers
    frame 3
    print variable
    x/32gx address

    但不能真正继续运行原进程,因为 core 只是快照,没有一个仍受操作系统调度的活动进程。

    core 分析最关键的要求是版本匹配:

    可执行文件、共享库、调试符号、源码和 core 必须对应同一次构建或同一部署版本。

    否则地址解释、结构体布局和栈回溯都可能错误。


    二十七、远程调试为什么仍然可以使用本机 GDB

    嵌入式设备可能:

    • 存储空间小;
    • 没有完整 shell;
    • 不适合运行庞大的 GDB;
    • 与开发机架构不同。

    因此常见架构是:

    开发机 目标机
    ┌──────────────┐ RSP ┌──────────────┐
    │ GDB │ ◄─────────────────► │ gdbserver │
    │ 符号与DWARF │ │ 目标控制后端 │
    └──────────────┘ └──────────────┘

    GDB 在开发机上负责复杂工作:

    • 解析 ELF 和 DWARF;
    • 表达式求值;
    • 源码行映射;
    • 管理断点;
    • 展示调用栈;
    • 用户交互。

    gdbserver 在目标侧负责:

    • 读取和写入内存;
    • 读取和写入寄存器;
    • 继续和单步;
    • 插入或移除断点;
    • 报告线程与停止原因。

    双方通过 GDB Remote Serial Protocol,RSP 通信。


    二十八、RSP 协议解决什么问题

    RSP 是 GDB 与远程 target stub 之间的协议。它最初面向串行链路,也可运行在 TCP 等传输之上。

    典型数据包概念格式:

    $payload#checksum

    常见操作包括:

    读取寄存器
    写入寄存器
    读取内存
    写入内存
    继续运行
    单步执行
    插入或删除软件断点
    查询线程
    报告停止原因
    传输目标描述

    关键点是:

    RSP 规定“双方说什么”,不规定目标端必须用什么方式实现。

    因此 RSP 对端可以是:

    • gdbserver,内部使用操作系统调试接口;
    • QEMU,直接读取模拟 CPU 和模拟内存;
    • OpenOCD,通过 JTAG/SWD 控制芯片;
    • 裸机调试 stub;
    • 自定义虚拟机或仿真器。

    QEMU 不需要用 ptrace 控制它所模拟的客户机 CPU。它本身已经拥有模拟寄存器、内存和执行循环,只需把这些状态通过 RSP 暴露给 GDB。


    二十九、GDB/MI 与 RSP 的区别

    这两个协议经常被混淆。

    29.1 GDB/MI

    MI 是 Machine Interface,用于让 IDE 或调试器前端控制 GDB。

    IDE / Debug Adapter
    │
    │ GDB/MI
    ▼
    GDB

    它提供机器可解析的命令和结构化输出,例如:

    -break-insert main
    -exec-run
    -stack-list-frames
    -var-create

    29.2 RSP

    RSP 用于 GDB 控制目标端:

    GDB
    │
    │ RSP
    ▼
    gdbserver / QEMU / OpenOCD / stub

    29.3 在 VS Code 中可能出现的完整链路

    更准确的概念架构通常是:

    VS Code
    │
    │ Debug Adapter Protocol(DAP)
    ▼
    C/C++ 调试适配器
    │
    │ GDB/MI
    ▼
    GDB
    │
    │ 本地 ptrace 或远程 RSP
    ▼
    目标进程 / gdbserver / QEMU

    因此不应简单说“VS Code 直接通过 MI 调试程序”。通常是扩展中的调试适配器承担 DAP 与 GDB/MI 之间的转换。


    三十、为什么 GDB 需要为 inferior 分配单独终端

    GDB 自己需要读取调试命令,而被调试程序也可能需要:

    • 从标准输入读取;
    • 向标准输出和标准错误输出;
    • 接收终端信号;
    • 查询终端属性。

    如果二者无协调地共享同一终端,输入输出和前台进程组控制会混在一起。

    IDE 调试器常为 inferior 创建伪终端 PTY:

    GDB/IDE 控制通道:发送调试命令
    Inferior PTY:程序自己的 stdin/stdout/stderr

    这样程序输出窗口和调试控制台可以相互独立。


    三十一、Python API 为什么可以扩展 GDB

    GDB 内嵌 Python 支持后,可以把内部对象暴露给脚本,例如:

    • inferior;
    • thread;
    • frame;
    • symbol;
    • type;
    • value;
    • breakpoint;
    • event。

    脚本可以:

    • 注册新命令;
    • 实现 pretty-printer;
    • 自动分析数据结构;
    • 在断点命中时执行逻辑;
    • 导出程序状态;
    • 构建特定领域的可视化工具;
    • 编写内核、数据库或虚拟机专用调试插件。

    例如,STL 容器的友好打印并不是 CPU 或 ptrace 提供的能力,而是调试器结合 C++ 类型布局和 pretty-printer 生成的高级展示。

    GDB 的层次由此再次体现出来:

    ptrace 只提供原始状态
    DWARF 赋予状态以源码语义
    Python 可以再赋予这些语义以领域相关展示


    三十二、一个极简调试器需要实现什么

    如果只针对 Linux x86-64,最小实验性调试器可以按以下模块设计。

    32.1 进程控制模块

    launch()
    attach()
    wait_event()
    continue()
    single_step()
    detach()

    32.2 寄存器模块

    get_registers()
    set_registers()
    get_pc()
    set_pc()

    32.3 内存模块

    read_memory(address, size)
    write_memory(address, bytes)

    32.4 软件断点模块

    add_breakpoint(address)
    remove_breakpoint(address)
    handle_breakpoint_hit()
    step_over_breakpoint()

    32.5 事件循环

    while (running) {
    StopEvent event = wait_for_stop();

    switch (event.kind) {
    case StopKind::Breakpoint:
    handle_breakpoint(event);
    break;

    case StopKind::Signal:
    handle_signal(event);
    break;

    case StopKind::Exited:
    running = false;
    break;

    default:
    break;
    }

    execute_user_command();
    }

    仅完成这些模块,你得到的是一个机器级调试器。

    若要接近 GDB 的源码级体验,还要继续实现:

    • ELF 解析;
    • 符号解析;
    • DWARF DIE;
    • 行号程序;
    • 位置表达式;
    • 类型系统;
    • 栈展开;
    • C/C++ 表达式解析;
    • 共享库管理;
    • 多线程;
    • 远程协议。

    真正困难的部分通常不是让程序停下来,而是把停止时的机器状态准确还原成程序员能理解的源码状态。


    三十三、建议亲手验证的三个实验

    实验一:观察软件断点修改代码字节

    示例程序:

    #include <stdio.h>

    int main(void) {
    int a = 1;
    int b = 2;
    int c = a + b;
    printf("%d\\n", c);
    return 0;
    }

    编译:

    gcc -g -O0 demo.c -o demo

    进入 GDB:

    disassemble /m main
    break demo.c:5
    set breakpoint always-inserted on

    找到断点地址后分别查看内存:

    x/8xb ADDRESS

    可以观察对应位置是否出现 0xCC。不同 GDB 版本、目标状态和断点插入策略可能使插入时机不同,因此应结合:

    maintenance info breakpoints

    以及程序是否已经启动进行判断。

    实验二:观察源码单步与指令单步的差别

    layout asm
    layout regs
    stepi
    step
    next

    对比:

    • stepi 每次执行一条机器指令;
    • step 根据源码行和函数信息决定停止位置;
    • 一次源码级 step 可能跨过多条机器指令。

    实验三:查看 DWARF

    readelf –debug-dump=info demo
    readelf –debug-dump=decodedline demo
    objdump –dwarf=info demo
    objdump –dwarf=decodedline demo

    重点寻找:

    DW_TAG_compile_unit
    DW_TAG_subprogram
    DW_TAG_variable
    DW_AT_name
    DW_AT_type
    DW_AT_location
    DW_AT_low_pc
    DW_AT_high_pc

    将变量位置描述与反汇编中的寄存器、栈偏移对应起来,可以直观看到 GDB 如何从名字走到机器状态。


    三十四、常见误区修正

    误区一:GDB 的全部原理就是 ptrace

    错误。ptrace 主要解决目标控制。源码行、变量、类型和栈回溯依赖符号、DWARF、ABI 和 GDB 自身语义处理。

    误区二:断点就是内核提供的 set_breakpoint 系统调用

    错误。Linux ptrace 没有一个通用的“按源码行设置断点”操作。GDB 解析地址后,通常通过修改指令或配置硬件调试资源实现。

    误区三:命中 INT3 后恢复原字节并直接继续即可

    不完整。若断点仍启用,需要先单步执行被恢复的原始指令,再重新插入断点,最后继续。

    误区四:变量都在栈上

    错误。变量可位于寄存器、内存、TLS、常量或多个片段中,也可能被优化删除。

    误区五:DWARF 会把完整源码放进 ELF

    通常错误。DWARF 主要保存源码路径和源码—机器程序映射,GDB 通常重新读取外部源码文件。

    误区六:bt 只是沿着 RBP 链读取返回地址

    错误。现代回溯常依赖 DWARF CFI、.eh_frame、架构规则、帧指针和其他 unwinder。

    误区七:源码级 next 就是 CPU 执行一条指令

    错误。源码级单步是由行号信息、临时断点、栈帧分析和机器单步组合形成的高级操作。

    误区八:VS Code 自己实现了底层调试

    通常不准确。常见链路是 VS Code 通过调试适配器控制 GDB,GDB 再控制本地或远程目标。

    误区九:所有平台上的 GDB 都依赖 Linux ptrace

    错误。ptrace 是 Linux/Unix 类系统上的一种目标控制机制。Windows、Darwin、裸机、JTAG 和模拟器都有不同后端。


    三十五、从一条 break 命令看完整数据流

    最后,把整篇文章压缩成一次断点操作。

    用户在 IDE 中点击 demo.cpp:42:

    1. IDE 生成设置断点请求
    2. 调试适配器把请求转换为 GDB/MI 命令
    3. GDB 查找目标源文件和第 42 行
    4. GDB 查询 DWARF 行号表
    5. GDB 得到一个或多个机器地址
    6. 本地调试:
    GDB 通过 Linux 目标后端修改目标内存或硬件寄存器
    远程调试:
    GDB 通过 RSP 请求远端 stub 插入断点
    7. 目标继续运行
    8. CPU 执行断点指令或触发硬件调试条件
    9. 内核或远端 stub 报告停止事件
    10. GDB 识别命中的逻辑断点
    11. GDB 读取寄存器、线程和栈帧
    12. GDB 使用 DWARF 映射回 demo.cpp:42
    13. GDB 计算局部变量位置并读取变量
    14. GDB 通过 MI 把结构化状态返回调试适配器
    15. IDE 高亮源码行并展示变量和调用栈

    用户只看到源码旁边一个红点,背后却贯穿了:

    IDE 协议
    → 调试器前端
    → 符号与 DWARF
    → 操作系统进程控制
    → CPU 异常机制
    → 再沿原路返回


    三十六、总结

    GDB 的核心不是某一条神奇指令,而是一套分层协作机制。

    第一层:目标执行控制

    在 Linux 本地调试中,GDB 借助 ptrace 等系统能力:

    • 启动或附加目标;
    • 等待停止事件;
    • 读写寄存器;
    • 读写内存;
    • 继续执行;
    • 单步执行;
    • 控制信号。

    第二层:处理器调试机制

    处理器提供:

    • 软件断点指令;
    • 单步陷阱;
    • 硬件断点与观察点;
    • 精确异常状态。

    第三层:源码语义恢复

    GDB 借助 ELF、符号表、DWARF 和 ABI:

    • 将源码行映射到机器地址;
    • 查找函数和变量;
    • 解释变量类型;
    • 计算变量位置;
    • 展开调用栈;
    • 处理共享库和地址重定位。

    第四层:工具生态

    GDB 通过:

    • CLI/TUI;
    • GDB/MI;
    • RSP;
    • Python API;
    • IDE 调试适配器;

    与用户、IDE、远程设备、模拟器和专用分析工具连接。

    可以用一句话概括:

    GDB 的工作,是控制机器程序在精确位置停下,再把寄存器和内存中的机器状态,尽可能还原为程序员熟悉的源码状态。

    理解这一点后,break、next、print、watch、bt 和远程调试就不再是一组彼此孤立的命令,而是同一套调试体系在不同层次上的表现。


    参考资料

    本文根据上传的《GDB 调试原理》字幕内容重新组织,并对其中若干简化表述进行了技术修正。主要核对资料包括:

  • GNU GDB 官方手册:Breakpoints、Continuing and Stepping、Signals、Frames、GDB/MI、Remote Serial Protocol、Python API、Core Files。
  • Linux ptrace(2) 系统调用手册。
  • Linux Kernel Documentation:Yama LSM 与 ptrace_scope。
  • DWARF Standards Committee:Introduction to the DWARF Debugging Format。
  • 赞(0)
    未经允许不得转载:171主机测评 » 深入理解 GDB 调试原理:从 `ptrace`、软件断点到 DWARF 与远程调试
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址