3.4 查看汇编信息
- 有的时候呢我们还需要查看一些底层的汇编代码,此时就可以通过【窗口】 ⇒ 【反汇编】进行查看
- 当然你也可以调试起来后在右击选择【转到反汇编】,博主一般都是用的这种方法,比较方便一些
💬 当然对于一些反汇编的知识这里就不讲了,有兴趣的同学可以去了解一下
3.5 查看寄存器信息
- 如果有学习过汇编语言或者是《计算机组成原理》的同学应该可以知道寄存器是 中央处理器(CPU) 很重要的一个部件,用来存放一些临时的变量,我们在调试汇编的时候可以发现用得比较多的有
eax
、ecx
、edx
、ebp
、esp
这些
💬 对于上述的调试小技巧,希望大家要熟练掌握,初学者可能80%的时间在写代码,20%的时间在调试。但是一个程序员可能20%的时间在写程序,但是80%的时间在调试
💬 我们现在所讲的只是一些简单的调试,等到你慢慢地进行深入学习后,可能会出现很复杂调试场景:多线程程序的调试、网络问题的调试等,此时光使用VS内置的调试器来进行调试可能就不够了,大家还要再去学习一些像【Windbg】、【SPY++】、【Dependency Walker】等等。张哥是C++软件调试专家,致力于C++软件调试方面的培训和教学,读者如果感兴趣的话可以多去看看他的文章 dvlinker主页
4、多多动手,尝试调试,才能有进步
清楚了调试的基本手法后,我们立马来尝试一下该怎样一步步地去进行调试
- 下面是我们要进行调试的代码
void test() { int a = 0; int n = 0; scanf("%d %d", &a, &n); // 计算 int sum = 0; int i = 0; int k = 0; for (int i = 0; i < n; i++) { k = k * 10 + a; sum += k; } // 打印 printf("%d\n", sum); }
- 首先我们在这一行打上一个断点,按F9或者直接使用鼠标点击即可
- 然后按下F5直接运行到断点处开始调试,但是呢可以看到命令窗口却等待我们进行输入,一看代码原来有一个
scanf
在等待我们进行输入
- 在输入具体的数值之后便发现右侧的断点处出现了调试的箭头,此时我们就真正地开始调试了
- 然后我们在循环处打上一个断点开始做调试的工作,此时再按下【F5】便可以运行到下一个断点处
- 那此时我在循环外面又打了一个断点,请问此时我再度按下【F5】的时候也会运行到下一个断点处吗
- 可以看到并没有运行到这个打印语句处,而是一直处于循环,这就是因为这个循环还没有到达结束条件,所以它是不会退出的,屡次按【F5】只会强制进入下一次循环,我们通过去看这个
i
的变化就可以看出来
💬 那有同学问:这该怎么办呢?有没有什么好的办法?
- 办法当然是有的,只需要将这个循环内部的断点给取消掉即可,此时再去按【F5】的时候就可以运行到下一断点处了
以上即为我们在学习了基本的调试技巧后所呈现的调试详解,继续看下去,还有更精彩的内容等着你哦😘
五、Linux环境下GDB调试介绍
请移步博主Linux基础入门篇之 调试器GDB的详细教程,带你感受在Linux环境下该如何进行调试
六、经典案例分析【步步调试教学】
接下去,博主就通过一步步的调试来带读者如何去排查问题、解决问题
1、问题代码段1 —— 阶乘之和
先来看一道C语言中比较基础的题目,求解阶乘的和,通过调试来观察为何会出现问题,如觉得已经会了的读者可以直接看第二道题
- 先上代码。逻辑很简答,首先输入n表示,表示n个阶乘之和,然后在内部循环中求出每一个数的阶乘,计算所得进行累加,最后便有了【阶乘之和】
int main() { int sum = 0; //保存最终结果 int n = 0; int ret = 1; //保存n的阶乘 scanf("%d", &n); for (int i = 1; i <= n; i++) { for (int j = 1; j <= i; j++) { ret *= j; } sum += ret; } printf("%d\n", sum); return 0; }
简单一些,计算1! + 2! + 3!
的阶乘之和。
- 首先看到我们进入了内存循环,此时
i = 1, j = 1
,内部的循环只会执行一次,求出1! = 1
- 此时的sum便为1
- 接着进入【2!】的计算,内部循环会执行2次,此刻
i = 2, j = 1
- 此时算出
ret = 2
,即【2! = 2】,累加到sum中,此时sum == 3
- 接下去计算【3!】,内部循环会执行3次,3!应该要为6
- 此刻
i = 3, j = 1
,注意观察此时的ret为2
- 当
j == 2
时ret进行了一次累乘,值便为4
- 当
j == 3
时继续进行累乘,此时ret为12
- 此时跳出循环开始累加【3!】的结果,此时
sum == 15
,结果错误❌
相信在认真看了我步步分析之后,你一定可以清楚为什么会出错❓
- 原因就在于每次在计算下一个数的阶乘时,上一次累乘之后的ret没有进行一个重置,便导致在计算下一个阶乘的时候重复累乘
- 这样运算结果就正确了,
1! + 2! + 3! = 9
💬 上面只是热热身,让读者熟悉一下该如何去进行调试排查。下面这个才叫做【真正的问题】
2、问题代码段2 —— 越界的危害
① 发现问题
好,我们来看这个代码段,首先请你给出它最终的运行结果💻
- 相信很多同学都认为这段代码最后的结果是程序报错,因为一眼就看出了for循环的边界条件有问题,导致产生了
数组访问越界
- 如果你是这么想的,那我要这么告诉你:你是个正常人😀,我问了我身边的朋友,第一时间就觉得这一定是一个
越界错误
,不过它的运行结果并不是你想的那样,而是一个死循环😵
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; }
② 分析问题
看了上面的这些结果,相信很多读者都非常诧异(・∀・(・∀・(・∀・*)。究竟是为什么呢?我们一起先来分析一下:mag:
- 通过调试来进行观察,首先我们进入循环,初始化数组
- 然后通过动图先将数组的合法10个元素修改为0,这里的快捷操作是
F9设置断点
+F5运行到下一断点处
- 但是可以看到,循环的终止条件为
i == 12
,那此刻循环就会继续执行,那你就会想arr[10]
这个位置不是越界非法的嘛,为什么访问不会报错呢?
- 首先我们来讨论一下
arr[10]
这个位置,为什么可以访问到? - 从下图可以看出,在内存中对于一个数组而言是连续存储的,数组后面的这些空间其实也是存在arr数组之后,它们都存在于main函数的函数栈帧中,对于函数栈帧来说,一块块存储空间都是紧密相连的,所以要想访问数组之后的空间也是可以访问到,==只是在我们的意识中他确实是存在越界访问的行为==
- 可能还是有同学不理解,举个简单的【例子】:假设你现在在一家酒店里,和家里人一起出来旅游,找了酒店中三个连在一起的房间住一晚上。你们旁边呢还有很多房间,都是连在一起的,那这个时候你可以闯入别人的房间🏠吗?虽然行为上是非法的,但是呢又是可以做得到的,只是会被人打一顿而已。这就可以理解为【数组的越界访问】
- 但是越界访问也就算了,难不成真的可以修改没分配内部空间的值吗,我们来看看
- 可以看到,这个位置上的值确实是发生了一个修改Σ(っ °Д °;)っ,而且没有报出错误,继续循环。其实对于编译器来说,有时候的
越界访问
不报出错误是正常的,因为它有时候确实检查不到,就和交警不可能查到每一个醉酒的司机是一个道理,不然为什么有这么多车祸呢🚗 - 非但是可以修改第10个位置的元素,后面第11个它也进行了修改。虽然这已经见怪不怪了
- 可是呢,到了第12个位置的时候,却出现了奇怪的事情,这块地址上的数竟然不是一个随机值,我们都知道面对未初始化的数据都是一个无符号整型(unsigned int),上面也看到过,是一个很大的负数,可是呢这个位置上的数确是
12
,而且刚好是i == 12
这个边界的位置
- 此时当我再执行
arr[i] = 0
时间,就发生了这样的事👇此时的【i】和【arr[12]】两个值竟然同时发生了变化
- 那有同学就更加差异了,这是为啥呀?????
③ 思考问题【⭐堆栈原理⭐】
分析了问题出现的地方,接下去就让我们通过堆栈的内存布局和原理来分析一下为何会出现这样的情况
- 刚才看到了当程序运行完
arr[12] = 0
时arr[12]和【i】这两个位置的值一起发生了变化,那就会思考它们会不会是一样的呢? - 接着分别在调试的时候取出它们的地址就可以发现确实是同一块空间【我第一次看到这个结果的时候也感觉很惊奇!】
- 其实就可以想到,对于变量i,应该是位于数组结束位置的后两位位置,这样才会在越界访问数组的时候导致访问到【i】,然后在修改这块块空间中的值时将循环变量【i】的值做了修改,那也就使得【i】永远到不了13,那也就不会跳出这个循环,会一直循环下去
- 变量【i】辛辛苦苦地通过循环加到了13,眼看前面的路就要走完了,但是呢你又给他拽回了起点,那也就只好重新开始。可是呢一次也就算了,你就是和它过不去,就站在
13
这个位置上,每次看【i】一到13就把他拖回起点,这也就使得它永远都过不去了 —— 血海深仇❣
当然就通过这样的方式来看还是了解不到在内存中它们究竟发生了什么,就下去我就通过画内存图的方式带你一探究竟:mag:
- 内存布局呢分为【堆区】【栈区】【静态区】三大块,对于像
arr数组
和变量i
这些都属于局部变量,对于局部变量来说都是存放在【栈区】中的。也就是我们说过的函数栈帧它就是在栈区开辟空间的 - 栈也可以称做为【堆栈】,它的使用习惯是:==先使用高地址,再使用低地址==。这一点很重要,是理解的关键所在!
- 通过创建两个变量来进行观察就可以发现先创建的变量就会先创建的变量就会现在栈中为其开辟空间,因此可以看到变量a的地址是比变量b的地址来得大的
- 下面我画的这张图其实就是内存中栈区的真正模样,也就是从上往下进行生长,上面是高地址,下面是低地址。因此一进到main函数的函数栈帧中时,就会先为变量【i】开辟一块空间,接着可能就会空出几个位置再为arr数组开辟十个元素的空间
- 可是呢有同学就会疑问,为什么要空出几个位置,而不是直接紧随其后就在变量【i】后面为其分配10块空间呢?这一点我们到后面再议👇
- 我们都知道对于数组的下标来说是从低到高进行变化的,也就是
从0 ~ 9
,那对于数组的地址是如何变化的呢?我们通过VS来看看
- 通过打印数组中每个元素的下标就可以发现数组中==每个元素的地址是由低到高进行一个变化的==。这一点也很重要,是理解的关键所在!
然后再去看上面这张图你就可以知道为什么在越界访问数组的时候会访问到先创建出来的变量【i】了
👉虽然变量【i】是先创建出来的,先开辟的空间;而数组arr是后创建出来的,后开辟的空间。不过呢,因为数组的下标和每一个元素的地址都是从低到高进行一个变化的
。又因为堆栈的使用习惯是:先使用高地址,再使用低地址,所以当数组在进行向后访问的时候,就有可能找到变量【i】,就有可能把【i】覆盖掉,就有可能把这个循环变量改成意想不到的值,导致循环的结束条件永远都不会成立,永远都是真,这也就导致了死循环产生👈
你,明白了吗👀
最后的话再来解释一下为什么开辟了变量【i】的栈帧空间后要空出几个位置才为数组arr开辟空间,而且刚好是两个这么巧呢?
- 其实这不是我瞎说的,也不是我能决定的,而是取决于编译器。
- 在
VC6.0
这个很老的编译器中,其实在局部变量的栈帧空间开辟中是不会再创建多余空间的; - 在
gcc
这个Linux环境下的编译器中,创建的局部变量之间会空出一个整型,也就是4个字节 - 但是在
VS 2013/2019/2022
这些编辑器中,中间都会空出两个整型,也就是8个字节
- 所以这段代码其实你在不同编译器下去运行虽然都是死循环,但是死循环的临界点和循环的这个范围都是不同的。例如在Linux下的gcc去编译运行的话
i = 11
就会发生死循环,具体的有兴趣可以去试试
④ 解决问题【DeBug与Release】
好,上面我们通过一系列的问题排查和思索,最终发现了问题所在,那现在就来更正一下这个问题
- 其实如何更正你已经可以想到了,那就是把对于变量【i】的定义放到数组定义的后面,这样数组在进行越界访问的时候就不会访问到后面的【i】了
- 不过可以看到,终于是出现了大家一开始想到的
越界访问
的情况
- 不过这种做法可是不对的,数组越界访问应该是我们要避免的一个问题,所以真正要做出修改的应该是循环中访问数组的结束条件
- 接下去我们再来看一个神奇的事情,对于代码在编译器中的运行环境我们可以知道有【DeBug】和【Release】两个版本,我将会出现死循环的这中定义方式放在两个不同的版本下进行了运行,查看变量i和数组的边界地址
- 然后便发现【Relsase】版本对变量i的地址做了一个
优化
,使其变到了数组arr的前面,==这样在数组向后进行越界访问的时候就不会发生覆盖造成同时修改了==
希望在看了我上述对这个问题的讲解之后,今后在碰到类似的问题也可以照常去分析排查:mag:
上面这题就是来自西安的一家公司叫做【Nice】,这是它们在2016年的校招笔试题
- 可以看到,题目中问题的是在Linux环境底下的运行结果是怎样的,所以我们还需要熟悉Linux的环境,毕竟C/C++程序员日常的开发环境基本都是在Linux环境下的,读者可以自行去分析一下这道题目看看是否真的掌握了💡