DebugBreak 和 ASSERTs 始终可用,随处可用






4.88/5 (13投票s)
2005年4月5日
7分钟阅读

100164

589
演示了如何改进标准的 DebugBreak 和 ASSERT,使其始终能正常工作。
引言
您确定在调试器外部运行时,您的断言或 DebugBreak 始终都会生效吗?您编写了代码,在其中放置了 ASSERT
,编译它,然后通常在调试器下启动并进行测试。但由于种种原因,您并非总是在调试器下运行被测应用程序。它可能由您的 QA 团队测试,或者您正在测试由宿主应用程序加载的插件 DLL。无论哪种情况,如果您的条件未能满足,您都期望看到断言失败对话框,以便您可以轻松地进入调试器并定位问题。通常情况下,这是有效的。然而有时,在看到断言失败窗口后,您会尝试附加调试器,但它却不起作用……
让我们找出原因以及如何解决。我假设您使用的是 Visual Studio .NET 2003 调试器,并且熟悉 Win32 SEH(结构化异常处理)。有关 SEH 的信息,请阅读 Jeffrey Richter 的《Programming Applications for Microsoft Windows》或 Matt Pietrek 的文章《A Crash Course on the Depths of Win32 Structured Exception Handling》。
解决方案
实际上,问题比断言更广泛。为了让您能够立即调试代码,ASSERT
宏使用了断点软件异常。不幸的是,即使您硬编码了断点,也可能无法进入调试器。原因可能是异常处理。一种相当常见的情况是,调用您函数的代码可能看起来像这样:
// ...
__try
{
// calling your function
YourFunction ( );
}
__except ( EXCEPTION_EXECUTE_HANDLER )
{
// handle an exception raised in your function
}
// ...
此代码片段的开发者打算捕获您代码中可能引发的所有异常,而不让它们中断他自己的代码。由于断点只是另一个软件异常(代码为 0x80000003),它将被此“捕获所有”的异常处理程序处理,使您无法进入调试器。不幸的是,这种情况相当普遍。开发 ATL COM 进程外服务器时也会遇到这种情况。
将以下代码编译为控制台应用程序并在调试器外部运行(为方便起见,示例中省略了 `#include`):
void Foo()
{
// ....
DebugBreak();
//...
}
int _tmain(int argc, _TCHAR* argv[])
{
__try
{
Foo();
}
__except ( EXCEPTION_EXECUTE_HANDLER )
{
printf ( "In main handler\n" );
}
return 0;
}
在输出中,您应该看到:
Enter Foo
In main handler
这意味着我们的断点已被 main 函数中设置的 SEH 过滤器捕获,过滤器指示它可以处理所有异常,因此控制权传递到 `__except` 块,然后程序安全地结束。通常情况下,您会期望出现“应用程序错误”对话框,该对话框允许您终止程序,并且如果您安装了调试器,还可以进行调试。
那么,让我们尝试显示此对话框,以便有机会调试代码。
“应用程序错误”对话框是通过一个名为 `UnhandledExceptionFilter` 的标准 API 函数启动的。此函数用作 `kernel32.dll` 中内部例程 `BaseProcessStart` 中默认异常处理程序的异常过滤器。我在这里不作详细解释——有关更多信息,请查阅 MSDN 或最好阅读 Jeffrey Richter 的《Programming Applications for Microsoft Windows》。
现在,让我们使用此函数!将 `Foo` 重写如下:
void Foo()
{
__try
{
printf( "Enter Foo\n" );
DebugBreak();
printf( "Leave Foo\n" );
}
__except ( UnhandledExceptionFilter( GetExceptionInformation() ) )
{
printf( "In Foo handler" );
}
}
再次在命令窗口中编译并运行应用程序。现在会显示对话框,指出已达到断点,并提供“确定”以终止应用程序或“取消”以进行调试的选项(在不同的 Windows 版本上可能略有不同)。
好的,我先终止应用程序。在输出中我看到:
Enter Foo
In Foo handler
这是因为当您按下“确定”按钮时,`UnhandledExceptionFilter` 返回 `EXCEPTION_EXECUTE_HANDLER`,因此 `Foo` 中的异常处理程序获得控制权。再次运行应用程序,这次按下“取消”。Visual Studio 调试器启动,似乎要停在我们的断点处。然而,事实并非如此。相反,程序仅完成执行,调试器就终止了。原因是当您按下“取消”时,`UnhandledExceptionFilter` 会启动调试器,等待它附加到应用程序,然后返回 `EXCEPTION_CONTINUE_SEARCH`。这会强制系统查找下一个异常处理程序,因此 main 函数中的 `__except` 块再次获得控制权。如果您看一下输出,就可以确信这一点。它看起来像这样:
Enter Foo
In main handler
这里一个值得问的好问题是,为什么当另一个应用程序失败并且默认异常处理程序在 `BaseProcessStart` 中触发时,调试器会正确附加?答案是,默认异常处理程序是所有异常处理程序列表中的最后一个处理程序,没有更多的处理程序可以传递控制权。尽管 `UnhandledExceptionFilter` 返回 `EXCEPTION_CONTINUE_SEARCH`,但没有东西可供搜索。因此,系统假定已附加了调试器,并尝试重新执行导致错误的(fault-causing)代码,这一次您的断点被调试器作为第一次机会异常捕获。
好的,我们得到了标准失败窗口,它通知我们关于断点,但我们仍然无法进入调试器。解决方案似乎非常明显:编写一个 `UnhandledExceptionFilter` 的包装器,当 `UnhandledExceptionFilter` 返回 `EXCEPTION_CONTINUE_SEARCH` 时,它返回 `EXCEPTION_CONTINUE_EXECUTION`。这将使系统重新执行导致错误的指令。包装器函数可以像这样:
LONG New_UnhandledExceptionFilter( PEXCEPTION_POINTERS pEp )
{
LONG lResult = UnhandledExceptionFilter ( pEp );
if (EXCEPTION_CONTINUE_SEARCH == lResult)
lResult = EXCEPTION_CONTINUE_EXECUTION;
return lResult;
}
void Foo()
{
__try
{
printf( "Enter Foo\n" );
DebugBreak( );
printf( "Leave Foo\n" );
}
__except ( New_UnhandledExceptionFilter( GetExceptionInformation() ) )
{
printf( "In Foo handler" );
}
}
重新生成并再次在调试器外部运行应用程序。这次,您将成功进入调试器,它将停在 `DebugBreak` 所在的行。很好!我们的断点正常工作了。
但是,我们如何修改 `ASSERT` 宏,使其与这个 *真正始终* 进行 debugbreak 的 `DebugBreak` 一起工作呢?我们可以编写自己的函数,设置 `__try`/`__except` 块并使用 `New_UnhandledExceptionFilter`,然后使用 DCRT 中的 `_CrtDbgReport` 等来断言。是的,所有这些都是可能的,但肯定很难看!我们需要一些漂亮的东西。好吧,忘掉 `New_UnhandledExceptionFilter`。我们真正需要的是一个新函数——让我命名它——`DebugBreakAnyway` :)。
在我弄清楚之前,我稍微玩了一下代码,最后得到了一个我称之为 `PreDebugBreakAnyway` 的辅助函数和一个 `DebugBreakAnyway` 宏,您可以在代码中使用它。首先,我想展示这个宏:
#define DebugBreakAnyway() \
__asm { call dword ptr[PreDebugBreakAnyway] } \
__asm { int 3h } \
__asm { nop }
正如您所见,第一步是调用 `PreDebugBreakAnyway` 函数。然后 `int 3h` 在 x86 处理器上引发断点异常。我为什么用汇编来写它?嗯,我花了足够多的时间玩代码,并试图保存 `EAX` 寄存器,而……不管怎样,现在都不重要了。这是在一切之后剩下的。当然,它在各种 CPU 上不可移植,所以您可以这样定义它:
#define DebugBreakAnyway() \
PreDebugBreakAnyway(); \
DebugBreak();
就这样。现在它是可移植的。
让我向您展示 `PreDebugBreakAnyway`。
void __stdcall PreDebugBreakAnyway()
{
if (IsDebuggerPresent())
{
// We're running under the debugger.
// There's no need to call the inner DebugBreak
// placed in the two __try/__catch blocks below,
// because the outer DebugBreak will
// force a first-chance exception handled in the debugger.
return;
}
__try
{
__try
{
DebugBreak();
}
__except ( UnhandledExceptionFilter(GetExceptionInformation()) )
{
// You can place the ExitProcess here to emulate work
// of the __except block from BaseStartProcess
// ExitProcess( 0 );
}
}
__except ( EXCEPTION_EXECUTE_HANDLER )
{
// We'll get here if the user has pushed Cancel (Debug).
// The debugger is already attached to our process.
// Return to let the outer DebugBreak be called.
}
}
虽然我在其中添加了一些注释,但让我们检查一下它的作用:
- 检查进程是否已附加调试器(此时 `IsDebuggerPresent` 非常有用)。如果是,则函数直接返回,允许随后调用 `DebugBreak`。因此,如果您在调试器下运行,对您来说就像遇到了一个普通的断点。需要注意的是,`IsDebuggerPresent` 仅在 Windows NT/2000/XP/2003 下工作。因此,如果您需要在 Win9x 下使用它,请考虑这篇文章。
- 设置两个 `__try`/`__except` 块。内部块允许显示“应用程序错误”对话框,并询问用户是希望终止还是运行调试器。如果她想调试,`UnhandledExceptionFilter` 返回 `EXCEPTION_CONTINUE_SEARCH`,外部 `__except` 捕获断点异常,不再让其继续传递。然后控制权从 `PreDebugBreakAnyway` 返回,外部 `DebugBreak` 被已经附加的调试器作为第一次机会异常处理,您将直接停在源代码中放置 `DebugBreakAnyway` 的位置。`DebugBreakAnyway` 宏只是使其看起来像是断点直接发生在您的源文件中。
如果用户想终止发生错误的应用程序,将执行内部 `__except`,因此您可能希望在其中放置 `ExitProcess`,以避免应用程序继续运行。
将此 `DebugBreakAnyway` 放在您喜欢的任何地方,您的断点将始终有效。但我开始这篇文章时提到了断言。好吧,为了使您的断言同样出色地工作,您需要替换标准 `ASSERT` 宏中的 `DebugBreak`。如果您使用 DCRT,可以在 `stdafx.h` 中编写您的 `ASSERT` 宏:
#define MY_ASSERT(expr) \
do { if (!(expr) && \
(1 == _CrtDbgReport(_CRT_ASSERT, __FILE__, __LINE__, NULL, NULL))) \
DebugBreakAnyway(); } while (0)
#define ASSERT MY_ASSERT
总之,这取决于您以及您使用的断言。就我而言,我使用地球上最好的断言 ;-) — John Robbins 的 `SUPERASSERT` 和他的 `BugslayerUtil.dll`。因此,我个人对其进行了轻微修改,以使其与 `DebugBreakAnyway` 一起工作。
许可证
本文没有明确附加许可证,但可能包含文章文本或下载文件本身的使用条款。如有疑问,请通过下方的讨论区联系作者。作者可能使用的许可证列表可以在此处找到。