断点调试的使用#

断点调试就是在源代码的某一行设置一个断点 (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}

设置断点#

鼠标指向在 代码编辑区 行号的左侧部分, 会发现鼠标位置出现一个圆圈, 左击 即可在对应行设置断点.

../_images/%E6%96%AD%E7%82%B9%E8%AE%BE%E7%BD%AE.png

断点设置#

添加断点后, 右击 可以再设置特殊功能: (你也可以 右击 该区域直接添加特殊功能的断点)

  • 条件: 给断点设置命中条件, 可以设置表达式、命中次数等, 当命中条件满足时断点才会被命中. 例如我们只想让 i == 10 时命中断点, 则可设置条件表达式 i == 10.

  • 操作/日志: 要求命中断点时输出自定义的消息.

  • ……

../_images/%E6%B7%BB%E5%8A%A0%E6%96%AD%E7%82%B9%E7%89%B9%E6%AE%8A%E5%8A%9F%E8%83%BD.png

添加断点特殊功能#

执行调试#

设置好断点后, 确认自己处于 Debug 或 RelWithDebInfo 模式, 通过 调试(D) ‣ 开始调试(S) 或按 F5 执行调试.

../_images/Debug%E6%A8%A1%E5%BC%8F.png

Debug 模式#

不同的构建配置

常见的构建配置 (build_type) 有:

Debug

调试版本, 一般由程序员用于调试. 它不对代码做任何优化并且会记录代码与程序间的对应关系 (称为调试信息), 从而允许实际地逐行执行代码.

Release

发行版本, 即最终发布给用户的版本. 基于 as-if 规则, 它只需要保证程序运行起来就像代码看起来那样 (as-is), 因而可以对代码进行尽可能的优化.

需要注意的是, 针对程序中的有符号数溢出、下标越界等未定义行为, 它怎么优化都可以, 因而即使炸掉你的电脑也是可能的, 所以不要让代码中有未定义行为.

RelWithDebInfo

具有调试信息的发行版本. 由于未定义行为等的存在, 我们可能遇到调试版本能正常运行, 但发行版本出现问题的情况, 此构建配置即用于调试这种情况.

由于发行版本对代码进行优化, 调试过程中可能出现一下跳过好几行的情况, 这是正常的.

MinSizeRel

以最小大小为首要目标进行优化的发行版本.

喜欢谈优化的新手请先知道有不同的构建配置; 抱怨程序慢的人请先切换到 Release 构建配置.

阅读和控制调试#

在执行调试后, 程序会执行直到中断 (命中断点、 抛出异常、 sanitizer 检测到下标越界等运行时错误 等等). 此后我们得到以下界面:

警告

如果没有得到这样的界面, 请通过窗口上方的 窗口(W) ‣ 重置窗口布局(R) 重置布局.

../_images/%E6%96%AD%E7%82%B9%E7%95%8C%E9%9D%A2.png

断点界面#

程序控制处#

对整个程序进行控制.

../_images/%E6%96%AD%E7%82%B9%E7%95%8C%E9%9D%A2_%E7%A8%8B%E5%BA%8F%E6%8E%A7%E5%88%B6%E5%A4%84.png

程序控制处#

  1. 执行调试/继续: 继续执行程序直到中断.

  2. 暂停: 如果程序正在执行, 中断程序.

  3. 停止: 停止执行程序.

  4. 重启: 重启程序.

调试位置控制处#

让命中断点或因其他原因中断的程序按要求执行:

../_images/%E6%96%AD%E7%82%B9%E7%95%8C%E9%9D%A2_%E8%B0%83%E8%AF%95%E4%BD%8D%E7%BD%AE%E6%8E%A7%E5%88%B6%E5%A4%84.png

调试位置控制处#

  1. 逐语句: 像正常执行程序一样一句句执行, 如果遇到函数调用则进入函数内部进行调试.

  2. 逐过程: 类似逐语句, 除了遇到函数调用时会直接完全执行该函数, 不调试函数内部.

  3. 跳出: 执行直到当前函数返回, 回到调用该函数的函数中.

一些调试器还允许更多的执行方式:

  1. 逐指令: 一条语句可能非常复杂 (例如 a * (b + c * d)), 编译后会对应于多条指令, 逐指令允许我们一条条指令进行调试.

  2. 撤销: 撤销之前的执行, 回到前一个位置.

变量窗口#

显示变量的名称、值和类型等, 不同的类型会有不同的显示格式以辅助调试, 例如指针可以展开直接查看指向对象的内容.

通过变量窗口, 你可以:

  • 查看变量的信息.

  • 修改此时变量的值.

  • 右键 ‣ 值更改时中断.

  • ……

变量窗口可分为:

自动窗口

这是 Visual Studio 特殊的功能, 会搜集显示它认为重要的变量信息, 例如函数所使用的全局变量等.

局部变量

显示函数内的局部变量.

监视

允许填入表达式从而实时查看其值变化.

例如对于数组 int array[10000], 你可能只想关注 array[50] 的值, 则可以添加表达式 array[50].

又如对于浮点数 double value, 你可能更关注它对应的 int 值, 则可以添加表达式 static_cast<int>(value).

../_images/%E6%96%AD%E7%82%B9%E8%B0%83%E8%AF%95_%E8%87%AA%E5%8A%A8%E7%AA%97%E5%8F%A3.png

调试到 add_1() 内时, 自动窗口搜集 index 变量信息.#

调用堆栈#

显示目前的函数调用信息, 最上一层即我们目前正在执行的函数调用.

../_images/%E6%96%AD%E7%82%B9%E8%B0%83%E8%AF%95_%E8%B0%83%E7%94%A8%E5%A0%86%E6%A0%88.png

调用堆栈#

如上图中我在第二次调用 add_1() 时中断, 从图中可知函数调用信息如下所示, 其中行号表示该函数目前执行到的语句:

1add_1() 行 9
2add_1() 行 10
3main() 行 15

我们可以双击调用堆栈中的函数, 从而切换到该函数进行查看 (只是查看, 我们程序仍然执行在当前位置).

../_images/%E6%96%AD%E7%82%B9%E8%B0%83%E8%AF%95_%E5%88%87%E6%8D%A2%E8%B0%83%E7%94%A8%E5%A0%86%E6%A0%88.png

切换函数#

其他功能#

目前仅展示了界面上可见的一些功能, 实际上用图形软件调试时, 鼠标也有一些特殊功能, 例如:

  • 鼠标放在表达式上可以看到表达式的当前值.

  • 鼠标右键表达式可以将它添加到监视中.

  • 鼠标放在语句上可以选择执行到该条语句.

但这些功能在不同图形软件上表现迥异, 故此处不再解释, 请自行发掘.

C 风格数组传参后怎么办?#

回到调用堆栈的例子,

../_images/%E6%96%AD%E7%82%B9%E8%B0%83%E8%AF%95_%E8%B0%83%E7%94%A8%E5%A0%86%E6%A0%88.png

调用堆栈#

注意到由于 C 风格数组传参时隐式类型转换为首元素的指针, 局部变量中 int* array 只能看到数组中首元素的值, 可我们实际是在对数组进行操作, 该怎么看到整个数组的变化呢?

以下提供两种方法,

  1. 就像之前所演示的, 我们切换调用堆栈到 main() 函数. 因为 main() 函数中的 array 类型确实是数组, 所以我们能看到整个数组的内容.

  2. 在监视窗口填入 reinterpret_cast<int (*)[5]>(array), 这会将传入的 int* array 强制转换为指向数组的指针 int (*)[5], 从而恢复数组的类型信息.

警告

这是 C 风格数组很容易隐式类型转换成指向首元素的指针所带来的硬伤, 有可能的话应该用 std::array<T, Size>std::vector<T> 替代它.

习题#

请在不修改代码的情况下调试代码, 并回答以下问题: (每问的调试可分别进行)

  1. sizeof 的功能是获取类型的大小. sizeof(array)sizeof(array[0]) 分别是获取哪个类型的大小?

  2. sizeof(array) / sizeof(array[0]) 的目的是什么?

  3. function() 函数实现了什么功能?

  4. 第三次调用 impl() 函数且刚刚进入函数内时, array 元素的内容是什么?

  5. 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}

一个教学的黑点#

通过断点调试可以自由直观地查看代码中的内容变化, 然而教学中却喜欢把代码贴在幻灯片上, 在旁边放上输出, 更可怕的是, 由于代码过长把输出放到第二页, 来回翻页让学生查看.

学会断点调试之后呢?#

本文涉及的断点调试仅仅是断点调试最基础的功能, 随便打开 Visual Studio 上方的 调试 菜单, 就会发现大量本文未涉及的功能.

我对调试器的学习也不多, 此处仅列举一些知道的材料: