ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
[TOC] # 逐过程调试(F10)和逐语句调试(F11) ![](https://box.kancloud.cn/d19077095b38c46d8b30d87403ab97d3_244x200.png) 当需要跳出函数时,可以点击“跳出”按钮,或者按`Shift+F11`键,就会返回刚才的代码。 `逐过程(F10)`和`逐语句(F11)`都可以用来进行单步调试,但是它们有所区别: * `逐过程(F10)`在遇到函数时,会把函数从整体上看做一条语句,不会进入函数内部; * `逐语句(F11)`在遇到函数时,认为函数由多条语句构成,会进入函数内部。 `逐语句(F10)`不仅可以进入库函数的内部,还可以进入自定义函数内部。在实际的调试过程中,两者结合可以发挥更大的威力 # 即时窗口 “即时窗口”是VS提供的一项非常强大的功能,在调试模式下,我们可以在即时窗口中输入C语言代码并立即运行,如下图所示: ![](https://box.kancloud.cn/4d6142668f80c234bbda0176b31af624_397x374.png) 在即时窗口中可以使用代码中的变量,可以输出变量或表达式的值(无需使用printf()函数),也可以修改变量的值。 即时窗口本质上是一个命令解释器,它负责解释我们输入的代码,再由VS中的对应模块执行,最后将输出结果呈现到即时窗口。 需要注意的是,在即时窗口中不能定义新的变量,因为程序运行时 Windows 已经为它分配好了只够刚好使用的内存,定义变量是需要额外分配内存的,所以调试器不允许在程序运行的过程中定义变量,因为这可能会导致不可预知的后果 **调用函数** 在即时窗口中除了可以使用代码中的变量,也可以调用代码中的函数。将下面的代码复制到源文件中 ~~~ int plus(int x, int y){ return x + y; } int main(){ return 0; } ~~~ 在第6行设置断点,并在即时窗口中输入`plus(5, 6)`,如下图所示: ![](https://box.kancloud.cn/0f9a9fde8cdb2da4f809f432d78e4d90_310x242.png) # 查看修改运行时内存 首先我们通过内存窗口查看变量的值,我们启动 Visual Studio,创建一个工程,输入如下代码: ~~~ #include <stdio.h> int main() { int testNumber = 5678; printf("testNumber 的内存地址为 0x00%x \n", &testNumber); //输出内存地址 //TODO:在这里插入断点 return 0; } ~~~ 我们在第七行设置好断点,然后按 F5 启动调试,等待触发断点。触发断点后,我们发现,IDE中并没有显示内存窗口(默认设置下),这时,我们点击菜单 -> 调试(D) -> 窗口 (W) -> 内存 (M) -> 内存1(1),就可以调出内存窗口了,如图: 我们看到,内存窗口里面显示着一大堆乱七八糟的数据,这里面的这些就是我们内存中的数据啦,我们可以通过变量 testNumber 的内存地址跳转到器对应的内存空间,我们看到 testNumber 的地址为 0x0018f830 (在不同的进程是不一样的),我们把这个地址输入到我们的内存窗口的地址栏。如图: ![](https://box.kancloud.cn/e097757b64538661cd0b080c8dd2b542_384x125.png) 我们看到,尽管我们已经输入了正确地地址,但是我们还是没有看到正确的数据显示,其实原因非常简单,我们来回顾一下 C 语言的一些入门知识:我们知道,在我们的源代码中,我们的 testNumber 变量被定义为 int 整形,我们再想想 int 整形在内存中占几个字节?没错,是4个字节。所以我们应该以四字节的形式格式化这些内存数据,这是我们在内存窗口中单击我们的鼠标右键,在弹出的菜单中选择“4字节整数(4)”,然后就能正确地显示相关的数据了,如图: ![](https://box.kancloud.cn/54b9f1d2038374614183c36965f30a12_233x346.png) 没错,查看内存就是这么的简单。接下来我们就来查看与修改浮点数的内存数据,我们看下面这段代码: ~~~ #include <stdio.h> int main() { double pi = 3.141592653589; printf("pi 的内存地址为 %x \n", &pi); //输出内存地址 //TODO:在这里插入断点 return 0; } ~~~ 同样的,我们在第7行设置断点,按F5启动调试,等待断点被触发: ![](https://box.kancloud.cn/39e94c85f84a6fdd5a00549d3371357c_192x38.png) 这时我们看到的内存地址是这样的,与我们在内存窗口看到的不同,我们需要将其补齐,在我们现阶段编写的小程序中,显示的内存地址基本上都是六位的,我们在前面加上 “0x00”,将其补到八位(内存窗口上的地址栏里有几位就补到几位)。然后我们将其输入到内存窗口上的地址栏 我们发现,现在显示的数据依然是错误的,因为查看器现在还是在使用我们之前设置的 4位整形格式 格式化我们的内存数据呢,我们知道,我们的 double 属于64位浮点数,所以我们在查看窗口点击鼠标右键,在弹出的菜单中选择“64位浮点(6)”,然后我们就能看到它正确地输出数据了 ![](https://box.kancloud.cn/51547bf956fda9ca9ab936cc11300f7a_274x96.png) 我们注意到,在我们设置的变量pi的值的基础上,内存窗口里显示的数据多了几个0,这些0所代表的就是 double 型数据的最高精度。接下来我们尝试在内存窗口修改变量 pi 的值,为其补齐精度。现在我们用鼠标右键点击我们的pi的内存数据,在弹出的菜单中选择编辑值(E),在显示的输入框中我们输入 3.1415926535897931,按回车即可保存。我们看看效果 ![](https://box.kancloud.cn/abfa9570030d3e800c5fe12ee1882b15_374x93.png) 怎么样,内存的查看与修改是不是很简单呢?其实我们只要记住下面的几个对应关系,常用的数值数据类型的内存查看与修改都不在话下 ![](https://box.kancloud.cn/36fdb36cd1b65ff27b0ee0ef2aeecfd3_464x240.png) # 条件断点 大家有没有碰到这样的情况,在一个循环体中设置断点,假设有一千次循环,我们想在第五百次循环中设置断点,这该怎么办?反正设置断点不断按 F5 继续运行四百九十九次是不可能的。那该怎么办呢?其实我们的断点是可以设置各种各样的条件的,对于上述情况,我们可以对断点的命中次数做一个限制。 我们首先在 Visual Studio 中创建一个工程,并且输入如下代码: ~~~ #include <stdio.h> int main(){ for ( int i=1 ; i <= 1000 ; i++ ) { //TODO:插入计次断点 printf("我真行!\n"); } } ~~~ 首先我们用鼠标右键单击第4行的断点图标,在弹出的菜单中选择 命中次数(H) ,接下来会弹出如下图的一个对话框,我们在中间的选择框中选择 “中断,条件是命中次数等于”,我们在右边的编辑框输入 500。 ![](https://box.kancloud.cn/729dc680bec8ce450903defc6f2775b7_620x266.png) 我们点击确定,断点就设置到位了,接下来我们按 F5 运行调试。 ![](https://box.kancloud.cn/69a3422ac77fa56ebfc45ac058552104_507x81.png) 我们看到,在输出四百九十九行“我真行!”后,程序进入了中断状态,这是我们注意到自动窗口中的变量 i 的值为 500,接下来我们把这个 i 的值改为 1,点击 继续(C) 继续程序的运行,这样程序就再输出了一千行“我真行!”,然后退出。没错,命中次数限制的使用就是这么简单。 我们再次用鼠标右键单击第4行的断点图标,在弹出的菜单中选择 命中次数(H) ,大家如果有兴趣的话,可以试试中间的选择框中其他的条件选项,使用方法基本一致,这里不再赘述。 接下来我们来了解一下断点触发条件的使用,在 Visual Studio 的调试器中,我们可以对断点设置断点触发条件,这个条件可以引用我们程序中的变量,比如我们程序中有两个变量 a、b ,我们的命中条件可以是 a == b 、 a >= b 、 a != b 甚至是 (a - b)\*(a\*2 - b) > 0 这样的复杂条件。 # assert断言函数 ,这个函数在 assert.h 头文件中被定义,在微软的 cl 编译器中它的原型是这样的: ~~~ #define assert(_Expression) (void)( (!!(_Expression)) || (_wassert(_CRT_WIDE(#_Expression), _CRT_WIDE(__FILE__), __LINE__), 0) ) ~~~ 我们看到 assert 在 cl 编译器中被包装成了一个宏,实际调用的函数是 \_wassert ,不过在一些编译器中,assert 就是一个函数。为了避免这些编译器差异带来的麻烦,我们就统一将 assert 当成一个函数使用。 我们来了解一下 assert 函数的用法和运行机制,assert 函数的用法很简单,我们只要传入一个表达式即可,它会计算我们传入的表达式的结果,如果为真,则不会有任何操作,但是如果我们传入的表达式的计算结果为假,它就会像  stderr (标准错误输出)打印一条错误信息,然后调用 abort 函数直接终止程序的运行。 现在我们在 Visual Studio 中创建一个工程,输入下面这段非常简单的代码: ~~~ #include <stdio.h> #include <assert.h> int main() { printf("assert 函数测试:"); assert(true); //表达式为真 assert(1 >= 2); //表达式为假 return 0; } ~~~ 我们看到,我们的输出窗口打印出了断言失败的信息,并且 Visual Studio 弹出了一个对话框询问我们是否继续执行。但是如果我们不绑定调试器,构建发布版程序,按 Ctrl + F5 直接运行呢?是的,这个 assert 语句就无效了,原因其实很简单,我们看看 assert.h 头文件中的这部分代码: ~~~ #ifdef NDEBUG #define assert(_Expression) ((void)0) #else /* NDEBUG */ ~~~ 我们看到,只要我们定义了 NDEBUG 宏,assert 就会失效,而 Visual Studio 的默认的发布版程序编译参数中定义了 NDEBUG 宏,所以我们不用额外定义,但是在其他编译器中,我们在发布程序的时候就必须在包含 assert.h 头文件前定义 NDEBUG 宏,避免 assert 生效,否则总是让用户看到“程序已经停止运行,正在寻找解决方案 . . .”的 Windows 系统对话框可就不妙了。 下面我们来了解一下 assert 的常用情境以及一些注意事项。举个C语言文件操作的例子: ~~~ #include <stdio.h> #include <assert.h> #include <stdlib.h> int main(void) { FILE *fpoint; fpoint = fopen("存在的文件.txt", "w"); //以可读写的方式打开一个文件 //如果文件不存在就自动创建一个同名文件 assert(fpoint); //(第一次断言)所以这里断言成功 fclose(fpoint); fpoint = fopen("不存在的文件.txt", "r"); //以只读的方式打开一个文件,如果不存在就打开文件失败 assert(fpoint); //(第二次断言)所以断言失败 printf("文件打开成功"); //程序永远都执行不到这里来 fclose(fpoint); return 0; } ~~~ 阅读代码,我们可以知道,如果我们错误的设置了读写权限,或者没有考虑 Windows 7(或更高版本) 的 UAC(用户账户权限控制) 问题的话,我们的程序出现的问题就很容易在我们的测试中暴露出来。事实上,上面的代码也不是一定能通过第一次断言的,我们把构建生成的(Debug 模式的)可执行文件复制到我们的系统盘的 Program Files 文件夹再执行,那么 Win7 及以上的操作系统的UAC都会导致程序无法通过第一次断言的。所以在这种情况下我们就需要在必要的情况申请管理员权限避免程序BUG的发生。 在我们的实际使用过程中,我们需要注意一些使用 assert 的问题。首先我们看看下面的这个断言语句: ~~~ //... assert( c1 /*条件1*/ && c2 /*条件2*/ ); //... ~~~ 我们思考一下:如果我们的程序在这里断言失败了,我们如何知道是 c1 断言失败还是 c2 断言失败呢,答案是:没有办法。在这里我们应该遵循使用 assert 函数的第一个原则:每次断言只能检验一个条件,所以上面的代码应该改成这样: ~~~ //... assert(c1 /*条件1*/); assert(c2 /*条件2*/); //... ~~~ 切换一下编译模式到发布模式: ![](https://box.kancloud.cn/a575810dcf552c285890599b191af477_198x80.png) 我们看到了一个完全不相同的运行结果,这是为什么呢?其实原因很简单,我们注意这段代码: ~~~ assert(i++ <= 100); ~~~ 我们的条件表达式为 i++ <= 100,这个表达式会更改我们的运行环境(变量i的值),在发布版程序中,所有的 assert 语句都会失效,那么这条语句也就被忽略了,但是我们可以把它改为 i++ ; assert(i <= 100); ,这样程序就能正常运行了。所以请记住:不要使用会改变环境的语句作为断言函数的参数,这可能导致实际运行中出现问题。 最后,我们再来探讨一下,什么时候应该用 assert 语句?一个健壮的程序,都会有30%~50%的错误处理代码,几乎用不上 assert 断言函数,我们应该将 assert 用到那些极少发生的问题下,比如Object\* pObject = new Object,返回空指针,这一般都是指针内存分配出错导致的,不是我们可以控制的。这时即使你使用了容错语句,后面的代码也不一定能够正常运行,所以我们也就只能停止运行报错了。 # OutputDebugString调试信息输出 Windows 操作系统提供的函数 —— OutputDebugString,这个函数非常常用,他可以向调试输出窗口输出信息(无需设置断点,执行就会输出调试信息),并且一般只在绑定了调试器的情况下才会生效,否则会被 Windows 直接忽略。接下来我们了解一下这个函数的使用方法。 首先,这个函数在 windows.h 中被定义,所以我们需要包含 windows.h 这个头文件才能使用 OutputDebugString 函数。这个函数的使用方式非常的简单,它只有简单的一个参数——我们要输出的调试信息。但是有一点值得注意:准确来说 OutputDebugString 并不是一个函数,他是一个宏。在高版本的 Visual Studio 中,因为编译的时候 Visual Studio 默认定义了 UNICODE 宏,所以我们查找 OutputDebugString 的定义会看到如下代码: ~~~ #ifdef UNICODE #define OutputDebugString OutputDebugStringW #else #define OutputDebugString OutputDebugStringA #endif // !UNICODE ~~~ 我们可以从代码高亮上看到,OutputDebugString 实际上等价于 OutputDebugStringW,这就意味着我们必须传入宽字符串(事实上只要定义了 UNICODE ,调用所有 Windows 提供的函数都需要使用宽字符),或者使用 TEXT 或 \_T 宏,并且这是最好的方法,这个宏会自动识别编译器是否处于默认宽字符的状态并对传入字符串做些处理,使用这个宏可以加强代码对不同编译器不同编译参数的兼容性。下面我们就来看一段示例代码 ~~~ #include <windows.h> #include <tchar.h> int main() { OutputDebugString(TEXT("你好")); OutputDebugString(_T("hello world")); //也可以:OutputDebugStringA("大家好才是真的好。"); //也可以:OutputDebugStringW(L"大家好才是真的好。"); //使用自动字符宏 TEXT 或者 _T 可以自动判断是否使用宽字符 getchar(); return 0; } ~~~ ![](https://box.kancloud.cn/7a7701a637364ca2c08bd346de84e822_710x264.png) 但在 Windows 下,我们一般使用 \\r\\n 作为完整的换行符。 直接使用这个调试信息输出函数有个弊端,那就是它不能直接输出含参数的字符串。但是我们可以通过 sprintf / wsprintf 等缓冲区写入函数来间接实现输出含参数字符串的功能。下面是一段示例代码: ~~~ #include <stdio.h> #include <windows.h> int main(){ //注意!这段代码我们指定使用ANSI字符! char szBuffer[200]; int number = 100; sprintf_s(szBuffer, "变量 number 的值是 %d \r\n", number); //写入缓冲区,注意不要溢出 OutputDebugStringA(szBuffer); sprintf_s(szBuffer, "变量 number 的地址是 %x \r\n", &number); OutputDebugStringA(szBuffer); //我门指定使用 ANSI 版本的 OutputDebugString 函数 return 0; } ~~~ 我们可以自己写一个前端函数,然后保存到头文件中(编译生成 dll 也可以,有兴趣的同学可以试试)。为了方便,我们已经编写好了这么一套函数。代码如下: ~~~ #include <stdio.h> #include <windows.h> #ifndef _DEBUG_INFO_HEADER_ //防止头文件被重复载入出错 #define _DEBUG_INFO_HEADER_ #if (defined UNICODE)||(defined _UNICODE) #define DebugInfo DebugInfoW #else #define DebugInfo DebugInfoA #endif // 函数: DebugInfoA(char*, int, ...) // // 目的: 以窄字符的形式输出调试信息 // // char* str - 格式化 ANSI 字符串 // ... - 任意不定长参数 // void DebugInfoA(char* str, ...){ char szBuffer[500]; //注意不要让缓冲区溢出! va_list Argv; va_start(Argv, str); _vsnprintf_s(szBuffer, 500, str, Argv); va_end(Argv); OutputDebugStringA(szBuffer); } // 函数: DebugInfoW(char*, int, ...) // // 目的: 以宽字符的形式输出调试信息 // // char* str - 格式化 UNICODE 字符串 // ... - 任意不定长参数 // void DebugInfoW(wchar_t* str, ...){ wchar_t szBuffer[1000]; va_list Argv; va_start(Argv, str); _vsnwprintf_s(szBuffer, 500, str, Argv); va_end(Argv); OutputDebugStringW(szBuffer); } #endif ~~~ 的这段代码会自动识别编译器是否默认使用了宽字符并且使用对应版本的输出函数,其中注释为 Visual Studio 的智能提示信息,我们把上面的代码保存到 debuginfo.h 并添加到当前工程中,就可以直接通过如下代码调用: ~~~ #include <stdio.h> #include <tchar.h> #include <windows.h> #include "debuginfo.h" int main(){ int num; //这里我们使用微软提供的 xxxx_s 安全函数 printf_s("请输入数值:\n"); scanf_s("%d", &num); DebugInfo(TEXT("用户输入的数是 %d\n"), num); return 0; } ~~~ 但是请注意不要让缓冲区溢出,否则会造成不可估计的后果。 我们除了在调试器中可以看到调试字符串的输出,我们还可以借助 Sysinternals 软件公司研发的一个相当高级的工具 —— DebugView 调试信息捕捉工具,这个工具可以在随时随地捕捉 OutputDebugString 调试字符串的输出(包括发布模式构建的程序),可以说这是个神器,大家可以在微软 MSDN 库上搜索下载。接下来我们运行 DebugView 调试信息捕捉工具。 这个调试信息查看工具显示了正确的调试信息。注意!如果挂载了 Visual Studio 的调试器,这个工具就会失效。 使用这个工具可以捕获发布版程序的输出,我们一般可以用来跟踪测试产品的运行状态。以趁早发现我们程序中存在的问题,避免发布后出现的容易被检测到的BUG。 # Release和Debug模式 首先我们再了解一下Visual Studio 中,Release构建模式和Debug 构建模式的区别。我们在 ![](https://box.kancloud.cn/f00b0065466712a9c73bedf436a22ba0_274x27.png) 可以切换构建模式。Release构建模式下构建的程序为发行版,而Debug构建模式下构建的程序为调试版。在 Visual Studio 中调试模式还会定义两个宏 \_DEBUG 和 DEBUG,后文我们将介绍它们的一些妙用。在 Visual Studio 中,如果我们要更改编译参数的话,可以点击菜单 -> 项目(P) -> 属性(P),我们在弹出的页面左侧选择配置属性即可对编译参数进行修改。 接下来,我们来了解一下调试标记。不知道大家有没有遇到这样的情况,我们需要在调试的时候额外运行一段代码,但是实际发布的时候却不需要这段代码呢。那该怎么办,绝大多数数的初学者会选择使用注释,即在发布的时候将无用的测试代码注释掉。但是这样很麻烦,下面我们就为大家介绍一种全新的方法——使用调试标记。事实上这种方法我们在前面使用过,但是没有详细讲解。 这种方法借助了预处理指令,方法很简单,我们首先定义一个宏作为处于调试状态的标记,后面的代码我们用 #ifdef 和 #endif 预处理指令检测宏是否被定义,然后由编译器判断是否编译其中的代码。这么做的好处就是可以减少发布程序的体积,另一方面可以提高发布程序的运行效率。下面是一段示范代码: ~~~ #include <stdio.h> #define _DEBUGNOW int main(){ #ifdef _DEBUGNOW printf("正在为调试做准备..."); #endif // _DEBUGNOW printf("程序正在运行..."); return 0; } ~~~