找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

759

积分

0

好友

99

主题
发表于 19 小时前 | 查看: 0| 回复: 0

“Bug” 的创始人,电脑专家格蕾丝·赫柏(Grace Murray Hopper),在1947年9月9日对Harvard Mark II计算机进行调试时,发现一只飞蛾死在继电器触点之间,导致机器停止工作。她将这只飞蛾贴在报告中,并用“bug”来表示程序中的错误,这个说法一直沿用至今。

历史上第一个被记录的计算机bug(一只飞蛾)

所有发生的事情都一定有迹可循,如果问心无愧,就不需要掩盖也就没有迹象了,如果问心有愧,就必然需要掩盖,那就一定会有迹象,迹象越多就越容易顺藤而上,这就是推理的途径。顺着这条途径顺流而下就是犯罪,逆流而上,就是真相。

一名优秀的程序员就是一名出色的侦探,每一次调试都是尝试破案的过程。

什么是调试?

调试(debug),又称除错,是发现和减少计算机程序或电子仪器设备中程序错误的一个过程。

调试的基本步骤

  1. 发现程序错误的存在;
  2. 以隔离、消除等方式对错误进行定位;
  3. 确定错误产生的原因;
  4. 提出纠正错误的解决办法;
  5. 对程序错误予以改正,重新测试。

Debug和Release版本

在进行调试前,必须先了解IDE的两种编译配置:

  • Debug:通常称为调试版本,它包含调试信息,并且不作任何优化,便于程序员调试程序。
  • Release:称为发布版本,它往往进行了各种优化,使得程序在代码大小和运行速度上都是最优的,以便用户使用。

看看下面这段简单的C语言代码在不同配置下的区别:

#include <stdio.h>
int main()
{
    char* p = "hello world!";
    printf("%s\n", p);
    return 0;
}

上述代码在Debug环境下的编译结果:

Visual Studio 2022 Debug模式编译界面

上述代码在Release环境下的编译结果:

Visual Studio 2022 Release模式编译界面

我们还可以查看两者反汇编代码的差异,Release版本通常更简洁高效。

Debug版本的反汇编代码

Release版本的反汇编代码

所以说,调试主要是在Debug版本的环境中,寻找代码中潜伏问题的过程。

编译器优化会带来什么影响? 请看下面这个经典的例子:

#include <stdio.h>
int main()
{
    int i = 0;
    int arr[10] = {0};
    for(i=0; i<=12; i++)
    {
        arr[i] = 0;
        printf("hello\n");
    }
    return 0;
}
  • 如果在Debug模式下编译,程序会陷入死循环。
  • 如果在Release模式下编译,程序则不会死循环。

为什么会有这种差异?根本原因在于Release模式下的内存布局优化改变了变量i和数组arr在栈上的相对位置,从而避免了数组越界修改循环变量的问题。

Debug模式下变量内存地址打印

Windows环境下使用Visual Studio 2022调试

注:Linux开发环境的调试工具通常是gdb。

1. 调试环境准备

Visual Studio中,务必在工具栏选择 Debug 模式和对应的平台(如x86/x64),才能正常进行代码调试。

选择Visual Studio 2022的Debug配置

2. 必须掌握的调试快捷键

熟练使用快捷键是提升调试效率的关键。

Visual Studio 2022调试菜单快捷键

最常用的几个快捷键:

  • F5:启动调试或继续运行到下一个断点。
  • F9:在光标所在行创建或取消断点。断点是调试的核心,它让程序能在任意位置暂停。
  • F10逐过程执行,将一次函数调用或一条语句视为一个整体来执行。
  • F11逐语句执行,每次只执行一条语句,可以进入函数内部(非常常用)。
  • Ctrl + F5:开始执行但不调试,直接运行程序。

F5和F9的经典组合:假设有一个循环,我们想在循环到第5次时暂停检查状态。

  1. 在循环体内设置断点。
  2. 右键断点 -> “条件...”,设置条件表达式,例如 i==5
  3. 按下F5,程序会自动运行并在满足条件时暂停。

设置断点条件

满足条件后程序暂停在断点处

此时,可以在监视窗口看到数组arr已被赋值5次,循环变量i的值为5。

3. 查看程序运行时信息

Visual Studio 提供了多个窗口来观察程序状态。

3.1 查看变量值(监视窗口)
在调试时,通过 调试 -> 窗口 -> 监视 可以打开监视窗口,实时查看变量、表达式的值。

使用监视窗口观察变量

3.2 查看内存信息
当需要分析指针、数组越界或底层内存问题时,内存窗口不可或缺。通过 调试 -> 窗口 -> 内存 可以打开。

打开内存查看窗口

3.3 查看调用堆栈
调用堆栈清晰展示了函数的调用链和当前执行位置,对于理解程序流程和定位递归、回调问题极有帮助。通过 调试 -> 窗口 -> 调用堆栈 打开。

查看函数调用堆栈

3.4 查看汇编代码
在调试时,有两种方式可以查看汇编指令:

  1. 右键代码编辑区,选择 “转到反汇编”
  2. 通过 调试 -> 窗口 -> 反汇编 打开窗口。

通过右键菜单转到反汇编

3.5 查看寄存器信息
对于学习汇编、理解函数调用约定或进行底层调试,寄存器窗口非常重要。通过 调试 -> 窗口 -> 寄存器 打开。

打开寄存器查看窗口

调试实战:两个经典案例

案例一:阶乘求和错误

实现代码:求 1! + 2! + 3! ... + n!(不考虑溢出)。

int main()
{
    int i = 0;
    int sum = 0;//保存最终结果
    int n = 0;
    int ret = 1;//保存n的阶乘
    scanf("%d", &n);
    for(i=1; i<=n; i++)
    {
        int j = 0;
        for(j=1; j<=i; j++)
        {
            ret *= j;
        }
        sum += ret;
    }
    printf("%d\n", sum);
    return 0;
}

当输入 3 时,预期输出应为 1 + 2 + 6 = 9,但实际输出却是 15

调试过程

  1. 推测问题:结果错误,问题可能出在循环计算阶乘的环节。
  2. 动手调试:在循环内设置断点,利用监视窗口观察 retsum 的变化。
  3. 定位问题:通过调试发现,在计算 3! 时,ret 的初始值不是1,而是累加了上一次 2! 的结果。这是因为 ret 变量在每次外层循环开始时,没有重新置为1。

调试监视窗口发现ret变量未重置

修正代码:只需在内层 for 循环开始前,将 ret 重置为1即可。

for (i = 1; i <= n; i++)
{
    int j = 0;
    ret = 1; // 增加此行,重置阶乘累积变量
    for (j = 1; j <= i; j++)
    {
        ret *= j;
    }
    sum += ret;
}

案例二:数组越界导致死循环

为什么下面这段代码在Debug模式下会死循环?

#include <stdio.h>
int main()
{
    int i = 0;
    int arr[10] = { 0 };
    for (i = 0; i <= 12; i++)
    {
        arr[i] = 0;
        printf("hello\n");
    }
    return 0;
}

代码陷入死循环,不断打印hello

深度解析
这涉及到局部变量(栈区)的内存布局。在特定的编译环境(如VC6.0或某些Debug配置)下,栈区内存的使用习惯是从高地址向低地址分配。变量 i 和数组 arr 在栈上的布局可能如下图所示:

栈内存布局示意图:数组越界覆盖循环变量

当循环执行到 i=12 时,arr[12] 的写操作实际上越界访问并覆盖了变量 i 所在的内存,将其值重置为0,从而导致循环条件 i<=12 永远成立,形成死循环。

使用内存窗口验证
我们可以通过调试器的内存窗口来验证这一布局。观察 &i&arr[0] 的地址,会发现 i 的地址确实比数组的末尾(arr[9])更高(在某些环境下可能更低,取决于具体实现),并且当写入 arr[12] 时,对应的内存地址正好是 i 的存储位置。

通过内存窗口观察变量地址与值

结论:在使用数组时,必须严格防止数组越界,否则可能引发难以预料且非常危险的bug。

如何编写易于调试的代码?

写出好代码是减少调试工作的根本。优秀的代码通常具备以下特点:

  • 代码运行正常,bug少
  • 效率高
  • 可读性高,可维护性高
  • 注释清晰,文档齐全

常见的编码技巧

  • 使用 assert:在关键位置使用断言检查假设条件,非法情况会立即报错。
  • 尽量使用 const:保护不该被修改的数据,让编译器帮助我们发现错误。
  • 养成良好的编码风格:如合理的命名、缩进、模块划分。
  • 添加必要的注释:解释复杂的逻辑或算法意图。

const 关键字的作用

const 修饰指针时,位置不同,含义也不同:

// 测试const放在*的左边
void test2()
{
    int n = 10;
    int m = 20;
    const int* p = &n; // const在*左边
    *p = 20; // 错误:不能通过p修改n的值
    p = &m;  // 正确:可以修改p指向的地址
}

// 测试const放在*的右边
void test3()
{
    int n = 10;
    int m = 20;
    int* const p = &n; // const在*右边
    *p = 20; // 正确:可以通过p修改n的值
    p = &m;  // 错误:不能修改p本身存储的地址
}

结论

  • const 放在 * 左边,修饰的是指针指向的内容,保证内容不能被指针修改。
  • const 放在 * 右边,修饰的是指针变量本身,保证指针的指向不能被修改。

编程中的常见错误类型

  1. 编译型错误
    通常是语法错误,编译器会直接报错并给出行号。直接查看错误信息就能解决,相对简单。

  2. 链接型错误
    通常是标识符未定义或拼写错误。查看错误信息中的标识符名称,在代码中定位并修正。

  3. 运行时错误
    程序能通过编译链接,但运行结果错误或崩溃。这类错误最复杂,必须依赖调试手段(设置断点、监视变量、查看内存/堆栈)逐步定位问题

程序员调试日常:神秘的程序员们漫画

总结

调试是程序员的核心技能。初学者可能80%的时间在写代码,20%的时间调试;而有经验的程序员可能20%的时间写新代码,80%的时间在调试、维护和优化现有代码。掌握高效的调试技巧,尤其是在像 Visual Studio 2022 这样强大的集成开发环境中,能极大提升解决问题的效率和代码质量。

多动手实践,勇敢地在你的代码中设置断点,尝试使用监视、内存、调用堆栈等所有调试窗口,你会在解决一个个具体问题的过程中,成为真正的调试高手。如果在学习过程中有更多心得或疑问,欢迎到云栈社区与其他开发者交流探讨。




上一篇:特征交叉新范式:字节跳动基于Transformer的统一交互架构,线上GMV提升5.68%
下一篇:电商AI经营实践观察:从效率重构到确定性增长的三大路径
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-25 20:36 , Processed in 0.837343 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表