线程与蚂蚁





5.00/5 (5投票s)
您的应用程序出现问题,并且计算机资源有限;此代码将帮助您找到问题,通过提供导致问题的函数调用序列。
引言
应用程序弹出窗口: ThreadsAndAnts.exe - 应用程序错误:指令在“0x74DD3E28”引用了内存“0x0000019c”中的内存。该内存无法“读取”。 |
ThreadsAndAnts.exe;嘿,是你的应用程序崩溃了!
但指令 0x74DD3E28 是什么?
经过调查,您发现这是第三方 DLL 中的一条指令,这对您来说毫无用处。您需要找出您的应用程序中的哪个代码正在调用此 DLL,并导致了异常——您需要异常的调用堆栈:导致此异常的函数调用序列。
您可以配置注册表以保存崩溃转储,或向您的应用程序添加代码以创建小型转储。但您需要DbgHelp.dll,而您的应用程序运行所在的嵌入式计算机上没有它。此外,即使您安装了它,您的代码也不支持下载转储文件;它只支持事件日志下载。
您可以使用StackWalk64 函数在事件日志中记录调用堆栈,但这同样需要 DbgHelp.dll,并且您的嵌入式计算机资源有限,因此您无法安装PDB(程序数据库)文件,而 DbgHelp.dll 需要这些文件才能提供调用堆栈的函数名称和偏移量;您必须在下载事件日志之后才能获取函数名称和偏移量。
经过进一步调查,您找到了一个不需要使用 DbgHelp.dll(获取 32 位应用程序的调用堆栈相对容易,无需它)或安装 PDB 文件的解决方案。
在崩溃之前,您的应用程序会记录以下消息:
应用程序“ThreadsAndAnts.exe”在地址“0x74DD3E28”处的指令在尝试“读取”地址“0x0000019c”处的内存时引发了异常;调用堆栈 |
74DD3E28 : KERNELBASE.dll! + 000B3E28 |
705249C5 : VCRUNTIME140.dll! + 000049C5 |
00E243F0 : ThreadsAndAnts.exe! + 000043F0 |
... |
这为您提供了 0x000043F0,即 0x74DD3E28 指令之前您应用程序中的最后一条指令,但它在哪个函数中?
您可以通过在应用程序的 MAP 文件中查找此地址来获取函数名称和偏移量,或者您可以创建一个 Excel 加载项为您完成此操作;读取您的应用程序的 MAP 文件并将 0x000043F0 替换为函数名称和偏移量。
应用程序“ThreadsAndAnts.exe”在地址“0x74DD3E28”处的指令在尝试“读取”地址“0x0000019c”处的内存时引发了异常;调用堆栈 |
74DD3E28 : KERNELBASE.dll! + 000B3E28 |
705249C5 : VCRUNTIME140.dll! + 000049C5 |
00E243F0 : ThreadsAndAnts.exe! ?OnThrowUnhandledExceptionButton@CThreadsAndAntsDlg@@IAEXXZ + 80 字节 |
... |
它将 0x000043F0 替换为 CThreadsAndAntsDlg
类、OnThrowUnhandledExceptionButton
函数,以及 80 字节的偏移量(从函数顶部开始)。
如果您无法确定该函数中的哪一行代码引发了异常,您可以尝试在此函数中的不同位置引发异常,例如,通过写入空指针:int* pi = NULL; *pi = 0; 直到您获得接近 80 字节的偏移量。它不会完全准确,因为 1 行代码可以包含多个指令,而且因为您正在添加指令来引发异常,但这将有助于您缩小范围。
您已找到导致异常的原因。
入门
- 将本文的源代码下载到您的计算机并解压缩。
- 让 Excel 每次启动时都加载“Call Stack.xlam”加载项,方法是选择“开发工具”>“Excel 加载项”>“浏览...”> 选择加载项 >“确定”> 勾选加载项 >“确定”。
如果您在 Excel 中看不到“开发工具”选项卡,可以通过选择“文件”>“选项”>“自定义功能区”> 勾选“开发工具”>“确定”来显示它。
如果 Excel 每次启动时都要求您启用加载项,您可以将其位置添加到受信任位置,方法是选择“文件”>“选项”>“信任中心”>“信任中心设置...”>“受信任位置”>“添加新位置...”> 选择位置 >“确定”>“确定”。
此加载项在您的“加载项”选项卡中添加了一个“调用堆栈”按钮,该按钮读取“ThreadsAndAnts.map”文件,并将函数名称和偏移量添加到您的日志文件中。
- 使用Visual Studio 2015或更高版本打开“ThreadsAndAnts.vcxproj”,然后进行构建。
或者,使用“ThreadsAndAnts”文件以及旧版本的 Visual Studio 或不同的编译器创建一个项目,然后进行构建。
或者,使用本文 zip 文件中提供的exe和map文件。
- 运行ThreadsAndAnts.exe。
- 将CallStack.cpp和*.h*以及HmoduleArray.cpp和*.h*添加到您的应用程序或您的应用程序使用的库中。
- 如果您的应用程序遇到问题,通过调用 `CCallStack` 类的 `GetCallStack` 函数之一来获取问题线程的调用堆栈,并将其记录下来。
例如,在应用程序启动时调用 `SetUnhandledExceptionFilter` 以在应用程序抛出未处理的异常时收到通知;有关示例,请参阅ThreadsAndAnts.cpp和*.h*。
如果您的所有应用程序在启动时都调用该库中的公共函数,您可以将 `SetUnhandledExceptionFilter` 和其他问题通知函数添加到库中,而无需将其添加到每个应用程序中。
- 为您的应用程序生成映射文件;如果您使用 Visual Studio,可以通过转到项目属性、链接器 > 调试 > 生成映射文件 = 是 来完成此操作。
发布应用程序的新版本时,请保存应用程序的映射文件。
并在事件日志中记录应用程序的版本,例如,在应用程序启动时或记录调用堆栈时,以便您知道要将哪个版本的映射文件与特定的事件日志或调用堆栈一起使用。
- 下载您的事件日志;假设它是一个CSV 文件,在 Excel 中打开它,将您的映射文件(或多个文件)复制到 CSV 文件的目录,然后单击“加载项”选项卡中的“调用堆栈”按钮;它会将映射文件(或多个文件)中的函数名称和偏移量添加到您的事件日志中。
Excel 加载项假定您的映射文件名(例如 ThreadsAndAnts.map)与您的 exe 文件名(例如 ThreadsAndAnts.exe)相同,并且您的日志消息文本在 Excel 的 C 列中。
您可以更改 Excel 加载项代码以满足您的特定需求,例如,如果您的日志消息文本不在 C 列中,您可以更改日志消息文本列常量 - MESSAGE_COLUMN。
要访问 Excel 加载项代码,请打开 Excel 并转到“开发工具”选项卡 >“Visual Basic”按钮 > VBAProject (Call Stack.xlam) > 模块 > Module1。
您还可以更改“调用堆栈”按钮的图像和工具提示文本。
一只蚂蚁,一只线程
……两颗疯狂跳动的心 可以说是一见钟情 Collin Raye |
想象你是一只蚂蚁。
你和你的蚁群正在收集食物——叶子——并将它们带回家。
但是一片叶子对你来说太大了,一个人无法搬动。
所以你把它切成 3 块较小的碎片。
你可以一次搬一块。
或者你可以找另外 2 只蚂蚁帮忙,更快地把所有碎片都搬回家。
一个线程就像你,也像另外 2 只正在帮助你的蚂蚁;你们在一起是 3 个工人,或者说 3 个线程。
只是对于线程来说,计算机先做一件事一段时间,然后放下它,做另一件事一段时间,依此类推。
如果在测试应用程序中单击“添加线程和蚂蚁”按钮,它将启动一个新的 `CAntThread` 线程,该线程包含一个 `CAntDlg` 对话框,该对话框会播放蚂蚁动画。
使用 C++ 和 MFC(Microsoft 基础类),您可以使用 `AfxBeginThread` 启动新线程。
//////////////////////////////////////////////////////////////////////////////
void CThreadsAndAntsDlg::OnAddThreadAndAntButton( void )
{
CAntThread* pAntThread = (CAntThread*) AfxBeginThread( RUNTIME_CLASS( CAntThread ) );
...
}
当您不再需要时,您可以使用 `TerminateThread` 来终止线程。
//////////////////////////////////////////////////////////////////////////////
CThreadsAndAntsDlg::~CThreadsAndAntsDlg( void )
{
...
// For all CAntThreads (i = 0 to N):
// Tell the thread to end, and wait for it to end.
TerminateThread( m_aAntThreads[i]->m_hThread, 0 );
...
WaitForSingleObject( m_aAntThreads[i]->m_hThread, INFINITE );
...
}
使用 C++ 和 STL(标准模板库),您可以使用 `std::thread` 启动新线程,并使用 `join` 来结束它。
您可以在应用程序中创建任意数量的线程(在合理范围内;您的计算机没有无限的资源)。
正念
正念的练习涉及从第一人称视角,时刻觉察到自己主观的意识体验。在练习正念时,一个人会意识到自己的“意识流”。 Wikipedia |
作为一只正念的蚂蚁,拥有出色的记忆力,你会意识到自己和周围的环境,而且不会迷路;你可以一直追溯脚步回到家。
你总是走安全的回家路,通过沿着到达你所在位置的确切反向路径前进。
这只小蚂蚁不会走黑暗森林里的捷径!
线程也是如此。
在其当前函数中,它知道
- 它所在的指令地址。
- 传递给函数的参数,以及
- 函数的局部变量值。
这些信息合在一起称为一个栈帧。
线程的调用堆栈包含每个函数的一个栈帧,这些函数是它已经访问过但尚未返回的。
如果一个函数调用第二个函数,那么线程会保留第一个函数的栈帧(它跳转的指令地址、传递给函数的参数以及函数的局部变量值)在线程的调用堆栈中,以便稍后返回到该函数并继续执行。
它会添加一个刚刚调用的函数的栈帧。
当前函数知道如何返回到调用它的函数,而那个函数又知道如何返回到调用它的函数,依此类推,直到线程的起始函数。
调用堆栈是一个链,一个函数调用(以及函数参数和局部变量值)的链接列表。
线程的上下文始终指向线程的当前栈帧,即它当前所在的函数的栈帧,这可以用来沿着栈帧的链接列表一直追溯到线程的起始栈帧。
使用本文的代码,您可以通过调用 `CCallStack` 类的 `GetCallStack` 函数之一来获取线程的调用堆栈;它们将调用堆栈作为字符串返回,然后您可以将其记录到事件日志中。
如果您单击“记录此应用程序的调用堆栈”按钮。
//////////////////////////////////////////////////////////////////////////////
// Called when the 'Log This App's Call Stacks' button is clicked.
void CThreadsAndAntsDlg::OnLogThisAppCallStacksButton( void )
{
...
// Get a handle to the currently executing thread.
HANDLE hThread = AfxGetThread()->m_hThread;
// Get the thread's call stack.
CCallStack cs;
CString sCallStack = cs.GetCallStack( hThread );
...
}
//////////////////////////////////////////////////////////////////////////////
// Returns the call stack of a thread within this app.
CString CCallStack::GetCallStack( HANDLE hThread )
{
m_hProcess = ::GetCurrentProcess();
// Duplicate hThread and save the result in m_hThread.
BOOL bDuplicateHandle = DuplicateHandle(
m_hProcess,
hThread,
m_hProcess,
&m_hThread,
0,
FALSE,
DUPLICATE_SAME_ACCESS );
if ( bDuplicateHandle == TRUE )
{
GetCallStack();
}
return m_sCallStack;
}
如果您单击“打开日志文件”按钮,等待 Excel 打开日志文件,然后转到 Excel 的“加载项”选项卡并单击“调用堆栈”按钮,以填充类、函数名称和偏移量,您将看到类似以下内容:
应用程序“ThreadsAndAnts.exe”主线程;调用堆栈 |
74DC8C62 : KERNELBASE.dll! + 000A8C62 |
... |
00192632 : ThreadsAndAnts.exe! ?OnLogThisAppCallStacksButton@CThreadsAndAntsDlg@@IAEXXZ + 226 字节 |
... |
00191138 : ThreadsAndAnts.exe! ?InitInstance@CThreadsAndAntsApp@@UAEHXZ + 136 字节 |
... |
00195DB8 : ThreadsAndAnts.exe! _wWinMainCRTStartup + 8 字节 |
... |
此调用堆栈告诉您什么?
- 在您的应用程序中调用的函数列表,从 `_wWinMainCRTStartup` 开始。
CThreadsAndAntsDlg::OnLogThisAppCallStacksButton
在其第 226 个(指令)字节处(从函数顶部开始)获取了此调用堆栈。
- 您的应用程序调用的 DLL 和 DLL 地址偏移量。
在某些情况下,您可能可以使用Dependency Walker打开 DLL 并查找这些地址偏移量处的函数名称。但是,如果不是您的 DLL,并且您没有其源代码,这可能没有帮助。
如果您单击“记录其他应用程序的调用堆栈”按钮,选择一个或多个进程,然后单击“确定”。
在此应用程序中,我们使用EnumProcesses来获取您计算机上所有正在运行的进程的进程 ID,并使用这些进程 ID 来获取进程及其线程的句柄。
//////////////////////////////////////////////////////////////////////////////
void CProcessListDlg::GetCallStacks( ..., DWORD dwProcessID )
{
...
CCallStack cs;
CString sCallStack;
CString sMessage;
HANDLE hThread = NULL;
HANDLE hSnapshot = NULL;
THREADENTRY32 te = {};
// Get a handle to the process.
HANDLE hProcess = OpenProcess( PROCESS_ALL_ACCESS, FALSE, dwProcessID );
if ( hProcess != NULL )
{
// Take a snapshot of the process' threads.
hSnapshot = CreateToolhelp32Snapshot( TH32CS_SNAPTHREAD, dwProcessID );
if ( hSnapshot != INVALID_HANDLE_VALUE )
{
te.dwSize = sizeof( THREADENTRY32 );
if ( Thread32First( hSnapshot, &te ) == TRUE )
{
do
{
// If the thread belongs to this process.
if ( te.th32OwnerProcessID == dwProcessID )
{
// Get a handle to the thread.
hThread = OpenThread( THREAD_ALL_ACCESS, FALSE, te.th32ThreadID );
if ( hThread != NULL )
{
sCallStack = cs.GetCallStack( hProcess, hThread );
...
CloseHandle( hThread );
}
}
} while ( Thread32Next( hSnapshot, &te ) == TRUE );
}
CloseHandle( hSnapshot );
}
CloseHandle( hProcess );
}
}
//////////////////////////////////////////////////////////////////////////////
// Returns the call stack of a thread within another app.
CString CCallStack::GetCallStack( HANDLE hProcess,
HANDLE hThread )
{
m_hProcess = hProcess;
m_hThread = hThread;
GetCallStack();
return m_sCallStack;
}
无论您是获取当前应用程序还是另一个应用程序中线程的调用堆栈,进程句柄都用于读取进程内存,而线程句柄用于获取线程的上下文。
但首先,需要暂停线程,以便在获取调用堆栈时它不会发生变化。
//////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////
void CCallStack::DoDumpStack( void )
{
// Pause the thread.
SuspendThread( m_hThread );
CONTEXT context = {};
memset( &context, 0, sizeof( context ) );
context.ContextFlags = CONTEXT_FULL;
// Get the thread's context.
GetThreadContext( m_hThread, &context );
// Get the thread's call stack.
DoDumpStack( &context );
// Resume the thread.
ResumeThread( m_hThread );
}
32 位应用程序
线程的上下文包含线程的当前栈帧;这是我们的起点。
在每个栈帧中,返回地址始终位于 EBP(基址指针)地址 + 4 字节处,而 EBP 地址的值始终包含前一个栈帧的 EBP地址。
#if defined _M_IX86
//////////////////////////////////////////////////////////////////////////////
// Gets the call stack of 32-bit apps.
void CCallStack::DoDumpStack32( const CONTEXT* pContext )
{
BOOL bReadProcessMemory = FALSE;
DWORD* pdwEbp = (DWORD*) pContext->Ebp;
DWORD dwPrevEbp = 0;
DWORD dwReturnAddress = 0;
SIZE_T stNumberOfBytesRead = 0;
...
// If we reach an EBP (Base Pointer) of NULL (0),
// this means that we've walked off the end of the call stack and are done.
for ( int iFrame = 0; ( iFrame < eMAX_FRAMES ) && ( pdwEbp != NULL ) ; iFrame++ )
{
// The return address will always be at EBP + 4 (bytes).
// '+ 1' is the same as adding 'sizeof( DWORD* )', which is 4 bytes.
// The ReadProcessMemory function allows us to read another app's memory,
// so we can get the call stacks of theads in other apps (on this computer).
bReadProcessMemory = ReadProcessMemory(
m_hProcess, // [in] HANDLE hProcess
pdwEbp + 1, // [in] LPCVOID lpBaseAddress
&dwReturnAddress, // [out] LPVOID lpBuffer
sizeof( dwReturnAddress ), // [in] SIZE_T nSize
&stNumberOfBytesRead ); // [out] SIZE_T* lpNumberOfBytesRead
if ( ( bReadProcessMemory == FALSE )
|| ( stNumberOfBytesRead < sizeof( dwReturnAddress ) )
|| ( dwReturnAddress == NULL ) )
{
break;
}
m_sCallStack += GetAddressInfo( (PVOID) dwReturnAddress );
// The Base Pointer address' value contains the previous Base Pointer address.
bReadProcessMemory = ReadProcessMemory(
m_hProcess, // [in] HANDLE hProcess
pdwEbp, // [in] LPCVOID lpBaseAddress
&dwPrevEbp, // [out] LPVOID lpBuffer
sizeof( dwPrevEbp ), // [in] SIZE_T nSize
&stNumberOfBytesRead ); // [out] SIZE_T* lpNumberOfBytesRead
if ( ( bReadProcessMemory == FALSE )
|| ( stNumberOfBytesRead < sizeof( dwPrevEbp ) ) )
{
break;
}
pdwEbp = (DWORD*) dwPrevEbp;
}
...
}
#endif
为了查找返回地址所在的模块(exe 或 dll)以及它相对于模块基地址的偏移量,我们使用 `CHmoduleArray` 类(而不是 DbgHelp.dll 的SymGetModuleInfo64 函数)。`CHmoduleArray` 使用GetModuleHandleEx 函数来获取当前进程(应用程序)的模块句柄(基地址),并使用EnumProcessModulesEx 函数来获取其他进程的模块句柄。
64 位应用程序
获取 64 位应用程序中线程的调用堆栈并不像获取 32 位应用程序中线程的调用堆栈那么简单;但您可以使用以下方法之一来获取它。
- DbgHelp.dll 的StackWalk64 函数。
- DIA SDK(调试接口访问软件开发工具包)。
要使用 DIA SDK,您必须获取 IDiaStackWalker 接口,使用 IDiaEnumStackFrames 接口获取调用堆栈帧,并创建一个自定义的 IDiaStackWalkHelper 派生类。
有一个示例 IDiaStackWalkHelper 派生类可供您使用,但它是为 32 位应用程序设计的,因此您必须更新其get_registerValue 函数以返回您线程上下文的 64 位寄存器,例如 Rip。
您还必须更新其pdataForVA 函数以返回一个IMAGE_RUNTIME_FUNCTION_ENTRY_VA 结构 - 这可能很困难 - 我找不到任何示例代码。
- RtlLookupFunctionEntry 和 RtlVirtualUnwind 函数;但您只能获取当前应用程序中线程的调用堆栈。
CCallStack 类使用 StackWalk64 函数来获取 64 位应用程序中线程的调用堆栈。
昆虫和 Bug
一个正常工作的程序是只包含未被观察到的 Bug 的程序。 墨菲定律 |
您可以设置一个函数,在应用程序抛出未处理的异常时调用。
//////////////////////////////////////////////////////////////////////////////
CThreadsAndAntsApp::CThreadsAndAntsApp( void )
...
{
// Sets the function to be called when the the unhandled exception filter
// detects an unhandled exception.
// Exception: the function is not called if this app is being debugged.
SetUnhandledExceptionFilter( MyUnhandledExceptionFilter );
...
}
未处理的异常仍会使您的应用程序崩溃,但首先会调用 `MyUnhandledExceptionFilter` 函数,该函数将记录一条包含异常调用堆栈的消息;导致异常的函数调用。
//////////////////////////////////////////////////////////////////////////////
LONG WINAPI CThreadsAndAntsApp::MyUnhandledExceptionFilter( struct _EXCEPTION_POINTERS* pExceptionInfo )
{
PEXCEPTION_RECORD pExceptionRecord = pExceptionInfo->ExceptionRecord;
CCallStack cs;
CString sMessage;
CString sCallStack = cs.GetCallStack( pExceptionInfo->ContextRecord );
// pExceptionInfo->ContextRecord doesn't include the frame that caused
// the exception - pExceptionRecord->ExceptionAddress - so add it.
CString sExceptionAddress = cs.GetAddressInfo( pExceptionRecord->ExceptionAddress );
if ( pExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION )
{
sMessage.Format( _T( "Application '%s' instruction at address '0x%p'
caused an exception while trying to '%s' memory at address '0x%p'; call stack : %s." ),
GetAppName(),
pExceptionRecord->ExceptionAddress,
aExceptionInformationStrings[pExceptionRecord->ExceptionInformation[0]],
pExceptionRecord->ExceptionInformation[1],
sExceptionAddress + sCallStack );
GetLogFile()->LogMessage( sMessage );
}
else
{
sMessage.Format( _T( "Application '%s' instruction at address '0x%p'
caused an exception of type '0x%X'; call stack : %s." ),
GetAppName(),
pExceptionRecord->ExceptionAddress,
pExceptionRecord->ExceptionCode,
sExceptionAddress + sCallStack );
GetLogFile()->LogMessage( sMessage );
}
return EXCEPTION_EXECUTE_HANDLER;
}
传递给 `MyUnhandledExceptionFilter` 函数的_EXCEPTION_POINTERS
参数包含有关异常的信息。
例如,它包含抛出未处理异常的线程的上下文,以及异常类型(代码)。
对于不同类型的异常,异常信息可能会有所不同。
例如,对于类型为 EXCEPTION_ACCESS_VIOLATION 的异常,ExceptionInformation 数组的第一个元素包含一个读写标志,该标志指示导致访问冲突的操作类型。
您可以通过单击“抛出未处理的异常”按钮来抛出未处理的异常。
它将导致应用程序崩溃,但首先会调用 `MyUnhandledExceptionFilter` 并记录抛出未处理异常的线程的调用堆栈。
//////////////////////////////////////////////////////////////////////////////
// Called when the 'Throw Unhandled Exception' button is clicked.
void CThreadsAndAntsDlg::OnThrowUnhandledExceptionButton( void )
{
// Write to a NULL pointer.
int* pi = NULL;
*pi = 0;
}
以下是您可以使用的一些函数,以便在应用程序遇到问题时收到通知。
在 `CThreadsAndAntsApp` 中有每个函数的示例,并且测试应用程序的对话框中有触发它们的按钮。
为...设置处理程序 | 函数 | 示例 |
未处理的异常 | SetUnhandledExceptionFilter | // 写入 NULL 指针。 int* pi = NULL; *pi = 0; |
纯虚函数调用 | _set_purecall_handler | 请参阅 CThreadsAndAntsDlg 类的 OnCallPureVirtualFunctionButton 函数。 |
无效参数 | _set_invalid_parameter_handler | // 使用无效参数调用 printf。 char* pc = NULL; printf( pc ); |
异常终止 | signal( SIGABRT, ... ) | // abort() 引发 SIGABRT 信号, // 就像调用 raise( SIGABRT ) 一样。 abort(); |
您不仅可以为异常记录调用堆栈,还可以为上述其他问题记录调用堆栈。
例如,您可能有一个进程监视应用程序,该应用程序启动并监视您的其他应用程序(以及一个看门狗来监视您的进程监视应用程序)。
您的进程监视应用程序可能,例如,“监视”另一个应用程序,通过定期向其发送消息来请求其状态。
如果应用程序没有响应,您的进程监视应用程序可能会记录该应用程序所有线程的调用堆栈,以帮助您找出其问题所在。
当您的应用程序收到来自进程监视应用程序的状态请求时,它可能会检查其所有线程是否仍在正常运行。
状态请求和响应,以及检查线程是否正常运行,可以根据您的需求以多种不同的方式实现。
这通常是好事,但有时您记录的调用堆栈可能不是那么有用。
如果您使用的是 Visual Studio,可以通过转到“配置属性”、“C/C++”>“优化”来更改应用程序的优化。