断点和监测点

使用最多的调试器特性大概是在一个函数的入口或者特定的源码行设置断点。一个简单的断点在许多案子可能不够。比如通常会在一个被怀疑的变量可能会被不正确修改的地方设置一些断点。但是当断点被碰到之前要经过上百次,则是繁琐和不具有可操作性的。你甚至可能会错过那个时刻,因为你需要从那么多合法的选择中找到那个坏的。普遍的解决方案是条件断点——将一个特定的条件表达式与断点关联起来。当断点被碰到时,调试器计算表达式。如果计算结果是真值,那么程序停止等待用户操作;否则程序继续运行。读者应该意识到条件断点的性能损耗。尽管条件断点为假值时看起来被调试的程序不被打断地运行着,程序实际上每一次都会停止下来,在表达式被计算过后重新恢复运行。如果消耗过大,比如可能会经常被调用的函数导致,我们需要采用一种更快的方式来检查数据。举个例子,函数插入可以避免调试器的介入(参看第六章获取更多细节)

表明一个断点条件的创意的方式很多。条件表达式的有效性反映了一个开发人员的经验值。下面的调试器是一些条件断点的例子:第一个命令告诉gdb在它停止程序之前忽略断点100次;第二个设置到函数的断点,条件是变量或者参数的索引值为5;最后一个命令在指令地址0x12345678设置断点,条件是函数GetRefCount返回值为0.这个条件需要调试器调用一个函数来计算表达式。

(gdb)ignore 1 100
(gdb)break foo if index==5
(gdb)break *0x12345678 if GetRefCount(this)==0

一个断点可以跟设置在代码一样设置在数据对象。后者是一个监测点,也叫数据断点。一个程序bug经常跟特定的数据对象关联和通过访问这个对象显露它自己。代码断点的目的是让我们在可能不正确改变数据的指令审阅程序状态。这个方式最明显的一个不足是它面向的是代码而非数据。被监测的代码可能处理很多数据对象,除了怀疑的,还有大部分时候,它都是合法和正确的。所以,当怀疑的是一个特定的数据对象的时候,这个调查的作用域太宽以至于不能有效。

如果监测点可以设置在正确的数据对象,我们有更大地几率抓住这个bug。监测点也适合当有很多地方可以不正确地修改数据对象的情况。一个代码断点在这些场合下不是很有帮助因为它在没有多少有趣信息的时候经常停止程序。既然每当被跟踪的数据对象被覆写或者读取的时候——取决于监测点的模式——监测点会停止程序,当我们知道数据对象是程序错误的关键但是我们不知道怎么以及哪里它被修改成无效的状态,这是最有效率的。监测点是一个强大的特性,用来通过关注数据引用定位程序失败。

设置一个断点和监测点在大多数情况下都是直观的。但是如果调试器的介入显著地影响重现问题,它需要仔细的考虑。断点和监测点使用不同的机制实现。调试器通过在特定位置替换指令为一个短的陷入指令来设置断点。原来的指令代码被存在了缓冲区。当程序执行陷入指令,也就是碰到断点,调试器被告知,程序被停止等待下一条命令。

如果原来的用户选择继续运行,调试器使用原来的代码替换陷入指令,恢复程序的运行。另外方面,同样的方法不适用于监测点因为数据对象是不可执行的。所以,它的实现一是定期地(软件模式)查询数据的值,二是使用CPU支持的调试寄存器(硬件模式)。软件监测点是通过单步运行程序和在每一步检测被跟踪的变量,这个比正常的运行慢百倍。

因为单步运行不能在多线程下保证,在多线程多处理的环境下,这个方法不能保证抓住数据被访问的瞬间。硬件监测点没有这样的问题,源于被跟踪变量的计算是由硬件完成,不会干扰调试器。但是硬件监测点在个数上是非常有限的。多数CPUs只有几个调试寄存器。如果监测点表达式复杂或者已经有许多监测点,数据大小会超出硬件的总容量,在这种情况下,调试器会隐式地回退到软件监测点。这会导致程序慢得在爬。所以你应该时刻注意调试器是否设置成了软件模式的监测点。如果是这样,那么你可能需要调整你的调试策略。比如,为了让硬件断点可以被使用,分解复杂的数据结构为更小的部分。

监测点可以设置像断点一样的条件。比如,下面的gdb命令在变量sum被改变且变量index大于100时停止程序。

(gdb)watch sum if index > 100

尽管硬件断点给性能带来的影响小,计算条件跟之前提及的具有同样的性能消耗。内核必须要临时停止程序和跟调试器通信,然后计算条件和确定下一个动作。