使用集成调试器:单步执行
本节阅读量:当你运行程序时,执行从main函数的顶部开始,然后逐条语句按顺序进行,直到程序结束。在程序运行的任何时间点,程序都会跟踪许多信息:正在使用的变量的值、调用了哪些函数(这样当那些函数返回时,程序就知道返回到哪里),以及程序中的当前执行点(这样它就知道下一步要执行哪条语句)。所有这些被跟踪的信息都称为程序状态(简称为状态)。
在前面的课程中,我们探索了各种通过修改代码来辅助调试的方法,包括打印诊断信息或使用日志记录器。这些是在程序运行时检查程序状态的简单方法。如果使用得当,它们可以很有效,但仍然有缺点:它们需要修改代码,这不仅花费时间,还可能引入新的错误,同时会使代码变得混乱,让现有代码更难理解。
到目前为止,我们展示的技术背后有一个隐含的假设:一旦运行代码,它将一直运行到完成(仅在需要接受输入时暂停),我们没有机会在任意时刻介入并检查程序的状态。
然而,如果我们能够打破这个假设呢?幸运的是,大多数现代IDE都附带了一个名为调试器的集成工具,它正是为此而设计的。
调试器(debugger)
调试器是一种计算机程序,它允许程序员控制另一个程序的执行方式,并在该程序运行时检查程序状态。例如,程序员可以使用调试器逐行执行程序,一路检查变量的值。通过将变量的实际值与预期值进行比较,或者通过代码观察执行路径,调试器可以极大地帮助跟踪语义(逻辑)错误。
调试器的核心功能有两方面:精确控制程序执行的能力,以及查看(并在需要时修改)程序状态的能力。
早期的调试器(如gdb)是带有命令行界面的独立程序,程序员必须键入晦涩的命令才能使用它们。后来的调试器(如Borland的turbo调试器的早期版本)仍然是独立的,但附带了自己的”图形”前端,使操作更加方便。如今许多现代IDE都内置了集成调试器——也就是说,调试器使用与代码编辑器相同的界面,因此你可以在编写代码的同一环境中进行调试(而不必切换到其他程序)。
虽然集成调试器非常方便,也建议初学者使用,但命令行调试器在不同系统上有更广泛的支持,并且在不支持图形界面的环境(例如嵌入式系统)中仍然被广泛使用。
几乎所有的现代调试器都包含相同的标准基本功能集——然而,在菜单布局方面几乎没有统一标准,键盘快捷键的一致性就更低了。尽管我们的示例将使用Microsoft Visual Studio的屏幕截图,但无论你使用哪种IDE,都应该能很容易地理解如何使用我们所讨论的每个功能。
本章的其余部分将用于学习如何使用调试器。
提示
不要忽视学习使用调试器。随着程序变得越来越复杂,你花在学习有效使用集成调试器上的时间,与它在查找和修复问题时所节省的时间相比,将变得微不足道。
警告
在继续本课程(以及后续与调试器相关的课程)之前,请确保你的项目使用了调试构建模式进行编译。
如果你使用的是发布模式编译项目,则调试器的功能可能无法正常工作。
单步执行(Stepping)
我们将首先学习一些允许我们控制程序执行方式的调试工具,从而开始对调试器的探索。
Stepping是一组相关调试器功能的统称,这些功能允许我们逐条语句地执行(单步执行)代码。
我们将依次介绍许多相关的单步执行命令。
逐语句(Step into)
Step into 命令在程序的正常执行路径中执行下一条语句,然后暂停程序的执行,以便我们可以使用调试器检查程序的状态。如果正在执行的语句包含函数调用,则单步执行将导致程序跳到被调用函数的顶部,在那里它将暂停。
让我们来看一个非常简单的程序:
|
|
首先,执行一次step into调试命令。
对于Visual Studio用户
在Visual Studio中,可以通过菜单 “调试”>“逐语句”或按F11快捷键来执行step into命令。
当程序尚未开始运行时,执行第一个调试命令后,你可能会看到以下几件事情发生:
- 如果需要,程序将重新编译。
- 程序将开始运行。因为我们的应用程序是控制台程序,所以应该会打开一个控制台输出窗口。窗口内容将为空,因为我们还没有输出任何内容。
- IDE可能会打开一些诊断窗口,这些窗口可能带有”诊断工具”、”调用堆栈”和”监视”等名称。稍后我们会讨论其中的一些——现在你可以先忽略它们。
因为我们已经执行了step into,所以你现在应该会看到函数main的左大括号(第9行)左侧出现了某种标记。在Visual Studio中,这个标记是一个黄色箭头。如果你使用的是其他IDE,应该会看到具有相同用途的标记。
该箭头标记指示它所指向的行即将被执行。在这种情况下,调试器告诉我们,下一行要执行的是函数main的左大括号(第9行)。
选择step into(使用上面列出的对应IDE命令)来执行左大括号,箭头将移动到下一条语句(第10行)。
这意味着将要执行的下一行是对函数printValue的调用。
再次选择step into。由于该语句包含对printValue的函数调用,因此我们将单步进入该函数,箭头将移动到printValue的顶部(第4行)。
再次选择step into以执行函数printValue的左大括号,这将把箭头推进到第5行。
再次选择step into,它将执行语句 std::cout « value « ‘\n’; ,并将箭头移动到第6行。
警告
由于运算符«是作为函数实现的,因此IDE可能会转而单步执行运算符«的实现。
如果发生这种情况,你将看到IDE打开一个新的代码文件,箭头标记将移动到名为operator«的函数的顶部(这是标准库的一部分)。关闭刚刚打开的代码文件,然后查找并执行step out调试命令(如果需要帮助,请参阅下面的”step out”部分)。
现在,由于 std::cout « value « ‘\n’; 已经执行,我们应该会在控制台窗口中看到值5。
提示
在之前的学习中,我们提到std::cout是带缓冲的,这意味着在请求std::cout打印值和它实际打印值之间可能存在延迟。因此,此时你可能还看不到值5。为了确保std::cout的所有输出立即显示,可以将以下语句临时添加到main()函数的顶部:
|
|
出于性能考虑,调试完成后应删除或注释掉该语句。
如果你不想反复添加/删除/注释/取消注释上面的内容,可以将该语句包装在条件编译预处理器指令中:
|
|
你需要确保定义了DEBUG预处理器宏,要么在该语句的上方定义,要么作为编译器设置的一部分。
再次选择step into以执行函数printValue的右大括号。此时,printValue已完成执行,控制权返回给main。
你会注意到箭头再次指向了printValue的调用位置!
虽然你可能以为调试器打算再次调用printValue,但实际上调试器只是在告诉你它刚刚从函数调用中返回。
再选择三次step into。此时,我们已经执行了程序中的所有行,程序运行完毕。一些调试器会在此时自动终止调试会话,另一些则可能不会。如果调试器没有自动终止,你可能需要在菜单中找到”停止调试”命令(在Visual Studio中,该命令位于”调试”>”停止调试”下)。
请注意,你可以在调试过程中的任何时候使用”停止调试”来结束调试会话。
恭喜你,你现在已经逐行执行完了一个程序,并观察了每一行的执行过程!
逐过程(Step over)
与step into类似,step over命令也会在程序的正常执行路径中执行下一条语句。然而,不同的是,step into会进入函数调用并逐行执行,而step over则会不停顿地执行完整个函数,并在函数执行后将控制权交还给你。
让我们来看一个使用step over跳过printValue函数调用的示例:
|
|
首先,使用step into,直到执行标记位于第10行:
现在,选择step over。调试器将执行该函数(在控制台输出窗口中打印值5),然后在下一条语句(第12行)将控制权交还给你。
当你确定某些函数已经正常工作,或者暂时不想深入调试它们时,step over命令提供了一种方便的方式来跳过这些函数。
对于Visual Studio用户
在Visual Studio中,可以通过菜单“调试”>“逐过程”或按F10快捷键来执行step-over命令。
跳出(Step out)
与其他两个单步执行命令不同,step out不只是执行下一行代码。相反,它会执行当前正在执行的函数中的所有剩余代码,然后在函数返回后将控制权交还给你。
让我们使用与上面相同的程序来看一个例子:
|
|
首先,使用step into执行程序,直到进入函数printValue,执行标记在第4行。
然后选择step out。你将注意到值5出现在输出窗口中,并且调试器在函数执行完毕后将控制权返回给了你(在第10行)。
当你不小心进入了一个不想调试的函数时,这个命令最为有用。
对于Visual Studio用户
在Visual Studio中,可以通过菜单“调试”>“跳出”或按Shift-F11快捷组合键来访问“Step out”命令。
执行太多代码
在单步执行程序时,你只能向前推进。一不小心就很容易跨过(越过)你想检查的地方。
如果超过了预期目标,通常的做法是停止调试,然后重新启动调试,这次要更加小心,避免再次越过目标。
后退一步
一些调试器(如Visual Studio Enterprise Edition)引入了一种单步执行功能,通常称为后退或反向调试。后退的目标是回退最后一步,以便你可以将程序恢复到先前的状态。如果你执行了过多的步骤,或者想重新检查刚刚执行的语句,这个功能会很有用。
实现后退功能增加了调试器的复杂性(因为它必须记录每一步的程序状态)。由于这种复杂性,该功能尚未标准化,不同调试器的实现也各不相同。希望在未来某个时候,它能够得到更广泛的支持。