Appearance
第 18 讲:VS 实用调试技巧
⏱️ 本讲 L1 内容建议安排 2 次 学习,每次约 40 分钟。调试是动手技能,务必边看边在自己的 VS 上操作;光读不练永远学不会。标有 📘 的内容和末尾 🔬 选学拓展可按需跳过。
🎯 学完本讲,你将能够
- 说清 Bug、调试(Debug)、Debug/Release 版本的含义
- 熟练使用 F9、F5、F10、F11、Ctrl+F5 五个快捷键完成一次完整调试
- 会在调试时用监视窗口、内存窗口观察变量和数组(含通过形参观察数组的写法)
- 能区分编译型、链接型、运行时三类错误并选择排查手段
- 独立调试"阶乘求和忘重置""越界写导致死循环"两个典型案例,讲清原因
🔗 先修知识
本讲需要:会创建 VS 项目并编译运行(第 1 讲)、数组与下标(第 5 讲)、函数(第 6 讲)、指针基础(第 12 讲)。
自检三问:
- 程序跑起来结果不对,和编译时报错,是同一类问题吗?
- 一个长度为 10 的数组,合法下标范围是多少?
- 函数调用时实参是怎样传给形参的?
一、Bug 这个词是怎么来的
Bug 原义是"虫子",在计算机领域指程序中隐藏的缺陷或漏洞。
这个词成为行业术语,与计算机先驱格蕾丝·赫柏(Grace Murray Hopper,美国海军军官、COBOL 语言的奠基人之一)有关。1947 年 9 月,她的团队在 Harvard Mark II 继电器计算机上工作时,机器突然故障。技术人员排查后,在一组继电器触点之间发现一只被电流击毙的飞蛾——它受光和热吸引飞入,导致触点短路。他们把这只飞蛾用胶带贴进工作日志,并登记为"第一个发现虫子(bug)的实例"。从此,"bug"就被用来指代程序错误,"debug(捉虫)"也就成了调试的代名词。
二、什么是调试
发现程序有问题之后,定位问题 → 分析原因 → 修改代码 → 重新验证,这一整套过程就是调试(debug)。
调试的第一步其实是心理关:承认程序确实有问题,而不是怀疑编译器、怀疑电脑。随后可以逐行跟踪执行,也可以通过注释/隔离部分代码缩小范围,直到锁定出错位置。
调试能力是新手和熟手之间最大的分水岭之一。代码人人能写,bug 不是人人会找。
三、Debug 和 Release
VS 工具栏上有两个配置可选:
| 版本 | 定位 | 特点 |
|---|---|---|
| Debug(调试版) | 给开发者用 | 包含完整调试信息,不做优化,可单步调试;体积较大 |
| Release(发布版) | 给最终用户用 | 进行多种优化,体积更小、速度更快;调试困难 |
写代码、调代码时保持 Debug;开发完成、交付用户时才切换 Release。同一份源码分别编译,对比可执行文件大小,Release 通常明显更小。
为什么 Release 里有时"看不到变量、单步跳来跳去"?因为优化会重排、合并甚至删除代码。调试必须在 Debug 下进行,原因见 L3。
四、调试快捷键(本讲核心)
先确认 VS 顶部配置为 Debug。五个必须形成肌肉记忆的快捷键:
| 快捷键 | 名称 | 作用 |
|---|---|---|
| F9 | 切换断点 | 在光标行打断点/取消断点;程序运行到断点处会暂停。右键红点还可设置条件断点(如 i == 100 时才停) |
| F5 | 启动调试 | 开始调试并运行,直到遇到下一个断点暂停;常与 F9 配合"打个断点 → F5 直接跑过去" |
| F10 | 逐过程 | 一次执行一条语句;遇到函数调用不进入函数,整条调用一次走完 |
| F11 | 逐语句 | 一次执行一条语句;遇到函数调用会进入函数内部继续逐行执行 |
| Ctrl+F5 | 不调试执行 | 直接运行程序,不暂停,适合已经确定正确、只想看结果时 |
记忆要点:F10 把"函数"当成一个过程一步跨过;F11 要进函数看细节。步过用 F10,步入用 F11。
五、暂停之后看什么:监视与内存
断点暂停(或单步执行)时,可以打开这些窗口(菜单栏【调试】→【窗口】,必须处于调试状态才看得到):
- 自动窗口:自动显示当前行及附近几行用到的变量,最省事;
- 局部变量:显示当前函数的全部局部变量;
- 监视:手动输入任意变量名、表达式(如
arr[3]、p != NULL),长期跟踪; - 内存:直接按地址查看原始字节,监视窗口看不懂时(如对齐、字符编码)用它;
- 寄存器、反汇编:观察寄存器和机器级指令,进阶使用;
- 调用堆栈:查看函数调用链,详见 L3。
下面用一个程序演示,建议在 VS 中输入并亲自观察:
📄 01_watch_demo.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int arr[10] = {0};
int num = 100;
char c = 'w';
for (int i = 0; i < 10; i++)
{
arr[i] = i;
}
/* 在这一行打断点,在监视窗口输入 arr、num、c 观察 */
printf("%d %c %d\n", num, c, arr[9]);
return 0;
}内存窗口的地址栏可输入 arr、&num、&c,回车后即从该地址开始逐字节显示。实测输出:
text
100 w 9六、调试实战 1:阶乘求和
目标:计算 1! + 2! + 3! + … + 10!。先写单个 n 的阶乘:
📄 02_factorial_input.c · ✅ 完整程序(可直接复制编译)
c
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
int main(void)
{
int n = 0;
scanf("%d", &n);
int ret = 1;
for (int i = 1; i <= n; i++)
{
ret *= i;
}
printf("%d\n", ret);
return 0;
}在此基础上套一层循环求总和,下面是一个常见的错误版本:
🧩 待改错片段 · 可编译但结果错误
c
#include <stdio.h>
int main(void)
{
int sum = 0;
int ret = 1; /* 只在开头初始化了一次 */
for (int n = 1; n <= 10; n++)
{
for (int i = 1; i <= n; i++)
{
ret *= i; /* 忘了把 ret 重置回 1 */
}
sum += ret;
}
printf("%d\n", sum);
return 0;
}用 F10 逐过程跟踪,在监视窗口同时看 n、ret、sum:n=2 时 ret 带着上一轮的 1 继续累乘,结果立刻偏大,后面误差越来越大并最终整数溢出。正确版本必须在每轮外层循环开始时把 ret 重置为 1:
📄 03_factorial_sum.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
int main(void)
{
int sum = 0;
int ret = 1;
for (int n = 1; n <= 10; n++)
{
ret = 1; /* 每轮重新从 1 开始乘 */
for (int i = 1; i <= n; i++)
{
ret *= i;
}
sum += ret;
}
printf("%d\n", sum);
return 0;
}实测输出:
text
4037913七、调试实战 2:越界写导致的死循环
下面是 C 语言教学史上最著名的案例之一:
🧩 题意片段 · 含未定义行为,用于调试观察,请勿模仿
c
#include <stdio.h>
int main(void)
{
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;
}arr 只有 10 个元素,循环却写到 arr[12]。运行后程序无限打印 hehe——越界写把循环变量 i 本身改成了 0。调试时在内存窗口观察布局即可明白:
- 局部变量在栈上分配,栈从高地址向低地址增长,而
i先定义、位置在高地址处,arr整体在低地址处; - 数组随下标增长地址由低到高延伸。于是越界写入的方向,正好"回头"朝向
i。
不同环境中 i 与数组之间的空隙由编译器决定 🟠,本机实测:
| 环境 | i 所在位置 | 触发点 |
|---|---|---|
| VS x86 / Debug(原课程环境) | arr[12](中间空 2 个 int) | arr[12] = 0 把 i 清零 |
| 本机 gcc 16 / x64 / -O0 | arr[10](紧挨着,无空隙) | arr[10] = 0 把 i 清零 |
两种环境都死循环,但偏移不同——这段代码的行为本质是未定义行为 🔴(C11 §6.5.6p8:指针加下标超出对象范围即 UB),"恰好覆盖 i"只是特定实现下的现象,绝不能依赖。
📜 标准卡(L2 选读):栈布局与"顺序相反"的澄清
- 一个常见误读:"切到 x64 后栈的使用顺序就反过来了。"栈的生长方向是平台 ABI 固定的:无论 x86 还是 x64,Windows 和 System V(Linux/macOS)的栈都是高地址向低地址增长,不会反转。
- 真正变化的是两件事:① 编译器安排局部变量先后次序的策略,标准完全不管 🟠;② Release 优化可能把变量放进寄存器、删除"无用"变量,内存中根本找不到它。
- 标准依据:C11 §6.2.4(存储期)、§6.5.6p8(指针加法越界为 UB);栈布局属于实现细节,C 标准不作规定。
八、调试实战 3:调试扫雷这类多函数程序
当代码包含多个函数(如下一讲的扫雷),调试要点:
- 直接在目标函数内部打断点,F5 启动后程序会自动在被调用到该行时暂停——不必从 main 一步步按进去;
- 数组传参后"退化"为指针,想在监视窗口通过形参看整个数组,使用
数组名, 元素个数的写法:
text
mine, 12 ← 一维数组:从指针处按 12 个元素展开
board, 12 ← 二维数组形参:同样可在监视中展开为行列结构- 单步进入函数(F11),对照调用前的数组内容验证形参是否正确接收。
调试时要心中有数:先想好"这一步执行后变量应该变成什么",再看实际值是否吻合。盲目按 F10 而没有预期,等于白调。
九、编程常见错误的三大归类
| 类型 | 典型表现 | 排查方式 |
|---|---|---|
| 编译型错误 | 语法错误,编译阶段就过不了 | 直接看错误信息和行号,双击可跳转到出错位置附近 |
| 链接型错误 | undefined reference to ...、LNK2019 等 | 在错误信息中找到标识符名字,回代码中查:函数/变量是否定义、是否拼写错、头文件是否包含、对应库是否链接 |
| 运行时错误 | 能编译链接,但崩溃、死循环、结果错误 | 编译和链接都帮不了你,只能靠调试:断点 + 监视 + 单步,逐段缩小范围 |
一句话记忆:编译错看语法,链接错找名字,运行错靠调试。
⚠️ 常见坑与报错表
| 现象 | 原因 | 对策 |
|---|---|---|
| F5 一闪而过,程序直接结束 | 没打断点,或断点位置在程序已执行过的地方 | 用 F9 在想停的行打断点,再 F5 |
| F10/F11 没反应 | 当前不是调试状态(只是 Ctrl+F5 在跑) | 先 Shift+F5 停止,改用 F5 启动调试 |
| 监视窗口输入数组名只看到一个地址 | 形参已退化为指针,监视默认只显示首元素 | 用 数组名, 个数 的格式 |
| Release 下单步混乱、变量"消失" | 优化重排/消除了代码与变量 | 调试一律切回 Debug |
| 循环越界写后出现诡异死循环 | 越界覆盖了循环变量或其他数据 🔴 | 调试定位越界点,修正循环边界 |
scanf 在 VS 下报 C4996 | VS 认为 scanf 不安全 | 文件顶部加 #define _CRT_SECURE_NO_WARNINGS |
| 改了代码但运行结果没变 | 看的是旧 exe,或改的文件不在当前项目里 | 重新生成,确认源文件属于该项目 |
🛠️ 动手练习
📊 本讲网页练习进度0 / 8(0%)
进度自动保存在本浏览器;编程题不计数,请在编辑器中完成。
第 1 题(知识点:调试快捷键 · 难度:⭐)
想让程序从开头直接运行到事先打好的断点处暂停,应使用哪个快捷键?
第 2 题(知识点:逐过程 vs 逐语句 · 难度:⭐)
在函数调用这一行,想进入函数内部逐行观察,必须使用:
第 3 题(知识点:Debug/Release · 难度:⭐)
关于 Debug 和 Release,下列说法正确的是?
第 4 题(知识点:越界写案例 · 难度:⭐⭐)
第七节的程序发生死循环,根本原因是什么?
第 5 题(知识点:链接型错误 · 难度:⭐⭐)
出现链接错误(如 undefined reference to 'test'),可能的原因有哪些?
第 6 题(知识点:阶乘案例 · 难度:⭐⭐)
阶乘求和的错误版本中,ret 忘记在每轮重置为 1,结果会怎样?
第 7 题(知识点:监视窗口技巧 · 难度:⭐⭐)
一维数组作为参数传入函数后退化为指针,想在 VS 监视窗口通过形参 mine 一次看到 12 个元素,应输入:
空①
第 8 题(知识点:调试实操 · 难度:⭐⭐⭐)
🛠 动手编程题 · 请在 VS2026(或你的编辑器)中完成
编写程序:定义含 10 个成绩的数组(如 88, 59, 73, 45, 92, 61, 58, 77, 39, 95),统计不及格(<60)人数。然后在自己的 VS 中完成一次完整调试。
✅ 过关标准:
- 程序最终打印
不及格人数:4; - 用文字写出你的调试过程,至少包含:在第几行按 F9 打了断点、用 F5 还是 F10 启动/单步、在监视窗口跟踪了哪个变量、观察到它如何变化;
- 能回答:如果输出的是 5,你会怎样用调试定位?
网页内无法练习写代码,亲手敲、亲手编译才能真正学会。下方按顺序展开 思路 → 步骤 → 答案。
💡 思路(卡住再点开)
遍历数组,每个元素与 60 比较,小于则计数器加 1。调试的关键是在"计数器加 1"那行打断点,循环一轮轮看计数器跳变,数一下它到底加了几次、分别在数组的哪个下标加的。
🧭 步骤
- 定义数组和计数器
count = 0; for循环逐个判断score < 60;- 在
count++行按 F9 打断点; - F5 启动:每次暂停时在监视窗口看
i、score[i]、count; - 继续 F5 直到程序结束,核对 count 是否为 4。
🔑 点击查看参考答案
c
#include <stdio.h>
int main(void)
{
int score[10] = {88, 59, 73, 45, 92, 61, 58, 77, 39, 95};
int count = 0;
for (int i = 0; i < 10; i++)
{
if (score[i] < 60)
{
count++; /* 在这行打断点,观察 i 和 count */
}
}
printf("不及格人数:%d\n", count);
return 0;
}实测输出:
text
不及格人数:4若结果变成 5:在 count++ 的断点处反复 F5,每次暂停记下监视窗口里的 i 和 score[i],多加的那一次对应的值若 ≥60,说明比较条件写错(如写成 <= 60);若遍历时下标多走一轮,则说明循环边界写错。
📝 小结与自测
本讲脉络:
- Bug 指程序缺陷;调试是"定位→分析→修复→验证"的完整过程。
- Debug 含调试信息、不优化;Release 为交付而优化。调试永远在 Debug 下做。
- 五个快捷键:F9 断点、F5 运行到断点、F10 逐过程(不进函数)、F11 逐语句(进函数)、Ctrl+F5 不调试运行。
- 暂停后用监视/内存等窗口观察;形参退化的数组用
名字, 个数展开。 - 编译错看语法,链接错找名字,运行错靠调试。
自测三问(先自己回答,再对答案):
- F10 和 F11 的区别是什么?
- 三类编程错误分别靠什么手段排查?
- 为什么说"越界写死循环"的案例是未定义行为,而不是 C 语言的固定规则?
🔑 自测答案
- F10 逐过程,遇到函数调用一步完成、不进入内部;F11 逐语句,会步入函数体逐行执行。
- 编译型错误看编译器的错误信息与行号;链接型错误根据错误中的标识符,检查定义、拼写、头文件和库;运行时错误只能靠断点、监视和单步调试定位。
- C 标准只保证访问数组合法下标内的元素;越界位置有什么、
i被分配在哪里,标准完全不规定。VS x86 中 i 在 arr[12]、gcc x64 中在 arr[10],死循环只是这些实现下的巧合,换环境可能崩溃或"正常"结束,因此是 UB。
🔬 选学拓展(L3)
WARNING
以下内容超出基础要求,供想深入工具链和跨平台开发的同学选学。
拓展 1:调用堆栈窗口——程序是"怎么走到这儿的"
当断点停在某个深层函数里,【调试】→【窗口】→【调用堆栈】会列出从 main 到当前函数的完整调用链,每层显示函数名、参数值和所在行号。双击某一层即可切到该调用点的上下文查看变量。排查"这个函数被谁错误调用了""参数是谁传错的"时,调用堆栈是第一工具。
拓展 2:assert 断言——让程序在出错点"当场喊停"
<assert.h> 提供 assert(表达式):条件为假时,程序立即终止,并打印失败的表达式、文件名和行号。
📄 L3_assert_demo.c · ✅ 完整程序(可直接复制编译)
c
#include <stdio.h>
#include <assert.h>
double average(const int *arr, int n)
{
assert(arr != NULL); /* 空指针:当场终止并指出位置 */
assert(n > 0); /* 长度非法:同样终止 */
int sum = 0;
for (int i = 0; i < n; i++)
sum += arr[i];
return (double)sum / n;
}
int main(void)
{
int a[5] = {1, 2, 3, 4, 5};
printf("%.2f\n", average(a, 5));
average(NULL, 5); /* 会触发断言终止,无需打印返回值 */
return 0;
}实测(在控制台中先打印第一行,第二行触发断言;管道重定向时因缓冲可能只看到断言信息):
text
3.00
Assertion failed: arr != NULL, file ...\L3_assert_demo.c, line 6断言失败后程序调用 abort() 终止,本机 MinGW 下退出码为 0xC0000409(快速失败)。
定义宏 NDEBUG 后重新编译,所有 assert 会被编译成空——发布版可零成本关闭断言检查。
拓展 3:优化到底做了什么——用 gcc 对比观察
VS Release 对应的 gcc 选项是 -O2。同一份代码,对比调试信息的有无:
bash
gcc -O0 -g demo.c # -O0 不优化,-g 带调试信息(对应 Debug)
gcc -O2 demo.c # 优化版本(对应 Release 的精神)优化可能把循环变量永远留在寄存器中(内存窗口看不到它)、把不变的计算提前算好、把多次调用合并。这不是 bug,恰恰是编译器在按规则工作——所以调试必须关掉优化。
拓展 4:不用 VS 也能调——gdb 速查
Linux/MinGW 环境下的标准调试器是 gdb,常用命令(对应 VS 操作):
| gdb 命令(缩写) | 对应 VS 操作 |
|---|---|
break 文件名:行号(b) | F9 设置断点 |
run(r) | F5 启动调试 |
next(n) | F10 逐过程 |
step(s) | F11 逐语句 |
print 变量(p) | 监视窗口 |
backtrace(bt) | 调用堆栈窗口 |
continue(c) | 继续运行到下一断点 |
quit(q) | 停止调试 |
📜 标准卡(L2 选读):本讲标准依据
- 断言:C11 §7.2.1.1(
assert)、§7.2.1(NDEBUG使断言失效)。 - 调试信息与优化级别不属于 C 标准范畴,由工具链定义:gcc 为
-g/-O系列;VS 为/Zi /Od(Debug)与/O2(Release)。 - 未定义行为依据:C11 §4p2(UB 无任何要求)、§6.5.6p8(指针运算越界)。