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

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

在前面的课程中,我们探讨了各种改变代码以帮助调试的方法,包括打印诊断信息或使用日志记录器。这些是检查程序运行时状态的简单方法。尽管如果使用得当,这些方法可以很有效,但它们仍然有缺点:它们需要改变代码,这既耗时又可能引入新错误,而且会使代码变得杂乱,使现有代码更难理解。

到目前为止我们所展示的技术背后有一个未明确说明的假设:一旦我们运行代码,它将运行到完成(只在需要接受输入时暂停),而我们没有机会介入并在我们想要的任意时刻检查程序的结果。

然而,如果我们能够消除这个假设呢?幸运的是,大多数现代集成开发环境(IDE)都附带了一个集成工具,称为调试器,它的设计目的正是为了做到这一点。

调试器

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

调试器的强大之处在于两个方面:能够精确控制程序的执行,以及能够查看(如果需要的话,还可以修改)程序的状态。

最初,调试器(如 gdb)是具有命令行界面的独立程序,程序员必须输入晦涩的命令才能使它们工作。后来的调试器(如 Borland 的早期 turbo 调试器)仍然是独立程序,但提供了一个"图形"前端,使使用它们变得更容易。如今,许多现代 IDE 都有了集成调试器,也就是说,调试器使用与代码编辑器相同的界面,因此你可以使用编写代码的相同环境进行调试(而不是必须切换程序)。

虽然集成调试器非常方便,也推荐初学者使用,但在不支持图形界面的环境中(例如嵌入式系统),命令行调试器仍然得到了很好的支持,并且被广泛使用。

几乎所有的现代调试器都包含相同的标准基本功能集 —— 然而,在访问这些功能的菜单布局方面几乎没有一致性,更不用说键盘快捷键了。尽管我们的示例将使用 Microsoft Visual Studio 的截图(我们也会介绍如何在 Code::Blocks 中完成所有操作),但无论你使用哪种 IDE,你应该都能轻松地找到我们讨论的每个功能的访问方式。

提示

只有当 IDE/集成调试器是活动窗口时,调试器的键盘快捷键才会起作用。

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

提示

不要忽视学习使用调试器。随着你的程序变得越来越复杂,你花在学习有效使用集成调试器上的时间将远远少于你节省的查找和修复问题的时间。

警告

在继续本课程(以及后续与使用调试器相关的课程)之前,请确保你的项目是使用调试构建配置编译的(更多信息请参阅 0.9 — 配置你的编译器:构建配置)。

如果你使用的是发布配置来编译项目,调试器的功能可能无法正常工作(例如,当你尝试进入程序时,它只会运行程序)。

对于 Code::Blocks 用户

如果你使用的是 Code::Blocks,你的调试器可能设置正确,也可能没有。让我们来检查一下。

首先,转到设置菜单 > 调试器……。接下来,在左侧打开 GDB/CDB 调试器树,选择默认项。应该会打开一个类似以下内容的对话框:

Code::Blocks安装

如果你在"可执行路径"应该出现的地方看到一个大大的红色条形,那么你需要定位你的调试器。为此,点击可执行路径字段右侧的……按钮。接下来,在你的系统上找到"gdb32.exe"文件 —— 我的在 C:\Program Files (x86)\CodeBlocks\MinGW\bin\gdb32.exe。然后点击确定。

对于 Code::Blocks 用户

有报告称,Code::Blocks 集成调试器(GDB)在识别包含空格或非英文字符的某些文件路径时可能会出现问题。如果在学习这些课程时调试器似乎出现故障,这可能是原因之一。

对于 VS Code 用户

要设置调试,请按 Ctrl+Shift+P 并选择"C/C++: 添加调试配置",接着选择"C/C++: g++ 构建并调试活动文件"。这应该会创建并打开 launch.json 配置文件。将"stopAtEntry"更改为 true: “stopAtEntry”: true,

然后打开 main.cpp 并通过按 F5 或按 Ctrl+Shift+P 并选择"调试:开始调试并在入口处停止"来开始调试。

单步执行

我们将首先通过检查一些允许我们控制程序执行方式的调试工具来开始对调试器的探索。

单步执行是一组相关调试功能的名称,它让我们能够逐条语句地执行(单步通过)我们的代码。

我们将依次介绍一些相关的单步命令。

进入函数

“进入函数"命令按程序的正常执行路径执行下一条语句,然后暂停程序的执行,以便我们使用调试器检查程序的状态。如果正在执行的语句包含函数调用,“进入函数"会使程序跳到被调用函数的顶部,并在那里暂停。

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

#include <iostream>

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

int main()
{
    printValue(5);
    return 0;
}

让我们使用"进入函数"命令来调试这个程序。

首先,找到并执行一次"进入函数"调试命令。

对于 Visual Studio 用户

在 Visual Studio 中,可以通过调试菜单 > 进入函数,或者按 F11 快捷键来访问"进入函数"命令。

对于 Code::Blocks 用户

在 Code::Blocks 中,可以通过调试菜单 > 进入函数,或者按 Shift-F7 快捷键组合来访问"进入函数"命令。

对于 VS Code 用户

在 VS Code 中,可以通过运行 > 进入函数来访问"进入函数"命令。

对于其他编译器/IDE 用户

如果你使用的是其他 IDE,你可能会在调试或运行菜单下找到"进入函数"命令。

当程序未运行且你执行第一个调试命令时,你可能会看到许多事情发生:

  • 如果需要,程序将重新编译。
  • 程序将开始运行。由于我们的应用程序是控制台程序,应该会打开一个控制台输出窗口。它将是空的,因为我们还没有输出任何内容。
  • 你的 IDE 可能会打开一些诊断窗口,这些窗口可能被称为"诊断工具”、“调用堆栈"和"监视"等。我们稍后会介绍其中一些是什么 —— 现在你可以忽略它们。
  • 因为我们执行了"进入函数”,你应该会看到某种标记出现在main函数的左大括号(第 9 行)左侧。在 Visual Studio 中,这个标记是一个黄色箭头(Code::Blocks 使用黄色三角形)。如果你使用的是其他 IDE,你应该会看到有相同用途的东西。 Code::Blocks安装

这个箭头标记表明将要执行的行。在这种情况下,调试器告诉我们,下一条要执行的语句是main函数的左大括号(第 9 行)。

选择"进入函数”(使用上面列出的适用于你的 IDE 的命令),执行左大括号,箭头将移动到下一条语句(第 10 行)。 Code::Blocks安装

这意味着下一条将要执行的语句是对printValue函数的调用。

再次选择"进入函数"。由于这条语句包含对printValue的函数调用,我们将进入该函数,箭头将移动到printValue的函数体顶部(第 4 行)。 Code::Blocks安装

再次选择"进入函数",以执行printValue函数的左大括号,这将使箭头前进到第 5 行。 Code::Blocks安装

再次选择"进入函数",这将执行语句std::cout << value << '\n',并将箭头移动到第 6 行。

警告

用于输出的<<运算符的版本是作为函数实现的。因此,你的 IDE 可能会进入<<运算符函数的实现中。

如果发生这种情况,你会看到你的 IDE 打开一个新代码文件,箭头标记将移动到名为operator<<的函数顶部(这是标准库的一部分)。关闭刚刚打开的代码文件,然后找到并执行"退出函数"调试命令(如果需要帮助,请参阅下面的"退出函数"部分)。

现在,由于std::cout << value << '\n'已经执行,我们应该在控制台窗口中看到值 5 出现。

提示

在之前的课程中,我们提到过std::cout是缓冲的,这意味着在你要求std::cout打印一个值和它实际打印之间可能会有延迟。因此,你可能在这个时候看不到值 5 出现。为了确保std::cout的所有输出都能立即输出,你可以在main()函数的顶部临时添加以下语句:

std::cout << std::unitbuf; // 为调试启用 std::cout 的自动刷新

出于性能原因,在调试完成后,应该移除或注释掉这条语句。

如果你不想不断地添加/移除/注释/取消注释上述内容,你可以将语句包装在条件编译预处理器指令中(在 2.10 — 预处理器简介中有介绍):

#ifdef DEBUG
std::cout << std::unitbuf; // 为调试启用 std::cout 的自动刷新
#endif

你需要确保在该语句上方或作为编译器设置的一部分定义了 DEBUG 预处理器宏。

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

你会注意到箭头再次指向printValueCode::Blocks安装

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

再选择三次"进入函数"。此时,我们已经执行了程序中的所有语句,所以结束了。有些调试器此时会自动终止调试会话,有些则不会。如果调试器没有自动终止,你可能需要在菜单中找到"停止调试"命令(在 Visual Studio 中,这个命令在调试 > 停止调试下)。

请注意,“停止调试"可以在调试过程中的任何时刻用来结束调试会话。

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

提示

在后续课程中,我们将探索其他调试器命令,其中一些命令可能只有在调试器已经运行时才可用。如果所需的调试命令不可用,请进入代码以启动调试器,然后再试一次。

跳过函数

与"进入函数"类似,“跳过函数"命令按程序的正常执行路径执行下一条语句。然而,与"进入函数"会进入函数调用并逐行执行它们不同,“跳过函数"将执行整个函数而不暂停,并在函数执行完毕后将控制权交还给你。

对于 Visual Studio 用户

在 Visual Studio 中,可以通过调试菜单 > 跳过函数,或者按 F10 快捷键来访问"跳过函数"命令。

对于 Code::Blocks 用户

在 Code::Blocks 中,“跳过函数"命令被称为"下一行”,可以通过调试菜单 > 下一行,或者按 F7 快捷键来访问。

对于 VS Code 用户

在 VS Code 中,可以通过运行 > 跳过函数,或者按 F10 快捷键来访问"跳过函数"命令。

让我们来看一个跳过printValue函数调用的例子:

#include <iostream>

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

int main()
{
    printValue(5);
    return 0;
}

首先,对程序使用"进入函数”,直到执行标记位于第 10 行: Code::Blocks安装

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

“跳过函数"命令提供了一种方便的方法,用于跳过那些你确信已经正常工作或目前不想调试的函数。

退出函数

与另外两个单步命令不同,“退出函数"不会仅仅执行下一行代码。相反,它会执行当前正在执行的函数中剩余的所有代码,然后在函数返回时将控制权交还给你。

对于 Visual Studio 用户

在 Visual Studio 中,可以通过调试菜单 > 退出函数,或者按 Shift-F11 快捷键组合来访问"退出函数"命令。

对于 Code::Blocks 用户

在 Code::Blocks 中,可以通过调试菜单 > 退出函数,或者按 Ctrl-F7 快捷键组合来访问"退出函数"命令。

对于 VS Code 用户

在 VS Code 中,可以通过运行 > 退出函数,或者按 Shift+F11 快捷键组合来访问"退出函数"命令。

让我们使用上面相同的程序来看一个使用"退出函数"的例子:

#include <iostream>

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

int main()
{
    printValue(5);
    return 0;
}

使用"进入函数"进入程序,直到你位于printValue函数内部,执行标记位于第 4 行。

Code::Blocks安装

然后选择"退出函数”。你会注意到值 5 出现在输出窗口中,调试器在函数终止后(第 10 行)将控制权交还给你。

Code::Blocks安装

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

迈步过度

在单步执行程序时,你通常只能向前迈步。很容易不小心迈过(过度)你想检查的地方。

如果你迈过了目标位置,通常的做法是停止调试,然后重新开始调试,这次要更加小心,不要错过目标。

后退一步

一些调试器(如 Visual Studio 企业版和 rr)引入了一种称为后退一步或反向调试的单步功能。后退一步的目标是倒回最后一步,以便将程序恢复到之前的状态。如果你过度迈步,或者想重新检查刚刚执行的语句,这会很有用。

实现后退一步需要调试器具备极高的复杂性(因为它必须为每一步跟踪一个单独的程序状态)。由于复杂性,这一功能尚未标准化,而且因调试器而异。截至写作时(2019 年 1 月),Visual Studio 社区版或最新版 Code::Blocks 都不支持这一功能。希望在未来的某个时候,它会逐渐普及到这些产品中,并得到更广泛的应用。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"