遍历调用堆栈






4.95/5 (134投票s)
本文档描述了(已文档化)遍历任何线程(自身、其他线程和远程线程)调用堆栈的方法。它包含了一个抽象层,因此调用应用程序无需了解其内部细节。
该项目现已在 GitHub 上维护 / https://github.com/JochenKalmbach/StackWalker
...
引言
在某些情况下,您需要显示当前线程的调用堆栈,或者您只对其他线程/进程的调用堆栈感兴趣。因此,我编写了这个项目。
该项目的目标如下:
- 生成调用堆栈的简单接口
- 基于 C++,允许重写多个方法
- 将实现细节(API)隐藏在类的接口之外
- 支持 x86、x64 和 IA64 架构
- 默认输出到调试器输出窗口(但可自定义)
- 支持用户提供的读内存函数
- 支持最广泛的开发 IDE(VC5-VC8)
- 最便携的调用堆栈遍历解决方案
背景
要遍历调用堆栈,有一个已文档化的接口:StackWalk64
。从 Win9x/W2K 开始,此接口位于 dbghelp.dll 库中(在 NT 上,它位于 imagehlp.dll 中)。但函数名称(StackWalk64
)自 W2K 开始发生变化(之前称为 StackWalk
(不带 64
))!本项目仅支持较新的 Xxx64 函数。如果您需要在旧系统上使用它,可以下载 NT/W9x 的可再发行组件。
最新版本的 dbghelp.dll 始终可以通过 Windows 调试工具下载。它还包含 symsrv.dll,可用于使用公共 Microsoft 符号服务器(可用于检索系统文件的调试信息;请参阅下文)。
Using the Code
该类的用法非常简单。例如,如果您想显示当前线程的调用堆栈,只需实例化一个 StackWalk
对象并调用 ShowCallstack
成员。
#include <windows.h>
#include "StackWalker.h"
void Func5() { StackWalker sw; sw.ShowCallstack(); }
void Func4() { Func5(); }
void Func3() { Func4(); }
void Func2() { Func3(); }
void Func1() { Func2(); }
int main()
{
Func1();
return 0;
}
这将在调试器输出窗口中产生以下输出:
[...] (output stripped)
d:\privat\Articles\stackwalker\stackwalker.cpp (736): StackWalker::ShowCallstack
d:\privat\Articles\stackwalker\main.cpp (4): Func5
d:\privat\Articles\stackwalker\main.cpp (5): Func4
d:\privat\Articles\stackwalker\main.cpp (6): Func3
d:\privat\Articles\stackwalker\main.cpp (7): Func2
d:\privat\Articles\stackwalker\main.cpp (8): Func1
d:\privat\Articles\stackwalker\main.cpp (13): main
f:\vs70builds\3077\vc\crtbld\crt\src\crt0.c (259): mainCRTStartup
77E614C7 (kernel32): (filename not available): _BaseProcessStart@4
您现在可以双击一行,IDE 将自动跳转到所需的行/文件。
提供自定义输出机制
如果您想将输出定向到文件或使用其他输出机制,只需从 StackWalker
类派生即可。您有两种选择:仅重写 OnOutput
方法或重写每个 OnXxx
函数。第一种解决方案(OnOutput
)非常简单,并使用其他 OnXxx
函数的默认实现(在大多数情况下应该足够了)。要同时输出到控制台,您需要执行以下操作:
class MyStackWalker : public StackWalker
{
public:
MyStackWalker() : StackWalker() {}
protected:
virtual void OnOutput(LPCSTR szText)
{ printf(szText); StackWalker::OnOutput(szText); }
};
检索详细的调用堆栈信息
如果您想要有关调用堆栈的详细信息(例如加载的模块、地址、错误等),可以重写相应的方法。提供了以下方法:
class StackWalker
{
protected:
virtual void OnSymInit(LPCSTR szSearchPath, DWORD symOptions, LPCSTR szUserName);
virtual void OnLoadModule(LPCSTR img, LPCSTR mod, DWORD64 baseAddr, DWORD size,
DWORD result, LPCSTR symType, LPCSTR pdbName, ULONGLONG fileVersion);
virtual void OnCallstackEntry(CallstackEntryType eType, CallstackEntry &entry);
virtual void OnDbgHelpErr(LPCSTR szFuncName, DWORD gle, DWORD64 addr);
};
在生成调用堆栈期间会调用这些方法。
各种类型的调用堆栈
在类的构造函数中,您需要指定是要为当前进程还是为另一个进程生成调用堆栈。可用的构造函数如下:
class StackWalker
{
public:
StackWalker(
int options = OptionsAll,
LPCSTR szSymPath = NULL,
DWORD dwProcessId = GetCurrentProcessId(),
HANDLE hProcess = GetCurrentProcess()
);
// Just for other processes with
// default-values for options and symPath
StackWalker(
DWORD dwProcessId,
HANDLE hProcess
);
};
要执行实际的堆栈遍历,您需要调用以下函数:
class StackWalker
{
public:
BOOL ShowCallstack(
HANDLE hThread = GetCurrentThread(),
CONTEXT *context = NULL,
PReadProcessMemoryRoutine readMemoryFunction = NULL,
LPVOID pUserData = NULL
);
};
显示异常的调用堆栈
使用此 StackWalker
,您还可以在异常处理程序中显示调用堆栈。您只需要编写一个执行堆栈遍历的过滤器函数。
// The exception filter function:
LONG WINAPI ExpFilter(EXCEPTION_POINTERS* pExp, DWORD dwExpCode)
{
StackWalker sw;
sw.ShowCallstack(GetCurrentThread(), pExp->ContextRecord);
return EXCEPTION_EXECUTE_HANDLER;
}
// This is how to catch an exception:
__try
{
// do some ugly stuff...
}
__except (ExpFilter(GetExceptionInformation(), GetExceptionCode()))
{
}
关注点
上下文和调用堆栈
要遍历给定线程的调用堆栈,您至少需要两个事实:
- 线程的上下文
上下文用于检索当前的指令指针以及堆栈指针 (SP) 的值,有时还包括帧指针 (FP)。SP 和 FP 之间的区别简而言之:SP 指向堆栈上的最新地址。FP 用于引用函数的参数。另请参阅堆栈指针和帧指针的区别。但对于处理器来说,只有 SP 是必不可少的。FP 仅由编译器使用。您也可以禁用 FP 的使用(参见:/Oy(省略帧指针))。
- 调用堆栈
调用堆栈是一个内存区域,其中包含所有调用者的数据/地址。这些数据必须用于检索调用堆栈(顾名思义)。最重要的一点是:此数据区域在堆栈遍历完成之前不得更改!这也是必须将线程置于
挂起
状态才能检索有效调用堆栈的原因。如果您想遍历当前线程的堆栈,那么在上下文记录中声明的地址之后,您不得更改调用堆栈内存。
初始化 STACKFRAME64
结构
为了成功使用 StackWalk64
遍历调用堆栈,您需要使用有意义的值初始化 STACKFRAME64
结构。在 StackWalk64
的文档中,只有关于此结构的小提示:
- 如果传递到
StackFrame
参数的STACKFRAME64
结构中的AddrPC
和AddrFrame
成员未初始化,则此函数将失败。
根据此文档,大多数程序仅初始化 AddrPC
和 AddrFrame
;这在最新的 dbhhelp.dll(v5.6.3.7)之前一直有效。现在,您还需要初始化 AddrStack
。在遇到此问题(以及其他问题)后,我与 dbghelp-team
进行了交谈,并得到了以下答复(2005-08-02;我的评论用斜体标出!):
AddrStack
应始终设置为所有平台的堆栈指针值。您当然可以发布AddrStack
应该被设置。您也欢迎说明dbghelp
的新版本现在需要此项。- 给定一个当前的
dbghelp
,您的代码应该:- 始终使用
StackWalk64
。 - 始终将
AddrPC
设置为当前指令指针(x86 上为Eip
,x64 上为Rip
,IA64 上为StIIP
)。 - 始终将
AddrStack
设置为当前堆栈指针(x86 上为Esp
,x64 上为Rsp
,IA64 上为IntSp
)。 - 当有意义时,将
AddrFrame
设置为当前的帧指针。在 x86 上是Ebp
,在 x64 上您可以使用Rbp
(但 VC2005B2 不使用它;而是使用Rdi
!),在 IA64 上您可以使用RsBSP
。当StackWalk64
不需要它进行展开时,它将忽略该值。 - 将
AddrBStore
设置为 IA64 的RsBSP
。
- 始终使用
遍历当前线程的调用堆栈
在 XP 之前的 x86 系统上,没有直接支持的函数来检索当前线程的上下文。推荐的方法是抛出异常并捕获它。现在您将拥有一个有效的上下文记录。捕获当前线程上下文的默认方法是执行一些内联汇编来检索 EIP
、ESP
和 EBP
。如果您想使用已文档化的方法,则需要为项目定义 CURRENT_THREAD_VIA_EXCEPTION
。但您应该意识到 GET_CURRENT_CONTEXT
是一个宏,它内部使用 __try __except
。您的函数必须能够包含这些语句。
从 XP 开始,以及在 x64 和 IA64 系统上,有一个已文档化的函数来检索当前线程的上下文:RtlCaptureContext
。
要遍历当前线程的堆栈,您只需执行以下操作:
StackWalker sw;
sw.ShowCallstack();
遍历同一进程中其他线程的调用堆栈
要遍历同一进程中另一个线程的调用堆栈,您需要挂起目标线程(这样调用堆栈在堆栈遍历期间不会改变)。但您应该意识到,在同一进程中挂起线程可能会导致死锁!(请参阅:为什么您永远不应该调用 Suspend/TerminateThread(第一部分、第二部分、第三部分))
如果您拥有线程的句柄,可以执行以下操作来检索调用堆栈:
MyStackWalker sw;
sw.ShowCallstack(hThread);
有关检索另一个线程调用堆栈的完整示例,请查看演示项目。
遍历其他进程中其他线程的调用堆栈
方法与遍历当前进程调用堆栈的方法几乎相同。您只需要提供 ProcessID
和进程句柄(hProcess
)。然后,您还需要挂起线程以执行堆栈遍历。有关检索另一个进程调用堆栈的完整示例,请参见演示项目。
重用 StackWalk 实例
只要您要遍历同一进程的堆栈,重用 StackWalk
实例就没有问题。如果您要执行大量堆栈遍历,建议重用实例。原因很简单:如果您创建一个新实例,则必须为每个实例重新加载符号文件。这非常耗时。此外,不允许从不同线程访问 StackWalk
函数(dbghelp.dll不是线程安全的!)。因此,创建一个以上实例没有意义...
符号搜索路径
默认情况下,会向 dbghelp.dll 提供符号搜索路径(SymBuildPath
和 SymUseSymSrv
)。此路径包含以下目录:
- 可选提供的
szSymPath
。如果提供了此参数,则自动设置SymBuildPath
选项。每个路径都必须用“;
”分隔。 - 当前目录
- EXE 的目录
- 环境变量
_NT_SYMBOL_PATH
- 环境变量
_NT_ALTERNATE_SYMBOL_PATH
- 环境变量
SYSTEMROOT
- 环境变量
SYSTEMROOT
加上“\system32” - 公共 Microsoft 符号服务器:SRV*%SYSTEMDRIVE%\websymbols*http://msdl.microsoft.com/download/symbols
符号服务器
如果您想使用 Microsoft 符号服务器提供的操作系统文件的公共符号,您要么需要 Windows 调试工具(这样 symsrv.dll 和最新的 dbghelp.dll 将自动找到),要么需要重新分发此包中的“dbghelp.dll”和“smysrv.dll”!
加载模块和符号
为了成功遍历线程的调用堆栈,dbghelp.dll 要求库能够识别模块。因此,您需要通过SymLoadModule64
“注册”进程的每个模块。要完成此操作,您需要枚举给定进程的模块。
从 Win9x 和 W2K 开始,可以使用 ToolHelp32 API。您需要创建进程的快照 (CreateToolhelp32Snapshot
),然后可以通过Module32First
和Module32Next
枚举模块。通常,ToolHelp
函数位于 kernel32.dll 中,但在 Win9x 上,它位于一个单独的 DLL:tlhelp32.dll 中。因此,我们需要检查这两个 DLL 中的函数。
如果您有 NT4,则 ToolHelp32 API 不可用。但在 NT4 中,您可以使用 PSAPI。要枚举所有模块,您需要调用EnumProcessModules
,但您只会获得模块的句柄。要向SymLoadModule64
提供信息,您还需要查询 ModuleBaseAddr
、SizeOfImage
(通过GetModuleInformation
)、ModuleBaseName
(通过GetModuleBaseName
)和 ModuleFileName(Path)
(通过GetModuleFileNameEx
)。
dbghelp.dll
dbghelp.dll 有几个问题。
- 第一个是,微软有两个“团队”重新分发 dbghelp.dll。一个团队是操作系统团队,另一个是调试工具团队(我不知道实际名称……)。总的来说,您可以说:随 Windows 调试工具提供的 dbghelp.dll 是最新版本。这两个团队之间的一个问题是 dbghelp.dll 的版本不同。例如,对于 XP-SP1,版本是5.1.2600.1106,日期是2002-08-29。从调试团队重新分发的版本6.0.0017.0 的日期是2002-04-31。因此,至少存在日期上的冲突(更新的版本反而更旧)。而且更难判断哪个版本“更好”(或功能更多)。
- 从 Me/W2K 开始,system32 中的 dbghelp.dll 文件受到系统文件保护的保护。所以如果您想使用更新的 dbghelp.dll,您需要重新分发 Windows 调试工具中的版本(将其放在与您的 EXE 相同的目录中)。这会在 W2K 上导致问题,如果您想遍历使用 VC7 或更高版本构建的应用程序的调用堆栈。VC7 编译器生成一种新的 PDB 格式(称为 DIA)。这种 PDB 格式无法被操作系统自带的 dbghelp.dll 读取。因此,您将无法获得非常有用的调用堆栈(或至少没有调试信息,如文件名、行号、函数名……)。要解决此问题,您需要重新分发更新的 dbghelp.dll。
- dbghelp.dll 版本6.5.3.7 有一个
StackWalk64
函数的错误或至少是文档更改。在文档中,您可以读到:如果传递到
StackFrame
参数的STACKFRAME64
结构中的AddrPC
和AddrFrame
成员未初始化,则此函数将失败。和
[
ContextRecord
] 参数仅在MachineType
参数不是IMAGE_FILE_MACHINE_I386
时才需要。但这不再是真的了。现在,如果您将
ContextRecord
传递为NULL
,则无法在 x86 系统上检索调用堆栈。在我看来,这是一个重大的文档更改。现在,您要么需要初始化AddrStack
,要么提供一个有效的ContextRecord
,其中包含EIP
、EBP
和ESP
寄存器! - 另请参阅Initializing the STACKFRAME64-structure章节中的注释……
选项
要对行为进行某种修改,您可以选择指定一些选项。以下是可用选项的列表:
typedef enum StackWalkOptions
{
// No addition info will be retrieved
// (only the address is available)
RetrieveNone = 0,
// Try to get the symbol-name
RetrieveSymbol = 1,
// Try to get the line for this symbol
RetrieveLine = 2,
// Try to retrieve the module-infos
RetrieveModuleInfo = 4,
// Also retrieve the version for the DLL/EXE
RetrieveFileVersion = 8,
// Contains all the above
RetrieveVerbose = 0xF,
// Generate a "good" symbol-search-path
SymBuildPath = 0x10,
// Also use the public Microsoft-Symbol-Server
SymUseSymSrv = 0x20,
// Contains all the above "Sym"-options
SymAll = 0x30,
// Contains all options (default)
OptionsAll = 0x3F
} StackWalkOptions;
已知问题
- NT/Win9x:本项目仅支持
StackWalk64
函数。如果您需要在 NT4/Win9x 上使用它,您需要重新分发该平台的 dbghelp.dll。 - 目前仅支持回调中的 ANSI 名称(当然,该项目可以与 UNICODE 编译……)。
- 要打开远程线程,我使用了“
OpenThread
”,该函数在 NT4/W9x 上不可用。有关在 NT4/W9x 中执行此操作的示例,请参阅远程库。 - 遍历混合模式调用堆栈(托管/非托管)仅返回非托管函数。
历史
- 2005-07-27
- 首次公开发布
- 支持 x86、x64 和 IA64。
- 2005-07-28
- 更改了描述,使其不至于给人留下这是遍历调用堆栈的唯一已文档化方法的印象……
ShowCallstack(hThread, ...)
现在接受一个NULL
CONTEXT
(这会强制捕获该线程的上下文);它也被简化了(现在所有情况只有一个函数)。- 添加了章节:Symbol-Search-Path、Loading the modules and symbols 和 dbghelp.dll。
- 2005-08-01
- 添加了 VC7.0 项目/解决方案文件。
- 为
GET_CURRENT_CONTEXT
定义添加了更多注释,关于使用RtlCaptureContext
函数。 - 解决了未初始化的
cnt
变量问题。 - 解决了
GetFileVersionInfoSize
和GetFileVersionInfo
的错误定义(在旧 PSDK 版本(VC7 及更早版本)中,第一个参数声明为LPTSTR
而不是LPCTSTR
)。 - 将使用的
ContextFlags
参数从CONTEX_ALL
更改为CONTEXT_FULL
(这也正常工作,并且在旧 PSDK 版本中受支持)。 - 现在可以在没有安装更新的 Platform-SDK 的情况下使用 VC5/6 进行编译(所有缺失的声明现在都已嵌入)。
- 添加了 VC6 项目/解决方案文件。
- 向
ShowCallstack
函数和PReadProcessMemoryRoutine
声明添加了一个pUserData
成员(用于传递一些用户定义的数据,这些数据可以在readMemoryFunction
回调中使用)。
- 2005-08-02
OnSymInit
现在默认也输出 OS 版本。- 在 main.cpp 中添加了执行异常调用堆栈遍历的示例(感谢owillebo)。
- 纠正了关于
RtlCaptureContext
的文章。此函数从 XP 开始也可用(感谢 Dan Moulding)。 - 添加了章节:Initializing the STACKFRAME64-structure、Displaying the callstack of an exception。
- 2005-08-05
- 删除了 Lint 的一些问题……感谢 Okko Willeboordse!
- 消除了 VC8 的 /analyze 开关的所有警告。
- 2005-09-06
- 修复了 VC5/6 和旧 PSDK 版本的一个小 bug(
OSVERSIONINFOEX
不存在)。
- 修复了 VC5/6 和旧 PSDK 版本的一个小 bug(
- 2005-11-07
- 检测到“无限循环调用堆栈”并终止堆栈遍历。
- 仅在 x64 和 IA64 平台上使用
RtlCaptureContext
。