反向调试是如何工作的?

89

GDB发布了新版本,支持反向调试(详见http://www.gnu.org/software/gdb/news/reversible.html)。我想知道它是如何工作的。

为了让反向调试正常工作,似乎需要为每一步存储整个机器状态,包括内存。这将使性能极其缓慢,更不要说占用大量内存。这些问题是如何解决的呢?


4
我想你可以通过存储状态增量而不是整个状态来解决问题,但这仍然可能很昂贵。 - spender
1
相关问题:https://dev59.com/iXRB5IYBdhLWcg3wxZxK - Brian Rasmussen
保存增量确实可以很好地工作,并且对于高效的完整系统可逆解决方案来说确实是必要的。 - jakobengblom2
8个回答

138

我是gdb的维护者之一,也是新反向调试的作者之一。很高兴谈论它的工作原理。正如一些人所猜测的那样,您需要保存足够的机器状态以便稍后恢复。有许多方案,其中一个方案是简单地保存每个机器指令修改的寄存器或内存位置。然后,要"撤消"该指令,只需还原这些寄存器或内存位置中的数据。

是的,这很耗费资源,但现代CPU速度非常快,当您进行交互操作(执行步进或断点)时,您不会真正注意到这一点。


4
逆调试只允许你撤销输入的“下一个”和“步进”命令,还是允许你撤销任意数量的指令?例如,如果我在一条指令上设置断点并让它一直执行到那里,那么我是否可以回滚到上一条指令,即使我跳过了它? - Nathan Fellman
12
您可以撤销任意数量的指令。您不仅限于仅在前进时停止的点。您可以设置新的断点并向后运行到该断点。例如,如果我在一条指令上设置了断点,并让它一直运行直到那个点,我是否可以回滚到之前的指令,即使我跳过了它?是的,只要您在运行到断点之前开启了记录模式。 - Michael Snyder
4
抱歉文本格式混乱,不知道出了什么问题。 - Michael Snyder
15
我担心逆调试会倒流时间,让我们回到60年代或70年代。我不想再穿喇叭裤,也不想再留长发。 - the Tin Man
4
修改操作系统状态的系统调用呢?它们会正常工作吗?如果它修改了一个不透明的句柄,该怎么办? - Adrian
显示剩余3条评论

13
请注意,实现反向执行时必须不忘使用模拟器、虚拟机和硬件记录器。
另一种解决方法是在物理硬件上跟踪执行,例如GreenHills和Lauterbach在其基于硬件的调试器中所做的。基于每个指令动作的固定跟踪,您可以通过逐个删除每个指令的效果来移动到跟踪中的任何点。请注意,这假设您可以跟踪所有影响调试器中可见状态的事物。
另一种方法是使用检查点+重新执行方法,VmWare Workstation 6.5和Virtutech Simics 3.0(及更高版本)使用此方法,并且似乎将随Visual Studio 2010一起提供。在这里,您使用虚拟机或模拟器来获取对系统执行的间接级别。您定期将整个状态转储到磁盘或内存中,然后依赖于模拟器能够确定性地重新执行完全相同的程序路径。
简单来说,它的工作原理是这样的:假设您在系统执行的时间T。要回到时间T-1,您需要从t < T的某个检查点开始,并执行(T-t-1)个周期,以便停留在之前一个周期的位置。这可以很好地运作,甚至适用于包含磁盘IO、内核级代码和设备驱动程序工作负载。关键在于拥有一个模拟器,其中包含整个目标系统及其所有处理器、设备、内存和IO。有关更多详细信息,请参见gdb邮件列表以及随后在gdb邮件列表上的讨论。我经常使用这种方法调试棘手的代码,特别是在设备驱动程序和早期操作系统引导方面。

另一个信息来源是Virtutech关于检查点的白皮书(完全披露,我撰写了该白皮书)。


另请参见http://jakob.engbloms.se/archives/1547及其两篇后续博客文章,以获取更全面的反向调试技术演示。 - jakobengblom2
如何考虑添加“设置保存点”的功能来代替实现反向调试。你可以在调试过程中选择当前步骤作为“保存点”,以后你就能够回到那个保存点并继续向前调试,必要时还可以编辑变量值。有些类似虚拟机的“快照”或操作系统的“还原点”。 - Rolf
我认为使用虚拟机存在的问题是无法确定地复现外部依赖项(例如服务器HTTP响应)的行为。 - Gili

10
尽管这个问题比较老,大多数答案也是如此,但仍然是一个有趣的话题,因此我发布了一个2015年的答案。我的硕士论文第一章和第二章,将反向调试和实时编程结合以实现计算机编程中的视觉思维,涵盖了一些历史上的反向调试方法(特别是快照(或检查点)和重放方法),并解释了它与全知调试之间的区别:
计算机应该能够在程序执行到某个点时为我们提供有关它的信息。这样的改进是可能的,并且在所谓的全知调试器中找到。它们通常被归类为反向调试器,尽管更准确地描述它们为“历史记录”调试器,因为它们仅记录执行期间的信息以供稍后查看或查询,而不允许程序员实际上倒退执行程序。 “全知”源于程序的整个状态历史已记录下来,执行后调试器可用。然后无需重新运行程序,也无需手动代码插装。
基于软件的全知调试从1969年的EXDAMS系统开始,当时被称为“调试时间历史回放”。GNU调试器GDB自2009年以来支持全知调试,具有其“进程记录和重放”功能。TotalView、UndoDB和Chronon似乎是目前最好的全知调试器,但都是商业系统。TOD(Java)似乎是最好的开源替代方案,它利用部分确定性重放,以及部分跟踪捕获和分布式数据库,以实现记录涉及的大量信息。
不仅允许导航记录的调试器,还存在能够向后步进执行时间的调试器。它们可以更准确地描述为反向调试器、时光旅行、双向或倒退调试器。
第一个这样的系统是1981年的COPE原型...

9
在EclipseCon的一个会议上,我们还询问了他们如何使用Java的Chronon Debugger进行此操作。该调试器不允许您实际地倒退,但可以回放记录的程序执行方式,以使其感觉像是反向调试。(主要区别在于,您无法在Chronon调试器中更改运行中的程序,而您可以在大多数其他Java调试器中执行此操作。)
如果我理解正确,它会操纵正在运行的程序的字节码,以便记录程序内部状态的每个更改。外部状态不需要额外记录。如果它们以某种方式影响您的程序,则必须具有与该外部状态匹配的内部变量(因此该内部变量就足够了)。
在回放时间,他们可以从记录的状态更改基本上重新创建运行程序的每个状态。
有趣的是,状态改变比起初看来要小得多。因此,如果您有一个条件“if”语句,您可能认为需要至少一个位来记录程序是执行了then还是else语句。在许多情况下,甚至可以避免这种情况,例如当这些不同的分支包含返回值时。然后,仅记录返回值就足够了(无论如何都需要),并且从返回值本身重新计算关于执行分支的决定。

5

mozilla rr 是 GDB 反向调试的更强大的替代品。

https://github.com/mozilla/rr

GDB内置的记录和回放存在严重限制,例如不支持AVX指令: gdb反向调试失败,显示“Process record does not support instruction 0xf0d at address”

rr的优点:

  • 目前要可靠得多。我已经对几个复杂软件进行了相对较长时间的测试。
  • 还提供带有gdbserver协议的GDB接口,使其成为一个很好的替代品
  • 对于大多数程序的性能影响很小,我没有注意到自己进行测量
  • 生成的跟踪在磁盘上很小,因为只记录了非确定性事件中的极少数事件。到目前为止,我从来没有担心过它们的大小

rr通过首先以记录每个非确定性事件(例如线程切换)发生的方式运行程序来实现这一点。

然后在第二次重播运行期间,它使用那个追踪文件(出奇地小),以确定性的方式重新构建了原始的非确定性运行中发生的情况,无论是向前还是向后。

rr最初是由Mozilla开发的,以帮助他们重现在夜间测试中出现的时间错误。但反向调试方面对于您遇到仅在执行几小时后才发生的错误也是至关重要的,因为您经常需要向后步进以检查导致后来失败的先前状态。

以下示例展示了一些其特点,尤其是reverse-nextreverse-stepreverse-continue命令。

在Ubuntu 18.04上安装:

sudo apt-get install rr linux-tools-common linux-tools-generic linux-cloud-tools-generic
sudo cpupower frequency-set -g performance
# Overcome "rr needs /proc/sys/kernel/perf_event_paranoid <= 1, but it is 3."
echo 'kernel.perf_event_paranoid=1' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p

测试程序:

reverse.c

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int f() {
    int i;
    i = 0;
    i = 1;
    i = 2;
    return i;
}

int main(void) {
    int i;

    i = 0;
    i = 1;
    i = 2;

    /* Local call. */
    f();

    printf("i = %d\n", i);

    /* Is randomness completely removed?
     * Recently fixed: https://github.com/mozilla/rr/issues/2088 */
    i = time(NULL);
    printf("time(NULL) = %d\n", i);

    return EXIT_SUCCESS;
}

编译并运行:

gcc -O0 -ggdb3 -o reverse.out -std=c89 -Wextra reverse.c
rr record ./reverse.out
rr replay

现在你进入了 GDB 会话中,可以进行逆向调试:

(rr) break main
Breakpoint 1 at 0x55da250e96b0: file a.c, line 16.
(rr) continue
Continuing.

Breakpoint 1, main () at a.c:16
16          i = 0;
(rr) next
17          i = 1;
(rr) print i
$1 = 0
(rr) next
18          i = 2;
(rr) print i
$2 = 1
(rr) reverse-next
17          i = 1;
(rr) print i
$3 = 0
(rr) next
18          i = 2;
(rr) print i
$4 = 1
(rr) next
21          f();
(rr) step
f () at a.c:7
7           i = 0;
(rr) reverse-step
main () at a.c:21
21          f();
(rr) next
23          printf("i = %d\n", i);
(rr) next
i = 2
27          i = time(NULL);
(rr) reverse-next
23          printf("i = %d\n", i);
(rr) next
i = 2
27          i = time(NULL);
(rr) next
28          printf("time(NULL) = %d\n", i);
(rr) print i
$5 = 1509245372
(rr) reverse-next
27          i = time(NULL);
(rr) next
28          printf("time(NULL) = %d\n", i);
(rr) print i
$6 = 1509245372
(rr) reverse-continue
Continuing.

Breakpoint 1, main () at a.c:16
16          i = 0;

在调试复杂软件时,你可能会遇到崩溃点并掉进深层框架。这种情况下,不要忘记要先进行reverse-next高级框架,然后再进行:

reverse-finish

在那个帧之前,仅仅做通常的up是不够的。

我认为rr最严重的限制有:

UndoDB是rr的商业替代品:https://undo.io 两者都是基于跟踪/重放的,但我不确定它们在功能和性能方面如何比较。


你知道我如何使用DDD实现这个吗?谢谢。 - spraff
@spraff 我不确定,但很可能。首先尝试将ddd连接到gdbserver。如果可以连接成功,那么使用rr也应该可以。 - Ciro Santilli OurBigBook.com
1
@spraff,不过不要使用ddd,使用gdb dashboard吧;-) https://dev59.com/e2kw5IYBdhLWcg3wHnB_#51301717 这肯定会起作用,因为它只是普通的GDB。 - Ciro Santilli OurBigBook.com

4

Nathan Fellman写道:

但是,反向调试只允许您撤消键入的下一个和步骤命令,还是允许您撤消任意数量的指令?

您可以撤消任意数量的指令。 您不受限于仅在前进时停止的点。 您可以设置新断点并向后运行到该断点。

例如,如果我在一条指令上设置了断点,并让它运行直到该指令,那么我能否回滚到先前的指令,即使我跳过了它?

可以。 只要在运行到断点之前打开记录模式即可。


2
任何反向解决方案的关键部分是在某个时刻启动它,并且只能在该点之前进行反向操作。没有什么魔法可以真正地运行一台机器并找出以前发生了什么,而不记录发生了什么事情。 - jakobengblom2

2
这里是另一种反向调试器ODB的工作原理。摘录如下:
“全知调试”是一个想法,它在程序的每个“感兴趣点”(设置一个值、调用方法、抛出/捕获异常)收集“时间戳”,然后允许程序员使用这些时间戳来探索该程序运行的历史。ODB...在加载程序的类时插入代码,并记录事件。
我猜gdb的工作方式也是类似的。

那么这是否需要在代码中添加指令,告诉编译器和调试器那些有趣的点在哪里? - Nathan Fellman
不错,www.LambdaCS.com/debugger/debugger.html 上有一个Java Web Start演示,向你展示它是如何工作的。看起来就像一个普通程序。那是ODB,不知道gdb怎么样。尽管如此,这非常酷 :) - demoncodemonkey
请注意,gdb解决方案不会以任何方式更改目标程序。如果您必须对程序进行仪器化以进行调试,则由于时间差异和其他干扰,问题消失的可能性很大。所有商业revexec工具都基于某种形式的外部记录,不会更改程序本身的代码。 - jakobengblom2
@jakobengblom2:我认为你过于强调通过写入内存、模拟执行或仅添加硬件断点来改变目标之间的差异。它们都会改变时间。实际上,目标仪器化可能是最少改变时间的方法。 - Ben Voigt

2

反向调试意味着您可以向后运行程序,这对于追踪问题的原因非常有用。

您不需要为每个步骤存储完整的机器状态,只需存储更改即可。这可能仍然相当昂贵。


我明白了,但是你仍然需要在每次更改时中断执行以保存更改。 - Nathan Fellman
是的,这是正确的,但是现在机器非常快,从人类的角度来看,我认为这种减速是可以接受的。它类似于valgrind,也许没有valgrind那么慢。 - Michael Snyder

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接