创建运行旧程序的仿真器是一项艰巨的任务。 您需要透彻了解目标硬件以及仿真器要执行的原始程序的正确功能。 除了功能正确之外,仿真器还必须达到以原始实时速度运行程序的性能目标。 实现这些目标不可避免地需要大量的调试。 这些错误——对于老式街机游戏的玩家来说,是重现原始体验的重要组成部分——通常是仿真器本身中的细微错误,但也可能是对目标硬件的误解,或者是原始程序中实际存在的已知错误。 (原始程序的二进制数据也可能已微妙地损坏或不是预期的版本。) 解决这些问题需要一些不寻常的调试技术。
主机系统的调试器可以通过在 CPU 中设置断点来用于目标(仿真)系统执行指令循环并检查保存仿真 CPU 寄存器和主内存内容的变量。 这适用于小的且易于定位的问题,例如自检或初始化代码中的故障,但对于执行后期阶段发生的问题用处不大。 在那时,程序的组织结构并不明显。 没有调试符号或源代码可用于对问题进行高级处理。 需要更强大的技术,并且这些技术已内置于仿真器本身中。
显然,可能出现的错误种类变化很大。 忽略主要的执行路径故障(例如,无限循环,程序计数器最终指向非程序内存),一个典型的故障会在目标的视频输出中被注意到,如图 1 所示。 在此示例中,精灵的位置绝对不正确。 顶部精灵偏离了预期的列,位置在 13,11 而不是 24,11。 我们知道外星人总是按列攻击,所以仿真器肯定出了问题。 仿真器内存状态中的一对值决定了精灵的位置。 实际上,此地址将是直接决定显示位置的视频硬件寄存器的位置。 您可以使用平台调试器来确认寄存器值实际上是 13,11。 不幸的是,即使您知道这些值是错误的,找到实际错误也并不容易。
不正确的寄存器值是一系列早期计算的结尾(请参阅侧边栏“屏幕图像链”)。 查找错误涉及反向追溯因果序列,以找到错误计算发生的位置。 但是,调试器仅向我们显示机器的当前状态。 如果可能的话,重建事件链将需要大量的侦探工作。
可能有一个解决问题的简单快捷方式。 如果您正在尝试从已知的正常工作的现有仿真器创建更快的仿真器,那么您可以使用正确的仿真器来调试不正确的仿真器。 可以修改正确的仿真器以创建输入和 CPU 状态的跟踪,并且目标仿真器可以读取此跟踪文件(请参阅侧边栏“6502 追踪示例”)。 输入值用作实际输入(操纵杆位置)。 在每次执行指令调用之后,当前机器状态与追踪进行匹配,并在出现分歧时暂停执行。 此时,您可以使用主机调试器详细检查问题并找到解决方案。
如果,更常见的情况是,您没有参考追踪怎么办? 您仍然知道错误的来源是当不正确的 X 值 13 被放入内存位置 0xc000 时。 您可以搜索追踪文件以查找写入此值的情况(例如,在 BUS 列中搜索 C000w0d)。 此时,您将看到不正确的值来自寄存器,并且只需查看几行追踪,您就会在shadow[]内存中识别出不正确值的地址(请参阅“屏幕图像链”侧边栏以显示正在跟踪的路径)。
此时,您可以对(现在)已知的影子内存地址应用相同的步骤,以查找追踪中修改 sprite 结构的点。 当您继续向后追踪时,您将找到更多要追踪的内存位置或可以手动验证的代码段,以查找有问题的仿真错误。 当然,这肯定不如使用参考追踪那么容易,但是很有可能在不不断重新运行仿真器的情况下找到错误。
使用追踪的仿真器可以找出机器描述中的问题,但是当问题存在于代码本身而不是机器描述中时,追踪也可能很有用。 使用这样的追踪可以使查找内存损坏问题变得更加容易。 考虑一种特别糟糕的损坏类型:损坏的数据结构归运行时内存分配器实现所有。 例如,程序在调用malloc()时崩溃,因为它遍历空闲列表。 调试器中可以明显看到实际的崩溃,但根本原因尚不清楚。
假设直接问题是分配器的链表中的位置 0x8004074 处的特定指针值已被修改。 它包含 0x1 而不是合理的值——一个无效且未对齐的内存引用。 标准调试器此时无法提供更多帮助,因为它只能显示无效值,但不能显示何时变为无效。 (您可以使用写入时断点,但是如果损坏的地址在运行之间发生变化,则该方法将不起作用。)
这就是追踪解决问题的地方。 向后搜索追踪,查找上次写入内存位置 0x8004074 的操作。 您可能会找到如下条目
0x80004074: 写入 0x1 PC=0x1072ab0 ...
将给定的程序计数器 (0x1072ab0) 转换为源代码中的位置将立即揭示损坏的可能原因。 例如,现在可能很清楚数组边界没有被正确遵守。 尝试在数组中存储值 1 导致了内存损坏。 当然,与精灵示例一样,损坏的实际来源可能需要进一步追溯。
以下是一些 C 代码片段,展示了精灵位置是如何确定的,从实际硬件值开始并向后追溯。 C 代码是逆向工程的,因为仿真器将直接执行机器指令。
第四个也是最后一个级别是第一个关注点。 也许仿真器对比较指令的实现存在问题。
追踪对于调试普通问题很有用,但是如何生成这些追踪并不立即显而易见。 与仿真器不同,您无法控制运行软件的微处理器的(硬件)实现。
有人可能会反对说,对于“真实”程序来说,如此详细的日志记录并非特别可行。 尽管硬盘存储的成本持续降低,但 I/O 带宽确实有限制。 如果存储空间确实有限,则还有其他几种方法。 例如,快照之间的追踪可以仅存储在 RAM 中,而不写入日志文件。 可以根据需要重新创建追踪(通过从给定状态重新运行目标)。 或者,只有最新的追踪可以存储在主内存中,从而在必要时提供对扫描追踪的快速访问。
在许多情况下,我们的 CPU 实现的完全不变性是一个精心设计的幻觉。 所有 Java(和 .NET)程序实际上都在虚拟机上运行。 修改这些虚拟机以记录追踪信息和状态快照是可行的——无需更改任何硬件。 这仅仅是说服虚拟机实现的拥有者进行必要的更改的问题。
即使是本地可执行文件也与真实硬件有所脱节。 所有现代操作系统都强制执行用户模式和内核模式执行之间的分离。 进程实际上是一个虚拟实体。 已经存在快照进程执行状态的能力(Unix 的古老核心转储)。 通过一些额外的操作系统支持,完全可以获取这些快照状态并将它们恢复为可以继续执行的真实进程。
即使是正在使用的整台机器也可能是虚拟的。 VMware 和 Parallels 等产品通常使用机器状态快照进行操作,这些快照包括用户模式、内核模式、设备驱动程序甚至硬盘驱动器的完整状态。
8 位 6502 微处理器只有几个寄存器:A、X、Y、S、PC 和 P。 实际系统也可能有一个简单的 8 位输入端口用于操纵杆位置。 追踪文件将是寄存器和 I/O 端口的(十六进制)值以及当前的(符号)指令
PC A X Y S P IO 总线 ASM在此示例中,位置 F010 的指令将值 7A 加载到 A 寄存器中,并且追踪的下一行反映了该更改。 状态寄存器(在 6502 上命名为 P)的最高位被清除,因为 A 中的值不是负数。 包含 BUS 列是为了显示将数据值读取或写入系统总线的情况。
有很多可能的方式来表示追踪。 对于长追踪,更有效的方法可能是简单地存储整个机器状态,并使用计数器描述在达到新的机器状态或 I/O 值更改之前要执行的指令数。 可以通过在给定机器状态下运行仿真器 N 步来(根据需要)重建详细的追踪。
追踪内存访问对于用汇编程序或 C 等语言编写的程序很有帮助。 内存和 CPU 传输很容易与实际源代码对应,但是更常见的是使用与机器相差一步的语言,其中 C 代码是正在使用的实际语言的解释器。 在这种情况下,代码的低级操作不容易映射到解释语言中的可识别操作。
在现代系统中,I/O 的使用也更加复杂。 程序不直接读取和写入硬件 I/O 位置。 相反,设备交互是通过操作系统调解的。
追踪可以以显而易见的方式应用于这些更高级别的程序:更改追踪以记录更高级别的事件。 要捕获哪些事件将取决于程序的类型。 GUI 程序可能需要捕获鼠标、键盘和窗口事件。 操作文件的程序将捕获打开/关闭和读取/写入操作。 构建在数据库之上的代码可能会记录 SQL 语句和结果。
好的追踪不同于简单的事件日志。 追踪必须提供足够的信息,以便仅使用追踪即可验证执行的正确性。 应该可以构建一个参考实现,它可以读取追踪并自动验证是否做出了正确的决策。 使用仿真器的经验表明,您可能首先编写参考实现并使用它来验证生产代码。 (您可以避免过早优化的陷阱,并发现简化的参考实现是令人满意的解决方案。) 编写参考实现似乎与生产版本一样需要付出努力,但是总有一些要求不会改变功能输出。 例如,生产代码可能需要查找表是持久的,而参考实现可以使用更简单的内存哈希表。
将快照、追踪和回放添加到现有的调试环境中,将大大减少查找和纠正顽固错误所需的时间。 低级代码在这个领域已经取得了一些进展; 对于某些平台,gdb 最近被赋予了反向执行的能力。 由于 CPU 操作是不可逆的,这意味着现在有方法可以捕获已编译程序的追踪信息。 如果添加保存和重新加载快照的功能,gdb 可以成为可追踪的调试器。
详细的 CPU 状态追踪对于优化和调试仿真器非常有帮助,但是该技术也可以应用于普通程序。 如果有参考实现可用于比较,则该方法几乎可以直接应用。 如果不是这种情况,追踪仍然可用于调试非本地问题。 向程序添加追踪功能的额外工作将在减少调试时间方面得到回报。
喜欢它,讨厌它? 让我们知道
彼得·菲利普斯 获得了不列颠哥伦比亚大学计算机科学学士学位。 他在软件开发方面拥有 15 年的经验,并且在过去的 10 年中一直从事游戏机游戏的工作。
© 2010 1542-7730/10/0300 $10.00
最初发表于 Queue 第 8 卷,第 3 期—
在 数字图书馆 中评论本文
Charisma Chan, Beth Cooper - 调试 Google 分布式系统中的事件
本文介绍了 2019 年对 Google 工程师如何调试生产问题进行的研究结果,包括工程师在不同组合中使用的工具类型、高级策略和低级任务,以有效地进行调试。 它考察了用于捕获数据的研究方法,总结了生产调查的常见工程过程,并分享了专家如何调试复杂分布式系统的示例。 最后,本文将这项研究的 Google 特性扩展到提供一些您可以在组织中应用的实用策略。
Devon H. O'Dell - 调试思维模式
软件开发人员将 35-50% 的时间用于验证和调试软件。 调试、测试和验证的成本估计占软件开发项目总预算的 50-75%,每年超过 1000 亿美元。 虽然工具、语言和环境减少了花在单个调试任务上的时间,但它们并没有显着减少花在调试上的总时间,也没有减少调试的成本。 因此,过度关注开发过程中消除错误是适得其反的; 程序员应该将调试视为解决问题的练习来接受。
Queue 读者 - 又是新的一天,又是新的错误
作为本期关于程序员工具的一部分,我们在 Queue 决定就调试主题进行非正式的 Web 投票。 我们请您告诉我们您使用的工具以及您如何使用它们。 我们还收集了一些关于那些难以追踪的错误的案例,这些错误有时会让我们考虑从事其他职业。