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

当你运行程序时,执行从 main 函数的顶部开始,然后按顺序逐条执行语句,直至程序结束。在程序运行的任何时刻,系统都在持续追踪大量信息:你使用的变量值、已调用的函数(以便这些函数返回时程序能知道从何处继续执行),以及程序当前的执行位置(用于确定下一步应执行哪条语句)。所有这些被追踪的信息统称为程序状态program state(或简称为状态)。

在之前的课程中,我们探讨了多种通过修改代码辅助调试的方法,包括打印诊断信息或使用日志工具。这些都是在程序运行时检查其状态的简单手段。虽然使用得当能有效解决问题,但它们仍存在缺点:需要修改代码,既耗时又可能引入新错误,还会使代码变得冗杂,降低现有代码的可读性。

我们此前展示的技术背后存在一个隐含假设:一旦代码开始运行,它将持续执行至结束(仅在接受输入时暂停),我们无法在任意时刻介入并检查程序结果。

但如果我们能打破这个假设呢?幸运的是,大多数现代集成开发环境都内置了名为调试器的工具,其设计目的正是实现这一功能。


调试器

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

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

早期调试器(如gdb)是独立运行的命令行程序,程序员需输入晦涩的命令才能操作。后期调试器(如Borland早期Turbo Debugger)虽仍为独立程序,但提供了“图形化”前端界面以提升易用性。如今多数现代集成开发环境(IDE)都内置了集成调试器——即与代码编辑器共享同一界面的调试器,使开发者能在编写代码的同一环境中进行调试(无需切换程序)。

尽管集成调试器便捷性高且适合初学者,命令行调试器在不支持图形界面的环境(如嵌入式系统)中仍被广泛使用且支持完善。

几乎所有现代调试器都包含相同的基础功能集,但这些功能的菜单布局缺乏统一性,键盘快捷键的规范性则更为欠缺。虽然我们的示例将采用微软Visual Studio的截图(同时也会说明如何在Code::Blocks中实现所有功能),但无论您使用何种IDE,都应能轻松掌握我们讨论的各项功能的调用方式。

提示
调试器键盘快捷键仅在IDE/集成调试器为活动窗口时生效。

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

提示
切勿忽视调试器的学习。随着程序日益复杂,你花在掌握集成调试器高效使用技巧上的时间,与它帮你节省的排查和修复问题的时间相比,简直微不足道。

警告

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

若您使用发布配置编译项目,调试器功能可能无法正常工作(例如尝试步入程序时,程序会直接运行而非进入调试状态)。

对于 Code::Blocks 用户

若您使用 Code::Blocks,调试器可能已正确配置,也可能尚未设置妥当。让我们进行检查。

首先,进入菜单栏的「设置」>「调试器...」。接着,在左侧展开 GDB/CDB 调试器树,选择「默认」。此时应会弹出类似下图所示的对话框:

image

如果在“可执行文件路径”应显示的位置看到一条大红色横条,则需要定位调试器。为此,请点击可执行文件路径字段右侧的“…”按钮。接下来在系统中找到“gdb32.exe”文件——我的文件位于C:\Program Files (x86)\CodeBlocks\MinGW\bin\gdb32.exe。然后点击确定。

对于 Code::Blocks 用户
有报告指出,Code::Blocks 内置调试器(GDB)在识别包含空格或非英文字符的文件路径时可能出现问题。若您在学习本教程过程中发现调试器运行异常,这可能是导致故障的原因。

对于 VS Code 用户:

要设置调试环境,请按 Ctrl+Shift+P 选择“C/C++: Add Debug Configuration”,接着选择“C/C++: g++ build and debug active file”。这将创建并打开 launch.json 配置文件。将“stopAtEntry”设置为 true:

“stopAtEntry”: true,

随后打开 main.cpp 文件,按 F5 键或按 Ctrl+Shift+P 选择“调试:开始调试并在入口处停止”即可启动调试。


单步执行

我们将从探索调试器开始,首先考察一些能够控制程序执行方式的调试工具。

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

我们将依次介绍若干相关的逐步执行命令。


步入(进入函数)

步入step into命令将执行程序正常执行路径中的下一条语句,然后暂停程序执行,以便我们使用调试器检查程序状态。如果正在执行的语句包含函数调用,步入执行将使程序跳转到被调用函数的顶部,并在该处暂停。

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

#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,通常可在“调试”或“运行”菜单下找到步入命令。
我:lldb(gdb)的见vim应用:构建运行C++/C

当程序未运行时执行首次调试命令,您可能会观察到以下现象:

  • 程序将根据需要重新编译。
  • 程序开始运行。由于我们的应用程序是控制台程序,此时应打开控制台输出窗口。由于尚未输出任何内容,该窗口目前为空白状态。
  • 您的集成开发环境可能会自动打开若干诊断窗口,例如“诊断工具”、“调用堆栈”和“监视器”等。这些窗口的功能将在后续章节详述——当前可暂时忽略。

由于执行了单步调试,您现在应在主函数(第9行)左大括号左侧看到某种标记。在Visual Studio中,该标记为黄色箭头(Code::Blocks使用黄色三角形)。若使用其他IDE,应能看到功能等效的标记。

image

此箭头标记表示被指向的代码行将被执行。在此情况下,调试器告知我们下一条待执行代码是函数 main 的左大括号(第 9 行)。

选择步入(使用上述对应您 IDE 的命令)以执行左大括号,箭头将移动至下一条语句(第 10 行)。

image

这意味着接下来将执行的代码行是调用函数 printValue。

再次选择步入。由于该语句包含对 printValue 的函数调用,我们将步入进入该函数,此时箭头将移动至 printValue 函数主体的开头(第 4 行)。

image

选择再次步入进入以执行函数printValue的左大括号,这将使箭头前进至第5行。

image

选择再次步入,这将执行语句 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; // enable automatic flushing for std::cout (for debugging)

出于性能考虑,调试完成后应删除或注释掉此语句。

若不想反复添加/删除/注释/取消注释上述语句,可将其包裹在条件编译预处理指令中(详见第2.10节——预处理器介绍):

#ifdef DEBUG
std::cout << std::unitbuf; // enable automatic flushing for std::cout (for debugging)
#endif

您需要确保DEBUG预处理器宏已被定义,要么在此语句上方某处定义,要么作为编译器设置的一部分进行定义。

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

你会注意到箭头再次指向printValue!

image

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

选择再执行三次。此时我们已执行完程序中的所有代码行,因此调试结束。部分调试器会在此时自动终止调试会话,而其他调试器则可能不会。若您的调试器未自动终止,需在菜单中查找“停止调试”命令(在Visual Studio中位于调试 > 停止调试)。

请注意,在调试过程的任何阶段均可使用“停止调试”命令终止调试会话。

恭喜!您已成功逐步执行程序并观察到每行代码的运行过程!

提示
在后续课程中,我们将探索其他调试器命令,其中部分命令可能需要调试器已启动才能使用。若所需调试命令不可用,请逐步进入代码以启动调试器,然后重试。


步过(跳过函数)

类似于步入,步过step over命令将执行程序正常执行路径中的下一条语句。然而,步入会进入函数调用并逐行执行,而步过则会不间断地执行整个函数,并在函数执行完毕后将控制权交还给您。

对于 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行:

image

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

当您确定函数已正常工作或暂时无意调试时,“步过”命令可便捷地跳过函数执行。


步出(跳出函数)

与其他两个步进命令不同,步出Step out命令不仅执行下一行代码,还会执行当前正在执行的函数中所有剩余代码,并在函数返回后将控制权交还给您。

对于 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 行。

image

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

image

当你意外进入一个不想调试的函数时,这条命令最为实用。


一步之遥

在程序调试过程中,通常只能向前逐步推进。很容易不小心跳过(越过)想要检查的位置。

若误越目标位置,通常的做法是停止调试并重新开始,这次需格外小心避免再次错过目标点。


步退

某些调试器(如Visual Studio企业版和rr)引入了一种通常称为步退或反向调试的步进功能。步退操作旨在回溯上一步,使程序恢复至先前状态。当执行过头或需要重新检查刚执行的语句时,此功能尤为实用。

实现步退功能需要调试器具备高度复杂的机制(因其需为每个步骤维护独立的程序状态)。由于技术难度,该功能尚未标准化,不同调试器实现各异。截至本文撰写时(2019年1月),Visual Studio社区版及最新版Code::Blocks均未支持此功能。期待未来该功能能逐步普及至这些产品,实现更广泛的应用。

posted @ 2026-02-11 08:53  游翔  阅读(0)  评论(0)    收藏  举报