Introduction
Do you need to traverse the callstack of a mixed-mode (unmananged/managed) application or are curious how it can be done?
In this article, I show how the IDebugClient
interface can be used to walk a mixed-mode callstack, and how to use the
IXCLRDataProcess
interface to find the symbol names of managed methods.
Although it gives a full native callstack, it is not able to completely resolve all managed
method names. But if you are curious about the IDebugClient
interface
and want to know more about how to interact with the CLR runtime, I think it can be interesting to continue reading.
Background
It all began with a wish to improve the performance of an application at work. The application is partly written in C++, partly in C#. Part of the C++ framework had already been optimized.
This was done by simply inserting a call to StackWalk64
(dbghelp.dll), at places that we called often, and writing the callstack to disk. Yes,
it is a poor-man's profiler,
but the truth is that it is effective and is very easy to use. The weakness is that it does not handle managed code, so I only got a partial stacktrace.
This led my to look at alternative stackwalk APIs. Each with its own pros and cons.
Alternative APIs
Below is an example of a mixed-mode callstack.
We start from the bottom, where a native code calls into managed code. This managed code calls into native code, and we end up with an interleaved stack.
System.Diagnostics.StackTrace
In .NET, you can quite easily walk the stack with the System.Diagnostics.StackTrace
class. Below is a sample written in C++/CLI (usable from both managed and unmanaged code).
static void DumpStackTrace()
{
auto sb = gcnew System::Text::StringBuilder();
auto stackTrace = gcnew System::Diagnostics::StackTrace();
auto frames = stackTrace->GetFrames();
for each(System::Diagnostics::StackFrame^ frame in frames)
{
auto methodBase = frame->GetMethod();
sb->Append(methodBase->Name);
auto parameters = methodBase->GetParameters();
sb->Append("(");
for (int i = 0; i < parameters->Length; i++)
{
auto parInfo = parameters[i];
if (i > 0)
sb->Append(", ");
sb->AppendFormat("{0} {1}", parInfo->ParameterType->Name, parInfo->Name);
}
sb->Append(")");
sb->AppendLine();
}
System::Console::WriteLine(sb->ToString());
}
Calling it in my mixed-mode application (C++/CLI), it gives me the following output:
ManagedA(Int32 a)
MixedAB(Int32 a, Int32 b)
NativeABC(Int32 , Int32 , Int32 )
MixedABCD(Int32 a, Int32 b, Int32 c, Int32 d)
ManagedABCDE(Int32 a, Int32 b, Int32 c, Int32 d, Int32 e)
MixedABCDEF(Int32 a, Int32 b, Int32 c, Int32 d, Int32 e, Int32 f)
It correctly unwinds the callstack down to the last function call MixedABCDEF
.
StackWalk64
Stackwalk64
lets us see native stack frames on the stack, but not the managed frames. One reason for this is that managed frames does not use the stack in the same way as native code.
Below is how you more or less use the StackWalk64
function. In order to map the Instruction Pointers to symbol names, one must
call SymInitialize
once, and call
SymGetSymFromAddr64
for each found EIP.
void DumpStackTraceEx(HANDLE processHandle, HANDLE threadHandle)
{
STACKFRAME64 stackFrame = { 0 };
CONTEXT context = { 0 };
context.ContextFlags = CONTEXT_FULL;
GetThreadContext(threadHandle, &context)
stackFrame.AddrPC.Offset = context.Eip;
stackFrame.AddrPC.Mode = AddrModeFlat;
stackFrame.AddrFrame.Offset = context.Ebp;
stackFrame.AddrFrame.Mode = AddrModeFlat;
stackFrame.AddrStack.Offset = context.Esp;
stackFrame.AddrStack.Mode = AddrModeFlat;
while(
StackWalk64(
IMAGE_FILE_MACHINE_I386,
processHandle,
threadHandle,
&stackFrame,
(PVOID)&context,
NULL,
SymFunctionTableAccess64,
SymGetModuleBase64,
NULL))
{
DWORD64 addr64 = stackFrame.AddrPC.Offset
printf("EIP = 0x%08I64X\n", addr64);
}
}
Using the same mixed-mode application, I get the following output:
Error: SymGetSymFromAddr64() failed. Module = UnknownModule Addr64 = 0x00287E72 BaseAddr = 0x00000000
Error: SymGetSymFromAddr64() failed. Module = UnknownModule Addr64 = 0x0028583F BaseAddr = 0x00000000
Error: SymGetSymFromAddr64() failed. Module = UnknownModule Addr64 = 0x00285527 BaseAddr = 0x00000000
MixedLib1!MixedLib1::MixedLib1_Func_Add_ABCDEF(25) : 0x65F810AC
MixedLib1!MixedLib1::MixedLib1_Func_Add_ABCDEFG(30) : 0x65F810E0
Error: SymGetSymFromAddr64() failed. Module = UnknownModule Addr64 = 0x00283A82 BaseAddr = 0x00000000
clr!DecCantStopCount(UnknownLine) : 0x6F271E1F
clr!CallDescrWorker(UnknownLine) : 0x6F2721BB
clr!CallDescrWorker(UnknownLine) : 0x6F2721BB
clr!CallDescrWorkerWithHandler(UnknownLine) : 0x6F294C02
clr!MethodDesc::CallDescr(UnknownLine) : 0x6F294DA4
clr!MethodDesc::CallTargetWorker(UnknownLine) : 0x6F294DD9
clr!MethodDescCallSite::Call_RetArgSlot(UnknownLine) : 0x6F294DF9
clr!ClassLoader::RunMain(UnknownLine) : 0x6F3E9643
clr!Assembly::ExecuteMainMethod(UnknownLine) : 0x6F41CEC8
clr!SystemDomain::ExecuteMainMethod(UnknownLine) : 0x6F41CCDC
clr!ExecuteEXE(UnknownLine) : 0x6F41D0D5
clr!_CorExeMainInternal(UnknownLine) : 0x6F41CFD5
clr!_CorExeMain(UnknownLine) : 0x6F40E258
mscoreei!_CorExeMain(UnknownLine) : 0x71C555AB
MSCOREE!ShellShim__CorExeMain(UnknownLine) : 0x71D37F16
MSCOREE!_CorExeMain_Exported(UnknownLine) : 0x71D34DE3
KERNEL32!BaseThreadInitThunk(UnknownLine) : 0x75C133CA
ntdll!__RtlUserThreadStart(UnknownLine) : 0x76EF9ED2
ntdll!_RtlUserThreadStart(UnknownLine) : 0x76EF9EA5
There are symbols that cannot be found. They actually belong to kernel32 and msvcrt. They should have been resolved, with a little troubleshooting they can probably be resolved.
Remember that SymInitialize
is asynchronous, it returns but Symbol files are loaded in the background. If you try to resolve before the symbol file has been loaded, you will get an error.
What I wanted to show was that the managed frames are not displayed. They are displayed as function calls within the CLR runtime, which isn't very helpful.
WinDbg
Let's see how WinDbg handles a mixed mode callstack.
0:000> k
ChildEBP RetAddr
002ce5e8 75cb7361 KERNEL32!ReadConsoleInternal+0x15
002ce670 75c3f1c6 KERNEL32!ReadConsoleA+0x40
002ce6b8 74dcc3b3 KERNEL32!ReadFileImplementation+0x75
002ce700 74dcc2bc msvcrt!_read_nolock+0x183
002ce744 74dcc472 msvcrt!_read+0x9f
002ce760 74dcee5d msvcrt!_filbuf+0x7d
002ce768 74dcede4 msvcrt!_ftbuf+0x72
002ce774 74dceb62 msvcrt!_ftbuf+0x89
002ce954 74e26866 msvcrt!_input_l+0x36c
002ce998 74e268d9 msvcrt!vwscanf+0x55
002ce9b0 0031435c msvcrt!scanf+0x18
WARNING: Frame IP not in any known module. Following frames may be wrong.
002ceaac 0031405a 0x31435c
002cebdc 65e210ac 0x31405a
002cebf8 65e210e0 MixedLib1!MixedLib1::MixedLib1_Func_Add_ABCDEF+0x1c
002cec18 00313a82 MixedLib1!MixedLib1::MixedLib1_Func_Add_ABCDEFG+0x20
002ced88 6f271e1f 0x313a82
002cedc4 6f2721bb clr!DecCantStopCount+0x13
002cede4 6f2721bb clr!CallDescrWorker+0x33
002cedf4 6f294c02 clr!CallDescrWorker+0x33
002cee70 6f294da4 clr!CallDescrWorkerWithHandler+0x8e
002cefb0 6f294dd9 clr!MethodDesc::CallDescr+0x194
002cefcc 6f294df9 clr!MethodDesc::CallTargetWorker+0x21
002cefe4 6f3e9643 clr!MethodDescCallSite::Call_RetArgSlot+0x1c
002cf148 6f41cec8 clr!ClassLoader::RunMain+0x238
002cf3b0 6f41ccdc clr!Assembly::ExecuteMainMethod+0xc1
002cf894 6f41d0d5 clr!SystemDomain::ExecuteMainMethod+0x4ec
002cf8e8 6f41cfd5 clr!ExecuteEXE+0x58
002cf934 6f40e258 clr!_CorExeMainInternal+0x19f
002cf96c 71c555ab clr!_CorExeMain+0x4e
002cf978 71d37f16 mscoreei!_CorExeMain+0x38
002cf988 71d34de3 MSCOREE!ShellShim__CorExeMain+0x99
002cf990 75c133ca MSCOREE!_CorExeMain_Exported+0x8
002cf99c 76ef9ed2 KERNEL32!BaseThreadInitThunk+0xe
002cf9dc 76ef9ea5 ntdll!__RtlUserThreadStart+0x70
002cf9f4 00000000 ntdll!_RtlUserThreadStart+0x1b
The stack looks very similar to mine. A bit better, because it correctly resolves functions from kernel32 and msvcrt.
But look closely. But there are addresses that cannot be resolved. Apparently, a normal stackwalk gets confused "WARNING: Frame IP not in any known module. Following frames may be wrong."
Normally DLLs get loaded into a memory space, and code is located within that memory range. Assemblies are also loaded, but don't contain any executable code.
The JIT compiler takes the IL code and generates machine code which it puts on the heap. A native stackwalker only sees the generated code, which is not in any loaded module (correct).
The stackwalker doesn't know anything about the IL code, nor can it use the PDB files correctly, because it maps to the IL-code and not the machine dependent code.
WinDbg with SOS extension
SOS is a
WinDbg extension to debug managed applications. It is capable of walking mixed
stackframes with the !clrstack
command. Let's see how well it performs.
0:000> .loadby sos clr
0:000> !clrstack
OS Thread Id: 0xbf0 (0)
Child SP IP Call Site
002ce9dc 75cb76f8 [InlinedCallFrame: 002ce9dc]
002ce9b8 0031435c DomainBoundILStubClass.IL_STUB_PInvoke(System.String, System.Text.StringBuilder, ...)
002ce9dc 0031405a [InlinedCallFrame: 002ce9dc] ManagedLib0.Win32Imports.scanf(System.String, ...)
002ceab4 0031405a ManagedLib0.Win32Imports.ReadLine()
002ceae8 00313d86 ManagedLib0.A.Add_A(Int32)
002ceb28 00313cbf ManagedLib0.AB.Add_AB(Int32, Int32)
002ceb44 00313c61 MixedLib1.ABC.Add_ABC(Int32, Int32, Int32)
002ceb68 00313bbd <Module>.MixedLib1.MixedLib1_Func_Add_ABCD(Int32, Int32, Int32, Int32)
002ceb94 00313b37 <Module>.MixedLib1.MixedLib1_Func_Add_ABCDE(Int32, Int32, Int32, Int32, Int32)
002cec40 00990b1b [InlinedCallFrame: 002cec40]
002cec20 00313a82 DomainBoundILStubClass.IL_STUB_PInvoke(Int32, Int32, Int32, Int32, Int32, Int32, Int32)
002cec40 0031391f [InlinedCallFrame: 002cec40] <Module>.MixedLib1.MixedLib1_Func_Add_ABCDEFG(Int32, Int32, Int32, Int32, Int32, Int32, Int32)
002cecf4 0031391f MixedLib1.MixedLib1Funcs.MixedLib1_Func_Add_ABCDEFGH(Int32, Int32, Int32, Int32, Int32, Int32, Int32, Int32)
002ced2c 0031386c <Module>.CppCliApp.MixedLib2_Func_Add_ABCDEFGHI(Int32, Int32, Int32, Int32, Int32, Int32, Int32, Int32, Int32)
002ced6c 003122d4 <Module>.main(System.String[])
002ced84 00311e21 <Module>.mainCRTStartupStrArray(System.String[])
002cf018 6f2721bb [GCFrame: 002cf018]
Pretty neat. This is what we want to obtain.
Discarding System.Diagnostics.StackTrace
The StackTrace
class works great, but it has disadvantages.
Firstly, it uses Reflection and is really slow. Secondly, the old way of manually instrumenting code is not good any more. The stacktrace calls can not be left in the source code,
and adding and removing them each time would be time consuming. Thirdly, I didn't really know where we had performance problems and where the stack should be traced.
What I now need is to take stacktrace samples and based on the frequency be pointed in a general direction where the problem was.
This is also know as Sample Profiling. An external application hooks up to a target app, and at regular intervals, e.g., every 20 ms, it takes a stacktrace sample.
Where to find information about possible solutions
- One possibility is to try to reverse engineer the SOS extension.
- Try to find some CLR API, maybe look at mscoree.h and related includes.
- Look at the Mono source code.
- Look at Microsoft's Shared Source CLI (Rotor V2.0). source code.
- Google Google Google
If you want to know more about the CLR runtime and how to interact with it through mscoree, and the CLR hosting interfaces,
I can recommend reading Customizing the Microsoft® .NET Framework Common Language Runtime.
The CLR APIs might not be enough. But a wonderful source of inspiration is the Rotor source code, it is the implementation of an unoptimized CLR runtime written by Microsoft
for standardization purposes.
The implementation
We will explore two interfaces. IDebugClient
that is said to give the full stacktrace and
IXCLRDATAProcess
(mscordacwks.dll) to translate managed addresses into readable method names.
IDebugClient
Microsoft has been kind enough to provide an API that can walk mixed-frames, IDebugClient
which is exposed by dbgeng.dll.
It is fairly straightforward to use the API. There are several samples on the internet.
You create an object through a special function called DebugCreate
and feed it the GUID of the
IDebugClient
interface.
DebugClient* debugClient = nullptr;
if ((hr = DebugCreate(__uuidof(IDebugClient), (void **)&debugClient)) != S_OK)
{
return false;
}
m_debugClient = debugClient;
Attaching to a process is straightforward:
const ULONG64 LOCAL_SERVER = 0;
int flags = DEBUG_ATTACH_NONINVASIVE | DEBUG_ATTACH_NONINVASIVE_NO_SUSPEND;
hr = debugClient->AttachProcess(LOCAL_SERVER, pId, flags);
if (hr != S_OK)
return false;
if ((hr = debugClient->QueryInterface(__uuidof(IDebugControl), (void **)&debugControl)) != S_OK)
{
debugClient->Release();
return false;
}
m_debugControl = debugControl;
I actually got into problems with the attach. It sometimes worked, sometimes not. It worked when I debugged it. It even worked by adding a sleep after the attach.
I found out what it was. It takes some time for the attach to complete, it is only initiated, so the object isn't really ready yet.
What we can do is, to set the execution status of the target app to "go". The process is already running (it was never suspended),
so the call will hopefully return immediately. But here is the clever thing. It returns when the debugger is properly attached.
hr = m_debugControl->SetExecutionStatus(DEBUG_STATUS_GO);
if ((hr = m_debugControl->WaitForEvent(DEBUG_WAIT_DEFAULT, INFINITE)) != S_OK)
{
return false;
}
Then using the IDebugClient
object, you create the other COM objects that you might need.
m_debugClient->QueryInterface(__uuidof(IDebugAdvanced), (void **)&m_ExtAdvanced))
m_debugClient->QueryInterface(__uuidof(IDebugAdvanced2), (void **)&m_ExtAdvanced2))
m_debugClient->QueryInterface(__uuidof(IDebugControl2), (void **)&m_ExtControl))
m_debugClient->QueryInterface(__uuidof(IDebugControl4), (void **)&m_ExtControl4))
m_debugClient->QueryInterface(__uuidof(IDebugDataSpaces), (void **)&m_ExtData))
m_debugClient->QueryInterface(__uuidof(IDebugDataSpaces2), (void **)&m_ExtData2))
m_debugClient->QueryInterface(__uuidof(IDebugRegisters), (void **)&m_ExtRegisters))
m_debugClient->QueryInterface(__uuidof(IDebugSymbols), (void **)&m_ExtSymbols))
m_debugClient->QueryInterface(__uuidof(IDebugSymbols2), (void **)&m_ExtSymbols2))
m_debugClient->QueryInterface(__uuidof(IDebugSymbols3), (void **)&m_ExtSymbols3))
m_debugClient->QueryInterface(__uuidof(IDebugSystemObjects), (void **)&m_ExtSystem))
I made one big mistake, which took some time to fix. Since I was just interested in the function
IDebugControl4::GetStackTrace
,
and I wasn't going to use it for stepping, setting breakpoints, etc., I didn't bother to implement the callback functions for printing to screen,
and the thing kept crashing on me when trying to get the interfaces. Come on! Couldn't someone have inserted an extra test to see whether
users of the API were interested in the events or the output? I implemented these debug output callback classes too.
Well, I left the function bodies totally empty. I wasn't interested in it anyway.
HRESULT hr1 = m_debugClient->SetOutputCallbacks(&g_DebugOutputCallback);
HRESULT hr2 = m_debugClient->SetEventCallbacks(&g_DebugEventCallbacks);
CLRDataCreateInstance
CLRDataCreateInstance
(defined in clrdata.idl CorGuids.lib) can return a COM object with
the interface IXCLRDataProcess
. This object can enumerate tasks, appdomains, methods, etc.
It also contains functions for mapping addresses or internal CLR IDs to methods, classes, assemblies, etc. Quite neat. This might be what we need.
HRESULT CLRDataCreateInstance (
[in] REFIID iid,
[in] ICLRDataTarget *target,
[out] void **iface
);
A small problem is that the IXCLRDataProcess
interface doesn't have a header file, but you can generate one from
xclrdata.idl
which is part of the Rotor source code.
According to the license, it is allowed to use the source code for non-commercial purposes.
Some of the Rotor source code can be non-trivial to understand. I want to give credit to Steve's Blog,
which gives some useful instructions, but unfortunately, Steve supplies no source code :(. So, there was actually quite
a lot of implementation and debugging work left to do.
Implementing ICLRDataTarget
In order to create an IXCLRDataProcess
, the function CLRCreateInstance
expects an
ICLRDataTarget
object
that interacts with the managed application. It is an interface that needs to be implemented by the user. I have no idea why a default implementation of this interface doesn't already exist.
It does only basic stuff such as reading and writing to raw memory, returning pointer size, etc.
interface ICLRDataTarget : IUnknown {
HRESULT GetCurrentThreadID
HRESULT GetImageBase
HRESULT GetMachineType
HRESULT GetPointerSize
HRESULT GetThreadContext
HRESULT GetTLSValue
HRESULT ReadVirtual
HRESULT Request
HRESULT SetThreadContext
HRESULT SetTLSValue
HRESULT WriteVirtual
};
I cut some corners doing the implementation. I only support the x86 architecture. Managed apps, compiled for the "any" platform, can run in both x86/x64 mode depending
on the OS hosting it, but in Visual Studio 2010, it actually defaults to the x86 architecture. Apart from that, I wanted the x86 to work first, before I tried x64.
Always do the easy case first. When it works, we extend.
public class DiagCLRDataTarget : public ICLRDataTarget
{
public:
virtual HRESULT STDMETHODCALLTYPE GetMachineType(
ULONG32 *machineType)
{
*machineType = IMAGE_FILE_MACHINE_I386;
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE GetPointerSize(
ULONG32 *pointerSize)
{
*pointerSize = sizeof(PVOID);
return S_OK;
}
virtual HRESULT STDMETHODCALLTYPE GetImageBase(
LPCWSTR imagePath,
CLRDATA_ADDRESS *baseAddress)
{
ULONG index = 0;
ULONG64 baseAddr = 0;
std::basic_string<WCHAR> img = std::basic_string<WCHAR>(imagePath);
std::basic_string<WCHAR> moduleName;
if (img == L"mscorwks.dll")
moduleName = L"mscorwks";
else if (img == L"clr.dll")
moduleName = L"clr";
else
moduleName = img;
HRESULT hr = this->m_debugNative->
m_ExtSymbols3->
GetModuleByModuleNameWide
(moduleName.c_str(), 0, &index, &baseAddr);
*baseAddress = baseAddr;
return hr;
}
virtual HRESULT STDMETHODCALLTYPE ReadVirtual(
CLRDATA_ADDRESS address,
BYTE *buffer,
ULONG32 bytesRequested,
ULONG32 *bytesRead)
{
PULONG bRead = reinterpret_cast<PULONG>(bytesRead);
return this->m_debugNative->
m_ExtData2->
ReadVirtual
(address, buffer, bytesRequested, bRead);
}
virtual HRESULT STDMETHODCALLTYPE WriteVirtual
virtual HRESULT STDMETHODCALLTYPE GetTLSValue
virtual HRESULT STDMETHODCALLTYPE SetTLSValue
virtual HRESULT STDMETHODCALLTYPE GetCurrentThreadID
virtual HRESULT STDMETHODCALLTYPE GetThreadContext
virtual HRESULT STDMETHODCALLTYPE SetThreadContext
virtual HRESULT STDMETHODCALLTYPE Request
};
The Stackwalker class
Initialization
In order to attach to a running process, some basic initialization is needed:
bool Stackwalker::Initialize(int pId)
{
m_pId = pId;
m_isClr4 = IsClr4Process(pId);
m_isManaged = IsDotNetProcess(pId);
bool result = m_debugNative->Initialize(pId);
return result;
}
How do you know if a process is a .NET process? The PE file header, present in all executables, contains that information.
A simpler way is to look if certain CLR modules have been loaded like clr, clrjit, mscorlib_ni, mscoree, etc.
How do you know if it is a CLR v4.0? Look for clr.dll. Mscorwks.dll was renamed from v2.0 to v4.0. Might give false positives, if someone names their modules
clr.dll,
but isn't this article about mixed-mode/managed apps anyway?
Obtaining the IXCLRDataprocess object
To create the IXCLDataProcess
object we need to call CLRDataCreateInstance
located in the data access
DLL named mscordacwks.dll.
Remember that the DLL is CLR version dependent, so it must be loaded from the correct file location, that is why we check the CLR version.
Then we have to manually load the library into memory, then call GetProcessAddress
to get the address of
CLRDataCreateInstance
.
Finally we call the function, giving it an instance of our ICLRDataTarget
.
HRESULT LoadDataAccessDLL(bool IsClrV4, ICLRDataTarget* target, HMODULE* dllHandle, void** iface)
{
std::basic_string<TCHAR> systemRootString;
std::basic_string<TCHAR> mscordacwksPathString;
std::basic_string<TCHAR> mscordacwksFileName;
const int size = 500;
TCHAR windir[size];
HRESULT hr = GetWindowsDirectory(windir, size);
systemRootString = std::basic_string<TCHAR>(windir);
if (IsClrV4)
{
mscordacwksPathString =
std::basic_string<TCHAR>("\\Microsoft.NET\\Framework\\v4.0.30319\\mscordacwks.dll");
}
else
{
mscordacwksPathString =
std::basic_string<TCHAR>("\\Microsoft.NET\\Framework\\v2.0.50727\\mscordacwks.dll");
}
mscordacwksFileName = systemRootString + mscordacwksPathString;
HMODULE accessDll = LoadLibrary(mscordacwksFileName.c_str());
PFN_CLRDataCreateInstance entry =
(PFN_CLRDataCreateInstance) GetProcAddress(accessDll, "CLRDataCreateInstance");
RESULT status;
void* ifacePtr = NULL;
if (!entry)
{
status = GetLastError();
FreeLibrary(accessDll);
}
else if ((status = entry(__uuidof(IXCLRDataProcess), target, &ifacePtr)) != S_OK)
{
FreeLibrary(accessDll);
}
else
{
*dllHandle = accessDll;
*iface = ifacePtr;
}
return status;
}
I am sorry about all the TCHAR
s, char
, std::string
, and
std::wstring
you might find in my code. A TCHAR
is a
wchar_t
when compiled for Unicode, and a char when compiled in multibyte.
Regardless of how it is compiled. StackWalk64
always uses chars, but most Win32 APIs adapts
themselves. It can be messy sometimes when you have to convert back and forth.
Putting it all together
HMODULE accessDLL;
void* iface = NULL;
HRESULT hr = LoadDataAccessDLL(m_isClr4, m_clrDataTarget, &accessDLL, &iface);
m_clrDataProcess = static_cast<IXCLRDataProcess*>(iface);
Now we are ready to use the object with the instruction pointers we get from the stackwalk.
HRESULT hr = m_clrDataProcess->GetRuntimeNameByAddress(
clrAddr , 0, maxSize - 1 , &nameLen, nameBuffer, &displacement);
It will return a symbolname for the managed Instruction Pointer (IP).
Resolving Managed Method names using IXCLRDataProcess::Request
In Steve's blog, you can read about something called
DacpMethodDescData
and IXCLRDataProcess::Request
.
It is a generic interface that takes an enum value describing what type of data you want, a pointer to the input parameter, and a pointer to the output parameter.
return dac->Request(DACPRIV_REQUEST_METHODDESC_NAME,
sizeof(addrMethodDesc), (PBYTE)&addrMethodDesc,
sizeof(WCHAR)*iNameChars, (PBYTE) pwszName);
A powerful interface, if you know what enum values to send in, otherwise you
are doomed. It gives the same info as GetRuntimeNameByAddress
.
Below is a code snippet, you can also find it in the attached source code.
WCHAR buffer[255];
struct DacpMethodDescData DacpData;
ZeroMemory(&DacpData, sizeof(DacpData));
CLRDATA_ADDRESS managedIP = static_cast<CLRDATA_ADDRESS>(ip);
HRESULT hr1 = DacpData.RequestFromIP(m_clrData, managedIP);
ULONG32 nameChars = sizeof(buffer)/sizeof(WCHAR) - 1;
if (SUCCEEDED(hr))
{
buffer[0] = 0;
HRESULT hr2 = DacpData.GetMethodName(m_clrData ,
DacpData.MethodDescPtr ,
nameChars ,
buffer);
if (SUCCEEDED(hr2))
result = std::basic_string<WCHAR>(buffer);
}
Sample apps
There are three applications present in the demo folder. It looks in the current folder for
pdb files.
It also looks in C:\symbols. It also tries to download PDB files from Microsoft and store them in
C:\symbols.
Without these symbols, Stackwalk64
might get lost very quickly, since it doesn't know about the calling convention, omitted frame-pointers, and other optimizations.
Start CppCliApp.exe first, it prints the process ID, and outputs a
Stackwalk64
and a System.Diagnostics.StackTrace
callstack. Use the
pId
when you use the other apps.
c:\Demo>CppCliApp.exe
Current Process Id #4404
---- StackTrace ----
....
---- StackWalk64 ----
....
C:\Demo>DiagApp.exe 4404
C:\Demo>StackWalk64App 4404
This is the result I get from DiagApp.exe (using the IClientDebug
Interface):
Process is managed
Process is Clr 4
Stack Trace:
KERNEL32!ReadConsoleInternal+0x00000015
KERNEL32!ReadConsoleA+0x00000040
KERNEL32!ReadFileImplementation+0x00000075
msvcrt!_read_nolock+0x00000183
msvcrt!_read+0x0000009F
msvcrt!_filbuf+0x0000007D
msvcrt!_ftbuf+0x00000072
msvcrt!_ftbuf+0x00000089
msvcrt!_input_l+0x0000036C
msvcrt!vwscanf+0x00000055
msvcrt!scanf+0x00000018
DomainBoundILStubClass.IL_STUB_PInvoke(System.String, System.Text.StringBuilder, ...)
ManagedLib0.Win32Imports.ReadLine()
MixedLib1!MixedLib1::MixedLib1_Func_Add_ABCDEF+0x0000001C
MixedLib1!MixedLib1::MixedLib1_Func_Add_ABCDEFG+0x00000020
DomainBoundILStubClass.IL_STUB_PInvoke(Int32, Int32, Int32, Int32, Int32, Int32, Int32)
clr!DecCantStopCount+0x00000013
clr!CallDescrWorker+0x00000033
clr!CallDescrWorker+0x00000033
clr!CallDescrWorkerWithHandler+0x0000008E
clr!MethodDesc::CallDescr+0x00000194
clr!MethodDesc::CallTargetWorker+0x00000021
clr!MethodDescCallSite::Call_RetArgSlot+0x0000001C
clr!ClassLoader::RunMain+0x00000238
clr!Assembly::ExecuteMainMethod+0x000000C1
clr!SystemDomain::ExecuteMainMethod+0x000004EC
clr!ExecuteEXE+0x00000058
clr!_CorExeMainInternal+0x0000019F
clr!_CorExeMain+0x0000004E
mscoreei!_CorExeMain+0x00000038
MSCOREE!ShellShim__CorExeMain+0x00000099
MSCOREE!_CorExeMain_Exported+0x00000008
KERNEL32!BaseThreadInitThunk+0x0000000E
ntdll!__RtlUserThreadStart+0x00000070
ntdll!_RtlUserThreadStart+0x0000001B
Stack Trace:
We managed to get a full stacktrace from a managed app. It even resolved the addresses that WinDbg failed on, thanks to
mscordacwks.dll.
But my own managed classes still don't appear. This is unfortunate. The calls to
clr!xxx
makes absolute sense if we think about it.
IL code cannot run, it must be JITted to machine code, but the CLR probably has
a native function that executes JITted code.
It is this function that we see.
On a purely managed app, I actually get a much better stacktrace. Many CLR functions show up in readable code, but my own managed method names are still hiding.
KERNEL32!ReadConsoleInternal+0x00000015
KERNEL32!ReadConsoleA+0x00000040
KERNEL32!ReadFileImplementation+0x00000075
DomainNeutralILStubClass.IL_STUB_PInvoke
System.IO.__ConsoleStream.ReadFileNative
System.IO.__ConsoleStream.Read(Byte[], Int32, Int32)
System.IO.StreamReader.ReadBuffer()
System.IO.StreamReader.ReadLine()
System.IO.TextReader+SyncTextReader.ReadLine()
System.Console.ReadLine()
clr!CallDescrWorker+0x00000033
clr!CallDescrWorker+0x00000033
clr!CallDescrWorkerWithHandler+0x0000008E
...
I have another mixed mode app, that actually confuses IDebugClient
. The stackwalk gets lost trying to find return addresses.
kernel32!GetConsoleInput+0x00000015
kernel32!ReadConsoleInputW+0x0000001A
DomainNeutralILStubClass.IL_STUB_PInvoke(IntPtr, InputRecord ByRef, Int32, Int32 ByRef)
System.Console.ReadKey(Boolean)
System.Console.ReadKey()
NativeLib!NativeABC+0x0000002E
0xFFFFFFFFCCCCCCCC
The IDebugClient
interface is supposed to be able to walk the callstack. I don't know why it fails. The difference is that I have a Managed C# app
at the bottom that calls into mixed-mode/libraries. The other app was a C++/CLI app that called into mixed-mode/managed libraries. The libraries are the same.
After all it was not the result I expected. Even if I got the full stacktrace, using the
IDebugClient
and the IXCLRDataProcess
is not enough.
The solution
The solution is to use the ICorProfiler
interface instead. It allows you to create an in-process profiler that interacts with the CLR.
It contains code for walking mixed mode apps. Inprocess means that it is a DLL that loads into the process space of the target process.
This means also that we can say goodbye to the IDebugClient
interface, since it is not possible to attach a debugger to
the same process we make the attach from.
I have done a small sampler profiler too, but it will be another article.
Points of interest
There are some great sources on the internet Profiler stack walking: Basics and beyond and
Building a mixed mode stack walker
The last link made me realise that what I really needed was the ICorProfiler
interface. But at that point
I had already done 95% of what I have just shown you. So I decided to finish it anyway.
I made a brave attempt, and I learned a great deal along the way. I hope some of this information can be useful for you too.
For people analyzing memory dumps of .NET apps using WinDbg and SOS, mscordacwks.dll
is a must to know about. A memory dump of a .NET app from one machine cannot
simply be copied to other machines for analyzing. The SOS extension must load
the correct version of mscordacwks.dll in order to understand the memory dump.
But if the machine where the memory dump was saved didn't use exactly the same .NET
Framework version, the SOS cannot understand the data. To overcome this problem,
mscordacwks.dll should be copied along with the dump file.