草庐IT

别再说调试器不好用了!

言征 2023-03-28 原文
​作者 | 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++!

(lldb) expr
Enter expressions, then terminate with an empty line to evaluate:
1: struct Foo {
2: int foo(float x) { return static_cast<int>(x) * 2; }
3: };
4: Foo f;
5: f.foo(3.14);
(int) $0 = 6
表达式求值是一个非常强大的功能,它为程序分析和实验开辟了许多可能性。通过调用函数,你可以探索程序在不同情况下的行为,甚至可以更改其状态和执行。调试器还经常使用表达式求值来增强其他功能,如条件断点、数据监视和数据格式化程序。

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/

有关别再说调试器不好用了!的更多相关文章

  1. ruby-on-rails - 无法让 rspec、spork 和调试器正常运行 - 2

    GivenIamadumbprogrammerandIamusingrspecandIamusingsporkandIwanttodebug...mmm...let'ssaaay,aspecforPhone.那么,我应该把“require'ruby-debug'”行放在哪里,以便在phone_spec.rb的特定点停止处理?(我所要求的只是一个大而粗的箭头,即使是一个有挑战性的程序员也能看到:-3)我已经尝试了很多位置,除非我没有正确测试它们,否则会发生一些奇怪的事情:在spec_helper.rb中的以下位置:require'rubygems'require'spork'

  2. ruby - JetBrains RubyMine 3.2.4 调试器不工作 - 2

    使用Ruby1.9.2运行IDE提示说需要gemruby​​-debug-base19x并提供安装它。但是,在尝试安装它时会显示消息Failedtoinstallgems.Followinggemswerenotinstalled:C:/ProgramFiles(x86)/JetBrains/RubyMine3.2.4/rb/gems/ruby-debug-base19x-0.11.30.pre2.gem:Errorinstallingruby-debug-base19x-0.11.30.pre2.gem:The'linecache19'nativegemrequiresinstall

  3. ruby-on-rails - 如何调试 cucumber 测试? - 2

    我有:When/^(?:|I)follow"([^"]*)"(?:within"([^"]*)")?$/do|link,selector|with_scope(selector)doclick_link(link)endend我打电话的地方:Background:GivenIamanexistingadminuserWhenIfollow"CLIENTS"我的HTML是这样的:CLIENTS我一直收到这个错误:.F-.F--U-----U(::)failedsteps(::)nolinkwithtitle,idortext'CLIENTS'found(Capybara::Element

  4. ruby - Ruby 是否有类似于 Perl 的 "perl -d"的逐步调试器? - 2

    Ruby是否有逐步调试器,类似于Perl的“perl-d”? 最佳答案 ruby-debug(对于ruby1.8),debugger(对于ruby1.9),byebug(对于ruby​​2.0)以及trepanning系列都有一个-x或--trace选项。在调试器内部,命令setlinetrace将打开或关闭线路跟踪。这是themanualforruby-debug原来的答案已经修改,因为数据噪声文章的链接,唉,不再有效了。还添加了ruby​​-debug的后继者 关于ruby-Ruby

  5. Simulink方法总结和避坑指南(一)——Simulink入门与基本调试方法 - 2

    文章目录一、项目场景二、基本模块原理与调试方法分析——信源部分:三、信号处理部分和显示部分:四、基本的通信链路搭建:四、特殊模块:interpretedMATLABfunction:五、总结和坑点提醒一、项目场景  最近一个任务是使用simulink搭建一个MIMO串扰消除的链路,并用实际收到的数据进行测试,在搭建的过程中也遇到了不少的问题(当然这比vivado里面的debug好不知道多少倍)。准备趁着这个机会,先以一个很基本的通信链路对simulink基础和相关的debug方法进行总结。  在本篇中,主要记录simulink的基本原理和基本的SISO通信传输链路(QPSK方式),计划在下篇记

  6. ruby-on-rails - 使用 Pow 作为服务器在 RubyMine 中调试 - Ruby 2.1.1 + Rails 4 - 2

    我已经开始使用RubyMine6。我正在处理Rails4、Ruby2.1.1项目。我无法找到如何使用Pow作为服务器调试到RubyMine。你能给我指明正确的方向吗? 最佳答案 我能够使用远程调试从RubyMine进行调试。我正在使用RubyMine6、Rails3、Ruby2.1.1。首先创建一个.powenv文件并添加:exportRUBY_DEBUG_PORT=1234exportPOW_WORKERS=1将以下gem添加到您的Gemfile:gem'ruby-debug-ide'gem'debase'创建一个新的初始化器st

  7. ruby-on-rails - 放置调试语句 - 2

    当我编写代码时,我非常频繁地使用“puts”语句进行调试。它允许我查看服务器中发生的情况。在调试代码的时候,不知道是什么原因,我把这些“puts”语句去掉了。这是个好主意还是我应该保留它们以使我的服务器日志更加清晰? 最佳答案 您应该使用记录器而不是puts。使用这种语句:Rails.logger.debug"DEBUG:#{self.inspect}#{caller(0).first}"ifRails.logger.debug?如果您想(几乎)实时查看调试,只需在另一个终端窗口中使用tail命令:tail-Flog/develop

  8. ruby-on-rails - Rails 在记录 200 OK 后在做什么? (调试响应时间慢) - 2

    我试图在我的RubyonRails应用程序中调试一个极其缓慢的请求调用。我已设法根据自己的喜好优化Controller方法,Rails的日志告诉我它已在XX毫秒内完成操作(Completed200OKin5049ms(Views:34.9ms|ActiveRecord:76.3ms)).但是,在加载页面时,在浏览器中实际呈现任何内容之前打印此消息很长;最多约15秒的等待时间。Rackmini-profiler证实了这一点,告诉我GET操作(不计算完成Controller操作所花费的时间)花费了14秒左右。(分析器还确认Controller操作的执行时间约为5秒)。我可以接受Contro

  9. ruby:如何在调试时进入任意函数 - 2

    假设我在调试时停在了点上:defget_databyebug=>@cache||=calculate_dataend而@cache有值,所以step函数calculate_data不会被执行。但是我需要在这个确切的运行时点检查calculate_data内部发生了什么。我可以只执行calculate_data并在控制台输出中查看其结果,但是我可以从调试控制台执行函数并同时进入它?(使用byebug或其他一些调试工具)。目标-是在任意时间检查calculate_data逻辑,特别是当get_data调用时@cache已填充。 最佳答案

  10. ruby-on-rails - 如何在不退出 IRB session 的情况下退出调试器? - 2

    这是一个长期存在的挫败感来源,但也许我遗漏了什么。如果我正在调试,并且我想退出调试器并返回到IRB或Rails控制台,“退出”将不起作用,因为它将退出IRB。“完成”似乎也与继续具有相同的效果。使用“删除”删除断点然后尝试“继续”或“完成”不起作用。有什么想法吗? 最佳答案 至少在byebug中,你可以这样做:evalreturn它具有计算当前函数的return语句的净效果。这有时会奏效,具体取决于调用堆栈的外观。现在虽然这不会删除当前断点....如果您只是想收回控制权,在大多数情况下这会做到这一点,具体取决于您的代码结构。在您的代

随机推荐