章节目录

使用集成调试器:单步执行

本节阅读量:

当你运行程序时,执行从main函数的顶部开始,然后逐条语句按顺序进行,直到程序结束。在程序运行的任何时间点,程序都会跟踪许多信息:正在使用的变量的值、调用了哪些函数(这样当那些函数返回时,程序就知道返回到哪里),以及程序中的当前执行点(这样它就知道下一步要执行哪条语句)。所有这些被跟踪的信息都称为程序状态(简称为状态)。

在前面的课程中,我们探索了各种通过修改代码来辅助调试的方法,包括打印诊断信息或使用日志记录器。这些是在程序运行时检查程序状态的简单方法。如果使用得当,它们可以很有效,但仍然有缺点:它们需要修改代码,这不仅花费时间,还可能引入新的错误,同时会使代码变得混乱,让现有代码更难理解。

到目前为止,我们展示的技术背后有一个隐含的假设:一旦运行代码,它将一直运行到完成(仅在需要接受输入时暂停),我们没有机会在任意时刻介入并检查程序的状态。

然而,如果我们能够打破这个假设呢?幸运的是,大多数现代IDE都附带了一个名为调试器的集成工具,它正是为此而设计的。


调试器(debugger)

调试器是一种计算机程序,它允许程序员控制另一个程序的执行方式,并在该程序运行时检查程序状态。例如,程序员可以使用调试器逐行执行程序,一路检查变量的值。通过将变量的实际值与预期值进行比较,或者通过代码观察执行路径,调试器可以极大地帮助跟踪语义(逻辑)错误。

调试器的核心功能有两方面:精确控制程序执行的能力,以及查看(并在需要时修改)程序状态的能力。

早期的调试器(如gdb)是带有命令行界面的独立程序,程序员必须键入晦涩的命令才能使用它们。后来的调试器(如Borland的turbo调试器的早期版本)仍然是独立的,但附带了自己的”图形”前端,使操作更加方便。如今许多现代IDE都内置了集成调试器——也就是说,调试器使用与代码编辑器相同的界面,因此你可以在编写代码的同一环境中进行调试(而不必切换到其他程序)。

虽然集成调试器非常方便,也建议初学者使用,但命令行调试器在不同系统上有更广泛的支持,并且在不支持图形界面的环境(例如嵌入式系统)中仍然被广泛使用。

几乎所有的现代调试器都包含相同的标准基本功能集——然而,在菜单布局方面几乎没有统一标准,键盘快捷键的一致性就更低了。尽管我们的示例将使用Microsoft Visual Studio的屏幕截图,但无论你使用哪种IDE,都应该能很容易地理解如何使用我们所讨论的每个功能。

本章的其余部分将用于学习如何使用调试器。


单步执行(Stepping)

我们将首先学习一些允许我们控制程序执行方式的调试工具,从而开始对调试器的探索。

Stepping是一组相关调试器功能的统称,这些功能允许我们逐条语句地执行(单步执行)代码。

我们将依次介绍许多相关的单步执行命令。


逐语句(Step into)

Step into 命令在程序的正常执行路径中执行下一条语句,然后暂停程序的执行,以便我们可以使用调试器检查程序的状态。如果正在执行的语句包含函数调用,则单步执行将导致程序跳到被调用函数的顶部,在那里它将暂停。

让我们来看一个非常简单的程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <iostream>

void printValue(int value)
{
    std::cout << value << '\n';
}

int main()
{
    printValue(5);

    return 0;
}

首先,执行一次step into调试命令。

当程序尚未开始运行时,执行第一个调试命令后,你可能会看到以下几件事情发生:

  1. 如果需要,程序将重新编译。
  2. 程序将开始运行。因为我们的应用程序是控制台程序,所以应该会打开一个控制台输出窗口。窗口内容将为空,因为我们还没有输出任何内容。
  3. 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行。

现在,由于 std::cout « value « ‘\n’; 已经执行,我们应该会在控制台窗口中看到值5。

再次选择step into以执行函数printValue的右大括号。此时,printValue已完成执行,控制权返回给main。

你会注意到箭头再次指向了printValue的调用位置!

函数执行结束

虽然你可能以为调试器打算再次调用printValue,但实际上调试器只是在告诉你它刚刚从函数调用中返回。

再选择三次step into。此时,我们已经执行了程序中的所有行,程序运行完毕。一些调试器会在此时自动终止调试会话,另一些则可能不会。如果调试器没有自动终止,你可能需要在菜单中找到”停止调试”命令(在Visual Studio中,该命令位于”调试”>”停止调试”下)。

请注意,你可以在调试过程中的任何时候使用”停止调试”来结束调试会话。

恭喜你,你现在已经逐行执行完了一个程序,并观察了每一行的执行过程!


逐过程(Step over)

与step into类似,step over命令也会在程序的正常执行路径中执行下一条语句。然而,不同的是,step into会进入函数调用并逐行执行,而step over则会不停顿地执行完整个函数,并在函数执行后将控制权交还给你。

让我们来看一个使用step over跳过printValue函数调用的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <iostream>

void printValue(int value)
{
    std::cout << value << '\n';
}

int main()
{
    printValue(5);

    return 0;
}

首先,使用step into,直到执行标记位于第10行:

执行到调用函数前

现在,选择step over。调试器将执行该函数(在控制台输出窗口中打印值5),然后在下一条语句(第12行)将控制权交还给你。

当你确定某些函数已经正常工作,或者暂时不想深入调试它们时,step over命令提供了一种方便的方式来跳过这些函数。


跳出(Step out)

与其他两个单步执行命令不同,step out不只是执行下一行代码。相反,它会执行当前正在执行的函数中的所有剩余代码,然后在函数返回后将控制权交还给你。

让我们使用与上面相同的程序来看一个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <iostream>

void printValue(int value)
{
    std::cout << value << '\n';
}

int main()
{
    printValue(5);

    return 0;
}

首先,使用step into执行程序,直到进入函数printValue,执行标记在第4行。

进入函数调用

然后选择step out。你将注意到值5出现在输出窗口中,并且调试器在函数执行完毕后将控制权返回给了你(在第10行)。

执行完调用函数

当你不小心进入了一个不想调试的函数时,这个命令最为有用。


执行太多代码

在单步执行程序时,你只能向前推进。一不小心就很容易跨过(越过)你想检查的地方。

如果超过了预期目标,通常的做法是停止调试,然后重新启动调试,这次要更加小心,避免再次越过目标。


后退一步

一些调试器(如Visual Studio Enterprise Edition)引入了一种单步执行功能,通常称为后退或反向调试。后退的目标是回退最后一步,以便你可以将程序恢复到先前的状态。如果你执行了过多的步骤,或者想重新检查刚刚执行的语句,这个功能会很有用。

实现后退功能增加了调试器的复杂性(因为它必须记录每一步的程序状态)。由于这种复杂性,该功能尚未标准化,不同调试器的实现也各不相同。希望在未来某个时候,它能够得到更广泛的支持。


3.4 更多调试策略

上一节

3.6 断点

下一节