28、逆向调试:rr与UndoDB的时间旅行

调试这件事,说白了就是跟bug玩捉迷藏。你设个断点,跑一下,看看变量对不对。但有些bug特别狡猾——它们只在特定条件下出现,等你反应过来,程序早就跑过了那个关键点。

这时候你会想:要是能像看录像一样,把程序的执行过程倒回去重看一遍就好了。

嗯,这就是时间旅行调试(TTD)要做的事。今天咱们聊聊两个工具:rr 和 UndoDB。

时间旅行调试是什么?

传统的调试方式,你只能向前走。设断点、单步执行、查看变量。一旦跑过了,就得重新来一遍。

时间旅行调试不一样。它会记录程序的整个执行过程——每条指令、每次内存访问、每个系统调用。然后你可以像拖进度条一样,在时间线上来回穿梭。

我在项目中遇到过这样一个场景:一个多线程程序,偶尔会死锁。用gdb跑了十几次,每次都抓不到现场。后来用rr录了一次,回放时在死锁发生前几毫秒停下来,所有线程的状态一目了然。那个bug,十分钟就定位了。

核心概念:时间旅行调试 = 录制执行轨迹 + 可逆回放

rr:开源的时间旅行调试器

rr(Record and Replay)是Mozilla开发的开源工具,基于Linux ptrace实现。它最大的优势是:确定性回放

什么意思?你录一次,可以回放无数次。每次回放,程序的行为完全一致。这意味着什么?你可以在回放时加断点、改条件、反复观察,不用担心环境变化导致bug消失。

rr的基本用法

# 录制程序执行
rr record ./my_program arg1 arg2

# 回放录制的内容
rr replay

# 列出所有录制会话
rr ps

# 回放指定会话
rr replay -s 1

录制完成后,rr会生成一个trace目录。里面包含了程序执行的所有信息。回放时,你实际上是在一个虚拟的CPU上重新执行这些指令。

个人经验:我习惯在录制时加上 rr record --chaos 参数。这个模式会随机化线程调度顺序,更容易暴露竞态条件。有一次线上一个诡异的崩溃,普通录制复现不了,开了chaos模式后,第三次录制就抓到了。

rr的逆向调试命令

进入rr回放后,你其实是在gdb里。但多了几个逆天命令:

命令 作用
reverse-nextrn 回退到上一条源代码行
reverse-steprs 回退一条指令(会进入函数内部)
reverse-continuerc 反向执行,直到遇到断点
reverse-finish 回退到当前函数被调用的位置

举个例子:

(rr) break main.c:42
(rr) continue
// 程序停在42行
(rr) print var
// var的值不对,但已经跑过了赋值的地方
(rr) reverse-continue
// 回退到上一次经过42行的位置
(rr) print var
// 现在var的值是对的,可以一步步往前查

你想想看,这在排查内存越界或者野指针时有多爽。发现某个指针不对,直接reverse-continue回到它被赋值的地方,看看是谁改的。

UndoDB:商业级的时间旅行调试

UndoDB是Undo公司的商业产品。跟rr比,它有几个明显的优势:

  • 支持更大的程序:rr对程序大小和运行时间有限制,UndoDB可以处理上亿行代码的大型应用
  • 性能开销更低:录制时的性能损耗只有2-5%,rr大概在10-20%
  • 支持多语言:C/C++、Java、Python、Go等
  • 集成IDE:可以跟VS Code、Eclipse等集成

不过UndoDB是收费的。个人开发者或者小团队,用rr就够了。大企业遇到棘手的线上问题,UndoDB确实能省不少时间。

注意:UndoDB的录制文件可能会很大。我曾经录过一个运行了8小时的服务器程序,trace文件达到了200多GB。所以录制前最好预估一下时间窗口,别把磁盘撑爆了。

时间旅行调试的适用场景

不是所有bug都需要时间旅行调试。我个人觉得,下面这几种情况特别适合:

  1. 偶发性bug:跑十次出现一次,普通调试根本抓不住
  2. 多线程竞态:线程间的时序问题,回放时可以看到每个线程的精确执行顺序
  3. 内存损坏:某个变量莫名其妙被改了,回放可以找到最后一次合法写入
  4. 复杂状态机:状态跳转逻辑复杂,需要反复观察状态变化过程

但也有一些场景不太适合:

  • 实时性要求极高的程序(录制会影响时序)
  • 运行时间极长的程序(trace文件会非常大)
  • 涉及硬件交互的程序(外设状态无法回放)

实战:用rr定位一个隐蔽的bug

我来分享一个真实的案例。之前维护一个网络库,偶尔会出现连接断开后内存泄漏。代码大概长这样:

void on_connection_close(connection_t *conn) {
    // 清理连接资源
    free(conn->buffer);
    free(conn->send_queue);
    
    // 通知上层
    notify_close(conn->id);
    
    // 释放连接本身
    free(conn);  // 问题可能在这里
}

用valgrind跑了几次,没发现明显泄漏。但线上监控显示,长时间运行后内存持续增长。

我决定用rr录一次:

$ rr record ./network_server
# 运行一段时间,触发连接断开
$ rr replay
(rr) break on_connection_close
(rr) continue
# 第一次进入函数
(rr) watch -l conn->ref_count
(rr) continue
# 第二次进入函数
(rr) reverse-continue
# 回退到第一次调用
(rr) print conn->ref_count
# 发现ref_count是2,但只free了一次

真相大白了。这个连接被两个地方引用,但关闭时只释放了一次。另一个引用还指向这块内存,导致泄漏。用rr回放,几分钟就找到了问题。

要是没有时间旅行调试,我可能得在代码里加一堆日志,重新部署,等个一两天才能复现。这就是工具的价值。

时间旅行调试的原理(简单理解)

你不需要完全理解底层实现,但知道个大概有助于用好它。

rr和UndoDB的核心思路是:录制不确定性,回放确定性

程序执行中,哪些是不确定的?

  • 系统调用的返回值(比如read()读到了什么数据)
  • 信号的处理时机
  • 多线程的调度顺序
  • 内存映射的地址

录制时,工具会记录所有这些不确定性因素。回放时,它把这些信息喂给程序,让程序以为自己在真实运行。实际上,所有指令的执行路径都是预先确定的。

说白了,就是给程序拍了一部电影。录制时把所有"意外"都记下来,回放时按剧本重演。

关键点:时间旅行调试不是模拟器,它是在真实CPU上回放。所以性能开销主要来自录制阶段,回放时几乎跟原生执行一样快。

使用建议

如果你刚开始接触时间旅行调试,我建议从rr入手。免费、开源、社区活跃。先拿一些简单的bug练手,熟悉了逆向调试的思维方式,再考虑是否升级到UndoDB。

另外,别忘了跟gdb配合使用。rr的回放界面就是gdb,你之前学的gdb技巧全都能用。只是多了几个反向执行的命令而已。

我曾经犯过一个错误:录制时没加 --chaos 参数,结果回放时一直复现不了那个竞态bug。后来加了参数,第三次录制就抓到了。嗯,这个坑你们别踩。

最后说一句:时间旅行调试不是银弹。它解决的是"难以复现"的问题,而不是"难以理解"的问题。如果你的bug逻辑清晰、稳定复现,用普通调试就够了。但遇到那种"跑一百次出现一次"的幽灵bug,rr和UndoDB就是你的救星。

时间旅行调试核心流程 录制阶段(Record) 记录系统调用 记录信号与中断 记录线程调度 生成 Trace 文件 包含所有不确定性因素的执行轨迹 回放阶段(Replay) 确定性指令执行 逆向调试(rn/rc/rs) 任意时间点断点 rr / UndoDB 时间旅行调试核心流程

公众号:蓝海资料掘金营,微信deep3321