断点调试的使用#
断点调试就是在源代码的某一行设置一个断点 (breakpoint), 开始调试时, 程序运行到这个断点位置且满足断点条件时 (称为命中, hit) 就会停住, 然后可以一步步往下调试. 调试过程中
可以实时显示各变量的当前值.
可以额外设置表达式查看它的对应值.
可以让程序到达断点时输出日志信息.
……
不同图形软件的断点调试方式没有大的区别, 此处以 Visual Studio 2022 为例.
1int index = 0;
2
3void add_1(int* array, int size) {
4 if (index == size) {
5 return;
6 }
7 array[index] += 1;
8 ++index;
9 add_1(array, size);
10}
11
12int main() {
13 int array[5] = {};
14 add_1(array, 5);
15}
设置断点#
鼠标指向在 代码编辑区 行号的左侧部分, 会发现鼠标位置出现一个圆圈, 左击 即可在对应行设置断点.

断点设置#
添加断点后, 右击 可以再设置特殊功能: (你也可以 右击 该区域直接添加特殊功能的断点)
条件: 给断点设置命中条件, 可以设置表达式、命中次数等, 当命中条件满足时断点才会被命中. 例如我们只想让
i == 10
时命中断点, 则可设置条件表达式i == 10
.操作/日志: 要求命中断点时输出自定义的消息.
……

添加断点特殊功能#
执行调试#
设置好断点后, 确认自己处于 Debug 或 RelWithDebInfo 模式, 通过
或按 F5 执行调试.
Debug 模式#
不同的构建配置
常见的构建配置 (build_type) 有:
- Debug
调试版本, 一般由程序员用于调试. 它不对代码做任何优化并且会记录代码与程序间的对应关系 (称为调试信息), 从而允许实际地逐行执行代码.
- Release
发行版本, 即最终发布给用户的版本. 基于 as-if 规则, 它只需要保证程序运行起来就像代码看起来那样 (as-is), 因而可以对代码进行尽可能的优化.
需要注意的是, 针对程序中的有符号数溢出、下标越界等未定义行为, 它怎么优化都可以, 因而即使炸掉你的电脑也是可能的, 所以不要让代码中有未定义行为.
- RelWithDebInfo
具有调试信息的发行版本. 由于未定义行为等的存在, 我们可能遇到调试版本能正常运行, 但发行版本出现问题的情况, 此构建配置即用于调试这种情况.
由于发行版本对代码进行优化, 调试过程中可能出现一下跳过好几行的情况, 这是正常的.
- MinSizeRel
以最小大小为首要目标进行优化的发行版本.
喜欢谈优化的新手请先知道有不同的构建配置; 抱怨程序慢的人请先切换到 Release 构建配置.
阅读和控制调试#
在执行调试后, 程序会执行直到中断 (命中断点、 抛出异常、 sanitizer 检测到下标越界等运行时错误 等等). 此后我们得到以下界面:
警告
如果没有得到这样的界面, 请通过窗口上方的
重置布局.
断点界面#
程序控制处#
对整个程序进行控制.

程序控制处#
执行调试/继续: 继续执行程序直到中断.
暂停: 如果程序正在执行, 中断程序.
停止: 停止执行程序.
重启: 重启程序.
调试位置控制处#
让命中断点或因其他原因中断的程序按要求执行:

调试位置控制处#
逐语句: 像正常执行程序一样一句句执行, 如果遇到函数调用则进入函数内部进行调试.
逐过程: 类似逐语句, 除了遇到函数调用时会直接完全执行该函数, 不调试函数内部.
跳出: 执行直到当前函数返回, 回到调用该函数的函数中.
一些调试器还允许更多的执行方式:
逐指令: 一条语句可能非常复杂 (例如
a * (b + c * d)
), 编译后会对应于多条指令, 逐指令允许我们一条条指令进行调试.撤销: 撤销之前的执行, 回到前一个位置.
变量窗口#
显示变量的名称、值和类型等, 不同的类型会有不同的显示格式以辅助调试, 例如指针可以展开直接查看指向对象的内容.
通过变量窗口, 你可以:
查看变量的信息.
修改此时变量的值.
.
……
变量窗口可分为:
- 自动窗口
这是 Visual Studio 特殊的功能, 会搜集显示它认为重要的变量信息, 例如函数所使用的全局变量等.
- 局部变量
显示函数内的局部变量.
- 监视
允许填入表达式从而实时查看其值变化.
例如对于数组
int array[10000]
, 你可能只想关注array[50]
的值, 则可以添加表达式array[50]
.又如对于浮点数
double value
, 你可能更关注它对应的int
值, 则可以添加表达式static_cast<int>(value)
.

调试到 add_1()
内时, 自动窗口搜集 index
变量信息.#
调用堆栈#
显示目前的函数调用信息, 最上一层即我们目前正在执行的函数调用.

调用堆栈#
如上图中我在第二次调用 add_1()
时中断, 从图中可知函数调用信息如下所示, 其中行号表示该函数目前执行到的语句:
1add_1() 行 9
2add_1() 行 10
3main() 行 15
我们可以双击调用堆栈中的函数, 从而切换到该函数进行查看 (只是查看, 我们程序仍然执行在当前位置).

切换函数#
其他功能#
目前仅展示了界面上可见的一些功能, 实际上用图形软件调试时, 鼠标也有一些特殊功能, 例如:
鼠标放在表达式上可以看到表达式的当前值.
鼠标右键表达式可以将它添加到监视中.
鼠标放在语句上可以选择执行到该条语句.
但这些功能在不同图形软件上表现迥异, 故此处不再解释, 请自行发掘.
C 风格数组传参后怎么办?#
回到调用堆栈的例子,

调用堆栈#
注意到由于 C 风格数组传参时隐式类型转换为首元素的指针, 局部变量中 int* array
只能看到数组中首元素的值, 可我们实际是在对数组进行操作, 该怎么看到整个数组的变化呢?
以下提供两种方法,
就像之前所演示的, 我们切换调用堆栈到
main()
函数. 因为main()
函数中的array
类型确实是数组, 所以我们能看到整个数组的内容.在监视窗口填入
reinterpret_cast<int (*)[5]>(array)
, 这会将传入的int* array
强制转换为指向数组的指针int (*)[5]
, 从而恢复数组的类型信息.
警告
这是 C 风格数组很容易隐式类型转换成指向首元素的指针所带来的硬伤, 有可能的话应该用 std::array<T, Size>
或 std::vector<T>
替代它.
习题#
请在不修改代码的情况下调试代码, 并回答以下问题: (每问的调试可分别进行)
sizeof 的功能是获取类型的大小.
sizeof(array)
和sizeof(array[0])
分别是获取哪个类型的大小?sizeof(array) / sizeof(array[0])
的目的是什么?function()
函数实现了什么功能?第三次调用
impl()
函数且刚刚进入函数内时,array
元素的内容是什么?function()
函数是如何实现它的功能的?
1void impl(int* array, int size, int index) {
2 if (index == size) {
3 return;
4 }
5
6 int current = array[index];
7
8 int i = 0;
9 for (i = index - 1; i >= 0 && array[i] > current; --i) {
10 array[i + 1] = array[i];
11 }
12 array[i + 1] = current;
13
14 impl(array, size, index + 1);
15}
16
17void function(int* array, int size) {
18 impl(array, size, 0);
19}
20
21int main() {
22 int array[] = {7, 6, 5, 4, 3, 2, 1};
23 function(array, sizeof(array) / sizeof(array[0]));
24}
点击查看答案
在
main()
函数内设置断点, 并在监视窗口填入array
和array[0]
得知, 它们的类型分别是int[7]
和int
.由 1,
sizeof(array)
获取整个数组的大小,sizeof(array[0])
获取数组中元素的大小, 则相除得到数组的长度.在
main()
函数调用function()
前设置断点, 用 逐过程 进行调试, 发现调用function()
后array
被按非降序排序了, 可猜测function()
的功能是按非降序排序.在
impl()
最开始设置条件断点, 命中条件设置为 "命中次数 = 3", 执行调试后切换调用堆栈到main()
, 发现array
的内容是{6, 7, 5, 4, 3, 2, 1}
在
impl()
最开始设置断点, 不断 执行调试/继续 并观察每次命中断点时array
的内容变化,1{7} | {6, 5, 4, 3, 2, 1} 2{6, 7} | {5, 4, 3, 2, 1} 3{5, 6, 7} | {4, 3, 2, 1} 4{4, 5, 6, 7} | {3, 2, 1} 5{3, 4, 5, 6, 7} | {2, 1} 6{2, 3, 4, 5, 6, 7} | {1} 7{1, 2, 3, 4, 5, 6, 7}
每次都将右边部分第一个元素插入到左边构成非降序, 这是插入排序.
一个教学的黑点#
通过断点调试可以自由直观地查看代码中的内容变化, 然而教学中却喜欢把代码贴在幻灯片上, 在旁边放上输出, 更可怕的是, 由于代码过长把输出放到第二页, 来回翻页让学生查看.
学会断点调试之后呢?#
本文涉及的断点调试仅仅是断点调试最基础的功能, 随便打开 Visual Studio 上方的
菜单, 就会发现大量本文未涉及的功能.我对调试器的学习也不多, 此处仅列举一些知道的材料: