VBAStack - Programmatically read call stack in VBA6/7
Update - big news! 30/01/2025
Hi again!
After the changes to VBAStack, I realised it was now so much simpler that it can actually run entirely inside VBA, no .NET or COM required. I realise this probably eclipses the original post, but I'll leave it below anyway.
Note that this code is incredibly rough in all honesty - it could do with adding some more Win32 API calls to check that the addresses it reads with RtlMoveMemory are actually readable, otherwise if something goes wrong it will cause CTD's.
Code:
Option Explicit On
'Tested on x86 Access 2003, x86 Access 2013, x86 Access 365, x64 Access 2013, and x64 Access 365.
'Example use:
' Private Sub Example()
'
' Dim StackFrames() As VBAStack.StackFrame
' StackFrames = VBAStack.GetCallstack()
'
' Dim str As String
' Dim i As Integer
'
' For i = 0 To UBound(StackFrames)
'
' str = str & StackFrames(i).FrameNumber & ", " & StackFrames(i).ProjectName & "::" & StackFrames(i).ObjectName & "::" & StackFrames(i).ProcedureName & vbCrLf
'
' Next
' MsgBox (str)
'
' 'Above outputs this:
' ' 1, MyMod::Example
' ' 2, MyMod::Sub2
' ' 3, Form_Form1::Command0_Click
'
' Dim frame As VBAStack.StackFrame
' frame = VBAStack.GetCurrentProcedure
'
' MsgBox (frame.ObjectName & "::" & frame.ProcedureName)
' 'Outputs this:
' ' MyMod::Example
'
' End Sub
#If VBA7 = False Then
Private Enum LongPtr
[_]
End Enum
Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (ByRef lpDest As Any, ByVal lpSource As LongPtr, ByVal cbCopy As Long)
#Else
Private Declare PtrSafe Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (ByRef lpDest As Any, ByVal lpSource As LongPtr, ByVal cbCopy As Long)
#End If
#If Win64 Then
Const PtrSize As Integer = 8
#Else
Const PtrSize As Integer = 4
#End If
Public Type StackFrame
ProjectName As String
ObjectName As String
ProcedureName As String
realFrameNumber As Integer
FrameNumber As Integer
Errored As Boolean
End Type
Public Function FrameCount() As Integer
On Error GoTo ErrorOccurred
FrameCount = -1
'Get ptr to VBA.Err
Dim errObj As LongPtr
errObj = ObjPtr(VBA.Err)
'Get g_ebThread
Dim g_ebThread As LongPtr
CopyMemory g_ebThread, (errObj + PtrSize * 6), PtrSize
If g_ebThread = 0 Then GoTo ErrorOccurred
'Get g_ExFrameTOS
Dim g_ExFrameTOS As LongPtr
#If Win64 Then
g_ExFrameTOS = g_ebThread + (&H10)
#Else
g_ExFrameTOS = g_ebThread + (&HC)
#End If
If g_ExFrameTOS = 0 Then GoTo ErrorOccurred
'Get top ExFrame
Dim pTopExFrame As LongPtr
CopyMemory pTopExFrame, g_ExFrameTOS, PtrSize
If pTopExFrame = 0 Then GoTo ErrorOccurred
'Loop over frames to count
Dim pExFrame As LongPtr: pExFrame = pTopExFrame
Do
CopyMemory pExFrame, pExFrame, PtrSize
FrameCount = FrameCount + 1
If pExFrame = 0 Then Exit Do
Loop
Exit Function
ErrorOccurred:
End Function
Public Function GetCurrentProcedure() As StackFrame
GetCurrentProcedure = VBAStack.GetStackFrame(2)
End Function
Public Function GetCallstack() As StackFrame()
Dim stackCount As Integer: stackCount = VBAStack.FrameCount
Dim index As Integer: index = 1
Dim FrameArray() As StackFrame
ReDim FrameArray(stackCount - 2)
Do Until index = stackCount
FrameArray(index - 1) = VBAStack.GetStackFrame(index + 1)
index = index + 1
Loop
GetCallstack = FrameArray
End Function
Public Function GetStackFrame(Optional ByVal FrameNumber As Integer = 1) As StackFrame
On Error GoTo ErrorOccurred
If FrameNumber < 1 Then GoTo ErrorOccurred
Dim retVal As StackFrame
retVal.realFrameNumber = FrameNumber
retVal.FrameNumber = FrameNumber - 1
'Get ptr to VBA.Err
Dim errObj As LongPtr
errObj = ObjPtr(VBA.Err)
'Get g_ebThread
Dim g_ebThread As LongPtr
CopyMemory g_ebThread, (errObj + PtrSize * 6), PtrSize
If g_ebThread = 0 Then GoTo ErrorOccurred
'Get g_ExFrameTOS
Dim g_ExFrameTOS As LongPtr
#If Win64 Then
g_ExFrameTOS = g_ebThread + (&H10)
#Else
g_ExFrameTOS = g_ebThread + (&HC)
#End If
If g_ExFrameTOS = 0 Then GoTo ErrorOccurred
'Get top ExFrame
Dim pTopExFrame As LongPtr
CopyMemory pTopExFrame, g_ExFrameTOS, PtrSize
If pTopExFrame = 0 Then GoTo ErrorOccurred
'Get next ExFrame (do this minimum once, since top frame is this procedure)
Dim pExFrame As LongPtr: pExFrame = pTopExFrame
Do
CopyMemory pExFrame, pExFrame, PtrSize
If pExFrame = 0 Then GoTo ErrorOccurred
FrameNumber = FrameNumber - 1
Loop Until FrameNumber = 0
'Get RTMI
Dim pRTMI As LongPtr
CopyMemory pRTMI, (pExFrame + PtrSize * 3), PtrSize
If pRTMI = 0 Then GoTo ErrorOccurred
'Get ObjectInfo
Dim pObjectInfo As LongPtr
CopyMemory pObjectInfo, pRTMI, PtrSize
If pObjectInfo = 0 Then GoTo ErrorOccurred
'Get Public Object Descriptor
Dim pPublicObject As LongPtr
CopyMemory pPublicObject, (pObjectInfo + PtrSize * 6), PtrSize
If pPublicObject = 0 Then GoTo ErrorOccurred
'Get pointer to module name string from Public Object Descriptor
Dim pObjectName As LongPtr
CopyMemory pObjectName, (pPublicObject + PtrSize * 6), PtrSize
If pObjectName = 0 Then GoTo ErrorOccurred
'Read the object name string
Dim objName As String
Dim readByteObjName As Byte
Do
CopyMemory readByteObjName, pObjectName, 1
pObjectName = pObjectName + 1
If readByteObjName = 0 Then Exit Do 'Read null char - end loop
objName = objName & Chr(readByteObjName)
Loop
retVal.ObjectName = objName
'Get pointer to methods array from ObjectInfo
Dim pMethodsArr As LongPtr
CopyMemory pMethodsArr, (pObjectInfo + PtrSize * 9), PtrSize
If pMethodsArr = 0 Then GoTo ErrorOccurred
'Get count of methods from Public Object Descriptor
Dim methodCount As Long
CopyMemory methodCount, (pPublicObject + PtrSize * 7), 4
If methodCount = 0 Then GoTo ErrorOccurred
'Search the method array to find our RTMI
Dim methodIndex As Integer: methodIndex = -1
Dim i As Integer
Dim pMethodRTMI As LongPtr
For i = methodCount - 1 To 0 Step -1
CopyMemory pMethodRTMI, (pMethodsArr + PtrSize * i), PtrSize
If pMethodRTMI = 0 Then GoTo ErrorOccurred
If pMethodRTMI = pRTMI Then
methodIndex = i
Exit For
End If
Next
If methodIndex = -1 Then GoTo ErrorOccurred
'Get array of method names from Public Object Descriptor
Dim pMethodNamesArr As LongPtr
CopyMemory pMethodNamesArr, (pPublicObject + PtrSize * 8), PtrSize
If pMethodNamesArr = 0 Then GoTo ErrorOccurred
'Get pointer to our method name
Dim pMethodName As LongPtr
CopyMemory pMethodName, (pMethodNamesArr + PtrSize * methodIndex), PtrSize
If pMethodName = 0 Then GoTo ErrorOccurred
'Read the method name string
Dim procName As String
Dim readByteProcName As Byte
Do
CopyMemory readByteProcName, pMethodName, 1
pMethodName = pMethodName + 1
If readByteProcName = 0 Then Exit Do 'Read null char - end loop
procName = procName & Chr(readByteProcName)
Loop
retVal.ProcedureName = procName
'Get ObjectTable
Dim pObjectTable As LongPtr
CopyMemory pObjectTable, (pObjectInfo + PtrSize * 1), PtrSize
If pObjectTable = 0 Then GoTo ErrorOccurred
'Get project name from ObjectTable
Dim pProjName As LongPtr
#If Win64 Then
CopyMemory pProjName, (pObjectTable + &H68), PtrSize
#Else
CopyMemory pProjName, (pObjectTable + &H40), PtrSize
#End If
If pProjName = 0 Then GoTo ErrorOccurred
'Read the project name string
Dim projName As String
Dim readByteProjName As Byte
Do
CopyMemory readByteProjName, pProjName, 1
pProjName = pProjName + 1
If readByteProjName = 0 Then Exit Do 'Read null char - end loop
projName = projName & Chr(readByteProjName)
Loop
retVal.ProjectName = projName
GetStackFrame = retVal
Exit Function
ErrorOccurred:
retVal.Errored = True
GetStackFrame = retVal
End Function
Original post
First post here after lurking for quite some time! Hopefully I'm doing this right.
Downloadable from Nuget, source is available at the Github repo.
Update 1.0.0.14 - 26/01/2025
VBAStack can now retrieve the callstack when running compiled Access MDE files! Good god that took some work. Still a bit shakey on the VB structures used (especially the x86/x64 differences between them) but so far, seems to behave great.
VBAStack
A library for retrieving VBA callstack information at runtime from Office applications. This enables debugging and error reporting capabilities for VBA add-ins and COM add-ins targeting Microsoft Office (or anything else that can call it and pass a VBE COM object to it, I suppose!).
Credit to "The Trick" - without finding his VbTrickTimer code, I would've just accepted that VBE7 doesn't export the functions to do this, and would never have found them.
Note on AI use
The vast majority of this code was written with my own two hands, but I will admit to prettifying things (mostly documentation, and the bones of this readme) with AI.
I did discover that it is absolutely terrible at debugging stuff when it isn't well-covered ground, though.
Overview
VBAStack allows you to programmatically retrieve the current VBA call stack from a running VBA application (Excel, Word, Access, etc.).
It does need to be loaded into the same process as the VBE, as it calls internal undocumented functions inside VBE7.dll. As such, it's best deployed as part of a COM add-in or VBA add-in and called from VBA error handlers.
I am personally using it with a VSTO addin for MS Access, which makes deployment incredibly easy (not quite as easy as a certain other tool that can get the callstack, but I'm working on it).
These functions are not publicly documented or even exported from the DLL, however Microsoft does include them in the symbol files available on the Microsoft Symbol Servers. As such, we can dynamically load them from the PDB files and call them at runtime.
Due to the undocumented nature of these functions, and my lack of confidence in my own ability, this library is provided as-is without any guarantees. Use at your own risk.
Requirements
- VBE 7.0+ (Office 2010 and later)
- . NET Framework 4.8
Architecture
The solution consists of several interconnected projects:
Core Projects
VBAStack (VB.NET)
The main library that provides the high-level API for retrieving VBA callstacks.
Key Components:
- VBECallstackProvider - Public API for getting callstack information
- VBESymbolResolver - Communicates with PdbEnum to resolve function addresses
- VBENativeWrapper - Marshals calls to native VBE7.DLL functions
- VBEWindowHook - Manages VBE window visibility during callstack capture
- VBEEnums - Defines VBE execution modes and constants
PdbEnum (C#)
Base library for enumerating symbols from PDB (Program Database) files. I do not recommend using this outside of this project, as I haven't tested it at all outside of getting the necessary symbols from VBE7.DLL's PDB.
Key Components:
- SymbolEnumerator - Interfaces with DbgHelp.dll to load and enumerate symbols
- ModuleHelper - Manages module loading and symbol resolution
- OutputFormatter - Formats symbol information for consumption
- ImageHlpStructures - P/Invoke structures for DbgHelp API
PdbEnum_x64 / PdbEnum_x86 (C#)
These are external platform-specific console executables that extract symbol addresses from VBE7.DLL's PDB files. This is somewhat of a hack - use of DbgHelp.dll can mess with debugging, so I figured it was best to isolate it in a separate process.
NativePtrCaller (C#)
Provides wrappers for calling native function pointers from managed code using C# 9.0 function pointers.
Key Functions:
- EbMode() - Gets current VBE execution mode
- EbSetMode() - Sets VBE execution mode (Design/Break/Run)
- EbGetCallstackCount() - Gets number of stack frames
- ErrGetCallstackString() - Retrieves formatted callstack string for a frame
Usage
Basic Example
VBA error handler in your Access, Excel, Word, etc. VBA module
Code:
Public Sub ExampleVbaProcedure()
On Error GoTo ErrorHandler
'... your code here ...
ErrorHandler:
Dim callstack As String
callstack = Application.COMAddins("MyAddin").Object.VSTO_GetVbaCallstack()
'or...
callstack = Application.COMAddins("MyAddin").Object.Generic_GetVbaCallstack(Application.VBE)
MsgBox "An error occurred. Callstack:" & vbCrLf & callstack
Exit Sub
End Sub
VB.Net - within a VSTO or COM add-in with access to the VBE object
Code:
Imports VBAStack
'Expose a public function for VBA to call...
Public Function VSTO_GetVbaCallstack() As String
Dim vbe As Object = Globals. ThisAddIn.Application.VBE ' "Globals.ThisAddin" is a VSTO thing
Return VBECallstackProvider.GetCallstack(vbe)
End Function
Public Function Generic_GetVbaCallstack(vbe As Object) As String
Return VBECallstackProvider.GetCallstack(vbe)
End Function
C# - same as above, but in C#
Code:
using VBAStack;
//Expose a public function for VBA to call...
public string VSTO_GetVbaCallstack()
{
Object vbe = Globals.ThisAddIn.Application.VBE; // "Globals. ThisAddin" is a VSTO thing
return VBECallstackProvider.GetCallstack(vbe);
}
public string Generic_GetVbaCallstack(Object vbe)
{
return VBECallstackProvider.GetCallstack(vbe);
}
Example Output
Code:
Module1::ProcessData
Module2::CalculateResults
ThisWorkbook::Workbook_Open
How It Works
- On first use, VBAStack calls PdbEnum to extract function addresses from VBE7.DLL's PDB symbols
- PdbEnum uses DbgHelp. dll to download symbols from Microsoft Symbol Servers if not already cached, then searches for our target functions in VBE7
- VBAStack then caches the function pointers for subsequent calls
- It then calls "EbMode", from VBE7 - this checks what state the editor is in.
- To make sure the VBE window doesn't flash up on screen during the next step, it sets up a window hook to intercept messages to the VBE, and hide it if necessary
- If the VBE is in "Run" mode, we switch it to "Break" mode using "EbSetMode" - this is necessary for the next 2 steps, as otherwise the callstack functions won't work
- We call "EbGetCallstackCount" to find out how many stack frames there are
- We loop through each stack frame, calling "ErrGetCallstackString" to get a formatted string for each frame (which differs from the old versions of these functions - for VBA6 it seems you used EbGetExecutingProj and iterated through some structures)
- Do a little formatting on the results, because I prefer "::" to "."
- Restores original VBE mode and window state, if we had to change them
- Returns formatted callstack string
Limitations
- VBE 7.0+ Only: Does not support currently Office 2007 or earlier (VBA6)
- Windows Only: Relies on Windows-specific APIs (DbgHelp.dll, kernel32.dll)
- PDB Dependency: Requires Microsoft symbol servers to be accessible for first-time symbol download. This WILL NOT WORK in offline environments unless symbols are pre-cached - and at that point, it's easier to just hardcode the offsets.
- Performance: First call may be a touch slow due to symbol loading (usually under a second on my machine, if it needs to download symbols though its entirely dependent on internet speed. The symbols are only ~6MB though, so it shouldn't be too bad)
Building
Todo. This sucks at the minute.
Deployment
When distributing:
Just add the Nuget package to your project. Or;
- Include in your project references
- Ensure and are in the same directory or a subdirectory
- Include
Code:
NativePtrCaller.dll
and - Ensure the target machine has internet access for initial PDB symbol download
Troubleshooting
"VBE version is less than 7.0"
Upgrade to Office 2010 or later, or try your hand at implementing this for VBA6 and remove that check.
"Could not get pointers to necessary VBE7 functions"
- Check that PdbEnum executables are present
- Verify internet connectivity for symbol server access
Empty callstack returned
- VBA code is not currently executing
- The VBE is in Design mode with no code on the stack
- There's an issue with symbol resolution