2.3 调试执行
上一节介绍了调试过程中最重要的技术——断点,合理设置断点有助于调试并发现BUG。本节主要介绍调试过程中另一个重要的概念——调试执行,即程序在断点处中断后,我们希望通过什么方式来执行程序中的代码,以达到预期效果,并能够快速地定位并解决BUG。
2.3.1 启动调试
启动调试有多种方法:可以从“调试”菜单中执行“开始调试”命令;也可以从“调试”菜单中执行“附加到进程”命令。两者的功能略有不同,后面会有一个专门的小节来介绍“附加到进程”这一命令。
还可以按F5键来启动调试。这种启动调试的方法一般是在程序还没有开始运行时使用,可以直接从调试器中启动程序。这种方式的好处是可以调试程序任意位置的代码,比如main函数的第一行。使用这种方式来运行程序是一个好习惯,尤其是在开发阶段,因为我们不知道程序中是否包含BUG,也不知道程序在执行过程中是否会崩溃、是否有内存泄漏等。但是,如果我们在调试器中运行程序,那么就很容易探测到上述情况,从而提前发现并解决BUG。如果在程序运行过程中出现了一些错误的操作,比如内存被多次释放、缓存区溢出等,就可以立即捕获这些错误。
建议平时以启动调试的方式来运行程序,而不要选择“开始执行(不调试)”,即使我们并没有特定的调试目的,这样也可以及早发现并解决程序中的问题。
启动调试后,程序会执行到第一个断点处暂停,这里的第一个断点指的不是位置上的第一个(即代码行最靠前的那一个),而是逻辑上的第一个(即所有断点中最先执行的那个)。
当程序暂停后,“启动调试”菜单就会变成“继续”,如果此时继续按F5键或者单击调试菜单中的“继续”,程序就会继续执行,直到遇到下一个断点。
与启动调试对应的功能是停止调试,可以从“调试”菜单中执行“停止调试”命令,或者按组合键Ctrl+Shift+F5来停止调试,此时无论程序是暂停状态还是运行状态,都会停止运行。另外一种方法是直接退出程序,但是必须是在程序没有进入暂停状态时退出,否则程序便无法退出。
2.3.2 逐语句执行
逐语句(Step Into)执行(F11键)也称为单步执行或者逐行执行,即一行一行地执行程序中的代码。如果某行代码中调用了一个函数,那么逐语句执行命令就会进入函数中去,而不是跳过函数。我们来看一个简单的例子,如代码清单2-1所示。
代码清单2-1 逐语句执行示例
在代码清单2-1中,order_bus函数的第一行(也就是第241行)代码设置了一个断点,当代码执行到这里时会暂停。我们可以按F11键来逐语句执行,每按一次就执行一行代码,当执行到第245行代码时,由于这条语句中调用了total_bus函数,因此这时按F11键,就会进入total_bus函数中继续执行,即调试已经从order_bus函数进入total_bus函数中。如果在total_bus函数中继续逐语句执行,那么执行方式一致。如果遇到了函数调用,还会继续进入函数内部执行。
如果调用的函数是系统函数或者C/C++库函数会怎样呢?逐句执行会得到什么结果?如果该函数有源代码,也会直接进入该函数中;如果该函数没有对应的源代码,则会像普通语句一样逐语句执行,不会进入函数中。
如果一行语句中有多个函数调用,逐语句执行会依次进入多个函数中执行,但是会按照什么顺序执行呢?我们来看一个逐语句执行多个函数调用的例子,如代码清单2-2所示。
代码清单2-2 逐语句执行多个函数调用
在代码清单2-2中,第163行代码设置了一个断点。该断点所在处的代码是一个if条件语句,if表达式里面调用了两个函数:is_ordered和get_seat_num。如果此时进行逐语句执行,会首先进入is_ordered函数中,从函数返回后,再次执行逐语句执行,则会进入到get_seat_num函数中。但是,我们都知道C/C++编译器会对代码进行一些优化,有时并不会完全按照预期执行,特别是条件语句。仍然以第163行代码为例,由于两个函数中间的操作符是&&,表示两个条件都为真时,整个if条件才为真。如果第一个函数返回false,第二个函数也不会执行。因为继续执行已经没有任何意义,无论get_seat_num函数获取到的值是否大于100,都不会改变整个if条件的取值,所以如果逐语句执行从is_ordered函数返回,并且其返回值为false的话,那么再次执行逐语句执行也不会进入get_seat_num函数中,而是会直接执行下一行代码。
类似的一种情况是,如果两个条件之间的操作符是||,如
if (is_ordered(bus) || get_seat_num(bus) < 100)
如果is_ordered函数的返回值为true,第二个函数get_seat_num就不会被执行,因为无论是否执行第二个函数get_seat_num,都不会影响整个if条件的判断。
注意
这里的逐语句执行好像是逐行执行,大多数情况下的确如此。但是C/C++代码规范比较灵活,有别于其他书写要求严格的编程语言,C/C++可以在一行中书写很多代码。所以逐语句(逐行)执行指的并不是物理意义上的一行代码,而是逻辑上的代码行,如果一行代码中书写了很多命令,逐语句执行的时候会逐个执行,就像逐个执行函数调用一样。
2.3.3 逐过程执行
逐过程(Step Over)执行与逐语句执行有一些相似之处。如果代码行中没有函数调用,那么执行结果是相同的,都是执行完当前代码行,在下一行代码处暂停。不同之处在于当前代码行中是否包含函数。如果当前代码行中有函数调用,逐语句执行会进入函数中然后暂停,而且如果有多个函数的话,逐语句执行会依次进入到每个函数中。逐过程执行则刚好相反,无论当前代码行有多少个函数调用,都不会进入到函数中,而是直接进入到下一行代码并暂停。
所以大多数情况下逐过程执行可以节省调试时间,对于不重要的函数调用或者函数代码,就可以使用逐过程执行或者按F10键,直接跳过该函数,执行下一行代码。
2.3.4 跳出执行
跳出执行(Step Out)是指跳出当前执行的函数。跳出执行的组合键是Shift+F11,该功能只有在程序暂停的状态下才可以使用,即正在逐语句或者逐过程执行代码时,跳出执行才有效。
跳出执行非常有用,比如我们正在一个函数中进行逐语句或者逐过程调试时,而且已经对关键代码进行了检查,相关的信息也进行了查看,如果并不关心函数后面部分的代码,这个时候就没有必要再逐步进行调试,就可以跳出执行。执行跳出命令或者按Shift+F11组合键,就会跳出当前函数的调试,进入调用该函数的代码的下一行代码处并暂停。
2.3.5 运行到光标处
运行到光标处(Run To Cursor)是一个非常有趣的功能。“调试”菜单中不包含该命令,只能在上下文菜单中找到或者使用Ctrl+F10组合键来调用。如图2-10所示,在第389行代码处单击鼠标右键后,就会在弹出的菜单中看到“运行到光标处”命令。
“运行到光标处”相当于先在光标处设置一个断点,然后继续执行“启动调试/继续”命令。不过这只是一个虚拟的断点,不会出现在“断点”窗口中,而且只会作用一次,即执行过后就不再起作用。
以图2-10为例,假设我们在第389行代码处执行“运行到光标处”命令或者按Ctrl+F10组合键,如果此时程序没有启动,那么程序会启动并进入调试状态。如果第389行代码之前还有其他断点,那么会先在其他断点处暂停,继续调试执行才会执行到第389行代码处并暂停,因此,“运行到光标处”设置的断点本身并不具备比其他断点更高的优先级,其作用只是一个普通的一次性断点。
图2-10 “运行到光标处”右键菜单
如果在执行“运行到光标处”命令时,调试已经开始,那么相当于先执行一个“继续”命令,然后执行至光标处暂停。
2.3.6 多次执行代码
多次执行代码指的是在调试状态下,多次执行某些代码。这个功能非常有用。如果对前面某个函数的调用没有理解清楚,或者对其返回的值有疑问,这时就可以对该函数重复执行一次,而不用等待下一次命中断点时再执行。因为能进入到一个断点是非常不容易的,特别是一些大型的软件,操作会非常耗时,BUG也不能稳定重现,因此最好在期望的断点处暂停下来,绝不能错过反复调试的机会。
假设我们正在进行代码调试,准备查看一辆班车的信息,如图2-11所示,我们在第216行代码处设置了一个断点,以查看查询到的班车信息。此时代码即将执行218行,但是发现班车信息并不是期望值,于是希望再次执行get_bus函数,查看问题出现的原因。
图2-11 代码多次执行示例
VC确实提供了这样的功能。从图2-11中可以看到,第218行代码的行首有一个箭头表示当前要执行的代码位置,它相当于代码执行的指针,这个指针指向哪里,代码就执行到哪里。因此,要想执行某行代码,可以将该指针移动到期望执行的地方。移动指针的操作没有菜单命令,也没有快捷键,只能通过拖动鼠标来执行,这也是最简单的方式——将箭头拖动到哪里,就从哪里开始执行。
将鼠标指针放到小箭头上面,就会出现如图2-12所示的代码执行的指针提示。这时只要按鼠标左键,拖动箭头到想要执行的位置,比如第216行代码,即可释放鼠标指针,此时执行指针就会指向新的位置,并准备好执行新位置的代码。
图2-12 代码执行的指针提示
从代码执行的指针提示可以发现,如果新的执行位置不合理,可能就会导致预料之外的结果,甚至会导致程序崩溃。因为在程序执行时,有很多信息需要保存,而且很多信息是互相依赖的,所以一定要保证拖动的位置能够正常执行,否则调试可能会终止。
利用代码执行指针的功能,除了可以反复执行某些代码,还可以跳过某些代码的执行。如果不想执行某行或者某几行代码,就可以通过移动执行指针来跳过这几行代码。同样地,如果这几行代码很重要,比如是一些赋值或者初始化的操作,就会影响后面代码的执行结果,需要特别注意。
注意
移动执行指针时需要遵循两个基本原则:一是不要移动到函数外;二是不要跳过重要的初始化操作语句。总之,最基本的原则是要保证程序能够正常运行。至于怎样做才能保证程序的正常运行,不同的程序需要进行具体分析,在实践中总结经验。