作者 | werat
译者 | 言征
策划 | 云昭
当人们说“调试器是无用的,使用日志和单元测试更好”时,我怀疑他们中的许多人认为调试器只能在某些行上设置断点,一步一步地通过代码,并检查变量值。虽然任何合理的调试器都可以做到这一切,但这只是冰山一角。想想看;40年前,我们就已经可以通过这一代码了,当然有些事情已经改变了吗?
1、断点
每个调试器都支持断点。在代码中的某一行上设置断点,当执行到达该行时,程序将停止。但现代调试器可以做的远远不止这些。
列断点。你知道不仅可以在特定的行上设置断点,还可以在行+列上设置断点吗?如果一行源代码包含多个表达式(例如,foo() + bar() + baz()等函数的调用) ,那么可以在行的中间放置一个断点,并直接跳到该执行点。LLDB已经支持了一段时间,而IDE支持可能会有所欠缺。Visual Studio有一个名为Stepinto-specific的命令,它解决了一个类似的问题——如果在同一行上有多个调用,它允许你选择单步执行哪个函数。
条件断点。通常,你可以在断点上设置一系列额外的选项。例如,你可以指定“命中计数”条件,以仅在命中某一次数或每N次迭代后触发断点。或者使用更强大的概念——条件表达式——在应用程序处于特定状态时触发断点。例如,只有在主线程和monster->name == "goblin"上发生命中时,才能触发断点。Visual Studio调试器还支持“when changes”类型的条件表达式–当 monster->hp 的值与上次命中断点时相比,发生变化时触发断点。
跟踪断点(或跟踪点)。但如果断点没有中断呢?🤔 不要再说了,我们可以向输出输出一条消息,而不是停止执行。而不仅仅是一个简单的字符串,比如“getherelol”;消息可以包含计算和嵌入程序值的表达式,例如“iteration #{i},当前monster是{monster->name}”。本质上,我们将printf调用注入到程序中的随机位置,而无需重新构建和重新启动程序。这样代码就会很整洁。
数据断点。断点也不必位于特定的行、地址或函数上。所有现代调试器都支持数据断点,这意味着每当内存中的某个特定位置被写入时,程序都可以停止。你不明白为什么这个怪物会随机死亡吗?在monster->hp的位置设置一个数据断点,并在值发生变化时得到通知。这在调试某些代码正在写入不应该写入的内存的情况下尤其有用。将其与打印消息相结合,你将获得一个强大的日志记录机制,这是printf无法实现的!
2、数据可视化
另一个基本的调试功能——数据检查。任何调试器都可以显示变量的值,但好的调试器为自定义可视化工具提供了丰富的功能。GDB有外观漂亮的打印,LLDB有数据格式化程序,Visual Studio有NatVis。所有这些机制都非常灵活,在可视化对象时几乎可以做任何事情。对于检查复杂的数据结构和不透明的指针来说,这是一个非常宝贵的功能。例如,开发者不必担心哈希图的内部表示,只需查看键/值条目的列表即可。
这些可视化工具非常有用,但好的调试器可以做得更好。如果你有一个GUI,为什么只局限于“文本”可视化?调试器可以显示数据表和图表(例如SQL查询的结果)、渲染图像(例如图标或纹理)、播放声音等。图形界面在这里打开了无限的可能性,这些可视化工具甚至不难实现。
Visual Studio 中的 Image Watch
3、表达式求值
大多数现代调试器都支持表达式求值。其思想是,你可以键入表达式(通常使用程序的语言),调试器将使用程序状态作为上下文对其进行评估。例如,键入 monsters[i]->get_name() ,调试器显示“goblin”(其中monsters和i是当前范围中的变量)。显然,在不同的调试器和不同的语言中,实现有很大的差异。
例如,Visual Studio C++调试器实现了C++的推理子集,甚至可以执行函数调用(有一些限制)。它使用基于解释器的方法,因此它非常快速且“安全”,但不允许执行真正的任意代码。GDB也做了同样的事情。另一方面,LLDB使用实际的编译器(Clang)将表达式编译为机器代码,然后在程序中执行它(尽管在某些情况下,它可以使用解释作为优化)。这实际上允许执行任何有效的C++!
表达式求值是一个非常强大的功能,它为程序分析和实验开辟了许多可能性。通过调用函数,你可以探索程序在不同情况下的行为,甚至可以更改其状态和执行。调试器还经常使用表达式求值来增强其他功能,如条件断点、数据监视和数据格式化程序。
4、并发和多线程
开发和调试多线程应用程序很困难。许多与并发相关的错误很难再现,尤其在调试器下运行时,程序运行的行为飘忽不定。不过,好的调试器可以在这里提供很多帮助。
调试器可以节省大量时间。一个很好的例子是调试死锁。如果你设法使应用程序处于死锁状态,那么你就幸运了!一个好的调试器将显示所有线程的调用堆栈以及它们之间的依赖关系。很容易看出哪些线程正在等待哪些资源(例如互斥锁)以及谁在占用这些资源。不久前,我写了一篇关于在VisualStudio中调试死锁的案例的文章,看看它有多简单。
开发和调试多线程应用程序的一个非常常见的问题是,很难控制执行哪些线程的时间和顺序。许多调试器都遵循“全有或全无”策略,这意味着当断点命中时,整个程序(即其所有线程)都会停止。如果单击“继续”,所有线程将再次开始运行。如果程序中的线程不重叠,这可以正常工作,但当相同的代码由不同的线程执行,并且以随机顺序命中相同的断点时,这会变得非常烦人。
一个好的调试器可以冻结和解冻线程。你可以选择哪些线程应该执行,哪些线程应该休眠。这使得调试高度并行化的代码更加容易,而且你还可以模拟不同的竞争条件和死锁。在Visual Studio中,你可以在UI中冻结和解冻线程,而GDB有一种叫做不停止模式的功能。RemedyBG有一个非常方便的UI,你可以快速切换到“solo”模式并返回。
之前提到,调试器可以显示线程之间的依赖关系。一个好的调试器还支持协同程序(绿色线程、任务等),并提供一些工具来可视化当前程序状态。例如,Visual Studio有一个叫做并行堆栈的功能。在此窗口中,你可以快速了解整个程序状态,并查看不同线程正在执行的代码。
5、热重载
想象一个典型的调试会话。你运行程序,加载数据,执行一些操作,最后到达发现错误的位置。你设置了一些断点,一步一步,突然意识到某个“if”条件是错误的——它应该是 >= 而不是 > 。你接下来要做什么?停止程序,修复条件,重建程序,运行它,加载数据,执行一些操作…等等。现在是2023年,你下一步要做什么?
修复条件并保存文件。很轻松动两下,程序就会接收代码中的更改!它没有重新启动,也没有失去状态,它就在你离开它的地方。你立即发现你的修复程序不正确,实际上应该是 == 。再次修复。
这种神奇的特性被称为热重载——一个好的调试器可以在不重新启动的情况下获取源代码中的更改并将其应用于实时运行的程序。许多使用动态或基于VM的语言(如JavaScript、Python或Java)的人都知道这是一件事,但并不是所有人都意识到C++或Rust等编译语言也有可能这样做!例如,Visual Studio支持通过“编辑并继续”对C++进行热重新加载。它确实有一长串的限制和不支持的更改,但它在许多常见场景(演示)中仍能正常工作。
另一项令人惊叹的技术是Live++——可以说是当今最好的热重载解决方案。它支持不同的编译器和构建系统,可以与任何IDE或调试器一起使用。不受支持的场景列表要短得多,其中许多都不是基本的限制——只要付出足够的努力,热重新加载几乎可以处理任何类型的更改。
热重新加载不仅仅是将更改应用于实时程序。一个好的热重新加载实现可以帮助从诸如访问违规之类的致命错误中恢复,或者改变不同编译单元的优化级别(以及可能的任何其他编译器标志)。它还可以远程执行,同时执行多个进程。
6、Time travel
有没有遇到过这样的问题,就是你在代码中踩得太远了?只是一点点,但伤害已经造成了。这时候,我们只能重新启动程序并重试,并后退几步。这可能比热重载更神奇,但一个好的调试器实际上可以及时运行。后退一步或设置一个断点,然后反向运行,直到它被击中,就像是2023年,而不是1998年一样。
许多调试器都支持这种操作。GDB通过记录每个指令所做的寄存器和内存修改来实现时间旅行,这使得撤消更改变得很简单。然而,这会导致显著的性能开销,因此在非交互模式下可能不太实用。另一种流行的方法,则基于大多数程序执行是确定性的观察。每当发生不确定的事情(系统调用、I/O等)时,我们都可以对程序进行快照,然后通过将其倒回到最近的快照并从那里执行代码,随时重建程序状态。这基本上就是UDB、WinDBG和rr所做的。
↑ 使用 Time Travel Debug for C/C++
7、全方位调试
最后一件事,是在调试场景中彻底改变游戏规则。传统调试有很多缺点。记录和回放是向前迈出的一大步,但如果除了记录可再现的程序跟踪之外,我们还预先计算了所有单独的程序状态,将它们存储在数据库中,并建立了索引以进行有效查询,会怎么样?
这听起来是不可能的,但实际上却出奇地可行。结果表明,程序状态压缩得很好,每条指令的存储量小于1bit!
这种方法被称为全知调试,它不仅解决了传统调试器所面临的一系列问题(例如堆栈展开),而且还打开了我们以前认为不可能实现的可能性。随着整个程序历史记录和索引,你可以问一些问题,比如“变量写了多少次,写在哪里?”、“哪个线程释放了这块内存?”甚至“这个特定的像素是如何渲染的?”。
还推荐观看罗伯特·奥卡拉汉(Robert O'Callahan,rr的作者)的《2022年的调试状态》(The State Of Debugging in 2022),这本书很好地说明了为什么全方位调试是未来,我们应该对工具提出更高的要求。
尽管这个想法可以追溯到几十年前,但高效实用的实现很难。现代全知调试器的一个很好的例子是Pernosco。它有一长串受支持的功能和用例,甚至简单的演示看起来都难以置信。
另一个很棒的工具是WhiteBox。它在编写代码时编译、运行和“调试”代码,为开发者提供对程序流程和结构的宝贵见解。它记录执行情况,并允许你随时检查程序状态。不过它仍然处于测试阶段。
7、调式or不调试?
每个现有的调试器都有其优缺点,不存在真正的银弹。在某些情况下,日志记录更方便,而在其他情况下,使用Time Travel调试器则可以将错误调查的时间,从几天缩短到几分钟。调试技术已经取得了长足的进步,有很多有趣的特性值得一看。开发者在使用过程中也可以从本地调试器供应商那里,提出改善的需求。
那么,你最喜欢调试器的哪项功能呢?
参考链接:https://werat.dev/blog/what-a-good-debugger-can-do/