Bootstrap

C语言第8节:调试(VS2022)

1. 什么是 bug

“Bug” 本意是“昆虫”或“虫子”,现在是指程序或系统中的错误或缺陷,导致其无法按预期执行。这种错误可能是代码中的逻辑错误、输入输出的意外处理、资源分配不当等问题。

1.1 Bug 的历史(了解)

最早被记录的“bug”出现在1947年,涉及一台名叫 Mark II 的计算机。这个“bug”的故事既有趣又具有象征意义,标志着“bug”在计算机历史上作为错误代名词的开端。以下是这个事件的详细背景:

1.1.1 Mark II计算机的背景

Mark II是哈佛大学建造的一台早期继电器式计算机,属于Harvard Mark系列计算机中的一部分。这台机器使用继电器和电磁开关进行运算,相比现代计算机的半导体芯片显得巨大笨重。Mark II的运作过程依赖许多物理电路,因此设备非常庞大,并且经常因为硬件的原因而出现故障。

1.1.2 Grace Hopper的发现

Grace Hopper是美国海军的一名计算机科学家和先驱人物,她在哈佛大学的计算机实验室工作时,领导团队维护和操作Mark II计算机。1947年9月9日,团队在运行Mark II时遇到了一些问题,计算机突然失灵,部分计算无法进行。他们检查了计算机的每个部分,最终发现在其中一个继电器上竟然卡住了一只飞蛾。

在这里插入图片描述

1.1.3 故障原因:飞蛾卡在继电器中

继电器是Mark II的核心部件之一,通过通断电流来实现运算指令的执行。然而,由于这只飞蛾的干扰,导致继电器无法正常运转,电流无法顺利通过,计算机也因此出现故障。这只飞蛾成了这台计算机的“第一个bug”,或者说,第一个被发现并记录下来的计算机“bug”。

1.1.4 记录“bug”的诞生

Hopper团队发现飞蛾后,便将其从继电器上移除,并将这次事件详细记录在实验日志上。他们用胶带将这只飞蛾贴在日志页上,并标注道“First actual case of bug being found”(发现实际bug的第一个案例)。这个动作不仅保存了历史证据,还开创了“bug”作为计算机错误的术语。从此,团队成员甚至在日志中用“debugging”来指代清理机器中的错误或故障。

1.1.5 “debugging”一词的推广

虽然在电气工程中“debug”这个词在此之前也曾被使用过,但Grace Hopper的团队正式确立了这个词的含义,使其逐渐推广到计算机和软件领域。之后,随着计算机的发展和普及,软件错误被称为“bug”,而排除这些错误的过程则称为“debugging”。

1.1.6. 历史价值与意义

这只飞蛾的事件不仅在计算机历史中占据了一席之地,还成为了“bug”一词作为故障和错误代名词的根源。至今,这只飞蛾的原始记录仍然保存在史密森尼博物馆里,作为计算机发展史中的一件象征性文物。它提醒着我们早期计算机科学家面对的挑战,也见证了“bug”在技术界的深远影响。

1.2 Bug 的来源

  • 程序员疏忽:由于复杂的逻辑或代码编写不严谨,容易引入错误。
  • 需求不明确:未理解或实现需求的真正意图,导致功能不符合需求。
  • 边界条件未处理:在输入为零、负数、超大数等边界情况下程序可能会出现异常行为。

1.3 解决 Bug 的方法

  • 代码复查:通过仔细检查代码或找他人代码审查来发现可能的 bug。
  • 单元测试:为代码的各个模块编写测试用例,以确保每个部分都符合预期。
  • 使用调试工具:如 GDB 或 Visual Studio 内置调试器,帮助逐步分析代码执行流程。

2. 什么是调试(debug)?

调试(debug) 是指识别、定位并修复程序 bug 的过程。调试通常包括以下几个步骤:

2.1 调试过程

  1. 承认错误:调试一个程序,首先是勇于承认出现了问题,很多程序员不愿意承认自己的错误,把问题甩锅给测试、前端等。

  2. 重现错误:确认 bug 存在并确定其触发条件。例如,确定哪些输入会导致程序崩溃。

  3. 设置断点:在代码的关键位置设置断点,使程序在这些位置暂停,以便观察变量值和程序状态。

  4. 分析程序状态:逐步执行代码,观察变量值、内存状态和程序执行流,确定 bug 的根本原因。

  5. 修复错误:一旦找到 bug 的根源,通过修改代码进行修复。

  6. 验证修复:重新运行程序,确保修复后的代码不会再产生相同的 bug,并进行回归测试,确保未引入新的问题。

2.2 调试的重要性

调试是软件开发过程中不可或缺的步骤,尤其在 C 语言中,由于其对内存的直接操作更容易出现复杂 bug。有效的调试可以节省开发时间,提高代码质量。

3. Debug 和 Release 模式的区别

在 C 语言开发中,编译器通常提供两种编译模式:DebugRelease

在这里插入图片描述

  • Debug 模式
    • 含有调试信息(如变量名称、函数名等),方便调试。
    • 未进行优化,因此变量值等更容易追踪。
    • 程序执行效率较低,主要用于开发和测试阶段。
    • 编译出的文件较大,因为包含了额外的调试信息。
  • Release 模式
    • 不包含调试信息,减少文件体积。
    • 经过编译器优化,执行效率高。
    • 用于发布产品,避免用户看到内部调试信息。
    • 可能对代码进行了重排或内联优化,因此某些错误在 Release 模式下可能无法重现。

4. VS 调试快捷键

Visual Studio 是常用的开发工具,提供了许多便捷的调试快捷键。以下是一些常用快捷键:

4.1 环境准备

在 Visual Studio 中进行调试之前,需要设置一些环境参数:

  • 配置项目属性:确保编译模式选择为 Debug,而不是 Release

    在这里插入图片描述

  • 调试信息生成:检查是否启用了调试符号文件(PDB 文件),以便跟踪代码。

  • 优化设置:在 Debug 模式下通常禁用优化,以便更准确地观察变量值。

4.2 调试快捷键

  • F5开始调试/继续 - 启动程序并进入调试模式。如果已经在调试中,继续执行程序,直至下一个断点或程序结束。
  • Shift + F5停止调试 - 结束当前调试会话,退出调试模式。
  • F9设置/取消断点 - 在当前行设置或取消断点,程序会在此行暂停执行,方便观察状态。
  • F10逐过程执行 - 逐步执行代码,但不会进入函数内部,而是跳过整个函数。
  • F11逐语句执行 - 逐行执行代码,包括进入函数内部,详细分析每一步执行情况。
  • Ctrl + F5不调试启动 - 启动程序而不进入调试模式,直接运行程序。
  • Ctrl + Shift + F5重新启动调试 - 停止当前调试并重新开始调试,适用于想要从头开始调试的情况。

5. 监视和内存观察

监视和内存观察是调试过程中的重要工具,帮助开发者深入了解程序的执行状态上下文环境中的变量的值内存使用情况。当然,这些观察的前提条件一定是开始调试后观察。

5.1 监视(Watch)

在这里插入图片描述

监视窗口允许开发者查看变量的实时值,尤其是在断点位置暂停时,可以观察变量的状态。

  • 添加监视:在代码中选中变量,右键选择“添加到监视窗口”。
  • 动态更新:监视窗口会动态显示变量的值变化,当代码逐步执行时,变量值会实时更新。
  • 观察表达式:不仅可以监视单个变量,还可以监视表达式。例如,可以监视 a + b 的值,方便计算和判断。

示例

int a = 5;
int b = 10;
int result = a + b; // 可以监视 result 的值来确认是否正确计算

在这里插入图片描述

5.2 内存观察(Memory)

如果监视窗口看的不够仔细,也是可以观察变量在内存中的存储情况,内存窗口允许开发者直接查看程序的内存状态,还是在【调试】->【窗口】->【内存】,这在处理指针和数组时特别有用。

在这里插入图片描述

  • 内存地址:在内存窗口中可以查看特定地址的内存内容,方便检查指针或数组访问的正确性。要在地址栏输入:arr&num&c,这类地址,就能观察到该地址处的数据。
  • 字节表示:内存窗口会以十六进制显示内存内容,也可以转换为字符、整数等格式。
  • 指针监视:对于指针类型变量,可以直接查看其指向的内存区域,判断是否存在空指针或非法访问。

示例

int array[5] = {1, 2, 3, 4, 5};
int *p = array;

在这里插入图片描述

通过内存窗口可以观察 p 指针的值以及它所指向的 array 数组的内存内容,确保没有越界访问。

6. 调试案例

在VS2022、X86、Debug 的环境下,编译器不做任何优化执行以下代码

#include <stdio.h>
int main()
{
	int i = 0;
	int arr[10] = {1,2,3,4,5,6,7,8,9,10};
	for(i=0; i<=12; i++)
	{
		arr[i] = 0;
		printf("hehe\n");
	}
	return 0;
}

6.1 内存布局

  • 通过调试我们知道了 arr 数组和变量 i 在栈区的分布。arr 数组占用了连续的内存空间,从低地址到高地址排列,共有 10 个元素(索引 0 到 9)。

  • 紧邻 arr 数组高地址的地方存放的是变量 i。这意味着,iarr[10] 的地址相邻,arr[10] 实际上指向了变量 i 的内存位置。

    在这里插入图片描述

6.2 越界访问导致的死循环

  • 由于 for 循环条件为 i <= 12,因此在循环中 i 的值会从 0 一直增加到 12。这意味着,当 i >= 10 时,会访问 arr[10]arr[11]arr[12] 等越界元素。
  • 当访问 arr[10] 时,由于 arr[10] 的内存位置和变量 i 的内存位置重叠,因此 arr[10] = 0 实际上修改了变量 i 的值为 0
  • 这样一来,i 每次在循环中被设置为 0,因此无法正常递增。循环条件 i <= 12 始终为真,导致程序进入了死循环。

6.3 详细过程

  • 在每次循环开始时,i 的值会递增,然后在 arr[i] = 0; 中被覆盖为 0
  • 这相当于每次循环都重新将 i 设置为 0,使得 for 循环条件永远成立,导致无限输出 "hehe"

6.4 总结

  • 死循环的原因在于数组越界访问覆盖了变量 i 的值,使得 i 的值始终无法增大。
  • 修改循环条件为 i < 10,即可避免越界访问,防止覆盖 i 的值,解决死循环问题。

7. 小练习

1!+2!+3!+4!+...10! 的和,请看下面的代码:

#include <stdio.h>
//写一个代码求n的阶乘
int main()
{
	int n = 0;
	scanf("%d", &n);
	int i = 1;
	int ret = 1;
	for(i=1; i<=n; i++)
	{
		ret *= i;
	}
	printf("%d\n", ret);
	return 0;
}

int main()
{
	int n = 0;
	int i = 1;
	int sum = 0;
	for(n=1; n<=10; n++)
	{
		for(i=1; i<=n; i++)
		{
			ret *= i;
		}
		sum += ret;
	}
	printf("%d\n", sum);
	return 0;
}

调试找一下问题。

8. 编程常见错误归类

在 C 语言中,错误通常分为以下几类:

8.1 编译型错误

编译型错误是编译器在代码编译阶段检测到的错误。常见的编译型错误包括:

  • 语法错误:如漏写分号、括号不匹配等。
  • 数据类型不匹配:如将 int 类型变量赋值给 char 变量。
  • 函数声明不匹配:调用未声明或参数类型不符的函数。

示例

int a = 5;
a = "hello"; // 错误,字符串不能赋值给整数变量

在这里插入图片描述

8.2 链接型错误

链接型错误通常出现在编译阶段之后,由于未正确链接外部函数或库导致的错误。常见的链接错误包括:

  • 未定义的引用:如调用了未定义的函数。
  • 重复定义:多个文件中重复定义了同名变量或函数。

示例

extern int myFunction(); // 声明了一个外部函数,但未定义,可能导致链接错误

8.3 运行时错误

运行时错误是程序在执行时遇到的问题。常见的运行时错误包括:

  • 空指针引用:访问了空指针指向的内存。
  • 数组越界:访问了数组边界之外的内存。
  • 除零错误:整数除以零会导致异常。

示例

int *p = NULL;
printf("%d", *p); // 错误,空指针访问会导致运行时错误

在这里插入图片描述
—完—

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;