目录
1、对0xCCCCCCCC、0xCDCDCDCD和0xFEEEFEEE等常见异常值的辨识度
2、在Debug下遇到报错弹框,点击重试,查看函数调用堆栈
3、调试时程序和调试器都发生了闪退,可以尝试到Output窗口中找线索
4、调用OutputDebugString接口,将打印日志输出到调试器输出窗口中
5、调用被废弃的API函数IsBadReadPtr和IsBadWritePtr,可能会导致VS调试时产生异常
6、VS自带的异常中断选项
7、条件断点与临时过滤条件
8、巧用数据断点
9、将VS附加到目标上调试
10、使用VS和IDA查看汇编代码
11、最后
用了多年的Visual Studio,很多人是不是还没完全掌握其有效的调试方法和技巧呢?是不是还在为无法快速定位问题而发愁呢?本文将根据多年使用Visual Studio的经验,带着大伙逐一认识和掌握Visual Studio多种实用的调试方法和技巧,帮助大家去高效地解决开发过程中遇到的多种难题。
我们在调试程序的过程中,当遇到变量的值为0xCCCCCCCC、0xCDCDCDCD和0xFEEEFEEE等特殊的异常值时,我们应立即察觉可能是程序出问题了。常见的异常值如下所示:
这些值是微软调试器故意在某些场景下设置的特殊值,便于程序员快速地察觉到可能存在的问题。其中,0xCCCCCCC是微软调试器在Debug下填充到未初始化的栈内存中的值,当字符串看就是 “烫烫烫烫……”;0xCDCDCDCD是微软调试器在Debug下填充到未初始化的堆内存中的值,当字符串看就是 “屯屯屯屯……”;0xFEEEFEEE是填充到已经释放的堆内存中的值。
所以遇到值为0xCCCCCCC或者0xCDCDCDCD的变量时,可能是这些变量没有初始化就被访问或使用了,这是非法的,所以大家平时要养成初始化变量的习惯。当遇到值为0xFEEEFEEE的变量时,有可能是该变量占用的内存已经被释放了,不能再使用了,再使用就会导致内存访问违例。
我们在Debug下调试代码的过程中,会时常遇到这样的报错弹框:
其实很简单,只要点击重试按钮,调试器就会中断下来,然后打开调用堆栈(call stack)窗口去查看此时的函数调用堆栈,通过函数调用堆栈就能大概看出是执行了什么操作引发了异常。
但有时光看函数调用堆栈,可能并不能看出问题所在,我们还可以尝试去看Output输出窗口中输出的信息,编译器和程序可能会将一些调试打印信息输出到Output窗口中,从打印信息中可能能找到线索。比如我们在使用duilib界面框架编写窗口的xml文件时,写了非法的xml节点:
从上图打印的信息可以看出,是duilib库在解析窗口的xml内容时产生了异常,duilib将有异常的xml节点附近的xml上下文打印到Output窗口中,通过这个打印我们就能找到xml中出问题的节点了。此问题是问题节点的开始符号“<”和结尾符“/>”不匹配导致的,具体地是我们在手动编写xml文件时手误写错导致的。
比如程序发生“stack overflow”线程栈溢出,有可能是函数递归调用触发的,也有可能是函数之间发生死循环调用触发的,也有可能是函数中的case语句分支过多导致的,只要发生了线程栈溢出的异常,程序就会直接闪退,调试器也会直接退出调试状态,此时无法查看异常时的详细信息,查看不到异常时的函数调用堆栈,更查看不到发生异常时的变量的值。
不过此时可以尝试到Output窗口中看看,看看能否找到一些线索,对于线程栈溢出时的闪退,会在Output窗口中输出“Stack overflow”异常提示信息,如下所示:
这样我们就知道发生线程栈溢出了。
那我们如何才能找到发生线程栈溢出时的函数调用堆栈呢?下面就轮到强大的Windows调试器windbg上场了,可以重新运行程序,然后将windbg附加到程序的进程上,复现线程栈溢出的问题,一旦问题复现,windbg会立即检测到该stack overflow的异常,中断下来,在windbg输入kn、kv或kp命令,就可以查看此时的函数调用堆栈了。查看到函数的调用堆栈,就知道是什么操作引发的异常了。线程栈溢出我们已经遇到过多次了,在这方面比较有经验。
我们可以在代码中调用Windows API函数OutputDebugString将打印日志输出到调试器的输出窗口中。VS中自带的TRACE宏,也可以将日志输出到调试窗口中,但TRACE宏只在Debug下才有用,Relase下是无效的。其实TRACE宏内部在Debug下也是调用API函数OutputDebugString的,在realse下该宏定义为空,TRACE宏的内部实现如下:
#define TRACE CONCRT_TRACE
// Enable tracing mechanisms
#if defined(_DEBUG) && defined(CONCRT_TRACING)
# define CONCRT_TRACE(...) ::Concurrency::details::_ConcRT_Trace(__VA_ARGS__)
#else
# define CONCRT_TRACE(...) ((void)0)
#endif
// Trace -- Used for tracing and debugging
void _ConcRT_Trace(
int trace_level,
const wchar_t * format,
...
)
{
InitializeUtilityRoutines();
// Check if tracing is disabled
if ((g_DesiredTraceLevel & trace_level) == 0) {
return;
}
wchar_t buffer[1024+1];
va_list args;
va_start(args, format);
ConcRT_FillBuffer(buffer, format, args);
va_end(args);
buffer[1024] = 0;
if (g_DebugOutFilePtr != NULL)
{
fwprintf(g_DebugOutFilePtr, buffer);
if (g_CommitFrequency > 0 && (g_TraceCount++ % g_CommitFrequency) == 0)
fflush(g_DebugOutFilePtr);
}
else
{
OutputDebugStringW(buffer);
}
}
系统API函数OutputDebugString无论是在Debug下还是在Release下都会生效,我们在使用VS调试时打印信息会输出到VS的输出窗口中,我们在使用windbg附加到进程上调试时打印信息会输出到Windbg的输出窗口中。
那OutputDebugString是如何将打印日志(字符串)输出到调试窗口中的呢?我们可以到reactos操作系统的开源代码中查看OutputDebugString的内部实现:(和Windows接口的内部实现基本一致的)
VOID WINAPI OutputDebugStringA(IN LPCSTR _OutputString)
{
_SEH2_TRY
{
ULONG_PTR a_nArgs[2];
a_nArgs[0] = (ULONG_PTR)(strlen(_OutputString) + 1);
a_nArgs[1] = (ULONG_PTR)_OutputString;
/* send the string to the user-mode debugger */
RaiseException(DBG_PRINTEXCEPTION_C, 0, 2, a_nArgs);
}
_SEH2_EXCEPT(EXCEPTION_EXECUTE_HANDLER)
{
// 此处代码省略
// ......
}
}
从上述代码可以看出,OutputDebugString内部调用了RaiseException接口产生了一个DBG_PRINTEXCEPTION_C异常(会产生了一个标准的EXCEPTION_RECORD异常结构),该异常会被发到内核的异常处理模块,然后由内核异常处理模块将该异常分发给用户态的调试器,调试器最终将打印日志显示到调试器窗口上。
比如我们使用的webrtc开源库会将运行日志输出到调试窗口中,比如我们在使用windbg附加到进程中调试时,在windbg的输出窗口中可以看到webrtc库输出的运行日之后。后来查看webrtc库的代码得知,webrtc库内部也是调用OutputDebugString将打印日志输出到调试器的输出窗口中的,代码如下:
有人可能会说,他们有完善的日志管理系统,不需要使用OutputDebugString函数。我们这个地方主要介绍这种输出日志的手段,大家可以根据自己的需要去选择。
有人经常会问,有没有系统API能判断当前内存地址是否可读可写,这样我们在读写内存之前可以使用这样的API函数判断一下。Windows之前是提供过IsBadReadPtr和IsBadWritePtr这样的API接口,但接口的返回值并不可信,而且这两个API函数现在已经废弃了,不用了。
MSDN上对该函数的说明:
Important:This function is obsolete(废弃的) and should not be used. Despite its name, it does not guarantee that the pointer is valid or that the memory pointed to is safe to use. .
有的第三方老的库可能还调用了这两个接口,在VS调试时可能会触发一个异常,但该异常不是致命的,可以直接跳过去的,程序可以继续详细运行。这种异常在使用VS或Windbg调试时都会引发中断,我们在日常工作中都遇到过。VS中只需要取消勾选“遇到此异常不再中断”即可跳过:
windbg动态调试时只需要输入并执行g命令即可跳过去:(有时可能要go多次)
所以,在用windbg附加调试运行的过程中,如果当前的中断是IsBadWritePtr、 IsBadReadPtr或者IsBadCodePtr接口触发的,那这个异常不是致命的,可以直接go跳过去,程序还能继续运行的。
VS中可以去指定想要中断的异常类型或者特殊异常,当发生这些异常时调试器就会中断下来,此时就可以去查看函数调用堆栈,去分析是什么原因触发的异常了。点击VS菜单栏中的Debug->Exceptions,弹出异常选项勾选对话框:
VS默认只勾选了一部分选项,你可以勾选你想用到的选项。
比如我们可以勾选c000008c Array bounds exceeded和c00000fd Stack overflow,这样VS在捕捉到这些异常时VS就会中断下来。上面讲到程序发生stack overflow异常时,程序会闪退,调试器也会直接退出调试状态,如果此处勾选了“c00000fd Stack overflow”选项,估计调试器会立即中断下来,这样我们就可以看到函数调用堆栈了,这个大家可以尝试一下。
VS中可以给断点设置过滤条件,满足条件后断点就会中断下来。添加条件断点的方法是,先添加一般断点,然后右键点击该断点,给断点设置条件,如下所示:
一般我们主要使用“条件”、“命中次数”和“筛选器”三种类型。可以设置命中断点的表达式条件,也可以设置断点的命中次数(包含大于、等于和小于),也可以给断点设置筛选器。
设置筛选器的界面如下:
在设置筛选条件时,可以设置线程id,这在多线程调试时比较有用。当然要事先打断点,将目标线程的id记录下来,然后再来设置过滤线程的条件。
但条件断点中设置条件表达式比较苛刻,只能设置简单的表达式,很多复杂点的表达式都不支持,设置后会提示不能设置这样的条件。个人觉得还是人为添加临时的if过滤条件代码最灵活(断点设置在if语句的body体中),我们平时经常使用这种方法,比如我们想在duilib库的CEditUI编辑框控件的DoEvent接口中添加断点进行调试,这个控件很多窗口都会使用,但我们只想调试某个窗口中的CEditUI控件对象,如果在DoEvent接口中直接打断点,会中断很多次,所以我们在该接口中人为添加if过滤条件的代码,通过控件名称(每个窗口中的控件名称是不同的)将目标对象过滤出来:
然后将断点设置在if语句的body中。这种添加临时if过滤条件代码的方式很灵活,可以设置各种复杂的过滤条件。我们在日常工作中主要还是使用临时添加if过滤条件的方式。
我们有时会遇到变量值无故被篡改,导致程序逻辑出现异常或产生崩溃,这类问题我们遇到好几次了。在最初排查问题时,搜索了操作变量的所有代码,仔细排查了都没问题,并添加了日志打印,运行程序后打印出来的日志显示也没问题,但变量还是被无缘无故地被篡改了!
这种情况一般都是内存越界导致的,内存越界有全局内存越界、栈内存越界和堆内存越界,直接排查代码是很难找到问题的。遇到这种情况,应该第一时间想到数据断点,第一时间请数据断点上场。
设置数据断点其实就是监控目标变量的内存,一旦内存被修改,就会命中该数据断点,调试器就会中断下来。设置数据断点是有技巧的,被监控的目标变量需要在被分配内存后才能被监控,一般在类的构造函数中先设置一般断点,命中断点后,目标监控变量就分配了内存,就可以通过表达式“&变量名”去设置数据断点了:(可以设置监控的内存长度)
一旦被监控的内存被修改,就会命中对应的数据断点,此时去查看函数调用堆栈就可以看到是什么代码篡改了目标变量的内存了。
这个地方需要留意一下,正常的赋值也是修改内存中的内容,也会命中数据断点,所以要将正常的赋值和内存越界篡改内存的情况区别开来。
很多时候直接调试源代码是最直接最有效的,除了使用VS直接调试代码之外,还支持将VS附加到目标进程上调试。这特别适用底层库(比如网络库、协议库、组件库等)的调试。
一般情况下,当程序发生崩溃时,我们会取来程序异常捕获模块捕获到的保存异常上下文信息的dump文件,先用windbg对异常进行分析,在排查不出问题时,如果问题好复现,我们会建议发生崩溃的dll模块的维护组尝试去使用VS手动附加调试一下,让他们去看看到底是什么问题。
附加调试之前,先将编译出来的dll库拷贝到exe程序的目录中,然后重新启动程序。再将打开待调试库代码的VS附加到目标进程上调试。如果要调试release版本(有的问题可能在debug下没问题,只在release下有问题,所以需要进行release版本的调试),则需要事先在工程属性中将优化关闭掉。
VS附加调试的入口:点击VS菜单栏中的“调试->附件到进程”,打开附加到进程的窗口,如下:
然后选择要附加到的目标进程,点击确定就可以了。
有人可能会问,直接将编译出来的dll库拷贝到目标exe程序的路径中,为啥能调试呢?其实本质上,函数变量符号及调试信息都是放置在pdb文件中的,拷贝过去的库文件中会默认写入其使用的pdb文件的绝对路径,不管你将dll库文件拷贝到本机的哪个位置,都能根据绝对路径找到pdb文件,都能加载到库的调试信息,所以都能进行调试的。如果手动将对应的pdb文件删除掉,就没法进行调试的。
这个附加调试一般在目标进程启动后去附加,但有时我们可能需要调试程序启动时的初始化代码,但等程序启动起来后,初始化的代码都执行完了,都没有机会调试了。这里有个技巧,可以在被调试进程的main函数中添加一个messagebox,先将目标进程阻塞住,等待VS附加到进程中,然后再点击messagebox中的OK按钮继续运行程序,这样就能调试初始化的代码了。
一般情况下,在分析软件异常时,我们会用windbg打开包含异常信息的dump文件或者将windbg附加到目标进程上去调试。关于如何使用windbg,可以参见之前写的这篇文章:
Windbg调试工具使用详解https://blog.csdn.net/chenlycly/article/details/120631007
软件是崩溃在某一条汇编指令上,windbg中可以看到这条崩溃的汇编指令,但有时很难通过C++代码看出为啥会导致崩溃,这时就需要去查看对应模块的二进制文件(dll或exe)的汇编代码的上下文,看看到底为啥会导致那句汇编代码崩溃了。
我们平时一般会使用反汇编工具IDA Pro去打开二进制文件,去查看二进制文件对应的汇编代码。关于如何使用IDA工具,可以参见之前写的这篇文章:
IDA反汇编工具使用详解https://blog.csdn.net/chenlycly/article/details/120635120
其实我们也可以在VS中查看C++代码对应的汇编代码,先在要查看汇编代码的C++代码处添加断点,当命中断点时,点击右键,在弹出的右键菜单中点击“转到反汇编”菜单项:
即可看到C++代码对应的汇编代码了,这对学习C++语句的汇编代码也是很有好处的。切换到汇编代码页面后,我们也可以直接调试汇编代码。
在查看汇编代码时,可以将C++代码与汇编代码一起对照看,从而搞清楚很多场景下的汇编代码是怎么编写的,这对我们走读汇编代码的上下文很有帮助。当我们遇到读不懂的汇编代码时,我们会在VS中写一些C++测试代码,然后这些C++代码的汇编代码是什么样子的,通过类比就能搞清楚了。
对于分析C++软件异常需要掌握哪些基本的汇编知识,可以查看我之前写的一篇文章:
分析C++软件异常需要掌握的汇编知识汇总(实战经验分享)https://blog.csdn.net/chenlycly/article/details/124758670
作为使用VS开发调试的开发人员,很有必要去掌握上述调试技巧的。在掌握这些调试手段与技巧之后,能让我们在排查问题时更加的高效。大家如果有什么好的调试方法和技巧,可以在评论区一起讨论。