调试C/C++代码 从无IDE到有IDE

C++程序调试

所有的错误都源于一个简单的前提:你认为它是正确的
本篇文章来源于 learnC++ ,个人翻译总结。喜欢看英文版的可以点这个连接。

一般的调试方法

一旦确定了问题,调试问题通常包括五个步骤:

  1. 找到问题的根本原因(通常是出现问题的代码行)
  2. 了解问题发生的原因
  3. 确保如何解决这个问题
  4. 修复导致的问题
  5. 重新测试以确保问题已得到修复并且没有出现新问题

下面使用一个极其简单的例子来进行说明

int main()
{
	int add(int a, int b);
	cout << add(5, 3) << endl;
	return 0;
}

int add(int a, int b) {
	
	return a - b;
}

这段代码虽然能够正常运行,但是却得不到我们想要的结果。错误的答案通过main函数中的cout打印到了屏幕上,所以这就为我们debug提供了一个起点。

找根本原因: 首先对于add的输入参数进行分析,输入的是5和3,很明显输入是正确的,但是输出却不是,所以一定是add函数出现了问题,产生了错误值。通过对add( )函数中的语句进行分析,发现是用错了运算符导致的。

理解问题: 在这个例子中产生的错误是因为用错了运算符而导致的

找出修复方法: 使用+代替-

修复问题: 将-改为+

重复测试: 更改完成之后重新运行程序,得到想要的结果。

虽然这个例子是微不足道、相当简单,但说明了诊断任何程序时将经历的基本过程。

调试策略

在调试程序时,在大多数情况下,绝大部分时间都将用于查找产生错误的位置。发现问题后,其余步骤所花费的时间(修复问题和验证问题已修复)相比之下通常是微不足道的。

通过检查代码发现问题

假设你已经注意到一个问题,并且你想追踪该特定问题的原因。在许多情况下(尤其是在较小的程序中),我们可以快速找到问题所在。

int main()
{
    getNames(); // 告诉用户输入一批名字
    sortNames(); // 按照字母顺序对他们进行排序
    printNames(); // 输出排序后的名字列表
 
    return 0;
}

如果你希望此程序按字母顺序打印名称,但它以相反的顺序打印名称,则问题可能出在 sortNames 函数中。如果您可以将问题缩小到特定函数,您也许可以通过查看代码来发现问题。

然而,随着程序变得越来越复杂,通过代码检查发现问题也变得越来越复杂。

  • 首先,有很多代码需要查看。查看一个长达数千行的程序中的每一行代码可能需要很长时间(更不用说它非常无聊)。
  • 其次,代码本身往往更复杂,就会有更多可能出错的地方。
  • 第三,代码的行为可能不会给你很多关于哪里出错的线索。比如你编写了一个输出股票推荐的程序,但它实际上什么都不输出,你可能不会知道从哪里开始寻找问题。
  • 最后,错误可能是由错误的假设引起的。几乎不可能在视觉上发现由错误假设引起的错误,因为您在检查代码时可能会做出同样的错误假设,而没有注意到错误。那么如果我们有一个通过代码检查找不到的问题,我们如何找到它?
通过运行程序发现问题

如果我们无法通过代码检查发现问题,我们可以采取另一种方法:我们可以观察程序运行时的行为,并尝试从中诊断问题。这种方法可以概括为:

  1. 弄清楚如何重现问题
  2. 运行程序并收集信息以缩小问题的范围
  3. 重复上一步直到找到问题

重现问题: 首先,发现问题的第一步也是最重要的一步是能够重现问题。原因很简单:除非你能观察到它的发生,否则很难找到问题。如果一个软件问题很明显(例如,程序每次运行时都在同一个地方崩溃),那么重现该问题可能是微不足道的。但是,有时重现问题可能要困难得多。该问题可能只发生在某些计算机上,或在特定情况下,此时能够尽可能多地使问题再次发生,因此我们可以一遍又一遍地运行我们的程序并寻找线索来确定导致问题的原因。

关注问题: 一旦我们可以合理地重现问题,下一步就是找出问题在代码中的哪个位置。根据问题的性质,这可能容易也可能困难。举例来说,假设我们不太清楚问题的实际出处。我们如何找到它?我们都玩过猜数字游戏,通过不断的猜测来缩小正确数字的范围,错误诊断也可以使用这个方法。在最坏的情况下,我们可能不知道错误在哪里。但是,我们确知道问题必须出在从程序开始到程序出现我们可以观察到的第一个不正确症状的点之间执行的代码中的某个地方。这至少排除了在第一个可观察到的症状之后执行的程序部分。但这仍然可能会留下很多代码需要覆盖。为了诊断问题,我们将对问题所在进行一些有根据的猜测,目的是快速找到问题所在。

和猜数字的方法类似,也可以用来诊断程序:

  • 如果在我们的程序中的某个时刻,我们可以证明问题尚未发生,这类似于收到“太低”的 hi-lo 结果——我们知道问题一定出现在程序后面的某个地方。例如,如果我们的程序每次都在同一个地方崩溃,并且我们可以证明程序在程序执行的某个特定点没有崩溃,那么崩溃一定在代码的后面。
  • 如果在我们的程序中的某个时刻,我们可以观察到与问题相关的不正确行为,那么这类似于接收到“太高”的 hi-lo 结果,我们知道问题必须在程序的早期某处。例如,假设一个程序打印某个变量 x 的值。您希望它打印值 2,但它打印了 8。变量 x 必须有错误的值。如果在程序执行过程中的某个时刻,我们可以看到变量 x 的值已经为 8,那么我们就知道问题一定在该点之前发生了。

基本调试策略

在这一节中,我们将探索一些基本策略,用于实际进行这些猜测并收集信息来帮助发现问题。

调试策略 1 注释掉你的代码

让我们从一个简单的例子开始。如果您的程序出现错误,减少必须搜索的代码量的一种方法是将一些代码注释掉并查看问题是否仍然存在。如果问题仍然存在,注释掉的代码对产生的问题没有影响。

有如下代码:

int main()
{
    getNames(); // 告诉用户输入一批名字
    doMaintenance(); // 做一些随机打乱
    sortNames(); // 排序
    printNames(); // 输出排序后的名字列表
 
    return 0;
}

假设这个程序应该按字母顺序打印用户输入的名称,但它却以相反的字母顺序打印它们。问题出在哪里? getNames 是否错误地输入了名称? sortNames 是否将它们向后排序? printNames 是否向后打印它们?它可以是任何这些东西。但是我们可能会怀疑 doMaintenance() 与问题无关,所以我们将其注释掉。

int main()
{
    getNames(); // 告诉用户输入一批名字
    // doMaintenance(); // 做一些随机打乱
    sortNames(); // 排序
    printNames(); // 输出排序后的名字列表
 
    return 0;
}

如果问题消失了,那么 doMaintenance 肯定是导致问题的原因,我们应该将注意力集中在那里。但是,如果问题仍然存在(更有可能),那么我们知道 doMaintenance 没有问题,我们可以从搜索中排除整个函数,减少了我们随后必须查看的代码量。

调试策略 2 验证你的代码流

在更复杂的程序中常见的另一个问题是程序调用函数的次数过多或过少(包括根本没有调用)。

在这种情况下,将语句放在函数顶部以打印函数名称会很有帮助。这样,当程序运行时,您可以看到调用了哪些函数。

小贴士


出于调试目的打印信息时,请使用 std::cerr 而不是 std::cout。一个原因是 std::cout 可能会被缓冲,这意味着在您要求 std::cout 输出信息和实际输出信息之间可能会有暂停。如果您使用 std::cout 输出,然后程序立即崩溃,则 std::cout 可能还没有实际输出。这可能会误导您关于问题所在。另一方面, std::cerr 是无缓冲的,这意味着您发送给它的任何内容都会立即输出。这有助于确保所有调试输出尽快出现(以牺牲一些性能为代价,通常在调试时不关心)。

考虑以下无法正常工作的简单程序:

#include <iostream>
 
int getValue()
{
	return 4;
}
 
int main()
{
    std::cout << getValue;
 
    return 0;
}

输出结果:

00007FF6D4FA1460

让我们为这些函数添加一些调试语句:

#include <iostream>
 
int getValue()
{
std::cerr << "getValue() called\n";
	return 4;
}
 
int main()
{
std::cerr << "main() called\n";
    std::cout << getValue;
 
    return 0;
}

**注意:**添加临时调试语句时,不要缩进它们,这会很有帮助。使得以后更容易找到它们以进行移除。

现在当这些函数执行时,它们会输出它们的名字,表明它们被调用了:

输出结果:

main() called
00007FF7A3F71460

现在我们可以看到函数 getValue 从未被调用过。调用函数的代码肯定有问题。让我们仔细看看调用函数的这行代码:

std::cout << getValue;

我们忘记了函数调用的括号。它应该是:

#include <iostream>
 
int getValue()
{
std::cerr << "getValue() called\n";
	return 4;
}
 
int main()
{
std::cerr << "main() called\n";
    std::cout << getValue(); // 增加一个括号
 
    return 0;
}

这现在将产生正确的输出:

main() called
getValue() called
4

调试完成后,就可以删除这些调试语句。

调试策略 3 打印值

对于某些类型的错误,程序可能正在计算或传递错误的值。

我们还可以输出变量(包括参数)或表达式的值,以确保它们是正确的。

考虑下面的程序,它应该将两个数字相加,但不能正常工作:

#include <iostream>
 
int add(int x, int y)
{
	return x + y;
}
 
void printResult(int z)
{
	std::cout << "The answer is: " << z << '\n';
}
 
int getUserInput()
{
	std::cout << "Enter a number: ";
	int x{};
	std::cin >> x;
	return x;
}
 
int main()
{
	int x{ getUserInput() };
	int y{ getUserInput() };
 
	std::cout << x << " + " << y << '\n';
 
	int z{ add(x, 5) };
	printResult(z);
 
	return 0;
}

下面是这个程序的一些输出:

Enter a number: 3
Enter a number: 33
3 + 33
The answer is: 8

答案是不对的。你看到错误了吗?即使在这个简短的程序中,也很难被发现。让我们添加一些代码来调试我们的值:

#include <iostream>
 
int add(int x, int y)
{
	return x + y;
}
 
void printResult(int z)
{
	std::cout << "The answer is: " << z << '\n';
}
 
int getUserInput()
{
	std::cout << "Enter a number: ";
	int x{};
	std::cin >> x;
	return x;
}
 
int main()
{
	int x{ getUserInput() };
std::cerr << "main::x = " << x << '\n';
	int y{ getUserInput() };
std::cerr << "main::y = " << y << '\n';
 
	std::cout << x << " + " << y << '\n';
 
	int z{ add(x, 5) };
std::cerr << "main::z = " << z << '\n';
	printResult(z);
 
	return 0;
}

输出结果:

Enter a number: 3
main::x = 3
Enter a number: 4
main::y = 4
3 + 4
main::z = 8
The answer is: 8

变量 x 和 y 获得了正确的值,但变量 z 不是。问题必须在这两点之间,这使得函数添加了一个关键的嫌疑人。就会把问题进一步的缩小范围

当然打印值的调试方法也有很多得局限性:

  1. 调试语句使您的代码变得混乱。
  2. 调试语句使程序的输出变得混乱。
  3. 调试语句在完成后必须删除,这使得它们不可重用。
  4. 调试语句需要修改您的代码无论是添加还是删除,这可能会引入新的错误。

更多的调试策略

条件化您的调试代码

在之前的调试中,完成调试语句后,您要么需要删除一些调试语句,要么将它们注释掉。然后,如果以后再次使用它们,则必须重新添加它们,或取消注释它们。

在整个程序中更容易禁用和启用调试的一种方法是使用预处理器指令使调试语句成为条件:

#include <iostream>
 
#define ENABLE_DEBUG // 注释掉以禁用调试
 
int getUserInput()
{
#ifdef ENABLE_DEBUG
std::cerr << "getUserInput() called\n";
#endif
	std::cout << "Enter a number: ";
	int x{};
	std::cin >> x;
	return x;
}
 
int main()
{
#ifdef ENABLE_DEBUG
std::cerr << "main() called\n";
#endif
    int x{ getUserInput() };
    std::cout << "You entered: " << x;
 
    return 0;
}

现在我们可以通过注释/取消注释#define ENABLE_DEBUG 来启用调试。这允许我们重用以前添加的调试语句,然后在完成它们后禁用它们,而不必实际从代码中删除它们。如果这是一个多文件程序,#define ENABLE_DEBUG 将进入包含在所有代码文件中的头文件,因此我们可以在单个位置注释/取消注释 #define 并将其传播到所有代码文件。

**优点:**这解决了必须删除调试语句的问题以及这样做的风险,但代价是代码更加混乱。

**缺点:**如果您输入错误(例如拼写错误“DEBUG”)或忘记将预处理指令包含在代码文件中,则该文件的部分或全部调试可能无法启用。因此,尽管这比无条件版本更好,但仍有改进的空间。

使用记录器logger

通过预处理器进行条件化调试的另一种方法是将调试信息发送到日志文件。日志文件是记录软件中发生的事件的文件(通常存储在磁盘上)。将信息写入日志文件的过程称为日志记录。大多数应用程序和操作系统都会写入可用于帮助诊断发生的问题的日志文件。

日志文件有几个优点:

  • 因为写入日志文件的信息与程序的输出是分开的,所以您可以避免因混合正常输出和调试输出而造成的混乱。
  • 日志文件也可以很容易地发送给其他人进行诊断——所以如果有人使用你的软件有问题,你可以让他们把日志文件发给你,这可能会帮助你找到问题所在。

虽然您可以编写自己的代码来创建日志文件并将输出发送给他们,但最好使用许多现有的第三方日志记录工具之一。您使用哪一种取决于您。

出于说明目的,我们将展示使用 plog 记录器。 Plog 是作为一组头文件实现的,因此很容易包含在您需要的任何地方,并且它轻巧且易于使用。

使用步骤:

  1. 包括记录器头文件
  2. 初始化记录器
  3. 输出到日志,就像你写到控制台一样
#include <iostream>
#include <plog/Log.h> // Step 1: 包括记录器头文件
 
int getUserInput()
{
	LOGD << "getUserInput() called"; // LOGD is defined by the plog library
 
	std::cout << "Enter a number: ";
	int x{};
	std::cin >> x;
	return x;
}
 
int main()
{
	plog::init(plog::debug, "Logfile.txt"); // Step 2: 初始化记录器
 
	LOGD << "main() called"; // Step 3: 输出到日志,就像你写到控制台一样
 
	int x{ getUserInput() };
	std::cout << "You entered: " << x;
 
	return 0;
}

日志的输出结果:

2018-12-26 20:03:33.295 DEBUG [4752] [main@14] main() called
2018-12-26 20:03:33.296 DEBUG [4752] [getUserInput@4] getUserInput() called

请注意: 使用此方法也不需要条件编译指令,因为大多数记录器都有一种减少/消除将输出写入日志的方法。这使得代码更容易阅读,因为条件编译行增加了很多混乱。使用 plog,可以通过将 init 语句更改为以下内容来暂时禁用日志记录:

plog::init(plog::none , "Logfile.txt"); //plog::none 消除了大多数消息的写入,基本上关闭了日志

那么 如何使用plog呢 直接使用的话会出现错误,需要自己去下载这个库,

点击这个plog 就OK了,然后点击release下载最近的source code 解压到任意的文件夹中,然后在VS中设置

视图->属性管理器->右键点击Debug_x64->属性,然后把C:\Users\27710\Desktop\plog-1.1.5\include复制到包含目录。

在这里插入图片描述


使用集成编译器 步进调试 Stepping

在此之前,我们探索了各种更改代码以帮助调试的方法,包括打印诊断信息或使用记录器。这些是在程序运行时检查程序状态的简单方法。虽然这些使用得当会很有效,但它们仍然有缺点:它们需要更改您的代码,这需要时间并可能引入新的错误,并且它们会使您的代码变得混乱,使现有代码更难以理解

到目前为止,我们所展示的技术是一个未说明的假设:一旦我们运行代码,它将运行到完成(仅暂停接受输入),我们没有机会在任何时候干预和检查程序的结果。

幸运的是,大多数现代 IDE 都带有一个称为调试器的集成工具,专门用于执行此操作。

单步执行是一组相关调试器功能的名称,这些功能让我们可以逐句执行(单步执行)我们的代码。

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

step over 命令在程序的正常执行路径中执行下一条语句。然而,step into 将输入函数调用并逐行执行它们,而 step over 将在不停止的情况下执行整个函数,并在函数执行后将控制权返回给您。

Step out 不只是执行下一行代码。相反,它执行当前正在执行的函数中的所有剩余代码,然后在函数返回时将控制权返回给您。会跳出当前调用的函数

在单步执行程序时,通常只能向前迈进。很容易不小心跨过(越过)你想检查的地方。如果你越过了你的预期目的地,通常要做的就是停止调试并重新开始调试,这次要小心一点,不要超过你的目标。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值