嵌入式应用程序的后验 C++ 异常分析






4.96/5 (12投票s)
一篇关于分析程序异常或软件崩溃的文章。
引言
任何规模合理的计算机程序都可能包含 bug 或由于意外的用户输入而产生不可预测的行为。最令人恼火的事情莫过于你的程序崩溃(例如,访问冲突、除以零等)或者在失控时(在客户那里,没有调试器可用)抛出意外的(未测试的)异常。接下来的事情自然就是,你需要找到问题的根源。
在大多数基于 Windows 的桌面应用程序中,如果异常没有在代码中被捕获,操作系统会捕获异常并弹出一个漂亮的对话框,告诉你出现了一个问题,并且可以通知微软。如果你设置正确,你就可以获取一个转储堆栈进行事后分析。
有时候,出于各种原因,人们会实现自己的“捕获和记录”机制来追踪问题。我就是这样做的,接下来我将解释。
背景
我编写嵌入式应用程序的软件,通常没有直接的用户交互。也就是说,没有屏幕、鼠标或键盘,这就是嵌入式软件。在这些情况下,你不希望调用代码在屏幕上显示需要用户按按钮的消息。我将向你展示我为这些情况开发的一种机制,它可以帮助你捕获、跟踪和分析问题。
示例代码特别针对 Microsoft C++ 编译器和 x86 CPU 架构。示例代码在桌面 Windows 和 Windows Embedded CE 上都能运行。Windows Mobile 基于 Windows Embedded CE,但由于 Windows Mobile 主要面向 ARM 架构,所以源代码示例不适用于那里。不过,通过适当的 ARM 编译器知识,这里解释的相同机制是可以使用的。
你可能还会想知道为什么我没有使用 Debug Help API。嗯,它们不适用于 Windows Embedded CE...
问题所在
我希望对可能发生的任何异常拥有完全的控制权。因此,我编写的每个线程都在最高级别上受到 try { } catch ()
块的保护,该块需要捕获任何异常。
DWORD WINAPI ThreadProc(LPVOID lpParameter) { try { ... } catch (std::exception& e) { } catch (...) { } }
- 在 C++ 中,你可以使用
try { } catch ()
语句来捕获你的代码throw
的异常。但是,默认情况下,它不会捕获结构化异常处理 (SEH) - 或者换句话说,特定于 Windows 操作系统的异常,如访问冲突或除以零。 - 此外,在你
throw
自己的异常而没有仔细设计派生自std::exception
的类,或者使用第三方库的情况下,如果你没有连接调试器,你就不知道异常发生在代码的哪个位置。如果至少能追踪从异常发生到你捕获它并将其保存到日志文件时的调用堆栈,那将非常有帮助。
解决方案
- 对于第一个问题,Microsoft 编译器可以帮助我们。虽然大多数程序员都不喜欢,但我指定 Microsoft C++ 编译器 /EHa 选项而不是默认的 /EHsc。也就是说,SEH 异常会被转换/转化为标准 C++ 异常。用 C++ 的术语来说,这意味着你可以使用
try { } catch (...)
语句来捕获所有异常,无论是 SEH 还是标准的 C++ 异常。它还消除了对非 C++ 标准__try { } __except() { }
或__try { } __finally { }
语句的需要。此外,如果你谨慎使用_set_se_translator()
API,你甚至可以生成更有意义的基于std::exception()
的异常。不过,我必须指出,混合使用以 /EHa 或 /EHsc 编译的模块(库/DLL)会导致意外行为。所以,一定要确保只使用其中一种机制,而不是同时使用两种。
- 第二个问题的解决方案更复杂。如果你
throw
自己的std::exception
,你总是可以提供一个有意义的描述,说明异常发生的原因以及在代码的哪个位置发生(使用__LINE__
和__FILE__
等预定义宏)。但是,如果你无法控制,或者异常描述是“发生了一个愚蠢的错误”?或者,如果你用catch (...)
语句捕获异常怎么办?接下来的解释将为你提供一个机制,用于追溯到异常发生的代码点。
追溯调用堆栈
虽然 try { } catch()
语句及其相应的行为(如自动堆栈展开和对象销毁)是标准的 C++ 机制,但 C++ 标准并未指定编译器应如何实现此机制。因此,实现是编译器特定的。以下是 Microsoft 特有的,并且仅用 Visual Studio 2008 测试过,尽管很可能对旧版本也适用。
示例代码实现了四个类:C1
、C2
、C3
和 C4
,每个类实现一个方法。C4::Test4()
调用 C3::Test3()
,它调用 C2::Test2()
,它调用 C1::Test1()
。C1::Test1()
会导致访问冲突
char* c = NULL;
*c = 'A';
或 std::exception()
throw std::exception("It failed");
(取决于你在源代码中 define
的内容)。在顶层,C4::Test4()
将使用 AnalyzeCallStack()
捕获任何异常
long C4::Test4(double f4)
{
try
{
...
C3 c3;
data += c3.Test3();
...
}
catch (...)
{
BYTE* pStack;
BYTE* pFrame;
__asm
{
mov pStack, esp;
mov pFrame, ebp;
}
AnalyzeCallStack(pFrame, pStack);
}
}
定义为
void AnalyzeCallStack(const BYTE* pFrame, const BYTE* pStack) { std::list<unsigned long> frames = findFrames((unsigned long)pFrame, (unsigned long)pStack); std::list<unsigned long> codeAddresses = findCodeAddress(frames); std::list<MODULEENTRY32> modulesList = GetModulesList(); Output(codeAddresses, modulesList); }
循序渐进
我们将一步一步解释 catch(...)
块内部发生的事情
- 首先,我们需要了解 Microsoft 编译器如何实现堆栈展开机制。秘密在 Slava Ok 的博客中揭示。当发生异常时,EBP CPU 寄存器将被调整到一个包含
catch
块的帧,但 ESP CPU 寄存器不会被触及。因此,进入
catch
块后的第一件事就是存储 EBP 和 ESP 寄存器BYTE* pStack; BYTE* pFrame; __asm { mov pStack, esp; mov pFrame, ebp; }
这已经为我们提供了要搜索调用堆栈信息堆栈区域的想法。
- 接下来,如果我们看看 C++ 函数调用的汇编代码,我们在第一个汇编指令中看到编译器发出以下汇编代码(也称为开始一个新的堆栈帧)
call func: // pushes address of 'retad:' on the stack retad: ... func: push ebp // pushes 'ebp' on the stack move ebp, esp
所有函数参数和局部变量现在都相对于 EBP 访问。我们还应该知道,汇编器
call
指令总是将返回地址隐式推入堆栈(由 ESP 指向)。总结一下,我们的堆栈布局如下
- 将所有这些知识结合起来,我们可以使用指向当前堆栈帧的 EBP 寄存器来遍历和记录当前调用堆栈。[ebp+4] 是调用者的地址,[[ebp]+4] 是调用者的调用者,[[[ebp]]+4] 是调用者的调用者的调用者,依此类推。
如果你仔细看我们之前说的话,我们可以通过获取堆栈上存储的帧指针(在 x86 架构上,堆栈总是从高地址向低地址增长)上方 4 字节(64 位 CPU 上是 8 字节)的值来找到包含
call
指令的地址。很酷,不是吗?我们已经从异常发生的那一刻起,一直追溯到
catch
语句的完整调用堆栈。std::list<unsigned long> findFrames(const unsigned long ebp, const unsigned long esp) { std::list<unsigned long> frames; frames.push_back(ebp); unsigned long toInvestigate = ebp - 4; while (toInvestigate >= esp) { unsigned long candidate = *reinterpret_cast<unsigned long*>(toInvestigate); std::list<unsigned long>::iterator iter = std::find(frames.begin(), frames.end(), candidate); if (iter != frames.end()) { frames.push_back(toInvestigate); toInvestigate -= 4; } else { --toInvestigate; } } return frames; }
- 但还有更多。如果我们能找到一种方法将原始地址指针值转换为有意义的 C++ 标签,这将使我们的调试任务更加容易。
在我们深入研究如何实现这一点之前,我需要更详细地解释一下 C++ 编译器是如何工作的。有时
call
指令后面会跟着一个jmp
指令,特别是当涉及类方法时。我们不关心call
指令的地址,而是关心jmp
指令的地址,因为后者与我们的 C++ 代码相关。这就是为什么你会看到代码同时搜索call
和jmp
指令。std::list<unsigned long> findCodeAddress(std::list<unsigned long> frames) { const unsigned char CALL = 0xE8; const unsigned char JMP = 0xE9; unsigned long callReturnAddress = 0; unsigned char firstInstruction = 0; long firstOffset = 0; unsigned long secondAddress = 0; unsigned char secondInstruction = 0; long secondOffset = 0; unsigned long codeAddress = 0; unsigned long n = 0; unsigned long data = 0; HANDLE process = GetCurrentProcess(); std::list<unsigned long> code_frames; std::list<unsigned long>::iterator iter; for (iter = frames.begin(); iter != frames.end(); ++iter) { if (ReadProcessMemory(process, (unsigned long*)(*iter + 4), &callReturnAddress, sizeof(unsigned long), &n) == FALSE) continue; if (callReturnAddress < 0x00010000) continue; if (callReturnAddress > 0x7FFFFFFF) continue; if (ReadProcessMemory(process, (unsigned char*)(callReturnAddress - 4 - 1), &firstInstruction, sizeof(unsigned char), &n) == FALSE) continue; if (ReadProcessMemory(process, ( long*)(callReturnAddress - 4 - 0), &firstOffset, sizeof( long), &n) == FALSE) continue; secondAddress = callReturnAddress + firstOffset; if (firstInstruction == CALL) { secondInstruction = *(unsigned char*)(secondAddress + 0); secondOffset = *( long*)(secondAddress + 1); if (secondInstruction == JMP) { codeAddress = secondAddress + secondOffset + 1 + 4; // JMP to '4byte address' } else // real CALL? { codeAddress = secondAddress; } } else if (firstInstruction == JMP) // No CALL, but JMP? (but what is callReturnAddress doing then on stack?) { // We come here at the first frame. It points // to where program should resume after the catch() codeAddress = secondAddress; } else { codeAddress = -1; } if (codeAddress != -1) { code_frames.push_back(codeAddress); } } return code_frames; }
- 最后但同样重要的是,我们将所有这些信息输出到一个名为 'dump.txt' 的日志文件中。请注意,我们使用特定的格式,以便以后更容易解析日志文件,用更有意义的符号名称来完成原始地址指针...我们还添加了一些关于程序启动时加载到内存中的所有模块(DLL)的信息。当我们将代码地址与 PDB 文件中存储的虚拟地址匹配时,我们需要这些信息。分配给 C++ 函数的每个虚拟地址都是相对于模块的基地址(DLL 在内存中的加载位置)的。
std::list<MODULEENTRY32> modulesList = GetModulesList(); Output(codeAddresses, modulesList);
PDB 文件和 DIA SDK
PDB 文件包含关于你的可执行代码(exe 或 DLL)的调试信息。调试器在调试会话期间需要的一切都存储在 PDB 文件中(符号名称、地址信息等)。由于 PDB 文件的格式和内容可能因编译器版本而异,Microsoft 创建了 Debug Information Access (DIA) SDK。使用此 SDK,可以通过统一的(且跨版本稳定的)接口迭代 PDB 文件信息。我不会详细介绍,你可以找到其他 CodeProject 文章来了解其用法。
DIA SDK 本身是一个 COM DLL,可以在 Visual Studio 安装中找到。默认情况下,它未注册,因此你需要自己注册。
regsvr32.exe "C:\Program Files\Microsoft Visual Studio 9.0\DIA SDK\bin\msdia90.dll"
我创建了一个 C# 控制台程序 - 名为 DiaTool(也包含在示例代码中)- 它需要
- 你的程序使用的 PDB 文件列表,存储在 DiaInput.txt 中
- 至少一个 'dump.txt' 文件进行分析
DiaTool 将尝试将所有报告的函数地址与不同 PDB 文件中存储的虚拟地址进行匹配。结果将保存在 'dump.txt.anl' 中。
当你编译自己的可执行文件时,为你创建的相应 PDB 文件将包含你程序的所有符号。但是,还有比你自己的代码更多的东西。C++ 程序将调用 CRT 运行时库。当 CRT 库被引用为 DLL 时,你也需要 CRT 运行时的 PDB 文件。此外,在异常期间,生成的代码将调用内核代码。因此,你需要 kernel32.dll 和 ntdll.dll 的 PDB 文件。如果你使用其他 DLL(COM、系统 DLL),你也需要相应的 DLL。你可以从 Microsoft 网站 这里 下载 Microsoft 特定的 PDB。或者,在 Visual Studio | Options | Debug 中指定以下信息,并在调试器附加的情况下运行你的程序一次。
当你调试程序时,在填入了符号文件位置后,Visual Studio 将自动从 Microsoft 符号服务器获取 PDB 文件,并将它们存储在 'D:\VisualStudioCache' 文件夹中。
使用示例
Callstack.exe
你可以运行几种不同风格的示例代码。你可以在 stdafx.h 中指定以下 define
CATCH_LOWER_LEVELS
:你可以选择在C1::Test1()
、C2::Test2()
和C3::Test3()
的较低级别包含try { } catch ()
语句,并体验其中的差异。如果有的话。DONT_SWALLOW
:如果定义了此项,异常将不会在C4::Test4()
的最高捕获级别被“吞没”;相反,它将被throw
到当前线程上下文之外。因此,操作系统或 Visual Studio 调试器将处理它。
这是输出到 'dump.txt' 的示例。
“CallStack”源代码包含比我在此描述的更多功能。我将留给你进一步探索和实验。
DiaTool.exe
DiaTool 接受一些命令行参数。运行 'DiaTool.exe /?' 会输出所有选项的列表,或者查看源代码。包含了一个 'DiaSample.bat' 文件来演示如何使用它。在处理完 'dump.txt' 后,它将输出 'dump.txt.anl',如下面的图片所示
'_RtlDispatchException@8' 是异常代码开始的地方,因此上面这行日志记录的就是异常发生的位置。这是根据 'callstacktest.exe : public: long __thiscall C1::Test1(void)' 中的示例。确实,这就是我们故意造成访问冲突的地方。
PDB 文件中甚至还有更多信息可供查找。请自行探索!
了解一下
根据你选择的编译器优化选项,编译器可能会消除帧块。当你为了速度进行编译时(通常在“Release”构建中),编译器可能会决定通过保留和使用 EBP 寄存器进行其他操作来节省指令。当你看到“丢失”的调用堆栈帧时,不要感到惊讶。它们并没有真正丢失,编译器只是对其进行了优化。有关更多信息,请参阅 '/Oy' 或帧指针省略编译器选项。另请参阅本文末尾的参考资料。
结论
我向你展示了可以从代码中发生异常的点重建 C++ 调用堆栈,直到它被你的代码捕获。事实上,它甚至可以在运行时替换调试器的一部分功能。
源代码是为 32 位桌面 Windows(XP 和 Vista)以及 32 位 Windows Embedded CE 6.0 编写和测试的。但是,通过一些修改,它可以编译并适用于 64 位代码。
我决定将 DIA 功能放入一个单独的 DiaTool 中,它需要在事后对转储信息运行,因为我将此代码用于在 Windows CE x86 硬件上运行的嵌入式 C++ 应用程序。因为 DIA COM DLL 在 Windows CE 下不运行。如果你只针对桌面 Win32 x86 应用程序,你可以将 DIA 功能直接集成到你的 C++ 分析代码中。这样就不需要事后单独的 DiaTool 来将 'dump.txt' 解析为 'dump.txt.anl'。
祝你追踪 bug 顺利!
参考文献
- The CodeProject - C++ Exceptions: Pros and Cons
- The CodeProject - Examining Function Calls using the Console Debugger
- The CodeProject - Symbols File Locator
- Reporting Crashes in IMVU: Who threw that C++ exception?
- Microsoft DIA SDK toolkit
- Microsoft DbgHelp API
- Microsoft C++ compiler optimization options
- Microsoft Windows _set_se_translator() API
- Intel Architecture Software Developer’s Manual Volume 2: Instruction Set Reference