探索 Gdb7.0 的新特性反向调试 (reverse debug)

引言

  GDB7.0 是 2009 年 10 月份正式发布的。和多数程序员一样,那则消息并不曾引起我的注意,因为 gdb 为数不多的几个新版本都让人觉得非常平淡。没有让人振奋的新特性。

  一晃几个月过去了,随意浏览 gdb 主页的时候,我突然发现一个叫做反向调试 (reverse debug) 的特性,默默地列在不引人注目的地方。”反向调试”?我们调试总是下一步,下一步,反向调试就是上一步,上一步了?

  经过简单的试用,我发现这是一个非常有用的特性,值得我们去学习和掌握。利用该功能,您可以让被调试进程反向执行。您可能会问,这有什么用处呢?

  嗯,我觉得软件调试往往是一个猜测的过程,一般的普通人似乎不太可能第一次就将断点设置在最正确的位置,所以我们经常会发现重要的代码执行路径已经错过。比如运行到断点之前,程序的某些状态就已经不正确了。以往,我们只好退出当前调试会话,从头再来。

  每次错误的猜测都必须让一切从头再来,如果您的运气不佳,很快就会觉得非常抓狂吧。

  假如有反向调试功能,在这种情况下,我们无须重新启动调试程序,只要简单地让被调试进程反向执行到我们怀疑的地方,如果这里没有问题,我们还可以继续正向执行调试,如此这般。如同我们在学习英语时使用复读机一样,来回将听不懂的部分重放,分析。这无疑将极大地提高工作效率。

  反向调试的使用简介

  反向调试的基本原理为 record/replay。将被调试进程的执行过程录制下来,然后便可以像 DVD 一样的任意反向或正向回放。因此,为了使用反向调试,您首先需要使用 record 命令进行录制。

  此后的调试过程和您以前所熟悉的过程一样,不过您现在多了几个反向控制的命令:

表 1. 反向调试基本命令

Command name description
Reverse-continue ('rc') Continueprogram being debugged but run it in reverse
Reverse-finish Execute backward until just before the selected stack frame is called
Reverse-next ('rn') Step program backward, proceeding through subroutine calls.
Reverse-nexti ('rni') Step backward one instruction, but proceed through called subroutines.
Reverse-step ('rs') Step program backward until it reaches the beginning of a previousline
Reverse-stepi Step backward exactly one instruction
set exec-direction Set direction of execution.

  让我们假设您已经使用”break 10”命令在程序的第 10 行设置断点。然后输入”run”让进程开始执行。不久,程序会暂停在第 10 行代码处,控制权返回 gdb,并等待您的命令。此时如果您使用 next 命令,那么程序会顺序执行第 11 行代码,如果您使用 reverse-next,那么程序将回退执行第 9 行代码。

  使用反向调试命令之后,任何时候,您还可以自由地使用其他的 gdb 命令。比如 print 来打印变量的值等等。非常方便。

  反向调试的实现原理

  除了使用这项特性所带来的好处之外,或许更令人着迷的是该特性的实现原理吧。我们都不曾见过时光倒流,能够回头执行指令的处理器也貌似从未出现过,那么 gdb 是如何实现反向执行的呢?

  为了说明这个问题,首先我们需要回顾一些 gdb 的基本概念。

  GDB 的基本概念

  Gdb 的一些重要术语以及 gdb 的整体结构

  进入一个陌生的国家前先学几句他们的常用语会比较好。GDB 这个小世界中也经常使用一些特有的名词,我们最好首先熟悉他们。

  Exec,指一个可执行文件,可以是 ELF 格式,也可以是古老的 a.out。

  Inferior,指一个正在运行的 exec,一般就是指被调试进程。

  接下来最好我们能够对 gdb 有一个整体的,高度概括的了解。

  Gdb 的设计目标是为各种平台上的人们提供一个通用的调试器,因此它必须有可扩展性,以便人们可以将它移植到不同的硬件和软件环境下。

  为了实现这个目标,GDB 的体系结构采用分层和封装的设计思想,将那些依赖于特定软硬件环境的部分进行抽象和封装。最重要的两个封装概念便是 gdbarch 和 target。他们和 gdb core 之间的关系大致可以用下图来描述:

图 1. GDB 的体系结构
探索 Gdb7.0 的新特性反向调试 (reverse debug)

  当需要在不同的 OS 上运行 GDB 时,只需提供相应的 target 便可;同样,当需要支持一种新的新的处理器时,也只需提供新的 gdbarch,而 gdb core 则无需任何修改。

  关于 gdbarch

  Gdbarch 是一个封装了所有关于处理器硬件细节的数据结构。在这个数据结构中,不仅包括一些描述处理器特性的变量,也包括一些函数(喜欢 OO 的人会自然地联想到类。)这些函数实现了对具体硬件的一些重要操作,比如分析 stack frame,等等。

  完整的 gdbarch 数据结构非常庞大,无法一一列出,下表分类总结了 gdbarch 中的重要信息:

表 2. gdbarch 数据结构

分类 说明
描述硬件体系结构和 ABI 细节的信息 比如 :
endianism : 大端系统还是小端系统
比如 return_value:描述该处理器 ABI 中规定的处理函数返回值的方法
breakpoint_from_pc: 用于断点替换的机器指令 , 比如 i386 中为 int3
struct gdbarch_tdep additional target specific data, beyond that which is
covered by the standard struct gdbarch.
描述标准数据结构的信息 高级语言的 int, char 等标准数据结构的具体定义
访问和显示寄存器的函数 read_pc: 返回指令指针寄存器
num_regs: 返回寄存器的个数
访问和分析 stack frame 的函数 不同体系结构的 stack frame 都不尽相同。这些函数提供了如何分析和创建 stack frame 的具体实现函数。

  可以看到 gdbarch 封装了所有 gdb 运行时所需要的硬件信息,以及如何访问和处理这些信息的具体函数。类似于面向对象设计中的类的设计,将关于处理器硬件细节的数据和方法都封装到 gdbarch 数据结构中。

  关于 target

  同 gdbarch 一样,target 也是一种封装。但 target 所封装的概念更复杂一些。它不仅封装某一种操作系统,也封装了不同的”调试方式”。

  首先,不同的操作系统对应不同的 target。同样在 i386 处理器下工作,Linux 和 vxworks 对于 debug 的支持是不同的,比如如何创建进程,如何控制进程等。这些不同对于 gdb core 是透明的,由 target 来屏蔽。

  此外,target 还封装了不同的”调试方式”。这个词比较抽象,最好是举例说明。

  比如,同样是在 i386 Linux 下面,您即可以使用 native 方式调试 exec,也可以调试一个 core dump 文件,还可以 attach 一个正在运行的进程并调试它。打开一个可执行文件和一个 core dump 文件的方法是不同的,同样,将一个可执行文件 load 进内存执行和 attach 到一个正在执行的进程也是不同的。

  对于这些不同,gdb 也采用 target 进行封装。对于 gdb core 来说,当它需要让一个调试目标开始运行时,便调用 target 相应的回调函数,而不必关心这个回调函数如何实现。启动进程的具体的细节由不同的 target 来具体实现。当 target 为 exec,即一个磁盘上的可执行文件时,可以使用 fork+exec 的方式;当 target 是一个远程调试目标时,可以通过 TCP/IP 发送一个命令给 gdb server 进行远程调试;当 target 是一个已经在运行的进程时,需要使用 ptrace 的 attach 命令挂载上去。诸如这些细节,gdb 统统由 target 这个概念来封装。

  这便是 target 这个概念的主要意义,不过,还有一些事实让 target 更加复杂。

  有时候,人们希望在同一个 gdb 会话中调试多个 target。最常见的例子是调试 core dump 文件时,往往需要同时打开产生 core dume 的可执行文件,以便读取符号。

  比如程序 a.out 产生了 core dump 文件 core.2629,当用 gdb 打开 core dump 文件后,使用 bt 命令查看调用顺序时,人们不能看到函数名。

图 2. 没有符号信息的调用堆栈显示
探索 Gdb7.0 的新特性反向调试 (reverse debug)

  此时人们往往还需要用 file 命令打开 a.out 程序,即一个 exec。

图 3. 有了符号信息的调用堆栈显示
探索 Gdb7.0 的新特性反向调试 (reverse debug)

  除了 core dump 分析,还有其它一些情况要求 gdb 同时管理多个 target。为了应对这些需求,gdb 对 target 采用了分层、优先级管理的堆栈模式。堆栈中的每一层由一个形如 xyz_stratum 的古怪名字来标示,如下图所示:

图 4. GDB 的 target stratum
探索 Gdb7.0 的新特性反向调试 (reverse debug)

  这个堆栈的优先级从上到下递增,gdb 总是采用最高优先级 target 所提供的函数进行操作。

  以图 2,3 中的命令为例,打开 core dump 文件时,core_stratum 层的 target 被 push 进入 target stack;当用户使用命令 file a.out 时,一个 file_stratum 层的 target 被 push 进入 target stack。他们依照自身的优先级归于不同的层,绝不会弄错。

  当 target stack 中只有 core_stratum 的 target 时,假如用户希望执行 run 命令是不可能的,因为 core dump 文件无法运行,而当载入了 exec target 后,这个 file_stratum 层的 target 提供了和 run 命令相应的回调函数,从而使得 gdb 可以使用 run 命令启动一个 inferior。您在稍后的章节中可以看出,这种分层结构对反向调试的实现非常有帮助。

  下表列出了 target 数据结构的重要内容:

表 3. Target 数据结构

分类 说明
关于 target 的说明信息 比如 :
to_name: target 的名字
to_stratum: 该 target 在 target stack 中的层数
控制调试目标的函数 比如 :
to_open: 打开 target, 对于 exec 或 core dump 文件执行文件打开操作 ; 对于 remote target, 打开 socket 建立 TCP 连接等操作 .
访问调试目标寄存器和内存的函数 比如 :
to_store_registers
处理断点的函数 比如 :
Insert_break_point
控制调试进程的函数 比如 :
to_resume. Function to tell the target to start running again (or for the first time).
等等。

  GDB 运行时的基本流程

  对一个目标进程进行调试,需要操作系统提供相应的调试功能。比如将一个正在运行的进程暂停并读写其地址空间等。在传统 Unix 中,一般由 ptrace 系统调用提供这些功能。本文不打算详细介绍 ptrace,读者可以通过参考资料 [5] 获得更详细的介绍。

  但 ptrace 的编程模式极大地影响了 gdb 的设计,下面我们研究 gdb 如何使用 Ptrace。

  首先,gdb 调用 ptrace 将一个目标进程暂停,然后,gdb 可以通过 ptrace 读写目标进程的地址空间,还可以通过 ptrace 让目标进程进入单步执行状态。Ptrace 函数返回之后,gdb 便开始等待目标进程发来的信号,以便进一步的工作。

  以单步执行为例,gdb 调用 ptrace 将目标进程设置为单步执行模式之后,便开始等待 inferior 的消息。因为处于单步模式,inferior 每执行一条指令,Linux 便将该进程挂起,并发送一个信号给 ptrace 的调用者,即 gdb。Gdb 接受到这条信号 ( 通过 wait 系统调用 ) 后,便知道目标进程已经完成了一次单步,然后进行相应处理,比如判断这里是否有断点,或进入交互界面等待用户的命令等等。

  这非常类似窗口系统中的消息循环模式。Ptrace 的这一工作模式影响了整个 gdb 的设计,无论具体的 target 是否支持 ptrace,gdb 都采用这种消息循环模式。

  理解了以上的基础知识,您就可以开始探索反向调试的具体实现细节了。

  反向调试原理和代码导读

  原理概述

  如前所述,反向调试的基本原理是录制回放。它将 inferior 运行过程中的每条指令的执行细节录制下来,存放到 log 中。当需要回退时,从 log 中读取前一条指令执行的细节,根据这些细节,执行 undo 操作,从而将 inferior 恢复到当时的状态,如此便实现了“上一步”。

  undo 就是将某条指令所做的工作取消。比如指令 A 将寄存器 reg1 的值加了 1,那么 undo 就是将其减一。

  原理很简单,然而要将此想法付诸实现,人们必须解决几个具体的问题:

  如何录制,又如何回放呢?

  首先,gdb7.0 引入了一个新的 target,叫做 record target。这个 target 提供了录制和回放的基本代码框架。

  其次,当我们说到一条指令的执行细节时,究竟是指那些具体内容呢?或者说我们究竟应该录制些什么呢?这些记录如何组织?这便是 record list 的主要内容。下面我们一一来了解这些知识。

  Record target

  反向调试引入了一个新的 target,叫做”record”,它工作在 target stack 的第二层。Gdb target 的分层结构带来了这样一种好处:高层的 target 可以复用底层 target 的功能,并在其上添加额外的功能。我想我们可以这么说:低层 target 完成基本的低级功能,高层 target 完成更高级的功能。

  Record target 就是一个带录制功能的高层 target,它依赖低层 target 完成诸如启动进程,插入断点,控制单步等基本功能。在此之上,它将 inferior 执行过程中的每条指令的细节记录下来。此外,它还处理几个反向调试特有的命令,reverse next 等。

  当用户希望进行反向执行时,record target 并不需要低层 target 的帮助。因为 inferior 的执行过程都已经被记录在一个 log 中,反向执行时,record target 从 log 中读取记录,并 undo 这些记录的影响,比如恢复先前寄存器的值,恢复被指令修改的内存值等,从而达到了反向执行的效果。

  下面我们详细分析几个重要的 record target 所提供的函数,从而对上述基本思想有更深入的理解。

  首先看 record_open 操作。如前所述,Record target 可以看作对低层 target 的一个 wrapper。Record_open 时,首先将当前 target 的重要回调函数 ( 比如后续将说明的 to_resume, to_wait 等 ) 复制到一系列的 beneath function pointers 中。然后将”record target” push 到 target stack 的顶层。

  第二个重要的操作是 record_resume。Record_resume 在 gdb 决定让目标进程开始运行之前被调用,因此这里是绝佳的录制点。该函数的实现比较简单:

清单 1. record_resume 函数

 record_resume (struct target_ops *ops, ptid_t ptid, int step, 
        enum target_signal signal) 
 { 
 record_resume_step = step; 
 if (!RECORD_IS_REPLAY) 
  { 
   if (do_record_message (get_current_regcache (), signal)) 
    { 
     record_resume_error = 0; 
    } 
   else 
    { 
     record_resume_error = 1; 
     return; 
    } 
   record_beneath_to_resume (record_beneath_to_resume_ops, ptid, 1, 
                signal); 
  } 
 } 

  Record_resume 首先调用 do_record_message 进行录制,然后调用低层 target 的 to_resume 函数(已经保存在 beneath function pointer 中)完成基本的 resume 工作。

  这里需要注意一点,在调用 record_beneath_to_resume时,第三个参数 step为 1,即单步执行。这是因为 record target需要录制目标进程的每条指令,因此假如用户命令为 continue或 next,而不是 step时 ,目标进程将继续执行下去直到遇到断点为止,在此期间的指令 gdb无法获知,便也无从记录。因此 record target强制目标进程进入单步执行状态。以便录制每一条指令。

  第三个重要的操作是 record_wait。从函数的名字便可以猜得该函数是 gdb 等待目标进程信号的函数。

  当 record target 执行了 record_resume 之后,inferior 恢复执行。而 gdb 自己则开始等待 inferior 的信号。前面已经看到,record_resume 强行让 inferior 进入单步状态,因此 inferior 在执行完一条指令后,便会被强制挂起,并向 gdb 发送一个 SIGCHLD 信号。此时 record_wait() 便开始执行。

  该函数首先判断是否需要进行录制,如果需要,则进一步判断当前的 inferior 是否是单步执行状态,如果是,则不需要进行录制,因为马上 inferior 就会停下来,而 gdb 再次让 inferior 恢复执行时将调用 record_resume,那里会执行录制工作。

  但如果当前的 inferior 不在单步状态,且下一条指令不是断点,那么如果让 inferior 继续执行则意味着 record target 将错过后续的指令执行而无法进行录制。因此,在这种情况下,record_wait 将进入一个循环,在每次循环迭代中执行录制,并让 inferior 进入单步执行状态,直到遇到断点或者 Inferior 执行 exit 为止。伪代码如下:

清单 2. 执行录制的伪代码

 while(1) 
 { 
 waitForNextSignal(); 
 recordThisInst(); 
 resumeInferiorAndSingleStep(); 
 if(this is a breakpoint || this is end of inferior) 
  break; 
 } 

  这样,通过 record_wait 的处理,inferior 的每条执行指令都将被录制下来。

  Record_wait 的另外一半代码是处理 replay 的。假如当前用户希望反向执行,那么 record_wait 就从日志中读取 inferior 上一条执行指令的相关记录,恢复寄存器,内存等上下文,从而实现“上一步”的操作。

  Record list

  每次执行一条指令,record target 便将关于该指令的信息录制下来。这些信息可以完整地描述一条指令执行的效果。在目前的 record target 中,记录的信息只包括两类:寄存器和内存。

  一条机器指令能够改变的就是寄存器或内存。因此每次执行一条指令,record target 对该指令进行分析,如果它修改了内存,那么便记录下被修改的内存的地址和值;如果它修改了寄存器,便记录下寄存器的序号和具体的值。

  这些修改记录由 struct record_entry 表示和存储。

清单 3 单个记录的数据结构

 struct record_entry 
 { 
 struct record_entry *prev; 
 struct record_entry *next; 
 enum record_type type; 
 union 
 { 
  /* reg */ 
  struct record_reg_entry reg; 
  /* mem */ 
  struct record_mem_entry mem; 
  /* end */ 
  struct record_end_entry end; 
 } u; 
 }; 

  多个 record_entry 通过 prev 和 next 连接成 record_list 链表。一条机器指令可能既修改内存也修改寄存器,因此一条指令的执行效果由 record_list 中的多个 entry 组成。有三种 entry,表示寄存器的 entry,表示 memory 的 entry 和标志指令结束的 entry。顾名思义,register entry 用来记录寄存器的修改情况;memory entry 用来记录内存的修改;end entry 表示指令结束。

  如下图所示:

图 5. 反向调试的 log 结构
探索 Gdb7.0 的新特性反向调试 (reverse debug)

  第一条指令 inst1 由三个 entry 组成,一个 memory entry, 一个 reg entry 和一个 end entry。表明 inst1 修改了内存和寄存器;同理,inst2,3 等也使用了同样的数据结构。

  函数 do_record_message

  函数 do_record_message 具体完成指令执行的录制细节。抛开 gdb 代码的层层调用细节,该函数的具体工作是调用 gdbarch 所提供的 process_record 回调函数。

  对于 i386,具体的 process_record 函数为

清单 4. 函数 process_record 定义

 int i386_process_record (struct gdbarch *gdbarch, struct regcache *regcache, 
 CORE_ADDR addr) 

  这是一个 1000 多行的巨型函数,我建议大家不必精读其中的每一行代码。。。

  大体说来,该函数首先反汇编正在执行的机器指令,根据反汇编的结果分析该指令是否修改了寄存器或者内存。如果有所修改,就分别分配新的 reg entry 或者 mem entry 并插入到 record_list,当对该指令的所有执行结果都分配了相应的 record_entry 之后,调用 record_arch_list_add_end 插入一个 end entry,然后返回。这样,do_record_message() 执行完后,关于当前指令的所有细节都被保存到 record_list 中了。

  record target 执行录制的时序图

  Record target 的录制过程用时序图来表示比较容易理解,因为将相关操作串起来的是事件而不是函数调用关系。假如您打算跟踪函数的调用关系,那么很快就会迷失到晕头转向。参考资料 [7] 是我看到的最好的关于 gdb 的文档,我觉得其中最棒的部分就是 gdb 命令执行时的时序图,这是一种非常好的表示方法。下面我打算用时序图来完整地描述前面罗罗嗦嗦几千字却依然描述不清的东西。

  最简单的 record 命令序列为:

 > b main 
 > run 
 > record 
 > continue 

  我们用图 6 来表示上述命令序列所对应的、gdb 内部执行过程的时序图。

图 6. 录制的时序图
探索 Gdb7.0 的新特性反向调试 (reverse debug)

  当用户输入 run 命令后,gdb 会调用 target_insert_breakpoint 将目前 active 的断点设置到 inferior 中,对于 i386 的 arch 目标,会将 inferior 的断点处的指令替换为 int 3 指令。然后,gdb 调用 target_resume,恢复 Inferior 的运行。此时,target stack 顶端的 target 会执行正常的 target_resume 操作,比如 ptrace(CONTINUE)。

  当 inferior 执行到断点地址时,此时的指令被替换为 int3,因此会产生一个 trap。该信号被 gdb 捕获进入 target_wait。此时 gdb 重新获得控制权,进入 current_interp_command_loop 等待用户输入命令。

  接下来用户输入 record 命令,这将导致 gdb 调用 record_open 函数,将 record target 推入 target stack 的栈顶,并回到 gdb 的命令行等待用户输入新的命令。

  下一条命令是 continue,gdb core 将调用当前 target 的 target_resume 函数来恢复 inferior 的运行。此时的 target_resume 函数已经变成 record_resume。通过前面的代码分析,我们可以知道,此时 record_resume 将调用 do_record_message 完成第一条指令的录制,target_resume 函数执行完将返回 process() 函数。Gdb core 此时将调用 target_wait() 函数来等待 inferior 的下一个消息。同样,此时的 target_wait 为 record_wait(),因此 gdb 进入 record_wait()。

  Record_wait 将完成剩余的录制过程。因为当前的用户命令为 continue,因此 record_wait 将进入代码清单 2 所示的循环,循环执行下列操作:

  录制当前指令

  调用下一层的 resume 函数并将 step 参数设置为一,强制 inferior 进入单步执行。

  等待 inferior 的消息

  由于单步执行,inferior 在执行完一条指令后又将 gdb 的 wait 操作唤醒,继续上述循环。如此循环往复,直到遇到断点或者执行到 exit 为止,从而完成录制过程。

  Recored target 回放功能的实现比较简单,本文长度有限,读者可以自行分析。

  GDB 反向调试的局限

  Gdb 的反向调试是从 2006 年左右开始研发的,虽然目前已经正式发布。但还是不太稳定,且有一些局限。简述如下:

  有 side effect 的语句虽然能够回退执行,但其所造成的 side effect 则无法撤销。比如打印到屏幕上的字符并不会因为打印语句的回退而自动消失。

  因此,反向调试不适用于 IO 操作。

  此外 , 支持反向调试的处理器体系结构还很有限 , 需要更多的研发人员参与进来 .

  结束语

  很多人都在问,反向调试究竟有多大的实际用处?我在本文的开头便简单介绍了一种使用它的场景,但我想这并不能令心存怀疑的人满意。实际上,以我的个人经验来看,50% 的程序员从来不使用调试器。对于很多实际工作,即使不使用调试器,通过不断的打印和代码分析最终也能够解决问题。但假如能正确地使用调试器,或许能够更加有效地解决问题,从而将人生的宝贵时间使用在其他更有意义的地方。正如 Norman Matloff 所说,调试是一种艺术。没有艺术,人类依然可以生存,然而远在 1 万多年前,拉科斯的史前人也要在岩洞的墙壁上涂抹出一头牛或者一匹马,那有什么实际用处呢?
admin

探索 Gdb7.0 的新特性反向调试 (reverse debug):等您坐沙发呢!

发表评论

表情
还能输入210个字